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; /// /// What a channel's feed publishes beside its APK. /// /// /// /// The Android counterpart of releases.win.json, 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. /// /// /// The comparison is and never the name. 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 0.2.0-alpha.0.7 is a parser nobody here /// should be writing. The name is for the person reading the banner and decides nothing. /// /// /// Android's own monotonic integer for the published build. /// What that build calls itself, for a human. /// The asset on the same release that holds it. internal sealed record AndroidChannelManifest( [property: JsonPropertyName("versionCode")] long VersionCode, [property: JsonPropertyName("versionName")] string VersionName, [property: JsonPropertyName("apk")] string Apk); /// One release as the forge describes it. Only the assets are read. internal sealed record ForgeRelease( [property: JsonPropertyName("assets")] IReadOnlyList? Assets); /// One file attached to a release. 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; /// /// Replaces this phone's copy of DodoSSH with a newer one from the project's own forge. /// /// /// /// The feed is a constant and the deployment is never asked. 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. /// /// /// Which release it asks depends on how this build was made. The release channel takes the newest /// non-prerelease; the nightly channel takes the release tagged nightly, 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. /// /// /// Nothing is installed by this class. 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. /// /// internal sealed class AndroidUpdateChannel : IUpdateChannel { /// The project's own forge, and the one address in this file. /// /// ◆ The owner is DodoTech-Public and that half of the path matters. The repository was moved /// between organisations, and Gitea leaves a 301 at the old one — so a client still naming it looks /// fine, because HttpClient follows a redirect on a GET. What it buys is a dependency on a /// redirect somebody can delete, and it does not extend to the release scripts, whose uploads are /// POSTs. The live path is named here, in VelopackUpdateChannel and in both scripts. /// private const string RepositoryApi = "https://git.dodotech.cloud/api/v1/repos/DodoTech-Public/DodoSSH"; /// /// The session name the installer writes under, and it is reused rather than made unique. /// /// /// 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. /// private const string InstallSession = "dodossh-update"; private readonly Context context; private readonly string channel; private readonly HttpClient http; /// What the last successful check found, kept so the download knows where to look. /// /// The seam only carries a version string — see — 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. /// private (string Version, string Url)? found; /// Where the fetched APK is, once there is one. 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); } /// public bool IsSupported => true; /// /// Never, on this head. /// /// /// 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. /// public bool ApplyingEndsTheProcess => false; /// public string CurrentVersion { get; } /// What Android thinks is installed, which is the number the comparison is made on. private long InstalledVersionCode { get; } /// /// Asks the channel's release what it is publishing. /// /// /// /// ◆ Nothing here is caught, and it used to catch everything. Every network and parse failure /// resolved to null on the argument that an unreachable forge is a phone on a train — which is a good /// argument for the timer and no argument at all for the button. UpdateViewModel already draws /// that line correctly: a background pass swallows and goes back to Idle, a pressed CHECK NOW reports /// the message. Swallowing here took the second half away and answered every failure with "you are on /// the latest build". /// /// /// What that hid was the whole feature not working. The release repository was private, so every /// request answered 404 — Gitea does not distinguish "not there" from "not yours" — and every phone /// reported itself current. The check had never once succeeded and nothing anywhere said so. The feed /// has to be readable without credentials; see the interface, which now records that as a precondition. /// /// /// A release that is reachable but missing the manifest or the APK it names throws too, and /// that is deliberate rather than incidental. It means CI published half a release, which is a fact /// worth an answer — "you are up to date" about a broken feed is the same lie in a smaller costume. The /// self-healing that argument protected is unaffected: the timer swallows it, so a half-published /// release still fixes itself without anybody being told. /// /// /// The forge could not be reached, or refused. /// The release is there and does not carry a usable build. public async Task CheckAsync(CancellationToken cancellationToken) { var release = await ReadAsync(ReleaseUrl(), ForgeJsonContext.Default.ForgeRelease, cancellationToken) .ConfigureAwait(false); if (Asset(release, $"android-{channel}.json") is not { } manifestAsset) { throw new InvalidOperationException( $"The {channel} release carries no android-{channel}.json, so there is nothing saying " + "what it publishes."); } var manifest = await ReadAsync( manifestAsset, ForgeJsonContext.Default.AndroidChannelManifest, cancellationToken) .ConfigureAwait(false); if (manifest is null) { throw new InvalidOperationException( $"The {channel} release's android-{channel}.json could not be read as a manifest."); } // The one place null is returned, and it means what null is documented to mean: this build is // current. Compared on the version code because that is the number Android itself accepts or // refuses an install on — see AndroidChannelManifest. if (manifest.VersionCode <= InstalledVersionCode) { return null; } if (Asset(release, manifest.Apk) is not { } apk) { throw new InvalidOperationException( $"The {channel} release advertises {manifest.VersionName} but carries no {manifest.Apk}."); } found = (manifest.VersionName, apk); return new AvailableUpdate(manifest.VersionName); } /// public async Task DownloadAsync( AvailableUpdate update, IProgress 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; } /// /// Asks Android to install what was fetched. /// /// /// /// This returns, unlike the desktop's. 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. /// /// /// The unknown-sources gate comes first, and it is not an error. 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. /// /// /// A PackageInstaller session rather than an ACTION_VIEW intent over a /// content:// URI. The intent form needs a FileProvider, 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. /// /// /// ◆ The pending intent handed to Commit is not a notification, and treating it as one is /// why this silently installed nothing. An application holding REQUEST_INSTALL_PACKAGES /// rather than the privileged INSTALL_PACKAGES never gets a decision back from a commit: what /// the platform sends first is STATUS_PENDING_USER_ACTION, carrying — in /// Intent.EXTRA_INTENT — the activity that draws the confirmation. Android does not draw it on /// its own. Something has to receive that broadcast and start it, and for the life of this feature /// nothing did: the intent named a bare action string with no receiver behind it, so the session was /// written, committed, and left staged forever while the user watched nothing happen. See /// , which is that something, and note that the intent naming it /// must be explicit — a mutable pending intent wrapping an implicit one is refused outright from /// API 34, which is where this build's target sits. /// /// 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; } HandToTheInstaller(packages.PackageInstaller, path); } /// Writes the fetched APK into a session and commits it. /// /// The session is abandoned on any failure, which is not tidiness. One that is created and neither /// committed nor abandoned stays staged, holding the space its SetSize reserved, and Android /// caps how many an application may have open at once — so a fault that repeats, which is exactly /// what a broken updater is, ends up unable to create a session at all. That would be a second and /// wholly unrelated symptom for the same cause, and the more confusing of the two. /// private void HandToTheInstaller(PackageInstaller installer, string path) { var parameters = new PackageInstaller.SessionParams(PackageInstallMode.FullInstall); var length = new FileInfo(path).Length; parameters.SetSize(length); var id = installer.CreateSession(parameters); try { 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); } // Mutable is required from API 31 — the installer fills the status 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; // Named at the receiver class rather than carrying an action string, and both halves of that // matter. An explicit intent is the only kind a mutable pending intent may wrap from API 34, // so the implicit one that was here throws before commit is even reached on any current // phone; and a broadcast with a receiver behind it is what gets the confirmation drawn at // all. See the remarks above. var callback = PendingIntent.GetBroadcast( context, 0, new Intent(context, typeof(InstallSessionReceiver)), flags); session.Commit(callback!.IntentSender!); } catch { try { installer.AbandonSession(id); } catch (Java.Lang.Throwable) { // The session could not be abandoned either. Whatever went wrong first is the useful // half of that, so it is the one allowed to propagate — a cleanup failure thrown from // here would replace the cause with a consequence. } throw; } } /// Which release this build's channel reads. /// /// releases/latest 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. /// 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 ReadAsync( string url, JsonTypeInfo 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); } } /// What Android records for the installed package, which is the number a newer build must beat. /// /// 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. /// 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); } } /// /// Starts the confirmation Android hands back when an install session is committed. /// /// /// /// This is the whole of "the platform draws the confirmation". It does, but only once asked: a /// commit by an application with REQUEST_INSTALL_PACKAGES answers /// STATUS_PENDING_USER_ACTION and puts the activity that draws the dialogue in /// Intent.EXTRA_INTENT, for the application to start. Nothing else in the system will start it. /// Without a receiver the commit still succeeds, the session still stages, and the update simply never /// arrives — which is precisely how this shipped. /// /// /// Every other status is ignored on purpose, and that part of the original reasoning survives: the two /// outcomes worth knowing are this process being replaced and this process carrying on, and both are /// already visible without being told. A failure the user caused by declining is not an error, and a /// failure Android caused says so in its own dialogue. /// /// /// Not exported. The only sender is the pending intent this application handed to its own commit, which /// the system delivers under this application's identity — so nothing outside needs to reach it, and an /// exported receiver that starts an activity out of an extra is a component anybody could use to launch /// an arbitrary screen with this application's package on it. /// /// [BroadcastReceiver(Enabled = true, Exported = false)] internal sealed class InstallSessionReceiver : BroadcastReceiver { /// public override void OnReceive(Context? context, Intent? intent) { if (context is null || intent is null) { return; } // Defaulted to a failure rather than to the pending value, so a broadcast arriving without a // status is treated as nothing to do instead of as a reason to go looking for an intent. var status = intent.GetIntExtra( PackageInstaller.ExtraStatus, (int)PackageInstallStatus.Failure); if (status != (int)PackageInstallStatus.PendingUserAction) { return; } if (ConfirmationIn(intent) is not { } confirmation) { return; } // The activity if there is one, and the application context otherwise with a task of its own — // the same choice, for the same reason, as SendToTheUnknownSourcesScreen above. A receiver's own // context cannot start an activity without the flag, and the update loop can perfectly well have // committed this while the application was backgrounded. if (PhoneEnvironment.CurrentActivity is { } activity) { activity.StartActivity(confirmation); return; } confirmation.AddFlags(ActivityFlags.NewTask); context.StartActivity(confirmation); } /// The activity Android wants started, read the way the running platform allows. /// /// The untyped overload is obsolete from API 33 and the typed one does not exist below it, so both /// are here behind the guard the analyser reads. The same shape as the pending-intent flags in /// , and for the same reason: minSdk is 28, so both branches run /// on phones this ships to. /// private static Intent? ConfirmationIn(Intent intent) { if (OperatingSystem.IsAndroidVersionAtLeast(33)) { return intent.GetParcelableExtra( Intent.ExtraIntent, Java.Lang.Class.FromType(typeof(Intent))) as Intent; } return intent.GetParcelableExtra(Intent.ExtraIntent) as Intent; } } /// /// Picks the update channel this copy of the phone head gets. /// /// /// The counterpart of the desktop's UpdateChannels.ForThisMachine, and it answers the same question /// about the same thing: was this copy installed by something that knows how to replace it. /// internal static class AndroidUpdateChannels { /// /// The channel for this build, or one that reports itself unavailable. /// /// /// /// 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 dotnet build. /// 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. /// /// /// Deliberately not 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 AndroidUpdateChannel.ApplyAndRestart, which walks them there. /// /// internal static IUpdateChannel ForThisPhone(HttpClient http) { var context = PhoneEnvironment.Require(); var channel = typeof(AndroidUpdateChannels).Assembly .GetCustomAttributes() .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); } }