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.
This commit is contained in:
1 parent
dfeb7f161c
commit
9ac236a981
15 files changed
+447
-383
No files matched your search
@@ -5,6 +5,39 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH
|
||||
|
||||
---
|
||||
|
||||
## [3.3.4] — 2026-09-02
|
||||
|
||||
### Added
|
||||
- **OS-level session-expiry enforcement.** The VPN service now holds an absolute
|
||||
deadline and tears the tunnel down on its own clock — a main-looper handler
|
||||
plus an `AlarmManager` exact alarm (falling back to an inexact alarm on
|
||||
Android 13+ when exact alarms aren't granted). The app pushes
|
||||
`now + expires_in_seconds` on each session sync, so a session ends even if the
|
||||
UI process has been killed.
|
||||
- **Session survival across process death.** The service persists the active
|
||||
session and re-establishes it when Android restarts it (`START_STICKY`).
|
||||
- **Support-ad extension reflected immediately.** A rewarded "Support us" ad now
|
||||
forces an immediate server sync, so the countdown and expiry deadline update
|
||||
without waiting for the next heartbeat.
|
||||
|
||||
### Changed
|
||||
- **Session-status beacon slowed.** Foreground poll: 5 s → 60 s; background poll:
|
||||
30 s → 60 s.
|
||||
- **Background-reliability guidance.** The "Always-on VPN" settings deep-link was
|
||||
removed; the battery-optimisation exemption remains, with expiry now enforced
|
||||
by the service itself.
|
||||
|
||||
### Removed
|
||||
- Legacy `ass` connection-mode migration — unrecognized persisted modes now
|
||||
default to TUN.
|
||||
|
||||
### Security & privacy
|
||||
- Session credentials and the per-user VLESS UUID are now held app-private on
|
||||
disk (`active_session.bin`) for the session's lifetime and removed on
|
||||
disconnect or expiry. Previously they were memory-only.
|
||||
|
||||
---
|
||||
|
||||
## [3.3.3] — 2026-09-01
|
||||
|
||||
### Fixed
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
<!-- 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
|
||||
|
||||
@@ -61,10 +61,13 @@ class MainActivity : FlutterActivity() {
|
||||
result.success(isIgnoringBatteryOptimizations())
|
||||
"requestIgnoreBatteryOptimizations" ->
|
||||
result.success(requestIgnoreBatteryOptimizations())
|
||||
// Android's own Always-on VPN is the only thing that keeps a
|
||||
// tunnel up across swipe-from-recents and reboot. It cannot be
|
||||
// toggled programmatically, so deep-link the user to it.
|
||||
"openVpnSettings" -> result.success(openVpnSettings())
|
||||
"setSessionExpiry" -> {
|
||||
val expiresAtMs = call.argument<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.
|
||||
@@ -153,16 +156,14 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Deep-links to the OS VPN settings, where Always-on VPN lives. */
|
||||
private fun openVpnSettings(): Boolean {
|
||||
return try {
|
||||
startActivity(
|
||||
Intent(Settings.ACTION_VPN_SETTINGS)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
true
|
||||
/** Pushes the session-expiry deadline to the VPN service process. */
|
||||
private fun sendSessionExpiryToDaemon(expiresAtMs: Long) {
|
||||
try {
|
||||
val intent = Intent(this, XrayVPNService::class.java)
|
||||
intent.putExtra("SESSION_EXPIRES_AT_MS", expiresAtMs)
|
||||
startService(intent)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
// Daemon not reachable; its persisted expiry still applies.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -18,9 +18,9 @@ abstract final class ConnectionSettings {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final storedMode = prefs.getString(_modeKey);
|
||||
|
||||
// Legacy 'ass' was the pre-V3 SOCKS5 label; map it to proxy. Everything
|
||||
// else (including the old 'auto') is TUN — there is no runtime auto mode.
|
||||
_mode = storedMode == ConnectionMode.proxy.name || storedMode == 'ass'
|
||||
// Any unrecognized value (including the pre-V3 'auto') is TUN. 'proxy' is
|
||||
// the only persisted value that selects the SOCKS5 mode.
|
||||
_mode = storedMode == ConnectionMode.proxy.name
|
||||
? ConnectionMode.proxy
|
||||
: ConnectionMode.tun;
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import 'package:flutter/services.dart';
|
||||
///
|
||||
/// Android stops background services of apps that are not exempt from Doze and
|
||||
/// App Standby, and it tears down the whole process group when a task is swiped
|
||||
/// away. Those are OS behaviours, not app bugs, and the OS provides exactly two
|
||||
/// sanctioned ways around them: a battery-optimisation exemption, and the
|
||||
/// system's own Always-on VPN. Both need one user tap; neither can be forced.
|
||||
/// away. Those are OS behaviours, not app bugs; the battery-optimisation
|
||||
/// exemption is the one sanctioned way an app can ask for around them, and it
|
||||
/// still needs one user tap.
|
||||
abstract final class PowerSettings {
|
||||
PowerSettings._();
|
||||
|
||||
@@ -42,16 +42,6 @@ abstract final class PowerSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the OS VPN settings screen, where Always-on VPN is configured.
|
||||
static Future<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.
|
||||
///
|
||||
/// The Xray service runs in a separate process, so nothing it stores in
|
||||
@@ -71,6 +61,19 @@ abstract final class PowerSettings {
|
||||
return const VpnRuntimeState(processAlive: false, vpnTransport: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pushes the absolute session-expiry timestamp (epoch ms) to the VPN
|
||||
/// service so it can tear the tunnel down even when the UI process is gone.
|
||||
static Future<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
|
||||
|
||||
@@ -7,11 +7,13 @@ import 'package:revoltvpn/logic/app_config.dart';
|
||||
import 'package:revoltvpn/logic/crypto_service.dart';
|
||||
import 'package:revoltvpn/logic/hivemind_service.dart';
|
||||
import 'package:revoltvpn/logic/notification_service.dart';
|
||||
import 'package:revoltvpn/logic/power_settings.dart';
|
||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||
|
||||
class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
Timer? _timer;
|
||||
int _tickCount = 0;
|
||||
int _lastPushedExpiryAtMs = 0;
|
||||
bool _appBackgrounded = false;
|
||||
|
||||
final VpnConnection vpnConnection;
|
||||
@@ -36,8 +38,12 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
static const int _maxOfflineSeconds = 120;
|
||||
int _offlineSeconds = 0;
|
||||
|
||||
static const int _pollIntervalSeconds = 5;
|
||||
static const int _backgroundPollIntervalSeconds = 30;
|
||||
// The countdown ticks locally every second; these only drive the slow
|
||||
// server heartbeat that catches server-side changes (support-ad extension,
|
||||
// 10 GB cap, session-ended). Keeping it slow avoids a chatty /session/status
|
||||
// beacon (see internal/LAUNCH_READINESS.md "cut the beacon rate").
|
||||
static const int _pollIntervalSeconds = 60;
|
||||
static const int _backgroundPollIntervalSeconds = 60;
|
||||
|
||||
int _lastUsedBytes = 0;
|
||||
DateTime? _lastSuccessfulSyncAt;
|
||||
@@ -159,6 +165,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
_lastSuccessfulSyncAt = null;
|
||||
_currentSpeedKBps = 0.0;
|
||||
_tickCount = 0;
|
||||
_lastPushedExpiryAtMs = 0;
|
||||
_hasSyncedOnce = false;
|
||||
_consecutiveFailures = 0;
|
||||
_offlineSeconds = 0;
|
||||
@@ -213,6 +220,10 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
/// Force an immediate server sync (e.g. after a support-ad extension so the
|
||||
/// countdown reflects the new expiry without waiting for the next heartbeat).
|
||||
Future<void> syncNow() => _syncWithHivemind();
|
||||
|
||||
Future<void> disconnect({String reason = 'User requested'}) async {
|
||||
await _doDisconnect(reason);
|
||||
}
|
||||
@@ -286,6 +297,14 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
|
||||
_remainingAtLastSync = expiresValue.toInt();
|
||||
_remainingSeconds = _remainingAtLastSync;
|
||||
// Push the absolute deadline to the VPN service so it can tear the
|
||||
// tunnel down on its own clock, even if this UI process is gone.
|
||||
final expiresAtMs =
|
||||
DateTime.now().millisecondsSinceEpoch + _remainingSeconds * 1000;
|
||||
if ((expiresAtMs - _lastPushedExpiryAtMs).abs() > 5000) {
|
||||
_lastPushedExpiryAtMs = expiresAtMs;
|
||||
unawaited(PowerSettings.setSessionExpiry(expiresAtMs));
|
||||
}
|
||||
_usedBytes = _readNonNegativeInt(data['used_bytes'], _usedBytes);
|
||||
|
||||
final now = DateTime.now();
|
||||
|
||||
@@ -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
|
||||
Widget build(BuildContext context) {
|
||||
final exempt = _batteryExempt;
|
||||
@@ -76,8 +68,8 @@ class _BackgroundReliabilityTileState extends State<BackgroundReliabilityTile>
|
||||
style: TextStyle(color: AppColors.textWhite, fontSize: 15),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Android stops background apps to save power. These two settings '
|
||||
'are what keep the VPN running when the app is closed.',
|
||||
'Android stops background apps to save power. Disabling battery '
|
||||
'optimisation is what keeps the VPN running when the app is closed.',
|
||||
style: TextStyle(color: AppColors.textDim, fontSize: 12),
|
||||
),
|
||||
),
|
||||
@@ -106,20 +98,6 @@ class _BackgroundReliabilityTileState extends State<BackgroundReliabilityTile>
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
version: 3.3.3+29
|
||||
version: 3.3.4+30
|
||||
|
||||
environment:
|
||||
sdk: ">=3.0.0 <4.0.0"
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user