Author SHA1 Message Date
esefxdzandClaude Opus 5 f26c459f6c fix(app): do not throw from ConnectionSettings.initialize
The migration write is best-effort again. `_mode` is already the safe TUN
default in memory by the time it runs, so a failed write leaves nothing
insecure -- it only means the migration is retried on the next launch.

Throwing there was a startup brick rather than a fail-closed guard.
`initialize()` is awaited from `main()` at lib/main.dart:19, before
`runApp()`, with no try/catch and no `runZonedGuarded`; `FlutterError.onError`
and `PlatformDispatcher.onError` are installed later and would not catch it
either. The branch also runs on every fresh install, since `storedMode` is null
and `null != 'tun'`. A `setString` returning false would therefore have meant a
black screen with no error UI, repeating every launch, in exchange for no
security benefit.

Also restores two comments this PR removed from code it does not otherwise
touch -- the `_reconnect` note on why the repair path shows no rewarded ad, and
the explanation of how an orphaned proxy outlives the app process -- and drops
a stray 'ass' from the legacy-values comment.

The `setMode` half of the PR is unchanged: persisting before mutating memory
and surfacing failures to the UI is the right behaviour.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019HT2x2JvJNg7XNsJubfHFf
2026-09-06 11:41:15 +03:00
Naveax 8cd450d5b1 fix(app): fail closed on invalid connection mode state
Restore SOCKS5 only from the current proxy value and keep UI state unchanged when persistence fails.
2026-09-04 00:07:09 +03:00
esefxdz 9ac236a981 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.
2026-09-02 20:33:33 +03:00
esefxdzandClaude Opus 5 dfeb7f161c fix(app): resume the session clock when attaching to a live tunnel
The clock sat at 00:00:00 on a connected tunnel, and the notification
countdown froze with it.

SessionTimer is built lazily, so when the app reattaches to a tunnel that
outlived its UI process, VpnConnection had already reported CONNECTED
before the timer existed. A change listener does not replay events it was
not present for, so the timer never learned it should be running. The
notification text is pushed by that same timer, which is why the countdown
froze at the same moment -- one cause, both symptoms.

The timer now evaluates the current connection state once at construction
rather than waiting for a transition, and is created eagerly alongside
VpnConnection so it is listening from the start.

Resuming also no longer requires adoptedRunningRuntime or a previous
successful sync. Both are false in a freshly started process, which is
exactly the case that needed to work. A connected tunnel with a stopped
clock is now always resumed.

Version 3.3.3+29.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-01 00:39:22 +03:00
esefxdzandClaude Opus 5 186dc906da feat(app): keep the tunnel alive via the OS, drop the workarounds
3.3.1 could display "connected" against a tunnel that was no longer
running, with a frozen countdown and no traffic. Three causes, and the
fix for each removes more code than it adds.

Liveness was read from getConnectedServerDelay(), which measures through
AppConfigs.V2RAY_CONFIG?.LOCAL_SOCKS5_PORT ?: 10807. AppConfigs lives in
the :RunSoLibXrayDaemon process, so from the UI process it is always null
and the port always fell back to 10807 -- accidentally correct while the
SOCKS port was fixed, permanently wrong once 3.3.1 made it ephemeral.
Liveness now comes from the OS: is our service process running, and is a
VPN transport actually present.

The foreground notification (id 1) is owned by XrayVPNService via
startForeground, but MainActivity was posting to the same id from the UI
process. When that process died its notification stayed on screen with
nothing left to update it. MainActivity now refuses to post for a dead
runtime and clears the stale notification instead.

Nothing ever asked Android for permission to stay alive. Adds the
battery-optimisation exemption prompt and a deep link to Always-on VPN --
the two sanctioned mechanisms for background survival, each needing one
user tap.

Removes Standard/Extreme resilience and the runtime-restart machinery:
recovery ran off a disconnect broadcast delivered to the app process, so
it could never fire when Android killed that process. Removes startup
restoration and the config-snapshot rebuild along with it; with the OS
keeping the tunnel up there is nothing to restore.

Version 3.3.2+28.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-01 00:16:05 +03:00
20 changed files with 873 additions and 637 deletions

No files matched your search

+86
View File
@@ -5,6 +5,92 @@ 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
- **The session clock stayed at 00:00:00 on a live tunnel, and the notification
countdown froze with it.** `SessionTimer` is built lazily, so when the app reattaches
to a tunnel that outlived its UI process, `VpnConnection` had already reported
"connected" before the timer existed — and a change listener does not replay events
it was not present for. The timer now evaluates the current connection state once at
construction instead of waiting for a transition, and is created eagerly alongside
`VpnConnection`. Because the timer is what pushes text to the foreground
notification, a stopped clock also meant a frozen countdown; both come from this.
- Resuming the clock no longer requires `adoptedRunningRuntime` or a previous
successful sync, both of which are false in a freshly started process. A connected
tunnel with a stopped clock is now always treated as something to resume.
## [3.3.2] — 2026-09-01
Background reliability. 3.3.1 could show "connected" against a tunnel that was no
longer running; this release fixes the cause rather than the symptom, and removes the
machinery that existed to work around it.
### Added
- **Background reliability settings.** Android stops background services of apps that
are not exempt from Doze, and tears down the process group when a task is swiped
away. Two OS mechanisms prevent that, and the app now surfaces both: a one-tap
**battery optimisation exemption**, and a deep link to Android's **Always-on VPN**
settings (with a note about "Block connections without VPN"). Neither can be enabled
programmatically — each needs a single user tap, once.
### Fixed
- **The app could report "connected" with no traffic flowing.** Startup state came from
`getConnectedServerDelay()`, which measures through
`AppConfigs.V2RAY_CONFIG?.LOCAL_SOCKS5_PORT ?: 10807`. `AppConfigs` lives in the VPN's
own process, so from the UI process it is always null and the port always fell back to
10807. That was accidentally correct while the SOCKS port was fixed; 3.3.1's
per-session ephemeral ports made it permanently wrong. Liveness is now determined from
the OS — whether our VPN service process is running, and whether a VPN transport is
actually present.
- **A frozen countdown could outlive the tunnel.** The foreground notification (id 1) is
owned by the VPN service via `startForeground`, but the UI process was posting to the
same id. When that process died, its notification stayed on screen with nothing left to
update it. The app no longer posts for a runtime that is not alive, and clears a stale
notification instead.
### Removed
- **Standard/Extreme resilience modes** and the runtime-restart machinery behind them.
Recovery ran off a disconnect broadcast delivered to the app process, so it could never
fire in the case that actually matters — Android killing that process. It was
complexity substituting for background persistence.
- **Startup restoration**, including the config-snapshot rebuild. With the tunnel kept
alive by the OS, there is nothing to restore; the app simply reports what is running.
- The `resilience_mode` preference and its settings tile.
## [3.3.1] — 2026-08-31
### Added
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+8 -8
View File
@@ -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<Exec>("patchFlutterVlessAllowedApps") {
val patchFlutterVlessRuntimeRecovery = tasks.register<Exec>("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<Exec>("patchFlutterVlessAllowe
commandLine(
dartExecutable.absolutePath,
"run",
flutterVlessPatchScript.absolutePath,
flutterVlessRecoveryPatchScript.absolutePath,
)
}
@@ -65,7 +65,7 @@ val patchFlutterVlessSecureSocks = tasks.register<Exec>("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) {
+6
View File
@@ -7,6 +7,12 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<!-- Doze/App Standby stop background services of non-exempt apps. A VPN
cannot stay up without this; Play allows it for VPN functionality. -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<!-- OS-level session-expiry alarm. Auto-granted on 31-32; on 33+ the daemon
falls back to setAndAllowWhileIdle if exact alarms aren't granted. -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<!-- flutter_vless 1.1.5 declares this legacy permission. ReVolt never needs it. -->
<uses-permission
@@ -3,6 +3,7 @@ package com.paladinvpn.app
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.ActivityManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
@@ -11,10 +12,13 @@ import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.os.VibrationEffect
import android.os.Vibrator
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.service.XrayVPNService
@@ -29,6 +33,7 @@ class MainActivity : FlutterActivity() {
private val notificationChannel = "com.revoltvpn.app/notification"
private val hapticsChannel = "com.revoltvpn.app/haptics"
private val networkChannel = "com.revoltvpn.app/network"
private val powerChannel = "com.revoltvpn.app/power"
private var notificationChannelReady = false
private var networkCallback: ConnectivityManager.NetworkCallback? = null
@@ -46,6 +51,31 @@ class MainActivity : FlutterActivity() {
}
}
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, powerChannel)
.setMethodCallHandler { call, result ->
when (call.method) {
// Doze and App Standby stop background services of apps that
// are not exempt. A VPN has to be exempt to survive the screen
// going off, so surface the real state rather than guessing.
"isIgnoringBatteryOptimizations" ->
result.success(isIgnoringBatteryOptimizations())
"requestIgnoreBatteryOptimizations" ->
result.success(requestIgnoreBatteryOptimizations())
"setSessionExpiry" -> {
val expiresAtMs = call.argument<Number>("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.
"isVpnRuntimeAlive" -> result.success(isVpnRuntimeAlive())
else -> result.notImplemented()
}
}
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, notificationChannel)
.setMethodCallHandler { call, result ->
if (call.method == "updateNotificationText") {
@@ -88,6 +118,96 @@ class MainActivity : FlutterActivity() {
super.onDestroy()
}
/** True when the OS has exempted us from Doze / App Standby. */
private fun isIgnoringBatteryOptimizations(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
return try {
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
pm.isIgnoringBatteryOptimizations(packageName)
} catch (_: Exception) {
false
}
}
/**
* Opens the system exemption prompt. Android shows this as a single dialog;
* it cannot be granted silently, so the caller must treat a false return as
* "ask again later" rather than a denial.
*/
private fun requestIgnoreBatteryOptimizations(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
if (isIgnoringBatteryOptimizations()) return true
return try {
startActivity(
Intent(
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
Uri.parse("package:$packageName"),
),
)
true
} catch (_: Exception) {
// Some OEM builds hide the direct intent; fall back to the list.
try {
startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
true
} catch (_: Exception) {
false
}
}
}
/** 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) {
// Daemon not reachable; its persisted expiry still applies.
}
}
/**
* Whether the VPN runtime is genuinely alive.
*
* The Xray service runs in :RunSoLibXrayDaemon, a separate process, so its
* statics (AppConfigs.V2RAY_CONFIG, V2RAY_STATE) are NOT visible from here --
* reading them yields a fresh, empty copy. getRunningAppProcesses still
* returns our *own* processes, which makes it the one reliable signal.
*/
private fun isVpnRuntimeAlive(): Map<String, Any> {
var processAlive = false
try {
val am = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
processAlive = am.runningAppProcesses
?.any { it.processName.endsWith(VPN_PROCESS_SUFFIX) } == true
} catch (_: Exception) {
}
var vpnTransport = false
try {
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
vpnTransport = cm.allNetworks.any { network ->
cm.getNetworkCapabilities(network)
?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true
}
} catch (_: Exception) {
}
return mapOf(
"processAlive" to processAlive,
"vpnTransport" to vpnTransport,
)
}
/** Removes a notification left behind by a runtime that is no longer alive. */
private fun cancelVpnNotification() {
try {
NotificationManagerCompat.from(this).cancel(NOTIFICATION_ID)
} catch (_: Exception) {
}
}
private fun updateVpnNotification(title: String, text: String, actionLabel: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) !=
@@ -96,6 +216,15 @@ class MainActivity : FlutterActivity() {
return
}
// The foreground notification (id 1) is owned by XrayVPNService via
// startForeground. Posting to it from this process while the service is
// gone leaves a frozen countdown nothing will ever update again.
val alive = isVpnRuntimeAlive()["processAlive"] == true
if (!alive) {
cancelVpnNotification()
return
}
ensureNotificationChannel()
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
@@ -344,6 +473,7 @@ class MainActivity : FlutterActivity() {
companion object {
private const val NOTIFICATION_ID = 1
private const val VPN_PROCESS_SUFFIX = ":RunSoLibXrayDaemon"
private const val NOTIFICATION_CHANNEL_ID = "XRAY_SERVICE_CHANNEL"
}
}
+1
View File
@@ -34,6 +34,7 @@ class _SupportButtonState extends State<SupportButton> {
final rewarded = await ad.showAd('support');
if (rewarded) {
await timer.markSupportRewardClaimed();
await timer.syncNow();
}
} finally {
if (mounted) setState(() => _busy = false);
+22 -23
View File
@@ -1,20 +1,17 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
enum ConnectionMode { tun, proxy }
enum ResilienceMode { standard, extreme }
abstract final class ConnectionSettings {
ConnectionSettings._();
static const String _modeKey = 'connection_mode';
static const String _resilienceModeKey = 'resilience_mode';
static ConnectionMode _mode = ConnectionMode.tun;
static ResilienceMode _resilienceMode = ResilienceMode.standard;
static bool _initialized = false;
static ConnectionMode get mode => _mode;
static ResilienceMode get resilienceMode => _resilienceMode;
static Future<void> initialize() async {
if (_initialized) return;
@@ -22,35 +19,37 @@ 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'
// Only the explicit current proxy value restores SOCKS5. Unknown and
// obsolete values, including the pre-V3 'auto' label, fail closed to
// full-tunnel TUN instead of silently selecting a weaker route.
_mode = storedMode == ConnectionMode.proxy.name
? ConnectionMode.proxy
: ConnectionMode.tun;
_resilienceMode =
prefs.getString(_resilienceModeKey) == ResilienceMode.extreme.name
? ResilienceMode.extreme
: ResilienceMode.standard;
if (storedMode != _mode.name) {
await prefs.setString(_modeKey, _mode.name);
// Best-effort. _mode is already the safe TUN default in memory, so a
// failed write leaves nothing insecure — it only means the migration
// is retried next launch. initialize() runs from main() before
// runApp(), with no zone guard, so throwing here would black-screen
// the app on the fresh-install path for no security gain.
final migrated = await prefs.setString(_modeKey, _mode.name);
if (!migrated) {
debugPrint('[ConnectionSettings] Could not persist migrated mode.');
}
}
_initialized = true;
}
static Future<void> setMode(ConnectionMode mode) async {
static Future<bool> setMode(ConnectionMode mode) async {
if (_initialized && _mode == mode) return true;
final prefs = await SharedPreferences.getInstance();
final saved = await prefs.setString(_modeKey, mode.name);
if (!saved) return false;
_mode = mode;
_initialized = true;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_modeKey, mode.name);
}
static Future<void> setResilienceMode(ResilienceMode mode) async {
_resilienceMode = mode;
_initialized = true;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_resilienceModeKey, mode.name);
return true;
}
}
+93
View File
@@ -0,0 +1,93 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// Background-survival controls.
///
/// 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; 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._();
static const MethodChannel _channel = MethodChannel(
'com.revoltvpn.app/power',
);
/// Whether the OS has exempted this app from Doze / App Standby.
static Future<bool> isBatteryOptimisationDisabled() async {
if (kIsWeb) return true;
try {
return await _channel.invokeMethod<bool>(
'isIgnoringBatteryOptimizations',
) ??
false;
} catch (_) {
return false;
}
}
/// Shows the system exemption dialog. Returns false only when no system UI
/// could be opened at all — a user declining still returns true.
static Future<bool> requestDisableBatteryOptimisation() async {
if (kIsWeb) return true;
try {
return await _channel.invokeMethod<bool>(
'requestIgnoreBatteryOptimizations',
) ??
false;
} catch (_) {
return false;
}
}
/// Whether the VPN runtime is genuinely alive.
///
/// The Xray service runs in a separate process, so nothing it stores in
/// statics is visible to us. This asks the OS instead: is our service process
/// running, and (for TUN) is there an active VPN transport?
static Future<VpnRuntimeState> runtimeState() async {
if (kIsWeb) return const VpnRuntimeState(processAlive: false, vpnTransport: false);
try {
final result = await _channel.invokeMapMethod<String, dynamic>(
'isVpnRuntimeAlive',
);
return VpnRuntimeState(
processAlive: result?['processAlive'] == true,
vpnTransport: result?['vpnTransport'] == true,
);
} catch (_) {
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<void> setSessionExpiry(int expiresAtMs) async {
if (kIsWeb) return;
try {
await _channel.invokeMethod<void>('setSessionExpiry', {
'expiresAtMs': expiresAtMs,
});
} catch (_) {
// Best-effort. The daemon also keeps its last persisted expiry.
}
}
}
@immutable
class VpnRuntimeState {
final bool processAlive;
final bool vpnTransport;
const VpnRuntimeState({
required this.processAlive,
required this.vpnTransport,
});
/// TUN needs both a live service and a real VPN interface. Proxy mode has no
/// VPN interface at all, so the service process is the whole signal.
bool aliveFor({required bool tunMode}) =>
processAlive && (!tunMode || vpnTransport);
}
+33 -12
View File
@@ -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;
@@ -47,6 +53,11 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
WidgetsBinding.instance.addObserver(this);
vpnConnection.addListener(_onVpnConnectionChanged);
unawaited(_loadSupportRewardState());
// This provider is built lazily, so the tunnel may already be up and
// VpnConnection may already have fired its change before we attached.
// A listener does not replay history -- evaluate the current state once.
// Deferred so we never notify during the build that created us.
unawaited(Future.microtask(_onVpnConnectionChanged));
}
int get remaining => _remainingSeconds;
@@ -102,18 +113,15 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
}
void _onVpnConnectionChanged() {
// A live tunnel with a stopped clock is always wrong, however we got here:
// adopting a tunnel that outlived the UI process, recovering from a blip, or
// attaching late to one that was already up. Previously this required either
// adoptedRunningRuntime or _hasSyncedOnce, both false on a fresh process, so
// the clock stayed at 00:00:00 and the notification countdown froze with it.
if (vpnConnection.status == VpnStatus.connected &&
!isRunning &&
vpnConnection.isStartupRestoration) {
_resumeTicking();
return;
}
if (vpnConnection.status == VpnStatus.connected &&
!isRunning &&
!_isDisconnecting &&
_hasSyncedOnce) {
debugPrint('[Timer] VPN reconnected after blip - resuming.');
!_isDisconnecting) {
debugPrint('[Timer] Connected with a stopped clock - resuming.');
_resumeTicking();
return;
}
@@ -157,6 +165,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
_lastSuccessfulSyncAt = null;
_currentSpeedKBps = 0.0;
_tickCount = 0;
_lastPushedExpiryAtMs = 0;
_hasSyncedOnce = false;
_consecutiveFailures = 0;
_offlineSeconds = 0;
@@ -211,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<void> syncNow() => _syncWithHivemind();
Future<void> disconnect({String reason = 'User requested'}) async {
await _doDisconnect(reason);
}
@@ -284,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();
+27 -185
View File
@@ -6,6 +6,7 @@ import 'package:revoltvpn/logic/connection_settings.dart';
import 'package:revoltvpn/logic/hivemind_service.dart';
import 'package:revoltvpn/logic/local_socks_tester.dart';
import 'package:revoltvpn/logic/network_monitor.dart';
import 'package:revoltvpn/logic/power_settings.dart';
import 'package:revoltvpn/logic/secure_socks_session.dart';
enum VpnStatus {
@@ -17,8 +18,6 @@ enum VpnStatus {
}
class VpnConnection extends ChangeNotifier {
static const int _maxExtremeRecoveryAttempts = 2;
bool _cancelled = false;
VpnStatus _status = VpnStatus.disconnected;
VpnStatus get status => _status;
@@ -29,8 +28,10 @@ class VpnConnection extends ChangeNotifier {
String? _errorMessage;
String? get errorMessage => _errorMessage;
bool _isStartupRestoration = false;
bool get isStartupRestoration => _isStartupRestoration;
/// True when the UI attached to a tunnel that was already running, rather
/// than starting one itself. The session timer uses this to resume ticking.
bool _adoptedRunningRuntime = false;
bool get adoptedRunningRuntime => _adoptedRunningRuntime;
bool _serverReachable = false;
bool get serverReachable => _serverReachable;
@@ -38,9 +39,6 @@ class VpnConnection extends ChangeNotifier {
ConnectionMode _activeMode = ConnectionMode.tun;
ConnectionMode get activeMode => _activeMode;
ResilienceMode _activeResilienceMode = ResilienceMode.standard;
ResilienceMode get activeResilienceMode => _activeResilienceMode;
String _networkTransport = 'unknown';
String get networkTransport => _networkTransport;
@@ -70,16 +68,10 @@ class VpnConnection extends ChangeNotifier {
}
Timer? _healthTimer;
Timer? _restartTimer;
StreamSubscription<NetworkSnapshot>? _networkSubscription;
String? _lastBaseConfig;
String _lastRemark = 'Revolt VPN';
bool _lastVerifyLocalSocks = false;
SecureSocksSession? _lastSecureSocks;
bool _restartInProgress = false;
bool _userDisconnecting = false;
int _connectionEpoch = 0;
late final FlutterVless _vless;
bool _initialized = false;
@@ -88,7 +80,6 @@ class VpnConnection extends ChangeNotifier {
VpnConnection() {
_activeMode = ConnectionSettings.mode;
_activeResilienceMode = ConnectionSettings.resilienceMode;
_init();
}
@@ -107,7 +98,6 @@ class VpnConnection extends ChangeNotifier {
await ConnectionSettings.initialize();
_activeMode = ConnectionSettings.mode;
_activeResilienceMode = ConnectionSettings.resilienceMode;
_vless = FlutterVless(
onStatusChanged: (status) {
@@ -150,59 +140,30 @@ class VpnConnection extends ChangeNotifier {
try {
final coreVersion = await _vless.getCoreVersion();
debugPrint('[VPN] Xray core version: $coreVersion');
final delay = await _vless.getConnectedServerDelay();
if (delay > 0) {
_isStartupRestoration = true;
_lastVerifyLocalSocks = _activeMode == ConnectionMode.proxy;
_setStatus(VpnStatus.connected, _connectedLabel);
unawaited(_restoreRecoverySnapshot());
}
} catch (_) {}
await _adoptRunningRuntime();
}
/// Android can kill the Dart process while the VPN foreground service keeps
/// running, which leaves a fresh [VpnConnection] attached to a live tunnel it
/// holds no config snapshot for — so Extreme recovery has nothing to restart
/// from. The Hivemind session is still active, so `/session/status` returns
/// the same VLESS parameters and the snapshot can be rebuilt without opening
/// a new session or running the ad bypass.
/// Reflect a tunnel that is already up when the UI starts.
///
/// TUN only. This mints *fresh* SOCKS credentials, which is invisible in TUN
/// because only tun2socks consumes them. In proxy mode those credentials are
/// the user's own client configuration, so rotating them behind their back
/// would silently break it — that case is surfaced in the UI instead.
Future<void> _restoreRecoverySnapshot() async {
if (kIsWeb || _activeMode != ConnectionMode.tun) return;
final epoch = _connectionEpoch;
bool stale() =>
_connectionEpoch != epoch ||
_userDisconnecting ||
_status != VpnStatus.connected;
/// This asks the OS whether our VPN service process is alive, rather than
/// probing a local SOCKS port. The old probe called getConnectedServerDelay(),
/// which reads AppConfigs.V2RAY_CONFIG?.LOCAL_SOCKS5_PORT ?: 10807 — but
/// AppConfigs lives in the VPN's own process, so from here it is always null
/// and the port always fell back to 10807. That was correct only while the
/// SOCKS port was fixed; per-session ephemeral ports made it permanently
/// wrong, so the app reported "connected" against a dead tunnel.
Future<void> _adoptRunningRuntime() async {
if (kIsWeb) return;
try {
final realUrl = await HivemindService.fetchConfigDirectly(
skipAdBypass: true,
);
// A real connect() may have landed while the fetch was in flight; never
// overwrite a snapshot that belongs to a live connection.
if (stale() || _lastBaseConfig != null) return;
final parsed = FlutterVless.parse(realUrl);
final secureSocks = await SecureSocksSession.create(
parsed.getFullConfiguration(),
);
if (stale() || _lastBaseConfig != null) return;
_lastBaseConfig = secureSocks.configJson;
_lastRemark = parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN';
_lastVerifyLocalSocks = false;
debugPrint('[VPN] Recovery snapshot rebuilt after process restart');
final state = await PowerSettings.runtimeState();
if (state.aliveFor(tunMode: _activeMode == ConnectionMode.tun)) {
_adoptedRunningRuntime = true;
_setStatus(VpnStatus.connected, _connectedLabel);
}
} catch (e) {
// Best-effort: without it the tunnel still works, only Extreme recovery
// stays unavailable until the next manual connect.
debugPrint('[VPN] Could not rebuild recovery snapshot: $e');
debugPrint('[VPN] Runtime liveness check failed: $e');
}
}
@@ -222,40 +183,21 @@ class VpnConnection extends ChangeNotifier {
break;
case VlessConnectionState.disconnected:
// Repeated DISCONNECTED broadcasts during an Extreme recovery debounce
// must not clear the config snapshot the pending recovery needs.
if (_restartInProgress || (_restartTimer?.isActive ?? false)) return;
final canRecover = !_userDisconnecting &&
_activeResilienceMode == ResilienceMode.extreme &&
_lastBaseConfig != null &&
_status == VpnStatus.connected;
if (canRecover) {
_scheduleRuntimeRestart('Runtime disconnected');
return;
}
_clearRuntimeSnapshot();
_setStatus(VpnStatus.disconnected, 'Tap to connect');
break;
case VlessConnectionState.connecting:
if (!_restartInProgress) {
_setStatus(VpnStatus.connecting, 'Establishing tunnel…');
}
_setStatus(VpnStatus.connecting, 'Establishing tunnel…');
break;
case VlessConnectionState.disconnecting:
_isStartupRestoration = false;
if (!_restartInProgress) {
_setStatus(VpnStatus.disconnecting, 'Tearing down…');
}
_setStatus(VpnStatus.disconnecting, 'Tearing down…');
break;
case VlessConnectionState.unknown:
if (_status != VpnStatus.connected &&
_status != VpnStatus.disconnected &&
!_restartInProgress) {
_status != VpnStatus.disconnected) {
_setStatus(VpnStatus.error, 'Connection failed');
}
break;
@@ -269,12 +211,9 @@ class VpnConnection extends ChangeNotifier {
_cancelled = false;
_userDisconnecting = false;
_restartTimer?.cancel();
_connectionEpoch++;
await ConnectionSettings.initialize();
_activeMode = ConnectionSettings.mode;
_activeResilienceMode = ConnectionSettings.resilienceMode;
if (!kIsWeb && !_initialized) {
_errorMessage = 'VPN service unavailable.';
@@ -369,10 +308,7 @@ class VpnConnection extends ChangeNotifier {
}
}
_lastBaseConfig = secureSocks.configJson;
_lastSecureSocks = secureSocks;
_lastRemark = remark;
_lastVerifyLocalSocks = verifyLocalSocks;
} catch (e) {
debugPrint('[VPN] Tunnel start error: $e');
await _stopRuntime();
@@ -436,96 +372,6 @@ class VpnConnection extends ChangeNotifier {
return _status == VpnStatus.connected;
}
void _scheduleRuntimeRestart(String reason) {
if (_activeResilienceMode != ResilienceMode.extreme ||
_restartInProgress ||
_userDisconnecting ||
_lastBaseConfig == null ||
(_restartTimer?.isActive ?? false)) {
return;
}
_setStatus(VpnStatus.connecting, 'Reconnecting…');
_restartTimer = Timer(const Duration(milliseconds: 1200), () {
unawaited(_restartActiveRuntime(reason));
});
}
Future<void> _restartActiveRuntime(String reason) async {
if (_restartInProgress || _userDisconnecting || _lastBaseConfig == null) {
return;
}
_restartInProgress = true;
final epoch = _connectionEpoch;
Object? lastError;
try {
for (var attempt = 1; attempt <= _maxExtremeRecoveryAttempts; attempt++) {
if (_connectionEpoch != epoch || _userDisconnecting || _lastBaseConfig == null) return;
try {
debugPrint(
'[VPN] Extreme recovery attempt '
'$attempt/$_maxExtremeRecoveryAttempts: $reason',
);
await _stopRuntime();
if (_userDisconnecting) return;
await Future.delayed(
Duration(milliseconds: attempt == 1 ? 600 : 900),
);
if (_userDisconnecting || _lastBaseConfig == null) return;
await _startRuntime(
config: _lastBaseConfig!,
remark: _lastRemark,
proxyOnly: _activeMode == ConnectionMode.proxy,
);
if (!await _waitForNativeConnected()) {
throw StateError('VPN runtime did not recover');
}
if (_lastVerifyLocalSocks && !(await _waitForLocalSocksListener())) {
throw StateError('Local SOCKS5 listener did not recover');
}
if (_userDisconnecting) return;
_errorMessage = null;
_setStatus(VpnStatus.connected, _connectedLabel);
return;
} catch (e) {
lastError = e;
debugPrint(
'[VPN] Extreme recovery attempt '
'$attempt/$_maxExtremeRecoveryAttempts failed: $e',
);
await _stopRuntime();
if (_userDisconnecting) return;
if (attempt < _maxExtremeRecoveryAttempts) {
_setStatus(
VpnStatus.connecting,
'Reconnecting… (${attempt + 1}/$_maxExtremeRecoveryAttempts)',
);
await Future.delayed(const Duration(milliseconds: 700));
}
}
}
if (_userDisconnecting) return;
debugPrint('[VPN] Extreme recovery exhausted: $lastError');
_clearRuntimeSnapshot();
_errorMessage = 'Could not restore the connection.';
_setStatus(VpnStatus.error, 'Reconnect failed');
} finally {
_restartInProgress = false;
}
}
Future<void> _stopRuntime() async {
try {
await _vless.stopVless().timeout(const Duration(seconds: 5));
@@ -535,7 +381,6 @@ class VpnConnection extends ChangeNotifier {
Future<void> disconnect() async {
_cancelled = true;
_userDisconnecting = true;
_restartTimer?.cancel();
HivemindService.cancel();
if (_status == VpnStatus.disconnected ||
@@ -586,10 +431,8 @@ class VpnConnection extends ChangeNotifier {
}
void _clearRuntimeSnapshot() {
_lastBaseConfig = null;
_lastVerifyLocalSocks = false;
_lastSecureSocks = null;
_isStartupRestoration = false;
_adoptedRunningRuntime = false;
}
void _setStatus(VpnStatus status, String message) {
@@ -609,7 +452,6 @@ class VpnConnection extends ChangeNotifier {
@override
void dispose() {
_healthTimer?.cancel();
_restartTimer?.cancel();
_networkSubscription?.cancel();
// Provider/UI disposal is not a user disconnect command. Keeping teardown
// in disconnect() prevents lifecycle churn from silently dropping the VPN.
+3
View File
@@ -47,6 +47,9 @@ class ReVoltApp extends StatelessWidget {
providers: [
ChangeNotifierProvider(create: (_) => VpnConnection(), lazy: false),
ChangeNotifierProxyProvider<VpnConnection, SessionTimer>(
// Eager, like VpnConnection: the timer has to be listening before the
// engine reports an already-running tunnel, not after the intro.
lazy: false,
create: (ctx) =>
SessionTimer(vpnConnection: ctx.read<VpnConnection>()),
update: (_, vpn, prev) =>
@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/logic/power_settings.dart';
/// Keeping the tunnel alive is an OS concern, not an app one.
///
/// Android stops background services of apps that are not exempt from Doze, and
/// removes the whole process group when its task is swiped away. There are
/// exactly two sanctioned ways around that, both requiring one user tap, and
/// neither can be enabled programmatically. This tile surfaces both.
class BackgroundReliabilityTile extends StatefulWidget {
const BackgroundReliabilityTile({super.key});
@override
State<BackgroundReliabilityTile> createState() =>
_BackgroundReliabilityTileState();
}
class _BackgroundReliabilityTileState extends State<BackgroundReliabilityTile>
with WidgetsBindingObserver {
bool? _batteryExempt;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_refresh();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// The exemption is granted in a system dialog, so the only reliable moment
// to re-read it is when we come back to the foreground.
if (state == AppLifecycleState.resumed) _refresh();
}
Future<void> _refresh() async {
final exempt = await PowerSettings.isBatteryOptimisationDisabled();
if (mounted) setState(() => _batteryExempt = exempt);
}
Future<void> _requestExemption() async {
final opened = await PowerSettings.requestDisableBatteryOptimisation();
if (!mounted || opened) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Could not open battery settings on this device.'),
),
);
}
@override
Widget build(BuildContext context) {
final exempt = _batteryExempt;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const ListTile(
title: Text(
'Background reliability',
style: TextStyle(color: AppColors.textWhite, fontSize: 15),
),
subtitle: Text(
'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),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
exempt == null
? 'Battery optimisation: checking…'
: exempt
? 'Battery optimisation: disabled for Revolt VPN ✓'
: 'Battery optimisation: still active — the system may '
'stop the VPN in the background.',
style: TextStyle(
color: exempt == true ? AppColors.accent : AppColors.textMuted,
fontSize: 12,
),
),
if (exempt == false) ...[
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _requestExemption,
icon: const Icon(Icons.battery_saver),
label: const Text('Allow background activity'),
),
],
],
),
),
],
);
}
}
@@ -51,8 +51,14 @@ class _ConnectionModeTileState extends State<ConnectionModeTile> {
return;
}
await ConnectionSettings.setMode(next);
final saved = await ConnectionSettings.setMode(next);
if (!mounted) return;
if (!saved) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not save connection mode.')),
);
return;
}
setState(() {
_mode = next;
@@ -94,7 +100,7 @@ class _ConnectionModeTileState extends State<ConnectionModeTile> {
if (vpn.status != VpnStatus.connected || !vpn.canTestActiveLocalSocks) {
final orphaned =
vpn.status == VpnStatus.connected && vpn.isStartupRestoration;
vpn.status == VpnStatus.connected && vpn.adoptedRunningRuntime;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
@@ -174,7 +180,7 @@ class _ConnectionModeTileState extends State<ConnectionModeTile> {
// memory that died with it. Saying "Connect" there would be a
// lie — the user is already connected.
final orphaned = vpn.status == VpnStatus.connected &&
vpn.isStartupRestoration;
vpn.adoptedRunningRuntime;
if (orphaned) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
@@ -1,84 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/logic/connection_settings.dart';
import 'package:revoltvpn/logic/vpn_connection.dart';
class ResilienceModeTile extends StatefulWidget {
const ResilienceModeTile({super.key});
@override
State<ResilienceModeTile> createState() => _ResilienceModeTileState();
}
class _ResilienceModeTileState extends State<ResilienceModeTile> {
ResilienceMode _mode = ConnectionSettings.resilienceMode;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
await ConnectionSettings.initialize();
if (mounted) setState(() => _mode = ConnectionSettings.resilienceMode);
}
bool _vpnBusy() {
final status = context.read<VpnConnection>().status;
return status == VpnStatus.connected ||
status == VpnStatus.connecting ||
status == VpnStatus.disconnecting;
}
Future<void> _change(ResilienceMode? next) async {
if (next == null || next == _mode) return;
if (_vpnBusy()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Disconnect before changing resilience mode.'),
),
);
return;
}
await ConnectionSettings.setResilienceMode(next);
if (mounted) setState(() => _mode = next);
}
@override
Widget build(BuildContext context) {
final extreme = _mode == ResilienceMode.extreme;
return ListTile(
title: const Text(
'Network resilience',
style: TextStyle(color: AppColors.textWhite, fontSize: 15),
),
subtitle: Text(
extreme
? 'Extreme: keeps the exact server transport unchanged and makes up to two controlled recovery attempts after each real Xray disconnect.'
: 'Standard: keeps the exact server transport unchanged and does not force health-check reconnects.',
style: const TextStyle(color: AppColors.textDim, fontSize: 12),
),
trailing: DropdownButtonHideUnderline(
child: DropdownButton<ResilienceMode>(
value: _mode,
dropdownColor: AppColors.bgCard,
onChanged: _change,
items: const [
DropdownMenuItem(
value: ResilienceMode.standard,
child: Text('Standard'),
),
DropdownMenuItem(
value: ResilienceMode.extreme,
child: Text('Extreme'),
),
],
),
),
);
}
}
+2 -2
View File
@@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/screens/settings/in_settings/background_reliability.dart';
import 'package:revoltvpn/screens/settings/in_settings/connection_mode.dart';
import 'package:revoltvpn/screens/settings/in_settings/haptic.dart';
import 'package:revoltvpn/screens/settings/in_settings/lightning.dart';
import 'package:revoltvpn/screens/settings/in_settings/rain.dart';
import 'package:revoltvpn/screens/settings/in_settings/resilience.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@@ -32,7 +32,7 @@ class SettingsScreen extends StatelessWidget {
padding: const EdgeInsets.symmetric(vertical: 8),
children: const [
ConnectionModeTile(),
ResilienceModeTile(),
BackgroundReliabilityTile(),
HapticFeedbackTile(),
RainToggleTile(),
LightningToggleTile(),
+1 -1
View File
@@ -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.1+27
version: 3.3.4+30
environment:
sdk: ">=3.0.0 <4.0.0"
-293
View File
@@ -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<String, dynamic> || decoded['packages'] is! List) {
fail('package_config.json is invalid');
}
Map<String, dynamic>? package;
for (final item in decoded['packages'] as List) {
if (item is Map && item['name'] == packageName) {
package = Map<String, dynamic>.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<String, String> 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<String> = ArrayList(),
''',
''' /** List of app package names to exclude from VPN. */
var BLOCKED_APPS: ArrayList<String> = ArrayList(),
/** List of app package names that are exclusively allowed into VPN. */
var ALLOWED_APPS: ArrayList<String> = 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<String> = ArrayList()
''',
'private var allowedApps:',
);
const currentAllowedHandler = ''' "setAllowedApps" -> {
val requested = call.argument<ArrayList<String>>("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<ArrayList<String>>("allowed_apps") ?: ArrayList()
allowedApps = ArrayList(
requested.map { it.trim() }.filter { it.isNotEmpty() }.distinct()
)
result.success(null)
}
''';
replaceOneOf(
pluginFile,
<String, String>{
legacyAllowedHandler: currentAllowedHandler,
''' when (call.method) {
"startVless" -> {
''': ''' when (call.method) {
$currentAllowedHandler "startVless" -> {
''',
},
'it.isNotEmpty() && it != context.packageName',
);
replaceOnce(
pluginFile,
''' config.BLOCKED_APPS = call.argument<ArrayList<String>>("blocked_apps") ?: ArrayList()
config.BYPASS_SUBNETS = call.argument<ArrayList<String>>("bypass_subnets") ?: ArrayList()
''',
''' config.BLOCKED_APPS = call.argument<ArrayList<String>>("blocked_apps") ?: ArrayList()
config.ALLOWED_APPS = ArrayList(allowedApps)
config.BYPASS_SUBNETS = call.argument<ArrayList<String>>("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,
<String, String>{
legacyPatchedPolicy: hardenedPolicy,
upstreamPolicy: hardenedPolicy,
},
'Allowed and blocked app policies cannot be mixed',
);
stdout.writeln('[flutter_vless patch] allowed-app routing patch is ready');
}
@@ -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<String, dynamic> || decoded['packages'] is! List) {
fail('package_config.json is invalid');
}
Map<String, dynamic>? package;
for (final item in decoded['packages'] as List) {
if (item is Map && item['name'] == packageName) {
package = Map<String, dynamic>.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');
}
+223 -23
View File
@@ -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',
);
}