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
17 changed files with 503 additions and 398 deletions

No files matched your search

+48
View File
@@ -5,6 +5,54 @@ 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 ## [3.3.2] — 2026-09-01
Background reliability. 3.3.1 could show "connected" against a tunnel that was no Background reliability. 3.3.1 could show "connected" against a tunnel that was no
+2 -2
View File
@@ -1,6 +1,6 @@
# Privacy Policy # Privacy Policy
**Last updated:** August 2026 **Last updated:** September 2026
ReVoltVPN is a free VPN service built and operated by ReVolt Team. 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 ## 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 ## 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 ## Protocol
- **Transport** — VLESS over XHTTP (HTTP/2 multiplexed, path-hidden behind `/revolt`) - **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 - **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 - **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" "bin/cache/dart-sdk/bin/dart"
}, },
) )
val flutterVlessPatchScript = File( val flutterVlessRecoveryPatchScript = File(
projectRootDir, projectRootDir,
"tool/patch_flutter_vless_allowed_apps.dart", "tool/patch_flutter_vless_runtime_recovery.dart",
) )
val flutterVlessSecureSocksPatchScript = File( val flutterVlessSecureSocksPatchScript = File(
projectRootDir, projectRootDir,
@@ -37,17 +37,17 @@ val flutterVlessSecureSocksScopeFixScript = File(
"tool/patch_flutter_vless_secure_socks_scope_fix.dart", "tool/patch_flutter_vless_secure_socks_scope_fix.dart",
) )
val patchFlutterVlessAllowedApps = tasks.register<Exec>("patchFlutterVlessAllowedApps") { val patchFlutterVlessRuntimeRecovery = tasks.register<Exec>("patchFlutterVlessRuntimeRecovery") {
group = "build setup" 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) workingDir(projectRootDir)
doFirst { doFirst {
if (!dartExecutable.isFile) { if (!dartExecutable.isFile) {
throw GradleException("Flutter Dart executable not found: ${dartExecutable.absolutePath}") throw GradleException("Flutter Dart executable not found: ${dartExecutable.absolutePath}")
} }
if (!flutterVlessPatchScript.isFile) { if (!flutterVlessRecoveryPatchScript.isFile) {
throw GradleException("flutter_vless patch script missing: ${flutterVlessPatchScript.absolutePath}") throw GradleException("flutter_vless recovery patch script missing: ${flutterVlessRecoveryPatchScript.absolutePath}")
} }
if (!File(projectRootDir, ".dart_tool/package_config.json").isFile) { if (!File(projectRootDir, ".dart_tool/package_config.json").isFile) {
throw GradleException("Flutter package metadata missing. Run `flutter pub get` before Android compilation.") throw GradleException("Flutter package metadata missing. Run `flutter pub get` before Android compilation.")
@@ -57,7 +57,7 @@ val patchFlutterVlessAllowedApps = tasks.register<Exec>("patchFlutterVlessAllowe
commandLine( commandLine(
dartExecutable.absolutePath, dartExecutable.absolutePath,
"run", "run",
flutterVlessPatchScript.absolutePath, flutterVlessRecoveryPatchScript.absolutePath,
) )
} }
@@ -65,7 +65,7 @@ val patchFlutterVlessSecureSocks = tasks.register<Exec>("patchFlutterVlessSecure
group = "build setup" group = "build setup"
description = "Add authenticated ephemeral SOCKS5 and fail-closed process recovery to pinned flutter_vless_android 1.1.5" description = "Add authenticated ephemeral SOCKS5 and fail-closed process recovery to pinned flutter_vless_android 1.1.5"
workingDir(projectRootDir) workingDir(projectRootDir)
dependsOn(patchFlutterVlessAllowedApps) dependsOn(patchFlutterVlessRuntimeRecovery)
doFirst { doFirst {
if (!dartExecutable.isFile) { if (!dartExecutable.isFile) {
+3
View File
@@ -10,6 +10,9 @@
<!-- Doze/App Standby stop background services of non-exempt apps. A VPN <!-- Doze/App Standby stop background services of non-exempt apps. A VPN
cannot stay up without this; Play allows it for VPN functionality. --> cannot stay up without this; Play allows it for VPN functionality. -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/> <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. --> <!-- flutter_vless 1.1.5 declares this legacy permission. ReVolt never needs it. -->
<uses-permission <uses-permission
@@ -61,10 +61,13 @@ class MainActivity : FlutterActivity() {
result.success(isIgnoringBatteryOptimizations()) result.success(isIgnoringBatteryOptimizations())
"requestIgnoreBatteryOptimizations" -> "requestIgnoreBatteryOptimizations" ->
result.success(requestIgnoreBatteryOptimizations()) result.success(requestIgnoreBatteryOptimizations())
// Android's own Always-on VPN is the only thing that keeps a "setSessionExpiry" -> {
// tunnel up across swipe-from-recents and reboot. It cannot be val expiresAtMs = call.argument<Number>("expiresAtMs")?.toLong() ?: 0L
// toggled programmatically, so deep-link the user to it. if (expiresAtMs > 0) {
"openVpnSettings" -> result.success(openVpnSettings()) sendSessionExpiryToDaemon(expiresAtMs)
}
result.success(null)
}
// Is our VPN runtime actually alive? The service lives in // Is our VPN runtime actually alive? The service lives in
// :RunSoLibXrayDaemon, a separate process, so its statics are // :RunSoLibXrayDaemon, a separate process, so its statics are
// NOT visible here -- this is the only honest check. // 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. */ /** Pushes the session-expiry deadline to the VPN service process. */
private fun openVpnSettings(): Boolean { private fun sendSessionExpiryToDaemon(expiresAtMs: Long) {
return try { try {
startActivity( val intent = Intent(this, XrayVPNService::class.java)
Intent(Settings.ACTION_VPN_SETTINGS) intent.putExtra("SESSION_EXPIRES_AT_MS", expiresAtMs)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), startService(intent)
)
true
} catch (_: Exception) { } catch (_: Exception) {
false // Daemon not reachable; its persisted expiry still applies.
} }
} }
+1
View File
@@ -34,6 +34,7 @@ class _SupportButtonState extends State<SupportButton> {
final rewarded = await ad.showAd('support'); final rewarded = await ad.showAd('support');
if (rewarded) { if (rewarded) {
await timer.markSupportRewardClaimed(); await timer.markSupportRewardClaimed();
await timer.syncNow();
} }
} finally { } finally {
if (mounted) setState(() => _busy = false); if (mounted) setState(() => _busy = false);
+22 -7
View File
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
enum ConnectionMode { tun, proxy } enum ConnectionMode { tun, proxy }
@@ -18,23 +19,37 @@ abstract final class ConnectionSettings {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final storedMode = prefs.getString(_modeKey); final storedMode = prefs.getString(_modeKey);
// Legacy 'ass' was the pre-V3 SOCKS5 label; map it to proxy. Everything // Only the explicit current proxy value restores SOCKS5. Unknown and
// else (including the old 'auto') is TUN — there is no runtime auto mode. // obsolete values, including the pre-V3 'auto' label, fail closed to
_mode = storedMode == ConnectionMode.proxy.name || storedMode == 'ass' // full-tunnel TUN instead of silently selecting a weaker route.
_mode = storedMode == ConnectionMode.proxy.name
? ConnectionMode.proxy ? ConnectionMode.proxy
: ConnectionMode.tun; : ConnectionMode.tun;
if (storedMode != _mode.name) { 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; _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; _mode = mode;
_initialized = true; _initialized = true;
final prefs = await SharedPreferences.getInstance(); return true;
await prefs.setString(_modeKey, mode.name);
} }
} }
+16 -13
View File
@@ -5,9 +5,9 @@ import 'package:flutter/services.dart';
/// ///
/// Android stops background services of apps that are not exempt from Doze and /// 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 /// 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 /// away. Those are OS behaviours, not app bugs; the battery-optimisation
/// sanctioned ways around them: a battery-optimisation exemption, and the /// exemption is the one sanctioned way an app can ask for around them, and it
/// system's own Always-on VPN. Both need one user tap; neither can be forced. /// still needs one user tap.
abstract final class PowerSettings { abstract final class PowerSettings {
PowerSettings._(); PowerSettings._();
@@ -42,16 +42,6 @@ abstract final class PowerSettings {
} }
} }
/// Opens the OS VPN settings screen, where Always-on VPN is configured.
static Future<bool> openVpnSettings() async {
if (kIsWeb) return false;
try {
return await _channel.invokeMethod<bool>('openVpnSettings') ?? false;
} catch (_) {
return false;
}
}
/// Whether the VPN runtime is genuinely alive. /// Whether the VPN runtime is genuinely alive.
/// ///
/// The Xray service runs in a separate process, so nothing it stores in /// 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); 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 @immutable
+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/crypto_service.dart';
import 'package:revoltvpn/logic/hivemind_service.dart'; import 'package:revoltvpn/logic/hivemind_service.dart';
import 'package:revoltvpn/logic/notification_service.dart'; import 'package:revoltvpn/logic/notification_service.dart';
import 'package:revoltvpn/logic/power_settings.dart';
import 'package:revoltvpn/logic/vpn_connection.dart'; import 'package:revoltvpn/logic/vpn_connection.dart';
class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
Timer? _timer; Timer? _timer;
int _tickCount = 0; int _tickCount = 0;
int _lastPushedExpiryAtMs = 0;
bool _appBackgrounded = false; bool _appBackgrounded = false;
final VpnConnection vpnConnection; final VpnConnection vpnConnection;
@@ -36,8 +38,12 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
static const int _maxOfflineSeconds = 120; static const int _maxOfflineSeconds = 120;
int _offlineSeconds = 0; int _offlineSeconds = 0;
static const int _pollIntervalSeconds = 5; // The countdown ticks locally every second; these only drive the slow
static const int _backgroundPollIntervalSeconds = 30; // 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; int _lastUsedBytes = 0;
DateTime? _lastSuccessfulSyncAt; DateTime? _lastSuccessfulSyncAt;
@@ -47,6 +53,11 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
vpnConnection.addListener(_onVpnConnectionChanged); vpnConnection.addListener(_onVpnConnectionChanged);
unawaited(_loadSupportRewardState()); 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; int get remaining => _remainingSeconds;
@@ -102,18 +113,15 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
} }
void _onVpnConnectionChanged() { 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 && if (vpnConnection.status == VpnStatus.connected &&
!isRunning && !isRunning &&
vpnConnection.adoptedRunningRuntime) { !_isDisconnecting) {
_resumeTicking(); debugPrint('[Timer] Connected with a stopped clock - resuming.');
return;
}
if (vpnConnection.status == VpnStatus.connected &&
!isRunning &&
!_isDisconnecting &&
_hasSyncedOnce) {
debugPrint('[Timer] VPN reconnected after blip - resuming.');
_resumeTicking(); _resumeTicking();
return; return;
} }
@@ -157,6 +165,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
_lastSuccessfulSyncAt = null; _lastSuccessfulSyncAt = null;
_currentSpeedKBps = 0.0; _currentSpeedKBps = 0.0;
_tickCount = 0; _tickCount = 0;
_lastPushedExpiryAtMs = 0;
_hasSyncedOnce = false; _hasSyncedOnce = false;
_consecutiveFailures = 0; _consecutiveFailures = 0;
_offlineSeconds = 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 { Future<void> disconnect({String reason = 'User requested'}) async {
await _doDisconnect(reason); await _doDisconnect(reason);
} }
@@ -284,6 +297,14 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
_remainingAtLastSync = expiresValue.toInt(); _remainingAtLastSync = expiresValue.toInt();
_remainingSeconds = _remainingAtLastSync; _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); _usedBytes = _readNonNegativeInt(data['used_bytes'], _usedBytes);
final now = DateTime.now(); final now = DateTime.now();
+3
View File
@@ -47,6 +47,9 @@ class ReVoltApp extends StatelessWidget {
providers: [ providers: [
ChangeNotifierProvider(create: (_) => VpnConnection(), lazy: false), ChangeNotifierProvider(create: (_) => VpnConnection(), lazy: false),
ChangeNotifierProxyProvider<VpnConnection, SessionTimer>( 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) => create: (ctx) =>
SessionTimer(vpnConnection: ctx.read<VpnConnection>()), SessionTimer(vpnConnection: ctx.read<VpnConnection>()),
update: (_, vpn, prev) => update: (_, vpn, prev) =>
@@ -55,14 +55,6 @@ class _BackgroundReliabilityTileState extends State<BackgroundReliabilityTile>
); );
} }
Future<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final exempt = _batteryExempt; final exempt = _batteryExempt;
@@ -76,8 +68,8 @@ class _BackgroundReliabilityTileState extends State<BackgroundReliabilityTile>
style: TextStyle(color: AppColors.textWhite, fontSize: 15), style: TextStyle(color: AppColors.textWhite, fontSize: 15),
), ),
subtitle: Text( subtitle: Text(
'Android stops background apps to save power. These two settings ' 'Android stops background apps to save power. Disabling battery '
'are what keep the VPN running when the app is closed.', 'optimisation is what keeps the VPN running when the app is closed.',
style: TextStyle(color: AppColors.textDim, fontSize: 12), style: TextStyle(color: AppColors.textDim, fontSize: 12),
), ),
), ),
@@ -106,20 +98,6 @@ class _BackgroundReliabilityTileState extends State<BackgroundReliabilityTile>
label: const Text('Allow background activity'), 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'),
),
], ],
), ),
), ),
@@ -51,8 +51,14 @@ class _ConnectionModeTileState extends State<ConnectionModeTile> {
return; return;
} }
await ConnectionSettings.setMode(next); final saved = await ConnectionSettings.setMode(next);
if (!mounted) return; if (!mounted) return;
if (!saved) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not save connection mode.')),
);
return;
}
setState(() { setState(() {
_mode = next; _mode = next;
+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. # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at # Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 3.3.2+28 version: 3.3.4+30
environment: environment:
sdk: ">=3.0.0 <4.0.0" 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) { if (!text.contains('private data class PersistedSession(')) {
stopSelf() const persistenceHelpers = r''' private data class PersistedSession(
return START_NOT_STICKY val proxyOnly: Boolean,
} val config: XrayConfig,
'''; val sessionExpiresAtMs: Long,
const newNullIntent = r''' if (intent == null) { ) : java.io.Serializable
if (isRunning && currentConfig != null) return START_REDELIVER_INTENT
stopSelf()
return START_NOT_STICKY
}
''';
text = replaceOnce(
text,
oldNullIntent,
newNullIntent,
'null intent lifecycle',
);
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')) { if (!text.contains(' currentConfig = config\n')) {
text = replaceOnce( text = replaceOnce(
text, text,
@@ -244,11 +376,79 @@ void patchService(File file) {
text = replaceOnce( text = replaceOnce(
text, text,
' return START_STICKY\n', ' val proxyOnly = intent.getBooleanExtra("PROXY_ONLY", false)\n',
' return START_REDELIVER_INTENT\n', ' val proxyOnly = intent.getBooleanExtra("PROXY_ONLY", false)\n proxyOnlyMode = proxyOnly\n persistSession()\n',
'redeliver START command', '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(')) { if (!text.contains('private data class SecureSocksCredentials(')) {
const credentialsHelper = r''' private data class SecureSocksCredentials( const credentialsHelper = r''' private data class SecureSocksCredentials(
val port: Int, 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 = replaceOnce(
text, text,
' isRunning = false\n tun2socksProcess?.destroy()\n', ' isRunning = false\n tun2socksProcess?.destroy()\n',
' isRunning = false\n recoveringXray = false\n tun2socksProcess?.destroy()\n', ' isRunning = false\n recoveringXray = false\n expiryHandler.removeCallbacks(expiryRunnable)\n cancelExpiryAlarm()\n tun2socksProcess?.destroy()\n',
'cancel recovery on cleanup', 'cancel recovery and expiry on cleanup',
); );
} }