Public Access
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.
115 lines
4.0 KiB
C#
115 lines
4.0 KiB
C#
using DodoSSH.Client.Session;
|
|
|
|
namespace DodoSSH.Client.App.Tests;
|
|
|
|
/// <summary>
|
|
/// A release channel that answers whatever the test tells it to.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b><see cref="ApplyAndRestart"/> records the call instead of making it, and that is the whole reason
|
|
/// <c>IUpdateChannel</c> exists as an interface.</b> The real one replaces the running process and never
|
|
/// returns, so a test could not observe it at all — and the single most important thing to be able to
|
|
/// assert about this feature is a negative: that a downloaded update is <em>never</em> applied unless
|
|
/// somebody pressed the button. A counter is what makes that assertion writable.
|
|
/// </para>
|
|
/// <para>
|
|
/// <see cref="HoldDownload"/> exists for the same reason in the other direction: without a way to stop a
|
|
/// download halfway, the Downloading state would be a state no test could ever catch the view model in.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed class FakeUpdateChannel : IUpdateChannel
|
|
{
|
|
/// <summary>What the next check finds. Null means this build is current.</summary>
|
|
internal AvailableUpdate? Available { get; set; }
|
|
|
|
/// <summary>When set, checking throws it.</summary>
|
|
internal Exception? CheckFailure { get; set; }
|
|
|
|
/// <summary>When set, downloading throws it.</summary>
|
|
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>
|
|
internal TaskCompletionSource? HoldDownload { get; set; }
|
|
|
|
/// <summary>The percentages a download reports on its way through.</summary>
|
|
internal IReadOnlyList<int> ProgressSteps { get; set; } = [25, 50, 100];
|
|
|
|
internal int Checks { get; private set; }
|
|
|
|
internal int Downloads { get; private set; }
|
|
|
|
internal int Restarts { get; private set; }
|
|
|
|
internal AvailableUpdate? RestartedWith { get; private set; }
|
|
|
|
/// <inheritdoc />
|
|
public bool IsSupported { get; set; } = true;
|
|
|
|
/// <summary>Settable, because the two heads answer it differently and both paths need driving.</summary>
|
|
/// <remarks>
|
|
/// True is the desktop's: the shell disposes itself before applying. False is the phone's, where the
|
|
/// install can be declined — and the test that matters is that declining leaves a working session
|
|
/// rather than a torn-down one.
|
|
/// </remarks>
|
|
public bool ApplyingEndsTheProcess { get; set; } = true;
|
|
|
|
/// <inheritdoc />
|
|
public string CurrentVersion { get; set; } = "1.0.0";
|
|
|
|
/// <inheritdoc />
|
|
public Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken)
|
|
{
|
|
Checks++;
|
|
|
|
return CheckFailure is { } failure
|
|
? Task.FromException<AvailableUpdate?>(failure)
|
|
: Task.FromResult(Available);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DownloadAsync(
|
|
AvailableUpdate update,
|
|
IProgress<int> progress,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
Downloads++;
|
|
|
|
if (DownloadFailure is { } failure)
|
|
{
|
|
throw failure;
|
|
}
|
|
|
|
if (HoldDownload is { } gate)
|
|
{
|
|
await gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
foreach (var percent in ProgressSteps)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
progress.Report(percent);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void ApplyAndRestart(AvailableUpdate update)
|
|
{
|
|
Restarts++;
|
|
RestartedWith = update;
|
|
|
|
if (RestartFailure is { } failure)
|
|
{
|
|
throw failure;
|
|
}
|
|
}
|
|
}
|