Commit Graph
4 Commits
Author SHA1 Message Date
jaap-jan ca7fee2358 Start the confirmation Android hands back, so an update can install
Pressing INSTALL closed the application, installed nothing and said nothing. That
is two independent faults in one method, either of which breaks it on its own,
and they hid each other: the first kills the process before the second can be
observed, and the second is silent by construction.

The pending intent handed to commit was implicit — an action string with no
component behind it. A mutable pending intent may not wrap one of those from API
34, and this head targets 36, so every current phone threw IllegalArgumentException
before commit was reached. Nothing caught it, so it left the command handler,
passed the dispatcher and took the process with it. That is the closing.

Below 34, where it did not throw, it still installed nothing. An application
holding REQUEST_INSTALL_PACKAGES rather than the privileged INSTALL_PACKAGES gets
no verdict back from a commit: what the platform answers first is
STATUS_PENDING_USER_ACTION, carrying the activity that draws the dialogue in
EXTRA_INTENT for the application to start. Android does not draw it on its own.
The comment here asserted the opposite — that a pending intent is required whether
or not anything listens, and that nothing needed to — so no receiver was ever
written, and the session was written, committed and left staged forever.

So there is a receiver now, not exported because the only sender is this
application's own commit, and the intent naming it is explicit, which is the same
change that stops the throw. Sessions are abandoned when anything fails, since one
created and neither committed nor abandoned stays staged against a per-application
cap — a repeating fault would have started failing at CreateSession instead, which
is the same bug wearing a completely unrelated face.

The reporting is the part worth keeping even after the cause is gone. Where
applying ends the process an exception has nowhere to go; where it does not, which
is this head's whole shape, it goes out through the dispatcher. RestartNowAsync now
answers the way CheckNowAsync already did, and the regression test asserts the
absence of a throw rather than the presence of one.

ADR 0014 rule 6 gets the correction in place: "asks Android to ask" is one step
longer than it reads. Check 17.5 needed no rewording — it asks for the installer
appearing by name, which is exactly the thing that never happened — so what it
gets instead is the two symptoms named, because both present as a dead button. It
is the only thing in the project that can catch either, and it plainly was never
run against a real pair of builds.

Note for whoever takes the next nightly: a broken updater cannot install its own
fix. The phone is running the code this commit replaces, so the first build
carrying it has to be sideloaded by hand; the ones after that install normally.

Compile-verified and manifest-verified — the receiver reaches the generated
manifest — and 321 tests pass. Not run on a device, which is what 17.5 is for.
2026-08-05 22:31:33 +02:00
jaap-jan 253c72d2b7 Name the organisation the repository actually lives in
ci / build and test (push) Canceled after 0s
ci / android head (push) Canceled after 0s
ci / api image (push) Canceled after 0s
It moved to DodoTech-Public, and every address in the product still said
DodoTech. That looked like it worked, which is the part worth writing down:
Gitea leaves a 301 at the old path and HttpClient follows a redirect on a GET, so
both update channels would have kept polling through it.

What a 301 does not survive is a POST. `vpk upload gitea` publishes the desktop
release by POSTing to that URL, so the stale address would have failed at the one
step the whole feature depends on — and a redirect is a thing an operator can
delete, which turns "works today" into the same silent outage this session has
already spent two commits on.

So both channel constants, both release scripts, the workflow's REPO, the image's
source label and the curl in phase 16 all name the live path. The local remote
too, which had been printing a redirect warning on every push.

Measured after the move: the org, the repo and the nightly release all answer 200
anonymously, and that release now carries both assets — the manifest and a 54 MB
APK. The upload going through also answers the open question about the reverse
proxy's body-size limit, which nothing local could test.
2026-08-05 12:51:19 +02:00
jaap-jan 9a7e3bbd5c Let a failed update check say so, instead of reporting good news
The phone reported every build as current because the release repository is
private. Gitea answers 404 rather than 403 for a repo you cannot see, the client
reads that address anonymously, and AndroidUpdateChannel caught the failure and
returned null — which IUpdateChannel documented as meaning "this build is the
latest". The check had never once succeeded on any phone and nothing anywhere
said so.

Two faults, and the second is why the first lasted.

The seam said null was the honest answer for an unreachable channel, on the
reasoning that the caller does the same thing either way. That is true of the
six-hourly pass and false of CHECK NOW. UpdateViewModel already draws the line
correctly — silent on the timer, the exception's message on the button — and it
could only ever draw the first half, because nothing was ever thrown at it. The
desktop's channel does not catch, so the interface described neither
implementation.

So CheckAsync throws now, and null means one thing. A release that is reachable
but missing its manifest or the APK it names throws too: "you are up to date"
about a half-published feed is the same lie in a smaller costume, and the
self-healing that argument protected is untouched, since the timer still swallows
everything.

The precondition is written down where somebody would look, rather than left as a
sentence about where a token could live. ADR 0013 §4 already said a private
release repository was incompatible with this design; nobody checked which side
of it this repository was on. It is one curl, and manual-checks phase 16 now
opens with it — pointedly not against /api/v1/version, which answers 200 from a
forge that is up whatever is readable on it, and which is what made this look
like nothing was wrong.

Phone check 17.4 was the one that passed all along. It now presses CHECK NOW with
the network off as well as on, because two different answers are the whole of
what makes the first one worth reading.
2026-08-05 11:17:44 +02:00
jaap-jan b4a6c19ac1 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.
2026-08-04 21:46:01 +02:00