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
|
|
@ -0,0 +1,144 @@
|
|||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Android.App;
|
||||
using Android.Content;
|
||||
using Android.Util;
|
||||
using PostIt.Models;
|
||||
using PostIt.Services;
|
||||
|
||||
namespace PostIt.Android.Services;
|
||||
|
||||
internal enum BackgroundChatFailure { SessionRequired, StartRejected, ConnectionFailed }
|
||||
|
||||
internal sealed class BackgroundChatException(BackgroundChatFailure failure, string message, Exception? inner = null)
|
||||
: InvalidOperationException(message, inner)
|
||||
{
|
||||
internal BackgroundChatFailure Failure { get; } = failure;
|
||||
}
|
||||
|
||||
internal sealed class AndroidBackgroundChatService : IBackgroundChatService
|
||||
{
|
||||
internal static readonly AndroidBackgroundChatService Instance = new();
|
||||
internal static readonly ChatServiceEpoch Epoch = new();
|
||||
internal const string EpochExtra = "postit-chat-epoch";
|
||||
internal const string IdentityExtra = "postit-chat-identity";
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
public async Task SynchronizeAsync(YavscApiClient api, bool enabled, CancellationToken ct = default)
|
||||
{
|
||||
// This runs before the first await: cancellation cannot queue a stop
|
||||
// behind an in-flight permission dialog or a native start.
|
||||
if (!enabled || !api.Settings.AndroidKeepChatConnected)
|
||||
{
|
||||
Invalidate();
|
||||
return;
|
||||
}
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (api.IsSigningOut)
|
||||
{
|
||||
Invalidate();
|
||||
throw new BackgroundChatException(BackgroundChatFailure.SessionRequired, "Deconnexion en cours.");
|
||||
}
|
||||
var epoch = Epoch.Begin();
|
||||
var identity = ChatBootState.Identity(api);
|
||||
var entered = false;
|
||||
try
|
||||
{
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
entered = true;
|
||||
Validate(api, epoch, identity, ct);
|
||||
if (!Epoch.Run(epoch, () => ChatBootState.Save(api))) throw new OperationCanceledException(ct);
|
||||
if (!ChatBootState.CanStart(api))
|
||||
throw new BackgroundChatException(BackgroundChatFailure.SessionRequired, "Connectez-vous avant d'activer la connexion de fond.");
|
||||
var context = global::Android.App.Application.Context;
|
||||
await ChatConnectionService.StartAndWaitAsync(context, epoch, identity!, ct).ConfigureAwait(false);
|
||||
Validate(api, epoch, identity, ct);
|
||||
if (!NotificationPermission.IsGranted(context))
|
||||
{
|
||||
if (MainActivity.Current is { } activity && MainActivity.IsForeground)
|
||||
{
|
||||
try
|
||||
{
|
||||
var granted = await NotificationPermission.RequestAsync(activity, ct).ConfigureAwait(false);
|
||||
Validate(api, epoch, identity, ct);
|
||||
await AndroidSettings.UpdateAsync(api.Settings, s => s.SetActionStatus(granted
|
||||
? "Connexion de fond active sans notification permanente. Android peut l'interrompre."
|
||||
: "Notifications refusees : SignalR reste actif, mais les alertes ne seront pas affichees.",
|
||||
granted ? StatusSeverity.Info : StatusSeverity.Warning)).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("PostIt.Chat", "Notification permission request failed: " + ex);
|
||||
AndroidSettings.ReportError(api.Settings,
|
||||
"SignalR reste actif ; demande d'autorisation des alertes impossible : " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
Validate(api, epoch, identity, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Cancel(epoch);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Cancel(epoch);
|
||||
Log.Error("PostIt.Chat", ex.ToString());
|
||||
try
|
||||
{
|
||||
await AndroidSettings.UpdateAsync(api.Settings, s => s.SetActionStatus(ex.Message, StatusSeverity.Error))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception statusError) { Log.Error("PostIt.Chat", statusError.ToString()); }
|
||||
if (ex is BackgroundChatException) throw;
|
||||
throw new BackgroundChatException(BackgroundChatFailure.StartRejected,
|
||||
"Android n'a pas pu demarrer le service de messagerie : " + ex.Message, ex);
|
||||
}
|
||||
finally { if (entered) _gate.Release(); }
|
||||
}
|
||||
|
||||
public Task DisableAsync(YavscApiClient api, CancellationToken ct = default)
|
||||
{
|
||||
Invalidate();
|
||||
// Logout calls this before deleting tokens. Never stop the shared
|
||||
// socket here: the process-owned messaging session manages it.
|
||||
try { ChatBootState.MarkSignedOut(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("PostIt.Chat", ex.ToString());
|
||||
AndroidSettings.ReportError(api.Settings, ex.Message);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static void Validate(YavscApiClient api, string epoch, string? identity, CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (!Epoch.IsCurrent(epoch) || api.IsSigningOut || !api.Settings.AndroidKeepChatConnected
|
||||
|| !api.HasValidSession || identity is null || identity != ChatBootState.Identity(api))
|
||||
throw new OperationCanceledException("Demarrage de messagerie obsolete.", ct);
|
||||
}
|
||||
|
||||
internal static bool StartNative(Context context, string epoch, string identity) =>
|
||||
Epoch.Run(epoch, () =>
|
||||
{
|
||||
var intent = new Intent(context, typeof(ChatConnectionService))
|
||||
.PutExtra(EpochExtra, epoch).PutExtra(IdentityExtra, identity);
|
||||
context.StartService(intent);
|
||||
});
|
||||
|
||||
internal static void Invalidate() => Epoch.Invalidate(Stop);
|
||||
internal static void Cancel(string epoch) => Epoch.Cancel(epoch, Stop);
|
||||
|
||||
internal static bool IsCurrent(string? epoch) => Epoch.IsCurrent(epoch);
|
||||
|
||||
private static void Stop()
|
||||
{
|
||||
ChatConnectionService.Suspend();
|
||||
global::Android.App.Application.Context.StopService(
|
||||
new Intent(global::Android.App.Application.Context, typeof(ChatConnectionService)));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue