From b4a6c19ac1e390c0d7c60d7e26ca0111cbd8b489 Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Tue, 4 Aug 2026 21:46:01 +0200 Subject: [PATCH] Let the phone replace itself, and give CI a channel it may sign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/ci.yml | 266 ++++++++++- build/nightly.keystore | Bin 0 -> 4474 bytes docs/adr/0014-android-updates.md | 119 +++++ docs/manual-checks.md | 92 ++++ docs/platform-flags.md | 26 + scripts/release-android.ps1 | 275 +++++++++++ src/DodoSSH.Client.Android/App.axaml.cs | 16 +- .../DodoSSH.Client.Android.csproj | 136 +++++- src/DodoSSH.Client.Android/MainActivity.cs | 6 +- .../Platform/AndroidUpdateChannel.cs | 450 ++++++++++++++++++ .../Properties/AndroidManifest.xml | 27 +- .../Resources/values/strings.xml | 21 + .../Views/PreferencesScreen.axaml | 73 ++- .../Platform/VelopackUpdateChannel.cs | 8 + src/DodoSSH.Client.Session/ClientUpdates.cs | 31 +- .../ViewModels/MainWindowViewModel.cs | 12 +- .../UpdateBannerTests.cs | 3 + .../FakeUpdateChannel.cs | 8 + 18 files changed, 1520 insertions(+), 49 deletions(-) create mode 100644 build/nightly.keystore create mode 100644 docs/adr/0014-android-updates.md create mode 100644 scripts/release-android.ps1 create mode 100644 src/DodoSSH.Client.Android/Platform/AndroidUpdateChannel.cs create mode 100644 src/DodoSSH.Client.Android/Resources/values/strings.xml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8bde9bc..4dfee39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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// — 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" <> "$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 diff --git a/build/nightly.keystore b/build/nightly.keystore new file mode 100644 index 0000000000000000000000000000000000000000..a6d553fd3824cc616afc8e96570cae904e244e65 GIT binary patch literal 4474 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 literal 0 HcmV?d00001 diff --git a/docs/adr/0014-android-updates.md b/docs/adr/0014-android-updates.md new file mode 100644 index 0000000..f585052 --- /dev/null +++ b/docs/adr/0014-android-updates.md @@ -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. diff --git a/docs/manual-checks.md b/docs/manual-checks.md index f7a1b9c..c768081 100644 --- a/docs/manual-checks.md +++ b/docs/manual-checks.md @@ -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. diff --git a/docs/platform-flags.md b/docs/platform-flags.md index 005d9ae..a95caf8 100644 --- a/docs/platform-flags.md +++ b/docs/platform-flags.md @@ -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)` — 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. diff --git a/scripts/release-android.ps1 b/scripts/release-android.ps1 new file mode 100644 index 0000000..33ab73f --- /dev/null +++ b/scripts/release-android.ps1 @@ -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.' diff --git a/src/DodoSSH.Client.Android/App.axaml.cs b/src/DodoSSH.Client.Android/App.axaml.cs index c780419..10be61d 100644 --- a/src/DodoSSH.Client.Android/App.axaml.cs +++ b/src/DodoSSH.Client.Android/App.axaml.cs @@ -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. diff --git a/src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj b/src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj index ca0c07f..6043ed5 100644 --- a/src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj +++ b/src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj @@ -19,15 +19,29 @@ 28 36 - dev.dodotech.dodossh - - 1 + release + + dev.dodotech.dodossh + + + 1 + + + + + dev.dodotech.dodossh.nightly + + + $(DodoNightlyVersionCode) + 1 + + + true + $(MSBuildThisFileDirectory)../../build/nightly.keystore + dodossh-nightly + nightly + nightly + + + + + + + - + + - $(MinVerMajor).$(MinVerMinor).$(MinVerPatch) + $(MinVerMajor).$(MinVerMinor).$(MinVerPatch) + $(MinVerVersion) + + <_AndroidVersionName Condition="'$(ApplicationDisplayVersion)' != ''">$(ApplicationDisplayVersion) diff --git a/src/DodoSSH.Client.Android/MainActivity.cs b/src/DodoSSH.Client.Android/MainActivity.cs index 02cbcbb..f1b7134 100644 --- a/src/DodoSSH.Client.Android/MainActivity.cs +++ b/src/DodoSSH.Client.Android/MainActivity.cs @@ -50,7 +50,11 @@ namespace DodoSSH.Client.Android; /// /// [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, diff --git a/src/DodoSSH.Client.Android/Platform/AndroidUpdateChannel.cs b/src/DodoSSH.Client.Android/Platform/AndroidUpdateChannel.cs new file mode 100644 index 0000000..aa8b9ef --- /dev/null +++ b/src/DodoSSH.Client.Android/Platform/AndroidUpdateChannel.cs @@ -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; + +/// +/// What a channel's feed publishes beside its APK. +/// +/// +/// +/// The Android counterpart of releases.win.json, 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. +/// +/// +/// The comparison is and never the name. 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 0.2.0-alpha.0.7 is a parser nobody here +/// should be writing. The name is for the person reading the banner and decides nothing. +/// +/// +/// Android's own monotonic integer for the published build. +/// What that build calls itself, for a human. +/// The asset on the same release that holds it. +internal sealed record AndroidChannelManifest( + [property: JsonPropertyName("versionCode")] long VersionCode, + [property: JsonPropertyName("versionName")] string VersionName, + [property: JsonPropertyName("apk")] string Apk); + +/// One release as the forge describes it. Only the assets are read. +internal sealed record ForgeRelease( + [property: JsonPropertyName("assets")] IReadOnlyList? Assets); + +/// One file attached to a release. +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; + +/// +/// Replaces this phone's copy of DodoSSH with a newer one from the project's own forge. +/// +/// +/// +/// The feed is a constant and the deployment is never asked. 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. +/// +/// +/// Which release it asks depends on how this build was made. The release channel takes the newest +/// non-prerelease; the nightly channel takes the release tagged nightly, 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. +/// +/// +/// Nothing is installed by this class. 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. +/// +/// +internal sealed class AndroidUpdateChannel : IUpdateChannel +{ + /// The project's own forge, and the one address in this file. + private const string RepositoryApi = "https://git.dodotech.cloud/api/v1/repos/DodoTech/DodoSSH"; + + /// + /// The session name the installer writes under, and it is reused rather than made unique. + /// + /// + /// 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. + /// + private const string InstallSession = "dodossh-update"; + + private readonly Context context; + private readonly string channel; + private readonly HttpClient http; + + /// What the last successful check found, kept so the download knows where to look. + /// + /// The seam only carries a version string — see — 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. + /// + private (string Version, string Url)? found; + + /// Where the fetched APK is, once there is one. + 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); + } + + /// + public bool IsSupported => true; + + /// + /// Never, on this head. + /// + /// + /// 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. + /// + public bool ApplyingEndsTheProcess => false; + + /// + public string CurrentVersion { get; } + + /// What Android thinks is installed, which is the number the comparison is made on. + private long InstalledVersionCode { get; } + + /// + public async Task 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; + } + } + + /// + public async Task DownloadAsync( + AvailableUpdate update, + IProgress 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; + } + + /// + /// Asks Android to install what was fetched. + /// + /// + /// + /// This returns, unlike the desktop's. 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. + /// + /// + /// The unknown-sources gate comes first, and it is not an error. 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. + /// + /// + /// A PackageInstaller session rather than an ACTION_VIEW intent over a + /// content:// URI. The intent form needs a FileProvider, 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. + /// + /// + 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!); + } + } + + /// Which release this build's channel reads. + /// + /// releases/latest 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. + /// + 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 ReadAsync( + string url, + JsonTypeInfo 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); + } + } + + /// What Android records for the installed package, which is the number a newer build must beat. + /// + /// 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. + /// + 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); + } +} + +/// +/// Picks the update channel this copy of the phone head gets. +/// +/// +/// The counterpart of the desktop's UpdateChannels.ForThisMachine, and it answers the same question +/// about the same thing: was this copy installed by something that knows how to replace it. +/// +internal static class AndroidUpdateChannels +{ + /// + /// The channel for this build, or one that reports itself unavailable. + /// + /// + /// + /// 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 dotnet build. + /// 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. + /// + /// + /// Deliberately not 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 AndroidUpdateChannel.ApplyAndRestart, which walks them there. + /// + /// + internal static IUpdateChannel ForThisPhone(HttpClient http) + { + var context = PhoneEnvironment.Require(); + + var channel = typeof(AndroidUpdateChannels).Assembly + .GetCustomAttributes() + .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); + } +} diff --git a/src/DodoSSH.Client.Android/Properties/AndroidManifest.xml b/src/DodoSSH.Client.Android/Properties/AndroidManifest.xml index 956fbf0..70f4063 100644 --- a/src/DodoSSH.Client.Android/Properties/AndroidManifest.xml +++ b/src/DodoSSH.Client.Android/Properties/AndroidManifest.xml @@ -18,6 +18,25 @@ + + + - + + + + + DodoSSH + + diff --git a/src/DodoSSH.Client.Android/Views/PreferencesScreen.axaml b/src/DodoSSH.Client.Android/Views/PreferencesScreen.axaml index 678d599..5009514 100644 --- a/src/DodoSSH.Client.Android/Views/PreferencesScreen.axaml +++ b/src/DodoSSH.Client.Android/Views/PreferencesScreen.axaml @@ -81,34 +81,77 @@ - + - + + HorizontalAlignment="Right" TextTrimming="CharacterEllipsis" + Text="{Binding Updates.CurrentVersion}" /> + + + + + + + + + + + +