feat(postit): keep Android SignalR chat in background without ongoing notification
Share the chat transport between UI, calls and an opt-in background service. Restore account rooms and attempt boot restart, keeping event alerts independent from connection permissions. Document Android limits and add lifecycle regressions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
a39c3912ea
commit
311d55dcc6
32 changed files with 2210 additions and 143 deletions
80
src/PostIt/PostIt.Android/Tests/ChatServiceEpochTests.cs
Normal file
80
src/PostIt/PostIt.Android/Tests/ChatServiceEpochTests.cs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
#if DEBUG
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PostIt.Android.Services;
|
||||
|
||||
namespace PostIt.Android.Tests;
|
||||
|
||||
/// <summary>Platform-free deterministic launch/disable races; also invoked by the Android debug activity.</summary>
|
||||
internal static class ChatServiceEpochTests
|
||||
{
|
||||
internal static async Task VerifyAsync()
|
||||
{
|
||||
foreach (var reason in new[] { "Sync(false)", "logout", "saved opt-out" })
|
||||
{
|
||||
var policy = new ChatServiceEpoch();
|
||||
var queued = policy.Begin();
|
||||
var stopped = 0;
|
||||
policy.Invalidate(() => stopped++);
|
||||
Require(!policy.Run(queued, () => throw new Exception("Stale launch executed")), reason);
|
||||
Require(policy.Restore() is null, reason + " restored in same process");
|
||||
Require(stopped == 1, reason + " did not stop synchronously");
|
||||
}
|
||||
|
||||
var owner = new ChatServiceEpoch();
|
||||
var old = owner.Begin();
|
||||
owner.Invalidate(() => { });
|
||||
var newer = owner.Begin();
|
||||
var laterStopped = false;
|
||||
Require(!owner.Cancel(old, () => laterStopped = true), "old timeout cancelled later enable");
|
||||
Require(!laterStopped && owner.IsCurrent(newer), "new generation lost");
|
||||
Require(owner.Cancel(newer, () => laterStopped = true), "current cancellation not applied");
|
||||
Require(laterStopped && !owner.IsCurrent(newer), "timed-out generation still launchable");
|
||||
Require(!owner.Run(newer, () => throw new Exception("Timed-out intent executed")), "timeout leak");
|
||||
|
||||
var firstProcess = new ChatServiceEpoch();
|
||||
var staleIntent = firstProcess.Begin();
|
||||
var freshProcess = new ChatServiceEpoch();
|
||||
var restored = freshProcess.Restore();
|
||||
Require(restored is not null && freshProcess.IsCurrent(restored), "fresh boot restore failed");
|
||||
Require(!freshProcess.IsCurrent(staleIntent), "previous-process intent accepted");
|
||||
|
||||
var queuedPolicy = new ChatServiceEpoch();
|
||||
var beforeSemaphore = queuedPolicy.Begin();
|
||||
using var release = new SemaphoreSlim(0, 1);
|
||||
var launched = false;
|
||||
var delayedLaunch = Task.Run(async () =>
|
||||
{
|
||||
await release.WaitAsync();
|
||||
queuedPolicy.Run(beforeSemaphore, () => launched = true);
|
||||
});
|
||||
queuedPolicy.Invalidate(() => { });
|
||||
release.Release();
|
||||
await delayedLaunch;
|
||||
Require(!launched, "queued semaphore/permission continuation launched after disable");
|
||||
|
||||
var concurrent = new ChatServiceEpoch();
|
||||
var concurrentEpoch = concurrent.Begin();
|
||||
var nativeLaunch = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var finishLaunch = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var launch = Task.Run(() => concurrent.Run(concurrentEpoch, () =>
|
||||
{
|
||||
nativeLaunch.TrySetResult();
|
||||
finishLaunch.Task.GetAwaiter().GetResult();
|
||||
}));
|
||||
await nativeLaunch.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
var stop = Task.Run(() => concurrent.Invalidate(() => { }));
|
||||
finishLaunch.TrySetResult();
|
||||
await Task.WhenAll(launch, stop).WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Require(!concurrent.IsCurrent(concurrentEpoch), "native launch escaped concurrent disable");
|
||||
Require(!concurrent.Run(concurrentEpoch, () => throw new Exception("Delayed Android intent executed")),
|
||||
"delayed Android intent accepted");
|
||||
}
|
||||
|
||||
private static void Require(bool value, string message)
|
||||
{
|
||||
if (!value) throw new InvalidOperationException(message);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Loading…
Reference in a new issue