From 256fd5738c881a8c4db9e2c900e24be385aa74da Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Wed, 4 Nov 2026 09:11:21 +0000 Subject: [PATCH] refresh the token --- CHANGELOG.md | 2 + doc/architecture/notifications.md | 45 ++++- src/PostIt/PostIt.Android/MainActivity.cs | 3 + .../Properties/AndroidManifest.xml | 1 + .../Services/AndroidBatteryOptimization.cs | 51 ++++++ .../Services/AndroidServiceProvider.cs | 5 + .../Services/ChatConnectionService.cs | 1 + .../AndroidChatServiceTestActivity.cs | 12 ++ .../PostIt.Tests/MessagingSessionTests.cs | 55 ++++++ .../PostIt.Tests/YavscApiClientTests.cs | 157 +++++++++++++++++- src/PostIt/PostIt/App.axaml.cs | 1 + src/PostIt/PostIt/Services/Platform.cs | 12 ++ src/PostIt/PostIt/Services/YavscApiClient.cs | 102 +++++++++++- .../PostIt/ViewModels/Settings/Settings.cs | 14 +- .../PostIt/Views/Layout/SettingsPage.axaml | 7 +- 15 files changed, 458 insertions(+), 10 deletions(-) create mode 100644 src/PostIt/PostIt.Android/Services/AndroidBatteryOptimization.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index e64d84633..79684a2c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Added +* [PostIt] Schedule silent OIDC renewal independently of API traffic and SignalR reconnects, while the app is active or background chat is enabled. Derive the wait from the access-token expiration minus the existing 60-second safety margin instead of polling; recalculate after token rotation, login/logout, activity and preference changes. Persist rotated tokens and serialize with request-driven renewal/logout. Run the same maintenance in headless Android chat; background execution and absolute refresh-token lifetime remain subject to Android/identity-provider limits. +* [PostIt.Android] Request Android's battery optimization exemption when explicitly saving settings with background chat enabled, only if not already exempt. Keep automatic settings saves and reconnects free of prompts; refusal does not disable SignalR. * [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. diff --git a/doc/architecture/notifications.md b/doc/architecture/notifications.md index 555667a42..a8024d7f7 100644 --- a/doc/architecture/notifications.md +++ b/doc/architecture/notifications.md @@ -224,6 +224,20 @@ 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. +Lorsque l'utilisateur clique sur « Sauver » avec cette option activée, +PostIt vérifie `PowerManager.IsIgnoringBatteryOptimizations` : +- si l'application n'est pas exemptée, ouvrir + `ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` avec l'URI `package:...` ; +- si elle est déjà exemptée, ne rien ouvrir ni demander. + +La permission `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` est déclarée pour +cette demande explicite. Android conserve la décision finale ; refuser +ne désactive ni l'option ni SignalR. Les sauvegardes internes (jetons, +salons), reconnexions et démarrages du téléphone n'ouvrent pas cet écran. +Cette demande directe d'exemption est soumise aux règles Google Play +applicables à l'usage de cette permission ; sa présence ne vaut pas +approbation pour la publication. + - **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 @@ -250,6 +264,35 @@ 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. +### Renouvellement de session + +Une maintenance unique par client API démarre avec l'interface et aussi +avec le service Android démarré sans interface. Elle programme une attente +à partir de `AccessTokenExpiresAt` (échéance calculée depuis la durée fournie +par le serveur OIDC, ou le claim JWT `exp`), moins la marge de sécurité de +60 secondes, indépendamment des appels HTTP et des reconnexions SignalR : +- pendant l'activité Android visible (sur desktop, pendant l'exécution + de l'application), même si « Garder la connexion » est désactivé ; +- ou avec « Garder la connexion » activé, même si l'activité est masquée. + +Le renouvellement utilise le `refresh_token` lorsque l'**access token** +entre dans sa marge d'expiration de 60 secondes. Les renouvellements +concurrents partagent le même verrou ; les jetons tournants sont +enregistrés et aucun renouvellement n'est engagé pendant la déconnexion. +Après chaque renouvellement, l'attente est recalculée avec la nouvelle +échéance. Connexion, déconnexion, changement d'activité ou de l'option +réveillent aussi l'ordonnanceur. Sans session ou lorsque les deux conditions +d'activité sont fausses, il attend un changement, sans contrôle périodique. +Un échec transitoire est signalé et retenté après 30 secondes ; un rejet +définitif exige une nouvelle connexion. + +Le serveur ne fournit pas ici d'échéance du refresh token au client : +PostIt n'en invente pas et ne peut pas repousser sa durée de vie absolue. +La rotation régulière ne garantit pas de prolonger une session révoquée +ou arrivée à sa limite absolue. Un processus suspendu ou arrêté par Android +ne peut pas exécuter ce timer ; au retour, le renouvellement reprend si la +session reste renouvelable. + ### Notifications et limites Le service écoute les messages privés, les messages des salons rejoints et @@ -275,7 +318,7 @@ Cette solution est de type *best effort*, pas une connexion immortelle : 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. + plan, tâche périodique ou exemption sans consentement 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 diff --git a/src/PostIt/PostIt.Android/MainActivity.cs b/src/PostIt/PostIt.Android/MainActivity.cs index ed092fefc..687b8bf51 100644 --- a/src/PostIt/PostIt.Android/MainActivity.cs +++ b/src/PostIt/PostIt.Android/MainActivity.cs @@ -71,11 +71,13 @@ public class MainActivity : AvaloniaMainActivity { base.OnResume(); IsForeground = true; + PostIt.Services.Platform.IsApplicationActive = true; } protected override void OnPause() { IsForeground = false; + PostIt.Services.Platform.IsApplicationActive = false; base.OnPause(); } @@ -85,6 +87,7 @@ public class MainActivity : AvaloniaMainActivity { Current = null; IsForeground = false; + PostIt.Services.Platform.IsApplicationActive = false; PostIt.Android.Services.NotificationPermission.OnActivityDestroyed(); } base.OnDestroy(); diff --git a/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml b/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml index fc3422e71..2b3834bb5 100644 --- a/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml +++ b/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml @@ -6,6 +6,7 @@ + diff --git a/src/PostIt/PostIt.Android/Services/AndroidBatteryOptimization.cs b/src/PostIt/PostIt.Android/Services/AndroidBatteryOptimization.cs new file mode 100644 index 000000000..c3b5bb39e --- /dev/null +++ b/src/PostIt/PostIt.Android/Services/AndroidBatteryOptimization.cs @@ -0,0 +1,51 @@ +using System; +using Android.Content; +using Android.OS; +using Android.Util; +using PostIt.ViewModels; +using AndroidSettingsIntent = Android.Provider.Settings; + +namespace PostIt.Android.Services; + +internal static class AndroidBatteryOptimization +{ + internal static Intent? CreateIntent(Context context) + { + var packageName = context.PackageName + ?? throw new InvalidOperationException("Identifiant de l'application Android indisponible."); + var power = context.GetSystemService(Context.PowerService) as PowerManager + ?? throw new InvalidOperationException("Gestion de la batterie Android indisponible."); + return power.IsIgnoringBatteryOptimizations(packageName) + ? null + : new Intent(AndroidSettingsIntent.ActionRequestIgnoreBatteryOptimizations) + .SetData(global::Android.Net.Uri.Parse("package:" + packageName)); + } + + internal static void Request(Settings settings) + { + if (MainActivity.Current is not { IsFinishing: false, IsDestroyed: false } activity + || !MainActivity.IsForeground) + { + AndroidSettings.ReportError(settings, + "Ouvrez PostIt pour demander l'exemption d'optimisation batterie."); + return; + } + activity.RunOnUiThread(() => + { + try + { + if (!MainActivity.IsForeground || activity.IsFinishing || activity.IsDestroyed) + throw new InvalidOperationException("PostIt doit etre visible pour ouvrir les parametres batterie."); + if (!settings.AndroidKeepChatConnected) return; + using var intent = CreateIntent(activity); + if (intent is not null) activity.StartActivity(intent); + } + catch (Exception ex) + { + Log.Error("PostIt.Chat", "Battery optimization settings failed: " + ex); + AndroidSettings.ReportError(settings, + "Impossible d'ouvrir les parametres batterie Android : " + ex.Message); + } + }); + } +} diff --git a/src/PostIt/PostIt.Android/Services/AndroidServiceProvider.cs b/src/PostIt/PostIt.Android/Services/AndroidServiceProvider.cs index 10eb74a42..a4af45f45 100644 --- a/src/PostIt/PostIt.Android/Services/AndroidServiceProvider.cs +++ b/src/PostIt/PostIt.Android/Services/AndroidServiceProvider.cs @@ -16,6 +16,11 @@ internal static class AndroidServiceProvider if (!api.Settings.AndroidKeepChatConnected) AndroidBackgroundChatService.Invalidate(); ChatBootState.Save(api); }; + api.Settings.Applied += (_, _) => + { + if (api.Settings.AndroidKeepChatConnected) + AndroidBatteryOptimization.Request(api.Settings); + }; api.SigningOut += () => { AndroidBackgroundChatService.Invalidate(); diff --git a/src/PostIt/PostIt.Android/Services/ChatConnectionService.cs b/src/PostIt/PostIt.Android/Services/ChatConnectionService.cs index c72a29ebc..f887a7d31 100644 --- a/src/PostIt/PostIt.Android/Services/ChatConnectionService.cs +++ b/src/PostIt/PostIt.Android/Services/ChatConnectionService.cs @@ -91,6 +91,7 @@ public sealed class ChatConnectionService : Service if (!ChatBootState.CanStart(_api) || identity is null || identity != ChatBootState.Identity(_api)) throw new BackgroundChatException(BackgroundChatFailure.SessionRequired, "Connexion de fond inactive : option ou session absente."); + _api.StartSessionMaintenance(); if (!AndroidBackgroundChatService.Epoch.Run(epoch, () => { if (_epoch == epoch && _loop is { IsCompleted: false } && !_stop.IsCancellationRequested) diff --git a/src/PostIt/PostIt.Tests/AndroidChatServiceTestActivity.cs b/src/PostIt/PostIt.Tests/AndroidChatServiceTestActivity.cs index 465f779a2..cde74694f 100644 --- a/src/PostIt/PostIt.Tests/AndroidChatServiceTestActivity.cs +++ b/src/PostIt/PostIt.Tests/AndroidChatServiceTestActivity.cs @@ -36,6 +36,18 @@ public sealed class AndroidChatServiceTestActivity : Activity ui.GetRequiredService().Settings), "settings differ"); Require(ReferenceEquals(Platform.BackgroundChatService, AndroidBackgroundChatService.Instance), "native adapter missing"); + using (var battery = AndroidBatteryOptimization.CreateIntent(this)) + { + var power = (PowerManager)GetSystemService(PowerService)!; + var exempt = power.IsIgnoringBatteryOptimizations(PackageName!); + Require(exempt ? battery is null + : battery?.Action == global::Android.Provider.Settings.ActionRequestIgnoreBatteryOptimizations, + "battery exemption must not prompt when already granted"); + Require(battery?.DataString == (exempt ? null : "package:" + PackageName), + "battery settings package mismatch"); + Require(CheckSelfPermission("android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS") + == Permission.Granted, "battery exemption request permission missing"); + } if (OperatingSystem.IsAndroidVersionAtLeast(29)) { var name = new ComponentName(this, Java.Lang.Class.FromType(typeof(ChatConnectionService))); diff --git a/src/PostIt/PostIt.Tests/MessagingSessionTests.cs b/src/PostIt/PostIt.Tests/MessagingSessionTests.cs index aa7a76930..10e699e50 100644 --- a/src/PostIt/PostIt.Tests/MessagingSessionTests.cs +++ b/src/PostIt/PostIt.Tests/MessagingSessionTests.cs @@ -20,6 +20,58 @@ namespace PostIt.Tests; public sealed class MessagingSessionTests { + [Fact] + public void Only_explicit_settings_application_raises_Applied_after_successful_save() + { + var path = Path.Combine(Path.GetTempPath(), $"postit-apply-{Guid.NewGuid():N}.json"); + try + { + var settings = new Settings(path); + var saves = 0; + var applications = 0; + settings.Saved += (_, _) => saves++; + settings.Applied += (_, _) => + { + Assert.False(settings.IsDirty); + Assert.True(File.Exists(path)); + applications++; + }; + settings.AndroidKeepChatConnected = true; + settings.Save(); + Assert.Equal(0, applications); + settings.DarkMode = true; + Assert.True(settings.ApplyCommand.CanExecute(null)); + settings.ApplyCommand.Execute(null); + Assert.Equal(1, applications); + Assert.Equal(2, saves); + Assert.False(settings.ApplyCommand.CanExecute(null)); + settings.AndroidKeepChatConnected = false; + Assert.True(settings.ApplyCommand.CanExecute(null)); + settings.Apply(); + Assert.Equal(2, applications); + } + finally { File.Delete(path); } + } + + [Fact] + public void Failed_settings_save_does_not_raise_Applied() + { + var directory = Path.Combine(Path.GetTempPath(), $"postit-apply-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + var settings = new Settings(directory); + var applied = false; + settings.Applied += (_, _) => applied = true; + settings.AndroidKeepChatConnected = true; + var failure = Record.Exception(() => settings.Apply()); + Assert.True(failure is IOException or UnauthorizedAccessException); + Assert.False(applied); + Assert.True(settings.IsDirty); + } + finally { Directory.Delete(directory); } + } + #if DEBUG [Fact] public async Task Native_start_generations_reject_stale_requests_after_stop_and_timeout() @@ -42,6 +94,9 @@ public sealed class MessagingSessionTests Assert.Contains("sans notification permanente", explanation); Assert.Contains("ne garantissent pas", explanation); Assert.False(checkbox.IsChecked); + var save = Assert.Single(window.GetVisualDescendants().OfType