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
+15 -1
View File
@@ -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.
@@ -19,15 +19,29 @@
<SupportedOSPlatformVersion>28</SupportedOSPlatformVersion>
<TargetPlatformVersion>36</TargetPlatformVersion>
<ApplicationId>dev.dodotech.dodossh</ApplicationId>
<!--
versionCode, and it stays a hand-bumped literal. Android requires a monotonically increasing
integer and SemVer does not give you one — deriving it from the version would work until the day
a patch number reached 10 and the arithmetic went backwards, which is the sort of failure that
surfaces as an upload Google refuses for reasons it will not explain.
============ WHICH CHANNEL THIS BUILD BELONGS TO ============
Two of them, and they are two applications rather than two configurations. Android identifies an
app by its package id and its signing certificate together, and refuses an update signed by a
different key — so a build CI signs and a build a person signs cannot replace one another whatever
they are called. Pretending otherwise would ship an installer that fails with
INSTALL_FAILED_UPDATE_INCOMPATIBLE at the one moment somebody is trying to get a fix.
So each channel gets its own id, its own key and its own feed, and neither can update the other by
construction rather than by accident. Both can be installed at once, which is the useful half: a
person testing a nightly does not lose the release they rely on. See ADR 0014.
release — dev.dodotech.dodossh, signed by the key ADR 0011 puts on a machine that is not a runner.
Cut from a v* tag by scripts/release-android.ps1, by a person.
nightly — dev.dodotech.dodossh.nightly, signed by a keystore committed to this repository in the
open. Cut from main by CI, every push.
Default release, so that an unqualified `dotnet build` is the real application and the nightly is
the one you have to ask for. A build with no keystore named at all falls back to the debug key,
which is what a developer's own device gets and is neither channel.
-->
<ApplicationVersion>1</ApplicationVersion>
<DodoChannel Condition="'$(DodoChannel)' == ''">release</DodoChannel>
<!--
False here for the same reason the desktop head sets it false: this process formats timestamps
@@ -36,6 +50,71 @@
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>
<!-- ============ the release channel ============ -->
<PropertyGroup Condition="'$(DodoChannel)' == 'release'">
<ApplicationId>dev.dodotech.dodossh</ApplicationId>
<!--
versionCode, and it stays a hand-bumped literal on this channel. Android requires a monotonically
increasing integer and SemVer does not give you one — deriving it from the version would work until
the day a patch number reached 10 and the arithmetic went backwards, which is the sort of failure
that surfaces as an install the phone refuses for reasons it will not explain.
Forgetting to bump it is caught rather than shipped: scripts/release-android.ps1 reads the code
already published to the release channel and refuses to build one that is not higher. A guard on a
literal, which is the arrangement this comment argued for, rather than arithmetic nobody checks.
-->
<ApplicationVersion>1</ApplicationVersion>
</PropertyGroup>
<!-- ============ the nightly channel ============ -->
<PropertyGroup Condition="'$(DodoChannel)' == 'nightly'">
<ApplicationId>dev.dodotech.dodossh.nightly</ApplicationId>
<!--
Passed in by CI as the number of commits on the branch, which is monotonic by construction and needs
nobody to remember anything. It is meaningless as a version and is not shown anywhere: versionName
is what a person reads, and it carries MinVer's full answer on this channel including the height —
see the target below.
The fallback exists so that `-p:DodoChannel=nightly` builds outside CI at all, and 1 is deliberately
the lowest it can be: a locally built nightly then cannot replace one installed from the feed, which
is the right way round.
-->
<ApplicationVersion Condition="'$(DodoNightlyVersionCode)' != ''">$(DodoNightlyVersionCode)</ApplicationVersion>
<ApplicationVersion Condition="'$(DodoNightlyVersionCode)' == ''">1</ApplicationVersion>
<!--
◆ THE KEY IS IN THE REPOSITORY, IN THE OPEN, AND THAT IS THE DESIGN RATHER THAN AN OVERSIGHT.
ADR 0011 rule 1 says the release key is never in CI, because a key a workflow can reach is a key held
by everyone who can change a workflow file. This does not break that rule; it sidesteps it by making
the CI key worth nothing. Everybody has it, so holding it grants nothing, so there is nothing to
steal and no secret to configure — which is also why this channel works on a fresh runner with no
setup at all.
What it costs is stated rather than buried: anyone can build an APK this channel's phones will accept
as an update. Reaching one still means being the thing they fetch from, which is a release on
git.dodotech.cloud over TLS — so the practical set is whoever can write a release here, the same set
ADR 0013 names for the desktop. That is an acceptable trade for a nightly and is not one for the
release channel, which is the whole reason there are two.
-->
<AndroidKeyStore>true</AndroidKeyStore>
<AndroidSigningKeyStore>$(MSBuildThisFileDirectory)../../build/nightly.keystore</AndroidSigningKeyStore>
<AndroidSigningKeyAlias>dodossh-nightly</AndroidSigningKeyAlias>
<AndroidSigningStorePass>nightly</AndroidSigningStorePass>
<AndroidSigningKeyPass>nightly</AndroidSigningKeyPass>
</PropertyGroup>
<ItemGroup>
<!--
Which feed this build asks. Assembly metadata rather than a compile-time constant, because the
updater needs the string and not a branch — and because a value baked into the assembly is one a
crash report can be asked for. See AndroidUpdateChannel.
-->
<AssemblyMetadata Include="DodoChannel" Value="$(DodoChannel)" />
</ItemGroup>
<!--
versionName, which is the string a person sees in Android's app info, taken from the same v* tag
as everything else so the two heads cannot claim different versions of one product.
@@ -45,11 +124,41 @@
is still the SDK's default 1.0.0 — an ApplicationDisplayVersion set in a PropertyGroup would read
as though it were derived and would ship 1.0.0 forever. Running after MinVer is what makes it true.
Major.Minor.Patch, without MinVer's prerelease or build-metadata parts: versionName is a free string
to Android, but it is shown to users, and "0.2.0-alpha.0.7+1a2b3c4" is not a version anybody can
read back to you over a support conversation.
Major.Minor.Patch on the release channel, without MinVer's prerelease or build-metadata parts:
versionName is a free string to Android, but it is shown to users, and "0.2.0-alpha.0.7+1a2b3c4" is
not a version anybody can read back to you over a support conversation.
The nightly channel keeps the whole thing, prerelease height and all, and for the opposite reason.
Every nightly between two tags shares one Major.Minor.Patch, so trimming it would put the same three
numbers on every build for weeks — on the one channel where "which build is this?" is the entire
question being asked. Nobody reads a nightly's version aloud; they paste it.
-->
<Target Name="UseTheDerivedVersionForAndroid" AfterTargets="MinVer" DependsOnTargets="MinVer">
<!--
◆ BeforeTargets on the stub generator, and it assigns an underscore-prefixed property. Both are
deliberate, and the shape this replaced looked more correct while shipping 1.0.0 on every build.
ApplicationDisplayVersion is read by Xamarin.Android.Common.targets in a plain top-level
PropertyGroup — `<_AndroidVersionName>$(ApplicationDisplayVersion)</_AndroidVersionName>` — which is
*evaluation*, not a target. Every project property is already final by the time any target runs, so a
target that sets ApplicationDisplayVersion sets it after the only thing that reads it has finished.
MinVer cannot run at evaluation time, so there is no arrangement of the public property that works.
Measured rather than reasoned about: aapt2 dump badging on the packaged APK said versionName='1.0.0'
while `-getProperty:ApplicationDisplayVersion` said 0.0.0-alpha.0.124. The property was right and the
manifest was not, which is exactly the shape of bug a -getProperty check cannot catch.
So this reaches for _AndroidVersionName, which is the value GenerateMainAndroidManifest is actually
handed, at the last moment before _GenerateJavaStubs runs it. An internal name is a real cost and it
is the smaller one: the alternative is passing -p:ApplicationDisplayVersion from CI and from the
release script, which makes an ordinary `dotnet build` of this head go on lying about its version and
puts the number in two places that can disagree.
ApplicationDisplayVersion is still assigned, and first, so that a caller passing it as a global
property wins — a global beats a target assignment, and _AndroidVersionName then follows theirs.
-->
<Target Name="UseTheDerivedVersionForAndroid"
BeforeTargets="_GenerateJavaStubs"
DependsOnTargets="MinVer">
<PropertyGroup>
<!--
Guarded, because the failure without it is silent and absurd: MinVerMajor and its two siblings
@@ -57,7 +166,10 @@
characters that are a legal Android versionName and are what the phone would then show. Measured,
not imagined; invoking this target on its own produced exactly that.
-->
<ApplicationDisplayVersion Condition="'$(MinVerMajor)' != ''">$(MinVerMajor).$(MinVerMinor).$(MinVerPatch)</ApplicationDisplayVersion>
<ApplicationDisplayVersion Condition="'$(MinVerMajor)' != '' and '$(DodoChannel)' != 'nightly'">$(MinVerMajor).$(MinVerMinor).$(MinVerPatch)</ApplicationDisplayVersion>
<ApplicationDisplayVersion Condition="'$(MinVerVersion)' != '' and '$(DodoChannel)' == 'nightly'">$(MinVerVersion)</ApplicationDisplayVersion>
<_AndroidVersionName Condition="'$(ApplicationDisplayVersion)' != ''">$(ApplicationDisplayVersion)</_AndroidVersionName>
</PropertyGroup>
</Target>
+5 -1
View File
@@ -50,7 +50,11 @@ namespace DodoSSH.Client.Android;
/// </para>
/// </remarks>
[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,
@@ -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);
}
}
@@ -18,6 +18,25 @@
<!-- Releases the device key. See AndroidDeviceKeyStore. -->
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<!--
◆ Hands a downloaded APK to the system installer, which is the whole of this head's updater. See
AndroidUpdateChannel and ADR 0014.
It is worth being exact about what this permission is and is not. It does not let this application
install anything: it lets it *ask*, and the platform then shows its own confirmation naming the
package and the source. On API 26 and later it is additionally gated by a per-application setting the
user has to turn on in Settings, which no permission dialogue can grant — so the first update walks
them there. Nothing is installed without two deliberate answers, neither of them to a screen this
application drew.
Declared rather than avoided by opening the release page in a browser. That would work, and it would
move the same install through Chrome's downloads and the same unknown-sources gate with one more step
and no less trust. What it would also do is give up any way of knowing a fix has been fetched, which
for an SSH client holding a team's credentials is the property ADR 0011's consequences call the
sharpest edge of shipping outside a store.
-->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!--
allowBackup and fullBackupContent are both off deliberately, and both are vault properties rather
than defaults worth inheriting. The local cache is a SQLite file holding the ciphertext mirror, the
@@ -32,7 +51,13 @@
that attribute exists so a launcher wanting a circle can be handed a second bitmap, and
an adaptive icon is already masked to whatever shape the launcher asks for.
-->
<application android:label="DodoSSH"
<!--
@string/app_name rather than the literal that was here, because the two channels are two installable
applications and both showing "DodoSSH" under the launcher icon is a home screen nobody can read. The
release channel's copy of that string is exactly what was written here before. See
Resources/values/strings.xml, and MainActivity, which has to name the same resource.
-->
<application android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:theme="@style/DodoTheme"
android:networkSecurityConfig="@xml/network_security_config"
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
The name under the launcher icon, for both channels.
A resource rather than the literal that used to be in the manifest, because the launcher reads the
*activity's* label and that one is written in a C# attribute where no MSBuild property can reach —
so a single @string is the only place the two can be answered at once, and this is the hook if they
ever need different answers.
◆ THEY DO NOT GET DIFFERENT ANSWERS TODAY, AND THAT IS A GIVING-UP RATHER THAN A DECISION. A nightly
installed beside a release puts two identically named icons on the home screen, which is a real if
small defect. Four ways to vary it per channel were tried and none reached the launcher; the findings
are in docs/platform-flags.md so the next attempt starts further along. What tells the two apart in
the meantime is the package name in Android's app info, the version, and the channel the application
names on its own preferences screen.
-->
<string name="app_name">DodoSSH</string>
</resources>
@@ -81,34 +81,77 @@
<TextBlock Classes="body" Margin="6,12,6,0" TextAlignment="Center"
Text="{Binding StatusMessage}" />
<!-- ============ ◆ which build this is ============ -->
<!-- ============ ◆ updates ============ -->
<!--
A fact, printed, in the shape ACCOUNT uses on the desktop's screen — and the same string the desktop
prints under UPDATES, read off the same place. It comes from the assembly's own informational
version, so it answers on a build run from a checkout as well as on a released one; see
ClientVersion.Current.
The phone's copy of the desktop's UPDATES section, over the same view model. What differs is the
last step and only the last step: pressing INSTALL hands the package to Android's own installer
rather than swapping files, so the platform draws a confirmation this application does not control
and the answer may be no. Everything before that — the timer, the quiet background pass, the loud
pressed one, the fetch — is shared. See AndroidUpdateChannel and ADR 0014.
It is worth a row of its own on the head that has no updater. On the desktop the version sits beside
a CHECK NOW that will tell you whether it is current; here it is the only answer, and it is the one
thing to read out when somebody asks which build is misbehaving. The sentence under it says where a
newer one comes from, which is ADR 0011 rule 2 in plain words: never from the server you sign in to.
The version is a fact, printed, in the shape ACCOUNT uses on the desktop. It comes from the
assembly's own informational version rather than from the updater, so it answers on a build run
from a checkout too; see ClientVersion.Current.
Bound through Updates, which is the shell's update view model and exists on this head too — over the
null channel, so it reports itself unsupported and its loop never starts. That is why there is no
button here rather than a disabled one. See ADR 0013.
The sentence about where builds come from is ADR 0011 rule 2 in plain words, and it is on the head
the rule was written for: no DodoSSH server will ever offer you the client, and one that does is not
one to trust.
-->
<TextBlock Classes="section" Text="THIS BUILD" Margin="6,26,0,0" />
<TextBlock Classes="section" Text="UPDATES" Margin="6,26,0,0" />
<Border Classes="card" Margin="0,10,0,0">
<StackPanel Spacing="10">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Classes="label" Text="VERSION" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" FontSize="13" Margin="10,0,0,0"
HorizontalAlignment="Right" Text="{Binding Updates.CurrentVersion}" />
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis"
Text="{Binding Updates.CurrentVersion}" />
</Grid>
<StackPanel Spacing="10" IsVisible="{Binding Updates.IsSupported}">
<CheckBox MinHeight="44" IsChecked="{Binding Updates.IsAutomatic}">
<TextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
Text="Look for newer builds on their own" />
</CheckBox>
<TextBlock Classes="body"
Text="Checks come from the project's own release page and never from the server you sign in to. Nothing is installed without you saying so, and Android asks again in its own words before anything is replaced." />
<!--
The bar, copying the desktop's and the transfers screen's. Shown while fetching and not
before: a bar at zero beside a button nobody has pressed is a screen that looks busy.
-->
<ProgressBar IsVisible="{Binding Updates.IsDownloading}" Height="4"
Minimum="0" Maximum="100" Value="{Binding Updates.DownloadPercent}" />
<Button Classes="secondary" Height="44" Content="CHECK NOW"
Command="{Binding Updates.CheckNowCommand}"
IsEnabled="{Binding Updates.CanCheckNow}" />
<!--
INSTALL rather than the desktop's RESTART NOW, and the word is the honest one: this does not
restart anything. It asks Android to install, and Android asks the user. What follows is the
system's screen, not this one.
-->
<Button Classes="primary" Height="44" Content="INSTALL"
IsVisible="{Binding Updates.IsReady}"
Command="{Binding Updates.RestartNowCommand}" />
<TextBlock Classes="body" IsVisible="{Binding Updates.IsReady}"
Text="{Binding Updates.RestartWarning}" />
<TextBlock Classes="detail" TextWrapping="Wrap" Text="{Binding Updates.Status}"
IsVisible="{Binding Updates.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
<!--
The build that cannot replace itself, which on this head is one an IDE deployed or one built
from a checkout — see AndroidUpdateChannels.ForThisPhone. Said rather than shown as dead
controls, following HasNoDeviceKeyOption above.
-->
<TextBlock Classes="body" IsVisible="{Binding Updates.IsUnsupported}"
Text="This head does not replace itself. A newer DodoSSH is an APK from the project's own release page, installed the way this one was — and no DodoSSH server will ever offer you one, whatever it says. A server that does is not a server to trust." />
Text="This build cannot replace itself — it was not installed from the project's release page. A newer DodoSSH is an APK from there, installed the way a released one would be. No DodoSSH server will ever offer you one, whatever it says; a server that does is not a server to trust." />
</StackPanel>
</Border>