refresh the token

This commit is contained in:
Paul Schneider 2026-11-04 09:11:21 +00:00
commit 256fd5738c
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
15 changed files with 458 additions and 10 deletions

View file

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

View file

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

View file

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

View file

@ -6,6 +6,7 @@
<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.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<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,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);
}
});
}
}

View file

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

View file

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

View file

@ -36,6 +36,18 @@ public sealed class AndroidChatServiceTestActivity : Activity
ui.GetRequiredService<YavscApiClient>().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)));

View file

@ -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<Button>(),
button => Equals(button.Content, "Sauver"));
Assert.Same(settings.ApplyCommand, save.Command);
checkbox.IsChecked = true;
Assert.True(settings.AndroidKeepChatConnected);
Assert.True(settings.IsDirty);

View file

@ -17,6 +17,159 @@ namespace PostIt.Tests;
/// </summary>
public class YavscApiClientTests
{
[Theory]
[InlineData(600, true, false, 540)]
[InlineData(3600, false, true, 3540)]
[InlineData(40, true, false, 0)]
[InlineData(-5, true, false, 0)]
[InlineData(600, false, false, -1)]
public async Task Maintenance_delay_is_derived_from_the_persisted_access_token_expiry(
int lifetimeSeconds, bool active, bool keepConnected, int expectedDelaySeconds)
{
var path = TokensPath();
var now = DateTimeOffset.UtcNow;
try
{
var store = new TokenStore(path);
store.Save(new RefreshTokenRecord("access", "refresh", now.AddSeconds(lifetimeSeconds)));
var settings = new Settings();
await using var client = new YavscApiClient(settings, store);
settings.AndroidKeepChatConnected = keepConnected;
Assert.Equal(expectedDelaySeconds < 0 ? Timeout.InfiniteTimeSpan
: TimeSpan.FromSeconds(expectedDelaySeconds), client.GetSessionRefreshDelay(now, active));
}
finally { File.Delete(path); }
}
[Theory]
[InlineData(true, false, 40, 1)]
[InlineData(false, true, 40, 1)]
[InlineData(false, false, 40, 0)]
[InlineData(true, true, 180, 0)]
public async Task Session_maintenance_refreshes_before_expiry_only_while_active_or_opted_in(
bool active, bool keepConnected, int secondsUntilExpiry, int expectedRefreshes)
{
using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer();
await apiServer.StartAsync();
var settings = BuildSettings(authority, apiServer.BaseUrl);
settings.AndroidKeepChatConnected = keepConnected;
var tokensPath = TokensPath();
try
{
await using var original = await LoginAndPersistAsync(settings, authority, tokensPath);
var store = new TokenStore(tokensPath);
var previous = store.Load()!;
var expiry = DateTimeOffset.UtcNow.AddSeconds(secondsUntilExpiry);
store.Save(previous with { AccessTokenExpiresAt = expiry });
await using var client = new YavscApiClient(settings, store);
await Task.WhenAll(Enumerable.Range(0, 8).Select(_ =>
client.RefreshSessionIfNeededAsync(active, TestContext.Current.CancellationToken)));
Assert.Equal(expectedRefreshes, authority.RefreshRequestCount);
Assert.Equal(0, apiServer.RequestCount);
Assert.True(expiry > DateTimeOffset.UtcNow, "Refresh must occur before expiry, not after a 401.");
var persisted = store.Load()!;
if (expectedRefreshes > 0)
{
Assert.NotEqual(previous.RefreshToken, persisted.RefreshToken);
Assert.True(persisted.AccessTokenExpiresAt > expiry);
}
else Assert.Equal(previous.RefreshToken, persisted.RefreshToken);
}
finally { File.Delete(tokensPath); }
}
[Fact]
public async Task Scheduled_maintenance_runs_without_an_API_call_and_starts_only_once()
{
using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer();
await apiServer.StartAsync();
var settings = BuildSettings(authority, apiServer.BaseUrl);
settings.AndroidKeepChatConnected = true;
var tokensPath = TokensPath();
try
{
await using var original = await LoginAndPersistAsync(settings, authority, tokensPath);
var store = new TokenStore(tokensPath);
store.Save(store.Load()! with { AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(62) });
await using var client = new YavscApiClient(settings, store);
var scheduledDelay = client.GetSessionRefreshDelay(DateTimeOffset.UtcNow, true);
Assert.InRange(scheduledDelay.TotalSeconds, 1, 2);
client.StartSessionMaintenance();
client.StartSessionMaintenance();
await Task.Delay(250, TestContext.Current.CancellationToken);
Assert.Equal(0, authority.RefreshRequestCount);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(10));
while (store.Load()!.AccessTokenExpiresAt - DateTimeOffset.UtcNow < TimeSpan.FromMinutes(2))
await Task.Delay(20, timeout.Token);
Assert.Equal(1, authority.RefreshRequestCount);
Assert.Equal(0, apiServer.RequestCount);
Assert.InRange(client.GetSessionRefreshDelay(DateTimeOffset.UtcNow, true).TotalSeconds, 500, 600);
}
finally { File.Delete(tokensPath); }
}
[Fact]
public async Task Session_maintenance_rejects_revoked_tokens_and_does_not_retry_after_logout()
{
using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer();
await apiServer.StartAsync();
var settings = BuildSettings(authority, apiServer.BaseUrl);
var tokensPath = TokensPath();
try
{
await using var original = await LoginAndPersistAsync(settings, authority, tokensPath);
var store = new TokenStore(tokensPath);
store.Save(store.Load()! with
{
RefreshToken = "revoked-refresh-token",
AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(40)
});
await using var client = new YavscApiClient(settings, store);
var ct = TestContext.Current.CancellationToken;
client.SigningOut += () => client.RefreshSessionIfNeededAsync(true, ct);
var error = await Assert.ThrowsAsync<RefreshFailedException>(() =>
client.RefreshSessionIfNeededAsync(true, ct));
Assert.True(error.IsPermanent);
Assert.Null(store.Load());
Assert.False(client.HasValidSession);
await client.LogoutAsync();
await client.RefreshSessionIfNeededAsync(true, ct);
Assert.Equal(1, authority.RefreshRequestCount);
}
finally { File.Delete(tokensPath); }
}
[Fact]
public async Task Session_maintenance_does_not_refresh_a_due_token_during_logout()
{
using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer();
await apiServer.StartAsync();
var settings = BuildSettings(authority, apiServer.BaseUrl);
var tokensPath = TokensPath();
try
{
await using var original = await LoginAndPersistAsync(settings, authority, tokensPath);
var store = new TokenStore(tokensPath);
store.Save(store.Load()! with { AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(40) });
await using var client = new YavscApiClient(settings, store);
client.SigningOut += () =>
{
Assert.True(client.HasValidSession);
return client.RefreshSessionIfNeededAsync(true, TestContext.Current.CancellationToken);
};
await client.LogoutAsync();
Assert.Equal(0, authority.RefreshRequestCount);
Assert.Null(store.Load());
Assert.False(client.HasValidSession);
}
finally { File.Delete(tokensPath); }
}
[Theory]
[InlineData(false)]
[InlineData(true)]
@ -474,7 +627,9 @@ public class YavscApiClientTests
.Select(_ => client.TrySilentLoginAsync(null, ct)).ToArray();
var calls = Enumerable.Range(0, 8)
.Select(_ => client.CallAsync<List<StubApiServer.Post>>(HttpMethod.Get, "posts", ct)).ToArray();
await Task.WhenAll(silentLogins.Cast<Task>().Concat(calls));
var maintenance = Enumerable.Range(0, 8)
.Select(_ => client.RefreshSessionIfNeededAsync(true, ct));
await Task.WhenAll(silentLogins.Cast<Task>().Concat(calls).Concat(maintenance));
Assert.All(silentLogins, login => Assert.True(login.Result));
Assert.All(calls, call => Assert.NotEmpty(call.Result));

View file

@ -52,6 +52,7 @@ public partial class App : Application, ILiveView, IVMPusherApp
this.ServiceProvider = Platform.CreateServiceProvider?.Invoke() ?? new ServiceCollection().BuildPostItServices();
var settings = ServiceProvider.GetRequiredService<Settings>();
ServiceProvider.GetRequiredService<YavscApiClient>().StartSessionMaintenance();
if (!LiveMode)
{

View file

@ -12,6 +12,18 @@ namespace PostIt.Services;
/// </summary>
public static class Platform
{
private static bool _isApplicationActive = !OperatingSystem.IsAndroid();
public static event Action? ApplicationActivityChanged;
public static bool IsApplicationActive
{
get => Volatile.Read(ref _isApplicationActive);
set
{
if (value == _isApplicationActive) return;
Volatile.Write(ref _isApplicationActive, value);
ApplicationActivityChanged?.Invoke();
}
}
public static IBackgroundChatService? BackgroundChatService { get; set; }
public static Func<IServiceProvider>? CreateServiceProvider { get; set; }
public static Func<PodcastCaptureDevices>? GetLanCallDevices { get; set; }

View file

@ -40,6 +40,11 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
public HttpClient Http { get; }
private readonly BearerTokenHandler _bearer;
private readonly SemaphoreSlim _refreshGate = new(1, 1);
private readonly CancellationTokenSource _maintenanceLifetime = new();
private readonly object _maintenanceGate = new();
private readonly SemaphoreSlim _maintenanceChanged = new(0, 1);
private Task? _maintenance;
private bool _maintenanceStopped;
private RefreshTokenRecord? _tokens;
private static readonly HttpRequestOptionsKey<bool> AnonymousRequest = new("PostIt.AnonymousRequest");
@ -103,6 +108,81 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
/// <summary>The current OIDC id_token, or null.</summary>
public string? CurrentIdToken => _tokens?.IdToken;
public void StartSessionMaintenance()
{
lock (_maintenanceGate)
{
_maintenanceLifetime.Token.ThrowIfCancellationRequested();
if (_maintenance is not null) return;
Platform.ApplicationActivityChanged += WakeSessionMaintenance;
Settings.PropertyChanged += OnMaintenanceSettingsChanged;
_maintenance = Task.Run(() => MaintainSessionAsync(_maintenanceLifetime.Token));
}
}
private void OnMaintenanceSettingsChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs args)
{
if (args.PropertyName == nameof(Settings.AndroidKeepChatConnected)) WakeSessionMaintenance();
}
private void WakeSessionMaintenance()
{
lock (_maintenanceGate)
{
if (!_maintenanceStopped && _maintenanceChanged.CurrentCount == 0)
_maintenanceChanged.Release();
}
}
internal TimeSpan GetSessionRefreshDelay(DateTimeOffset now, bool applicationActive)
{
var tokens = _tokens;
if ((!applicationActive && !Settings.AndroidKeepChatConnected)
|| IsSigningOut || string.IsNullOrEmpty(tokens?.RefreshToken))
return Timeout.InfiniteTimeSpan;
var delay = tokens.AccessTokenExpiresAt - RefreshSkew - now;
return delay > TimeSpan.Zero ? delay : TimeSpan.Zero;
}
internal async Task RefreshSessionIfNeededAsync(bool applicationActive, CancellationToken ct)
{
if ((!applicationActive && !Settings.AndroidKeepChatConnected)
|| IsSigningOut || string.IsNullOrEmpty(_tokens?.RefreshToken)) return;
await ForceRefreshAsync(ct).ConfigureAwait(false);
}
private async Task MaintainSessionAsync(CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested)
{
var delay = GetSessionRefreshDelay(DateTimeOffset.UtcNow, Platform.IsApplicationActive);
// SemaphoreSlim's timed wait accepts at most Int32.MaxValue milliseconds.
if (delay > TimeSpan.FromMilliseconds(int.MaxValue))
delay = TimeSpan.FromMilliseconds(int.MaxValue);
if (delay != TimeSpan.Zero
&& await _maintenanceChanged.WaitAsync(delay, ct).ConfigureAwait(false))
continue;
try
{
using var attempt = CancellationTokenSource.CreateLinkedTokenSource(ct);
attempt.CancelAfter(TimeSpan.FromSeconds(20));
await RefreshSessionIfNeededAsync(Platform.IsApplicationActive, attempt.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex)
{
Console.Error.WriteLine($"Session maintenance failed: {ex}");
Settings.SetActionStatus($"Renouvellement automatique de la session impossible : {ex.Message}",
PostIt.Models.StatusSeverity.Error);
await _maintenanceChanged.WaitAsync(TimeSpan.FromSeconds(30), ct).ConfigureAwait(false);
}
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { }
}
/// <summary>Force a new interactive login (PKCE). Throws on failure.</summary>
/// <param name="progress">Optional sink for the discrete phases of
/// the flow; the UI uses this to render a debug-friendly status
@ -157,6 +237,7 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
IdToken: result.IdentityToken);
_store.Save(_tokens);
WakeSessionMaintenance();
progress?.Report(OIDCLoginPhase.Success);
}
@ -400,6 +481,8 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
await _refreshGate.WaitAsync(ct).ConfigureAwait(false);
try
{
if (IsSigningOut)
throw new InvalidOperationException("Deconnexion en cours.");
var tokens = _tokens;
if (tokens is null || string.IsNullOrEmpty(tokens.RefreshToken))
throw new InvalidOperationException("Session expirée. Veuillez vous reconnecter.");
@ -417,6 +500,7 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
{
_store.Clear();
_tokens = null;
WakeSessionMaintenance();
}
throw new RefreshFailedException(
isPermanent
@ -432,6 +516,7 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
IdToken: result.IdentityToken ?? tokens.IdToken);
_store.Save(_tokens);
WakeSessionMaintenance();
}
finally
{
@ -566,6 +651,7 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
public async Task LogoutAsync()
{
Interlocked.Exchange(ref _signingOut, 1);
WakeSessionMaintenance();
try
{
if (SigningOut is { } handlers)
@ -584,6 +670,7 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
{
_refreshGate.Release();
Interlocked.Exchange(ref _signingOut, 0);
WakeSessionMaintenance();
}
}
// Optional: clear the IdP session in the browser too.
@ -591,11 +678,22 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
await Task.CompletedTask;
}
public ValueTask DisposeAsync()
public async ValueTask DisposeAsync()
{
Task? maintenance;
lock (_maintenanceGate)
{
_maintenanceStopped = true;
_maintenanceLifetime.Cancel();
Platform.ApplicationActivityChanged -= WakeSessionMaintenance;
Settings.PropertyChanged -= OnMaintenanceSettingsChanged;
maintenance = _maintenance;
}
if (maintenance is not null) await maintenance.ConfigureAwait(false);
_maintenanceChanged.Dispose();
_maintenanceLifetime.Dispose();
Http.Dispose();
_refreshGate.Dispose();
return ValueTask.CompletedTask;
}
/// <summary>

View file

@ -19,6 +19,7 @@ namespace PostIt.ViewModels;
public partial class Settings : ViewModelBase
{
public event EventHandler? Saved;
public event EventHandler? Applied;
public Settings() { }
internal Settings(string settingsFileFullName) => SettingsFileFullName = settingsFileFullName;
@ -575,6 +576,13 @@ public partial class Settings : ViewModelBase
private bool CanSave() => IsDirty;
[RelayCommand(CanExecute = nameof(CanSave))]
public void Apply()
{
Save();
Applied?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Re-notify the <c>SaveCommand</c> (generated by
/// <c>[RelayCommand]</c> on <see cref="Save"/>) so XAML
@ -582,6 +590,10 @@ public partial class Settings : ViewModelBase
/// outside the scope of a direct save (e.g. on <see cref="Load"/>
/// / <see cref="ApplyJson"/>).
/// </summary>
partial void OnIsDirtyChanged(bool value) => SaveCommand.NotifyCanExecuteChanged();
partial void OnIsDirtyChanged(bool value)
{
SaveCommand.NotifyCanExecuteChanged();
ApplyCommand.NotifyCanExecuteChanged();
}
}

View file

@ -52,7 +52,7 @@
<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."
<TextBlock Text="SignalR uniquement, sans notification permanente. Les alertes s'affichent seulement a reception d'un message ou evenement. Sauver avec cette option active demande l'exemption batterie Android seulement si PostIt n'est pas deja exempte. Vous restez libre de refuser. Autorisez aussi les donnees en arriere-plan 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."
@ -67,11 +67,8 @@
Click="ChooseAvatar_Click"
Margin="0,0,8,0"/>
<!-- Sauver: bind to the RelayCommand-generated property
(SaveCommand). IsEnabled tracks IsDirty so the button
auto-disables when there's nothing to persist. -->
<Button Content="Sauver"
Command="{Binding SaveCommand}"
Command="{Binding ApplyCommand}"
IsEnabled="{Binding IsDirty}"/>
</StackPanel>
</StackPanel>