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.
This commit is contained in:
2026-08-05 11:17:44 +02:00
parent ca07d63585
commit 9a7e3bbd5c
6 changed files with 168 additions and 46 deletions
+23 -3
View File
@@ -58,10 +58,30 @@ public interface IUpdateChannel
/// <summary>Asks the release channel whether there is anything newer.</summary>
/// <returns>
/// The newer build, or <see langword="null"/> if this one is current. Null is also the honest answer
/// when the channel cannot be reached at all: the caller does the same thing either way, and an
/// unreachable forge is not a state a user can act on.
/// The newer build, or <see langword="null"/> if this one is current. <b>Null means exactly that and
/// nothing else.</b>
/// </returns>
/// <remarks>
/// <para>
/// ◆ <b>A channel that cannot be reached throws, and this used to say the opposite.</b> The reasoning
/// was that the caller does the same thing either way, which is true of a background pass and false of
/// a pressed button: <c>UpdateViewModel</c> swallows a failure on the timer and reports one on
/// CHECK NOW, and it can only do the second if there is something to report. Answering null for an
/// unreachable forge turned every failure into "you are on the latest build".
/// </para>
/// <para>
/// That was not merely a bad answer, it was the wrong one about a real outage: the release repository
/// was private, so every check on every phone got a 404 and every check said the build was current.
/// Nobody could see it, which is the whole argument for this direction — the caller decides what
/// silence is worth, and it cannot decide about a failure it was never told about.
/// </para>
/// <para>
/// <b>The feed must be readable without credentials, and that is a precondition rather than an
/// implementation detail.</b> A token cannot live anywhere useful here: it would be needed before
/// unlock, so it cannot be in the encrypted cache, and putting it in plaintext beside the cache is
/// exactly what <c>ClientSettings</c> refuses. See ADR 0013 and ADR 0014.
/// </para>
/// </remarks>
Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken);
/// <summary>Fetches an update a previous <see cref="CheckAsync"/> found.</summary>