From 34722c751b92d79fdc13ea2a69a155af5780fff9 Mon Sep 17 00:00:00 2001 From: esefxdz Date: Sat, 17 Oct 2026 00:25:17 +0300 Subject: [PATCH] fix(session): recognise expiry after device sleep by wall clock _reconcileElapsedTime() runs off a Dart Stopwatch, which is CLOCK_MONOTONIC and does not advance while the device is suspended - exactly the state an RTC_WAKEUP expiry alarm fires in. The "did this drop happen at the deadline" test therefore came out false after any sleep, and the screen-off expiry (the common one) still showed no reason in the UI. Each successful sync now records an absolute DateTime deadline, and the tunnel-dropped branch compares against that instead. Co-Authored-By: Claude Opus 5 --- lib/logic/session_timer.dart | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/logic/session_timer.dart b/lib/logic/session_timer.dart index a7cfa3c..fb59bf4 100644 --- a/lib/logic/session_timer.dart +++ b/lib/logic/session_timer.dart @@ -42,6 +42,11 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { int _lastUsedBytes = 0; Stopwatch? _sinceLastSuccessfulSync; + // Wall-clock deadline, kept alongside the Stopwatch because they answer + // different questions. The Stopwatch is CLOCK_MONOTONIC: it does not advance + // while the device is suspended, which is exactly when an RTC_WAKEUP expiry + // alarm fires, so it cannot be used to recognise an expiry after sleep. + DateTime? _deadlineAt; double _currentSpeedKBps = 0.0; SessionTimer({required this.vpnConnection}) { @@ -145,8 +150,12 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { // in arbitrary phase, so expiry almost always arrives here rather than // at the countdown check in _tick - without this, the commonest session // end left the UI with nothing to say. - _reconcileElapsedTime(); - final expired = _hasSyncedOnce && _remainingSeconds <= 2; + final deadline = _deadlineAt; + final expired = _hasSyncedOnce && + deadline != null && + DateTime.now().isAfter( + deadline.subtract(const Duration(seconds: 5)), + ); // postAlert is false either way: the native side already posted for its // own deadline, and the notification's Disconnect action is the user // asking, which must never raise a "not protected" alert. @@ -170,6 +179,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { _lastUsedBytes = 0; _sinceLastSuccessfulSync?.stop(); _sinceLastSuccessfulSync = null; + _deadlineAt = null; _currentSpeedKBps = 0.0; _tickCount = 0; _hasSyncedOnce = false; @@ -260,6 +270,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { debugPrint('[Timer] Disconnecting: $reason'); _lastEndMessage = endedMessage; + _deadlineAt = null; _timer?.cancel(); _timer = null; _currentSpeedKBps = 0.0; @@ -340,6 +351,9 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { final elapsedMs = _sinceLastSuccessfulSync?.elapsedMilliseconds; _remainingAtLastSync = expiresValue.toInt(); _remainingSeconds = _remainingAtLastSync; + _deadlineAt = DateTime.now().add( + Duration(seconds: _remainingAtLastSync), + ); _usedBytes = _readNonNegativeInt(data['used_bytes'], _usedBytes); final deltaBytes = _usedBytes - _lastUsedBytes;