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 0000000..a6d553f Binary files /dev/null and b/build/nightly.keystore differ 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}" /> + + + + + + + + + + + +