From 296b087db66caeddb677f4855c4e5417b917c8b7 Mon Sep 17 00:00:00 2001 From: esefxdz Date: Sat, 17 Oct 2026 16:26:41 +0300 Subject: [PATCH] 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 --- .../kotlin/com/paladinvpn/app/MainActivity.kt | 12 ++++++++++++ lib/logic/notification_service.dart | 15 +++++++++++++++ lib/logic/session_timer.dart | 4 ++++ 3 files changed, 31 insertions(+) diff --git a/android/app/src/main/kotlin/com/paladinvpn/app/MainActivity.kt b/android/app/src/main/kotlin/com/paladinvpn/app/MainActivity.kt index 662b60a..d3cbfb3 100644 --- a/android/app/src/main/kotlin/com/paladinvpn/app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/paladinvpn/app/MainActivity.kt @@ -75,6 +75,18 @@ class MainActivity : FlutterActivity() { RevoltAlerts.postDisconnected(this) 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() } } diff --git a/lib/logic/notification_service.dart b/lib/logic/notification_service.dart index bf830d2..e3ee070 100644 --- a/lib/logic/notification_service.dart +++ b/lib/logic/notification_service.dart @@ -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 cancelTimer() async { + _lastText = null; + try { + await _channel.invokeMethod('cancelNotification'); + } catch (_) { + // Cosmetic. + } + } + /// Forces the next connect to re-post even if the countdown resumes on the /// same value. static void reset() => _lastText = null; diff --git a/lib/logic/session_timer.dart b/lib/logic/session_timer.dart index f5196da..d55ee1c 100644 --- a/lib/logic/session_timer.dart +++ b/lib/logic/session_timer.dart @@ -270,6 +270,10 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); 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) { unawaited(NotificationService.showDisconnectedAlert()); }