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:
esefxdz committed 2026-09-02 20:33:33 +03:00
1 parent dfeb7f161c
commit 9ac236a981
15 files changed
+447 -383

No files matched your search

-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',
);
}