diff --git a/CHANGELOG.md b/CHANGELOG.md index fda24c1..8c6e9b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,60 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH --- +## [3.3.7] — 2026-10-17 + +### Added +- **A warning before a session ends.** The VPN service schedules it off the same + deadline it already enforces, so it still arrives when the app is closed, and + a support top-up moves it with the deadline. It does not promise an exact + number of minutes: on a sleeping phone Android may hold it back, and the app + would rather say "soon" than say "5 minutes" with one left. +- **"Disconnected — not protected" notification** when a session ends without + you asking: the 2-hour expiry, the data limit, the server ending the + session, or the tunnel stopping unexpectedly. +- **SOCKS5 mode now asks for the notification permission.** It never did, + because it never raises the VPN consent dialog, so on Android 13+ these + alerts would have been dropped before they were shown. +- **The app now shows what went wrong.** Connect errors, the reason a session + ended, and progress while connecting appear under the status bar instead of + the button silently going back to idle. + +### Changed +- **A control-plane outage no longer tears down a working tunnel.** The app used + to disconnect itself after about two minutes of failed status polls, putting + you back on your real IP; it now keeps the tunnel up and shows + "Can't reach the server — still connected". Expiry is still enforced by the + VPN service's own deadline and by the server. + +### Fixed +- **Rewarded ads no longer break connecting.** With ads enabled, the ad's + server-side-verification nonce and the nonce the connect flow waited for were + minted separately, so the client rejected the very session the ad created and + failed with "Session not activated". The ad's nonce is now passed through to + the connect call, the fake bypass callback is skipped when a real ad was + watched, and the client polls longer (~36 s) for Google's callback. +- **SOCKS5 "Reconnect" re-attaches to the current session** instead of + requesting a new one, so repairing a lost proxy no longer needs (or grants) + a fresh session. +- **The end-of-session warning now actually arrives when the app is closed.** + It was scheduled only on an inexact alarm, and a sleeping phone could hold + that alarm back past the end of the session it was warning about — so in the + one case the warning exists for, it never came. It now also runs off the VPN + service's own timer, the same one that already ends the session on time. +- **Disconnecting from Android's VPN settings no longer leaves a countdown in + the notification shade.** It used to sit there frozen, showing time remaining + on a tunnel that was already gone. +- **The VPN reconnects itself after the tunnel core stops.** Recovery never + worked: every attempt failed on the same internal error, and each one left + another copy of the core running in the background. One restart now brings it + back without dropping the connection. +- **Disconnecting now releases your session on the server** instead of leaving + it open until it expires on its own. +- **A core that fails to start now says why.** Its output was being thrown + away, so the only trace was an exit code. + +--- + ## [3.3.6] — 2026-10-08 ### Fixed 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 07ca37d..d3cbfb3 100644 --- a/android/app/src/main/kotlin/com/paladinvpn/app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/paladinvpn/app/MainActivity.kt @@ -20,6 +20,7 @@ import android.os.VibratorManager import android.provider.Settings import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import com.github.tfox.flutter_vless.xray.utils.RevoltAlerts import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.EventChannel @@ -61,14 +62,32 @@ class MainActivity : FlutterActivity() { MethodChannel(flutterEngine.dartExecutor.binaryMessenger, notificationChannel) .setMethodCallHandler { call, result -> - if (call.method == "updateNotificationText") { - val text = call.argument("text") ?: "" - val title = call.argument("title") ?: "Revolt VPN" - val action = call.argument("disconnectLabel") ?: "Disconnect" - updateVpnNotification(title, text, action) - result.success(null) - } else { - result.notImplemented() + when (call.method) { + "updateNotificationText" -> { + val text = call.argument("text") ?: "" + val title = call.argument("title") ?: "Revolt VPN" + val action = call.argument("disconnectLabel") ?: "Disconnect" + updateVpnNotification(title, text, action) + result.success(null) + } + // Session ends only Dart sees; the service posts the rest. + "showDisconnectedAlert" -> { + 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/components/connect_button.dart b/lib/components/connect_button.dart index 60227d2..93cc9f9 100644 --- a/lib/components/connect_button.dart +++ b/lib/components/connect_button.dart @@ -95,10 +95,10 @@ class _ConnectButtonState extends State setState(() => _busy = true); try { - final adWatched = await ad.showAd('main'); - if (!adWatched && AdManager.adsEnabled) return; + final adNonce = await ad.showAd('main'); + if (adNonce == null && AdManager.adsEnabled) return; - final ok = await vpn.connect(); + final ok = await vpn.connect(adNonce: adNonce); if (ok) { await timer.start(); HapticSettings.success(); diff --git a/lib/components/status_bar.dart b/lib/components/status_bar.dart index 707bb52..9727151 100644 --- a/lib/components/status_bar.dart +++ b/lib/components/status_bar.dart @@ -5,6 +5,41 @@ import 'package:revoltvpn/logic/app_colors.dart'; import 'package:revoltvpn/logic/session_timer.dart'; import 'package:revoltvpn/logic/vpn_connection.dart'; +// The one line worth showing right now, and whether it is a problem. +class _StatusDetail { + const _StatusDetail(this.text, {this.isProblem = false}); + + final String text; + final bool isProblem; +} + +_StatusDetail? _detail(VpnConnection vpn, SessionTimer timer) { + final error = vpn.errorMessage; + if (error != null && error.isNotEmpty) { + return _StatusDetail(error.replaceAll('\n', ' '), isProblem: true); + } + + if (vpn.status == VpnStatus.connecting || + vpn.status == VpnStatus.disconnecting) { + return _StatusDetail(vpn.statusMessage); + } + + // Control plane only - the tunnel still carries traffic. + if (vpn.status == VpnStatus.connected && timer.serverUnreachable) { + return const _StatusDetail( + 'Can\'t reach the server — still connected', + isProblem: true, + ); + } + + if (vpn.status != VpnStatus.connected) { + final ended = timer.lastEndMessage; + if (ended != null) return _StatusDetail(ended, isProblem: true); + } + + return null; +} + class StatusBar extends StatelessWidget { const StatusBar({super.key}); @@ -24,6 +59,8 @@ class StatusBar extends StatelessWidget { ? 'Connecting…' : (vpn.serverReachable ? 'Ready' : 'Offline')); + final detail = _detail(vpn, timer); + return Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Column( @@ -96,6 +133,21 @@ class StatusBar extends StatelessWidget { ], ), ), + // Errors and session-end reasons used to render nowhere. + if (detail != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + detail.text, + textAlign: TextAlign.center, + style: TextStyle( + color: detail.isProblem + ? AppColors.red + : AppColors.textDim, + fontSize: 11, + ), + ), + ), ], ), ); diff --git a/lib/components/support_button.dart b/lib/components/support_button.dart index 4565016..ac1d3ab 100644 --- a/lib/components/support_button.dart +++ b/lib/components/support_button.dart @@ -31,7 +31,7 @@ class _SupportButtonState extends State { setState(() => _busy = true); try { - final rewarded = await ad.showAd('support'); + final rewarded = await ad.showAd('support') != null; if (rewarded) { await timer.markSupportRewardClaimed(); await timer.syncNow(); diff --git a/lib/logic/ad_manager.dart b/lib/logic/ad_manager.dart index 51a8fe7..307919a 100644 --- a/lib/logic/ad_manager.dart +++ b/lib/logic/ad_manager.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'package:revoltvpn/logic/crypto_service.dart'; @@ -97,13 +96,16 @@ class AdManager extends ChangeNotifier { // ── Show ad (or debug bypass) ───────────────────────────────────── - Future showAd(String adType) async { - // Debug bypass: fire fake AdMob callback so support ads work in dev. - // The server's ADMOB_BYPASS must be True for this to succeed. - if (!adsEnabled && kDebugMode) { + // Returns the nonce put in the SSV custom_data, null if unrewarded. connect() + // must reuse it: minting a second nonce there rejects the ad's own session. + Future showAd(String adType) async { + if (!adsEnabled) { + // Main connect fires its own bypass in fetchConfigDirectly (rule 87). + if (adType != 'support' || !kDebugMode) return null; + + // Needs ADMOB_BYPASS=True server-side. final deviceId = await CryptoService.getDeviceId(); - final nonce = '${Random().nextInt(0x7FFFFFFF)}-${DateTime.now().millisecondsSinceEpoch}'; - HivemindService.setExpectedNonce(nonce); + final nonce = HivemindService.newNonce(); try { final customData = jsonEncode({ 'device_id': deviceId, @@ -115,29 +117,24 @@ class AdManager extends ChangeNotifier { '?signature=test&key_id=test' '&custom_data=${Uri.encodeComponent(customData)}'); await HivemindService.directGet(fakeUrl, timeout: const Duration(seconds: 8)); - return true; + return nonce; } catch (_) { - return false; + return null; } } - if (!adsEnabled) return false; - await ensureSdkInitialized(); if (!_isAdLoaded || _rewardedAd == null) { final loaded = await preloadAd(); if (!loaded || _rewardedAd == null) { debugPrint('[AdManager] Cannot show ad, failed to load.'); - return false; + return null; } } final deviceId = await CryptoService.getDeviceId(); - - final nonce = '${Random().nextInt(0x7FFFFFFF)}-${DateTime.now().millisecondsSinceEpoch}'; - HivemindService.setExpectedNonce(nonce); - debugPrint('[AdManager] Ad nonce: $nonce'); + final nonce = HivemindService.newNonce(); final ssvOptions = ServerSideVerificationOptions( customData: jsonEncode({ @@ -182,7 +179,7 @@ class AdManager extends ChangeNotifier { }, ); - return rewardCompleter.future; + return await rewardCompleter.future ? nonce : null; } @override diff --git a/lib/logic/hivemind_service.dart b/lib/logic/hivemind_service.dart index 3791d2b..81111df 100644 --- a/lib/logic/hivemind_service.dart +++ b/lib/logic/hivemind_service.dart @@ -76,18 +76,84 @@ class HivemindService { } } + static Future controlPost( + Uri uri, + Map body, { + Duration timeout = const Duration(seconds: 5), + }) async { + final client = http.Client(); + final request = http.Request('POST', uri) + ..headers['User-Agent'] = _ua + ..headers['Content-Type'] = 'application/json' + ..body = jsonEncode(body) + ..followRedirects = false; + + Future send() async { + final streamed = await client.send(request); + final declaredLength = streamed.contentLength; + if (declaredLength != null && declaredLength > _maxControlResponseBytes) { + throw const FormatException('Control response is too large.'); + } + + final bytes = []; + await for (final chunk in streamed.stream) { + if (bytes.length + chunk.length > _maxControlResponseBytes) { + throw const FormatException('Control response is too large.'); + } + bytes.addAll(chunk); + } + + return http.Response.bytes(bytes, streamed.statusCode, + headers: streamed.headers, request: request); + } + + try { + return await send().timeout( + timeout, + onTimeout: () { + client.close(); + throw TimeoutException('Control request timed out.', timeout); + }, + ); + } finally { + client.close(); + } + } + + // Hands the session back so the server can drop the VLESS client instead of + // leaving it provisioned until the window runs out. + // + // Only the user-initiated disconnect calls this. An expiry, a hard cap or a + // server-ended session is already gone server-side, and /session/stop takes + // nothing but a device_id as proof (hivemind/api.py notes this), so a + // transient client error must never be allowed to destroy a live session. + // Best effort throughout: the session expires on its own regardless. + static Future releaseSession() async { + try { + final deviceId = await CryptoService.getDeviceId(); + await controlPost( + _publicUrl('/session/stop'), + {'device_id': deviceId}, + timeout: const Duration(seconds: 3), + ); + } catch (_) { + // Nothing to recover: the server expires the session anyway. + } + } + static void cancel() { _currentCallId++; _expectedNonce = null; } - static void setExpectedNonce(String nonce) { - _expectedNonce = nonce; - } - + // Fetches this device's VLESS URL. + // adNonce — wait for the session that ad created (SSV can lag, so poll longer) + // resume — re-attach to the session already held; any nonce accepted + // neither — ads off: mint a nonce and fire the bypass (rule 87) static Future fetchConfigDirectly({ void Function(int attempt, int total)? onAttempt, - bool skipAdBypass = false, + String? adNonce, + bool resume = false, }) async { // Capture the generation before the first await. Otherwise a disconnect // that lands while secure storage is resolving the device ID can be lost @@ -96,15 +162,15 @@ class HivemindService { final deviceId = await CryptoService.getDeviceId(); _throwIfCancelled(callId); - final nonce = _newNonce(); + final nonce = resume ? null : (adNonce ?? newNonce()); _expectedNonce = nonce; - if (!skipAdBypass) { - await _runConfiguredBypass(deviceId, nonce); + if (!resume && adNonce == null) { + await _runConfiguredBypass(deviceId, nonce!); _throwIfCancelled(callId); } - const maxAttempts = 5; + final maxAttempts = adNonce != null ? 10 : 5; for (var attempt = 1; attempt <= maxAttempts; attempt++) { _throwIfCancelled(callId); onAttempt?.call(attempt, maxAttempts); @@ -122,7 +188,9 @@ class HivemindService { } if (attempt < maxAttempts) { - await Future.delayed(_pollBackoff[attempt - 1]); + await Future.delayed( + _pollBackoff[min(attempt - 1, _pollBackoff.length - 1)], + ); _throwIfCancelled(callId); } } @@ -142,7 +210,7 @@ class HivemindService { } } - static String _newNonce() { + static String newNonce() { final random = Random.secure(); final high = random.nextInt(0x7FFFFFFF); final low = random.nextInt(0x7FFFFFFF); @@ -161,9 +229,10 @@ class HivemindService { } } + // nonce == null accepts whatever nonce the server holds (resume path). static Future<_HivemindSessionConfig?> _fetchActiveSession( String deviceId, - String nonce, + String? nonce, ) async { final statusUri = _publicUrl('/session/status').replace( queryParameters: {'device_id': deviceId}, @@ -175,9 +244,8 @@ class HivemindService { if (decoded is! Map) return null; final serverNonce = decoded['nonce']; - if (_expectedNonce != nonce || - serverNonce is! String || - serverNonce != nonce) { + if (_expectedNonce != nonce) return null; + if (nonce != null && (serverNonce is! String || serverNonce != nonce)) { return null; } diff --git a/lib/logic/notification_service.dart b/lib/logic/notification_service.dart index 88f46f1..e3ee070 100644 --- a/lib/logic/notification_service.dart +++ b/lib/logic/notification_service.dart @@ -32,6 +32,30 @@ class NotificationService { } } + // Same id as the service's own alert, so a double post replaces. + static Future showDisconnectedAlert() async { + try { + await _channel.invokeMethod('showDisconnectedAlert'); + } catch (_) { + // Informational only. + } + } + + /// 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 035775d..44e54e7 100644 --- a/lib/logic/session_timer.dart +++ b/lib/logic/session_timer.dart @@ -36,14 +36,15 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { static const FlutterSecureStorage _supportStorage = FlutterSecureStorage(); static const int _maxConsecutiveFailures = 3; - static const int _maxOfflineSeconds = 120; - int _offlineSeconds = 0; static const int _pollIntervalSeconds = 60; static const int _backgroundPollIntervalSeconds = 60; int _lastUsedBytes = 0; Stopwatch? _sinceLastSuccessfulSync; + // Wall clock, because the Stopwatch is CLOCK_MONOTONIC and stops across + // device suspend - exactly when the RTC_WAKEUP expiry alarm fires. + DateTime? _deadlineAt; double _currentSpeedKBps = 0.0; SessionTimer({required this.vpnConnection}) { @@ -67,6 +68,13 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { int get usedBytes => _usedBytes; double get currentSpeedKBps => _currentSpeedKBps; + // Display state only - an unreachable API never tears down a live tunnel. + bool get serverUnreachable => _consecutiveFailures >= _maxConsecutiveFailures; + + // Why the last session ended, if the user did not end it. Cleared on connect. + String? _lastEndMessage; + String? get lastEndMessage => _lastEndMessage; + String get formatted { final h = (_remainingSeconds ~/ 3600).toString().padLeft(2, '0'); final m = ((_remainingSeconds % 3600) ~/ 60).toString().padLeft(2, '0'); @@ -124,16 +132,35 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { if (!hadActiveSession || _isDisconnecting) return; if (vpnConnection.status == VpnStatus.error) { - unawaited(_doDisconnect('VPN entered error state')); + unawaited(_doDisconnect( + 'VPN entered error state', + endedMessage: 'The tunnel stopped. You are not protected.', + )); return; } if (vpnConnection.status == VpnStatus.disconnected) { - unawaited(_doDisconnect('VPN tunnel dropped')); + // The native deadline beats the 1 s tick, so expiry lands here, not in + // _tick. postAlert is false: the service already posted, and a + // notification-Disconnect tap is the user asking. + final deadline = _deadlineAt; + final expired = _hasSyncedOnce && + deadline != null && + DateTime.now().isAfter( + deadline.subtract(const Duration(seconds: 5)), + ); + unawaited(_doDisconnect( + 'VPN tunnel dropped', + endedMessage: + expired ? 'Your session ended. You are not protected.' : null, + postAlert: false, + )); } } - Future start() async { + // resetSupportClaim is false when re-attaching to a session already held: + // the top-up is once per server session and this flag is what enforces it. + Future start({bool resetSupportClaim = true}) async { _sessionEpoch++; _remainingSeconds = 0; _remainingAtLastSync = 0; @@ -141,19 +168,22 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { _lastUsedBytes = 0; _sinceLastSuccessfulSync?.stop(); _sinceLastSuccessfulSync = null; + _deadlineAt = null; _currentSpeedKBps = 0.0; _tickCount = 0; _hasSyncedOnce = false; _consecutiveFailures = 0; - _offlineSeconds = 0; _isDisconnecting = false; _appBackgrounded = false; + _lastEndMessage = null; NotificationService.reset(); - _supportStateEpoch++; - _supportRewardClaimed = false; - _supportRewardStateLoaded = true; - unawaited(_persistSupportRewardState(false)); + if (resetSupportClaim) { + _supportStateEpoch++; + _supportRewardClaimed = false; + _supportRewardStateLoaded = true; + unawaited(_persistSupportRewardState(false)); + } _timer?.cancel(); _timer = Timer.periodic(const Duration(seconds: 1), _tick); @@ -169,16 +199,11 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { _reconcileElapsedTime(); } - if (_consecutiveFailures >= _maxConsecutiveFailures) { - _offlineSeconds++; - if (_offlineSeconds >= _maxOfflineSeconds) { - unawaited(_doDisconnect('Server unreachable')); - return; - } - } - if (_hasSyncedOnce && _remainingSeconds <= 0) { - unawaited(_doDisconnect('Session expired')); + unawaited(_doDisconnect( + 'Session expired', + endedMessage: 'Your session ended. You are not protected.', + )); return; } @@ -216,15 +241,27 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { } Future disconnect({String reason = 'User requested'}) async { + // Only here, not in _doDisconnect: this is the one path the user actually + // asked for. Unawaited so a slow or unreachable API never holds up the + // teardown. + unawaited(HivemindService.releaseSession()); await _doDisconnect(reason); } - Future _doDisconnect(String reason) async { + // endedMessage is what the UI shows; postAlert raises the notification. + // A user-requested disconnect passes neither. + Future _doDisconnect( + String reason, { + String? endedMessage, + bool postAlert = true, + }) async { if (_isDisconnecting) return; _isDisconnecting = true; _sessionEpoch++; debugPrint('[Timer] Disconnecting: $reason'); + _lastEndMessage = endedMessage; + _deadlineAt = null; _timer?.cancel(); _timer = null; _currentSpeedKBps = 0.0; @@ -237,6 +274,13 @@ 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()); + } _isDisconnecting = false; notifyListeners(); @@ -282,7 +326,11 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { return; } if (!activeValue) { - await _doDisconnect('Server ended session'); + await _doDisconnect( + 'Server ended session', + endedMessage: + 'The server ended your session. You are not protected.', + ); return; } @@ -298,6 +346,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; @@ -313,7 +364,10 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { _sinceLastSuccessfulSync = Stopwatch()..start(); if (data['cap_exhausted'] == true) { - await _doDisconnect('Data cap reached'); + await _doDisconnect( + 'Data cap reached', + endedMessage: 'Data limit reached. You are not protected.', + ); return; } @@ -322,12 +376,15 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { await vpnConnection.setNativeSessionDeadline(_remainingAtLastSync); } catch (e) { debugPrint('[Timer] Failed to arm native session deadline: $e'); - await _doDisconnect('Native session deadline unavailable'); + await _doDisconnect( + 'Native session deadline unavailable', + endedMessage: + 'Could not arm the session timer. You are not protected.', + ); return; } if (epoch != _sessionEpoch || _isDisconnecting) return; _consecutiveFailures = 0; - _offlineSeconds = 0; _hasSyncedOnce = true; notifyListeners(); } else { @@ -364,6 +421,16 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { final elapsed = clock.elapsed.inSeconds; _remainingSeconds = (_remainingAtLastSync - elapsed).clamp(0, 1 << 31).toInt(); + + // The Stopwatch over-reports after device suspend. Take the smaller of + // the two, never the wall clock alone - a backward clock must not extend. + final deadline = _deadlineAt; + if (deadline != null) { + final byWallClock = deadline.difference(DateTime.now()).inSeconds; + if (byWallClock < _remainingSeconds) { + _remainingSeconds = byWallClock.clamp(0, 1 << 31).toInt(); + } + } } @override diff --git a/lib/logic/vpn_connection.dart b/lib/logic/vpn_connection.dart index 17368e4..788b8cf 100644 --- a/lib/logic/vpn_connection.dart +++ b/lib/logic/vpn_connection.dart @@ -200,7 +200,7 @@ class VpnConnection extends ChangeNotifier { } } - Future connect({bool skipAdBypass = false}) async { + Future connect({String? adNonce, bool resume = false}) async { if (_disposed || _userDisconnecting || _status == VpnStatus.connected || @@ -224,6 +224,17 @@ class VpnConnection extends ChangeNotifier { return false; } + // Proxy mode skips requestPermission below (VPN consent it never needs), + // so it has to ask for POST_NOTIFICATIONS on its own. + if (!kIsWeb && _activeMode == ConnectionMode.proxy) { + try { + await _nativeControl.invokeMethod('requestNotificationPermission'); + } catch (e) { + debugPrint('[VPN] Notification permission request failed: $e'); + } + if (!_isCurrentConnect(connectEpoch)) return false; + } + if (!kIsWeb && _activeMode == ConnectionMode.tun) { final ok = await _vless.requestPermission(); if (!_isCurrentConnect(connectEpoch)) return false; @@ -254,7 +265,8 @@ class VpnConnection extends ChangeNotifier { String realUrl; try { realUrl = await HivemindService.fetchConfigDirectly( - skipAdBypass: skipAdBypass, + adNonce: adNonce, + resume: resume, onAttempt: (attempt, total) { if (!_isCurrentConnect(connectEpoch)) return; _setStatus( diff --git a/lib/screens/settings/in_settings/connection_mode.dart b/lib/screens/settings/in_settings/connection_mode.dart index 46519d7..0f0fe32 100644 --- a/lib/screens/settings/in_settings/connection_mode.dart +++ b/lib/screens/settings/in_settings/connection_mode.dart @@ -67,10 +67,8 @@ class _ConnectionModeTileState extends State { widget.onChanged?.call(next); } - /// Recovers from an orphaned proxy: tear the runtime down and start a fresh - /// session so a readable set of credentials exists again. No rewarded ad — - /// the user is repairing a connection they already paid for, not opening a - /// new one. + // Recovers an orphaned proxy: tear the runtime down and re-attach to the + // session already held. No ad, no new session. Future _reconnect() async { if (_reconnecting) return; final vpn = context.read(); @@ -80,9 +78,9 @@ class _ConnectionModeTileState extends State { try { await timer.disconnect(); if (!mounted) return; - if (await vpn.connect()) { + if (await vpn.connect(resume: true)) { if (!mounted) return; - await timer.start(); + await timer.start(resetSupportClaim: false); } } finally { if (mounted) { diff --git a/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/FlutterVlessPlugin.kt b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/FlutterVlessPlugin.kt index 35cc1f7..57ea27c 100644 --- a/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/FlutterVlessPlugin.kt +++ b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/FlutterVlessPlugin.kt @@ -240,6 +240,28 @@ class FlutterVlessPlugin : FlutterPlugin, ActivityAware, PluginRegistry.Activity } } + // POST_NOTIFICATIONS without VpnService.prepare, for proxy mode. + "requestNotificationPermission" -> { + val currentActivity = activity + if (currentActivity == null) { + result.error("NO_ACTIVITY", "Activity is not attached", null) + return + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ActivityCompat.checkSelfPermission( + currentActivity, + Manifest.permission.POST_NOTIFICATIONS, + ) != PackageManager.PERMISSION_GRANTED + ) { + ActivityCompat.requestPermissions( + currentActivity, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + REQUEST_CODE_POST_NOTIFICATIONS, + ) + } + result.success(null) + } + "requestPermission" -> { val currentActivity = activity if (currentActivity == null) { diff --git a/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/core/XrayCoreManager.kt b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/core/XrayCoreManager.kt index 54975b6..16a4348 100644 --- a/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/core/XrayCoreManager.kt +++ b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/core/XrayCoreManager.kt @@ -11,6 +11,8 @@ import android.content.pm.PackageManager import android.graphics.Color import android.os.Build import android.os.CountDownTimer +import android.os.Handler +import android.os.Looper import android.util.Log import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat @@ -37,6 +39,13 @@ object XrayCoreManager { private const val NOTIFICATION_ID = 1 private const val TAG = "XrayCoreManager" + + // The core's output is kept only for its first seconds, and only to explain + // a non-zero exit. A core that has been up longer than this may be carrying + // traffic, and its log can name destinations, which must never reach the + // device log (rule 95) - so past the window the buffer is dropped. + private const val STARTUP_LOG_WINDOW_MS = 10_000L + private const val STARTUP_LOG_MAX_LINES = 40 private var xrayProcess: Process? = null private var countDownTimer: CountDownTimer? = null private var seconds = 0 @@ -347,12 +356,29 @@ object XrayCoreManager { // Monitor process in a separate thread to detect crash Thread { try { + val startedAt = System.currentTimeMillis() + val startupLog = ArrayDeque() process.inputStream.bufferedReader().use { reader -> - reader.forEachLine { _ -> } + reader.forEachLine { line -> + if (System.currentTimeMillis() - startedAt <= STARTUP_LOG_WINDOW_MS) { + if (startupLog.size >= STARTUP_LOG_MAX_LINES) startupLog.removeFirst() + startupLog.addLast(line) + } else if (startupLog.isNotEmpty()) { + startupLog.clear() + } + } } - + val exitCode = process.waitFor() - Log.e(TAG, "Xray process exited with code $exitCode") + if (exitCode != 0 && startupLog.isNotEmpty()) { + Log.e( + TAG, + "Xray process exited with code $exitCode; startup output:\n" + + startupLog.joinToString("\n") + ) + } else { + Log.e(TAG, "Xray process exited with code $exitCode") + } if (xrayProcess === process && AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN == config.RUNTIME_TOKEN && (AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTED || @@ -383,6 +409,18 @@ object XrayCoreManager { } catch (e: Exception) { Log.e(TAG, "Failed to start Xray process", e) + // The process may already be running: pb.start() succeeds before + // anything below it can throw. Leaving it alive leaked one live + // core per recovery attempt. Clear the field BEFORE destroying so + // the monitor thread's `xrayProcess === process` guard fails and + // recovery is not re-entered for a core we are killing here. + val orphan = xrayProcess + xrayProcess = null + try { + orphan?.destroy() + } catch (destroyError: Exception) { + Log.w(TAG, "Failed to destroy partially started Xray process", destroyError) + } deleteEphemeralConfigOnFailure(configFilesDir) return false } @@ -429,7 +467,25 @@ object XrayCoreManager { AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTING } + // CountDownTimer builds a Handler, so it only constructs on a looper thread. + // Core recovery runs on a bare Thread (XrayVPNService.handleXrayCoreExit), + // where this threw "Can't create handler inside thread ... that has not + // called Looper.prepare()" and took the whole restart down with it. + private val mainHandler = Handler(Looper.getMainLooper()) + + private fun runOnMain(block: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) block() else mainHandler.post(block) + } + private fun startTimer(context: Context) { + // Captured now: a stop that lands while the post is queued must not + // leave a timer ticking for a generation that is already gone. + val token = AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN + runOnMain { startTimerOnMain(context, token) } + } + + private fun startTimerOnMain(context: Context, token: String?) { + if (AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN != token) return countDownTimer?.cancel() seconds = 0 countDownTimer = object : CountDownTimer(Long.MAX_VALUE, 1000) { @@ -454,9 +510,11 @@ object XrayCoreManager { } private fun stopTimer() { - countDownTimer?.cancel() - countDownTimer = null - seconds = 0 + runOnMain { + countDownTimer?.cancel() + countDownTimer = null + seconds = 0 + } } fun sendStatusBroadcast(context: Context, config: XrayConfig) { diff --git a/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/service/XrayVPNService.kt b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/service/XrayVPNService.kt index 68b694e..67a6e4e 100644 --- a/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/service/XrayVPNService.kt +++ b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/service/XrayVPNService.kt @@ -16,6 +16,7 @@ import android.util.Log import com.github.tfox.flutter_vless.xray.core.XrayCoreManager import com.github.tfox.flutter_vless.xray.dto.XrayConfig import com.github.tfox.flutter_vless.xray.utils.AppConfigs +import com.github.tfox.flutter_vless.xray.utils.RevoltAlerts import org.json.JSONObject import java.io.File @@ -44,6 +45,14 @@ class XrayVPNService : VpnService() { private var sessionDeadlineToken: String? = null private val sessionDeadlineRunnable = Runnable { enforceSessionDeadline() } + // The warning's own backstop, mirroring the deadline's. The alarm alone is + // not enough: it is inexact on purpose (no SCHEDULE_EXACT_ALARM), and with + // the app closed on a dozing device it was measured never arriving at all, + // while the deadline's Handler fired within a second of its target. + private val sessionWarningRunnable = Runnable { + handleSessionWarningAlarm(sessionDeadlineToken.orEmpty()) + } + override fun onCreate() { super.onCreate() } @@ -66,6 +75,13 @@ class XrayVPNService : VpnService() { return START_NOT_STICKY } + // Must stay above the command dispatch: an intent with no COMMAND + // falls through to the final else and stops the tunnel. + if (intent.getBooleanExtra(EXTRA_SESSION_WARNING, false)) { + handleSessionWarningAlarm(intent.getStringExtra("RUNTIME_TOKEN").orEmpty()) + return START_NOT_STICKY + } + val command = if (Build.VERSION.SDK_INT >= 33) { intent.getSerializableExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS::class.java) } else { @@ -82,7 +98,7 @@ class XrayVPNService : VpnService() { Log.w(TAG, "Ignoring STOP_SERVICE without matching runtime generation") return START_NOT_STICKY } - stopAll(requestedToken) + stopAll(requestedToken, userRequested = true) return START_NOT_STICKY } @@ -121,6 +137,7 @@ class XrayVPNService : VpnService() { XrayCoreManager.stopCore(this) } cleanup(clearDeadline = true) + RevoltAlerts.cancelAll(this) shuttingDownIntentionally = false currentConfig = config @@ -418,14 +435,21 @@ class XrayVPNService : VpnService() { } } - private fun stopAll(confirmationToken: String = currentConfig?.RUNTIME_TOKEN.orEmpty()) { + // Anything that stops a live tunnel unasked must say so. isRunning is read + // before cleanup() clears it, which keeps a failed connect silent. + private fun stopAll( + confirmationToken: String = currentConfig?.RUNTIME_TOKEN.orEmpty(), + userRequested: Boolean = false, + ) { if (shuttingDownIntentionally) return + val alertUser = isRunning && !userRequested shuttingDownIntentionally = true cleanup(clearDeadline = true) currentConfig = null XrayCoreManager.stopCore(this, confirmationToken) stopForeground(true) stopSelf() + if (alertUser) RevoltAlerts.postDisconnected(this) } private fun armSessionDeadline(config: XrayConfig, remainingSeconds: Long) { @@ -451,17 +475,91 @@ class XrayVPNService : VpnService() { return } + armExpiryWarning(config.RUNTIME_TOKEN, epochDeadline) + deadlineHandler.removeCallbacks(sessionDeadlineRunnable) if (delayMs == 0L) deadlineHandler.post(sessionDeadlineRunnable) else deadlineHandler.postDelayed(sessionDeadlineRunnable, delayMs) } + // Advisory, unlike the deadline itself: nothing here fails closed. Dart + // re-pushes the deadline every sync, so this runs about once a minute. + private fun armExpiryWarning(runtimeToken: String, expiresAtEpochMs: Long) { + if (runtimeToken.isEmpty()) return + + val warnAt = expiresAtEpochMs - RevoltAlerts.WARNING_LEAD_MS + // Inside the window already (or the short bootstrap deadline). Cancel + // nothing here: with no dedupe on the re-push, cancelling on this + // branch deletes the armed warning and never re-arms it. + if (warnAt <= System.currentTimeMillis() + WARNING_REARM_GRACE_MS) return + + cancelSessionWarningAlarm() + // A warning on screen now belongs to an older deadline (top-up). + RevoltAlerts.cancelExpiryWarning(this) + + try { + val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager + val pendingIntent = sessionWarningPendingIntent(runtimeToken) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + // Inexact on purpose - keeps this off SCHEDULE_EXACT_ALARM. + alarmManager.setAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + warnAt, + pendingIntent, + ) + } else { + alarmManager.set(AlarmManager.RTC_WAKEUP, warnAt, pendingIntent) + } + } catch (e: Exception) { + Log.w(TAG, "Failed to schedule session-expiry warning", e) + } + + // postDelayed runs on uptimeMillis, so a truly suspended device still + // fires this late - handleSessionWarningAlarm then rejects it on the + // window check rather than claiming "5 minutes" with seconds left. + // Whenever the process is merely backgrounded, which is the common + // case, this is what actually delivers the warning. + val warnDelay = warnAt - System.currentTimeMillis() + deadlineHandler.removeCallbacks(sessionWarningRunnable) + if (warnDelay > 0L) { + deadlineHandler.postDelayed(sessionWarningRunnable, warnDelay) + } + } + + private fun handleSessionWarningAlarm(alarmToken: String) { + val config = currentConfig + if (config == null) { + // Woke a process with no live runtime; nothing to warn about. + stopSelf() + return + } + if (alarmToken.isEmpty() || alarmToken != config.RUNTIME_TOKEN) { + Log.w(TAG, "Ignoring session warning for stale runtime generation") + return + } + + val deadline = sessionDeadlineElapsed ?: return + val remaining = deadline - SystemClock.elapsedRealtime() + if (remaining < WARNING_FLOOR_MS || + remaining > RevoltAlerts.WARNING_LEAD_MS + WARNING_SLACK_MS + ) { + // Too late, or the deadline moved. Saying "5 minutes" with + // seconds left is worse than silence. + Log.w(TAG, "Ignoring session warning outside the warning window") + return + } + + RevoltAlerts.postExpiryWarning(this) + } + private fun clearSessionDeadline() { deadlineHandler.removeCallbacks(sessionDeadlineRunnable) sessionDeadlineElapsed = null sessionDeadlineToken = null clearPersistedDeadline() cancelSessionExpiryAlarm() + cancelSessionWarningAlarm() + RevoltAlerts.cancelExpiryWarning(this) } private fun enforceSessionDeadline() { @@ -561,8 +659,12 @@ class XrayVPNService : VpnService() { } else { clearPersistedDeadline() cancelSessionExpiryAlarm() + cancelSessionWarningAlarm() + RevoltAlerts.cancelExpiryWarning(this) stopForeground(true) stopSelf() + // This arm does not go through stopAll, so it posts for itself. + RevoltAlerts.postDisconnected(this) } } @@ -607,6 +709,37 @@ class XrayVPNService : VpnService() { } } + private fun cancelSessionWarningAlarm() { + deadlineHandler.removeCallbacks(sessionWarningRunnable) + try { + val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager + // Look it up rather than build it: FLAG_UPDATE_CURRENT would + // rewrite the live alarm's RUNTIME_TOKEN to "" before cancelling. + existingSessionWarningPendingIntent()?.let { alarmManager.cancel(it) } + } catch (e: Exception) { + Log.w(TAG, "Failed to cancel session-warning alarm", e) + } + } + + private fun existingSessionWarningPendingIntent(): PendingIntent? { + val intent = Intent(this, XrayVPNService::class.java) + .putExtra(EXTRA_SESSION_WARNING, true) + val flags = PendingIntent.FLAG_NO_CREATE or + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 + return PendingIntent.getService(this, SESSION_WARNING_REQUEST_CODE, intent, flags) + } + + // Identity is component + request code, not extras, so the warning needs + // its own code or it clobbers the expiry alarm. + private fun sessionWarningPendingIntent(runtimeToken: String): PendingIntent { + val intent = Intent(this, XrayVPNService::class.java) + .putExtra(EXTRA_SESSION_WARNING, true) + .putExtra("RUNTIME_TOKEN", runtimeToken) + val flags = PendingIntent.FLAG_UPDATE_CURRENT or + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 + return PendingIntent.getService(this, SESSION_WARNING_REQUEST_CODE, intent, flags) + } + private fun sessionExpiryPendingIntent(runtimeToken: String): PendingIntent { val intent = Intent(this, XrayVPNService::class.java) .putExtra(EXTRA_SESSION_EXPIRED, true) @@ -628,7 +761,7 @@ class XrayVPNService : VpnService() { } override fun onRevoke() { - stopAll() + stopAll(userRequested = true) super.onRevoke() } @@ -681,6 +814,14 @@ class XrayVPNService : VpnService() { private const val PREF_RUNTIME_TOKEN = "runtime_token" private const val PREF_EXPIRES_AT_MS = "expires_at_ms" private const val EXTRA_SESSION_EXPIRED = "SESSION_EXPIRED" + private const val EXTRA_SESSION_WARNING = "SESSION_WARNING" private const val SESSION_EXPIRY_REQUEST_CODE = 1001 + private const val SESSION_WARNING_REQUEST_CODE = 1002 + // Drift allowed before a warning is treated as belonging elsewhere. + private const val WARNING_SLACK_MS = 60_000L + // Don't re-arm for a deadline that moved by round-trip jitter alone. + private const val WARNING_REARM_GRACE_MS = 15_000L + // Below this the warning has lost its point. + private const val WARNING_FLOOR_MS = 60_000L } } diff --git a/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/utils/RevoltAlerts.kt b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/utils/RevoltAlerts.kt new file mode 100644 index 0000000..d014dc3 --- /dev/null +++ b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/utils/RevoltAlerts.kt @@ -0,0 +1,143 @@ +package com.github.tfox.flutter_vless.xray.utils + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log + +// local_packages/.../xray/utils/RevoltAlerts.kt +// +// The two session alerts. Both XrayVPNService (:RunSoLibXrayDaemon) and +// MainActivity post them, sharing ids so a second post replaces. Separate from +// the foreground-service notification (id 1, IMPORTANCE_LOW): these must be +// noticed, and must be dismissible. +object RevoltAlerts { + + const val CHANNEL_ID = "REVOLT_VPN_ALERTS" + const val WARNING_NOTIFICATION_ID = 2 + const val DISCONNECTED_NOTIFICATION_ID = 3 + + const val WARNING_LEAD_MS = 5 * 60 * 1000L + + private const val TAG = "RevoltAlerts" + private const val LAUNCH_REQUEST_CODE = 2001 + + // Neither line names a number or a mechanism, because this notification + // cannot honour either. The caller accepts it anywhere from WARNING_FLOOR_MS + // to WARNING_LEAD_MS + WARNING_SLACK_MS remaining, so "5 minutes" can be + // off by four of them; and "watch an ad" is a dead end in a release build, + // where the support bypass is still kDebugMode-gated (rule 87). Put the + // number back once the warning is punctual, and the ad back once the + // support button works without a debug build. + fun postExpiryWarning(context: Context) { + post( + context, + WARNING_NOTIFICATION_ID, + "Revolt VPN disconnects soon", + "Open the app to extend your session.", + ) + } + + fun postDisconnected(context: Context) { + post( + context, + DISCONNECTED_NOTIFICATION_ID, + "Revolt VPN disconnected", + "Your traffic is no longer protected. Tap to reconnect.", + ) + } + + fun cancelExpiryWarning(context: Context) = cancel(context, WARNING_NOTIFICATION_ID) + + fun cancelDisconnected(context: Context) = cancel(context, DISCONNECTED_NOTIFICATION_ID) + + fun cancelAll(context: Context) { + cancelExpiryWarning(context) + cancelDisconnected(context) + } + + private fun cancel(context: Context, id: Int) { + try { + manager(context)?.cancel(id) + } catch (e: Exception) { + Log.w(TAG, "Failed to cancel alert $id", e) + } + } + + private fun post(context: Context, id: Int, title: String, text: String) { + if (!canPost(context)) { + Log.w(TAG, "POST_NOTIFICATIONS not granted; dropping alert $id") + return + } + + try { + val manager = manager(context) ?: return + ensureChannel(manager) + + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(context, CHANNEL_ID) + } else { + @Suppress("DEPRECATION") + Notification.Builder(context).setPriority(Notification.PRIORITY_HIGH) + } + + val icon = context.resources + .getIdentifier("notification_icon", "drawable", context.packageName) + .takeIf { it != 0 } ?: android.R.drawable.ic_dialog_info + + builder + .setContentTitle(title) + .setContentText(text) + .setStyle(Notification.BigTextStyle().bigText(text)) + .setSmallIcon(icon) + .setAutoCancel(true) + .setOngoing(false) + // A replacing post must not sound again. + .setOnlyAlertOnce(true) + + launchIntent(context)?.let { builder.setContentIntent(it) } + + manager.notify(id, builder.build()) + } catch (e: Exception) { + Log.w(TAG, "Failed to post alert $id", e) + } + } + + private fun canPost(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true + return context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + } + + private fun manager(context: Context): NotificationManager? = + context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager + + // Importance is fixed at creation, so ask for HIGH up front. + private fun ensureChannel(manager: NotificationManager) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + if (manager.getNotificationChannel(CHANNEL_ID) != null) return + val channel = NotificationChannel( + CHANNEL_ID, + "Session alerts", + NotificationManager.IMPORTANCE_HIGH, + ) + channel.description = "Warns before a session ends and when protection stops." + manager.createNotificationChannel(channel) + } + + private fun launchIntent(context: Context): PendingIntent? { + val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) + ?: return null + intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_NEW_TASK + val flags = PendingIntent.FLAG_UPDATE_CURRENT or + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 + return PendingIntent.getActivity(context, LAUNCH_REQUEST_CODE, intent, flags) + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 52a3186..4d9c1d0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 3.3.6+32 +version: 3.3.7+33 environment: sdk: ">=3.0.0 <4.0.0" diff --git a/test/session_lifecycle_regression_test.dart b/test/session_lifecycle_regression_test.dart new file mode 100644 index 0000000..e282042 --- /dev/null +++ b/test/session_lifecycle_regression_test.dart @@ -0,0 +1,134 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +// Source assertions, in the style of release_verification_regression_test.dart. +// The behaviours below were all found broken on a device and fixed on +// 2026-10-17 (internal/DEVICE_TEST_2026-10-17.md). None of them can be covered +// by a Dart unit test - they live in the vendored Android runtime, and the one +// that does not needs a live tunnel - but every one of them regresses silently +// and looks like tidying when it does. Pin the shape instead of the behaviour. + +const _runtime = 'local_packages/flutter_vless_android-1.1.5/android/src/main/' + 'kotlin/com/github/tfox/flutter_vless/xray'; + +void main() { + test('Xray core recovery survives running off a bare thread', () { + final core = File('$_runtime/core/XrayCoreManager.kt').readAsStringSync(); + + // startTimer builds a CountDownTimer, which builds a Handler. Recovery runs + // on a plain Thread, so constructing it inline threw Looper.prepare() and + // killed every restart attempt. + expect(core, contains('Handler(Looper.getMainLooper())')); + expect(core, contains('runOnMain')); + expect( + core, + contains('runOnMain { startTimerOnMain(context, token) }'), + reason: 'startTimer must not construct the CountDownTimer inline', + ); + + // The post must not outlive the generation that scheduled it. + expect(core, contains('if (AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN != token) return')); + + // pb.start() succeeds before the throw, so the catch has to kill the + // process or each retry strands another live core. + expect(core, contains('val orphan = xrayProcess')); + expect( + core.indexOf('xrayProcess = null'), + lessThan(core.indexOf('orphan?.destroy()')), + reason: 'clear the field before destroying, so the monitor thread\'s ' + 'identity guard fails and recovery is not re-entered', + ); + }); + + test('a failed Xray core still reports why, without logging destinations', () { + final core = File('$_runtime/core/XrayCoreManager.kt').readAsStringSync(); + + expect(core, contains('STARTUP_LOG_WINDOW_MS')); + expect(core, contains('STARTUP_LOG_MAX_LINES')); + expect( + core, + isNot(contains('reader.forEachLine { _ -> }')), + reason: 'discarding the core output leaves exit 23 undiagnosable', + ); + // Rule 95: a core that has been up long enough to carry traffic must not + // have its log retained. + expect(core, contains('startupLog.clear()')); + }); + + test('the expiry warning has the same backstop as the deadline', () { + final service = File('$_runtime/service/XrayVPNService.kt').readAsStringSync(); + + // The alarm alone is inexact on purpose (no SCHEDULE_EXACT_ALARM) and Doze + // deferred it past the end of the session it was warning about. + expect(service, contains('sessionWarningRunnable')); + expect( + service, + contains('deadlineHandler.postDelayed(sessionWarningRunnable, warnDelay)'), + reason: 'without this the warning never arrives when the app is closed', + ); + expect( + service, + contains('deadlineHandler.removeCallbacks(sessionWarningRunnable)'), + reason: 'a cancelled warning must drop its Handler callback too', + ); + expect(service, isNot(contains('SCHEDULE_EXACT_ALARM"'))); + }); + + test('the warning promises neither a number of minutes nor an ad', () { + final alerts = File('$_runtime/utils/RevoltAlerts.kt').readAsStringSync(); + + // It is accepted anywhere from WARNING_FLOOR_MS to LEAD + SLACK remaining, + // so a figure can be off by minutes; and the support bypass is still + // kDebugMode-gated (rule 87), so an ad is a dead end in release. + expect(alerts, isNot(contains('disconnects in 5 minutes'))); + expect(alerts, isNot(contains('Watch an ad'))); + expect(alerts, contains('Revolt VPN disconnects soon')); + }); + + test('the countdown notification is taken down when the tunnel stops', () { + final service = File('lib/logic/notification_service.dart').readAsStringSync(); + final timer = File('lib/logic/session_timer.dart').readAsStringSync(); + final activity = + File('android/app/src/main/kotlin/com/paladinvpn/app/MainActivity.kt') + .readAsStringSync(); + + // updateTimer re-posts an ongoing, non-dismissible notification every tick, + // so a tick after the service stops strands a frozen countdown. + expect(service, contains('cancelTimer')); + expect(activity, contains('"cancelNotification"')); + expect(timer, contains('await NotificationService.cancelTimer()')); + + // Must run after the service is down, or the platform swallows the cancel. + expect( + timer.indexOf('await vpnConnection.disconnect();'), + lessThan(timer.indexOf('await NotificationService.cancelTimer()')), + ); + }); + + test('only a user-requested disconnect hands the session back', () { + final timer = File('lib/logic/session_timer.dart').readAsStringSync(); + final hivemind = File('lib/logic/hivemind_service.dart').readAsStringSync(); + + expect(hivemind, contains('releaseSession')); + expect(hivemind, contains("_publicUrl('/session/stop')")); + + // /session/stop takes a device_id as its only proof of ownership, so it + // must never be reachable from an error path. disconnect() is called only + // by the connect button; _doDisconnect is every other ending. + final disconnectEntry = timer.indexOf('Future disconnect('); + final doDisconnect = timer.indexOf('Future _doDisconnect('); + final release = timer.indexOf('HivemindService.releaseSession()'); + expect(release, greaterThan(disconnectEntry)); + expect( + release, + lessThan(doDisconnect), + reason: 'releaseSession belongs in disconnect(), not _doDisconnect', + ); + expect( + 'HivemindService.releaseSession()'.allMatches(timer).length, + 1, + reason: 'exactly one call site', + ); + }); +}