Public Access
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8591035170 | ||
|
|
ca7fee2358 |
@@ -84,6 +84,18 @@ losing the local cache, the outbox and the device key.
|
|||||||
turned this application on in the unknown-sources screen. Two deliberate answers, neither to a screen
|
turned this application on in the unknown-sources screen. Two deliberate answers, neither to a screen
|
||||||
DodoSSH controls.
|
DodoSSH controls.
|
||||||
|
|
||||||
|
◆ "Asks Android to ask" is one step longer than it sounds, and reading it as one step is why this
|
||||||
|
installed nothing at all from the day it shipped until 2026-08-05. An application holding only
|
||||||
|
`REQUEST_INSTALL_PACKAGES` gets no verdict back from committing a session: what the platform returns
|
||||||
|
first is `STATUS_PENDING_USER_ACTION`, carrying the confirmation activity in `Intent.EXTRA_INTENT` for
|
||||||
|
the application to start. **Android does not draw the dialogue on its own.** The commit succeeded, the
|
||||||
|
session staged, and nobody was ever asked anything. The receiver that starts it is
|
||||||
|
`InstallSessionReceiver`; the intent naming it has to be explicit, since a mutable pending intent
|
||||||
|
wrapping an implicit one is refused outright from API 34. Check 17.5 asks for exactly the right thing
|
||||||
|
— "Android's own installer appears naming the package" — so nothing needed rewording; it wants an
|
||||||
|
older build installed and a newer one published to run at all, and on the evidence it was never run
|
||||||
|
against a real pair.
|
||||||
|
|
||||||
7. **Applying does not end the process, and the shell had to learn that.** On Windows, applying replaces
|
7. **Applying does not end the process, and the shell had to learn that.** On Windows, applying replaces
|
||||||
the files and restarts, so the shell disposes the vault first — that is what zeroes the identity keys,
|
the files and restarts, so the shell disposes the vault first — that is what zeroes the identity keys,
|
||||||
the vault keys and the cache key. On Android the install is a *request* and the answer may be no, so
|
the vault keys and the cache key. On Android the install is a *request* and the answer may be no, so
|
||||||
|
|||||||
@@ -2091,6 +2091,14 @@ around being broken. An `INSTALL_FAILED_UPDATE_INCOMPATIBLE` means the two build
|
|||||||
keys — on the nightly channel that means the committed keystore changed, and on the release channel it means
|
keys — on the nightly channel that means the committed keystore changed, and on the release channel it means
|
||||||
the wrong keystore was used.
|
the wrong keystore was used.
|
||||||
|
|
||||||
|
◆ Two failures worth naming separately, because both look like "the button does nothing" and neither is a
|
||||||
|
signing problem. **The application closes when INSTALL is pressed** — that is an exception escaping the
|
||||||
|
command handler, and the message it should have shown is now the Failed line on this screen. **Nothing
|
||||||
|
happens at all, and the application carries on** — Android was asked to install and nobody started the
|
||||||
|
confirmation it handed back; see `InstallSessionReceiver` and the ◆ note in ADR 0014 rule 6. This check is
|
||||||
|
the only thing in the project that can catch either, which is the argument for running it on a real pair of
|
||||||
|
builds rather than reasoning about it.
|
||||||
|
|
||||||
### 17.6 Declining leaves a working session · **the one that would be missed**
|
### 17.6 Declining leaves a working session · **the one that would be missed**
|
||||||
|
|
||||||
Repeat 17.5 to the point where Android's installer is on screen, with a terminal open and the vault
|
Repeat 17.5 to the point where Android's installer is on screen, with a terminal open and the vault
|
||||||
|
|||||||
@@ -298,6 +298,19 @@ internal sealed class AndroidUpdateChannel : IUpdateChannel
|
|||||||
/// and a grant on every launch, all so the installer can read a file this application already has
|
/// 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.
|
/// open — where a session hands it the bytes directly.
|
||||||
/// </para>
|
/// </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>
|
/// </remarks>
|
||||||
public void ApplyAndRestart(AvailableUpdate update)
|
public void ApplyAndRestart(AvailableUpdate update)
|
||||||
{
|
{
|
||||||
@@ -318,7 +331,19 @@ internal sealed class AndroidUpdateChannel : IUpdateChannel
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var installer = packages.PackageInstaller;
|
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 parameters = new PackageInstaller.SessionParams(PackageInstallMode.FullInstall);
|
||||||
var length = new FileInfo(path).Length;
|
var length = new FileInfo(path).Length;
|
||||||
|
|
||||||
@@ -326,8 +351,10 @@ internal sealed class AndroidUpdateChannel : IUpdateChannel
|
|||||||
|
|
||||||
var id = installer.CreateSession(parameters);
|
var id = installer.CreateSession(parameters);
|
||||||
|
|
||||||
using (var session = installer.OpenSession(id))
|
try
|
||||||
{
|
{
|
||||||
|
using var session = installer.OpenSession(id);
|
||||||
|
|
||||||
using (var destination = session.OpenWrite(InstallSession, 0, length))
|
using (var destination = session.OpenWrite(InstallSession, 0, length))
|
||||||
using (var source = File.OpenRead(path))
|
using (var source = File.OpenRead(path))
|
||||||
{
|
{
|
||||||
@@ -339,20 +366,41 @@ internal sealed class AndroidUpdateChannel : IUpdateChannel
|
|||||||
session.Fsync(destination);
|
session.Fsync(destination);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A pending intent is how the platform reports what the user decided, and one is required
|
// Mutable is required from API 31 — the installer fills the status in — and does not exist
|
||||||
// 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
|
// 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.
|
// against the older platform. minSdk here is 28, so both cases are real.
|
||||||
var flags = OperatingSystem.IsAndroidVersionAtLeast(31)
|
var flags = OperatingSystem.IsAndroidVersionAtLeast(31)
|
||||||
? PendingIntentFlags.Mutable | PendingIntentFlags.UpdateCurrent
|
? PendingIntentFlags.Mutable | PendingIntentFlags.UpdateCurrent
|
||||||
: PendingIntentFlags.UpdateCurrent;
|
: PendingIntentFlags.UpdateCurrent;
|
||||||
|
|
||||||
var callback = PendingIntent.GetBroadcast(context, 0, new Intent(InstallSession), flags);
|
// 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!);
|
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>
|
/// <summary>Which release this build's channel reads.</summary>
|
||||||
@@ -432,6 +480,93 @@ internal sealed class AndroidUpdateChannel : IUpdateChannel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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>
|
/// <summary>
|
||||||
/// Picks the update channel this copy of the phone head gets.
|
/// Picks the update channel this copy of the phone head gets.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -397,7 +397,22 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await restart(update).ConfigureAwait(true);
|
// ◆ Reported rather than allowed to escape, and the phone is why. Where applying ends the process
|
||||||
|
// a throw here is nearly unreachable; where it does not — see ApplyingEndsTheProcess — the whole
|
||||||
|
// point is that this returns and the application carries on, so an exception has somewhere to go:
|
||||||
|
// out of a command handler, past the dispatcher, and into the process. What that looks like to
|
||||||
|
// somebody holding the phone is pressing INSTALL and having the application close, with nothing
|
||||||
|
// installed and nothing said. Failed and the message is the same answer CheckNowAsync gives, and
|
||||||
|
// it is the difference between a bug that describes itself and one that presents as a crash.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await restart(update).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
State = UpdateState.Failed;
|
||||||
|
Status = exception.Message;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ internal sealed class FakeUpdateChannel : IUpdateChannel
|
|||||||
/// <summary>When set, downloading throws it.</summary>
|
/// <summary>When set, downloading throws it.</summary>
|
||||||
internal Exception? DownloadFailure { get; set; }
|
internal Exception? DownloadFailure { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When set, applying throws it — after counting, since the call was still made.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The phone's install can fail on the spot rather than by ending the process: a session the platform
|
||||||
|
/// refuses, or an unknown-sources answer that changed under it. What that must not do is escape a
|
||||||
|
/// command handler, so this is what lets a test hold the view model to reporting it.
|
||||||
|
/// </remarks>
|
||||||
|
internal Exception? RestartFailure { get; set; }
|
||||||
|
|
||||||
/// <summary>When set, a download waits on it before completing.</summary>
|
/// <summary>When set, a download waits on it before completing.</summary>
|
||||||
internal TaskCompletionSource? HoldDownload { get; set; }
|
internal TaskCompletionSource? HoldDownload { get; set; }
|
||||||
|
|
||||||
@@ -97,5 +105,10 @@ internal sealed class FakeUpdateChannel : IUpdateChannel
|
|||||||
{
|
{
|
||||||
Restarts++;
|
Restarts++;
|
||||||
RestartedWith = update;
|
RestartedWith = update;
|
||||||
|
|
||||||
|
if (RestartFailure is { } failure)
|
||||||
|
{
|
||||||
|
throw failure;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,31 @@ public sealed class UpdateFlowTests : IDisposable
|
|||||||
channel.RestartedWith!.Version.ShouldBe("1.3.0");
|
channel.RestartedWith!.Version.ShouldBe("1.3.0");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// ◆ The regression for an install that closed the application instead of installing anything. On the
|
||||||
|
/// phone applying is a request that returns, so a failure in it throws on the spot rather than being
|
||||||
|
/// cut short by the process ending — and with nothing catching it, it left a command handler, passed
|
||||||
|
/// the dispatcher and took the process with it. Pressing INSTALL closed the application, said nothing,
|
||||||
|
/// and installed nothing, which reads as anything but a bug in the updater.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AnInstallThatFails_IsReportedRatherThanThrown()
|
||||||
|
{
|
||||||
|
channel.Available = new AvailableUpdate("1.3.0");
|
||||||
|
channel.ApplyingEndsTheProcess = false;
|
||||||
|
channel.RestartFailure = new InvalidOperationException("The installer refused the session.");
|
||||||
|
|
||||||
|
var updates = Build();
|
||||||
|
|
||||||
|
await updates.CheckOnceAsync(Token);
|
||||||
|
|
||||||
|
// Not Should.ThrowAsync: the assertion is that this does not throw at all.
|
||||||
|
await updates.RestartNowCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
updates.State.ShouldBe(UpdateState.Failed);
|
||||||
|
updates.Status.ShouldBe("The installer refused the session.");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RestartingWithNothingReady_DoesNothing()
|
public async Task RestartingWithNothingReady_DoesNothing()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user