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" />
+
+
+
+
+