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:
Paul Schneider 2026-11-03 12:43:46 +00:00
commit 311d55dcc6
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
32 changed files with 2210 additions and 143 deletions

View file

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

View file

@ -0,0 +1,34 @@
using System;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers;
using PostIt.Services;
namespace PostIt.Android.Services;
internal static class AndroidServiceProvider
{
private static readonly Lazy<IServiceProvider> Provider = new(() =>
{
var provider = new ServiceCollection().BuildPostItServices();
var api = provider.GetRequiredService<YavscApiClient>();
api.Settings.Saved += (_, _) =>
{
if (!api.Settings.AndroidKeepChatConnected) AndroidBackgroundChatService.Invalidate();
ChatBootState.Save(api);
};
api.SigningOut += () =>
{
AndroidBackgroundChatService.Invalidate();
try { ChatBootState.MarkSignedOut(); }
catch (Exception ex)
{
global::Android.Util.Log.Error("PostIt.Chat", ex.ToString());
AndroidSettings.ReportError(api.Settings, ex.Message);
}
return System.Threading.Tasks.Task.CompletedTask;
};
return provider;
});
internal static IServiceProvider GetOrCreate() => Provider.Value;
}

View file

@ -0,0 +1,38 @@
using System;
using System.Threading.Tasks;
using PostIt.ViewModels;
namespace PostIt.Android.Services;
internal static class AndroidSettings
{
internal static Task UpdateAsync(Settings settings, Action<Settings> update)
{
if (MainActivity.Current is not { IsDestroyed: false, IsFinishing: false } activity)
{
update(settings);
return Task.CompletedTask;
}
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
activity.RunOnUiThread(() =>
{
try
{
update(settings);
completion.TrySetResult();
}
catch (Exception ex) { completion.TrySetException(ex); }
});
return completion.Task;
}
internal static void ReportError(Settings settings, string message)
{
_ = ReportAsync();
async Task ReportAsync()
{
try { await UpdateAsync(settings, s => s.SetActionStatus(message, PostIt.Models.StatusSeverity.Error)); }
catch (Exception ex) { global::Android.Util.Log.Error("PostIt.Chat", ex.ToString()); }
}
}
}

View file

@ -0,0 +1,43 @@
using System;
using System.Threading.Tasks;
using Android.App;
using Android.Content;
using Android.Util;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Models;
using PostIt.Services;
namespace PostIt.Android.Services;
[BroadcastReceiver(Name = "fr.pschneider.postit.ChatBootReceiver", Enabled = true, Exported = true)]
[IntentFilter([Intent.ActionBootCompleted])]
public sealed class ChatBootReceiver : BroadcastReceiver
{
public override void OnReceive(Context? context, Intent? intent)
{
if (context is null || intent?.Action != Intent.ActionBootCompleted || !ChatBootState.OptedIn) return;
var epoch = AndroidBackgroundChatService.Epoch.Restore();
if (epoch is null) return;
var pending = GoAsync();
_ = Task.Run(() =>
{
YavscApiClient? api = null;
try
{
api = AndroidServiceProvider.GetOrCreate().GetRequiredService<YavscApiClient>();
if (!ChatBootState.CanStart(api)) return;
var identity = ChatBootState.Identity(api);
if (identity is null || !AndroidBackgroundChatService.StartNative(context, epoch, identity)) return;
Log.Info("PostIt.Chat", "Best-effort chat restart requested after boot.");
}
catch (Exception ex)
{
Log.Error("PostIt.Chat", "Boot restart rejected: " + ex);
if (api is not null)
AndroidSettings.ReportError(api.Settings,
"Android a refuse le redemarrage de la messagerie : " + ex.Message);
}
finally { pending?.Finish(); }
});
}
}

View file

@ -0,0 +1,59 @@
using System;
using System.IO;
using System.Text;
using System.Text.Json;
using Android.Content;
using PostIt.Services;
namespace PostIt.Android.Services;
internal static class ChatBootState
{
private static ISharedPreferences Preferences =>
global::Android.App.Application.Context.GetSharedPreferences("postit-chat-service", FileCreationMode.Private)!;
internal static bool OptedIn => Preferences.GetBoolean("enabled", false);
internal static void Save(YavscApiClient api)
{
var identity = Identity(api);
var saved = Preferences.Edit()!
.PutBoolean("enabled", api.Settings.AndroidKeepChatConnected)!
.PutBoolean("signed-in", !api.IsSigningOut && api.HasValidSession && identity is not null)!
.PutString("identity", identity)!.Commit();
if (!saved) throw new IOException("Impossible d'enregistrer l'etat Android de la messagerie.");
}
internal static void MarkSignedOut()
{
if (!Preferences.Edit()!.PutBoolean("signed-in", false)!.Commit())
throw new IOException("Impossible d'enregistrer la deconnexion Android.");
}
internal static bool CanStart(YavscApiClient api) =>
OptedIn && Preferences.GetBoolean("signed-in", false)
&& api.Settings.AndroidKeepChatConnected && !api.IsSigningOut && api.HasValidSession
&& Identity(api) is { } identity && identity == Preferences.GetString("identity", null);
internal static string? Identity(YavscApiClient api)
{
var token = api.CurrentIdToken ?? api.CurrentAccessToken;
if (string.IsNullOrEmpty(token)) return null;
try
{
var parts = token.Split('.');
if (parts.Length != 3) return null;
var payload = parts[1].Replace('-', '+').Replace('_', '/');
payload = payload.PadRight((payload.Length + 3) / 4 * 4, '=');
using var json = JsonDocument.Parse(Encoding.UTF8.GetString(Convert.FromBase64String(payload)));
if (!json.RootElement.TryGetProperty("sub", out var subject)
|| string.IsNullOrEmpty(subject.GetString())) return null;
return $"{api.Settings.ApiUrl.TrimEnd('/')}\n{api.Settings.Authentication.Authority.TrimEnd('/')}\n{subject.GetString()}";
}
catch (Exception ex) when (ex is FormatException or JsonException or InvalidOperationException)
{
global::Android.Util.Log.Warn("PostIt.Chat", "Session identity unavailable for boot restart.");
return null;
}
}
}

View file

@ -0,0 +1,294 @@
using System;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using Android.App;
using Android.Content;
using Android.OS;
using Android.Util;
using AndroidX.Core.App;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Models;
using PostIt.Services;
using PostIt.ViewModels.Chat;
using OperationCanceledException = System.OperationCanceledException;
namespace PostIt.Android.Services;
[Service(Name = "fr.pschneider.postit.ChatConnectionService", Exported = false)]
public sealed class ChatConnectionService : Service
{
private const string MessageChannel = "postit-chat-messages";
private const int MessageNotificationId = 4053;
private static readonly ConcurrentDictionary<string, TaskCompletionSource<bool>> Startups = new();
private static volatile ChatConnectionService? _current;
private CancellationTokenSource _stop = new();
private string? _epoch;
private readonly List<IDisposable> _subscriptions = [];
private readonly object _observerGate = new();
private YavscApiClient? _api;
private ChatHubConnection? _transport;
private HubConnection? _observedConnection;
private Task? _loop;
private volatile bool _destroyed;
private bool _wasConnected;
internal static async Task StartAndWaitAsync(Context context, string epoch, string identity, CancellationToken ct)
{
var pending = Startups.GetOrAdd(epoch,
_ => new(TaskCreationOptions.RunContinuationsAsynchronously));
try
{
ct.ThrowIfCancellationRequested();
if (!AndroidBackgroundChatService.StartNative(context, epoch, identity))
throw new OperationCanceledException("Demarrage de messagerie obsolete.", ct);
await pending.Task.WaitAsync(TimeSpan.FromSeconds(30), ct).ConfigureAwait(false);
}
catch
{
// Cancel only the owner of this attempt, never a later re-enable.
AndroidBackgroundChatService.Cancel(epoch);
throw;
}
finally
{
Startups.TryRemove(epoch, out _);
}
}
internal static void Suspend()
{
foreach (var startup in Startups)
if (!AndroidBackgroundChatService.IsCurrent(startup.Key))
startup.Value.TrySetException(new OperationCanceledException("Demarrage de messagerie annule."));
if (_current is not { } current) return;
current._stop.Cancel();
current.FailStartup(new OperationCanceledException("Demarrage de messagerie annule."));
}
public override StartCommandResult OnStartCommand(Intent? intent, StartCommandFlags flags, int startId)
{
var epoch = intent is null
? AndroidBackgroundChatService.Epoch.Restore()
: intent.GetStringExtra(AndroidBackgroundChatService.EpochExtra);
try
{
if (!AndroidBackgroundChatService.IsCurrent(epoch))
{
if (_current == this && AndroidBackgroundChatService.IsCurrent(_epoch))
return StartCommandResult.Sticky;
StopSelfResult(startId);
return StartCommandResult.NotSticky;
}
EnsureChannels();
var provider = AndroidServiceProvider.GetOrCreate();
_api = provider.GetRequiredService<YavscApiClient>();
_transport = provider.GetRequiredService<ChatHubConnection>();
var identity = intent is null ? ChatBootState.Identity(_api)
: intent.GetStringExtra(AndroidBackgroundChatService.IdentityExtra);
if (!ChatBootState.CanStart(_api) || identity is null || identity != ChatBootState.Identity(_api))
throw new BackgroundChatException(BackgroundChatFailure.SessionRequired,
"Connexion de fond inactive : option ou session absente.");
if (!AndroidBackgroundChatService.Epoch.Run(epoch, () =>
{
if (_epoch == epoch && _loop is { IsCompleted: false } && !_stop.IsCancellationRequested)
{
if (_transport.Connection.State == HubConnectionState.Connected) CompleteStartup(epoch!);
return;
}
_stop.Cancel();
_stop = new CancellationTokenSource();
_epoch = epoch;
_current = this;
_wasConnected = false;
_loop = RunAsync(epoch!, _stop.Token);
}))
{
StopSelfResult(startId);
return StartCommandResult.NotSticky;
}
return StartCommandResult.Sticky;
}
catch (Exception ex)
{
ReportError(ex);
if (epoch is not null)
{
if (Startups.TryGetValue(epoch, out var pending)) pending.TrySetException(ex);
AndroidBackgroundChatService.Cancel(epoch);
}
if (!AndroidBackgroundChatService.IsCurrent(_epoch)) StopSelfResult(startId);
return StartCommandResult.NotSticky;
}
}
private async Task RunAsync(string epoch, CancellationToken ct)
{
while (!ct.IsCancellationRequested && AndroidBackgroundChatService.IsCurrent(epoch))
{
if (_api is null || _transport is null || !ChatBootState.CanStart(_api))
{
FailStartup(new BackgroundChatException(BackgroundChatFailure.SessionRequired,
"La connexion de fond necessite une session active."), epoch);
AndroidBackgroundChatService.Cancel(epoch);
return;
}
try
{
var connection = await _transport.GetConnectionAsync().ConfigureAwait(false);
if (ct.IsCancellationRequested || !AndroidBackgroundChatService.IsCurrent(epoch)) return;
Observe(connection);
if (connection.State == HubConnectionState.Disconnected)
{
using var attempt = CancellationTokenSource.CreateLinkedTokenSource(ct);
attempt.CancelAfter(TimeSpan.FromSeconds(20));
await _transport.StartAsync(attempt.Token).ConfigureAwait(false);
}
if (connection.State == HubConnectionState.Connected)
{
if (!_wasConnected)
await AndroidSettings.UpdateAsync(_api.Settings,
s =>
{
if (!_destroyed && AndroidBackgroundChatService.IsCurrent(epoch)
&& ChatBootState.CanStart(_api))
s.SetActionStatus(NotificationPermission.IsGranted(this)
? "Connexion de fond active sans notification permanente. Android peut l'interrompre."
: "SignalR actif sans notification permanente. Alertes desactivees dans Android.",
NotificationPermission.IsGranted(this) ? StatusSeverity.Info : StatusSeverity.Warning);
})
.ConfigureAwait(false);
ct.ThrowIfCancellationRequested();
if (!AndroidBackgroundChatService.IsCurrent(epoch)) return;
if (!ChatBootState.CanStart(_api)) { AndroidBackgroundChatService.Cancel(epoch); return; }
_wasConnected = true;
CompleteStartup(epoch);
}
else
{
_wasConnected = false;
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
catch (Exception ex)
{
if (!AndroidBackgroundChatService.IsCurrent(epoch)) return;
_wasConnected = false;
var failure = new BackgroundChatException(BackgroundChatFailure.ConnectionFailed,
"Connexion de fond indisponible ; nouvelle tentative automatique. " + ex.Message, ex);
ReportError(failure);
FailStartup(failure, epoch);
}
try { await Task.Delay(TimeSpan.FromSeconds(5), ct).ConfigureAwait(false); }
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
}
}
private void Observe(HubConnection connection)
{
lock (_observerGate)
{
if (_destroyed || ReferenceEquals(connection, _observedConnection)) return;
ClearSubscriptions();
_observedConnection = connection;
_subscriptions.Add(connection.On<string, string, string>("notifyUser", (type, _, _) =>
{
if (type is "pv" or "private" or "private_message" or "PrivateMessage")
NotifyMessage("Nouveau message prive. Ouvrez PostIt.");
}));
// The current Yavsc hub delivers private chat under addPV.
_subscriptions.Add(connection.On<string, string>("addPV", (_, _) =>
NotifyMessage("Nouveau message prive. Ouvrez PostIt.")));
_subscriptions.Add(connection.On<ChatMessage>("ReceiveMessage", _ =>
NotifyMessage("Nouveau message dans un salon. Ouvrez PostIt.")));
_subscriptions.Add(connection.On<string, string>("push", (_, _) =>
NotifyMessage("Nouvelle notification d'activite. Ouvrez PostIt.")));
}
}
private void NotifyMessage(string text)
{
if (_destroyed || _stop.IsCancellationRequested || !AndroidBackgroundChatService.IsCurrent(_epoch)
|| MainActivity.IsForeground
|| _api is null || !ChatBootState.CanStart(_api)
|| !NotificationPermission.IsGranted(this)) return;
try
{
var builder = new NotificationCompat.Builder(this, MessageChannel);
builder.SetSmallIcon(Yavsc.Resource.Drawable.ic_stat_postit);
builder.SetContentTitle("PostIt");
builder.SetContentText(text);
builder.SetVisibility(NotificationCompat.VisibilityPrivate);
builder.SetContentIntent(OpenApp());
builder.SetAutoCancel(true);
builder.SetOnlyAlertOnce(false);
GetNotificationManager().Notify(MessageNotificationId, builder.Build());
}
catch (Exception ex) { ReportError(ex); }
}
private void EnsureChannels()
{
if (!OperatingSystem.IsAndroidVersionAtLeast(26)) return;
var manager = (NotificationManager?)GetSystemService(NotificationService)
?? throw new InvalidOperationException("Notifications Android indisponibles.");
manager.CreateNotificationChannel(new NotificationChannel(MessageChannel,
"Messages et activite", NotificationImportance.Default));
}
private PendingIntent? OpenApp()
{
var launch = PackageManager?.GetLaunchIntentForPackage(PackageName!);
return launch is null ? null : PendingIntent.GetActivity(this, 4053, launch,
PendingIntentFlags.UpdateCurrent | PendingIntentFlags.Immutable);
}
private NotificationManagerCompat GetNotificationManager() =>
NotificationManagerCompat.From(this)
?? throw new InvalidOperationException("Notifications Android indisponibles.");
private void ReportError(Exception ex)
{
Log.Error("PostIt.Chat", ex.ToString());
if (_api is not null) AndroidSettings.ReportError(_api.Settings, ex.Message);
}
private static void CompleteStartup(string epoch)
{
if (Startups.TryGetValue(epoch, out var pending)) pending.TrySetResult(true);
}
private void FailStartup(Exception ex, string? epoch = null)
{
epoch ??= _epoch;
if (epoch is not null && Startups.TryGetValue(epoch, out var pending)) pending.TrySetException(ex);
}
private void ClearSubscriptions()
{
lock (_observerGate)
{
foreach (var subscription in _subscriptions) subscription.Dispose();
_subscriptions.Clear();
_observedConnection = null;
}
}
public override void OnDestroy()
{
_destroyed = true;
_stop.Cancel();
ClearSubscriptions();
if (ReferenceEquals(_current, this))
{
_current = null;
FailStartup(new OperationCanceledException("Service de messagerie arrete."));
}
// The socket belongs to the shared process session, not this service.
base.OnDestroy();
}
public override IBinder? OnBind(Intent? intent) => null;
}

View file

@ -0,0 +1,70 @@
using System;
namespace PostIt.Android.Services;
/// <summary>Serializes native launch ownership, including intents delivered after cancellation.</summary>
internal sealed class ChatServiceEpoch
{
private readonly object _gate = new();
private readonly string _process = Guid.NewGuid().ToString("N");
private long _epoch;
private bool _enabled;
internal string Begin()
{
lock (_gate)
{
if (!_enabled) { ++_epoch; _enabled = true; }
return Current;
}
}
internal string? Restore()
{
lock (_gate)
{
// Only a genuinely fresh process may restore without a new user request.
if (_epoch == 0) { ++_epoch; _enabled = true; }
return _enabled ? Current : null;
}
}
internal bool IsCurrent(string? epoch)
{
lock (_gate) return _enabled && epoch == Current;
}
internal bool Run(string? epoch, Action action)
{
lock (_gate)
{
if (!_enabled || epoch != Current) return false;
action();
return true;
}
}
internal void Invalidate(Action stop)
{
lock (_gate)
{
++_epoch;
_enabled = false;
stop();
}
}
internal bool Cancel(string epoch, Action stop)
{
lock (_gate)
{
if (!_enabled || epoch != Current) return false;
++_epoch;
_enabled = false;
stop();
return true;
}
}
private string Current => $"{_process}:{_epoch}";
}

View file

@ -0,0 +1,85 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Android.App;
using Android.Content;
using Android.Content.PM;
using AndroidX.Core.App;
using AndroidX.Core.Content;
namespace PostIt.Android.Services;
/// <summary>Runtime POST_NOTIFICATIONS request (Android 13+), routed through MainActivity.</summary>
internal static class NotificationPermission
{
private const int RequestCode = 4051;
private const string AskedKey = "notification-permission-asked";
private const string PermissionName = "android.permission.POST_NOTIFICATIONS";
private static TaskCompletionSource<bool>? _pending;
internal static bool IsGranted(Context context)
=> (!OperatingSystem.IsAndroidVersionAtLeast(33)
|| ContextCompat.CheckSelfPermission(context, PermissionName) == Permission.Granted)
&& NotificationManagerCompat.From(context)?.AreNotificationsEnabled() == true;
internal static async Task<bool> RequestAsync(Activity activity, CancellationToken ct)
{
if (IsGranted(activity)) return true;
if (!OperatingSystem.IsAndroidVersionAtLeast(33))
return false; // Disabled from system settings: no runtime prompt exists.
if (!MainActivity.IsForeground || activity.IsFinishing || activity.IsDestroyed)
return false;
ct.ThrowIfCancellationRequested();
if (_pending is { } existing)
return await existing.Task.WaitAsync(ct).ConfigureAwait(false);
var preferences = activity.GetSharedPreferences("postit-chat-service", FileCreationMode.Private)!;
if (preferences.GetBoolean(AskedKey, false))
return false; // Subsequent retries must use Android notification settings.
var pending = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
if (Interlocked.CompareExchange(ref _pending, pending, null) is { } inFlight)
return await inFlight.Task.WaitAsync(ct).ConfigureAwait(false);
activity.RunOnUiThread(() =>
{
try
{
if (!MainActivity.IsForeground || activity.IsFinishing || activity.IsDestroyed)
{
Interlocked.CompareExchange(ref _pending, null, pending);
pending.TrySetResult(false);
return;
}
if (!preferences.Edit()!.PutBoolean(AskedKey, true)!.Commit())
throw new System.IO.IOException("Impossible d'enregistrer la demande d'autorisation Android.");
ActivityCompat.RequestPermissions(activity, [PermissionName], RequestCode);
}
catch (Exception ex)
{
Interlocked.CompareExchange(ref _pending, null, pending);
pending.TrySetException(ex);
}
});
// Cancellation cancels only this waiter, not the OS request. Retain the
// request until its callback so retries cannot open a second dialog.
return await pending.Task.WaitAsync(ct).ConfigureAwait(false);
}
internal static bool HandleResult(int requestCode, string[]? permissions, Permission[]? results)
{
if (requestCode != RequestCode) return false;
var granted = false;
if (permissions is not null && results is not null)
{
var index = Array.IndexOf(permissions, PermissionName);
granted = index >= 0 && index < results.Length && results[index] == Permission.Granted;
}
Interlocked.Exchange(ref _pending, null)?.TrySetResult(granted);
return true;
}
internal static void OnActivityDestroyed() =>
Interlocked.Exchange(ref _pending, null)?.TrySetResult(false);
}