yavsc/src/PostIt/PostIt.Android/Services/AndroidBackgroundChatService.cs
Paul Schneider 311d55dcc6
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>
2026-11-03 12:43:46 +00:00

144 lines
6.2 KiB
C#

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)));
}
}