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
@@ -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;
/// <summary>
/// What a channel's feed publishes beside its APK.
/// </summary>
/// <remarks>
/// <para>
/// The Android counterpart of <c>releases.win.json</c>, 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.
/// </para>
/// <para>
/// <b>The comparison is <see cref="VersionCode"/> and never the name.</b> 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 <c>0.2.0-alpha.0.7</c> is a parser nobody here
/// should be writing. The name is for the person reading the banner and decides nothing.
/// </para>
/// </remarks>
/// <param name="VersionCode">Android's own monotonic integer for the published build.</param>
/// <param name="VersionName">What that build calls itself, for a human.</param>
/// <param name="Apk">The asset on the same release that holds it.</param>
internal sealed record AndroidChannelManifest(
[property: JsonPropertyName("versionCode")] long VersionCode,
[property: JsonPropertyName("versionName")] string VersionName,
[property: JsonPropertyName("apk")] string Apk);
/// <summary>One release as the forge describes it. Only the assets are read.</summary>
internal sealed record ForgeRelease(
[property: JsonPropertyName("assets")] IReadOnlyList<ForgeAsset>? Assets);
/// <summary>One file attached to a release.</summary>
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;
/// <summary>
/// Replaces this phone's copy of DodoSSH with a newer one from the project's own forge.
/// </summary>
/// <remarks>
/// <para>
/// <b>The feed is a constant and the deployment is never asked.</b> 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.
/// </para>
/// <para>
/// <b>Which release it asks depends on how this build was made.</b> The release channel takes the newest
/// non-prerelease; the nightly channel takes the release tagged <c>nightly</c>, 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.
/// </para>
/// <para>
/// <b>Nothing is installed by this class.</b> 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.
/// </para>
/// </remarks>
internal sealed class AndroidUpdateChannel : IUpdateChannel
{
/// <summary>The project's own forge, and the one address in this file.</summary>
private const string RepositoryApi = "https://git.dodotech.cloud/api/v1/repos/DodoTech/DodoSSH";
/// <summary>
/// The session name the installer writes under, and it is reused rather than made unique.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private const string InstallSession = "dodossh-update";
private readonly Context context;
private readonly string channel;
private readonly HttpClient http;
/// <summary>What the last successful check found, kept so the download knows where to look.</summary>
/// <remarks>
/// The seam only carries a version string — see <see cref="AvailableUpdate"/> — 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.
/// </remarks>
private (string Version, string Url)? found;
/// <summary>Where the fetched APK is, once there is one.</summary>
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);
}
/// <inheritdoc />
public bool IsSupported => true;
/// <summary>
/// Never, on this head.
/// </summary>
/// <remarks>
/// <see cref="ApplyAndRestart"/> 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.
/// </remarks>
public bool ApplyingEndsTheProcess => false;
/// <inheritdoc />
public string CurrentVersion { get; }
/// <summary>What Android thinks is installed, which is the number the comparison is made on.</summary>
private long InstalledVersionCode { get; }
/// <inheritdoc />
public async Task<AvailableUpdate?> 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;
}
}
/// <inheritdoc />
public async Task DownloadAsync(
AvailableUpdate update,
IProgress<int> 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;
}
/// <summary>
/// Asks Android to install what was fetched.
/// </summary>
/// <remarks>
/// <para>
/// <b>This returns, unlike the desktop's.</b> 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.
/// </para>
/// <para>
/// <b>The unknown-sources gate comes first, and it is not an error.</b> 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.
/// </para>
/// <para>
/// A <c>PackageInstaller</c> session rather than an <c>ACTION_VIEW</c> intent over a
/// <c>content://</c> URI. The intent form needs a <c>FileProvider</c>, 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.
/// </para>
/// </remarks>
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!);
}
}
/// <summary>Which release this build's channel reads.</summary>
/// <remarks>
/// <c>releases/latest</c> 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.
/// </remarks>
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<T?> ReadAsync<T>(
string url,
JsonTypeInfo<T> 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);
}
}
/// <summary>What Android records for the installed package, which is the number a newer build must beat.</summary>
/// <remarks>
/// 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.
/// </remarks>
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);
}
}
/// <summary>
/// Picks the update channel this copy of the phone head gets.
/// </summary>
/// <remarks>
/// The counterpart of the desktop's <c>UpdateChannels.ForThisMachine</c>, and it answers the same question
/// about the same thing: was this copy installed by something that knows how to replace it.
/// </remarks>
internal static class AndroidUpdateChannels
{
/// <summary>
/// The channel for this build, or one that reports itself unavailable.
/// </summary>
/// <remarks>
/// <para>
/// 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 <c>dotnet build</c>.
/// 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.
/// </para>
/// <para>
/// Deliberately <em>not</em> 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 <c>AndroidUpdateChannel.ApplyAndRestart</c>, which walks them there.
/// </para>
/// </remarks>
internal static IUpdateChannel ForThisPhone(HttpClient http)
{
var context = PhoneEnvironment.Require();
var channel = typeof(AndroidUpdateChannels).Assembly
.GetCustomAttributes<AssemblyMetadataAttribute>()
.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);
}
}