Files
DodoSSH/src/DodoSSH.Client.Android/Platform/AndroidUpdateChannel.cs
T
jaap-jan ca7fee2358 Start the confirmation Android hands back, so an update can install
Pressing INSTALL closed the application, installed nothing and said nothing. That
is two independent faults in one method, either of which breaks it on its own,
and they hid each other: the first kills the process before the second can be
observed, and the second is silent by construction.

The pending intent handed to commit was implicit — an action string with no
component behind it. A mutable pending intent may not wrap one of those from API
34, and this head targets 36, so every current phone threw IllegalArgumentException
before commit was reached. Nothing caught it, so it left the command handler,
passed the dispatcher and took the process with it. That is the closing.

Below 34, where it did not throw, it still installed nothing. An application
holding REQUEST_INSTALL_PACKAGES rather than the privileged INSTALL_PACKAGES gets
no verdict back from a commit: what the platform answers first is
STATUS_PENDING_USER_ACTION, carrying the activity that draws the dialogue in
EXTRA_INTENT for the application to start. Android does not draw it on its own.
The comment here asserted the opposite — that a pending intent is required whether
or not anything listens, and that nothing needed to — so no receiver was ever
written, and the session was written, committed and left staged forever.

So there is a receiver now, not exported because the only sender is this
application's own commit, and the intent naming it is explicit, which is the same
change that stops the throw. Sessions are abandoned when anything fails, since one
created and neither committed nor abandoned stays staged against a per-application
cap — a repeating fault would have started failing at CreateSession instead, which
is the same bug wearing a completely unrelated face.

The reporting is the part worth keeping even after the cause is gone. Where
applying ends the process an exception has nowhere to go; where it does not, which
is this head's whole shape, it goes out through the dispatcher. RestartNowAsync now
answers the way CheckNowAsync already did, and the regression test asserts the
absence of a throw rather than the presence of one.

ADR 0014 rule 6 gets the correction in place: "asks Android to ask" is one step
longer than it reads. Check 17.5 needed no rewording — it asks for the installer
appearing by name, which is exactly the thing that never happened — so what it
gets instead is the two symptoms named, because both present as a dead button. It
is the only thing in the project that can catch either, and it plainly was never
run against a real pair of builds.

Note for whoever takes the next nightly: a broken updater cannot install its own
fix. The phone is running the code this commit replaces, so the first build
carrying it has to be sideloaded by hand; the ones after that install normally.

Compile-verified and manifest-verified — the receiver reaches the generated
manifest — and 321 tests pass. Not run on a device, which is what 17.5 is for.
2026-08-05 22:31:33 +02:00

617 lines
28 KiB
C#

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>
/// <remarks>
/// ◆ The owner is <c>DodoTech-Public</c> 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 <c>HttpClient</c> 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 <c>VelopackUpdateChannel</c> and in both scripts.
/// </remarks>
private const string RepositoryApi = "https://git.dodotech.cloud/api/v1/repos/DodoTech-Public/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; }
/// <summary>
/// Asks the channel's release what it is publishing.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>Nothing here is caught, and it used to catch everything.</b> 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. <c>UpdateViewModel</c> 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".
/// </para>
/// <para>
/// <b>What that hid was the whole feature not working.</b> 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.
/// </para>
/// <para>
/// A release that is <em>reachable</em> 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.
/// </para>
/// </remarks>
/// <exception cref="HttpRequestException">The forge could not be reached, or refused.</exception>
/// <exception cref="InvalidOperationException">The release is there and does not carry a usable build.</exception>
public async Task<AvailableUpdate?> 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);
}
/// <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>
/// <para>
/// ◆ <b>The pending intent handed to <c>Commit</c> is not a notification, and treating it as one is
/// why this silently installed nothing.</b> An application holding <c>REQUEST_INSTALL_PACKAGES</c>
/// rather than the privileged <c>INSTALL_PACKAGES</c> never gets a decision back from a commit: what
/// the platform sends first is <c>STATUS_PENDING_USER_ACTION</c>, carrying — in
/// <c>Intent.EXTRA_INTENT</c> — 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
/// <see cref="InstallSessionReceiver"/>, 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.
/// </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;
}
HandToTheInstaller(packages.PackageInstaller, path);
}
/// <summary>Writes the fetched APK into a session and commits it.</summary>
/// <remarks>
/// 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 <c>SetSize</c> 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.
/// </remarks>
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;
}
}
/// <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>
/// Starts the confirmation Android hands back when an install session is committed.
/// </summary>
/// <remarks>
/// <para>
/// <b>This is the whole of "the platform draws the confirmation".</b> It does, but only once asked: a
/// commit by an application with <c>REQUEST_INSTALL_PACKAGES</c> answers
/// <c>STATUS_PENDING_USER_ACTION</c> and puts the activity that draws the dialogue in
/// <c>Intent.EXTRA_INTENT</c>, 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[BroadcastReceiver(Enabled = true, Exported = false)]
internal sealed class InstallSessionReceiver : BroadcastReceiver
{
/// <inheritdoc />
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);
}
/// <summary>The activity Android wants started, read the way the running platform allows.</summary>
/// <remarks>
/// 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
/// <see cref="AndroidUpdateChannel"/>, and for the same reason: minSdk is 28, so both branches run
/// on phones this ships to.
/// </remarks>
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;
}
}
/// <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);
}
}