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);
}
}