fix(session): take down the countdown notification when the tunnel stops

Disconnecting the VPN from Android's own settings left "Revolt VPN
00:28:13" in the shade, frozen, for as long as the phone stayed up - a
notification claiming a protected session over a tunnel that was gone.

updateTimer() re-posts notification id 1 as ongoing and non-dismissible
from the UI process, every tick. The service cancels its own foreground
copy when it stops, but a tick landing just after that re-creates it as
an ordinary notification nobody owns, and nothing ever cancelled it:
NotificationService.reset() only clears the dedupe cache. AGENTS.md
rule 31 describes the cancel this restores.

_doDisconnect now cancels it, after vpnConnection.disconnect() so the
service is already down - cancelling while it is still in the foreground
would be swallowed by the platform.

Verified on an emulator, revoking via Settings -> VPN -> Disconnect:

  before  countdown stranded at 00:28:13 indefinitely
  after   no live notifications

User disconnect still posts no alert, and expiry still posts "Revolt VPN
disconnected" with the countdown gone.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
esefxdzandClaude Opus 5 committed 2026-10-17 16:26:41 +03:00
1 parent eab43b0420
commit 296b087db6
3 files changed
+31

No files matched your search

@@ -75,6 +75,18 @@ class MainActivity : FlutterActivity() {
RevoltAlerts.postDisconnected(this) RevoltAlerts.postDisconnected(this)
result.success(null) result.success(null)
} }
// The countdown re-post is ongoing and non-dismissible, so
// whoever posted it last has to take it down. A tick that
// lands after the service cancelled its own foreground
// notification otherwise strands a frozen countdown that
// claims a protected session forever. Harmless while the
// service is still in the foreground - the platform keeps
// its notification regardless.
"cancelNotification" -> {
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
.cancel(NOTIFICATION_ID)
result.success(null)
}
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
+15
View File
@@ -41,6 +41,21 @@ class NotificationService {
} }
} }
/// Takes down the countdown notification.
///
/// [updateTimer] re-posts an ongoing, non-dismissible notification, so a tick
/// that lands after the VPN service has cancelled its own foreground copy
/// leaves a frozen countdown on screen that nobody owns. Call this once the
/// tunnel is actually down.
static Future<void> cancelTimer() async {
_lastText = null;
try {
await _channel.invokeMethod<void>('cancelNotification');
} catch (_) {
// Cosmetic.
}
}
/// Forces the next connect to re-post even if the countdown resumes on the /// Forces the next connect to re-post even if the countdown resumes on the
/// same value. /// same value.
static void reset() => _lastText = null; static void reset() => _lastText = null;
+4
View File
@@ -270,6 +270,10 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
notifyListeners(); notifyListeners();
await vpnConnection.disconnect(); await vpnConnection.disconnect();
// After the service is down, so the cancel is not swallowed by a still
// foreground notification. Without it a revoke leaves the countdown on
// screen, frozen, claiming a session that no longer protects anything.
await NotificationService.cancelTimer();
if (endedMessage != null && postAlert) { if (endedMessage != null && postAlert) {
unawaited(NotificationService.showDisconnectedAlert()); unawaited(NotificationService.showDisconnectedAlert());
} }