feat(postit): keep Android SignalR chat in background without ongoing notification

Share the chat transport between UI, calls and an opt-in background service. Restore account rooms and attempt boot restart, keeping event alerts independent from connection permissions. Document Android limits and add lifecycle regressions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-11-03 12:43:46 +00:00
commit 311d55dcc6
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
32 changed files with 2210 additions and 143 deletions

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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))
{

View file

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

View file

@ -30,6 +30,7 @@
<ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup>
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
<Compile Include="..\PostIt.Tests\AndroidChatServiceTestActivity.cs" Link="Tests\AndroidChatServiceTestActivity.cs" />
<Compile Include="..\PostIt.Tests\AndroidPodcastCaptureTestActivity.cs" Link="Tests\AndroidPodcastCaptureTestActivity.cs" />
<Compile Include="..\PostIt.Tests\AndroidCameraFrameRotationTestActivity.cs" Link="Tests\AndroidCameraFrameRotationTestActivity.cs" />
<Compile Include="..\PostIt.Tests\AndroidLanCallTestActivity.cs" Link="Tests\AndroidLanCallTestActivity.cs" />

View file

@ -1,9 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M4,2h16a2,2 0,0 1,2 2v12a2,2 0,0 1,-2 2H8l-4,4V4a2,2 0,0 1,2,-2z" />
</vector>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,80 @@
#if DEBUG
using System;
using System.Threading;
using System.Threading.Tasks;
using PostIt.Android.Services;
namespace PostIt.Android.Tests;
/// <summary>Platform-free deterministic launch/disable races; also invoked by the Android debug activity.</summary>
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

View file

@ -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;
/// <summary>Debug-only boot composition test, without Avalonia or network startup.</summary>
[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<YavscApiClient>(),
ui.GetRequiredService<YavscApiClient>()), "API client differs");
Require(ReferenceEquals(boot.GetRequiredService<ChatHubConnection>(),
ui.GetRequiredService<ChatHubConnection>()), "chat transport differs");
Require(ReferenceEquals(boot.GetRequiredService<Settings>(),
ui.GetRequiredService<YavscApiClient>().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

View file

@ -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<CheckBox>("BackgroundChatCheckBox")!;
var container = checkbox.GetVisualAncestors().OfType<StackPanel>().First();
Assert.Equal(OperatingSystem.IsAndroid(), container.IsVisible);
var explanation = Assert.Single(container.Children.OfType<TextBlock>()).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<HttpRequestException>(() => 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<InvalidOperationException>(() => 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<ConnectionCounter>();
await using var server = builder.Build();
server.MapHub<TestChatHub>("/" + 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<int>("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<ConnectionCounter>();
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<int>("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<ConnectionCounter>();
await using var server = builder.Build();
server.MapHub<TestChatHub>("/" + 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<int>("ConnectionCount"));
Assert.Equal("SavedRoom", Assert.Single(resumed.JoinedRooms).Name);
Assert.Equal(new[] { "SavedRoom" }, server.Services.GetRequiredService<ConnectionCounter>().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<int>("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<string> 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<bool> 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;
}
}
}

View file

@ -20,12 +20,15 @@
<PackageReference Include="Avalonia.Headless.XUnit" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\PostIt.Android\Services\ChatServiceEpoch.cs" Link="Android\ChatServiceEpoch.cs" />
<Compile Include="..\PostIt.Android\Tests\ChatServiceEpochTests.cs" Link="Android\ChatServiceEpochTests.cs" />
<Compile Include="..\PostIt.Android\Services\CameraFrameOrientation.cs" Link="Android\CameraFrameOrientation.cs" />
<Compile Include="..\PostIt.Android\Services\CameraFrameClock.cs" Link="Android\CameraFrameClock.cs" />
</ItemGroup>

View file

@ -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<Settings>();
if (!LiveMode)
@ -295,6 +295,7 @@ public partial class App : Application, ILiveView, IVMPusherApp
await api.TrySilentLoginAsync().ConfigureAwait(true);
var sessionStatus = provider.GetRequiredService<SessionStatusViewModel>();
await sessionStatus.RefreshAsync();
await provider.GetRequiredService<MessagingSession>().StartAsync(connectOnLogin: OperatingSystem.IsAndroid());
}
/// <summary>

View file

@ -53,6 +53,8 @@ public static class ServiceCollectionHelpers
// Models
services.AddSingleton<ChatViewModel>();
services.AddSingleton<ChatHubConnection>();
services.AddSingleton<MessagingSession>();
// Android activities need fresh controls; retain state in singleton VMs.
void AddReusablePage<TPage>() where TPage : class

View file

@ -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;
/// <summary>One authenticated chat transport per application process, independent of page lifetime.</summary>
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<string, ChatRoomInfo> _rooms = new(StringComparer.Ordinal);
private readonly Queue<ChatDelivery> _messages = new();
private long _sequence;
public sealed record ChatDelivery(long Sequence, string? Room, string Sender, string Text);
public event Action<ChatDelivery>? MessageReceived;
public ChatDelivery[] RecentMessages
{
get { lock (_roomsGate) return _messages.ToArray(); }
}
public event Action<ChatRoomInfo>? RoomJoined;
public event Action<string>? 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<ChatRoomInfo>("joint", room =>
{
if (ReferenceEquals(Connection, connection) && IsCurrentConfiguration)
RememberRoom(room);
});
connection.On<ChatMessage>("ReceiveMessage", message =>
RememberMessage(connection, message.Room, message.Name ?? "", message.Message ?? ""));
connection.On<string, string>("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<HubConnection> 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<ChatRoomInfo> JoinAsync(string room, CancellationToken ct = default)
{
var connection = Connection;
var info = await connection.InvokeAsync<ChatRoomInfo>("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<ChatRoomInfo>("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<string, string[]>(_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));
}
}

View file

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

View file

@ -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;
/// <summary>Coordinates process-scoped chat and the optional native background service.</summary>
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();
}
}

View file

@ -12,6 +12,8 @@ namespace PostIt.Services;
/// </summary>
public static class Platform
{
public static IBackgroundChatService? BackgroundChatService { get; set; }
public static Func<IServiceProvider>? CreateServiceProvider { get; set; }
public static Func<PodcastCaptureDevices>? GetLanCallDevices { get; set; }
public static Func<string, int, ILanCallMedia>? CreateLanCallMedia { get; set; }
public static Func<PodcastCaptureDevices>? GetPodcastCaptureDevices { get; set; }

View file

@ -27,6 +27,9 @@ namespace PostIt.Services;
/// </summary>
public class YavscApiClient : IYavscApiClient, IAsyncDisposable
{
public event Func<Task>? 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<Task> 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;

View file

@ -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<Settings>(); // Assurez-vous que _settings est initialisé avant utilisation.
_settings = settings ?? app!.ServiceProvider!.GetRequiredService<Settings>();
_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<string, string, string>("notifyUser", (type, user, msg) =>
if (!_handlersRegistered)
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
Log($"[{type}] {user}: {msg}"));
});
// 2. Ecoute des notifications de salon (notifyRoom)
_connection.On<string, string, string>("notifyRoom", (type, room, user) =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
// 1. Ecoute des notifications globales (notifyUser)
_connection.On<string, string, string>("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<ChatRoomInfo>("joint", (roomInfo) =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() => OnRoomJoined(roomInfo));
});
// 4. Réception des messages de salon (diffusés par ChatHub.Send)
_connection.On<ChatMessage>("ReceiveMessage", (msg) =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
// 2. Ecoute des notifications de salon (notifyRoom)
_connection.On<string, string, string>("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<ChatRoomInfo>("Join", CurrentRoom);
var info = await _transport!.JoinAsync(CurrentRoom);
if (info == null)
{
Log("Erreur lors de la validation du salon par le serveur.");

View file

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

View file

@ -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<string, string[]> 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<string, string[]> value) => MarkDirty();
/// <summary>
/// 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;

View file

@ -37,6 +37,7 @@
Text="{Binding CurrentRoom}"
PlaceholderText="Nom du salon" />
<Button Command="{Binding JoinRoomCommand}" Content="Rejoindre Salon (Join)" />
<Button Command="{Binding LeaveRoomCommand}" Content="Quitter le salon" IsEnabled="{Binding !!SelectedRoom}" />
<!-- Choix de la salle en focus parmi celles jointes -->
<ComboBox
Width="180"

View file

@ -48,6 +48,13 @@
<CheckBox Grid.Row="11" x:Name="DarkModeCheckBox" IsChecked="{Binding DarkMode, Mode=TwoWay}"/>
<StackPanel Grid.Row="12" Spacing="8">
<StackPanel IsVisible="{Binding SupportsBackgroundChatService}" Spacing="6">
<CheckBox x:Name="BackgroundChatCheckBox"
Content="Garder la connexion au serveur active et la reprendre au demarrage du telephone"
IsChecked="{Binding AndroidKeepChatConnected, Mode=TwoWay}" />
<TextBlock Text="SignalR uniquement, sans notification permanente. Les alertes s'affichent seulement a reception d'un message ou evenement. Autorisez les donnees en arriere-plan et, si souhaite, desactivez l'optimisation batterie pour PostIt dans Android. Ces reglages ne garantissent pas la survie du service ni sa reprise au demarrage. Sans cette option, la connexion dure pendant la vie du processus PostIt."
TextWrapping="Wrap" />
</StackPanel>
<TextBlock Text="Avatar: PNG, JPG/JPEG, WEBP, GIF. Taille max 2 MB."
Opacity="0.8"
TextWrapping="Wrap" />