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