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

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

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

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

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

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

Two measured bugs found on the way, both older than this work and both invisible to
a -getProperty check. ApplicationDisplayVersion is read by the Android targets in a
top-level PropertyGroup, so the target setting it from MinVer ran after the only
thing that reads it: every APK ever built here said versionName 1.0.0. And nothing
found so far varies the launcher name per channel — four mechanisms tried, all of
them recorded in platform-flags, none of them reaching the label the launcher shows.
The two channels share an icon name for now and are told apart by package name,
version, and what the preferences screen says.
This commit is contained in:
2026-08-04 21:46:01 +02:00
parent f90c331334
commit b4a6c19ac1
18 changed files with 1520 additions and 49 deletions
+92
View File
@@ -1811,3 +1811,95 @@ is wrong. Reinstalling then asks for the passphrase rather than for a server.
**Failure means:** the cache going with the application is the pack-id collision, and whoever ran this has
lost their offline unlock and any change that was still in the outbox. That is the failure ADR 0013
decision 2 exists to prevent, and it is why 16.1 checks the same thing from the other end.
---
## Phase 17 — Installing and updating the phone
Every check here needs a real Android device or emulator. Nothing about this is automated and structurally
cannot be for the reason phase 8 gives, with one more on top: the install is another application's screen.
Two channels, and they are two applications — see [ADR 0014](adr/0014-android-updates.md). The nightly is
what CI publishes on every push to main and is signed with a key that is in this repository; the release
channel is cut by `scripts/release-android.ps1` on a machine holding the real key. Most of these checks can
be walked on the nightly, which is the point of having one.
### 17.1 The unknown-sources gate is asked for and not assumed
On a phone that has never installed a DodoSSH APK, download one from the release page and open it.
**Pass:** Android says the browser is not allowed to install unknown apps and offers the settings screen.
Granting it there and returning completes the install.
**Failure means:** nothing — this is the platform working, and it is the cost ADR 0011 names. It is a check
because the *next* one depends on somebody having been through it.
### 17.2 Both channels install side by side
Install a nightly on a phone that already has a release build, or the other way round.
**Pass:** two applications, two icons, both openable, each with its own keychain. Android's app info shows
`dev.dodotech.dodossh` for one and `dev.dodotech.dodossh.nightly` for the other.
**Failure means:** if the second install replaces the first, the two channels are sharing a package id and
the separation ADR 0014 rests on is not there. If it is *refused*, they share an id and differ in key,
which is the same fault seen from the other side.
Both icons say DodoSSH, which is a known defect rather than a surprise — see `docs/platform-flags.md`.
### 17.3 The version on screen is the version that was built
Settings → Preferences → UPDATES.
**Pass:** a version matching the tag it was built from, or on a nightly the full MinVer string with its
height. Never `1.0.0`.
**Failure means:** `1.0.0` is the `_AndroidVersionName` fix having come undone; see platform-flags.
### 17.4 A check that finds nothing says so, and one on a timer does not
Press CHECK NOW on the newest build there is.
**Pass:** it answers — "DodoSSH x.y.z is the latest build." Then leave the app open and do nothing for
several minutes.
**Pass:** no message appears on its own. A background pass that found nothing is silent, which is what makes
the feature tolerable.
### 17.5 An update is found, fetched without being asked, and installed only when asked · **the whole point**
With an older build installed, publish or wait for a newer one on the same channel, then press CHECK NOW.
**Pass:** the bar moves, then an INSTALL button and a line saying what it costs. **Nothing is installed
yet.** Leave it sitting there and confirm the application still works normally.
Press INSTALL.
**Pass:** Android's own installer appears naming the package. Agreeing replaces the app and it reopens as
the new version, still enrolled, with the vault and its hosts intact.
**Failure means:** an install that happens without INSTALL being pressed is the policy this feature is built
around being broken. An `INSTALL_FAILED_UPDATE_INCOMPATIBLE` means the two builds were signed by different
keys — on the nightly channel that means the committed keystore changed, and on the release channel it means
the wrong keystore was used.
### 17.6 Declining leaves a working session · **the one that would be missed**
Repeat 17.5 to the point where Android's installer is on screen, with a terminal open and the vault
unlocked. Press back or cancel.
**Pass:** DodoSSH is still running, still unlocked, and the shell is still connected.
**Failure means:** a locked keychain or a dead session is the shell having disposed itself before asking —
`IUpdateChannel.ApplyingEndsTheProcess` not being read, or answering true on this head. It punishes somebody
for declining an update, and nothing in an automated suite would notice.
### 17.7 A nightly cannot update a release build
With a release build installed, download the nightly APK and try to install it *over* it — rename it if the
package manager will not offer to.
**Pass:** refused, or installed as a second application. Never a replacement.
**Failure means:** the channels are not separate, and a public key is signing the application people keep
their credentials in.