diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d4b3e25a..e64d84633 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added +* [PostIt.Android] Add an opt-in ordinary background service for the existing SignalR ChatHub connection, without a permanent notification; show native alerts only for received messages/events and attempt restart after phone startup where Android permits it. Reuse one process-wide connection between service, pages and calls, restore locally remembered rooms per account/host and clean up on opt-out/logout. No Firebase, Google push configuration or server push migration; explain background-data/battery settings and Android execution limits. * [PostIt/Calls] Support direct IPv6 SRTP calls using global, ULA and loopback addresses, shared endpoint validation, IPv6 UDP sockets and bracketed reception URLs. Exclude scoped/link-local, multicast, unspecified and IPv4-mapped addresses; verify real bidirectional IPv6 audio/video, wrong-key rejection and port lifecycle with LibVLC. * [PostIt/Calls] Allow directly assigned public IPv4 addresses for direct SRTP calls, alongside LAN/VPN and loopback addresses. Share unicast validation between interface discovery, media and server signaling; continue rejecting unspecified, link-local, multicast, reserved and IPv6 addresses. Keep explicit address-disclosure consent and no automatic NAT traversal. * [PostIt] Update bundled settings with the default podcast title, camera/microphone preferences and French language selection. diff --git a/doc/Architecture.md b/doc/Architecture.md index 8fb43ee2b..820cd37d0 100644 --- a/doc/Architecture.md +++ b/doc/Architecture.md @@ -23,6 +23,7 @@ Chaque sujet est détaillé dans sa propre page (sous | Dictionnaires métier (héritage en arbre) | [dictionnaires-metier.md](architecture/dictionnaires-metier.md) | | Offre fournisseur + frontmatter (`ClasseFormulaire`, `ClasseDevis`, parsing) | [offres-frontmatter.md](architecture/offres-frontmatter.md) | | PostIt / OIDC (client desktop, custom URI scheme, silent refresh) | [postit-oidc.md](architecture/postit-oidc.md) | +| Notifications Web/PostIt, ChatHub et service Android SignalR | [notifications.md](architecture/notifications.md) | ## Stack technique diff --git a/doc/architecture/notifications.md b/doc/architecture/notifications.md index 8cf85cfb8..555667a42 100644 --- a/doc/architecture/notifications.md +++ b/doc/architecture/notifications.md @@ -20,12 +20,13 @@ clients. | Annonce persistée | Entité `Notification` en base, API HTTP | Affichée dans le layout et masquable | Liste sur `HomePage`, chargée par HTTP et masquable | | Message d'interface | Liste de portée requête dans `ViewBag.Notify` | Affichée comme alerte dans le layout | Non consommé | | Événement de chat | Hub SignalR authentifié à `/chathub` | Le site Web n'en consomme pas dans ses vues MVC | `notifyUser` et `notifyRoom` alimentent les journaux du chat | -| Événement métier `push` | SignalR vers les connexions actives, plus e-mail | Pas de consommateur identifié dans les vues Web | Pas de gestionnaire `push` dans le client PostIt actuel | +| Événement métier `push` | SignalR vers les connexions actives, plus e-mail | Pas de consommateur identifié dans les vues Web | Alerte native par le service Android ; liste persistée sur l'accueil via HTTP | +| Alerte native Android | Connexion SignalR existante | Sans objet | Service de fond facultatif ; notification seulement à réception d'un événement | Les notifications persistées sont donc différentes d'une notification système native (notification du navigateur ou du système d'exploitation). Le client -PostIt affiche une liste dans sa page d'accueil ; il ne demande pas de -permission de notification native. +PostIt affiche une liste dans sa page d'accueil. Sur Android, le service de +messagerie peut aussi afficher des notifications natives, après autorisation. ## 1. Annonces persistées @@ -173,6 +174,7 @@ Les événements de notification émis par le hub comprennent notamment : | `notifyRoom` | `type`, salon, utilisateur ou détail | Ajout au journal du salon ; met à jour la liste d'utilisateurs sur `UserJoin` | | `joint` | `ChatRoomInfo` | Initialise la vue du salon rejoint | | `ReceiveMessage` | message avec nom, salon et corps | Ajoute le message à l'historique du salon | +| `addPV` | expéditeur, texte privé | Ajoute le message privé au journal système ; alerte native Android en arrière-plan | | `notifyUserInRoom` | `type`, salon, message | Pas de gestionnaire correspondant dans `ChatViewModel` actuellement | Les constantes de types du chat sont déclarées dans @@ -181,23 +183,128 @@ Les messages `notifyUser` et `notifyRoom` ne sont pas enregistrés en base : ils décrivent l'état temps réel de connexion, de salon ou les erreurs de conversation. -Le [`ChatViewModel`](../../src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs) +Le singleton +[`ChatHubConnection`](../../src/PostIt/PostIt/Services/ChatHubConnection.cs) ouvre le hub avec le jeton d'accès PostIt, tente un renouvellement silencieux -du jeton, et active la reconnexion automatique. Après reconnexion, il rejoint -à nouveau le salon sélectionné, car la connexion SignalR précédente et ses -groupes ne sont plus actifs. Les callbacks mettent à jour les collections sur -le thread UI d'Avalonia. +du jeton et active la reconnexion automatique. Il est partagé par le service +Android, le +[`ChatViewModel`](../../src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs) +et la signalisation des appels : ouvrir la page ne crée pas une seconde +connexion. + +Le transport conserve en mémoire les 500 dernières livraisons de messages +privés et de salon pour les présenter si l'interface s'ouvre après le +démarrage du service. Ce tampon est borné, non persisté sur disque et vidé +à l'arrêt de la connexion ou au changement de compte. Il ne permet pas de +récupérer les messages envoyés lorsque le socket était déconnecté. + +Les salons sont mémorisés localement, par hôte et compte, dans +`Settings.ChatRoomsByAccount`. Le transport les rejoint à nouveau après une +reconnexion ou un redémarrage du processus, sans dépendre de l'interface +Avalonia. « Quitter le salon » supprime cette mémorisation. Cela n'ajoute pas +d'abonnement serveur hors ligne ni d'historique des messages manqués. +Les callbacks destinés aux collections de l'interface sont dispatchés sur +le thread UI et les callbacks obsolètes sont ignorés après déconnexion. ### Événement métier `push` En plus des événements du chat, `YavscMessageSender` utilise le même contexte SignalR pour émettre `push` avec le topic et une charge utile sérialisée. Ce -nom d'événement est distinct de `notifyUser` et `notifyRoom`. Aucun -gestionnaire `push` n'est présent dans le client PostIt actuel : les annonces -métier persistées sont visibles dans PostIt lors du prochain chargement ou -rafraîchissement HTTP. +nom d'événement est distinct de `notifyUser` et `notifyRoom`. Le service +Android peut en afficher une alerte native ; les annonces métier persistées +restent visibles dans PostIt lors du prochain chargement ou rafraîchissement +HTTP. -## 5. Points d'attention pour les évolutions +## 5. Android : connexion SignalR en arrière-plan + +### Réglage utilisateur et durée de vie + +`AndroidKeepChatConnected`, désactivé par défaut, est éditable dans les +paramètres Android : +« Garder la connexion au serveur active et la reprendre au démarrage du +téléphone ». Enregistrer les paramètres pour appliquer le changement. + +- **Désactivé** : le chat reste connecté pendant la vie du processus PostIt, + même si l'activité est masquée, mais rien ne garantit que ce processus + survive en arrière-plan. Les messages alimentent le journal ; les alertes + natives en arrière-plan nécessitent le service activé. +- **Activé** : un service Android ordinaire héberge la même connexion + SignalR, **sans notification permanente**. Un récepteur + `BOOT_COMPLETED` tente de reprendre ce service si l'option et une session + enregistrée sont encore disponibles. +- La fermeture d'une page ou de l'activité ne ferme pas la messagerie. + La déconnexion du compte arrête le service et la connexion avant la + suppression des jetons. Les appels et captures caméra/micro restent + soumis à leur propre cycle de vie. +- Le service peut démarrer sans construire l'interface Avalonia. L'ouverture + ultérieure de l'application réutilise le même conteneur et le même + transport, sans connexion doublonnée. + +Le service n'appelle ni `StartForegroundService` ni `StartForeground` et ne +publie aucune notification de connexion/reconnexion. Pour l'arrêter, +désactiver l'option et enregistrer les paramètres, ou déconnecter le compte. +Les notifications ne sont créées que lors de nouveaux messages ou événements ; +elles disparaissent à leur ouverture et ne sont pas marquées permanentes. +Android 13+ demande l'autorisation `POST_NOTIFICATIONS` pour ces alertes +visibles. Un refus empêche les alertes, **pas la connexion SignalR**. +Les échecs de démarrage sont signalés, sans basculer silencieusement vers +un service de premier plan et sans répéter une permission refusée. + +### Notifications et limites + +Le service écoute les messages privés, les messages des salons rejoints et +l'événement métier SignalR `push`. En arrière-plan, il affiche des alertes +locales génériques pour ne pas exposer de contenu privé sur l'écran +verrouillé. Il ne faut pas confondre cet événement SignalR nommé `push` avec +un service de push externe. + +**Aucun Firebase/FCM, serveur Google de notifications, jeton d'appareil, +configuration de projet ou migration push n'est requis.** Les API métier et +le ChatHub existants restent les seuls services réseau utilisés pour la +messagerie. + +Cette solution est de type *best effort*, pas une connexion immortelle : + +- Android, Doze, l'absence de réseau et les politiques des constructeurs + peuvent interrompre le socket ou empêcher le redémarrage du service. + Autoriser les données en arrière-plan permet le trafic réseau ; désactiver + manuellement l'optimisation de batterie pour PostIt assouplit les limites + de Doze. Ces réglages ne garantissent pas la survie du processus ou du + service et ne suppriment pas les autres limites d'exécution Android. +- Android 8+ peut arrêter un service ordinaire lorsque l'application reste + en arrière-plan, ou refuser `StartService` depuis le récepteur de démarrage. + Dans ce cas, PostIt signale le refus et attend une nouvelle ouverture de + l'application ; aucune tentative de contournement par service de premier + plan, tâche périodique ou exemption automatique n'est ajoutée. +- Après un arrêt forcé par l'utilisateur, rouvrir PostIt ; le démarrage du + téléphone n'est pas un contournement de cet arrêt. +- Le renouvellement nécessite une session encore valide. Une session + révoquée ou sans jeton de renouvellement exige une reconnexion. +- Les messages de chat sont éphémères : les messages envoyés pendant une + interruption ne sont pas récupérés par cette fonctionnalité. Les + notifications métier persistées restent accessibles par l'API. +- Aucun type `specialUse` ni permission `FOREGROUND_SERVICE_SPECIAL_USE` + n'est déclaré pour la messagerie. Les services de premier plan des appels + et captures caméra/micro restent inchangés : ils ne sont pas utilisés pour + maintenir le chat. + +### Validation sur téléphone + +Les tests locaux vérifient le partage de connexion, la restauration des +salons et le nettoyage de session. Ils ne remplacent pas une validation +Android réelle : absence de notification lors de l'activation et des +reconnexions, notification seulement à réception d'un événement activité +masquée, retour dans l'interface sans second socket, désactivation dans les +paramètres, déconnexion/reconnexion, coupure et retour réseau, veille +prolongée, redémarrage du téléphone et refus de permission sans arrêt du chat. + +Une activité de vérification est incluse uniquement dans l'APK Debug : +`fr.pschneider.postit.ChatServiceTestActivity`. Son résultat est écrit dans +le fichier privé de l'application `files/chat-service-test-result.txt`. +Elle vérifie la composition du conteneur partagé, pas la réception réelle +après un démarrage ou une longue veille. + +## 6. Points d'attention pour les évolutions - Conserver les règles d'audience alignées entre l'API et le site Web ; en particulier, appliquer un filtrage `Target` côté Web avant d'exposer les diff --git a/src/PostIt/PostIt.Android/MainActivity.cs b/src/PostIt/PostIt.Android/MainActivity.cs index 4cf0947eb..ed092fefc 100644 --- a/src/PostIt/PostIt.Android/MainActivity.cs +++ b/src/PostIt/PostIt.Android/MainActivity.cs @@ -79,6 +79,17 @@ public class MainActivity : AvaloniaMainActivity base.OnPause(); } + protected override void OnDestroy() + { + if (ReferenceEquals(Current, this)) + { + Current = null; + IsForeground = false; + PostIt.Android.Services.NotificationPermission.OnActivityDestroyed(); + } + base.OnDestroy(); + } + protected override void OnRestart() { base.OnRestart(); @@ -87,7 +98,8 @@ public class MainActivity : AvaloniaMainActivity public override void OnRequestPermissionsResult(int requestCode, string[] permissions, Permission[] grantResults) { - if (PostIt.Android.Services.PodcastPermissions.HandleResult(requestCode, permissions, grantResults) + if (PostIt.Android.Services.NotificationPermission.HandleResult(requestCode, permissions, grantResults) + || PostIt.Android.Services.PodcastPermissions.HandleResult(requestCode, permissions, grantResults) || PostIt.Android.Services.AndroidCurrentLocationProvider .HandlePermissionResult(requestCode, grantResults)) { diff --git a/src/PostIt/PostIt.Android/PlatformBootstrap.cs b/src/PostIt/PostIt.Android/PlatformBootstrap.cs index 5b7b30def..ca07591a7 100644 --- a/src/PostIt/PostIt.Android/PlatformBootstrap.cs +++ b/src/PostIt/PostIt.Android/PlatformBootstrap.cs @@ -20,6 +20,8 @@ internal static class PlatformBootstrap return activity is null ? null : new AndroidSystemBrowser(activity); }; + Platform.CreateServiceProvider = AndroidServiceProvider.GetOrCreate; + Platform.BackgroundChatService = AndroidBackgroundChatService.Instance; Platform.TryGetCurrentLocationAsync = AndroidCurrentLocationProvider.TryGetCurrentLocationAsync; Platform.CreateVideoSurface = (player, replay) => new AndroidVideoSurface(player, replay); if (System.OperatingSystem.IsAndroidVersionAtLeast(26)) diff --git a/src/PostIt/PostIt.Android/PostIt.Android.csproj b/src/PostIt/PostIt.Android/PostIt.Android.csproj index 6cbde4739..8742f0b38 100644 --- a/src/PostIt/PostIt.Android/PostIt.Android.csproj +++ b/src/PostIt/PostIt.Android/PostIt.Android.csproj @@ -30,6 +30,7 @@ + diff --git a/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml b/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml index 730d6eec1..fc3422e71 100644 --- a/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml +++ b/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml @@ -1,9 +1,11 @@  + + diff --git a/src/PostIt/PostIt.Android/Resources/drawable/ic_stat_postit.xml b/src/PostIt/PostIt.Android/Resources/drawable/ic_stat_postit.xml new file mode 100644 index 000000000..e055d1b42 --- /dev/null +++ b/src/PostIt/PostIt.Android/Resources/drawable/ic_stat_postit.xml @@ -0,0 +1,10 @@ + + + + diff --git a/src/PostIt/PostIt.Android/Services/AndroidBackgroundChatService.cs b/src/PostIt/PostIt.Android/Services/AndroidBackgroundChatService.cs new file mode 100644 index 000000000..ddefc4cf3 --- /dev/null +++ b/src/PostIt/PostIt.Android/Services/AndroidBackgroundChatService.cs @@ -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))); + } +} diff --git a/src/PostIt/PostIt.Android/Services/AndroidServiceProvider.cs b/src/PostIt/PostIt.Android/Services/AndroidServiceProvider.cs new file mode 100644 index 000000000..10eb74a42 --- /dev/null +++ b/src/PostIt/PostIt.Android/Services/AndroidServiceProvider.cs @@ -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 Provider = new(() => + { + var provider = new ServiceCollection().BuildPostItServices(); + var api = provider.GetRequiredService(); + 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; +} diff --git a/src/PostIt/PostIt.Android/Services/AndroidSettings.cs b/src/PostIt/PostIt.Android/Services/AndroidSettings.cs new file mode 100644 index 000000000..e710c0c85 --- /dev/null +++ b/src/PostIt/PostIt.Android/Services/AndroidSettings.cs @@ -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 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()); } + } + } +} diff --git a/src/PostIt/PostIt.Android/Services/ChatBootReceiver.cs b/src/PostIt/PostIt.Android/Services/ChatBootReceiver.cs new file mode 100644 index 000000000..c38d46c0f --- /dev/null +++ b/src/PostIt/PostIt.Android/Services/ChatBootReceiver.cs @@ -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(); + 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(); } + }); + } +} diff --git a/src/PostIt/PostIt.Android/Services/ChatBootState.cs b/src/PostIt/PostIt.Android/Services/ChatBootState.cs new file mode 100644 index 000000000..160a30781 --- /dev/null +++ b/src/PostIt/PostIt.Android/Services/ChatBootState.cs @@ -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; + } + } +} diff --git a/src/PostIt/PostIt.Android/Services/ChatConnectionService.cs b/src/PostIt/PostIt.Android/Services/ChatConnectionService.cs new file mode 100644 index 000000000..c72a29ebc --- /dev/null +++ b/src/PostIt/PostIt.Android/Services/ChatConnectionService.cs @@ -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> Startups = new(); + private static volatile ChatConnectionService? _current; + private CancellationTokenSource _stop = new(); + private string? _epoch; + private readonly List _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(); + _transport = provider.GetRequiredService(); + 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("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("addPV", (_, _) => + NotifyMessage("Nouveau message prive. Ouvrez PostIt."))); + _subscriptions.Add(connection.On("ReceiveMessage", _ => + NotifyMessage("Nouveau message dans un salon. Ouvrez PostIt."))); + _subscriptions.Add(connection.On("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; +} diff --git a/src/PostIt/PostIt.Android/Services/ChatServiceEpoch.cs b/src/PostIt/PostIt.Android/Services/ChatServiceEpoch.cs new file mode 100644 index 000000000..9b083aa16 --- /dev/null +++ b/src/PostIt/PostIt.Android/Services/ChatServiceEpoch.cs @@ -0,0 +1,70 @@ +using System; + +namespace PostIt.Android.Services; + +/// Serializes native launch ownership, including intents delivered after cancellation. +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}"; +} diff --git a/src/PostIt/PostIt.Android/Services/NotificationPermission.cs b/src/PostIt/PostIt.Android/Services/NotificationPermission.cs new file mode 100644 index 000000000..5348f7130 --- /dev/null +++ b/src/PostIt/PostIt.Android/Services/NotificationPermission.cs @@ -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; + +/// Runtime POST_NOTIFICATIONS request (Android 13+), routed through MainActivity. +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? _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 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(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); +} diff --git a/src/PostIt/PostIt.Android/Tests/ChatServiceEpochTests.cs b/src/PostIt/PostIt.Android/Tests/ChatServiceEpochTests.cs new file mode 100644 index 000000000..f79589b72 --- /dev/null +++ b/src/PostIt/PostIt.Android/Tests/ChatServiceEpochTests.cs @@ -0,0 +1,80 @@ +#if DEBUG +using System; +using System.Threading; +using System.Threading.Tasks; +using PostIt.Android.Services; + +namespace PostIt.Android.Tests; + +/// Platform-free deterministic launch/disable races; also invoked by the Android debug activity. +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 diff --git a/src/PostIt/PostIt.Tests/AndroidChatServiceTestActivity.cs b/src/PostIt/PostIt.Tests/AndroidChatServiceTestActivity.cs new file mode 100644 index 000000000..465f779a2 --- /dev/null +++ b/src/PostIt/PostIt.Tests/AndroidChatServiceTestActivity.cs @@ -0,0 +1,58 @@ +#if ANDROID +using System; +using System.IO; +using Android.App; +using Android.Content; +using Android.Content.PM; +using Android.OS; +using Microsoft.Extensions.DependencyInjection; +using PostIt.Android; +using PostIt.Android.Services; +using PostIt.Services; +using PostIt.ViewModels; + +namespace PostIt.Tests; + +/// Debug-only boot composition test, without Avalonia or network startup. +[Activity(Name = "fr.pschneider.postit.ChatServiceTestActivity", Exported = true)] +public sealed class AndroidChatServiceTestActivity : Activity +{ + protected override async void OnCreate(Bundle? state) + { + base.OnCreate(state); + var resultPath = Path.Combine(FilesDir!.AbsolutePath, "chat-service-test-result.txt"); + try + { + await PostIt.Android.Tests.ChatServiceEpochTests.VerifyAsync(); + var boot = AndroidServiceProvider.GetOrCreate(); + PlatformBootstrap.InitPlatform(); + var ui = Platform.CreateServiceProvider!.Invoke(); + Require(ReferenceEquals(boot, ui), "boot and UI providers differ"); + Require(ReferenceEquals(boot.GetRequiredService(), + ui.GetRequiredService()), "API client differs"); + Require(ReferenceEquals(boot.GetRequiredService(), + ui.GetRequiredService()), "chat transport differs"); + Require(ReferenceEquals(boot.GetRequiredService(), + ui.GetRequiredService().Settings), "settings differ"); + Require(ReferenceEquals(Platform.BackgroundChatService, AndroidBackgroundChatService.Instance), + "native adapter missing"); + if (OperatingSystem.IsAndroidVersionAtLeast(29)) + { + var name = new ComponentName(this, Java.Lang.Class.FromType(typeof(ChatConnectionService))); + var service = PackageManager!.GetServiceInfo(name, PackageInfoFlags.MetaData)!; + Require(service.ForegroundServiceType == (ForegroundService)0, + "chat must not be a foreground service"); + Require(!service.Exported, "chat service must not be exported"); + } + File.WriteAllText(resultPath, "PASS\nepoch-races=passed\nprovider=shared\napi=shared\nsettings=shared\ntransport=shared\nAvalonia=not-initialized"); + } + catch (Exception ex) { File.WriteAllText(resultPath, $"FAIL\n{ex}"); } + finally { Finish(); } + } + + private static void Require(bool condition, string message) + { + if (!condition) throw new InvalidOperationException(message); + } +} +#endif diff --git a/src/PostIt/PostIt.Tests/MessagingSessionTests.cs b/src/PostIt/PostIt.Tests/MessagingSessionTests.cs new file mode 100644 index 000000000..aa7a76930 --- /dev/null +++ b/src/PostIt/PostIt.Tests/MessagingSessionTests.cs @@ -0,0 +1,446 @@ +using Avalonia.Headless.XUnit; +using Avalonia.Controls; +using Avalonia.Threading; +using Avalonia.VisualTree; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.SignalR; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Text; +using System.Security.Cryptography; +using PostIt.Services; +using PostIt.ViewModels; +using PostIt.ViewModels.Chat; +using PostIt.Views; +using Yavsc.Api.Client; + +namespace PostIt.Tests; + +public sealed class MessagingSessionTests +{ +#if DEBUG + [Fact] + public async Task Native_start_generations_reject_stale_requests_after_stop_and_timeout() + => await PostIt.Android.Tests.ChatServiceEpochTests.VerifyAsync(); +#endif + + [AvaloniaFact] + public void Settings_page_binds_opt_in_and_hides_it_on_other_platforms() + { + var settings = new Settings(); + var page = new SettingsPage { DataContext = settings }; + var window = new Window { Content = page }; + try + { + window.Show(); + var checkbox = page.FindControl("BackgroundChatCheckBox")!; + var container = checkbox.GetVisualAncestors().OfType().First(); + Assert.Equal(OperatingSystem.IsAndroid(), container.IsVisible); + var explanation = Assert.Single(container.Children.OfType()).Text!; + Assert.Contains("sans notification permanente", explanation); + Assert.Contains("ne garantissent pas", explanation); + Assert.False(checkbox.IsChecked); + checkbox.IsChecked = true; + Assert.True(settings.AndroidKeepChatConnected); + Assert.True(settings.IsDirty); + settings.AndroidKeepChatConnected = false; + Assert.False(checkbox.IsChecked); + } + finally { window.Close(); } + } + + [Fact] + public void Background_chat_service_are_opt_in_and_saved_only_after_persistence() + { + var path = Path.Combine(Path.GetTempPath(), $"postit-chat-{Guid.NewGuid():N}.json"); + try + { + var settings = new Settings(path); + Assert.False(settings.AndroidKeepChatConnected); + var saves = 0; + settings.Saved += (_, _) => + { + Assert.True(File.Exists(path)); + Assert.False(settings.IsDirty); + saves++; + }; + settings.AndroidKeepChatConnected = true; + Assert.True(settings.IsDirty); + settings.Save(); + var restored = new Settings(path); + restored.Authentication.Authority = ""; + restored.Load(); + Assert.True(restored.AndroidKeepChatConnected); + Assert.False(restored.IsDirty); + Assert.Equal(1, saves); + } + finally { File.Delete(path); } + } + + [Fact] + public async Task Logout_exposes_credentials_for_service_shutdown_but_clears_them_even_if_cleanup_fails() + { + var path = Path.Combine(Path.GetTempPath(), $"postit-chat-{Guid.NewGuid():N}"); + try + { + var store = CreateStore(path); + await using var api = new YavscApiClient(new Settings(), store); + api.SigningOut += () => + { + Assert.True(api.IsSigningOut); + Assert.True(api.HasValidSession); + Assert.NotNull(api.CurrentAccessToken); + throw new HttpRequestException("offline"); + }; + await Assert.ThrowsAsync(() => api.LogoutAsync()); + Assert.False(api.HasValidSession); + Assert.False(api.IsSigningOut); + Assert.Null(api.CurrentAccessToken); + Assert.Null(store.Load()); + } + finally { File.Delete(path); } + } + + [Fact] + public async Task Starting_a_connection_during_logout_is_rejected_even_while_credentials_exist() + { + var path = Path.Combine(Path.GetTempPath(), $"postit-chat-signout-{Guid.NewGuid():N}"); + try + { + var settings = new Settings(); + await using var api = new YavscApiClient(settings, CreateStore(path)); + await using var transport = new ChatHubConnection(api); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + api.SigningOut += () => release.Task; + var logout = api.LogoutAsync(); + try + { + Assert.True(api.IsSigningOut); + Assert.True(api.HasValidSession); + await Assert.ThrowsAsync(() => transport.StartAsync()); + var vm = new ChatViewModel(api, settings, transport); + await vm.ConnectAsync(); + Assert.False(vm.ConnectionRequested); + Assert.False(vm.IsConnected); + } + finally + { + release.TrySetResult(); + await logout; + } + Assert.False(api.HasValidSession); + Assert.False(api.IsSigningOut); + } + finally { File.Delete(path); } + } + + [AvaloniaFact] + public async Task Disconnect_cancels_an_in_flight_chat_handshake_instead_of_waiting_for_network_timeout() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Logging.ClearProviders(); + var handshake = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var server = builder.Build(); + server.MapPost("/" + Yavsc.Constants.ChatHubPath + "/negotiate", async (Microsoft.AspNetCore.Http.HttpContext context) => + { + handshake.TrySetResult(); + await Task.Delay(Timeout.Infinite, context.RequestAborted); + }); + await server.StartAsync(); + var path = Path.Combine(Path.GetTempPath(), $"postit-chat-cancel-{Guid.NewGuid():N}"); + try + { + var settings = new Settings { ApiUrl = server.Urls.Single() + "/api/v1/" }; + settings.Authentication.Authority = "https://login.example.test"; + await using var api = new YavscApiClient(settings, CreateStore(path)); + await using var transport = new ChatHubConnection(api); + var vm = new ChatViewModel(api, settings, transport); + var connect = vm.ConnectAsync(); + await handshake.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var disconnect = vm.DisconnectAsync(); + var deadline = DateTime.UtcNow.AddSeconds(5); + while (!disconnect.IsCompleted && DateTime.UtcNow < deadline) + { + Dispatcher.UIThread.RunJobs(); + await Task.Delay(10, TestContext.Current.CancellationToken); + } + Assert.True(disconnect.IsCompleted, $"Transport={transport.Connection.State}, chat={vm.Status.Message}"); + await disconnect; + await connect; + Assert.False(vm.ConnectionRequested); + Assert.False(vm.IsConnected); + } + finally + { + File.Delete(path); + await server.StopAsync(); + } + } + + [Theory] + [InlineData("alice", "https://api.example.test/api/v1/", true)] + [InlineData("bob", "https://api.example.test/api/v1/", false)] + [InlineData("alice", "https://other.example.test/api/v1/", false)] + public async Task Remembered_rooms_are_isolated_by_account_and_server(string user, string apiUrl, bool restored) + { + var path = Path.Combine(Path.GetTempPath(), $"postit-chat-identity-{Guid.NewGuid():N}"); + try + { + const string authority = "https://login.example.test"; + var settings = new Settings { ApiUrl = apiUrl }; + settings.Authentication.Authority = authority; + var aliceKey = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes( + authority + "\nhttps://api.example.test/api/v1/\nalice"))); + settings.ChatRoomsByAccount[aliceKey] = ["PrivateRoom"]; + var jwt = "e30." + Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"sub\":\"" + user + "\"}")) + .TrimEnd('=').Replace('+', '-').Replace('/', '_') + ".signature"; + var store = new TokenStore(path); + store.Save(new RefreshTokenRecord(jwt, "refresh", DateTimeOffset.UtcNow.AddHours(1), jwt)); + await using var api = new YavscApiClient(settings, store); + await using var transport = new ChatHubConnection(api); + Assert.Equal(restored ? 1 : 0, transport.JoinedRooms.Length); + } + finally { File.Delete(path); } + } + + [AvaloniaTheory] + [InlineData(true)] + [InlineData(false)] + public async Task Session_uses_one_chat_connection_and_logout_disables_service_before_token_purge(bool connectOnLogin) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Logging.ClearProviders(); + builder.Services.AddSignalR(); + builder.Services.AddSingleton(); + await using var server = builder.Build(); + server.MapHub("/" + Yavsc.Constants.ChatHubPath); + await server.StartAsync(); + var path = Path.Combine(Path.GetTempPath(), $"postit-chat-{Guid.NewGuid():N}"); + var previous = Platform.BackgroundChatService; + var native = new RecordingChatService(); + Platform.BackgroundChatService = native; + try + { + var settings = new Settings(path + ".settings") + { + ApiUrl = server.Urls.Single() + "/api/v1/", + AndroidKeepChatConnected = true + }; + settings.Authentication.Authority = "https://login.example.test"; + await using var api = new YavscApiClient(settings, CreateStore(path)); + await using var transport = new ChatHubConnection(api); + var chat = new ChatViewModel(api, settings, transport); + var session = new SessionStatusViewModel { Api = api }; + session.Refresh(); + await using var messaging = new MessagingSession(api, session, chat); + await messaging.StartAsync(connectOnLogin); + if (!connectOnLogin) + { + Assert.False(chat.IsConnected); + await chat.ConnectAsync(); + } + Assert.True(chat.IsConnected, chat.Status.Message); + await Task.WhenAll(chat.ConnectAsync(), chat.ConnectAsync(), messaging.StartAsync()); + Assert.Equal(1, await transport.Connection.InvokeAsync("ConnectionCount")); + Assert.Equal(connectOnLogin ? new[] { true } : new[] { false, true }, native.Preferences); + chat.CurrentRoom = "first"; + await chat.JoinRoomCommand(); + chat.CurrentRoom = "second"; + await chat.JoinRoomCommand(); + Assert.Equal(new[] { "first", "second" }, chat.JoinedRooms); + var counter = server.Services.GetRequiredService(); + counter.Context!.Abort(); + var deadline = DateTime.UtcNow.AddSeconds(40); + while (!counter.RoomsRejoined.Task.IsCompleted && DateTime.UtcNow < deadline) + { + Dispatcher.UIThread.RunJobs(); + await Task.Delay(20, TestContext.Current.CancellationToken); + } + Assert.True(counter.RoomsRejoined.Task.IsCompleted, + $"Connections={counter.Count}, transport={transport.Connection.State}, chat={chat.Status.Message}"); + Assert.Equal(2, await transport.Connection.InvokeAsync("ConnectionCount")); + Assert.Equal(new[] { "first", "second" }, counter.RejoinedRooms); + + Platform.SuspendPlayback(); + Assert.True(chat.IsConnected); + await chat.DisconnectAsync(); + settings.Save(); + Assert.False(chat.ConnectionRequested); + Assert.False(native.Preferences.Last()); + await chat.ConnectAsync(); + Assert.True(native.Preferences.Last()); + settings.AndroidKeepChatConnected = false; + settings.Save(); + await native.DisabledByPreference.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.False(native.Preferences.Last()); + native.BlockNextStart = true; + settings.AndroidKeepChatConnected = true; + settings.Save(); + await native.ServiceStartRequested.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await session.LogoutAsync().WaitAsync(TimeSpan.FromSeconds(5)); + Assert.False(chat.IsConnected); + Assert.False(chat.ConnectionRequested); + Assert.False(api.HasValidSession); + Assert.True(native.DisabledWithValidToken); + Assert.True(native.ServiceStartCancelled); + } + finally + { + Platform.BackgroundChatService = previous; + File.Delete(path); + File.Delete(path + ".settings"); + await server.StopAsync(); + } + } + + [AvaloniaFact] + public async Task Headless_transport_restores_account_rooms_and_the_later_ui_reuses_its_socket() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Logging.ClearProviders(); + builder.Services.AddSignalR(); + builder.Services.AddSingleton(); + await using var server = builder.Build(); + server.MapHub("/" + Yavsc.Constants.ChatHubPath); + await server.StartAsync(); + var path = Path.Combine(Path.GetTempPath(), $"postit-chat-rooms-{Guid.NewGuid():N}"); + try + { + var settings = new Settings(path + ".settings") { ApiUrl = server.Urls.Single() + "/api/v1/" }; + settings.Authentication.Authority = "https://login.example.test"; + var store = CreateStore(path); + var jwt = "e30." + Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"sub\":\"alice\"}")).TrimEnd('=') + .Replace('+', '-').Replace('/', '_') + ".signature"; + store.Save(new RefreshTokenRecord(jwt, "refresh", DateTimeOffset.UtcNow.AddHours(1), jwt)); + await using (var api = new YavscApiClient(settings, store)) + await using (var transport = new ChatHubConnection(api)) + { + await transport.StartAsync(TestContext.Current.CancellationToken); + await transport.JoinAsync("SavedRoom", TestContext.Current.CancellationToken); + Assert.Single(settings.ChatRoomsByAccount); + } + var restored = new Settings(path + ".settings"); + restored.Authentication.Authority = ""; + restored.Load(); + await using var resumedApi = new YavscApiClient(restored, store); + await using var resumed = new ChatHubConnection(resumedApi); + await resumed.StartAsync(TestContext.Current.CancellationToken); + Assert.Equal(2, await resumed.Connection.InvokeAsync("ConnectionCount")); + Assert.Equal("SavedRoom", Assert.Single(resumed.JoinedRooms).Name); + Assert.Equal(new[] { "SavedRoom" }, server.Services.GetRequiredService().RejoinedRooms); + var firstMessages = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allMessages = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + resumed.MessageReceived += delivery => + { + if (delivery.Sequence == 2) firstMessages.TrySetResult(); + if (delivery.Sequence == 504) allMessages.TrySetResult(); + }; + await resumed.Connection.InvokeAsync("EmitMessages", 1); + await firstMessages.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(2, resumed.RecentMessages.Length); + + var vm = new ChatViewModel(resumedApi, restored, resumed); + await vm.ConnectAsync(); + Assert.Equal(2, await resumed.Connection.InvokeAsync("ConnectionCount")); + Assert.Equal(new[] { "SavedRoom" }, vm.JoinedRooms); + Assert.Contains("alice : room-0", vm.FocusedMessages); + vm.SelectedRoom = null; + Assert.Contains("[Prive] bob : private-0", vm.FocusedMessages); + vm.SelectedRoom = "SavedRoom"; + await resumed.Connection.InvokeAsync("EmitMessages", 251); + await allMessages.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(500, resumed.RecentMessages.Length); + await vm.LeaveRoom(); + Assert.Empty(resumed.JoinedRooms); + Assert.Empty(vm.JoinedRooms); + Assert.Empty(Assert.Single(restored.ChatRoomsByAccount).Value); + await vm.DisconnectAsync(); + Assert.Empty(resumed.RecentMessages); + Assert.DoesNotContain(vm.FocusedMessages, message => message.Contains("private-0")); + } + finally + { + File.Delete(path); + File.Delete(path + ".settings"); + await server.StopAsync(); + } + } + + private static TokenStore CreateStore(string path) + { + var store = new TokenStore(path); + store.Save(new RefreshTokenRecord("access", "refresh", DateTimeOffset.UtcNow.AddHours(1), null)); + return store; + } + + public sealed class ConnectionCounter + { + public int Count; + public HubCallerContext? Context; + public List RejoinedRooms { get; } = []; + public TaskCompletionSource RoomsRejoined { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + + public sealed class TestChatHub(ConnectionCounter counter) : Hub + { + public int ConnectionCount() => counter.Count; + public ChatRoomInfo Join(string room) + { + if (counter.Count > 1) + { + counter.RejoinedRooms.Add(room); + if (counter.RejoinedRooms.Count == 2) counter.RoomsRejoined.TrySetResult(); + } + return new ChatRoomInfo { Name = room }; + } + public Task Part(string room, string reason) => Task.CompletedTask; + public async Task EmitMessages(int count) + { + for (var index = 0; index < count; index++) + { + await Clients.Caller.SendAsync("ReceiveMessage", new ChatMessage + { + Room = "SavedRoom", Name = "alice", Message = $"room-{index}" + }); + await Clients.Caller.SendAsync("addPV", "bob", $"private-{index}"); + } + } + public override Task OnConnectedAsync() + { + counter.Context = Context; + Interlocked.Increment(ref counter.Count); + return base.OnConnectedAsync(); + } + } + + private sealed class RecordingChatService : IBackgroundChatService + { + public List Preferences { get; } = []; + public bool DisabledWithValidToken { get; private set; } + public bool BlockNextStart { get; set; } + public bool ServiceStartCancelled { get; private set; } + public TaskCompletionSource DisabledByPreference { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource ServiceStartRequested { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public async Task SynchronizeAsync(YavscApiClient api, bool enabled, CancellationToken ct = default) + { + Preferences.Add(enabled); + if (!enabled) DisabledByPreference.TrySetResult(); + if (BlockNextStart) + { + ServiceStartRequested.TrySetResult(); + try { await Task.Delay(Timeout.Infinite, ct); } + finally { ServiceStartCancelled = ct.IsCancellationRequested; } + } + } + public Task DisableAsync(YavscApiClient api, CancellationToken ct = default) + { + DisabledWithValidToken = api.HasValidSession; + return Task.CompletedTask; + } + } +} diff --git a/src/PostIt/PostIt.Tests/PostIt.Tests.csproj b/src/PostIt/PostIt.Tests/PostIt.Tests.csproj index 66b2522b5..a7c7cecec 100644 --- a/src/PostIt/PostIt.Tests/PostIt.Tests.csproj +++ b/src/PostIt/PostIt.Tests/PostIt.Tests.csproj @@ -20,12 +20,15 @@ + + + diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index 65530fed6..82ada85a2 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -50,7 +50,7 @@ public partial class App : Application, ILiveView, IVMPusherApp { if (TryHandOffCustomSchemeUrl()) return; - this.ServiceProvider = new ServiceCollection().BuildPostItServices(); + this.ServiceProvider = Platform.CreateServiceProvider?.Invoke() ?? new ServiceCollection().BuildPostItServices(); var settings = ServiceProvider.GetRequiredService(); if (!LiveMode) @@ -295,6 +295,7 @@ public partial class App : Application, ILiveView, IVMPusherApp await api.TrySilentLoginAsync().ConfigureAwait(true); var sessionStatus = provider.GetRequiredService(); await sessionStatus.RefreshAsync(); + await provider.GetRequiredService().StartAsync(connectOnLogin: OperatingSystem.IsAndroid()); } /// diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs index e95aa3c15..be3589160 100644 --- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -53,6 +53,8 @@ public static class ServiceCollectionHelpers // Models services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); // Android activities need fresh controls; retain state in singleton VMs. void AddReusablePage() where TPage : class diff --git a/src/PostIt/PostIt/Services/ChatHubConnection.cs b/src/PostIt/PostIt/Services/ChatHubConnection.cs new file mode 100644 index 000000000..8afbac353 --- /dev/null +++ b/src/PostIt/PostIt/Services/ChatHubConnection.cs @@ -0,0 +1,273 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR.Client; +using PostIt.ViewModels.Chat; +using PostIt.Models; + +namespace PostIt.Services; + +/// One authenticated chat transport per application process, independent of page lifetime. +public sealed class ChatHubConnection : IAsyncDisposable +{ + private readonly SemaphoreSlim _gate = new(1); + private readonly YavscApiClient _api; + private string _apiUrl; + private string _authority; + private string? _accountKey; + private readonly object _roomsGate = new(); + private readonly Dictionary _rooms = new(StringComparer.Ordinal); + private readonly Queue _messages = new(); + private long _sequence; + public sealed record ChatDelivery(long Sequence, string? Room, string Sender, string Text); + public event Action? MessageReceived; + public ChatDelivery[] RecentMessages + { + get { lock (_roomsGate) return _messages.ToArray(); } + } + public event Action? RoomJoined; + public event Action? Error; + public ChatRoomInfo[] JoinedRooms + { + get { lock (_roomsGate) return _rooms.Values.ToArray(); } + } + public HubConnection Connection { get; private set; } + public bool IsCurrentConfiguration => _apiUrl == _api.Settings.ApiUrl && + _authority == _api.Settings.Authentication.Authority && _accountKey == GetAccountKey(); + + public ChatHubConnection(YavscApiClient api) + { + _api = api; + _apiUrl = api.Settings.ApiUrl; + _authority = api.Settings.Authentication.Authority; + _accountKey = GetAccountKey(); + LoadRooms(); + Connection = BuildConnection(); + } + + private HubConnection BuildConnection() + { + var api = _api; + var hubBase = api.Settings.ApiUrl.TrimEnd('/'); + if (hubBase.EndsWith("/api/v1", StringComparison.OrdinalIgnoreCase)) + hubBase = hubBase[..^"/api/v1".Length]; + var connection = new HubConnectionBuilder().WithUrl(hubBase + "/" + Yavsc.Constants.ChatHubPath, options => + { + options.AccessTokenProvider = async () => + { + if (api.IsSigningOut || !IsCurrentConfiguration) + throw new InvalidOperationException("La configuration de messagerie a change."); + if (!await api.TrySilentLoginAsync()) + throw new InvalidOperationException("La session de messagerie a expire. Reconnectez-vous."); + return api.CurrentAccessToken; + }; + }).WithAutomaticReconnect(new RetryPolicy()).Build(); + connection.On("joint", room => + { + if (ReferenceEquals(Connection, connection) && IsCurrentConfiguration) + RememberRoom(room); + }); + connection.On("ReceiveMessage", message => + RememberMessage(connection, message.Room, message.Name ?? "", message.Message ?? "")); + connection.On("addPV", (sender, message) => + RememberMessage(connection, null, sender, message)); + connection.Reconnected += _ => ReferenceEquals(Connection, connection) && IsCurrentConfiguration + ? RestoreRoomsAsync(connection, CancellationToken.None) : Task.CompletedTask; + return connection; + } + + public async Task GetConnectionAsync(CancellationToken ct = default) + { + await _gate.WaitAsync(ct); + try + { + if (!IsCurrentConfiguration) + { + await Connection.DisposeAsync(); + _apiUrl = _api.Settings.ApiUrl; + _authority = _api.Settings.Authentication.Authority; + _accountKey = GetAccountKey(); + lock (_roomsGate) _messages.Clear(); + LoadRooms(); + Connection = BuildConnection(); + } + return Connection; + } + finally { _gate.Release(); } + } + + public async Task StartAsync(CancellationToken ct = default) + { + await GetConnectionAsync(ct); + await _gate.WaitAsync(ct); + try + { + if (_api.IsSigningOut) + throw new InvalidOperationException("La deconnexion du compte est en cours."); + if (Connection.State == HubConnectionState.Disconnected) + { + await Connection.StartAsync(ct); + await RestoreRoomsAsync(Connection, ct); + } + } + finally { _gate.Release(); } + } + + public async Task StopAsync(CancellationToken ct = default) + { + await _gate.WaitAsync(ct); + try { await Connection.StopAsync(ct); } + finally + { + lock (_roomsGate) _messages.Clear(); + _gate.Release(); + } + } + + public async ValueTask DisposeAsync() + { + await Connection.DisposeAsync(); + _gate.Dispose(); + } + + public async Task JoinAsync(string room, CancellationToken ct = default) + { + var connection = Connection; + var info = await connection.InvokeAsync("Join", room, ct) + ?? throw new InvalidOperationException("Le serveur n'a pas confirme le salon."); + if (!ReferenceEquals(Connection, connection) || !IsCurrentConfiguration) + throw new InvalidOperationException("La session de messagerie a change."); + if (string.IsNullOrWhiteSpace(info.Name)) + throw new InvalidOperationException("Le serveur a retourne un salon sans nom."); + RememberRoom(info); + lock (_roomsGate) PersistRooms(); + return info; + } + + public async Task LeaveAsync(string room, CancellationToken ct = default) + { + var connection = Connection; + await connection.InvokeAsync("Part", room, "Leaving", ct); + if (!ReferenceEquals(Connection, connection) || !IsCurrentConfiguration) + throw new InvalidOperationException("La session de messagerie a change."); + lock (_roomsGate) + { + _rooms.Remove(room); + PersistRooms(); + } + } + + private async Task RestoreRoomsAsync(HubConnection connection, CancellationToken ct) + { + foreach (var room in JoinedRooms) + { + try + { + var info = await connection.InvokeAsync("Join", room.Name!, ct); + if (info == null || string.IsNullOrWhiteSpace(info.Name)) + throw new InvalidOperationException("Le serveur n'a pas confirme le salon."); + if (ReferenceEquals(Connection, connection) && IsCurrentConfiguration) + RememberRoom(info); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + ReportError($"Impossible de rejoindre le salon {room.Name} : {ex.Message}"); + } + } + } + + private void RememberRoom(ChatRoomInfo room) + { + if (string.IsNullOrWhiteSpace(room.Name)) + { + ReportError("Le serveur a retourne un salon sans nom."); + return; + } + lock (_roomsGate) + { + _rooms[room.Name] = room; + } + RoomJoined?.Invoke(room); + } + + private void RememberMessage(HubConnection connection, string? room, string sender, string text) + { + if (!ReferenceEquals(Connection, connection) || !IsCurrentConfiguration) return; + ChatDelivery delivery; + lock (_roomsGate) + { + delivery = new ChatDelivery(++_sequence, room, sender, text); + _messages.Enqueue(delivery); + while (_messages.Count > 500) _messages.Dequeue(); + } + MessageReceived?.Invoke(delivery); + } + + private void LoadRooms() + { + lock (_roomsGate) + { + _rooms.Clear(); + if (_accountKey != null && _api.Settings.ChatRoomsByAccount.TryGetValue(_accountKey, out var rooms)) + foreach (var room in rooms.Where(room => !string.IsNullOrWhiteSpace(room))) + _rooms[room] = new ChatRoomInfo { Name = room }; + } + } + + private void PersistRooms() + { + if (_accountKey == null) return; + var rooms = new Dictionary(_api.Settings.ChatRoomsByAccount) + { + [_accountKey] = _rooms.Keys.ToArray() + }; + _api.Settings.ChatRoomsByAccount = rooms; + try { _api.Settings.Save(); } + catch (Exception ex) { ReportError($"Sauvegarde des salons impossible : {ex.Message}"); } + } + + private void ReportError(string message) + { + if (App.Current is App { ServiceProvider: not null } && !Avalonia.Threading.Dispatcher.UIThread.CheckAccess()) + Avalonia.Threading.Dispatcher.UIThread.Post(() => _api.Settings.SetActionStatus(message, StatusSeverity.Error)); + else + _api.Settings.SetActionStatus(message, StatusSeverity.Error); + Error?.Invoke(message); + } + + private string? GetAccountKey() + { + if (!_api.HasValidSession) return null; + var token = _api.CurrentIdToken ?? _api.CurrentAccessToken; + if (string.IsNullOrEmpty(token)) return null; + var parts = token.Split('.'); + if (parts.Length != 3) return null; + try + { + var payload = parts[1].Replace('-', '+').Replace('_', '/'); + payload = payload.PadRight((payload.Length + 3) / 4 * 4, '='); + using var json = JsonDocument.Parse(Convert.FromBase64String(payload)); + if (!json.RootElement.TryGetProperty("sub", out var subject) || + subject.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(subject.GetString())) + return null; + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes( + _api.Settings.Authentication.Authority + "\n" + _api.Settings.ApiUrl + "\n" + subject.GetString()))); + } + catch (Exception ex) when (ex is FormatException or JsonException) + { + ReportError("Identite du compte illisible : les salons ne seront pas memorises."); + return null; + } + } + + private sealed class RetryPolicy : IRetryPolicy + { + public TimeSpan? NextRetryDelay(RetryContext retryContext) + => TimeSpan.FromSeconds(Math.Min(60, 2 + retryContext.PreviousRetryCount * 5)); + } +} diff --git a/src/PostIt/PostIt/Services/IBackgroundChatService.cs b/src/PostIt/PostIt/Services/IBackgroundChatService.cs new file mode 100644 index 000000000..0d636bad9 --- /dev/null +++ b/src/PostIt/PostIt/Services/IBackgroundChatService.cs @@ -0,0 +1,10 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace PostIt.Services; + +public interface IBackgroundChatService +{ + Task SynchronizeAsync(YavscApiClient api, bool enabled, CancellationToken ct = default); + Task DisableAsync(YavscApiClient api, CancellationToken ct = default); +} diff --git a/src/PostIt/PostIt/Services/MessagingSession.cs b/src/PostIt/PostIt/Services/MessagingSession.cs new file mode 100644 index 000000000..2688a8d39 --- /dev/null +++ b/src/PostIt/PostIt/Services/MessagingSession.cs @@ -0,0 +1,178 @@ +using System; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; +using PostIt.ViewModels; +using PostIt.ViewModels.Chat; + +namespace PostIt.Services; + +/// Coordinates process-scoped chat and the optional native background service. +public sealed class MessagingSession : IAsyncDisposable +{ + private readonly YavscApiClient _api; + private readonly SessionStatusViewModel _session; + private readonly ChatViewModel _chat; + private readonly CancellationTokenSource _lifetime = new(); + private readonly SemaphoreSlim _gate = new(1); + private bool _started; + private bool _connectOnLogin; + private bool? _servicePreference; + private bool _signingOut; + private Task? _maintenance; + private string? _apiUrl; + private string? _authority; + private CancellationTokenSource? _serviceOperation; + private readonly object _operationGate = new(); + + public MessagingSession(YavscApiClient api, SessionStatusViewModel session, ChatViewModel chat) + { + _api = api; + _session = session; + _chat = chat; + } + + public async Task StartAsync(bool connectOnLogin = true) + { + if (_started) return; + _started = true; + _connectOnLogin = connectOnLogin; + _apiUrl = _api.Settings.ApiUrl; + _authority = _api.Settings.Authentication.Authority; + _api.SigningOut += OnSigningOutAsync; + _session.PropertyChanged += OnSessionChanged; + _api.Settings.Saved += OnSettingsSaved; + _chat.ConnectionRequestedChanged += OnConnectionRequestedChanged; + if (_api.HasValidSession && _connectOnLogin) await _chat.ConnectAsync(); + _maintenance = MaintainChatAsync(_lifetime.Token); + await SynchronizeServiceAsync(); + } + + private async Task MaintainChatAsync(CancellationToken ct) + { + try + { + while (!ct.IsCancellationRequested) + { + if (!_api.HasValidSession && _chat.ConnectionRequested) + { + try { await OnSigningOutAsync(); } + catch (Exception ex) + { + _api.Settings.SetActionStatus($"Arret du service de messagerie incomplet : {ex.Message}", Models.StatusSeverity.Error); + } + _session.Refresh(); + } + if (!_signingOut && _api.HasValidSession && !_chat.IsConnected && _chat.ConnectionRequested) + await _chat.ConnectAsync(); + await SynchronizeServiceAsync(); + await Task.Delay(TimeSpan.FromSeconds(30), ct); + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { } + } + + private async void OnSessionChanged(object? sender, PropertyChangedEventArgs args) + { + if (args.PropertyName != nameof(SessionStatusViewModel.IsLoggedIn)) return; + if (_session.IsLoggedIn) _signingOut = false; + await SynchronizeServiceAsync(); + if (_session.IsLoggedIn && (_connectOnLogin || _chat.ConnectionRequested)) + { + await _chat.ConnectAsync(); + } + + } + + private async void OnConnectionRequestedChanged() + { + if (_signingOut || _lifetime.IsCancellationRequested) return; + await SynchronizeServiceAsync(); + } + + private async void OnSettingsSaved(object? sender, EventArgs args) + { + try + { + if (!_api.Settings.AndroidKeepChatConnected) CancelServiceOperation(); + if (_apiUrl != _api.Settings.ApiUrl || _authority != _api.Settings.Authentication.Authority) + { + _apiUrl = _api.Settings.ApiUrl; + _authority = _api.Settings.Authentication.Authority; + _servicePreference = null; + var reconnect = _connectOnLogin || _chat.ConnectionRequested; + await _chat.DisconnectAsync(); + if (_api.HasValidSession && reconnect) await _chat.ConnectAsync(); + } + await SynchronizeServiceAsync(); + } + catch (Exception ex) + { + _api.Settings.SetActionStatus($"Mise a jour de la messagerie impossible : {ex.Message}", Models.StatusSeverity.Error); + } + } + + private async Task SynchronizeServiceAsync() + { + await _gate.WaitAsync(); + try + { + var native = Platform.BackgroundChatService; + if (native == null || !_api.HasValidSession || _api.IsSigningOut || _signingOut) return; + var enabled = _api.Settings.AndroidKeepChatConnected && _chat.ConnectionRequested; + if (_servicePreference == enabled) return; + try + { + using var serviceOperation = CancellationTokenSource.CreateLinkedTokenSource(_lifetime.Token); + lock (_operationGate) _serviceOperation = serviceOperation; + try + { + await native.SynchronizeAsync(_api, enabled, serviceOperation.Token); + _servicePreference = enabled; + } + finally { lock (_operationGate) _serviceOperation = null; } + } + catch (OperationCanceledException) when (_signingOut || _lifetime.IsCancellationRequested || !_api.Settings.AndroidKeepChatConnected) { } + catch (Exception ex) + { + _api.Settings.SetActionStatus($"Service de messagerie Android indisponible : {ex.Message}", Models.StatusSeverity.Error); + } + } + finally { _gate.Release(); } + } + + private async Task OnSigningOutAsync() + { + _signingOut = true; + CancelServiceOperation(); + await _gate.WaitAsync(); + try + { + _servicePreference = null; + try + { + if (Platform.BackgroundChatService is { } native) + await native.DisableAsync(_api); + } + finally { await _chat.DisconnectAsync(); } + } + finally { _gate.Release(); } + } + + public async ValueTask DisposeAsync() + { + _api.SigningOut -= OnSigningOutAsync; + _session.PropertyChanged -= OnSessionChanged; + _api.Settings.Saved -= OnSettingsSaved; + _chat.ConnectionRequestedChanged -= OnConnectionRequestedChanged; + _lifetime.Cancel(); + if (_maintenance != null) await _maintenance; + await _chat.DisconnectAsync(); + _lifetime.Dispose(); + } + + private void CancelServiceOperation() + { + lock (_operationGate) _serviceOperation?.Cancel(); + } +} diff --git a/src/PostIt/PostIt/Services/Platform.cs b/src/PostIt/PostIt/Services/Platform.cs index 77bf4c691..a13446447 100644 --- a/src/PostIt/PostIt/Services/Platform.cs +++ b/src/PostIt/PostIt/Services/Platform.cs @@ -12,6 +12,8 @@ namespace PostIt.Services; /// public static class Platform { + public static IBackgroundChatService? BackgroundChatService { get; set; } + public static Func? CreateServiceProvider { get; set; } public static Func? GetLanCallDevices { get; set; } public static Func? CreateLanCallMedia { get; set; } public static Func? GetPodcastCaptureDevices { get; set; } diff --git a/src/PostIt/PostIt/Services/YavscApiClient.cs b/src/PostIt/PostIt/Services/YavscApiClient.cs index 1a747abff..e00441b1e 100644 --- a/src/PostIt/PostIt/Services/YavscApiClient.cs +++ b/src/PostIt/PostIt/Services/YavscApiClient.cs @@ -27,6 +27,9 @@ namespace PostIt.Services; /// public class YavscApiClient : IYavscApiClient, IAsyncDisposable { + public event Func? SigningOut; + private int _signingOut; + public bool IsSigningOut => Volatile.Read(ref _signingOut) != 0; // 60s of slack before the access_token's nominal expiry. Covers // network latency + JWT validation on the server side. private static readonly TimeSpan RefreshSkew = TimeSpan.FromSeconds(60); @@ -562,8 +565,27 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable public async Task LogoutAsync() { - _store.Clear(); - _tokens = null; + Interlocked.Exchange(ref _signingOut, 1); + try + { + if (SigningOut is { } handlers) + foreach (Func handler in handlers.GetInvocationList()) + await handler(); + } + finally + { + await _refreshGate.WaitAsync(); + try + { + try { _store.Clear(); } + finally { _tokens = null; } + } + finally + { + _refreshGate.Release(); + Interlocked.Exchange(ref _signingOut, 0); + } + } // Optional: clear the IdP session in the browser too. // await _oidc.LogoutAsync(new LogoutRequest()); await Task.CompletedTask; diff --git a/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs b/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs index d44ebcd65..144ab7e57 100644 --- a/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Threading.Tasks; +using System.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.AspNetCore.SignalR.Client; @@ -22,14 +23,30 @@ namespace PostIt.ViewModels.Chat { private readonly Settings _settings; private readonly YavscApiClient? _api; + private readonly ChatHubConnection? _transport; + private bool _handlersRegistered; + private readonly SemaphoreSlim _connectionGate = new(1); + private int _sessionVersion; + private readonly object _connectOperationGate = new(); + private CancellationTokenSource? _connectOperation; + private long _lastDeliverySequence; + public bool ConnectionRequested { get; private set; } + public event Action? ConnectionRequestedChanged; // Le paramètre optionnel permet l'instanciation design-time // (Design.DataContext dans ChatPage.axaml) sans conteneur DI. - public ChatViewModel(YavscApiClient? api = null) + public ChatViewModel(YavscApiClient? api = null, Settings? settings = null, ChatHubConnection? transport = null) { var app = App.Current as App; - _settings = app!.ServiceProvider!.GetRequiredService(); // Assurez-vous que _settings est initialisé avant utilisation. + _settings = settings ?? app!.ServiceProvider!.GetRequiredService(); _api = api; + _transport = transport; + if (_transport != null) + { + _transport.RoomJoined += room => PostForCurrentSession(() => OnRoomJoined(room)); + _transport.Error += message => PostForCurrentSession(() => Log(message)); + _transport.MessageReceived += delivery => PostForCurrentSession(() => OnMessageReceived(delivery)); + } Status = new StatusNotice("Déconnecté", StatusSeverity.Info, new RelayCheckCommand("Connecter", async () => await ConnectAsync())); FocusedMessages = _systemMessages; @@ -46,7 +63,7 @@ namespace PostIt.ViewModels.Chat public partial LanCallViewModel? LanCall { get; set; } [ObservableProperty] - public partial string CurrentRoom { get; set; } = "General"; + public partial string CurrentRoom { get; set; } = "General"; [ObservableProperty] public partial string NickName { get; set; } = ""; @@ -105,6 +122,30 @@ namespace PostIt.ViewModels.Chat FocusedMessages.Add(line); } + private void PostForCurrentSession(Action callback) + { + var version = Volatile.Read(ref _sessionVersion); + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + if (ConnectionRequested && version == Volatile.Read(ref _sessionVersion)) + callback(); + }); + } + + private void OnMessageReceived(ChatHubConnection.ChatDelivery delivery) + { + if (delivery.Sequence <= _lastDeliverySequence) return; + _lastDeliverySequence = delivery.Sequence; + if (delivery.Room == null) + { + Log($"[Prive] {delivery.Sender} : {delivery.Text}"); + return; + } + if (!_messagesByRoom.TryGetValue(delivery.Room, out var messages)) + messages = _messagesByRoom[delivery.Room] = new(); + messages.Add($"{delivery.Sender} : {delivery.Text}"); + } + // Commandes pour les boutons [RelayCommand] public async Task ConnectCommand() => await ConnectAsync(); @@ -112,6 +153,24 @@ namespace PostIt.ViewModels.Chat [RelayCommand] public async Task JoinRoomCommand() => await JoinRoomAsync(); + [RelayCommand] + public async Task LeaveRoom() + { + var room = SelectedRoom; + if (_transport == null || room == null) return; + try + { + if (LanCall != null) await LanCall.HangupAsync(); + await _transport.LeaveAsync(room); + JoinedRooms.Remove(room); + _messagesByRoom.Remove(room); + _usersByRoom.Remove(room); + SelectedRoom = JoinedRooms.Count > 0 ? JoinedRooms[0] : null; + Log($"Salon quitte : {room}"); + } + catch (Exception ex) { Log($"Impossible de quitter le salon : {ex.Message}"); } + } + // Le serveur (ChatHub.Send) refuse tout message d'un utilisateur // qui n'a pas joint le salon ("NoJoinNoSend") : on garde la // commande inactive tant qu'aucune salle n'est en focus. @@ -137,7 +196,18 @@ namespace PostIt.ViewModels.Chat public override bool CanNavigateNext { get; protected set; } = false; public override bool CanNavigatePrevious { get; protected set; } = true; - private async Task DisconnectAsync() + public async Task DisconnectAsync() + { + ConnectionRequested = false; + ConnectionRequestedChanged?.Invoke(); + lock (_connectOperationGate) _connectOperation?.Cancel(); + Interlocked.Increment(ref _sessionVersion); + await _connectionGate.WaitAsync(); + try { await DisconnectCoreAsync(); } + finally { _connectionGate.Release(); } + } + + private async Task DisconnectCoreAsync() { Exception? callError = null; if (LanCall != null) @@ -148,28 +218,55 @@ namespace PostIt.ViewModels.Chat } Calls?.Dispose(); Calls = null; - if (_connection != null) + try { - await _connection.StopAsync(); - await _connection.DisposeAsync(); - _connection = null; + if (_connection != null) + await _transport!.StopAsync(); + } + catch (Exception error) { callError = error; throw; } + finally + { + IsConnected = false; + SelectedRoom = null; + JoinedRooms.Clear(); + _messagesByRoom.Clear(); + _usersByRoom.Clear(); + _systemMessages.Clear(); + Status = new StatusNotice(callError == null ? "Déconnecté" : + "Déconnecté ; erreur de fermeture : " + callError.Message, + callError == null ? StatusSeverity.Info : StatusSeverity.Error, + new RelayCheckCommand("Connecter", async () => await ConnectAsync())); } - IsConnected = false; - SelectedRoom = null; - JoinedRooms.Clear(); - _messagesByRoom.Clear(); - _usersByRoom.Clear(); - _systemMessages.Clear(); - Status = new StatusNotice(callError == null ? "Déconnecté" : - "Déconnecté ; erreur de fermeture de l'appel : " + callError.Message, - callError == null ? StatusSeverity.Info : StatusSeverity.Error, - new RelayCheckCommand("Connecter", async () => await ConnectAsync())); } - private async Task ConnectAsync() + public async Task ConnectAsync() + { + if (_api?.IsSigningOut == true) + { + Status = StatusNotice.Info("La deconnexion du compte est en cours."); + return; + } + ConnectionRequested = true; + ConnectionRequestedChanged?.Invoke(); + await _connectionGate.WaitAsync(); + try + { + if (!ConnectionRequested) return; + using var operation = new CancellationTokenSource(); + lock (_connectOperationGate) _connectOperation = operation; + try { await ConnectCoreAsync(operation.Token); } + finally { lock (_connectOperationGate) _connectOperation = null; } + } + finally { _connectionGate.Release(); } + } + + private async Task ConnectCoreAsync(CancellationToken ct) { try { + if (_transport?.IsCurrentConfiguration == true && + _connection?.State is HubConnectionState.Connected or HubConnectionState.Reconnecting) + return; if (_connection != null) { if (LanCall != null) @@ -179,8 +276,6 @@ namespace PostIt.ViewModels.Chat } Calls?.Dispose(); Calls = null; - await _connection.DisposeAsync(); - _connection = null; } Status = new StatusNotice("Connexion en cours...", StatusSeverity.Info, new RelayCheckCommand("Vérifier la connexion", async () => await CheckConnectionAsync())); @@ -192,125 +287,92 @@ namespace PostIt.ViewModels.Chat return; } - // Le hub est mappé à la racine du service ("/chathub"), - // pas sous le préfixe versionné "/api/v1" : on le retire. - var hubBase = _settings.ApiUrl.TrimEnd('/'); - if (hubBase.EndsWith("/api/v1", StringComparison.OrdinalIgnoreCase)) - hubBase = hubBase[..^"/api/v1".Length]; - var hubUrl = hubBase + "/" + Constants.ChatHubPath; - - _connection = new HubConnectionBuilder() - .WithUrl(hubUrl, - - options => - { - // Le hub est [Authorize] : on attache le Bearer OIDC. - // AccessTokenProvider est rappelé à chaque reconnexion, - // donc on tente un refresh silencieux à chaque appel. - // Un refresh mort (invalid_grant) ne doit pas faire - // échouer StartAsync avec une exception interne : on - // renvoie le token courant (éventuellement expiré) et - // le serveur répondra un 401 propre, surfacé à l'UI. - options.AccessTokenProvider = async () => - { - await _api.TrySilentLoginAsync(); - return _api.CurrentAccessToken; - }; - }) - .WithAutomaticReconnect() - .Build(); + if (_transport == null) + throw new InvalidOperationException("Transport de messagerie indisponible."); + var connection = await _transport.GetConnectionAsync(ct); + if (!ReferenceEquals(_connection, connection)) + _handlersRegistered = false; + _connection = connection; + // Le hub est [Authorize] : on attache le Bearer OIDC. Calls = new CallSignalingClient(_connection); if (SupportsLanCalls) LanCall = new LanCallViewModel(Calls, settings: _settings) { Room = SelectedRoom ?? "" }; else - Calls.Invitation += call => Avalonia.Threading.Dispatcher.UIThread.Post(() => + Calls.Invitation += call => PostForCurrentSession(() => Log($"Invitation audio-vidéo de {call.Caller} dans {call.RoomName} ; transport non disponible sur cette plateforme.")); - Calls.Ended += call => Avalonia.Threading.Dispatcher.UIThread.Post(() => + Calls.Ended += call => PostForCurrentSession(() => Log($"Appel terminé : {call.Reason}.")); - // 1. Ecoute des notifications globales (notifyUser) - _connection.On("notifyUser", (type, user, msg) => + if (!_handlersRegistered) { - Avalonia.Threading.Dispatcher.UIThread.Post(() => - Log($"[{type}] {user}: {msg}")); - }); - - // 2. Ecoute des notifications de salon (notifyRoom) - _connection.On("notifyRoom", (type, room, user) => - { - Avalonia.Threading.Dispatcher.UIThread.Post(() => + // 1. Ecoute des notifications globales (notifyUser) + _connection.On("notifyUser", (type, user, msg) => { - if (_messagesByRoom.TryGetValue(room, out var roomLog)) - roomLog.Add($"*{user} a interagi avec le salon {room} (Action: {type})"); - else - Log($"*{user} a interagi avec le salon {room} (Action: {type})"); - if (type == "UserJoin" && _usersByRoom.TryGetValue(room, out var users) - && !users.Contains(user)) - users.Add(user); + PostForCurrentSession(() => + { + Log($"[{type}] {user}: {msg}"); + }); }); - }); - // 3. Réception de l'état complet suite au Join réussi (joint) - _connection.On("joint", (roomInfo) => - { - Avalonia.Threading.Dispatcher.UIThread.Post(() => OnRoomJoined(roomInfo)); - }); - - // 4. Réception des messages de salon (diffusés par ChatHub.Send) - _connection.On("ReceiveMessage", (msg) => - { - Avalonia.Threading.Dispatcher.UIThread.Post(() => + // 2. Ecoute des notifications de salon (notifyRoom) + _connection.On("notifyRoom", (type, room, user) => { - var room = msg.Room ?? "?"; - if (!_messagesByRoom.TryGetValue(room, out var roomLog)) - roomLog = _messagesByRoom[room] = new(); - roomLog.Add($"{msg.Name} : {msg.Message}"); + PostForCurrentSession(() => + { + if (_messagesByRoom.TryGetValue(room, out var roomLog)) + roomLog.Add($"*{user} a interagi avec le salon {room} (Action: {type})"); + else + Log($"*{user} a interagi avec le salon {room} (Action: {type})"); + if (type == "UserJoin" && _usersByRoom.TryGetValue(room, out var users) + && !users.Contains(user)) + users.Add(user); + }); }); - }); - await _connection.StartAsync(); + // WithAutomaticReconnect ne lève pas d'exception en cas de + // perte réseau : l'état passe par Reconnecting/Closed. Sans + // ces handlers, IsConnected resterait optimiste et Join() + // échouerait avec "connection is not active". + _connection.Reconnecting += error => + { + PostForCurrentSession(() => + { + IsConnected = false; + Status = new StatusNotice($"Connexion perdue ({error?.Message ?? "réseau"}), reconnexion...", StatusSeverity.Error, + new RelayCheckCommand("Déconnecter", async () => await DisconnectAsync())); + }); + return Task.CompletedTask; + }; + _connection.Reconnected += _ => + { + PostForCurrentSession(() => + { + IsConnected = true; + Status = new StatusNotice("Reconnecté au Hub SignalR", StatusSeverity.Info, + new RelayCheckCommand("Déconnecter", async () => await DisconnectAsync())); + }); + return Task.CompletedTask; + }; + _connection.Closed += error => + { + PostForCurrentSession(() => + { + IsConnected = false; + Status = new StatusNotice($"Déconnecté ({error?.Message ?? "fin de connexion"})", StatusSeverity.Info, + new RelayCheckCommand("Connecter", async () => await ConnectAsync())); + }); + return Task.CompletedTask; + }; + _handlersRegistered = true; + } + await _transport.StartAsync(ct); + foreach (var room in _transport.JoinedRooms) + OnRoomJoined(room); + foreach (var delivery in _transport.RecentMessages) + OnMessageReceived(delivery); IsConnected = true; Status = new StatusNotice("Connecté au Hub SignalR", StatusSeverity.Info, new RelayCheckCommand("Déconnecter", async () => await DisconnectAsync())); - - // WithAutomaticReconnect ne lève pas d'exception en cas de - // perte réseau : l'état passe par Reconnecting/Closed. Sans - // ces handlers, IsConnected resterait optimiste et Join() - // échouerait avec "connection is not active". - _connection.Reconnecting += error => - { - Avalonia.Threading.Dispatcher.UIThread.Post(() => - { - IsConnected = false; - Status = new StatusNotice($"Connexion perdue ({error?.Message ?? "réseau"}), reconnexion...", StatusSeverity.Error, - new RelayCheckCommand("Déconnecter", async () => await DisconnectAsync())); - }); - return Task.CompletedTask; - }; - _connection.Reconnected += async _ => - { - Avalonia.Threading.Dispatcher.UIThread.Post(() => - { - IsConnected = true; - Status = new StatusNotice("Reconnecté au Hub SignalR", StatusSeverity.Info, - new RelayCheckCommand("Déconnecter", async () => await DisconnectAsync())); - }); - // La reconnexion crée une nouvelle ConnectionId côté - // serveur : l'appartenance aux salons est perdue, il - // faut rejoindre à nouveau le salon courant. - if (!string.IsNullOrWhiteSpace(CurrentRoom)) - await JoinRoomAsync(); - }; - _connection.Closed += error => - { - Avalonia.Threading.Dispatcher.UIThread.Post(() => - { - IsConnected = false; - Status = new StatusNotice($"Déconnecté ({error?.Message ?? "fin de connexion"})", StatusSeverity.Info, - new RelayCheckCommand("Connecter", async () => await ConnectAsync())); - }); - return Task.CompletedTask; - }; } catch (Exception ex) { @@ -365,7 +427,7 @@ namespace PostIt.ViewModels.Chat Log($"Connexion non active ({_connection.State}), tentative de reconnexion..."); try { - await _connection.StartAsync(); + await _transport!.StartAsync(); } catch (Exception ex) { @@ -377,7 +439,7 @@ namespace PostIt.ViewModels.Chat try { // Appel de la méthode publique 'Join' du Hub (renvoie un ChatRoomInfo) - var info = await _connection.InvokeAsync("Join", CurrentRoom); + var info = await _transport!.JoinAsync(CurrentRoom); if (info == null) { Log("Erreur lors de la validation du salon par le serveur."); diff --git a/src/PostIt/PostIt/ViewModels/Layout/SessionStatusViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/SessionStatusViewModel.cs index f6bf2282a..dedf210f2 100644 --- a/src/PostIt/PostIt/ViewModels/Layout/SessionStatusViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Layout/SessionStatusViewModel.cs @@ -166,7 +166,14 @@ public partial class SessionStatusViewModel : ViewModelBase public async System.Threading.Tasks.Task LogoutAsync() { if (Api is null) return; - await Api.LogoutAsync().ConfigureAwait(false); + try { await Api.LogoutAsync().ConfigureAwait(true); } + catch (Exception ex) + { + Refresh(); + SessionLabel = $"Deconnecte ; nettoyage de la messagerie incomplet : {ex.Message}"; + LogoutCompleted?.Invoke(); + return; + } Refresh(); LogoutCompleted?.Invoke(); } diff --git a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs index b8bbb2702..6b10f982b 100644 --- a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs +++ b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs @@ -18,6 +18,7 @@ namespace PostIt.ViewModels; public partial class Settings : ViewModelBase { + public event EventHandler? Saved; public Settings() { } internal Settings(string settingsFileFullName) => SettingsFileFullName = settingsFileFullName; @@ -58,6 +59,12 @@ public partial class Settings : ViewModelBase public partial string? PodcastMicrophoneId { get; set; } [ObservableProperty] public partial string? LanCallAddress { get; set; } + [ObservableProperty] + public partial bool AndroidKeepChatConnected { get; set; } + [ObservableProperty] + public partial Dictionary ChatRoomsByAccount { get; set; } = new(); + [JsonIgnore] + public bool SupportsBackgroundChatService => OperatingSystem.IsAndroid(); [ObservableProperty] [JsonIgnore] @@ -159,6 +166,8 @@ public partial class Settings : ViewModelBase partial void OnPodcastCameraIdChanged(string? value) => MarkDirty(); partial void OnPodcastMicrophoneIdChanged(string? value) => MarkDirty(); partial void OnLanCallAddressChanged(string? value) => MarkDirty(); + partial void OnAndroidKeepChatConnectedChanged(bool value) => MarkDirty(); + partial void OnChatRoomsByAccountChanged(Dictionary value) => MarkDirty(); /// /// Authentication can be reassigned wholesale by @@ -422,6 +431,8 @@ public partial class Settings : ViewModelBase PodcastCameraId = settings.PodcastCameraId; PodcastMicrophoneId = settings.PodcastMicrophoneId; LanCallAddress = settings.LanCallAddress; + AndroidKeepChatConnected = settings.AndroidKeepChatConnected; + ChatRoomsByAccount = settings.ChatRoomsByAccount ?? new(); if (!(settings.Authentication is null)) { this.Authentication = new AuthenticationSettings(); @@ -559,6 +570,7 @@ public partial class Settings : ViewModelBase throw; } } + Saved?.Invoke(this, EventArgs.Empty); } private bool CanSave() => IsDirty; diff --git a/src/PostIt/PostIt/Views/Chat/ChatPage.axaml b/src/PostIt/PostIt/Views/Chat/ChatPage.axaml index d2da099bb..6dd8ab95a 100644 --- a/src/PostIt/PostIt/Views/Chat/ChatPage.axaml +++ b/src/PostIt/PostIt/Views/Chat/ChatPage.axaml @@ -37,6 +37,7 @@ Text="{Binding CurrentRoom}" PlaceholderText="Nom du salon" />