From 9ac236a981061931db5faf6d2cbb72b6fabe5e41 Mon Sep 17 00:00:00 2001 From: esefxdz Date: Wed, 2 Sep 2026 20:33:33 +0300 Subject: [PATCH] feat(app): OS session-expiry enforcement, session persistence, support sync Enforce session expiry at the OS level (AlarmManager + handler) so the tunnel dies even if the UI process is killed; persist the session so a START_STICKY restart re-establishes it; sync immediately after a support top-up; slow the session beacon 5s/30s -> 60s/60s; drop the Always-on-VPN deep-link and the legacy 'ass' migration. Docs: CHANGELOG 3.3.4, privacy-policy log disclosure, README. --- CHANGELOG.md | 33 ++ PRIVACY_POLICY.md | 4 +- README.md | 2 +- android/app/build.gradle.kts | 16 +- android/app/src/main/AndroidManifest.xml | 3 + .../kotlin/com/paladinvpn/app/MainActivity.kt | 27 +- lib/components/support_button.dart | 1 + lib/logic/connection_settings.dart | 6 +- lib/logic/power_settings.dart | 29 +- lib/logic/session_timer.dart | 23 +- .../in_settings/background_reliability.dart | 26 +- pubspec.yaml | 2 +- tool/patch_flutter_vless_allowed_apps.dart | 293 ------------------ .../patch_flutter_vless_runtime_recovery.dart | 119 +++++++ tool/patch_flutter_vless_secure_socks_v2.dart | 246 +++++++++++++-- 15 files changed, 447 insertions(+), 383 deletions(-) delete mode 100644 tool/patch_flutter_vless_allowed_apps.dart create mode 100644 tool/patch_flutter_vless_runtime_recovery.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index d9fd75d..58a17b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,39 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH --- +## [3.3.4] — 2026-09-02 + +### Added +- **OS-level session-expiry enforcement.** The VPN service now holds an absolute + deadline and tears the tunnel down on its own clock — a main-looper handler + plus an `AlarmManager` exact alarm (falling back to an inexact alarm on + Android 13+ when exact alarms aren't granted). The app pushes + `now + expires_in_seconds` on each session sync, so a session ends even if the + UI process has been killed. +- **Session survival across process death.** The service persists the active + session and re-establishes it when Android restarts it (`START_STICKY`). +- **Support-ad extension reflected immediately.** A rewarded "Support us" ad now + forces an immediate server sync, so the countdown and expiry deadline update + without waiting for the next heartbeat. + +### Changed +- **Session-status beacon slowed.** Foreground poll: 5 s → 60 s; background poll: + 30 s → 60 s. +- **Background-reliability guidance.** The "Always-on VPN" settings deep-link was + removed; the battery-optimisation exemption remains, with expiry now enforced + by the service itself. + +### Removed +- Legacy `ass` connection-mode migration — unrecognized persisted modes now + default to TUN. + +### Security & privacy +- Session credentials and the per-user VLESS UUID are now held app-private on + disk (`active_session.bin`) for the session's lifetime and removed on + disconnect or expiry. Previously they were memory-only. + +--- + ## [3.3.3] — 2026-09-01 ### Fixed diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md index d3e1f8e..cb21628 100644 --- a/PRIVACY_POLICY.md +++ b/PRIVACY_POLICY.md @@ -1,6 +1,6 @@ # Privacy Policy -**Last updated:** August 2026 +**Last updated:** September 2026 ReVoltVPN is a free VPN service built and operated by ReVolt Team. @@ -27,7 +27,7 @@ Support ads (the "Support us" button) extend an active session by 30 minutes and ## Operational logs -We keep a short in-memory log buffer (last 200 lines) for debugging and server health monitoring. These logs include truncated device IDs (first 8 characters only), session start/stop events, and error messages. Logs never leave the server, are not retained after a server restart, and are only accessible to the server operator via local console. We also maintain an aggregate total of all data ever transferred (in GB) for capacity planning — this counter is not tied to any individual device. +Server logs are written to the systemd journal (`journalctl -u hivemind`). These logs include truncated device IDs (first 8 characters only), session start/stop events, and error messages. Logs stay on the server and are accessible only to the server operator via local console; retention follows the server's systemd journal settings. We also maintain an aggregate total of all data ever transferred (in GB) for capacity planning — this counter is not tied to any individual device. ## What we do not collect diff --git a/README.md b/README.md index 67b9e99..8f3ac7c 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ No email. No password. No payment. The ad pays for the server. ## Protocol - **Transport** — VLESS over XHTTP (HTTP/2 multiplexed, path-hidden behind `/revolt`) -- **Obfuscation** — Xray Reality spoofs `some website's` TLS. To any DPI box, your tunnel looks like a browser visiting that website +- **Obfuscation** — Xray Reality spoofs a real website's TLS. To any DPI box, your tunnel looks like a browser visiting that website - **Encryption** — TLS 1.3 with borrowed certificate (Reality). No certbot, no domain ownership required - **API** — Standard HTTPS to a separate domain. Tunnel destination is IP-pinned — domain compromise is DoS only diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c4b7afa..b302bb4 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -24,9 +24,9 @@ val dartExecutable = File( "bin/cache/dart-sdk/bin/dart" }, ) -val flutterVlessPatchScript = File( +val flutterVlessRecoveryPatchScript = File( projectRootDir, - "tool/patch_flutter_vless_allowed_apps.dart", + "tool/patch_flutter_vless_runtime_recovery.dart", ) val flutterVlessSecureSocksPatchScript = File( projectRootDir, @@ -37,17 +37,17 @@ val flutterVlessSecureSocksScopeFixScript = File( "tool/patch_flutter_vless_secure_socks_scope_fix.dart", ) -val patchFlutterVlessAllowedApps = tasks.register("patchFlutterVlessAllowedApps") { +val patchFlutterVlessRuntimeRecovery = tasks.register("patchFlutterVlessRuntimeRecovery") { group = "build setup" - description = "Patch pinned flutter_vless_android 1.1.5 with ReVolt routing/runtime hardening" + description = "Recover stale Xray core and tun2socks socket before restart" workingDir(projectRootDir) doFirst { if (!dartExecutable.isFile) { throw GradleException("Flutter Dart executable not found: ${dartExecutable.absolutePath}") } - if (!flutterVlessPatchScript.isFile) { - throw GradleException("flutter_vless patch script missing: ${flutterVlessPatchScript.absolutePath}") + if (!flutterVlessRecoveryPatchScript.isFile) { + throw GradleException("flutter_vless recovery patch script missing: ${flutterVlessRecoveryPatchScript.absolutePath}") } if (!File(projectRootDir, ".dart_tool/package_config.json").isFile) { throw GradleException("Flutter package metadata missing. Run `flutter pub get` before Android compilation.") @@ -57,7 +57,7 @@ val patchFlutterVlessAllowedApps = tasks.register("patchFlutterVlessAllowe commandLine( dartExecutable.absolutePath, "run", - flutterVlessPatchScript.absolutePath, + flutterVlessRecoveryPatchScript.absolutePath, ) } @@ -65,7 +65,7 @@ val patchFlutterVlessSecureSocks = tasks.register("patchFlutterVlessSecure group = "build setup" description = "Add authenticated ephemeral SOCKS5 and fail-closed process recovery to pinned flutter_vless_android 1.1.5" workingDir(projectRootDir) - dependsOn(patchFlutterVlessAllowedApps) + dependsOn(patchFlutterVlessRuntimeRecovery) doFirst { if (!dartExecutable.isFile) { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0b45736..1476797 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -10,6 +10,9 @@ + + result.success(requestIgnoreBatteryOptimizations()) - // Android's own Always-on VPN is the only thing that keeps a - // tunnel up across swipe-from-recents and reboot. It cannot be - // toggled programmatically, so deep-link the user to it. - "openVpnSettings" -> result.success(openVpnSettings()) + "setSessionExpiry" -> { + val expiresAtMs = call.argument("expiresAtMs")?.toLong() ?: 0L + if (expiresAtMs > 0) { + sendSessionExpiryToDaemon(expiresAtMs) + } + result.success(null) + } // Is our VPN runtime actually alive? The service lives in // :RunSoLibXrayDaemon, a separate process, so its statics are // NOT visible here -- this is the only honest check. @@ -153,16 +156,14 @@ class MainActivity : FlutterActivity() { } } - /** Deep-links to the OS VPN settings, where Always-on VPN lives. */ - private fun openVpnSettings(): Boolean { - return try { - startActivity( - Intent(Settings.ACTION_VPN_SETTINGS) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), - ) - true + /** Pushes the session-expiry deadline to the VPN service process. */ + private fun sendSessionExpiryToDaemon(expiresAtMs: Long) { + try { + val intent = Intent(this, XrayVPNService::class.java) + intent.putExtra("SESSION_EXPIRES_AT_MS", expiresAtMs) + startService(intent) } catch (_: Exception) { - false + // Daemon not reachable; its persisted expiry still applies. } } diff --git a/lib/components/support_button.dart b/lib/components/support_button.dart index 59a1b86..4565016 100644 --- a/lib/components/support_button.dart +++ b/lib/components/support_button.dart @@ -34,6 +34,7 @@ class _SupportButtonState extends State { final rewarded = await ad.showAd('support'); if (rewarded) { await timer.markSupportRewardClaimed(); + await timer.syncNow(); } } finally { if (mounted) setState(() => _busy = false); diff --git a/lib/logic/connection_settings.dart b/lib/logic/connection_settings.dart index 35ae125..6189360 100644 --- a/lib/logic/connection_settings.dart +++ b/lib/logic/connection_settings.dart @@ -18,9 +18,9 @@ abstract final class ConnectionSettings { final prefs = await SharedPreferences.getInstance(); final storedMode = prefs.getString(_modeKey); - // Legacy 'ass' was the pre-V3 SOCKS5 label; map it to proxy. Everything - // else (including the old 'auto') is TUN — there is no runtime auto mode. - _mode = storedMode == ConnectionMode.proxy.name || storedMode == 'ass' + // Any unrecognized value (including the pre-V3 'auto') is TUN. 'proxy' is + // the only persisted value that selects the SOCKS5 mode. + _mode = storedMode == ConnectionMode.proxy.name ? ConnectionMode.proxy : ConnectionMode.tun; diff --git a/lib/logic/power_settings.dart b/lib/logic/power_settings.dart index 2768818..cee5934 100644 --- a/lib/logic/power_settings.dart +++ b/lib/logic/power_settings.dart @@ -5,9 +5,9 @@ import 'package:flutter/services.dart'; /// /// Android stops background services of apps that are not exempt from Doze and /// App Standby, and it tears down the whole process group when a task is swiped -/// away. Those are OS behaviours, not app bugs, and the OS provides exactly two -/// sanctioned ways around them: a battery-optimisation exemption, and the -/// system's own Always-on VPN. Both need one user tap; neither can be forced. +/// away. Those are OS behaviours, not app bugs; the battery-optimisation +/// exemption is the one sanctioned way an app can ask for around them, and it +/// still needs one user tap. abstract final class PowerSettings { PowerSettings._(); @@ -42,16 +42,6 @@ abstract final class PowerSettings { } } - /// Opens the OS VPN settings screen, where Always-on VPN is configured. - static Future openVpnSettings() async { - if (kIsWeb) return false; - try { - return await _channel.invokeMethod('openVpnSettings') ?? false; - } catch (_) { - return false; - } - } - /// Whether the VPN runtime is genuinely alive. /// /// The Xray service runs in a separate process, so nothing it stores in @@ -71,6 +61,19 @@ abstract final class PowerSettings { return const VpnRuntimeState(processAlive: false, vpnTransport: false); } } + + /// Pushes the absolute session-expiry timestamp (epoch ms) to the VPN + /// service so it can tear the tunnel down even when the UI process is gone. + static Future setSessionExpiry(int expiresAtMs) async { + if (kIsWeb) return; + try { + await _channel.invokeMethod('setSessionExpiry', { + 'expiresAtMs': expiresAtMs, + }); + } catch (_) { + // Best-effort. The daemon also keeps its last persisted expiry. + } + } } @immutable diff --git a/lib/logic/session_timer.dart b/lib/logic/session_timer.dart index f783201..4625647 100644 --- a/lib/logic/session_timer.dart +++ b/lib/logic/session_timer.dart @@ -7,11 +7,13 @@ import 'package:revoltvpn/logic/app_config.dart'; import 'package:revoltvpn/logic/crypto_service.dart'; import 'package:revoltvpn/logic/hivemind_service.dart'; import 'package:revoltvpn/logic/notification_service.dart'; +import 'package:revoltvpn/logic/power_settings.dart'; import 'package:revoltvpn/logic/vpn_connection.dart'; class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { Timer? _timer; int _tickCount = 0; + int _lastPushedExpiryAtMs = 0; bool _appBackgrounded = false; final VpnConnection vpnConnection; @@ -36,8 +38,12 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { static const int _maxOfflineSeconds = 120; int _offlineSeconds = 0; - static const int _pollIntervalSeconds = 5; - static const int _backgroundPollIntervalSeconds = 30; + // The countdown ticks locally every second; these only drive the slow + // server heartbeat that catches server-side changes (support-ad extension, + // 10 GB cap, session-ended). Keeping it slow avoids a chatty /session/status + // beacon (see internal/LAUNCH_READINESS.md "cut the beacon rate"). + static const int _pollIntervalSeconds = 60; + static const int _backgroundPollIntervalSeconds = 60; int _lastUsedBytes = 0; DateTime? _lastSuccessfulSyncAt; @@ -159,6 +165,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { _lastSuccessfulSyncAt = null; _currentSpeedKBps = 0.0; _tickCount = 0; + _lastPushedExpiryAtMs = 0; _hasSyncedOnce = false; _consecutiveFailures = 0; _offlineSeconds = 0; @@ -213,6 +220,10 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { } } + /// Force an immediate server sync (e.g. after a support-ad extension so the + /// countdown reflects the new expiry without waiting for the next heartbeat). + Future syncNow() => _syncWithHivemind(); + Future disconnect({String reason = 'User requested'}) async { await _doDisconnect(reason); } @@ -286,6 +297,14 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { _remainingAtLastSync = expiresValue.toInt(); _remainingSeconds = _remainingAtLastSync; + // Push the absolute deadline to the VPN service so it can tear the + // tunnel down on its own clock, even if this UI process is gone. + final expiresAtMs = + DateTime.now().millisecondsSinceEpoch + _remainingSeconds * 1000; + if ((expiresAtMs - _lastPushedExpiryAtMs).abs() > 5000) { + _lastPushedExpiryAtMs = expiresAtMs; + unawaited(PowerSettings.setSessionExpiry(expiresAtMs)); + } _usedBytes = _readNonNegativeInt(data['used_bytes'], _usedBytes); final now = DateTime.now(); diff --git a/lib/screens/settings/in_settings/background_reliability.dart b/lib/screens/settings/in_settings/background_reliability.dart index ffa5f29..5b74118 100644 --- a/lib/screens/settings/in_settings/background_reliability.dart +++ b/lib/screens/settings/in_settings/background_reliability.dart @@ -55,14 +55,6 @@ class _BackgroundReliabilityTileState extends State ); } - Future _openVpnSettings() async { - final opened = await PowerSettings.openVpnSettings(); - if (!mounted || opened) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Could not open VPN settings.')), - ); - } - @override Widget build(BuildContext context) { final exempt = _batteryExempt; @@ -76,8 +68,8 @@ class _BackgroundReliabilityTileState extends State style: TextStyle(color: AppColors.textWhite, fontSize: 15), ), subtitle: Text( - 'Android stops background apps to save power. These two settings ' - 'are what keep the VPN running when the app is closed.', + 'Android stops background apps to save power. Disabling battery ' + 'optimisation is what keeps the VPN running when the app is closed.', style: TextStyle(color: AppColors.textDim, fontSize: 12), ), ), @@ -106,20 +98,6 @@ class _BackgroundReliabilityTileState extends State label: const Text('Allow background activity'), ), ], - const SizedBox(height: 16), - const Text( - 'Always-on VPN keeps the tunnel up even after the app is closed ' - 'or the phone restarts. Turn it on for Revolt VPN in Android\'s ' - 'VPN settings, and enable "Block connections without VPN" if you ' - 'want traffic to stop rather than leak when it drops.', - style: TextStyle(color: AppColors.textMuted, fontSize: 12), - ), - const SizedBox(height: 8), - OutlinedButton.icon( - onPressed: _openVpnSettings, - icon: const Icon(Icons.settings), - label: const Text('Open VPN settings'), - ), ], ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 3afba06..4308f0d 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.3+29 +version: 3.3.4+30 environment: sdk: ">=3.0.0 <4.0.0" diff --git a/tool/patch_flutter_vless_allowed_apps.dart b/tool/patch_flutter_vless_allowed_apps.dart deleted file mode 100644 index 94468fd..0000000 --- a/tool/patch_flutter_vless_allowed_apps.dart +++ /dev/null @@ -1,293 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -const packageName = 'flutter_vless_android'; -const expectedVersionFragment = 'flutter_vless_android-1.1.5'; - -Never fail(String message) { - stderr.writeln('[flutter_vless patch] $message'); - exit(1); -} - -Directory packageRoot() { - final configFile = File('.dart_tool/package_config.json'); - if (!configFile.existsSync()) { - fail('.dart_tool/package_config.json is missing; run flutter pub get first'); - } - - final decoded = jsonDecode(configFile.readAsStringSync()); - if (decoded is! Map || decoded['packages'] is! List) { - fail('package_config.json is invalid'); - } - - Map? package; - for (final item in decoded['packages'] as List) { - if (item is Map && item['name'] == packageName) { - package = Map.from(item); - break; - } - } - if (package == null) fail('$packageName was not found'); - - final rootUriValue = package['rootUri']; - if (rootUriValue is! String || rootUriValue.isEmpty) { - fail('$packageName has no rootUri'); - } - - final rootUri = Uri.parse(rootUriValue); - final resolved = rootUri.hasScheme - ? rootUri - : configFile.parent.absolute.uri.resolveUri(rootUri); - if (resolved.scheme != 'file') fail('unsupported package URI: $resolved'); - - final root = Directory.fromUri(resolved); - final normalized = root.absolute.path.replaceAll('\\', '/'); - if (!normalized.contains(expectedVersionFragment)) { - fail('expected flutter_vless_android 1.1.5, got ${root.path}'); - } - if (!root.existsSync()) fail('package root does not exist: ${root.path}'); - return root; -} - -void replaceOnce( - File file, - String oldValue, - String newValue, - String marker, -) { - if (!file.existsSync()) fail('missing upstream source: ${file.path}'); - final text = file.readAsStringSync(); - if (text.contains(marker)) { - stdout.writeln('[flutter_vless patch] already current: ${file.path}'); - return; - } - if (!text.contains(oldValue)) { - fail('upstream source changed; expected block not found in ${file.path}'); - } - file.writeAsStringSync(text.replaceFirst(oldValue, newValue)); - stdout.writeln('[flutter_vless patch] patched: ${file.path}'); -} - -void replaceOneOf( - File file, - Map replacements, - String marker, -) { - if (!file.existsSync()) fail('missing upstream source: ${file.path}'); - var text = file.readAsStringSync(); - if (text.contains(marker)) { - stdout.writeln('[flutter_vless patch] already current: ${file.path}'); - return; - } - for (final entry in replacements.entries) { - if (!text.contains(entry.key)) continue; - text = text.replaceFirst(entry.key, entry.value); - file.writeAsStringSync(text); - stdout.writeln('[flutter_vless patch] patched/upgraded: ${file.path}'); - return; - } - fail('upstream/legacy patch shape not recognized in ${file.path}'); -} - -void main() { - final root = packageRoot(); - final kotlin = Directory( - '${root.path}/android/src/main/kotlin/com/github/tfox/flutter_vless', - ); - - final configFile = File('${kotlin.path}/xray/dto/XrayConfig.kt'); - replaceOnce( - configFile, - ''' /** List of app package names to exclude from VPN. */ - var BLOCKED_APPS: ArrayList = ArrayList(), -''', - ''' /** List of app package names to exclude from VPN. */ - var BLOCKED_APPS: ArrayList = ArrayList(), - - /** List of app package names that are exclusively allowed into VPN. */ - var ALLOWED_APPS: ArrayList = ArrayList(), -''', - 'var ALLOWED_APPS:', - ); - - final pluginFile = File('${kotlin.path}/FlutterVlessPlugin.kt'); - replaceOnce( - pluginFile, - ''' private lateinit var context: Context -''', - ''' private lateinit var context: Context - private var allowedApps: ArrayList = ArrayList() -''', - 'private var allowedApps:', - ); - - const currentAllowedHandler = ''' "setAllowedApps" -> { - val requested = call.argument>("allowed_apps") ?: ArrayList() - allowedApps = ArrayList( - requested - .map { it.trim() } - .filter { it.isNotEmpty() && it != context.packageName } - .distinct() - .sorted() - ) - result.success(null) - } -'''; - const legacyAllowedHandler = ''' "setAllowedApps" -> { - val requested = call.argument>("allowed_apps") ?: ArrayList() - allowedApps = ArrayList( - requested.map { it.trim() }.filter { it.isNotEmpty() }.distinct() - ) - result.success(null) - } -'''; - replaceOneOf( - pluginFile, - { - legacyAllowedHandler: currentAllowedHandler, - ''' when (call.method) { - "startVless" -> { -''': ''' when (call.method) { -$currentAllowedHandler "startVless" -> { -''', - }, - 'it.isNotEmpty() && it != context.packageName', - ); - - replaceOnce( - pluginFile, - ''' config.BLOCKED_APPS = call.argument>("blocked_apps") ?: ArrayList() - config.BYPASS_SUBNETS = call.argument>("bypass_subnets") ?: ArrayList() -''', - ''' config.BLOCKED_APPS = call.argument>("blocked_apps") ?: ArrayList() - config.ALLOWED_APPS = ArrayList(allowedApps) - config.BYPASS_SUBNETS = call.argument>("bypass_subnets") ?: ArrayList() -''', - 'config.ALLOWED_APPS = ArrayList(allowedApps)', - ); - - final serviceFile = File('${kotlin.path}/xray/service/XrayVPNService.kt'); - replaceOnce( - serviceFile, - ''' if (config != null) { - // Ensure clean state before starting - cleanup() -''', - ''' if (config != null) { - // A fast reconnect must not leave the previous Xray process - // owning the local SOCKS/HTTP ports. - if (XrayCoreManager.isXrayRunning()) { - Log.w(TAG, "Stopping stale Xray core before restart") - XrayCoreManager.stopCore(this) - } - cleanup() -''', - 'Stopping stale Xray core before restart', - ); - - replaceOnce( - serviceFile, - ''' val tun2socksPath = File(applicationInfo.nativeLibraryDir, "libtun2socks.so").absolutePath - val sockPath = File(filesDir, "sock_path").absolutePath -''', - ''' val tun2socksPath = File(applicationInfo.nativeLibraryDir, "libtun2socks.so").absolutePath - val socketFile = File(filesDir, "sock_path") - if (socketFile.exists() && !socketFile.delete()) { - Log.w(TAG, "Could not delete stale tun2socks socket") - } - val sockPath = socketFile.absolutePath -''', - 'Could not delete stale tun2socks socket', - ); - - final upstreamPolicy = r''' try { - builder.addDisallowedApplication(packageName) -} catch (e: Exception) { - Log.e(TAG, \"Failed to exclude app from VPN\", e) -} - -for (pkg in config.BLOCKED_APPS) { - try { - builder.addDisallowedApplication(pkg) - Log.d(TAG, \"Excluded from VPN: $pkg\") - } catch (e: Exception) { - Log.e(TAG, \"Failed to exclude $pkg from VPN\", e) - } -} -'''.replaceAll(r'\"', '"'); - - final legacyPatchedPolicy = r''' if (config.ALLOWED_APPS.isNotEmpty()) { - for (pkg in config.ALLOWED_APPS) { - try { - builder.addAllowedApplication(pkg) - Log.d(TAG, \"Allowed into VPN: $pkg\") - } catch (e: Exception) { - Log.e(TAG, \"Failed to allow $pkg into VPN\", e) - } - } - } else { - try { - builder.addDisallowedApplication(packageName) - } catch (e: Exception) { - Log.e(TAG, \"Failed to exclude app from VPN\", e) - } - - for (pkg in config.BLOCKED_APPS) { - try { - builder.addDisallowedApplication(pkg) - Log.d(TAG, \"Excluded from VPN: $pkg\") - } catch (e: Exception) { - Log.e(TAG, \"Failed to exclude $pkg from VPN\", e) - } - } - } -'''.replaceAll(r'\"', '"'); - - final hardenedPolicy = r''' if (config.ALLOWED_APPS.isNotEmpty() && config.BLOCKED_APPS.isNotEmpty()) { - throw IllegalStateException(\"Allowed and blocked app policies cannot be mixed\") - } - - if (config.ALLOWED_APPS.isNotEmpty()) { - var allowedCount = 0 - for (pkg in config.ALLOWED_APPS) { - try { - builder.addAllowedApplication(pkg) - allowedCount++ - Log.d(TAG, \"Allowed into VPN: $pkg\") - } catch (e: Exception) { - throw IllegalStateException(\"Failed to allow selected app $pkg\", e) - } - } - if (allowedCount == 0) { - throw IllegalStateException(\"Selected-only routing has no valid applications\") - } - } else { - try { - builder.addDisallowedApplication(packageName) - } catch (e: Exception) { - Log.e(TAG, \"Failed to exclude app from VPN\", e) - } - - for (pkg in config.BLOCKED_APPS) { - try { - builder.addDisallowedApplication(pkg) - Log.d(TAG, \"Excluded from VPN: $pkg\") - } catch (e: Exception) { - Log.e(TAG, \"Failed to exclude $pkg from VPN\", e) - } - } - } -'''.replaceAll(r'\"', '"'); - - // Support both pristine 1.1.5 and the earlier local allowlist patch. - replaceOneOf( - serviceFile, - { - legacyPatchedPolicy: hardenedPolicy, - upstreamPolicy: hardenedPolicy, - }, - 'Allowed and blocked app policies cannot be mixed', - ); - - stdout.writeln('[flutter_vless patch] allowed-app routing patch is ready'); -} diff --git a/tool/patch_flutter_vless_runtime_recovery.dart b/tool/patch_flutter_vless_runtime_recovery.dart new file mode 100644 index 0000000..cdc676d --- /dev/null +++ b/tool/patch_flutter_vless_runtime_recovery.dart @@ -0,0 +1,119 @@ +import 'dart:convert'; +import 'dart:io'; + +const packageName = 'flutter_vless_android'; +const expectedVersionFragment = 'flutter_vless_android-1.1.5'; + +Never fail(String message) { + stderr.writeln('[flutter_vless recovery patch] $message'); + exit(1); +} + +Directory packageRoot() { + final configFile = File('.dart_tool/package_config.json'); + if (!configFile.existsSync()) { + fail('.dart_tool/package_config.json is missing; run flutter pub get first'); + } + + final decoded = jsonDecode(configFile.readAsStringSync()); + if (decoded is! Map || decoded['packages'] is! List) { + fail('package_config.json is invalid'); + } + + Map? package; + for (final item in decoded['packages'] as List) { + if (item is Map && item['name'] == packageName) { + package = Map.from(item); + break; + } + } + if (package == null) fail('$packageName was not found'); + + final rootUriValue = package['rootUri']; + if (rootUriValue is! String || rootUriValue.isEmpty) { + fail('$packageName has no rootUri'); + } + + final rootUri = Uri.parse(rootUriValue); + final resolved = rootUri.hasScheme + ? rootUri + : configFile.parent.absolute.uri.resolveUri(rootUri); + if (resolved.scheme != 'file') fail('unsupported package URI: $resolved'); + + final root = Directory.fromUri(resolved); + final normalized = root.absolute.path.replaceAll('\\', '/'); + if (!normalized.contains(expectedVersionFragment)) { + fail('expected flutter_vless_android 1.1.5, got ${root.path}'); + } + if (!root.existsSync()) fail('package root does not exist: ${root.path}'); + return root; +} + +void replaceOnce( + File file, + String oldValue, + String newValue, + String marker, +) { + if (!file.existsSync()) fail('missing upstream source: ${file.path}'); + final text = file.readAsStringSync(); + if (text.contains(marker)) { + stdout.writeln('[flutter_vless recovery patch] already current: ${file.path}'); + return; + } + if (!text.contains(oldValue)) { + fail('upstream source changed; expected block not found in ${file.path}'); + } + file.writeAsStringSync(text.replaceFirst(oldValue, newValue)); + stdout.writeln('[flutter_vless recovery patch] patched: ${file.path}'); +} + +void main() { + final root = packageRoot(); + final kotlin = Directory( + '${root.path}/android/src/main/kotlin/com/github/tfox/flutter_vless', + ); + + final serviceFile = File('${kotlin.path}/xray/service/XrayVPNService.kt'); + if (!serviceFile.existsSync()) { + fail('pinned Android runtime service source is missing'); + } + + // A fast reconnect must not leave the previous Xray process owning the local + // SOCKS/HTTP ports. + replaceOnce( + serviceFile, + ''' if (config != null) { + // Ensure clean state before starting + cleanup() +''', + ''' if (config != null) { + // A fast reconnect must not leave the previous Xray process + // owning the local SOCKS/HTTP ports. + if (XrayCoreManager.isXrayRunning()) { + Log.w(TAG, "Stopping stale Xray core before restart") + XrayCoreManager.stopCore(this) + } + cleanup() +''', + 'Stopping stale Xray core before restart', + ); + + // The tun2socks Unix socket must not survive a previous session. + replaceOnce( + serviceFile, + ''' val tun2socksPath = File(applicationInfo.nativeLibraryDir, "libtun2socks.so").absolutePath + val sockPath = File(filesDir, "sock_path").absolutePath +''', + ''' val tun2socksPath = File(applicationInfo.nativeLibraryDir, "libtun2socks.so").absolutePath + val socketFile = File(filesDir, "sock_path") + if (socketFile.exists() && !socketFile.delete()) { + Log.w(TAG, "Could not delete stale tun2socks socket") + } + val sockPath = socketFile.absolutePath +''', + 'Could not delete stale tun2socks socket', + ); + + stdout.writeln('[flutter_vless recovery patch] runtime recovery patch is ready'); +} diff --git a/tool/patch_flutter_vless_secure_socks_v2.dart b/tool/patch_flutter_vless_secure_socks_v2.dart index f64d827..c68deb4 100644 --- a/tool/patch_flutter_vless_secure_socks_v2.dart +++ b/tool/patch_flutter_vless_secure_socks_v2.dart @@ -215,24 +215,156 @@ void patchService(File file) { ); } - const oldNullIntent = r''' if (intent == null) { - stopSelf() - return START_NOT_STICKY - } -'''; - const newNullIntent = r''' if (intent == null) { - if (isRunning && currentConfig != null) return START_REDELIVER_INTENT - stopSelf() - return START_NOT_STICKY - } -'''; - text = replaceOnce( - text, - oldNullIntent, - newNullIntent, - 'null intent lifecycle', - ); + if (!text.contains('private data class PersistedSession(')) { + const persistenceHelpers = r''' private data class PersistedSession( + val proxyOnly: Boolean, + val config: XrayConfig, + val sessionExpiresAtMs: Long, + ) : java.io.Serializable + private var proxyOnlyMode = false + private var sessionExpiresAtMs = 0L + + private val expiryHandler = android.os.Handler(android.os.Looper.getMainLooper()) + private val expiryRunnable = Runnable { + if (sessionExpiresAtMs > 0 && System.currentTimeMillis() >= sessionExpiresAtMs) { + Log.w(TAG, "Session expired; tearing down VPN") + sessionExpiresAtMs = 0L + cancelExpiryAlarm() + postSessionExpiredNotification() + deletePersistedSession() + stopAll() + } + } + + private fun scheduleExpiryCheck() { + expiryHandler.removeCallbacks(expiryRunnable) + if (sessionExpiresAtMs > 0) { + val delay = (sessionExpiresAtMs - System.currentTimeMillis()).coerceAtLeast(0) + expiryHandler.postDelayed(expiryRunnable, delay) + } + scheduleExpiryAlarm() + } + + private fun scheduleExpiryAlarm() { + cancelExpiryAlarm() + if (sessionExpiresAtMs <= 0) return + try { + val alarmManager = getSystemService(android.content.Context.ALARM_SERVICE) as android.app.AlarmManager + try { + alarmManager.setExactAndAllowWhileIdle( + android.app.AlarmManager.RTC_WAKEUP, + sessionExpiresAtMs, + sessionExpiredPendingIntent(), + ) + } catch (e: SecurityException) { + alarmManager.setAndAllowWhileIdle( + android.app.AlarmManager.RTC_WAKEUP, + sessionExpiresAtMs, + sessionExpiredPendingIntent(), + ) + } + } catch (e: Exception) { + Log.w(TAG, "Failed to schedule expiry alarm", e) + } + } + + private fun cancelExpiryAlarm() { + try { + val alarmManager = getSystemService(android.content.Context.ALARM_SERVICE) as android.app.AlarmManager + alarmManager.cancel(sessionExpiredPendingIntent()) + } catch (e: Exception) { + } + } + + private fun sessionExpiredPendingIntent(): android.app.PendingIntent { + val intent = Intent(this, XrayVPNService::class.java) + intent.putExtra("SESSION_EXPIRED", true) + val flags = android.app.PendingIntent.FLAG_UPDATE_CURRENT or + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + android.app.PendingIntent.FLAG_IMMUTABLE + } else { + 0 + } + return android.app.PendingIntent.getService(this, 1001, intent, flags) + } + + private fun persistSession() { + val config = currentConfig ?: return + try { + java.io.ObjectOutputStream( + java.io.FileOutputStream(File(filesDir, "active_session.bin")), + ).use { it.writeObject(PersistedSession(proxyOnlyMode, config, sessionExpiresAtMs)) } + } catch (e: Exception) { + Log.w(TAG, "Failed to persist active session", e) + } + } + + private fun loadPersistedSession(): PersistedSession? { + return try { + val file = File(filesDir, "active_session.bin") + if (!file.exists()) return null + java.io.ObjectInputStream(java.io.FileInputStream(file)).use { + it.readObject() as? PersistedSession + } + } catch (e: Exception) { + Log.w(TAG, "Failed to restore persisted session", e) + null + } + } + + private fun deletePersistedSession() { + try { + File(filesDir, "active_session.bin").delete() + } catch (e: Exception) { + Log.w(TAG, "Failed to delete persisted session", e) + } + } + + private fun postSessionExpiredNotification() { + try { + createNotificationChannel() + val launchIntent = packageManager.getLaunchIntentForPackage(packageName) + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + android.app.PendingIntent.FLAG_IMMUTABLE or android.app.PendingIntent.FLAG_UPDATE_CURRENT + } else { + android.app.PendingIntent.FLAG_UPDATE_CURRENT + } + val contentIntent = if (launchIntent != null) { + android.app.PendingIntent.getActivity(this, 0, launchIntent, flags) + } else { + null + } + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + android.app.Notification.Builder(this, "vpn_service_channel") + } else { + @Suppress("DEPRECATION") + android.app.Notification.Builder(this) + } + builder + .setContentTitle("Revolt VPN") + .setContentText("Session expired. Reconnect to continue.") + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setAutoCancel(true) + if (contentIntent != null) { + builder.setContentIntent(contentIntent) + } + val manager = getSystemService(android.app.NotificationManager::class.java) + manager?.notify(2, builder.build()) + } catch (e: Exception) { + Log.w(TAG, "Failed to post session expired notification", e) + } + } +'''; + text = replaceOnce( + text, + ' override fun onCreate() {\n super.onCreate()\n }\n', + ' override fun onCreate() {\n super.onCreate()\n }\n\n$persistenceHelpers', + 'session persistence helpers', + ); + } + + // Snapshot the running config so a START_STICKY restart can re-establish it. if (!text.contains(' currentConfig = config\n')) { text = replaceOnce( text, @@ -244,11 +376,79 @@ void patchService(File file) { text = replaceOnce( text, - ' return START_STICKY\n', - ' return START_REDELIVER_INTENT\n', - 'redeliver START command', + ' val proxyOnly = intent.getBooleanExtra("PROXY_ONLY", false)\n', + ' val proxyOnly = intent.getBooleanExtra("PROXY_ONLY", false)\n proxyOnlyMode = proxyOnly\n persistSession()\n', + 'persist session snapshot', ); + text = replaceOnce( + text, + ' if (command == AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE) {\n stopAll()\n return START_NOT_STICKY\n }\n', + ' if (command == AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE) {\n deletePersistedSession()\n stopAll()\n return START_NOT_STICKY\n }\n', + 'delete session on explicit stop', + ); + + const oldNullIntent = r''' if (intent == null) { + stopSelf() + return START_NOT_STICKY + } +'''; + const newNullIntent = r''' if (intent == null) { + if (isRunning && currentConfig != null) return START_STICKY + val persisted = loadPersistedSession() + if (persisted != null) { + Log.w(TAG, "Re-establishing VPN from persisted session") + createNotificationChannel() + val notification = createNotification("VPN Service Running") + try { + if (Build.VERSION.SDK_INT >= 34) { + startForeground(1, notification, FOREGROUND_SERVICE_TYPE_SPECIAL_USE) + } else { + startForeground(1, notification) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to start foreground", e) + } + if (XrayCoreManager.isXrayRunning()) { + Log.w(TAG, "Stopping stale Xray core before restart") + XrayCoreManager.stopCore(this) + } + cleanup() + currentConfig = persisted.config + proxyOnlyMode = persisted.proxyOnly + sessionExpiresAtMs = persisted.sessionExpiresAtMs + if (XrayCoreManager.startCore(this, persisted.config)) { + if (!persisted.proxyOnly) { + setupVpn(persisted.config) + } else { + isRunning = true + } + scheduleExpiryCheck() + } else { + stopSelf() + return START_NOT_STICKY + } + return START_STICKY + } + stopSelf() + return START_NOT_STICKY + } +'''; + text = replaceOnce( + text, + oldNullIntent, + newNullIntent, + 'null intent lifecycle', + ); + + text = replaceOnce( + text, + ' val command = if (Build.VERSION.SDK_INT >= 33) {\n', + ' if (intent.getBooleanExtra("SESSION_EXPIRED", false)) {\n Log.w(TAG, "Session-expiry alarm fired; tearing down VPN")\n sessionExpiresAtMs = 0L\n cancelExpiryAlarm()\n deletePersistedSession()\n postSessionExpiredNotification()\n stopAll()\n return START_NOT_STICKY\n }\n\n if (intent.hasExtra("SESSION_EXPIRES_AT_MS")) {\n sessionExpiresAtMs = intent.getLongExtra("SESSION_EXPIRES_AT_MS", 0L)\n scheduleExpiryCheck()\n persistSession()\n return if (isRunning && currentConfig != null) START_STICKY else START_NOT_STICKY\n }\n\n val command = if (Build.VERSION.SDK_INT >= 33) {\n', + 'session expiry update', + ); + + if (!text.contains('private data class SecureSocksCredentials(')) { const credentialsHelper = r''' private data class SecureSocksCredentials( val port: Int, @@ -508,12 +708,12 @@ void patchService(File file) { ); } - if (!text.contains(' recoveringXray = false\n tun2socksProcess?.destroy()')) { + if (!text.contains(' cancelExpiryAlarm()\n tun2socksProcess?.destroy()')) { text = replaceOnce( text, ' isRunning = false\n tun2socksProcess?.destroy()\n', - ' isRunning = false\n recoveringXray = false\n tun2socksProcess?.destroy()\n', - 'cancel recovery on cleanup', + ' isRunning = false\n recoveringXray = false\n expiryHandler.removeCallbacks(expiryRunnable)\n cancelExpiryAlarm()\n tun2socksProcess?.destroy()\n', + 'cancel recovery and expiry on cleanup', ); }