Let the phone replace itself, and give CI a channel it may sign
ci / build and test (push) Successful in 1m53s
ci / android head (push) Failing after 32s
ci / api image (push) Successful in 28s

The Android head had no updater and no release path, and the two are one problem:
Android refuses an update signed by a different key, and CI generates a fresh debug
key in every container. An APK released from a workflow could be installed once and
never updated again — each new one an uninstall, which on this product means losing
the cache, the outbox and the device key.

So there are two channels, and they are two applications because the platform gives
no third option. dev.dodotech.dodossh is cut from a v* tag by a person running
scripts/release-android.ps1 with the key ADR 0011 rule 1 keeps off runners.
dev.dodotech.dodossh.nightly is cut from main by CI and signed with a keystore
committed here in the open — a key everybody has cannot be stolen and grants nothing
by being held, which is why putting it in CI does not touch the rule. Neither can
update the other, by construction. See ADR 0014.

The android job assumed an image with a JDK and an Android SDK on it, which is what
a GitHub runner is and what this project's is not. It now installs a JDK, fetches
Google's command-line tools, accepts the licences and installs API 36 — each a no-op
where it is already satisfied, and each cached by the persistent runner's own disk
rather than by an action that would move a quarter of a gigabyte to rebuild a
directory that never left.

The client reads a small JSON manifest beside the APK, the counterpart of
releases.win.json, and compares Android's versionCode rather than a version name:
that integer is what the platform itself uses to accept or refuse an install, so
comparing anything else would offer updates the phone then rejects. It fetches, and
then asks Android to ask — the system draws its own confirmation, and from API 26
will not draw even that until unknown sources is on for this application.

IUpdateChannel gained ApplyingEndsTheProcess. On Windows applying replaces the files
and restarts, so the shell disposes the vault first and that is what zeroes the keys.
On the phone the install is a request and the answer may be no, so disposing first
would answer "not now" with a locked keychain and every shell closed — a punishment
for declining an update.

Two measured bugs found on the way, both older than this work and both invisible to
a -getProperty check. ApplicationDisplayVersion is read by the Android targets in a
top-level PropertyGroup, so the target setting it from MinVer ran after the only
thing that reads it: every APK ever built here said versionName 1.0.0. And nothing
found so far varies the launcher name per channel — four mechanisms tried, all of
them recorded in platform-flags, none of them reaching the label the launcher shows.
The two channels share an icon name for now and are told apart by package name,
version, and what the preferences screen says.
This commit is contained in:
2026-08-04 21:46:01 +02:00
parent f90c331334
commit b4a6c19ac1
18 changed files with 1520 additions and 49 deletions
+249 -17
View File
@@ -259,6 +259,10 @@ jobs:
android:
name: android head
runs-on: [linux]
# Writes the nightly release at the end of the job; see that step for what the capability is and why
# it is acceptable here and nowhere else. Job-scoped, so nothing else in this file gains it.
permissions:
contents: write
steps:
# Duplicated from the build job; see the comment there for why it cannot be factored
# out. Any change here has to be made in all three.
@@ -330,17 +334,129 @@ jobs:
# The cost of keeping it out is that nothing in the main job would notice this head
# breaking, which for a project sharing view models with the desktop one is a matter of
# when rather than whether. This job is that notice.
# ============ the toolchain this job needs and a bare runner does not have ============
#
# This job assumed an image with a JDK and an Android SDK already on it, which is what a GitHub
# ubuntu-latest runner is and what this project's own runner is not. Every step below installs one
# of the things that assumption was making, and each is a no-op where it is already satisfied.
#
# The order matters once: the SDK's licence acceptance and every sdkmanager call are Java programs,
# so the JDK has to be first.
- name: ensure a jdk
run: |
set -eu
SUDO=""
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
# An existing one is used whatever its provenance — the runner image's, or a previous run's.
# 17 is the floor: .NET for Android 36 refuses to start javac below it, and says so in a
# message that names a path rather than a version.
if command -v javac >/dev/null 2>&1; then
have="$(javac -version 2>&1 | sed 's/javac //; s/\..*//')"
if [ "${have:-0}" -ge 17 ]; then
echo "javac $(javac -version 2>&1)"
exit 0
fi
echo "javac $have is below 17; installing a newer one"
fi
if command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update -qq
$SUDO apt-get install -y --no-install-recommends openjdk-17-jdk-headless
elif command -v apk >/dev/null 2>&1; then
$SUDO apk add --no-cache openjdk17-jdk
elif command -v dnf >/dev/null 2>&1; then
$SUDO dnf install -y java-17-openjdk-devel
else
echo "No apt-get, apk or dnf here, so a JDK cannot be installed from inside the job." >&2
echo "Add one to the runner's image, or point JAVA_HOME at one." >&2
exit 1
fi
# Exported for every later step. dirname twice: `which javac` is .../bin/javac and JAVA_HOME is
# the directory above bin. readlink -f because the packaged javac is a symlink into
# /usr/lib/jvm, and the link is what the alternatives system points at rather than the real
# home the Android SDK wants.
home="$(dirname "$(dirname "$(readlink -f "$(command -v javac)")")")"
echo "JAVA_HOME=$home" >> "$GITHUB_ENV"
echo "javac $(javac -version 2>&1) at $home"
# The SDK, from Google's own zip rather than a package. There is no distribution package for it that
# carries the platform this head needs, and unlike the JDK there is nothing to reuse: the archive is
# the supported way to get cmdline-tools and it is what every CI image does behind the scenes.
#
# Cached by path rather than by an actions/cache, deliberately. This runner is persistent, so the
# directory survives between runs and the check below turns the whole step into an echo; a cache
# action would upload and download a quarter of a gigabyte to reproduce a directory that never left.
- name: ensure the android sdk
env:
# Pinned, and the number is the commandline-tools release rather than an API level — they are
# versioned separately and this one is the current stable. Floating it would make the toolchain
# a moving part of every build.
CMDLINE_TOOLS: commandlinetools-linux-11076708_latest.zip
run: |
set -eu
SUDO=""
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
for tool in curl unzip; do
command -v "$tool" >/dev/null 2>&1 && continue
echo "Installing $tool"
if command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update -qq && $SUDO apt-get install -y --no-install-recommends "$tool"
elif command -v apk >/dev/null 2>&1; then
$SUDO apk add --no-cache "$tool"
elif command -v dnf >/dev/null 2>&1; then
$SUDO dnf install -y "$tool"
fi
done
# Under the runner's own tool directory rather than /opt, so it needs no root and survives
# between runs on a persistent runner. ANDROID_HOME and ANDROID_SDK_ROOT are both exported
# because the .NET Android SDK reads one and sdkmanager reads the other, and a job that set only
# one fails halfway through with a message about neither.
sdk="${ANDROID_HOME:-$HOME/android-sdk}"
mkdir -p "$sdk"
if [ ! -x "$sdk/cmdline-tools/latest/bin/sdkmanager" ]; then
echo "Fetching $CMDLINE_TOOLS"
curl -fsSL -o /tmp/cmdline-tools.zip \
"https://dl.google.com/android/repository/$CMDLINE_TOOLS"
rm -rf /tmp/cmdline-tools-unpacked
unzip -q /tmp/cmdline-tools.zip -d /tmp/cmdline-tools-unpacked
# The archive unpacks to a directory called cmdline-tools, and sdkmanager insists on living
# at cmdline-tools/<channel>/ — unpacking it in place gives cmdline-tools/cmdline-tools and
# every later call fails with "Could not determine SDK root".
mkdir -p "$sdk/cmdline-tools"
mv /tmp/cmdline-tools-unpacked/cmdline-tools "$sdk/cmdline-tools/latest"
rm -f /tmp/cmdline-tools.zip
fi
echo "ANDROID_HOME=$sdk" >> "$GITHUB_ENV"
echo "ANDROID_SDK_ROOT=$sdk" >> "$GITHUB_ENV"
export ANDROID_HOME="$sdk" ANDROID_SDK_ROOT="$sdk"
# yes rather than echo y: there are several licences and each wants its own answer, so a single
# y accepts the first and leaves the rest pending — which surfaces later as a package that
# "failed to install" with no reason given. The pipe is allowed to break when sdkmanager exits
# first, which is what the || true is for and is not hiding a failure: the install below is
# what reports one.
yes 2>/dev/null | "$sdk/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null 2>&1 || true
# API 36 specifically, and it is not a preference: Avalonia.Controls.WebView ships only a
# net10.0-android36.0 assembly, so anything lower cannot resolve it and the head loses its
# terminal. See docs/android-port.md. platform-tools comes along because aapt2 and apksigner
# are what the packaging step actually shells out to.
"$sdk/cmdline-tools/latest/bin/sdkmanager" \
"platform-tools" "platforms;android-36" "build-tools;36.0.0"
echo "Android SDK at $sdk"
# After the SDK, because the workload's own first-run checks look for one and are quieter when they
# find it. --skip-sign-check is for the workload package feed, not for anything this project signs.
- name: install the android workload
run: dotnet workload install android --skip-sign-check
# API 36 specifically, and it is not a preference: Avalonia.Controls.WebView ships only a
# net10.0-android36.0 assembly, so anything lower cannot resolve it and the head loses its
# terminal. See docs/android-port.md.
- name: install the android sdk platform
run: |
echo "y" | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \
"platforms;android-36" "build-tools;36.0.0"
- name: restore
run: dotnet restore src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj --locked-mode
@@ -353,15 +469,131 @@ jobs:
# are both link-time: a native library with no android ABI, and a managed assembly that
# resolves for net10.0 but has nothing to dex. Neither shows up in a compile.
#
# Debug-signed on purpose, and it has to stay that way: no keystore secret, no AndroidKeyStore=true.
# docs/adr/0011-android-distribution.md puts the release key on a machine that is not a runner,
# because a signing key reachable from a workflow is a key held by everyone who can change one.
# This APK is a build check. It is not something anybody installs.
- name: package
run: >
dotnet build src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj
--no-restore --configuration Release
-t:SignAndroidPackage -p:RuntimeIdentifier=android-arm64
# ◆ THE NIGHTLY CHANNEL, WHICH IS AN INSTALLABLE APPLICATION AND NOT THE ONE. It has its own package
# id and is signed by a keystore committed to this repository in the open, so it can neither replace
# nor be replaced by the release channel — see the csproj, and ADR 0014. The APK this produces is
# meant to be installed; the release APK is cut from a v* tag by a person running
# scripts/release-android.ps1, on a machine that holds the key ADR 0011 rule 1 keeps off runners.
#
# No RuntimeIdentifier, where this step used to pin android-arm64. That produced the smallest
# possible build check and the least installable artefact: an arm64-only APK will not run on an
# x86_64 emulator, which is what most people testing a nightly actually have. Every supported ABI
# costs size on a package nobody ships to users.
#
# versionCode is the commit count, which is monotonic by construction and needs nobody to remember
# anything. It is not a version and is never displayed; versionName carries MinVer's full answer
# including the height, which is what tells two nightlies apart.
- name: package the nightly
id: nightly
run: |
set -euo pipefail
code="$(git rev-list --count HEAD)"
dotnet build src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj \
--no-restore --configuration Release \
-t:SignAndroidPackage \
-p:DodoChannel=nightly \
-p:DodoNightlyVersionCode="$code"
apk="$(find src/DodoSSH.Client.Android/bin/Release -name '*-Signed.apk' | head -1)"
if [ -z "$apk" ]; then
echo "The package step produced no signed APK." >&2
exit 1
fi
# Read back out of the APK rather than recomputed, so what the feed advertises is what the
# bytes say. A versionName derived a second time in shell is a second implementation of the
# csproj's target, and the two would drift on the first change to either.
badging="$($ANDROID_HOME/build-tools/36.0.0/aapt2 dump badging "$apk")"
name="$(printf '%s' "$badging" | sed -n "s/.*versionName='\([^']*\)'.*/\1/p" | head -1)"
staged="$RUNNER_TEMP/android-nightly"
rm -rf "$staged"
mkdir -p "$staged"
cp "$apk" "$staged/DodoSSH-nightly-$name.apk"
# The channel manifest, which is what the client reads and the whole reason the feed is
# machine-readable at all. versionCode is the comparison — it is the number Android itself uses
# to accept or refuse an install, so comparing anything else would let the client offer an
# update the platform then rejects. versionName is for the person reading the banner.
cat > "$staged/android-nightly.json" <<JSON
{"versionCode":$code,"versionName":"$name","apk":"DodoSSH-nightly-$name.apk"}
JSON
echo "staged=$staged" >> "$GITHUB_OUTPUT"
echo "version=$name" >> "$GITHUB_OUTPUT"
ls -la "$staged"
# ◆ PUBLISHING IT, AND WHAT THAT CAPABILITY IS.
#
# Whoever can write a release here can put a build on every nightly phone, because the client fetches
# from this feed and Android's only check is that the signature matches — and this channel's key is
# in the repository for everybody. That is the same capability as the signing key, reached through a
# different door, and it is exactly what ADR 0011 rule 1 keeps off runners.
#
# It is acceptable here for one reason: this is not that channel. A nightly is signed by a key with
# no secrecy to lose, installs under its own package id, and cannot update the application anybody
# is trusting with their credentials. The release channel has none of this — no job, no token, no
# key on a runner — and the two are separate applications so that no mistake here can reach it.
#
# main only. A tag build must not touch this: a v* tag is the release channel's, and cutting it is a
# person's job.
- name: publish the nightly
if: github.ref == 'refs/heads/main'
env:
FORGE: https://git.dodotech.cloud
REPO: DodoTech/DodoSSH
TOKEN: ${{ secrets.GITHUB_TOKEN }}
STAGED: ${{ steps.nightly.outputs.staged }}
VERSION: ${{ steps.nightly.outputs.version }}
run: |
set -euo pipefail
if [ -z "${TOKEN:-}" ]; then
echo "No token, so the nightly was built and not published." >&2
echo "GITHUB_TOKEN is provided by the runner; an empty one means Actions is configured" >&2
echo "without it, and the job's contents: write permission is what asks for it." >&2
exit 1
fi
api="$FORGE/api/v1/repos/$REPO"
auth="Authorization: token $TOKEN"
# Deleted and recreated rather than updated in place. A rolling tag has to move, and moving one
# through this API is two calls with no atomic form either way — so the shape with the fewest
# states is to remove both and make them again. The window where no nightly exists is a few
# seconds and the client's answer to it is the same as to an unreachable forge: try later.
existing="$(curl -fsS -H "$auth" "$api/releases/tags/nightly" 2>/dev/null || true)"
if [ -n "$existing" ]; then
id="$(printf '%s' "$existing" | sed -n 's/.*"id":\([0-9]*\).*/\1/p' | head -1)"
[ -n "$id" ] && curl -fsS -X DELETE -H "$auth" "$api/releases/$id" >/dev/null || true
fi
curl -fsS -X DELETE -H "$auth" "$api/tags/nightly" >/dev/null 2>&1 || true
created="$(curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' \
-d "$(printf '{"tag_name":"nightly","target_commitish":"%s","name":"Nightly %s","prerelease":true,"body":"Built from %s by CI, signed with the public nightly key. Installs beside the release build, never over it. See docs/adr/0014-android-updates.md."}' \
"$GITHUB_SHA" "$VERSION" "$GITHUB_SHA")" \
"$api/releases")"
release="$(printf '%s' "$created" | sed -n 's/.*"id":\([0-9]*\).*/\1/p' | head -1)"
if [ -z "$release" ]; then
echo "Gitea accepted the release call and returned no id:" >&2
printf '%s\n' "$created" >&2
exit 1
fi
# The APK first and the manifest last, which is the ordering the client depends on: it reads the
# manifest and then fetches what the manifest names, so a manifest published before its APK is a
# few seconds in which every phone is told to download something that is not there yet.
for file in "$STAGED"/*.apk "$STAGED"/android-nightly.json; do
echo "Uploading $(basename "$file")"
curl -fsS -X POST -H "$auth" \
-F "attachment=@$file" \
"$api/releases/$release/assets?name=$(basename "$file")" >/dev/null
done
echo "Published nightly $VERSION"
image:
name: api image
Binary file not shown.
+119
View File
@@ -0,0 +1,119 @@
# ADR 0014 — Updating the Android client, and the two channels that makes necessary
- Status: accepted
- Date: 2026-08-04
- Builds on: [ADR 0011](0011-android-distribution.md), [ADR 0013](0013-desktop-distribution-and-updates.md)
- Amends: ADR 0011's consequence that there is "no automatic update"
## Context
[ADR 0011](0011-android-distribution.md) settled who holds the Android release key and listed the price
of shipping outside a store: *"There is no discovery, no automatic update, and no Play channel."* It
also left the door open in rule 2 — *"If an update check is ever added it points at the project's
domain"* — and named the missing update path as **the sharpest edge of the decision** for a product sold
to teams.
[ADR 0013](0013-desktop-distribution-and-updates.md) then built exactly that for Windows, and the
argument it turns on applies here word for word: an SSH client holding a team's infrastructure
credentials is precisely the software where *"the fix shipped and the user never got it"* is a security
outcome rather than an inconvenience.
So the question is not whether the phone should update itself. It is how, given one Android fact that
decides everything else:
**An installed app can only be updated by a package signed with the same key.** Not "should not" —
Android refuses, with `INSTALL_FAILED_UPDATE_INCOMPATIBLE`. A key is an app's identity for its whole
life.
That collides with ADR 0011 rule 1, which puts the release key on a machine that is not a runner. CI can
build an APK; what it cannot do is sign one that any installed copy will accept, because the debug key it
falls back to is generated fresh in every container. An APK released from CI today could be installed
once and never updated again — and each "update" would require an uninstall, which on this product means
losing the local cache, the outbox and the device key.
## Decision
**Two channels, which are two applications, because Android has no third option.**
1. **`release``dev.dodotech.dodossh`, signed by the key ADR 0011 rule 1 describes.** Cut from a `v*`
tag by a person running `scripts/release-android.ps1` on a machine that is not a runner. No workflow
touches it, no secret exists for it, and CI has no job that could. ADR 0011 rule 1 is unchanged, and
its "Rejected" entry — *a release key held by CI so tagging cuts a release* — still stands.
2. **`nightly``dev.dodotech.dodossh.nightly`, signed by a keystore committed to this repository in the
open.** Cut from `main` by CI on every push, published as a pre-release on the project's own forge.
**The key is public on purpose, and that is what makes it safe to put in CI.** ADR 0011 rule 1 exists
because a key a workflow can reach is a key held by everyone who can change a workflow file. A key
everybody already has cannot be stolen, needs no secret to configure, and grants nothing by being
held — so the rule has nothing to protect. It also means this channel works on a fresh runner with no
setup, which is the practical reason it was reachable at all.
3. **Neither channel can update the other, by construction rather than by care.** Different package ids
and different keys, so a mistake is an install Android refuses rather than a nightly quietly replacing
somebody's real client. Both can be installed at once, which is the useful half: testing a nightly
costs nobody the build they rely on.
4. **The update check reads the project's forge and never the deployment**, which is ADR 0011 rule 2
applied unchanged. The feed address is a `const` in `AndroidUpdateChannel` and there is deliberately
no setting for it — a configurable update URL is exactly the knob that would let an operator, or a
stray edit to a plaintext file, point the update path at the party ADR 0001 models as the adversary.
5. **The comparison is Android's `versionCode` and not the version name.** That integer is what the
platform itself uses to accept or refuse an install, so comparing anything else would let the client
offer an update the platform then rejects. The feed publishes it in a small JSON manifest beside the
APK — the counterpart of `releases.win.json` — so a check costs a few hundred bytes rather than a
download.
6. **Nothing is installed by the application.** It fetches, then asks Android to ask. The platform draws
its own confirmation naming the package, and from API 26 will not draw even that until the user has
turned this application on in the unknown-sources screen. Two deliberate answers, neither to a screen
DodoSSH controls.
7. **Applying does not end the process, and the shell had to learn that.** On Windows, applying replaces
the files and restarts, so the shell disposes the vault first — that is what zeroes the identity keys,
the vault keys and the cache key. On Android the install is a *request* and the answer may be no, so
disposing first would answer "not now" with a locked keychain and every shell closed. `IUpdateChannel`
gained `ApplyingEndsTheProcess`; where it is false the session is left alone, and the keys go when
Android kills the process on the install it agreed to.
## Consequences
**The nightly channel is a real attack surface and should be described as one.** Anyone can build an APK
a nightly phone will accept, because the key is public. Reaching one still means being what it fetches
from — a release on `git.dodotech.cloud` over TLS — so the practical set is *whoever can write a release
on this repository*, which is the same set ADR 0013 names for the desktop. That is acceptable for a
channel whose users are testing it and is not acceptable for the one holding people's credentials, which
is the entire reason there are two.
**A nightly is not a beta of the release channel; it is a different application.** Moving from one to the
other is an uninstall and a fresh enrolment. There is no migration and there will not be one — the two
caches are encrypted under keys held by two package identities the platform keeps apart.
**ADR 0011's "no automatic update" consequence is now wrong for the release channel and remains true for
reach.** Discovery, MDM deployment and the sideloading permission are all unchanged. What changed is only
that an installed copy can now learn a newer one exists.
**The release channel's `versionCode` stays a hand-bumped literal**, and forgetting to bump it is caught
rather than shipped: the release script reads what is already published and refuses to build a package
that does not beat it.
**Two identical launcher icons.** A nightly installed beside a release shows the same name under both.
Four ways to vary it per channel were tried and none reached the launcher label; the findings are in
`docs/platform-flags.md`. What tells them apart today is the package name in Android's app info, the
version, and the channel the application names on its own preferences screen.
## Rejected
- **A release keystore in CI so tagging cuts the real release.** The convenience is the point of CI and
the key is the point of ADR 0011; where they collide the key wins. This ADR is what makes that refusal
survivable rather than merely principled — the nightly channel is where the convenience went.
- **One channel, released from CI, signed with a stable key held as a secret.** This is the arrangement
most projects land on and it is ADR 0011's rejected entry with an extra step: the secret is reachable
by whoever can change a workflow file, and what it signs is the client holding the credentials.
- **Opening the release page in a browser instead of installing.** It would work, and it moves the same
APK through the same unknown-sources gate with one more step and no less trust. What it gives up is any
way of knowing a fix has been fetched, which is the property this ADR exists to buy.
- **A version-name comparison, so the feed could be a directory listing.** SemVer with prerelease heights
needs a parser, and the parser would disagree with the platform sooner or later. The number Android
uses is the number to compare.
+92
View File
@@ -1811,3 +1811,95 @@ is wrong. Reinstalling then asks for the passphrase rather than for a server.
**Failure means:** the cache going with the application is the pack-id collision, and whoever ran this has
lost their offline unlock and any change that was still in the outbox. That is the failure ADR 0013
decision 2 exists to prevent, and it is why 16.1 checks the same thing from the other end.
---
## Phase 17 — Installing and updating the phone
Every check here needs a real Android device or emulator. Nothing about this is automated and structurally
cannot be for the reason phase 8 gives, with one more on top: the install is another application's screen.
Two channels, and they are two applications — see [ADR 0014](adr/0014-android-updates.md). The nightly is
what CI publishes on every push to main and is signed with a key that is in this repository; the release
channel is cut by `scripts/release-android.ps1` on a machine holding the real key. Most of these checks can
be walked on the nightly, which is the point of having one.
### 17.1 The unknown-sources gate is asked for and not assumed
On a phone that has never installed a DodoSSH APK, download one from the release page and open it.
**Pass:** Android says the browser is not allowed to install unknown apps and offers the settings screen.
Granting it there and returning completes the install.
**Failure means:** nothing — this is the platform working, and it is the cost ADR 0011 names. It is a check
because the *next* one depends on somebody having been through it.
### 17.2 Both channels install side by side
Install a nightly on a phone that already has a release build, or the other way round.
**Pass:** two applications, two icons, both openable, each with its own keychain. Android's app info shows
`dev.dodotech.dodossh` for one and `dev.dodotech.dodossh.nightly` for the other.
**Failure means:** if the second install replaces the first, the two channels are sharing a package id and
the separation ADR 0014 rests on is not there. If it is *refused*, they share an id and differ in key,
which is the same fault seen from the other side.
Both icons say DodoSSH, which is a known defect rather than a surprise — see `docs/platform-flags.md`.
### 17.3 The version on screen is the version that was built
Settings → Preferences → UPDATES.
**Pass:** a version matching the tag it was built from, or on a nightly the full MinVer string with its
height. Never `1.0.0`.
**Failure means:** `1.0.0` is the `_AndroidVersionName` fix having come undone; see platform-flags.
### 17.4 A check that finds nothing says so, and one on a timer does not
Press CHECK NOW on the newest build there is.
**Pass:** it answers — "DodoSSH x.y.z is the latest build." Then leave the app open and do nothing for
several minutes.
**Pass:** no message appears on its own. A background pass that found nothing is silent, which is what makes
the feature tolerable.
### 17.5 An update is found, fetched without being asked, and installed only when asked · **the whole point**
With an older build installed, publish or wait for a newer one on the same channel, then press CHECK NOW.
**Pass:** the bar moves, then an INSTALL button and a line saying what it costs. **Nothing is installed
yet.** Leave it sitting there and confirm the application still works normally.
Press INSTALL.
**Pass:** Android's own installer appears naming the package. Agreeing replaces the app and it reopens as
the new version, still enrolled, with the vault and its hosts intact.
**Failure means:** an install that happens without INSTALL being pressed is the policy this feature is built
around being broken. An `INSTALL_FAILED_UPDATE_INCOMPATIBLE` means the two builds were signed by different
keys — on the nightly channel that means the committed keystore changed, and on the release channel it means
the wrong keystore was used.
### 17.6 Declining leaves a working session · **the one that would be missed**
Repeat 17.5 to the point where Android's installer is on screen, with a terminal open and the vault
unlocked. Press back or cancel.
**Pass:** DodoSSH is still running, still unlocked, and the shell is still connected.
**Failure means:** a locked keychain or a dead session is the shell having disposed itself before asking —
`IUpdateChannel.ApplyingEndsTheProcess` not being read, or answering true on this head. It punishes somebody
for declining an update, and nothing in an automated suite would notice.
### 17.7 A nightly cannot update a release build
With a release build installed, download the nightly APK and try to install it *over* it — rename it if the
package manager will not offer to.
**Pass:** refused, or installed as a second application. Never a replacement.
**Failure means:** the channels are not separate, and a public key is signing the application people keep
their credentials in.
+26
View File
@@ -616,3 +616,29 @@ surface — but it is still an unmetered write path.
**`/api/v1/me` does not update `last_seen_at_utc`.** Deliberate: a GET that writes on every call is
a smell, and nothing depends on the value yet. Revisit when device management lands, since that is
the first feature that needs it.
**`ApplicationDisplayVersion` cannot be set from a target, so the Android head shipped `1.0.0`.**
`Xamarin.Android.Common.targets` reads it in a plain top-level `PropertyGroup`
`<_AndroidVersionName>$(ApplicationDisplayVersion)</_AndroidVersionName>` — which is *evaluation*, not a
target. Every project property is already final before any target runs, so the MinVer-derived value set in
`UseTheDerivedVersionForAndroid` was assigned after the only thing that reads it had finished. MinVer cannot
run at evaluation time, so no arrangement of the public property works. The tell is that
`-getProperty:ApplicationDisplayVersion` answers correctly while `aapt2 dump badging` on the packaged APK
says `versionName='1.0.0'` — measured, and the reason a `-getProperty` check cannot catch this class of bug.
The fix is to assign `_AndroidVersionName` from a target hooked `BeforeTargets="_GenerateJavaStubs"`; an
internal name, taken deliberately over passing `-p:ApplicationDisplayVersion` from every caller and leaving
an ordinary `dotnet build` lying about its version.
**Nothing found so far varies the Android launcher name per build.** Four mechanisms were tried and all
four produce the same label. `AndroidManifestPlaceholders` is wired to the manifest task and does not reach
`android:label` — measured on a build whose placeholder property evaluated to `appLabel=DodoSSH nightly`
and whose APK reported `DodoSSH`. `ApplicationTitle`, the documented property, feeds an `ApplicationLabel`
task parameter that the label already on the application element wins against. A second resource directory
under `Resources/` is picked up by the SDK's own glob as a *qualifier* and fails the build outright with
`APT2142: invalid configuration 'nightly'`. And an `AndroidResource` `Remove`/`Include` swap does nothing
from the project body — the SDK's glob is added by `Sdk.targets`, imported below it, so the removal runs
before the item exists — while the same swap inside a target that runs before `UpdateAndroidResources` also
had no effect. Underneath all of it: the launcher shows the *activity's* label, and that one is a string in
a C# attribute. The two channels ADR 0014 defines therefore share a launcher name, and are told apart by
the package name in Android's app info, by the version, and by the channel the application names on its own
preferences screen.
+275
View File
@@ -0,0 +1,275 @@
#Requires -Version 7.0
<#
.SYNOPSIS
Builds, signs and publishes the Android release channel.
.DESCRIPTION
The phone's counterpart of release-windows.ps1, and the same two-phase shape for the same reason: the
thing that is uploaded must be the thing that was installed and checked, so nothing is rebuilt between
the two phases.
Phase 1 (no token, no upload) builds and signs the APK with the project's release keystore and stops,
printing where it is. Phase 2 (-Upload) attaches it to the tag's release on the project's own forge.
WHY THIS IS A SCRIPT AND NOT A CI JOB
docs/adr/0011-android-distribution.md rule 1 puts the release key on a machine that is not a runner,
because a key a workflow can reach is a key held by everyone who can change a workflow file. That is
the whole of it, and docs/adr/0014-android-updates.md explains why the convenience of a CI release
went to a separate nightly channel with a deliberately public key instead of coming here.
The forge token is the second half of the same capability and is treated the same way: prompted for,
never stored, never a workflow secret. Whoever can write a release can publish an update every phone
on this channel will install, which is the signing key reached through a different door.
.PARAMETER Upload
Runs phase 2 against the package phase 1 produced. Prompts for a forge token.
.PARAMETER KeystorePath
The release keystore. Defaults to the DODOSSH_ANDROID_KEYSTORE environment variable.
.PARAMETER KeyAlias
The key inside it. Defaults to DODOSSH_ANDROID_ALIAS, then to 'dodossh'.
.EXAMPLE
./scripts/release-android.ps1
./scripts/release-android.ps1 -Upload
#>
[CmdletBinding()]
param(
[switch] $Upload,
[string] $KeystorePath = $env:DODOSSH_ANDROID_KEYSTORE,
[string] $KeyAlias = $(if ($env:DODOSSH_ANDROID_ALIAS) { $env:DODOSSH_ANDROID_ALIAS } else { 'dodossh' })
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$RepoRoot = Split-Path -Parent $PSScriptRoot
$Project = Join-Path $RepoRoot 'src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj'
$Staging = Join-Path $RepoRoot 'artifacts/android'
$Forge = 'https://git.dodotech.cloud'
$Repo = 'DodoTech/DodoSSH'
$Api = "$Forge/api/v1/repos/$Repo"
function Write-Step([string] $Message) {
Write-Host ''
Write-Host "==> $Message" -ForegroundColor Cyan
}
function Stop-With([string] $Message) {
Write-Host ''
Write-Host $Message -ForegroundColor Red
exit 1
}
# ============ what the tag says, which is the version of record ============
function Get-TagVersion {
$tag = & git -C $RepoRoot describe --exact-match --tags HEAD 2>$null
if ($LASTEXITCODE -ne 0 -or -not $tag) {
Stop-With @'
HEAD is not on a tag, so there is no version to release.
The tag is the version of record here MinVer derives every assembly version from it, and a build
from an untagged commit answers 0.0.0-alpha.0.N rather than failing. Tag first:
git tag v0.1.0
'@
}
if ($tag -notmatch '^v\d+\.\d+\.\d+') {
Stop-With "HEAD is on '$tag', which is not a v* release tag."
}
return $tag.Substring(1)
}
# ============ what is already published, so a versionCode cannot go backwards ============
function Get-PublishedVersionCode {
# Android refuses an install whose versionCode is not higher than the installed one, and
# ApplicationVersion in the csproj is a hand-bumped literal on this channel by decision — see the
# comment there. This is the guard that comment promises: forgetting the bump fails here rather than
# on somebody's phone, where it presents as an install that simply will not go on.
try {
$latest = Invoke-RestMethod -Uri "$Api/releases/latest" -Method Get -ErrorAction Stop
}
catch {
Write-Host ' no published release yet, so any versionCode will do' -ForegroundColor DarkGray
return 0
}
$manifest = $latest.assets | Where-Object { $_.name -eq 'android-release.json' } | Select-Object -First 1
if (-not $manifest) {
Write-Host ' the latest release carries no android manifest' -ForegroundColor DarkGray
return 0
}
$published = Invoke-RestMethod -Uri $manifest.browser_download_url -Method Get
return [int] $published.versionCode
}
if (-not $Upload) {
# ================================ phase 1 ================================
Write-Step 'Checking the working tree'
if (& git -C $RepoRoot status --porcelain) {
Stop-With 'The working tree has changes. A release is built from a commit, not from a desk.'
}
$version = Get-TagVersion
Write-Host " v$version"
if (-not $KeystorePath) {
Stop-With @'
No keystore. Pass -KeystorePath, or set DODOSSH_ANDROID_KEYSTORE.
This is the key ADR 0011 rule 1 keeps off runners and out of this repository. It is the application's
identity for its whole life: losing it means no installed copy can ever be updated again.
'@
}
if (-not (Test-Path $KeystorePath)) {
Stop-With "No keystore at $KeystorePath."
}
Write-Step 'Reading what is already published'
$publishedCode = Get-PublishedVersionCode
$declaredCode = [int] (& dotnet msbuild $Project -getProperty:ApplicationVersion -nologo `
| ForEach-Object { $_.Trim() } | Where-Object { $_ })
Write-Host " published versionCode $publishedCode, this build declares $declaredCode"
if ($declaredCode -le $publishedCode) {
Stop-With @"
ApplicationVersion in DodoSSH.Client.Android.csproj is $declaredCode and the published channel is
already at $publishedCode. Android refuses an install that does not increase it, so this package
could be uploaded and would not install over anything.
Bump ApplicationVersion, commit, move the tag, and run this again.
"@
}
Write-Step 'Restoring'
& dotnet restore $Project --locked-mode
if ($LASTEXITCODE -ne 0) { Stop-With 'Restore failed.' }
Write-Step 'Building and signing'
# The keystore's passwords are prompted for and held only for this call. They are not parameters,
# because a parameter is a thing that ends up in shell history.
$storePass = Read-Host -Prompt 'Keystore password' -AsSecureString
$keyPass = Read-Host -Prompt "Password for key '$KeyAlias' (blank to reuse)" -AsSecureString
$storePlain = [System.Net.NetworkCredential]::new('', $storePass).Password
$keyPlain = [System.Net.NetworkCredential]::new('', $keyPass).Password
if (-not $keyPlain) { $keyPlain = $storePlain }
if (Test-Path $Staging) { Remove-Item -Recurse -Force $Staging }
New-Item -ItemType Directory -Path $Staging | Out-Null
# No RuntimeIdentifier, so every supported ABI is packaged. An arm64-only APK will not install on a
# 32-bit handset or an x86_64 emulator, and this is the package strangers are pointed at.
& dotnet build $Project --no-restore --configuration Release `
-t:SignAndroidPackage `
-p:DodoChannel=release `
-p:AndroidKeyStore=true `
-p:AndroidSigningKeyStore=$KeystorePath `
-p:AndroidSigningKeyAlias=$KeyAlias `
-p:AndroidSigningStorePass=$storePlain `
-p:AndroidSigningKeyPass=$keyPlain
if ($LASTEXITCODE -ne 0) { Stop-With 'The build failed.' }
$signed = Get-ChildItem -Path (Join-Path $RepoRoot 'src/DodoSSH.Client.Android/bin/Release') `
-Recurse -Filter '*-Signed.apk' | Select-Object -First 1
if (-not $signed) { Stop-With 'The build produced no signed APK.' }
$apkName = "DodoSSH-$version.apk"
Copy-Item $signed.FullName (Join-Path $Staging $apkName)
# The manifest the client reads. versionCode is the comparison and versionName is what a person sees;
# see docs/adr/0014-android-updates.md for why it is not the other way round.
$manifest = [ordered] @{
versionCode = $declaredCode
versionName = $version
apk = $apkName
}
$manifest | ConvertTo-Json -Compress `
| Set-Content -Path (Join-Path $Staging 'android-release.json') -Encoding utf8 -NoNewline
Write-Step 'Built'
Get-ChildItem $Staging | Format-Table Name, Length
Write-Host @"
Install this on a phone and walk docs/manual-checks.md phase 17 before uploading anything. The APK in
artifacts/android is what phase 2 uploads nothing is rebuilt so what you check is what ships.
./scripts/release-android.ps1 -Upload
"@ -ForegroundColor Yellow
exit 0
}
# ================================ phase 2 ================================
Write-Step 'Uploading'
$version = Get-TagVersion
$apkPath = Join-Path $Staging "DodoSSH-$version.apk"
$manifestPath = Join-Path $Staging 'android-release.json'
if (-not (Test-Path $apkPath) -or -not (Test-Path $manifestPath)) {
Stop-With "No package for v$version in $Staging. Run phase 1 first."
}
$token = Read-Host -Prompt 'Forge token with release write' -AsSecureString
$tokenPlain = [System.Net.NetworkCredential]::new('', $token).Password
if (-not $tokenPlain) { Stop-With 'No token, so nothing was uploaded.' }
$headers = @{ Authorization = "token $tokenPlain" }
# --merge in spirit: the tag push may already have created a release entry, and creating a second one for
# the same tag fails. Reused where it exists.
try {
$release = Invoke-RestMethod -Uri "$Api/releases/tags/v$version" -Headers $headers -Method Get
Write-Host " reusing the existing release for v$version"
}
catch {
$body = @{
tag_name = "v$version"
name = "DodoSSH $version"
prerelease = $version -match '-'
} | ConvertTo-Json
$release = Invoke-RestMethod -Uri "$Api/releases" -Headers $headers -Method Post `
-ContentType 'application/json' -Body $body
Write-Host " created the release for v$version"
}
# The APK first and the manifest last, which is the order the client depends on: it reads the manifest
# and then fetches what the manifest names, so a manifest published before its APK is a window in which
# every phone is told to download something that is not there.
foreach ($file in @($apkPath, $manifestPath)) {
$name = Split-Path -Leaf $file
# Replaced rather than added beside. Gitea will happily hold two assets with one name, and the client
# takes the first — which after a re-upload is whichever the API happens to list first.
$existing = $release.assets | Where-Object { $_.name -eq $name } | Select-Object -First 1
if ($existing) {
Invoke-RestMethod -Uri "$Api/releases/$($release.id)/assets/$($existing.id)" `
-Headers $headers -Method Delete | Out-Null
}
Write-Host " $name"
Invoke-RestMethod -Uri "$Api/releases/$($release.id)/assets?name=$name" `
-Headers $headers -Method Post -Form @{ attachment = Get-Item $file } | Out-Null
}
Write-Step "Published v$version"
Write-Host 'Phones on the release channel will see it within six hours, or on the next CHECK NOW.'
+15 -1
View File
@@ -122,6 +122,18 @@ public sealed partial class DodoSshApp : Avalonia.Application
// key. A straight implementation of the interface the session layer has always taken.
var deviceKeys = new AndroidDeviceKeyStore(paths);
// Difference 6, and the newest: where newer builds come from. The same shape of question
// deviceKeys answers — a property of this installation, decided once, here — and the same answer
// when this copy was not installed by anything that can replace it. See ADR 0014.
//
// Its own HttpClient rather than the sync client's: this one talks to the project's forge and that
// one talks to the deployment, and the whole point of ADR 0011 rule 2 is that those are different
// parties. Sharing a handler would be one connection pool, one set of default headers and one
// place for a future change to leak a token from the second into the first. Never disposed, for
// the reason nothing else here is: an Avalonia Application has no disposal hook, and this lives as
// long as the process.
var updates = AndroidUpdateChannels.ForThisPhone(new HttpClient());
var browser = new AndroidBrowserLauncher();
var viewModel = new MainWindowViewModel(
@@ -151,7 +163,9 @@ public sealed partial class DodoSshApp : Avalonia.Application
// answers localhost here — so without this the account's device list would show one localhost
// per phone, on the very screen a lost device is revoked from, and every log entry a phone wrote
// would name the same machine. See PhoneEnvironment.DeviceName.
deviceName: PhoneEnvironment.DeviceName);
deviceName: PhoneEnvironment.DeviceName,
updates: updates);
// Started rather than awaited: framework initialisation must not block on a schema migration. The
// view model shows its own progress and handles its own failures.
@@ -19,15 +19,29 @@
<SupportedOSPlatformVersion>28</SupportedOSPlatformVersion>
<TargetPlatformVersion>36</TargetPlatformVersion>
<ApplicationId>dev.dodotech.dodossh</ApplicationId>
<!--
versionCode, and it stays a hand-bumped literal. Android requires a monotonically increasing
integer and SemVer does not give you one — deriving it from the version would work until the day
a patch number reached 10 and the arithmetic went backwards, which is the sort of failure that
surfaces as an upload Google refuses for reasons it will not explain.
============ WHICH CHANNEL THIS BUILD BELONGS TO ============
Two of them, and they are two applications rather than two configurations. Android identifies an
app by its package id and its signing certificate together, and refuses an update signed by a
different key — so a build CI signs and a build a person signs cannot replace one another whatever
they are called. Pretending otherwise would ship an installer that fails with
INSTALL_FAILED_UPDATE_INCOMPATIBLE at the one moment somebody is trying to get a fix.
So each channel gets its own id, its own key and its own feed, and neither can update the other by
construction rather than by accident. Both can be installed at once, which is the useful half: a
person testing a nightly does not lose the release they rely on. See ADR 0014.
release — dev.dodotech.dodossh, signed by the key ADR 0011 puts on a machine that is not a runner.
Cut from a v* tag by scripts/release-android.ps1, by a person.
nightly — dev.dodotech.dodossh.nightly, signed by a keystore committed to this repository in the
open. Cut from main by CI, every push.
Default release, so that an unqualified `dotnet build` is the real application and the nightly is
the one you have to ask for. A build with no keystore named at all falls back to the debug key,
which is what a developer's own device gets and is neither channel.
-->
<ApplicationVersion>1</ApplicationVersion>
<DodoChannel Condition="'$(DodoChannel)' == ''">release</DodoChannel>
<!--
False here for the same reason the desktop head sets it false: this process formats timestamps
@@ -36,6 +50,71 @@
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>
<!-- ============ the release channel ============ -->
<PropertyGroup Condition="'$(DodoChannel)' == 'release'">
<ApplicationId>dev.dodotech.dodossh</ApplicationId>
<!--
versionCode, and it stays a hand-bumped literal on this channel. Android requires a monotonically
increasing integer and SemVer does not give you one — deriving it from the version would work until
the day a patch number reached 10 and the arithmetic went backwards, which is the sort of failure
that surfaces as an install the phone refuses for reasons it will not explain.
Forgetting to bump it is caught rather than shipped: scripts/release-android.ps1 reads the code
already published to the release channel and refuses to build one that is not higher. A guard on a
literal, which is the arrangement this comment argued for, rather than arithmetic nobody checks.
-->
<ApplicationVersion>1</ApplicationVersion>
</PropertyGroup>
<!-- ============ the nightly channel ============ -->
<PropertyGroup Condition="'$(DodoChannel)' == 'nightly'">
<ApplicationId>dev.dodotech.dodossh.nightly</ApplicationId>
<!--
Passed in by CI as the number of commits on the branch, which is monotonic by construction and needs
nobody to remember anything. It is meaningless as a version and is not shown anywhere: versionName
is what a person reads, and it carries MinVer's full answer on this channel including the height —
see the target below.
The fallback exists so that `-p:DodoChannel=nightly` builds outside CI at all, and 1 is deliberately
the lowest it can be: a locally built nightly then cannot replace one installed from the feed, which
is the right way round.
-->
<ApplicationVersion Condition="'$(DodoNightlyVersionCode)' != ''">$(DodoNightlyVersionCode)</ApplicationVersion>
<ApplicationVersion Condition="'$(DodoNightlyVersionCode)' == ''">1</ApplicationVersion>
<!--
◆ THE KEY IS IN THE REPOSITORY, IN THE OPEN, AND THAT IS THE DESIGN RATHER THAN AN OVERSIGHT.
ADR 0011 rule 1 says the release key is never in CI, because a key a workflow can reach is a key held
by everyone who can change a workflow file. This does not break that rule; it sidesteps it by making
the CI key worth nothing. Everybody has it, so holding it grants nothing, so there is nothing to
steal and no secret to configure — which is also why this channel works on a fresh runner with no
setup at all.
What it costs is stated rather than buried: anyone can build an APK this channel's phones will accept
as an update. Reaching one still means being the thing they fetch from, which is a release on
git.dodotech.cloud over TLS — so the practical set is whoever can write a release here, the same set
ADR 0013 names for the desktop. That is an acceptable trade for a nightly and is not one for the
release channel, which is the whole reason there are two.
-->
<AndroidKeyStore>true</AndroidKeyStore>
<AndroidSigningKeyStore>$(MSBuildThisFileDirectory)../../build/nightly.keystore</AndroidSigningKeyStore>
<AndroidSigningKeyAlias>dodossh-nightly</AndroidSigningKeyAlias>
<AndroidSigningStorePass>nightly</AndroidSigningStorePass>
<AndroidSigningKeyPass>nightly</AndroidSigningKeyPass>
</PropertyGroup>
<ItemGroup>
<!--
Which feed this build asks. Assembly metadata rather than a compile-time constant, because the
updater needs the string and not a branch — and because a value baked into the assembly is one a
crash report can be asked for. See AndroidUpdateChannel.
-->
<AssemblyMetadata Include="DodoChannel" Value="$(DodoChannel)" />
</ItemGroup>
<!--
versionName, which is the string a person sees in Android's app info, taken from the same v* tag
as everything else so the two heads cannot claim different versions of one product.
@@ -45,11 +124,41 @@
is still the SDK's default 1.0.0 — an ApplicationDisplayVersion set in a PropertyGroup would read
as though it were derived and would ship 1.0.0 forever. Running after MinVer is what makes it true.
Major.Minor.Patch, without MinVer's prerelease or build-metadata parts: versionName is a free string
to Android, but it is shown to users, and "0.2.0-alpha.0.7+1a2b3c4" is not a version anybody can
read back to you over a support conversation.
Major.Minor.Patch on the release channel, without MinVer's prerelease or build-metadata parts:
versionName is a free string to Android, but it is shown to users, and "0.2.0-alpha.0.7+1a2b3c4" is
not a version anybody can read back to you over a support conversation.
The nightly channel keeps the whole thing, prerelease height and all, and for the opposite reason.
Every nightly between two tags shares one Major.Minor.Patch, so trimming it would put the same three
numbers on every build for weeks — on the one channel where "which build is this?" is the entire
question being asked. Nobody reads a nightly's version aloud; they paste it.
-->
<Target Name="UseTheDerivedVersionForAndroid" AfterTargets="MinVer" DependsOnTargets="MinVer">
<!--
◆ BeforeTargets on the stub generator, and it assigns an underscore-prefixed property. Both are
deliberate, and the shape this replaced looked more correct while shipping 1.0.0 on every build.
ApplicationDisplayVersion is read by Xamarin.Android.Common.targets in a plain top-level
PropertyGroup — `<_AndroidVersionName>$(ApplicationDisplayVersion)</_AndroidVersionName>` — which is
*evaluation*, not a target. Every project property is already final by the time any target runs, so a
target that sets ApplicationDisplayVersion sets it after the only thing that reads it has finished.
MinVer cannot run at evaluation time, so there is no arrangement of the public property that works.
Measured rather than reasoned about: aapt2 dump badging on the packaged APK said versionName='1.0.0'
while `-getProperty:ApplicationDisplayVersion` said 0.0.0-alpha.0.124. The property was right and the
manifest was not, which is exactly the shape of bug a -getProperty check cannot catch.
So this reaches for _AndroidVersionName, which is the value GenerateMainAndroidManifest is actually
handed, at the last moment before _GenerateJavaStubs runs it. An internal name is a real cost and it
is the smaller one: the alternative is passing -p:ApplicationDisplayVersion from CI and from the
release script, which makes an ordinary `dotnet build` of this head go on lying about its version and
puts the number in two places that can disagree.
ApplicationDisplayVersion is still assigned, and first, so that a caller passing it as a global
property wins — a global beats a target assignment, and _AndroidVersionName then follows theirs.
-->
<Target Name="UseTheDerivedVersionForAndroid"
BeforeTargets="_GenerateJavaStubs"
DependsOnTargets="MinVer">
<PropertyGroup>
<!--
Guarded, because the failure without it is silent and absurd: MinVerMajor and its two siblings
@@ -57,7 +166,10 @@
characters that are a legal Android versionName and are what the phone would then show. Measured,
not imagined; invoking this target on its own produced exactly that.
-->
<ApplicationDisplayVersion Condition="'$(MinVerMajor)' != ''">$(MinVerMajor).$(MinVerMinor).$(MinVerPatch)</ApplicationDisplayVersion>
<ApplicationDisplayVersion Condition="'$(MinVerMajor)' != '' and '$(DodoChannel)' != 'nightly'">$(MinVerMajor).$(MinVerMinor).$(MinVerPatch)</ApplicationDisplayVersion>
<ApplicationDisplayVersion Condition="'$(MinVerVersion)' != '' and '$(DodoChannel)' == 'nightly'">$(MinVerVersion)</ApplicationDisplayVersion>
<_AndroidVersionName Condition="'$(ApplicationDisplayVersion)' != ''">$(ApplicationDisplayVersion)</_AndroidVersionName>
</PropertyGroup>
</Target>
+5 -1
View File
@@ -50,7 +50,11 @@ namespace DodoSSH.Client.Android;
/// </para>
/// </remarks>
[Activity(
Label = "DodoSSH",
// The launcher reads this rather than the application's, so a channel that renamed only the
// application element would still put two identical entries on the home screen. A resource because
// this is a compile-time string and the two channels need different answers from one binary's
// sources. See Resources/values/strings.xml.
Label = "@string/app_name",
Theme = "@style/DodoTheme",
MainLauncher = true,
LaunchMode = LaunchMode.SingleTask,
@@ -0,0 +1,450 @@
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using DodoSSH.Client.Session;
using global::Android.Content;
using global::Android.Content.PM;
namespace DodoSSH.Client.Android.Platform;
/// <summary>
/// What a channel's feed publishes beside its APK.
/// </summary>
/// <remarks>
/// <para>
/// The Android counterpart of <c>releases.win.json</c>, and it exists for the same reason: the client has
/// to answer "is there something newer" without downloading a hundred megabytes to find out. A few hundred
/// bytes fetched on a timer is the difference between a check that can run every six hours and one that
/// cannot run at all.
/// </para>
/// <para>
/// <b>The comparison is <see cref="VersionCode"/> and never the name.</b> That integer is what Android
/// itself uses to accept or refuse an install, so comparing anything else would let this offer an update
/// the platform then rejects — and a SemVer comparison over <c>0.2.0-alpha.0.7</c> is a parser nobody here
/// should be writing. The name is for the person reading the banner and decides nothing.
/// </para>
/// </remarks>
/// <param name="VersionCode">Android's own monotonic integer for the published build.</param>
/// <param name="VersionName">What that build calls itself, for a human.</param>
/// <param name="Apk">The asset on the same release that holds it.</param>
internal sealed record AndroidChannelManifest(
[property: JsonPropertyName("versionCode")] long VersionCode,
[property: JsonPropertyName("versionName")] string VersionName,
[property: JsonPropertyName("apk")] string Apk);
/// <summary>One release as the forge describes it. Only the assets are read.</summary>
internal sealed record ForgeRelease(
[property: JsonPropertyName("assets")] IReadOnlyList<ForgeAsset>? Assets);
/// <summary>One file attached to a release.</summary>
internal sealed record ForgeAsset(
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("browser_download_url")] string? DownloadUrl);
[JsonSourceGenerationOptions(UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(AndroidChannelManifest))]
[JsonSerializable(typeof(ForgeRelease))]
internal sealed partial class ForgeJsonContext : JsonSerializerContext;
/// <summary>
/// Replaces this phone's copy of DodoSSH with a newer one from the project's own forge.
/// </summary>
/// <remarks>
/// <para>
/// <b>The feed is a constant and the deployment is never asked.</b> ADR 0011 rule 2, and it is the same
/// property the desktop's channel carries: an operator who could answer the update check could pin a
/// chosen user to a build with a known hole by withholding the answer, without holding any key at all.
/// There is no setting for this and there is deliberately nowhere to put one.
/// </para>
/// <para>
/// <b>Which release it asks depends on how this build was made.</b> The release channel takes the newest
/// non-prerelease; the nightly channel takes the release tagged <c>nightly</c>, which CI replaces on every
/// push to main. The two are separate applications with separate signing keys — see ADR 0014 — so neither
/// feed can ever hand the other an APK Android would accept, which is what makes a mistake here loud
/// rather than dangerous.
/// </para>
/// <para>
/// <b>Nothing is installed by this class.</b> It fetches, and then it asks Android to ask the user. The
/// platform draws its own dialogue naming the package, and on API 26 and later it will not draw even that
/// until the user has turned this application on in the unknown-sources settings screen. Two deliberate
/// answers, neither of them to a screen this application controls.
/// </para>
/// </remarks>
internal sealed class AndroidUpdateChannel : IUpdateChannel
{
/// <summary>The project's own forge, and the one address in this file.</summary>
private const string RepositoryApi = "https://git.dodotech.cloud/api/v1/repos/DodoTech/DodoSSH";
/// <summary>
/// The session name the installer writes under, and it is reused rather than made unique.
/// </summary>
/// <remarks>
/// A session is opened, written and committed inside one call, so two of them cannot overlap — and a
/// name that varied would leave abandoned sessions behind on a phone that lost power mid-write.
/// </remarks>
private const string InstallSession = "dodossh-update";
private readonly Context context;
private readonly string channel;
private readonly HttpClient http;
/// <summary>What the last successful check found, kept so the download knows where to look.</summary>
/// <remarks>
/// The seam only carries a version string — see <see cref="AvailableUpdate"/> — so the URL and the
/// asset name stay on this side of it and are matched back by version. Cleared by nothing: a stale
/// answer is replaced by the next check, and a download for a version this does not recognise is
/// refused rather than guessed at.
/// </remarks>
private (string Version, string Url)? found;
/// <summary>Where the fetched APK is, once there is one.</summary>
private string? fetched;
internal AndroidUpdateChannel(Context context, string channel, HttpClient http)
{
this.context = context;
this.channel = channel;
this.http = http;
CurrentVersion = ClientVersion.Current;
InstalledVersionCode = ReadInstalledVersionCode(context);
}
/// <inheritdoc />
public bool IsSupported => true;
/// <summary>
/// Never, on this head.
/// </summary>
/// <remarks>
/// <see cref="ApplyAndRestart"/> hands the package to Android and comes straight back; what happens
/// next is a system dialogue the user may decline. See the interface, which explains what the caller
/// does differently — the short version being that declining must not cost somebody their session.
/// </remarks>
public bool ApplyingEndsTheProcess => false;
/// <inheritdoc />
public string CurrentVersion { get; }
/// <summary>What Android thinks is installed, which is the number the comparison is made on.</summary>
private long InstalledVersionCode { get; }
/// <inheritdoc />
public async Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken)
{
// Every failure below resolves to null rather than throwing, and the caller's remark says why: an
// unreachable forge is a phone on a train. It is not news and it heals itself in six hours.
try
{
var release = await ReadAsync(ReleaseUrl(), ForgeJsonContext.Default.ForgeRelease, cancellationToken)
.ConfigureAwait(false);
if (Asset(release, $"android-{channel}.json") is not { } manifestAsset)
{
return null;
}
var manifest = await ReadAsync(
manifestAsset,
ForgeJsonContext.Default.AndroidChannelManifest,
cancellationToken)
.ConfigureAwait(false);
if (manifest is null || manifest.VersionCode <= InstalledVersionCode)
{
return null;
}
if (Asset(release, manifest.Apk) is not { } apk)
{
// A manifest naming an APK the release does not carry. CI uploads the package before the
// manifest precisely so this window is short, and answering null rather than throwing is
// what makes a half-published release a thing that fixes itself.
return null;
}
found = (manifest.VersionName, apk);
return new AvailableUpdate(manifest.VersionName);
}
catch (Exception exception) when (exception is HttpRequestException
or JsonException
or TaskCanceledException
or IOException)
{
return null;
}
}
/// <inheritdoc />
public async Task DownloadAsync(
AvailableUpdate update,
IProgress<int> progress,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(update);
ArgumentNullException.ThrowIfNull(progress);
if (found is not { } target || !string.Equals(target.Version, update.Version, StringComparison.Ordinal))
{
throw new InvalidOperationException(
"That update did not come from this channel's last check. Check again before downloading.");
}
// Into the cache directory, which Android reclaims under storage pressure. That is the right home
// for a file whose only job is to survive until the installer has read it, and it is the same
// trade DocumentStaging takes for uploads. The profile directory is not used, because a partly
// written APK sitting next to the vault forever is the cost of getting this wrong.
var directory = Path.Combine(PhoneEnvironment.CacheDirectory, "updates");
Directory.CreateDirectory(directory);
// One name, overwritten. A phone that downloaded three updates it never installed should not be
// holding three hundred megabytes on their behalf.
var path = Path.Combine(directory, "dodossh-update.apk");
using var response = await http
.GetAsync(target.Url, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var total = response.Content.Headers.ContentLength ?? 0;
var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var destination = File.Create(path);
await using (stream.ConfigureAwait(false))
await using (destination.ConfigureAwait(false))
{
var buffer = new byte[81920];
long copied = 0;
int read;
while ((read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
{
await destination
.WriteAsync(buffer.AsMemory(0, read), cancellationToken)
.ConfigureAwait(false);
copied += read;
// Only where the server said how big it is. A feed answering without a length gets a bar
// that sits at zero and then completes, which is honest; inventing a percentage from a
// total nobody knows is not.
if (total > 0)
{
progress.Report((int)(copied * 100 / total));
}
}
}
fetched = path;
}
/// <summary>
/// Asks Android to install what was fetched.
/// </summary>
/// <remarks>
/// <para>
/// <b>This returns, unlike the desktop's.</b> The install is a request; the platform draws the
/// confirmation and the user answers it. If they agree, Android stops this process and starts the new
/// build — which is also what zeroes the keys, since the caller deliberately did not dispose the vault
/// first. If they decline, everything carries on exactly as it was.
/// </para>
/// <para>
/// <b>The unknown-sources gate comes first, and it is not an error.</b> Being allowed to install is a
/// per-application setting rather than a permission a dialogue can grant, so the honest answer to not
/// having it is to open the screen where it is granted. That is the one and only place this sends
/// somebody out of the application.
/// </para>
/// <para>
/// A <c>PackageInstaller</c> session rather than an <c>ACTION_VIEW</c> intent over a
/// <c>content://</c> URI. The intent form needs a <c>FileProvider</c>, an exported provider element
/// and a grant on every launch, all so the installer can read a file this application already has
/// open — where a session hands it the bytes directly.
/// </para>
/// </remarks>
public void ApplyAndRestart(AvailableUpdate update)
{
ArgumentNullException.ThrowIfNull(update);
if (fetched is not { } path || !File.Exists(path))
{
throw new InvalidOperationException("There is no downloaded update to install.");
}
var packages = context.PackageManager
?? throw new InvalidOperationException("Android returned no package manager.");
if (!packages.CanRequestPackageInstalls())
{
SendToTheUnknownSourcesScreen();
return;
}
var installer = packages.PackageInstaller;
var parameters = new PackageInstaller.SessionParams(PackageInstallMode.FullInstall);
var length = new FileInfo(path).Length;
parameters.SetSize(length);
var id = installer.CreateSession(parameters);
using (var session = installer.OpenSession(id))
{
using (var destination = session.OpenWrite(InstallSession, 0, length))
using (var source = File.OpenRead(path))
{
source.CopyTo(destination);
// Before the stream is closed, and it is not optional: without it the bytes may still be
// in a buffer when commit runs, and the installer rejects the session for a size that
// does not match the one declared above.
session.Fsync(destination);
}
// A pending intent is how the platform reports what the user decided, and one is required
// whether or not anything listens. Nothing here does: the two outcomes are this process being
// replaced and this process carrying on, and both are already visible without being told.
// Mutable is required from API 31 — the installer fills the result in — and does not exist
// below it, where every pending intent is mutable and naming the flag will not compile
// against the older platform. minSdk here is 28, so both cases are real.
var flags = OperatingSystem.IsAndroidVersionAtLeast(31)
? PendingIntentFlags.Mutable | PendingIntentFlags.UpdateCurrent
: PendingIntentFlags.UpdateCurrent;
var callback = PendingIntent.GetBroadcast(context, 0, new Intent(InstallSession), flags);
session.Commit(callback!.IntentSender!);
}
}
/// <summary>Which release this build's channel reads.</summary>
/// <remarks>
/// <c>releases/latest</c> skips prereleases, which is what keeps the nightly — published as one — out
/// of the release channel's answer even though both live on the same forge.
/// </remarks>
private string ReleaseUrl() =>
string.Equals(channel, "nightly", StringComparison.Ordinal)
? $"{RepositoryApi}/releases/tags/nightly"
: $"{RepositoryApi}/releases/latest";
private static string? Asset(ForgeRelease? release, string name) =>
release?.Assets?.FirstOrDefault(
asset => string.Equals(asset.Name, name, StringComparison.Ordinal))?.DownloadUrl;
private async Task<T?> ReadAsync<T>(
string url,
JsonTypeInfo<T> shape,
CancellationToken cancellationToken)
{
var stream = await http.GetStreamAsync(url, cancellationToken).ConfigureAwait(false);
await using (stream.ConfigureAwait(false))
{
return await JsonSerializer
.DeserializeAsync(stream, shape, cancellationToken)
.ConfigureAwait(false);
}
}
/// <summary>What Android records for the installed package, which is the number a newer build must beat.</summary>
/// <remarks>
/// Read from the platform rather than from the assembly, because the assembly's version is a SemVer
/// string and this comparison has to be the one the installer will make. A phone that cannot answer
/// gets 0, which makes every published build look newer — the wrong way to fail, but the failure is
/// then a refused install rather than a missed security fix.
/// </remarks>
private static long ReadInstalledVersionCode(Context context)
{
try
{
var name = context.PackageName;
// No flags: the version code is on the bare record and every flag there is asks for more.
if (context.PackageManager?.GetPackageInfo(name!, (PackageInfoFlags)0) is { } info)
{
return info.LongVersionCode;
}
}
catch (PackageManager.NameNotFoundException)
{
// A package that cannot find itself. Nothing to do about it here.
}
return 0;
}
private void SendToTheUnknownSourcesScreen()
{
var intent = new Intent(
global::Android.Provider.Settings.ActionManageUnknownAppSources,
global::Android.Net.Uri.Parse($"package:{context.PackageName}"));
// The activity if there is one, and the application context otherwise with a task of its own —
// starting an activity from a non-activity context without that flag throws, and the update loop
// can perfectly well be the thing that raised this while the app was backgrounded.
if (PhoneEnvironment.CurrentActivity is { } activity)
{
activity.StartActivity(intent);
return;
}
intent.AddFlags(ActivityFlags.NewTask);
context.StartActivity(intent);
}
}
/// <summary>
/// Picks the update channel this copy of the phone head gets.
/// </summary>
/// <remarks>
/// The counterpart of the desktop's <c>UpdateChannels.ForThisMachine</c>, and it answers the same question
/// about the same thing: was this copy installed by something that knows how to replace it.
/// </remarks>
internal static class AndroidUpdateChannels
{
/// <summary>
/// The channel for this build, or one that reports itself unavailable.
/// </summary>
/// <remarks>
/// <para>
/// Unavailable in two cases. A build with no channel metadata was compiled without going through
/// either of the two the csproj declares, which in practice means somebody's own <c>dotnet build</c>.
/// And a debuggable build is one an IDE deployed: it is signed with the local debug key, so no
/// published APK could replace it, and offering would end at a refusal the platform words badly.
/// </para>
/// <para>
/// Deliberately <em>not</em> gated on the unknown-sources setting. That is a thing the user can turn
/// on, and reporting the whole feature missing because they have not yet would be hiding the button
/// that explains how. See <c>AndroidUpdateChannel.ApplyAndRestart</c>, which walks them there.
/// </para>
/// </remarks>
internal static IUpdateChannel ForThisPhone(HttpClient http)
{
var context = PhoneEnvironment.Require();
var channel = typeof(AndroidUpdateChannels).Assembly
.GetCustomAttributes<AssemblyMetadataAttribute>()
.FirstOrDefault(attribute => string.Equals(attribute.Key, "DodoChannel", StringComparison.Ordinal))
?.Value;
if (string.IsNullOrWhiteSpace(channel))
{
return new UnavailableUpdateChannel();
}
var debuggable = context.ApplicationInfo is { } info
&& info.Flags.HasFlag(ApplicationInfoFlags.Debuggable);
return debuggable
? new UnavailableUpdateChannel()
: new AndroidUpdateChannel(context, channel, http);
}
}
@@ -18,6 +18,25 @@
<!-- Releases the device key. See AndroidDeviceKeyStore. -->
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<!--
◆ Hands a downloaded APK to the system installer, which is the whole of this head's updater. See
AndroidUpdateChannel and ADR 0014.
It is worth being exact about what this permission is and is not. It does not let this application
install anything: it lets it *ask*, and the platform then shows its own confirmation naming the
package and the source. On API 26 and later it is additionally gated by a per-application setting the
user has to turn on in Settings, which no permission dialogue can grant — so the first update walks
them there. Nothing is installed without two deliberate answers, neither of them to a screen this
application drew.
Declared rather than avoided by opening the release page in a browser. That would work, and it would
move the same install through Chrome's downloads and the same unknown-sources gate with one more step
and no less trust. What it would also do is give up any way of knowing a fix has been fetched, which
for an SSH client holding a team's credentials is the property ADR 0011's consequences call the
sharpest edge of shipping outside a store.
-->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!--
allowBackup and fullBackupContent are both off deliberately, and both are vault properties rather
than defaults worth inheriting. The local cache is a SQLite file holding the ciphertext mirror, the
@@ -32,7 +51,13 @@
that attribute exists so a launcher wanting a circle can be handed a second bitmap, and
an adaptive icon is already masked to whatever shape the launcher asks for.
-->
<application android:label="DodoSSH"
<!--
@string/app_name rather than the literal that was here, because the two channels are two installable
applications and both showing "DodoSSH" under the launcher icon is a home screen nobody can read. The
release channel's copy of that string is exactly what was written here before. See
Resources/values/strings.xml, and MainActivity, which has to name the same resource.
-->
<application android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:theme="@style/DodoTheme"
android:networkSecurityConfig="@xml/network_security_config"
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
The name under the launcher icon, for both channels.
A resource rather than the literal that used to be in the manifest, because the launcher reads the
*activity's* label and that one is written in a C# attribute where no MSBuild property can reach —
so a single @string is the only place the two can be answered at once, and this is the hook if they
ever need different answers.
◆ THEY DO NOT GET DIFFERENT ANSWERS TODAY, AND THAT IS A GIVING-UP RATHER THAN A DECISION. A nightly
installed beside a release puts two identically named icons on the home screen, which is a real if
small defect. Four ways to vary it per channel were tried and none reached the launcher; the findings
are in docs/platform-flags.md so the next attempt starts further along. What tells the two apart in
the meantime is the package name in Android's app info, the version, and the channel the application
names on its own preferences screen.
-->
<string name="app_name">DodoSSH</string>
</resources>
@@ -81,34 +81,77 @@
<TextBlock Classes="body" Margin="6,12,6,0" TextAlignment="Center"
Text="{Binding StatusMessage}" />
<!-- ============ ◆ which build this is ============ -->
<!-- ============ ◆ updates ============ -->
<!--
A fact, printed, in the shape ACCOUNT uses on the desktop's screen — and the same string the desktop
prints under UPDATES, read off the same place. It comes from the assembly's own informational
version, so it answers on a build run from a checkout as well as on a released one; see
ClientVersion.Current.
The phone's copy of the desktop's UPDATES section, over the same view model. What differs is the
last step and only the last step: pressing INSTALL hands the package to Android's own installer
rather than swapping files, so the platform draws a confirmation this application does not control
and the answer may be no. Everything before that — the timer, the quiet background pass, the loud
pressed one, the fetch — is shared. See AndroidUpdateChannel and ADR 0014.
It is worth a row of its own on the head that has no updater. On the desktop the version sits beside
a CHECK NOW that will tell you whether it is current; here it is the only answer, and it is the one
thing to read out when somebody asks which build is misbehaving. The sentence under it says where a
newer one comes from, which is ADR 0011 rule 2 in plain words: never from the server you sign in to.
The version is a fact, printed, in the shape ACCOUNT uses on the desktop. It comes from the
assembly's own informational version rather than from the updater, so it answers on a build run
from a checkout too; see ClientVersion.Current.
Bound through Updates, which is the shell's update view model and exists on this head too — over the
null channel, so it reports itself unsupported and its loop never starts. That is why there is no
button here rather than a disabled one. See ADR 0013.
The sentence about where builds come from is ADR 0011 rule 2 in plain words, and it is on the head
the rule was written for: no DodoSSH server will ever offer you the client, and one that does is not
one to trust.
-->
<TextBlock Classes="section" Text="THIS BUILD" Margin="6,26,0,0" />
<TextBlock Classes="section" Text="UPDATES" Margin="6,26,0,0" />
<Border Classes="card" Margin="0,10,0,0">
<StackPanel Spacing="10">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Classes="label" Text="VERSION" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" FontSize="13" Margin="10,0,0,0"
HorizontalAlignment="Right" Text="{Binding Updates.CurrentVersion}" />
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis"
Text="{Binding Updates.CurrentVersion}" />
</Grid>
<StackPanel Spacing="10" IsVisible="{Binding Updates.IsSupported}">
<CheckBox MinHeight="44" IsChecked="{Binding Updates.IsAutomatic}">
<TextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
Text="Look for newer builds on their own" />
</CheckBox>
<TextBlock Classes="body"
Text="Checks come from the project's own release page and never from the server you sign in to. Nothing is installed without you saying so, and Android asks again in its own words before anything is replaced." />
<!--
The bar, copying the desktop's and the transfers screen's. Shown while fetching and not
before: a bar at zero beside a button nobody has pressed is a screen that looks busy.
-->
<ProgressBar IsVisible="{Binding Updates.IsDownloading}" Height="4"
Minimum="0" Maximum="100" Value="{Binding Updates.DownloadPercent}" />
<Button Classes="secondary" Height="44" Content="CHECK NOW"
Command="{Binding Updates.CheckNowCommand}"
IsEnabled="{Binding Updates.CanCheckNow}" />
<!--
INSTALL rather than the desktop's RESTART NOW, and the word is the honest one: this does not
restart anything. It asks Android to install, and Android asks the user. What follows is the
system's screen, not this one.
-->
<Button Classes="primary" Height="44" Content="INSTALL"
IsVisible="{Binding Updates.IsReady}"
Command="{Binding Updates.RestartNowCommand}" />
<TextBlock Classes="body" IsVisible="{Binding Updates.IsReady}"
Text="{Binding Updates.RestartWarning}" />
<TextBlock Classes="detail" TextWrapping="Wrap" Text="{Binding Updates.Status}"
IsVisible="{Binding Updates.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
<!--
The build that cannot replace itself, which on this head is one an IDE deployed or one built
from a checkout — see AndroidUpdateChannels.ForThisPhone. Said rather than shown as dead
controls, following HasNoDeviceKeyOption above.
-->
<TextBlock Classes="body" IsVisible="{Binding Updates.IsUnsupported}"
Text="This head does not replace itself. A newer DodoSSH is an APK from the project's own release page, installed the way this one was — and no DodoSSH server will ever offer you one, whatever it says. A server that does is not a server to trust." />
Text="This build cannot replace itself — it was not installed from the project's release page. A newer DodoSSH is an APK from there, installed the way a released one would be. No DodoSSH server will ever offer you one, whatever it says; a server that does is not a server to trust." />
</StackPanel>
</Border>
@@ -112,6 +112,14 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
/// <inheritdoc />
public bool IsSupported => true;
/// <summary>Always, on this head.</summary>
/// <remarks>
/// Velopack's apply runs <c>Update.exe</c> over this installation and restarts it, so the process is
/// gone by the time anything could have asked a question. The phone's is the other answer; see
/// <see cref="IUpdateChannel.ApplyingEndsTheProcess"/> for what the caller does differently.
/// </remarks>
public bool ApplyingEndsTheProcess => true;
/// <inheritdoc />
/// <remarks>
/// From the assembly rather than from <c>manager.CurrentVersion</c>, so that this and the version an
+30 -1
View File
@@ -70,11 +70,32 @@ public interface IUpdateChannel
/// <param name="cancellationToken">Cancellation token.</param>
Task DownloadAsync(AvailableUpdate update, IProgress<int> progress, CancellationToken cancellationToken);
/// <summary>
/// Whether <see cref="ApplyAndRestart"/> ends this process itself.
/// </summary>
/// <remarks>
/// <para>
/// True on a desktop, where applying an update is this application replacing its own files and
/// restarting — the caller tears the vault down first, because that is what zeroes the identity keys,
/// the vault keys and the cache key while there is still a process to zero them in.
/// </para>
/// <para>
/// <b>False on the phone, and it is not a detail.</b> There, applying hands the package to Android's
/// own installer and returns; what happens next is a system dialogue the user may perfectly well say
/// no to. A caller that had already disposed the vault would answer "not now" with a locked keychain
/// and every shell gone — punishing somebody for declining an update. Where this is false the caller
/// leaves the session alone, and the keys go when the platform kills the process, which it does the
/// moment the install is agreed to.
/// </para>
/// </remarks>
bool ApplyingEndsTheProcess { get; }
/// <summary>
/// Swaps this build for the downloaded one and starts it.
/// </summary>
/// <remarks>
/// <b>This does not return.</b> It is the reason the whole capability is behind an interface rather
/// <b>This does not return where <see cref="ApplyingEndsTheProcess"/> is true.</b> It is the reason the
/// whole capability is behind an interface rather
/// than being three lines inline in the view model: a test cannot drive a method that ends the process,
/// and the single most important thing to be able to assert about this feature is that a downloaded
/// update is <em>never</em> applied unless somebody asked for it. A fake that records the call instead
@@ -109,6 +130,14 @@ public sealed class UnavailableUpdateChannel : IUpdateChannel
/// <inheritdoc />
public string CurrentVersion => ClientVersion.Current;
/// <summary>Always true, and never read: nothing here applies anything.</summary>
/// <remarks>
/// True rather than false because it is the answer that makes a caller who ignores
/// <see cref="IsSupported"/> do the safer thing — tear the session down — before reaching a method
/// that is about to throw at it anyway.
/// </remarks>
public bool ApplyingEndsTheProcess => true;
/// <inheritdoc />
public Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken) =>
Task.FromResult<AvailableUpdate?>(null);
@@ -454,9 +454,19 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// ends the process, and disposing this view model is what zeroes the identity keys, the vault
// keys and the cache key — so the other order would leave them sitting in a memory image the
// installer is about to write over, and would abandon a transfer still writing to a part file.
//
// ◆ Unless applying does not end the process, which is the phone. There the swap is a request
// to the platform's own installer and the answer may be no, so tearing everything down first
// would answer "not now" with a locked keychain and every shell closed — a punishment for
// declining an update. Nothing is zeroed in that case, and nothing needs to be while the
// process is still the one holding it; when the install is agreed to, Android ends the process
// outright. See IUpdateChannel.ApplyingEndsTheProcess.
restart: async update =>
{
await DisposeAsync().ConfigureAwait(true);
if (channel.ApplyingEndsTheProcess)
{
await DisposeAsync().ConfigureAwait(true);
}
channel.ApplyAndRestart(update);
});
@@ -37,6 +37,9 @@ public sealed class UpdateBannerTests
{
public bool IsSupported => true;
// The desktop's answer, which is what this suite lays out. Nothing here applies anything.
public bool ApplyingEndsTheProcess => true;
public string CurrentVersion => "1.0.0";
public Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken) =>
@@ -46,6 +46,14 @@ internal sealed class FakeUpdateChannel : IUpdateChannel
/// <inheritdoc />
public bool IsSupported { get; set; } = true;
/// <summary>Settable, because the two heads answer it differently and both paths need driving.</summary>
/// <remarks>
/// True is the desktop's: the shell disposes itself before applying. False is the phone's, where the
/// install can be declined — and the test that matters is that declining leaves a working session
/// rather than a torn-down one.
/// </remarks>
public bool ApplyingEndsTheProcess { get; set; } = true;
/// <inheritdoc />
public string CurrentVersion { get; set; } = "1.0.0";