diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b302bb4..4543fde 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,5 +1,4 @@ import java.util.Properties -import java.io.File import java.io.FileInputStream plugins { @@ -7,128 +6,6 @@ plugins { id("dev.flutter.flutter-gradle-plugin") } -val projectRootDir = rootProject.projectDir.parentFile -val localPropertiesFile = rootProject.file("local.properties") -val localProperties = Properties().apply { - if (localPropertiesFile.exists()) { - localPropertiesFile.inputStream().use { load(it) } - } -} -val flutterSdkPath = localProperties.getProperty("flutter.sdk") - ?: throw GradleException("flutter.sdk not set in android/local.properties") -val dartExecutable = File( - flutterSdkPath, - if (System.getProperty("os.name").lowercase().contains("windows")) { - "bin/cache/dart-sdk/bin/dart.exe" - } else { - "bin/cache/dart-sdk/bin/dart" - }, -) -val flutterVlessRecoveryPatchScript = File( - projectRootDir, - "tool/patch_flutter_vless_runtime_recovery.dart", -) -val flutterVlessSecureSocksPatchScript = File( - projectRootDir, - "tool/patch_flutter_vless_secure_socks_v2.dart", -) -val flutterVlessSecureSocksScopeFixScript = File( - projectRootDir, - "tool/patch_flutter_vless_secure_socks_scope_fix.dart", -) - -val patchFlutterVlessRuntimeRecovery = tasks.register("patchFlutterVlessRuntimeRecovery") { - group = "build setup" - 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 (!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.") - } - } - - commandLine( - dartExecutable.absolutePath, - "run", - flutterVlessRecoveryPatchScript.absolutePath, - ) -} - -val patchFlutterVlessSecureSocks = tasks.register("patchFlutterVlessSecureSocks") { - group = "build setup" - description = "Add authenticated ephemeral SOCKS5 and fail-closed process recovery to pinned flutter_vless_android 1.1.5" - workingDir(projectRootDir) - dependsOn(patchFlutterVlessRuntimeRecovery) - - doFirst { - if (!dartExecutable.isFile) { - throw GradleException("Flutter Dart executable not found: ${dartExecutable.absolutePath}") - } - if (!flutterVlessSecureSocksPatchScript.isFile) { - throw GradleException("secure SOCKS patch script missing: ${flutterVlessSecureSocksPatchScript.absolutePath}") - } - if (!File(projectRootDir, ".dart_tool/package_config.json").isFile) { - throw GradleException("Flutter package metadata missing. Run `flutter pub get` before Android compilation.") - } - } - - commandLine( - dartExecutable.absolutePath, - "run", - flutterVlessSecureSocksPatchScript.absolutePath, - ) -} - -val patchFlutterVlessSecureSocksScopeFix = tasks.register("patchFlutterVlessSecureSocksScopeFix") { - group = "build setup" - description = "Repair the secure Xray process monitor capture without changing stock 1.1.5 runtime sequencing" - workingDir(projectRootDir) - dependsOn(patchFlutterVlessSecureSocks) - - doFirst { - if (!dartExecutable.isFile) { - throw GradleException("Flutter Dart executable not found: ${dartExecutable.absolutePath}") - } - if (!flutterVlessSecureSocksScopeFixScript.isFile) { - throw GradleException("secure SOCKS scope fix script missing: ${flutterVlessSecureSocksScopeFixScript.absolutePath}") - } - if (!File(projectRootDir, ".dart_tool/package_config.json").isFile) { - throw GradleException("Flutter package metadata missing. Run `flutter pub get` before Android compilation.") - } - } - - commandLine( - dartExecutable.absolutePath, - "run", - flutterVlessSecureSocksScopeFixScript.absolutePath, - ) -} - -// App compilation and the transitive Android plugin compilation must both wait -// for the pinned runtime patches. Plain `flutter build apk` must behave like CI -// instead of relying on a hidden manual pre-build step. -tasks.configureEach { - if (name == "preBuild") { - dependsOn(patchFlutterVlessSecureSocksScopeFix) - } -} - -gradle.projectsEvaluated { - rootProject.findProject(":flutter_vless_android") - ?.tasks - ?.matching { it.name == "preBuild" } - ?.configureEach { - dependsOn(patchFlutterVlessSecureSocksScopeFix) - } -} - android { namespace = "com.paladinvpn.app" compileSdk = flutter.compileSdkVersion diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1476797..0b45736 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -10,9 +10,6 @@ - - when (call.method) { - // Doze and App Standby stop background services of apps that - // are not exempt. A VPN has to be exempt to survive the screen - // going off, so surface the real state rather than guessing. "isIgnoringBatteryOptimizations" -> result.success(isIgnoringBatteryOptimizations()) "requestIgnoreBatteryOptimizations" -> result.success(requestIgnoreBatteryOptimizations()) - "setSessionExpiry" -> { - val expiresAtMs = call.argument("expiresAtMs")?.toLong() ?: 0L - if (expiresAtMs > 0) { - sendSessionExpiryToDaemon(expiresAtMs) - } - result.success(null) - } - // Is our VPN runtime actually alive? The service lives in - // :RunSoLibXrayDaemon, a separate process, so its statics are - // NOT visible here -- this is the only honest check. - "isVpnRuntimeAlive" -> result.success(isVpnRuntimeAlive()) else -> result.notImplemented() } } @@ -118,7 +101,6 @@ class MainActivity : FlutterActivity() { super.onDestroy() } - /** True when the OS has exempted us from Doze / App Standby. */ private fun isIgnoringBatteryOptimizations(): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true return try { @@ -129,11 +111,6 @@ class MainActivity : FlutterActivity() { } } - /** - * Opens the system exemption prompt. Android shows this as a single dialog; - * it cannot be granted silently, so the caller must treat a false return as - * "ask again later" rather than a denial. - */ private fun requestIgnoreBatteryOptimizations(): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true if (isIgnoringBatteryOptimizations()) return true @@ -146,7 +123,6 @@ class MainActivity : FlutterActivity() { ) true } catch (_: Exception) { - // Some OEM builds hide the direct intent; fall back to the list. try { startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) true @@ -156,58 +132,6 @@ class MainActivity : FlutterActivity() { } } - /** Pushes the session-expiry deadline to the VPN service process. */ - private fun sendSessionExpiryToDaemon(expiresAtMs: Long) { - try { - val intent = Intent(this, XrayVPNService::class.java) - intent.putExtra("SESSION_EXPIRES_AT_MS", expiresAtMs) - startService(intent) - } catch (_: Exception) { - // Daemon not reachable; its persisted expiry still applies. - } - } - - /** - * Whether the VPN runtime is genuinely alive. - * - * The Xray service runs in :RunSoLibXrayDaemon, a separate process, so its - * statics (AppConfigs.V2RAY_CONFIG, V2RAY_STATE) are NOT visible from here -- - * reading them yields a fresh, empty copy. getRunningAppProcesses still - * returns our *own* processes, which makes it the one reliable signal. - */ - private fun isVpnRuntimeAlive(): Map { - var processAlive = false - try { - val am = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager - processAlive = am.runningAppProcesses - ?.any { it.processName.endsWith(VPN_PROCESS_SUFFIX) } == true - } catch (_: Exception) { - } - - var vpnTransport = false - try { - val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - vpnTransport = cm.allNetworks.any { network -> - cm.getNetworkCapabilities(network) - ?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true - } - } catch (_: Exception) { - } - - return mapOf( - "processAlive" to processAlive, - "vpnTransport" to vpnTransport, - ) - } - - /** Removes a notification left behind by a runtime that is no longer alive. */ - private fun cancelVpnNotification() { - try { - NotificationManagerCompat.from(this).cancel(NOTIFICATION_ID) - } catch (_: Exception) { - } - } - private fun updateVpnNotification(title: String, text: String, actionLabel: String) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != @@ -216,15 +140,6 @@ class MainActivity : FlutterActivity() { return } - // The foreground notification (id 1) is owned by XrayVPNService via - // startForeground. Posting to it from this process while the service is - // gone leaves a frozen countdown nothing will ever update again. - val alive = isVpnRuntimeAlive()["processAlive"] == true - if (!alive) { - cancelVpnNotification() - return - } - ensureNotificationChannel() val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { @@ -234,17 +149,28 @@ class MainActivity : FlutterActivity() { } val launchIntent = packageManager.getLaunchIntentForPackage(packageName) - launchIntent?.action = "FROM_DISCONNECT_BTN" launchIntent?.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK - val stopIntent = Intent(this, XrayVPNService::class.java) - stopIntent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE) - val stopPendingIntent = PendingIntent.getService(this, 0, stopIntent, flags) + // The VPN service owns the authoritative Disconnect PendingIntent and + // embeds the current runtime-generation token in it. Never recreate an + // unscoped STOP intent from the UI process: a stale notification could + // otherwise stop a newer tunnel. Reuse the live service action when the + // platform exposes it; on older Android versions omit the action rather + // than weaken generation scoping. + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val serviceStopAction = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + manager.activeNotifications + .firstOrNull { it.id == NOTIFICATION_ID } + ?.notification + ?.actions + ?.firstOrNull() + ?.actionIntent + } else { + null + } - // Public lock-screen preview: keep the VPN status visible but hide the - // session countdown/speed so usage metadata never reaches the lock screen. val publicVersion = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) .setSmallIcon(R.drawable.notification_status_icon) .setContentTitle("Revolt VPN") @@ -255,7 +181,6 @@ class MainActivity : FlutterActivity() { .setSmallIcon(R.drawable.notification_status_icon) .setContentTitle(title) .setContentText(text) - .addAction(0, actionLabel, stopPendingIntent) .setColorized(true) .setColor(0xFF0D1117.toInt()) .setOngoing(true) @@ -267,6 +192,10 @@ class MainActivity : FlutterActivity() { .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) .setPublicVersion(publicVersion) + if (serviceStopAction != null) { + builder.addAction(0, actionLabel, serviceStopAction) + } + if (launchIntent != null) { builder.setContentIntent(PendingIntent.getActivity(this, 0, launchIntent, flags)) } @@ -473,7 +402,6 @@ class MainActivity : FlutterActivity() { companion object { private const val NOTIFICATION_ID = 1 - private const val VPN_PROCESS_SUFFIX = ":RunSoLibXrayDaemon" - private const val NOTIFICATION_CHANNEL_ID = "XRAY_SERVICE_CHANNEL" + private const val NOTIFICATION_CHANNEL_ID = "REVOLT_VPN_SERVICE" } } diff --git a/lib/logic/notification_service.dart b/lib/logic/notification_service.dart index d9e9f8c..88f46f1 100644 --- a/lib/logic/notification_service.dart +++ b/lib/logic/notification_service.dart @@ -16,16 +16,17 @@ class NotificationService { /// Sets the notification's content text to [text]. /// /// The native side rebuilds the whole notification, so skip the round-trip - /// when the text has not changed. + /// when the text has not changed. A failed native update is intentionally + /// not cached so the next tick can retry it. static Future updateTimer(String text) async { if (text == _lastText) return; - _lastText = text; try { await _channel.invokeMethod('updateNotificationText', { 'title': 'Revolt VPN', 'text': text, 'disconnectLabel': 'Disconnect', }); + _lastText = text; } catch (_) { // Cosmetic. } diff --git a/lib/logic/session_timer.dart b/lib/logic/session_timer.dart index 4625647..4164fb3 100644 --- a/lib/logic/session_timer.dart +++ b/lib/logic/session_timer.dart @@ -7,13 +7,11 @@ 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; @@ -25,7 +23,10 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { bool _hasSyncedOnce = false; int _consecutiveFailures = 0; bool _isDisconnecting = false; - bool _syncInProgress = false; + int? _syncEpochInProgress; + Completer? _syncCompletion; + int _sessionEpoch = 0; + bool _supportRewardClaimed = false; bool _supportRewardStateLoaded = false; int _supportStateEpoch = 0; @@ -38,25 +39,21 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { static const int _maxOfflineSeconds = 120; int _offlineSeconds = 0; - // 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; + static const int _pollIntervalSeconds = 5; + static const int _backgroundPollIntervalSeconds = 30; int _lastUsedBytes = 0; - DateTime? _lastSuccessfulSyncAt; + Stopwatch? _sinceLastSuccessfulSync; double _currentSpeedKBps = 0.0; SessionTimer({required this.vpnConnection}) { WidgetsBinding.instance.addObserver(this); vpnConnection.addListener(_onVpnConnectionChanged); unawaited(_loadSupportRewardState()); - // This provider is built lazily, so the tunnel may already be up and - // VpnConnection may already have fired its change before we attached. - // A listener does not replay history -- evaluate the current state once. - // Deferred so we never notify during the build that created us. + + // VpnConnection is eager and can report an already-running native tunnel + // before this provider is created. ChangeNotifier listeners do not replay + // old events, so always evaluate the current state once after construction. unawaited(Future.microtask(_onVpnConnectionChanged)); } @@ -113,11 +110,8 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { } void _onVpnConnectionChanged() { - // A live tunnel with a stopped clock is always wrong, however we got here: - // adopting a tunnel that outlived the UI process, recovering from a blip, or - // attaching late to one that was already up. Previously this required either - // adoptedRunningRuntime or _hasSyncedOnce, both false on a fresh process, so - // the clock stayed at 00:00:00 and the notification countdown froze with it. + // A live tunnel with a stopped clock is always wrong, whether this process + // adopted an existing runtime, attached late, or recovered from a blip. if (vpnConnection.status == VpnStatus.connected && !isRunning && !_isDisconnecting) { @@ -130,7 +124,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { if (!hadActiveSession || _isDisconnecting) return; if (vpnConnection.status == VpnStatus.error) { - _stopForVpnFailure('VPN entered error state'); + unawaited(_doDisconnect('VPN entered error state')); return; } @@ -139,33 +133,16 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { } } - void _stopForVpnFailure(String reason) { - if (_isDisconnecting) return; - _isDisconnecting = true; - debugPrint('[Timer] Stopping after VPN failure: $reason'); - - _timer?.cancel(); - _timer = null; - _currentSpeedKBps = 0.0; - _remainingSeconds = 0; - _remainingAtLastSync = 0; - _hasSyncedOnce = false; - _lastSuccessfulSyncAt = null; - _consecutiveFailures = 0; - _offlineSeconds = 0; - NotificationService.reset(); - notifyListeners(); - } - Future start() async { + _sessionEpoch++; _remainingSeconds = 0; _remainingAtLastSync = 0; _usedBytes = 0; _lastUsedBytes = 0; - _lastSuccessfulSyncAt = null; + _sinceLastSuccessfulSync?.stop(); + _sinceLastSuccessfulSync = null; _currentSpeedKBps = 0.0; _tickCount = 0; - _lastPushedExpiryAtMs = 0; _hasSyncedOnce = false; _consecutiveFailures = 0; _offlineSeconds = 0; @@ -188,7 +165,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { void _tick(Timer t) { if (_isDisconnecting) return; - if (_hasSyncedOnce && _consecutiveFailures < _maxConsecutiveFailures) { + if (_hasSyncedOnce) { _reconcileElapsedTime(); } @@ -220,9 +197,23 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { } } - /// Force an immediate server sync (e.g. after a support-ad extension so the - /// countdown reflects the new expiry without waiting for the next heartbeat). - Future syncNow() => _syncWithHivemind(); + /// Force a control-plane refresh after an event that changes the session + /// server-side. If a periodic sync is already in flight, wait for it and + /// issue a fresh request so the event cannot be hidden behind the busy guard. + Future syncNow() async { + final requestedEpoch = _sessionEpoch; + while (requestedEpoch == _sessionEpoch && !_isDisconnecting) { + final active = _syncEpochInProgress == requestedEpoch + ? _syncCompletion + : null; + if (active != null) { + await active.future; + continue; + } + await _syncWithHivemind(); + return; + } + } Future disconnect({String reason = 'User requested'}) async { await _doDisconnect(reason); @@ -231,6 +222,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { Future _doDisconnect(String reason) async { if (_isDisconnecting) return; _isDisconnecting = true; + _sessionEpoch++; debugPrint('[Timer] Disconnecting: $reason'); _timer?.cancel(); @@ -239,13 +231,13 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { _remainingSeconds = 0; _remainingAtLastSync = 0; _hasSyncedOnce = false; - _lastSuccessfulSyncAt = null; + _sinceLastSuccessfulSync?.stop(); + _sinceLastSuccessfulSync = null; NotificationService.reset(); notifyListeners(); await vpnConnection.disconnect(); - if (!_isDisconnecting) return; _isDisconnecting = false; notifyListeners(); } @@ -257,20 +249,28 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { } Future _syncWithHivemind() async { - if (_isDisconnecting || _syncInProgress) return; - _syncInProgress = true; + if (_isDisconnecting) return; + final epoch = _sessionEpoch; + if (_syncEpochInProgress == epoch) return; + + final completion = Completer(); + _syncCompletion = completion; + _syncEpochInProgress = epoch; + try { final deviceId = await CryptoService.getDeviceId(); + if (epoch != _sessionEpoch || _isDisconnecting) return; + final base = Uri.parse('${AppConfig.hivemindApiPublic}/session/status'); final url = base.replace(queryParameters: {'device_id': deviceId}); - final response = await HivemindService.directGet(url); - if (_isDisconnecting) return; + final response = await HivemindService.controlGet(url); + if (epoch != _sessionEpoch || _isDisconnecting) return; if (response.statusCode == 200) { final decoded = jsonDecode(response.body); if (decoded is! Map) { debugPrint('[Timer] Invalid session payload type.'); - _markSyncFailure(); + _markSyncFailure(epoch); return; } final data = decoded; @@ -278,7 +278,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { final activeValue = data['active']; if (activeValue is! bool) { debugPrint('[Timer] Session payload missing boolean active state.'); - _markSyncFailure(); + _markSyncFailure(epoch); return; } if (!activeValue) { @@ -291,61 +291,66 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { !expiresValue.isFinite || expiresValue < 0) { debugPrint('[Timer] Session payload has invalid expiry.'); - _markSyncFailure(); + _markSyncFailure(epoch); return; } + final elapsedMs = _sinceLastSuccessfulSync?.elapsedMilliseconds; _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(); - final previousSyncAt = _lastSuccessfulSyncAt; final deltaBytes = _usedBytes - _lastUsedBytes; - if (_hasSyncedOnce && previousSyncAt != null) { - final elapsedMs = now.difference(previousSyncAt).inMilliseconds; - if (deltaBytes > 0 && elapsedMs > 0) { - _currentSpeedKBps = (deltaBytes / (elapsedMs / 1000.0)) / 1000.0; - } else { - _currentSpeedKBps = 0.0; - } + if (_hasSyncedOnce && elapsedMs != null && elapsedMs > 0) { + _currentSpeedKBps = deltaBytes > 0 + ? (deltaBytes / (elapsedMs / 1000.0)) / 1000.0 + : 0.0; } else { _currentSpeedKBps = 0.0; } _lastUsedBytes = _usedBytes; - _lastSuccessfulSyncAt = now; + _sinceLastSuccessfulSync?.stop(); + _sinceLastSuccessfulSync = Stopwatch()..start(); if (data['cap_exhausted'] == true) { await _doDisconnect('Data cap reached'); return; } + if (epoch != _sessionEpoch || _isDisconnecting) return; + try { + await vpnConnection.setNativeSessionDeadline(_remainingAtLastSync); + } catch (e) { + debugPrint('[Timer] Failed to arm native session deadline: $e'); + await _doDisconnect('Native session deadline unavailable'); + return; + } + if (epoch != _sessionEpoch || _isDisconnecting) return; _consecutiveFailures = 0; _offlineSeconds = 0; _hasSyncedOnce = true; notifyListeners(); } else { - _markSyncFailure(); + _markSyncFailure(epoch); } } catch (e) { - if (!_isDisconnecting) { + if (epoch == _sessionEpoch && !_isDisconnecting) { debugPrint('Hivemind sync error: $e'); - _markSyncFailure(); + _markSyncFailure(epoch); } } finally { - _syncInProgress = false; + if (_syncEpochInProgress == epoch) { + _syncEpochInProgress = null; + } + if (identical(_syncCompletion, completion)) { + _syncCompletion = null; + } + if (!completion.isCompleted) completion.complete(); } } - void _markSyncFailure() { + void _markSyncFailure(int epoch) { + if (epoch != _sessionEpoch || _isDisconnecting) return; _consecutiveFailures++; if (_consecutiveFailures >= _maxConsecutiveFailures) { notifyListeners(); @@ -353,10 +358,10 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { } void _reconcileElapsedTime() { - final syncedAt = _lastSuccessfulSyncAt; - if (!_hasSyncedOnce || syncedAt == null) return; + final clock = _sinceLastSuccessfulSync; + if (!_hasSyncedOnce || clock == null) return; - final elapsed = DateTime.now().difference(syncedAt).inSeconds; + final elapsed = clock.elapsed.inSeconds; _remainingSeconds = (_remainingAtLastSync - elapsed).clamp(0, 1 << 31).toInt(); } @@ -396,9 +401,15 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { @override void dispose() { + // Invalidate every asynchronous generation before removing listeners so + // late storage/network completions cannot notify a disposed notifier. + _sessionEpoch++; + _supportStateEpoch++; + _isDisconnecting = true; WidgetsBinding.instance.removeObserver(this); vpnConnection.removeListener(_onVpnConnectionChanged); _timer?.cancel(); + _sinceLastSuccessfulSync?.stop(); super.dispose(); } } diff --git a/lib/logic/vpn_connection.dart b/lib/logic/vpn_connection.dart index 80d7c83..4147fe6 100644 --- a/lib/logic/vpn_connection.dart +++ b/lib/logic/vpn_connection.dart @@ -1,12 +1,12 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_vless/flutter_vless.dart'; import 'package:revoltvpn/logic/connection_settings.dart'; import 'package:revoltvpn/logic/hivemind_service.dart'; import 'package:revoltvpn/logic/local_socks_tester.dart'; import 'package:revoltvpn/logic/network_monitor.dart'; -import 'package:revoltvpn/logic/power_settings.dart'; import 'package:revoltvpn/logic/secure_socks_session.dart'; enum VpnStatus { @@ -18,7 +18,12 @@ enum VpnStatus { } class VpnConnection extends ChangeNotifier { - bool _cancelled = false; + static const MethodChannel _nativeControl = MethodChannel('flutter_vless'); + + int _connectEpoch = 0; + bool _suppressNativeConnect = false; + bool _disposed = false; + VpnStatus _status = VpnStatus.disconnected; VpnStatus get status => _status; @@ -45,6 +50,7 @@ class VpnConnection extends ChangeNotifier { SecureSocksSession? get activeSocksSession => _lastSecureSocks; bool get canTestActiveLocalSocks => + !_disposed && _status == VpnStatus.connected && _activeMode == ConnectionMode.proxy && _lastSecureSocks != null; @@ -94,9 +100,10 @@ class VpnConnection extends ChangeNotifier { } Future _startEngine() async { - if (kIsWeb) return; + if (kIsWeb || _disposed) return; await ConnectionSettings.initialize(); + if (_disposed) return; _activeMode = ConnectionSettings.mode; _vless = FlutterVless( @@ -115,13 +122,16 @@ class VpnConnection extends ChangeNotifier { notificationIconResourceType: 'drawable', notificationIconResourceName: 'notification_status_icon', ); + if (_disposed) return; _initialized = true; } catch (e) { debugPrint('[VPN] VLESS init error (expected on emulator): $e'); } + if (_disposed) return; _networkSubscription = NetworkMonitor.changes.listen( (snapshot) { + if (_disposed) return; // ConnectivityManager is informational only. Restarting from every // callback caused the old VPN-created-network reconnect loop. _networkTransport = snapshot.transport; @@ -139,32 +149,10 @@ class VpnConnection extends ChangeNotifier { try { final coreVersion = await _vless.getCoreVersion(); - debugPrint('[VPN] Xray core version: $coreVersion'); - } catch (_) {} - - await _adoptRunningRuntime(); - } - - /// Reflect a tunnel that is already up when the UI starts. - /// - /// This asks the OS whether our VPN service process is alive, rather than - /// probing a local SOCKS port. The old probe called getConnectedServerDelay(), - /// which reads AppConfigs.V2RAY_CONFIG?.LOCAL_SOCKS5_PORT ?: 10807 — but - /// AppConfigs lives in the VPN's own process, so from here it is always null - /// and the port always fell back to 10807. That was correct only while the - /// SOCKS port was fixed; per-session ephemeral ports made it permanently - /// wrong, so the app reported "connected" against a dead tunnel. - Future _adoptRunningRuntime() async { - if (kIsWeb) return; - try { - final state = await PowerSettings.runtimeState(); - if (state.aliveFor(tunMode: _activeMode == ConnectionMode.tun)) { - _adoptedRunningRuntime = true; - _setStatus(VpnStatus.connected, _connectedLabel); + if (!_disposed) { + debugPrint('[VPN] Xray core version: $coreVersion'); } - } catch (e) { - debugPrint('[VPN] Runtime liveness check failed: $e'); - } + } catch (_) {} } String get _connectedLabel { @@ -176,9 +164,15 @@ class VpnConnection extends ChangeNotifier { } } + bool _isCurrentConnect(int epoch) => + !_disposed && epoch == _connectEpoch && !_userDisconnecting; + void _mapStatus(VlessStatus status) { + if (_disposed) return; switch (status.connectionState) { case VlessConnectionState.connected: + if (_suppressNativeConnect || _userDisconnecting) return; + if (_connectEpoch == 0) _adoptedRunningRuntime = true; _setStatus(VpnStatus.connected, _connectedLabel); break; @@ -188,6 +182,7 @@ class VpnConnection extends ChangeNotifier { break; case VlessConnectionState.connecting: + if (_suppressNativeConnect || _userDisconnecting) return; _setStatus(VpnStatus.connecting, 'Establishing tunnel…'); break; @@ -196,6 +191,7 @@ class VpnConnection extends ChangeNotifier { break; case VlessConnectionState.unknown: + if (_suppressNativeConnect || _userDisconnecting) return; if (_status != VpnStatus.connected && _status != VpnStatus.disconnected) { _setStatus(VpnStatus.error, 'Connection failed'); @@ -205,27 +201,34 @@ class VpnConnection extends ChangeNotifier { } Future connect({bool skipAdBypass = false}) async { - if (_status == VpnStatus.connected || _status == VpnStatus.connecting) { + if (_disposed || + _userDisconnecting || + _status == VpnStatus.connected || + _status == VpnStatus.connecting || + _status == VpnStatus.disconnecting) { return false; } - _cancelled = false; + final connectEpoch = ++_connectEpoch; + _suppressNativeConnect = false; _userDisconnecting = false; await ConnectionSettings.initialize(); + if (!_isCurrentConnect(connectEpoch)) return false; _activeMode = ConnectionSettings.mode; if (!kIsWeb && !_initialized) { + _suppressNativeConnect = true; _errorMessage = 'VPN service unavailable.'; _setStatus(VpnStatus.error, 'Service unavailable'); return false; } - // TUN needs the Android VPN permission; SOCKS5 proxy mode runs a local - // authenticated proxy without a VpnService, so no VPN permission is needed. if (!kIsWeb && _activeMode == ConnectionMode.tun) { final ok = await _vless.requestPermission(); + if (!_isCurrentConnect(connectEpoch)) return false; if (!ok) { + _suppressNativeConnect = true; _errorMessage = 'VPN permission denied.'; _setStatus(VpnStatus.error, 'Permission required'); return false; @@ -241,6 +244,7 @@ class VpnConnection extends ChangeNotifier { if (kIsWeb) { await Future.delayed(const Duration(seconds: 1)); + if (!_isCurrentConnect(connectEpoch)) return false; _setStatus(VpnStatus.connected, 'Secured (dev mode)'); return true; } @@ -252,6 +256,7 @@ class VpnConnection extends ChangeNotifier { realUrl = await HivemindService.fetchConfigDirectly( skipAdBypass: skipAdBypass, onAttempt: (attempt, total) { + if (!_isCurrentConnect(connectEpoch)) return; _setStatus( VpnStatus.connecting, 'Contacting server ($attempt/$total)…', @@ -259,9 +264,11 @@ class VpnConnection extends ChangeNotifier { }, ); } catch (e) { + if (!_isCurrentConnect(connectEpoch)) return false; debugPrint('[VPN] Config fetch error: $e'); final raw = e.toString().replaceAll('Exception: ', ''); if (raw.contains('Cancelled')) return false; + _suppressNativeConnect = true; if (raw.contains('timed out') || raw.contains('Session not activated')) { _errorMessage = 'The server did not respond in time.\nCheck your connection and try again.'; @@ -273,11 +280,7 @@ class VpnConnection extends ChangeNotifier { return false; } - if (_cancelled) { - _setStatus(VpnStatus.disconnected, 'Tap to connect'); - return false; - } - + if (!_isCurrentConnect(connectEpoch)) return false; _setStatus(VpnStatus.connecting, 'Starting secure route…'); try { @@ -285,6 +288,7 @@ class VpnConnection extends ChangeNotifier { final baseConfig = parsed.getFullConfiguration(); final remark = parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN'; final secureSocks = await SecureSocksSession.create(baseConfig); + if (!_isCurrentConnect(connectEpoch)) return false; final verifyLocalSocks = _activeMode == ConnectionMode.proxy; await _startRuntime( @@ -292,26 +296,45 @@ class VpnConnection extends ChangeNotifier { remark: remark, proxyOnly: verifyLocalSocks, ); + if (!_isCurrentConnect(connectEpoch)) return false; _setStatus( VpnStatus.connecting, - verifyLocalSocks ? 'Waiting for local SOCKS5…' : 'Waiting for VPN interface…', + verifyLocalSocks + ? 'Waiting for local SOCKS5…' + : 'Waiting for VPN interface…', ); - if (!await _waitForNativeConnected()) { + if (!await _waitForNativeConnected(connectEpoch)) { throw StateError('VPN runtime did not report CONNECTED'); } if (verifyLocalSocks) { + if (!_isCurrentConnect(connectEpoch)) return false; _setStatus(VpnStatus.connecting, 'Checking Local SOCKS5…'); - if (!await _waitForLocalSocksListener(secureSocks)) { + if (!await _waitForLocalSocksListener( + secureSocks, + connectEpoch, + )) { throw StateError('Local SOCKS5 listener did not become ready'); } } + if (!_isCurrentConnect(connectEpoch)) return false; _lastSecureSocks = secureSocks; } catch (e) { + if (!_isCurrentConnect(connectEpoch)) return false; debugPrint('[VPN] Tunnel start error: $e'); - await _stopRuntime(); + _suppressNativeConnect = true; + try { + await _stopRuntime(); + } catch (stopError) { + debugPrint('[VPN] Runtime cleanup failed after start error: $stopError'); + if (!_isCurrentConnect(connectEpoch)) return false; + _errorMessage = 'VPN failed to shut down cleanly.\nPlease restart the app.'; + _setStatus(VpnStatus.error, 'Shutdown failed'); + return false; + } + if (!_isCurrentConnect(connectEpoch)) return false; _clearRuntimeSnapshot(); _errorMessage = _activeMode == ConnectionMode.proxy ? 'SOCKS5 gateway failed to start.\nTry reconnecting.' @@ -320,6 +343,7 @@ class VpnConnection extends ChangeNotifier { return false; } + if (!_isCurrentConnect(connectEpoch)) return false; _setStatus(VpnStatus.connected, _connectedLabel); return true; } @@ -336,22 +360,20 @@ class VpnConnection extends ChangeNotifier { ); } - Future _waitForLocalSocksListener([ - SecureSocksSession? session, - ]) async { - final active = session ?? _lastSecureSocks; - if (active == null) return false; - + Future _waitForLocalSocksListener( + SecureSocksSession session, + int connectEpoch, + ) async { for (var attempt = 0; attempt < 4; attempt++) { + if (!_isCurrentConnect(connectEpoch)) return false; final result = await LocalSocksTester.testListener( host: '127.0.0.1', - port: active.port, - username: active.username, - password: active.password, + port: session.port, + username: session.username, + password: session.password, ); - if (result.ok) { - return true; - } + if (!_isCurrentConnect(connectEpoch)) return false; + if (result.ok) return true; if (attempt < 3) { await Future.delayed(const Duration(milliseconds: 250)); } @@ -359,54 +381,59 @@ class VpnConnection extends ChangeNotifier { return false; } - Future _waitForNativeConnected() async { - // startVless() queues the Android foreground service. Wait for the native - // service/core broadcast rather than treating MethodChannel completion as - // proof that a TUN interface was established. + Future _waitForNativeConnected(int connectEpoch) async { for (var attempt = 0; attempt < 24; attempt++) { + if (!_isCurrentConnect(connectEpoch)) return false; if (_status == VpnStatus.connected) return true; - if (_status == VpnStatus.error) return false; - if (_cancelled || _userDisconnecting) return false; + if (_status == VpnStatus.error || _status == VpnStatus.disconnected) { + return false; + } await Future.delayed(const Duration(milliseconds: 250)); } - return _status == VpnStatus.connected; + return _isCurrentConnect(connectEpoch) && + _status == VpnStatus.connected; } Future _stopRuntime() async { - try { - await _vless.stopVless().timeout(const Duration(seconds: 5)); - } catch (_) {} + await _vless.stopVless().timeout(const Duration(seconds: 8)); + } + + Future setNativeSessionDeadline(int remainingSeconds) async { + if (kIsWeb) return; + if (_disposed) throw StateError('VPN connection has been disposed'); + if (!_initialized) throw StateError('VPN native service is not initialized'); + if (remainingSeconds < 0) { + throw ArgumentError.value(remainingSeconds, 'remainingSeconds'); + } + await _nativeControl.invokeMethod( + 'setSessionDeadline', + {'remainingSeconds': remainingSeconds}, + ); } Future disconnect() async { - _cancelled = true; + if (_disposed || _status == VpnStatus.disconnecting) return; + + _connectEpoch++; + _suppressNativeConnect = true; _userDisconnecting = true; HivemindService.cancel(); - if (_status == VpnStatus.disconnected || - _status == VpnStatus.disconnecting) { - _clearRuntimeSnapshot(); - _userDisconnecting = false; - return; - } - _setStatus(VpnStatus.disconnecting, 'Tearing down…'); if (kIsWeb) { await Future.delayed(const Duration(milliseconds: 500)); + if (_disposed) return; _clearRuntimeSnapshot(); _setStatus(VpnStatus.disconnected, 'Tap to connect'); _userDisconnecting = false; return; } - bool timedOut = false; try { - await _vless.stopVless().timeout( - const Duration(seconds: 5), - onTimeout: () => timedOut = true, - ); + await _vless.stopVless().timeout(const Duration(seconds: 8)); } catch (e) { + if (_disposed) return; debugPrint('[VPN] VLESS stop error: $e'); _errorMessage = 'VPN shutdown error.\nPlease restart the app.'; _clearRuntimeSnapshot(); @@ -415,15 +442,7 @@ class VpnConnection extends ChangeNotifier { return; } - if (timedOut) { - debugPrint('[VPN] stopVless() timed out after 5 s.'); - _errorMessage = 'VPN did not shut down cleanly.\nPlease restart the app.'; - _clearRuntimeSnapshot(); - _setStatus(VpnStatus.error, 'Shutdown failed'); - _userDisconnecting = false; - return; - } - + if (_disposed) return; _errorMessage = null; _clearRuntimeSnapshot(); _setStatus(VpnStatus.disconnected, 'Tap to connect'); @@ -436,21 +455,27 @@ class VpnConnection extends ChangeNotifier { } void _setStatus(VpnStatus status, String message) { + if (_disposed) return; _status = status; _statusMessage = message; notifyListeners(); } Future _checkHealth() async { - // A live tunnel already proves reachability; only probe while disconnected - // so we don't add direct /health traffic while the VPN is active. - if (_status == VpnStatus.connected) return; - _serverReachable = await HivemindService.checkHealth(); + if (_disposed || _status == VpnStatus.connected) return; + final reachable = await HivemindService.checkHealth(); + if (_disposed || _status == VpnStatus.connected) return; + _serverReachable = reachable; notifyListeners(); } @override void dispose() { + _disposed = true; + _connectEpoch++; + _suppressNativeConnect = true; + _userDisconnecting = true; + HivemindService.cancel(); _healthTimer?.cancel(); _networkSubscription?.cancel(); // Provider/UI disposal is not a user disconnect command. Keeping teardown diff --git a/local_packages/flutter_vless_android-1.1.5/.metadata b/local_packages/flutter_vless_android-1.1.5/.metadata new file mode 100644 index 0000000..fe7c308 --- /dev/null +++ b/local_packages/flutter_vless_android-1.1.5/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2" + channel: "stable" + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + - platform: android + create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/local_packages/flutter_vless_android-1.1.5/CHANGELOG.md b/local_packages/flutter_vless_android-1.1.5/CHANGELOG.md new file mode 100644 index 0000000..d188bf1 --- /dev/null +++ b/local_packages/flutter_vless_android-1.1.5/CHANGELOG.md @@ -0,0 +1,49 @@ +## 1.1.5 + +* Updated the Android runtime dependency to `dev.tfox.fluttervless:xray-android:26.7.11`. +* Rebuilt Android `libxray.so` binaries for `armeabi-v7a`, `arm64-v8a`, `x86`, and `x86_64` against Xray-core `v26.7.11`. +* Fixed Android VPN app routing: all configured blocked apps are now applied to `VpnService.Builder` as disallowed applications and bypass the Xray VPN tunnel. + +## 1.1.4 + +* Updated the Android runtime dependency to `dev.tfox.fluttervless:xray-android:26.6.27.1`. +* Rebuilt Android `libxray.so` binaries for `armeabi-v7a`, `arm64-v8a`, `x86`, and `x86_64` against Xray-core `v26.6.27`. + +## 1.1.3 + +* Updated the Android runtime dependency to `dev.tfox.fluttervless:xray-android:26.6.22`. +* Rebuilt Android `libxray.so` binaries for `armeabi-v7a`, `arm64-v8a`, `x86`, and `x86_64` against Xray-core `v26.6.22`. + +## 1.1.2 + +* Clarified that Android device and emulator runtime binaries are delivered through the main Maven `xray-android` AAR. +* Kept the Android runtime dependency on `dev.tfox.fluttervless:xray-android:26.6.1.1`. + +## 1.1.1 + +* Updated the Android device runtime `libxray.so` binaries to Xray-core `v26.6.1`. +* Kept Android 15+ 16KB page-size linker alignment for the rebuilt ARM binaries. +* Moved Android runtime binaries and geodata into the Maven `dev.tfox.fluttervless:xray-android:26.6.1.1` AAR dependency. +* Included device and emulator ABIs (`armeabi-v7a`, `arm64-v8a`, `x86`, `x86_64`) in the main Maven runtime AAR. +* Removed raw device `jniLibs` and geodata files from the Pub.dev wrapper package; the wrapper now resolves the runtime from Maven Central. + +## 1.1.0 + +* Replaced Android Dart boilerplate with the shared `VlessMethodChannelAdapter`. +* Removed template `getPlatformVersion` scaffold files. +* Moved `flutter_lints` to `dev_dependencies`. +* Removed publish-time dependency overrides from `pubspec.yaml`. + +## 1.0.2 + +* XRay version up + +## 1.0.1 + +* XRay version up + +## 1.0.0 + +* Initial release of the Android implementation for `flutter_vless`. +* Supports VLESS/VMESS protocols using Xray core. +* Implements `VlessPlatform` interface. diff --git a/local_packages/flutter_vless_android-1.1.5/LICENSE b/local_packages/flutter_vless_android-1.1.5/LICENSE new file mode 100644 index 0000000..b560af6 --- /dev/null +++ b/local_packages/flutter_vless_android-1.1.5/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024-2026 13FOX Studio / tfox.dev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/local_packages/flutter_vless_android-1.1.5/NOTICE b/local_packages/flutter_vless_android-1.1.5/NOTICE new file mode 100644 index 0000000..d687308 --- /dev/null +++ b/local_packages/flutter_vless_android-1.1.5/NOTICE @@ -0,0 +1,13 @@ +flutter_vless_android + +Copyright (c) 2024-2026 13FOX Studio / tfox.dev. + +This package is the Android implementation of the official flutter_vless +project maintained by 13FOX Studio. + +Official project: https://github.com/XIIIFOX/flutter_vless +Official publisher: https://pub.dev/publishers/tfox.dev +Website: https://tfox.dev + +Redistributions, forks, and derived packages must preserve the copyright notice +and MIT license notice where required by the license. diff --git a/local_packages/flutter_vless_android-1.1.5/README.md b/local_packages/flutter_vless_android-1.1.5/README.md new file mode 100644 index 0000000..5451e26 --- /dev/null +++ b/local_packages/flutter_vless_android-1.1.5/README.md @@ -0,0 +1,14 @@ +# flutter_vless_android + +The Android implementation of the `flutter_vless` plugin. + +## Runtime + +Android runtime binaries and geodata are delivered through the Maven Central AAR dependency `dev.tfox.fluttervless:xray-android:26.7.11`. This keeps the Pub.dev package lightweight while preserving the same packaged Xray files in the final Android app. + +Do not add raw `android/src/main/jniLibs` or geodata files back to this package. Rebuild runtime files in `android_runtime/xray_android/src/main`, publish the Maven AAR, then publish this wrapper package. + +## Emulator Support + +The current Maven runtime AAR includes `armeabi-v7a`, `arm64-v8a`, `x86`, and `x86_64`. +This covers both physical Android devices and Android emulators. diff --git a/local_packages/flutter_vless_android-1.1.5/UPSTREAM b/local_packages/flutter_vless_android-1.1.5/UPSTREAM new file mode 100644 index 0000000..6dbe705 --- /dev/null +++ b/local_packages/flutter_vless_android-1.1.5/UPSTREAM @@ -0,0 +1,4 @@ +Upstream: https://github.com/XIIIFOX/flutter_vless +Tag: v1.1.5 +Android package: flutter_vless_android 1.1.5 +ReVolt hardening is pre-applied to this vendored package. diff --git a/local_packages/flutter_vless_android-1.1.5/analysis_options.yaml b/local_packages/flutter_vless_android-1.1.5/analysis_options.yaml new file mode 100644 index 0000000..a5744c1 --- /dev/null +++ b/local_packages/flutter_vless_android-1.1.5/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/local_packages/flutter_vless_android-1.1.5/android/.gitignore b/local_packages/flutter_vless_android-1.1.5/android/.gitignore new file mode 100644 index 0000000..161bdcd --- /dev/null +++ b/local_packages/flutter_vless_android-1.1.5/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.cxx diff --git a/local_packages/flutter_vless_android-1.1.5/android/XRAY_SETUP.md b/local_packages/flutter_vless_android-1.1.5/android/XRAY_SETUP.md new file mode 100644 index 0000000..db32077 --- /dev/null +++ b/local_packages/flutter_vless_android-1.1.5/android/XRAY_SETUP.md @@ -0,0 +1,92 @@ +# Xray & Tun2socks Android Build Guide + +This guide explains how to build the native libraries (`libxray.so` and `libtun2socks.so`) for the Flutter Vless plugin. + +Android runtime files are stored in `android_runtime/xray_android/src/main` and published as the Maven Central AAR `dev.tfox.fluttervless:xray-android`. They are not stored in the `flutter_vless_android` Pub.dev package. + +**Key Features:** +- ✅ **Android 15+ Support**: Builds with 16KB page size alignment. +- ✅ **Socket FD Passing**: `tun2socks` is patched to receive the TUN file descriptor via a Unix socket (bypassing Android process restrictions). + +## Prerequisites + +1. **Go (Golang)**: Version 1.21+ installed. +2. **Android NDK**: Version r27+ (recommended). +3. **macOS/Linux**: Build scripts are designed for Unix-like environments. + +## 1. Environment Setup + +Export the path to your Android NDK. + +```bash +# Example (Adjust path to your NDK installation) +export ANDROID_NDK_HOME="$HOME/Library/Android/sdk/ndk/27.0.12077973" +``` + +## 2. Build Xray (`libxray.so`) + +This script builds the Xray core for Android device and emulator architectures (`arm64-v8a`, `armeabi-v7a`, `x86`, `x86_64`) by default and writes them to the Maven runtime module. + +```bash +cd android +chmod +x build_xray.sh +./build_xray.sh +``` + +## 3. Build Tun2socks (`libtun2socks.so`) + +We use a custom Go-based build script (`build_tun2socks.sh`) instead of the old C-based one. This ensures 16KB page alignment and includes the socket-based FD passing logic. + +```bash +cd android +chmod +x build_tun2socks.sh +./build_tun2socks.sh +``` + +## 4. Verification (16KB Page Size) + +To confirm that the libraries are compatible with Android 15+ (16KB page size), check the `LOAD` segment alignment using `llvm-readelf`. + +```bash +# Check alignment (should be 0x4000) +$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-readelf -l ../../../android_runtime/xray_android/src/main/jniLibs/arm64-v8a/libtun2socks.so | grep LOAD | head -1 +``` + +**Expected Output:** +``` +LOAD ... 0x4000 +``` +If you see `0x1000`, it is **NOT** compatible with 16KB devices. + +## 5. Package The Maven Runtime AAR + +The `flutter_vless_android` Pub.dev package consumes the Android device runtime through the Maven artifact: + +```text +dev.tfox.fluttervless:xray-android:26.7.11 +``` + +After rebuilding `libxray.so`, `libtun2socks.so`, or the geodata files in `android_runtime/xray_android/src/main`, build the local Maven repository from the repository root: + +```bash +tool/build_android_runtime_maven.sh +``` + +For local Android wrapper or example builds before Maven Central publication, pass the local repository path: + +```bash +cd example/android +./gradlew :app:assembleDebug \ + -PflutterVlessAndroidRuntimeRepo="$PWD/../../android_runtime/xray_android/build/repo" +``` + +To upload the signed bundle to Maven Central, configure the Central Portal and signing environment variables documented in `android_runtime/xray_android/README.md`, then run: + +```bash +tool/publish_android_runtime_maven.sh +``` + +## 6. Troubleshooting + +- **"bad file descriptor"**: This means the socket FD passing failed. Ensure `XrayVPNService.kt` is correctly sending the FD to the socket path specified in `-sock-path`. +- **"permission denied"**: Ensure the app has permissions to write to the socket file in its private directory. diff --git a/local_packages/flutter_vless_android-1.1.5/android/build.gradle b/local_packages/flutter_vless_android-1.1.5/android/build.gradle new file mode 100644 index 0000000..0543d9b --- /dev/null +++ b/local_packages/flutter_vless_android-1.1.5/android/build.gradle @@ -0,0 +1,95 @@ +group 'com.github.tfox.flutter_vless' +version '1.0' + +buildscript { + ext.kotlin_version = '2.2.20' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:8.1.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +rootProject.allprojects { + repositories { + def localRuntimeRepo = findProperty('flutterVlessAndroidRuntimeRepo') ?: System.getenv('FLUTTER_VLESS_ANDROID_RUNTIME_REPO') + if (localRuntimeRepo) { + maven { url uri(localRuntimeRepo) } + } + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + if (project.android.hasProperty("namespace")) { + namespace 'com.github.tfox.flutter_vless' + } + + compileSdkVersion 34 + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + defaultConfig { + minSdkVersion 23 + targetSdkVersion 35 + + // 16KB page size support for Android 15+ + ndk { + abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64' + } + } + + // Configure 16KB page alignment + buildFeatures { + prefab true + } + + dependencies { + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.mockito:mockito-core:5.0.0' + testImplementation 'org.json:json:20240303' + } + + testOptions { + unitTests.returnDefaultValues = true + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } + + sourceSets { + main { + java.srcDirs += 'src/main/kotlin' + } + } + packaging { + jniLibs { + pickFirsts += ['lib/**/libc++_shared.so'] + } + } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'com.google.code.gson:gson:2.10.1' + implementation "dev.tfox.fluttervless:xray-android:${findProperty('flutterVlessXrayRuntimeVersion') ?: '26.7.11'}" +} diff --git a/local_packages/flutter_vless_android-1.1.5/android/build_tun2socks.sh b/local_packages/flutter_vless_android-1.1.5/android/build_tun2socks.sh new file mode 100755 index 0000000..f26a369 --- /dev/null +++ b/local_packages/flutter_vless_android-1.1.5/android/build_tun2socks.sh @@ -0,0 +1,122 @@ +#!/bin/bash +set -euo pipefail + +# Build script for tun2socks (Go version) with 16KB page size support +# This is required for Android 15+ compatibility + +# Configuration +TUN2SOCKS_REPO="https://github.com/xjasonlyu/tun2socks" +TARGET_DIR="${TARGET_DIR:-../../../android_runtime/xray_android/src/main/jniLibs}" +NDK_PATH="${ANDROID_NDK_HOME:-$HOME/Library/Android/sdk/ndk/28.2.13676358}" + +# Check NDK +if [ ! -d "$NDK_PATH" ]; then + echo "Error: NDK not found at $NDK_PATH" + echo "Please export ANDROID_NDK_HOME pointing to your NDK installation." + exit 1 +fi + +echo "Using NDK at: $NDK_PATH" + +case "$(uname -s)" in + Darwin) TOOLCHAIN="${NDK_PATH}/toolchains/llvm/prebuilt/darwin-x86_64" ;; + Linux) TOOLCHAIN="${NDK_PATH}/toolchains/llvm/prebuilt/linux-x86_64" ;; + *) echo "Error: unsupported host OS $(uname -s)"; exit 1 ;; +esac +if [ ! -d "$TOOLCHAIN" ]; then + echo "Error: NDK toolchain not found at $TOOLCHAIN" + echo "Please check ANDROID_NDK_HOME and host OS." + exit 1 +fi + +# Clone tun2socks if not exists +if [ ! -d "tun2socks-go" ]; then + echo "Cloning tun2socks (Go version)..." + git clone "$TUN2SOCKS_REPO" tun2socks-go +else + echo "tun2socks-go directory exists, pulling latest..." + cd tun2socks-go && git pull && cd .. +fi + +# Build Function +build_tun2socks() { + local ARCH_NAME=$1 + local GO_ARCH=$2 + local GO_ARM=$3 + local ANDROID_TARGET=$4 + local OUTPUT_DIR="${TARGET_DIR}/${ARCH_NAME}" + + echo "Building tun2socks for ${ARCH_NAME}..." + + mkdir -p "$OUTPUT_DIR" + + export CGO_ENABLED=1 + export GOOS=android + export GOARCH=$GO_ARCH + export GOARM=$GO_ARM + + export CC="${TOOLCHAIN}/bin/${ANDROID_TARGET}-clang" + export CXX="${TOOLCHAIN}/bin/${ANDROID_TARGET}-clang++" + + # Verify compiler exists + if [ ! -f "$CC" ]; then + echo "Error: Compiler not found at $CC" + return 1 + fi + + cd tun2socks-go + + # Build with 16KB page alignment - same flags as xray + go build -v -trimpath -ldflags "-s -w -buildid= -linkmode=external -extldflags '-Wl,-z,max-page-size=16384'" -buildmode=pie -o "../${OUTPUT_DIR}/libtun2socks.so" . + + echo "Success: ${OUTPUT_DIR}/libtun2socks.so created." + + # Verify 16KB alignment + if [ -x "${TOOLCHAIN}/bin/llvm-readelf" ]; then + echo "Verifying 16KB alignment:" + ALIGN=$("${TOOLCHAIN}/bin/llvm-readelf" -l "../${OUTPUT_DIR}/libtun2socks.so" | grep "LOAD" | head -1 | awk '{print $NF}') + if [ "$ALIGN" = "0x4000" ]; then + echo "Alignment: $ALIGN (16KB) - correct" + else + echo "Warning: alignment is $ALIGN; expected 0x4000" + fi + fi + + cd .. +} + +# Build for Architectures +echo "Building tun2socks (Go version) with 16KB page size support..." + +# ARM64 +if [ "${TUN2SOCKS_BUILD_ARM64:-1}" = "1" ]; then + build_tun2socks "arm64-v8a" "arm64" "" "aarch64-linux-android21" +fi + +# ARMv7 +if [ "${TUN2SOCKS_BUILD_ARMV7:-1}" = "1" ]; then + build_tun2socks "armeabi-v7a" "arm" "7" "armv7a-linux-androideabi21" +fi + +# x86 is included in the main runtime AAR. +if [ "${TUN2SOCKS_BUILD_X86:-1}" = "1" ]; then + build_tun2socks "x86" "386" "" "i686-linux-android21" +fi + +# x86_64 is included in the main runtime AAR. +if [ "${TUN2SOCKS_BUILD_X86_64:-1}" = "1" ]; then + build_tun2socks "x86_64" "amd64" "" "x86_64-linux-android21" +fi + +echo "" +echo "=========================================" +echo "Build process finished!" +echo "=========================================" +echo "" +echo "Verification:" +echo "Run this command to check all libraries:" +echo "" +echo "for arch in armeabi-v7a arm64-v8a x86 x86_64; do" +echo " echo \"=== \$arch ===\";" +echo " ${TOOLCHAIN}/bin/llvm-readelf -l ${TARGET_DIR}/\$arch/libtun2socks.so | grep LOAD | head -1;" +echo "done" diff --git a/local_packages/flutter_vless_android-1.1.5/android/build_xray.sh b/local_packages/flutter_vless_android-1.1.5/android/build_xray.sh new file mode 100755 index 0000000..a743a0c --- /dev/null +++ b/local_packages/flutter_vless_android-1.1.5/android/build_xray.sh @@ -0,0 +1,116 @@ +#!/bin/bash +set -euo pipefail + +# Configuration +XRAY_REPO="https://github.com/XTLS/Xray-core" +XRAY_VERSION="${XRAY_VERSION:-v26.7.11}" +TARGET_DIR="${TARGET_DIR:-../../../android_runtime/xray_android/src/main/jniLibs}" +NDK_PATH="${ANDROID_NDK_HOME:-$HOME/Library/Android/sdk/ndk/28.2.13676358}" + +# Check NDK +if [ ! -d "$NDK_PATH" ]; then + echo "Error: NDK not found at $NDK_PATH" + echo "Please export ANDROID_NDK_HOME pointing to your NDK installation." + exit 1 +fi + +echo "Using NDK at: $NDK_PATH" + +# MacOS NDK Toolchain path +case "$(uname -s)" in + Darwin) TOOLCHAIN="${NDK_PATH}/toolchains/llvm/prebuilt/darwin-x86_64" ;; + Linux) TOOLCHAIN="${NDK_PATH}/toolchains/llvm/prebuilt/linux-x86_64" ;; + *) echo "Error: unsupported host OS $(uname -s)"; exit 1 ;; +esac +if [ ! -d "$TOOLCHAIN" ]; then + echo "Error: NDK toolchain not found at $TOOLCHAIN" + echo "Are you on macOS? If not, please edit the script to match your OS." + exit 1 +fi + +# Clone Xray if not exists +if [ ! -d "Xray-core" ]; then + echo "Cloning Xray-core..." + git clone "$XRAY_REPO" +else + echo "Xray-core directory exists, pulling latest..." + cd Xray-core && git fetch --tags && cd .. +fi + +cd Xray-core +git fetch --tags +git checkout "$XRAY_VERSION" +cd .. + +# Build Function +build_xray() { + local ARCH_NAME=$1 + local GO_ARCH=$2 + local GO_ARM=$3 + local ANDROID_TARGET=$4 + local OUTPUT_DIR="${TARGET_DIR}/${ARCH_NAME}" + + echo "Building for ${ARCH_NAME}..." + + mkdir -p "$OUTPUT_DIR" + + export CGO_ENABLED=1 + export GOOS=android + export GOARCH=$GO_ARCH + export GOARM=$GO_ARM + + export CC="${TOOLCHAIN}/bin/${ANDROID_TARGET}-clang" + export CXX="${TOOLCHAIN}/bin/${ANDROID_TARGET}-clang++" + + # 16KB page size support for Android 15+ + export LDFLAGS="-Wl,-z,max-page-size=16384" + + # Verify compiler exists + if [ ! -f "$CC" ]; then + echo "Error: Compiler not found at $CC" + # Try older NDK naming convention if new one fails (API 21 is standard) + # Some NDKs use specific API levels in clang name + return 1 + fi + + cd Xray-core + + # Build with 16KB page alignment + if go build -v -trimpath -buildvcs=false \ + -gcflags "all=-l=4" \ + -ldflags "-X github.com/xtls/xray-core/core.build=${XRAY_VERSION} -s -w -buildid= -checklinkname=0 -linkmode=external -extldflags=${LDFLAGS}" \ + -buildmode=pie \ + -o "../${OUTPUT_DIR}/libxray.so" ./main; then + echo "Success: ${OUTPUT_DIR}/libxray.so created." + else + echo "Failed to build for ${ARCH_NAME}" + return 1 + fi + + cd .. +} + +# Build for Architectures +# API Level 21 is generally safe for modern Flutter apps (Android 5.0+) + +# ARM64 +if [ "${XRAY_BUILD_ARM64:-1}" = "1" ]; then + build_xray "arm64-v8a" "arm64" "" "aarch64-linux-android21" +fi + +# ARMv7 +if [ "${XRAY_BUILD_ARMV7:-1}" = "1" ]; then + build_xray "armeabi-v7a" "arm" "7" "armv7a-linux-androideabi21" +fi + +# x86 is included in the main runtime AAR. +if [ "${XRAY_BUILD_X86:-1}" = "1" ]; then + build_xray "x86" "386" "" "i686-linux-android21" +fi + +# x86_64 is included in the main runtime AAR. +if [ "${XRAY_BUILD_X86_64:-1}" = "1" ]; then + build_xray "x86_64" "amd64" "" "x86_64-linux-android21" +fi + +echo "Build process finished." diff --git a/local_packages/flutter_vless_android-1.1.5/android/settings.gradle b/local_packages/flutter_vless_android-1.1.5/android/settings.gradle new file mode 100644 index 0000000..8df1cc5 --- /dev/null +++ b/local_packages/flutter_vless_android-1.1.5/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'flutter_vless_android' diff --git a/local_packages/flutter_vless_android-1.1.5/android/src/main/AndroidManifest.xml b/local_packages/flutter_vless_android-1.1.5/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..55e0011 --- /dev/null +++ b/local_packages/flutter_vless_android-1.1.5/android/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/FlutterVlessPlugin.kt b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/FlutterVlessPlugin.kt new file mode 100644 index 0000000..35cc1f7 --- /dev/null +++ b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/FlutterVlessPlugin.kt @@ -0,0 +1,402 @@ +// Copyright (c) 2024-2026 13FOX Studio / tfox.dev. +// SPDX-License-Identifier: MIT + +package com.github.tfox.flutter_vless + +import android.Manifest +import android.app.Activity +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.net.VpnService +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import com.github.tfox.flutter_vless.xray.dto.XrayConfig +import com.github.tfox.flutter_vless.xray.service.XrayVPNService +import com.github.tfox.flutter_vless.xray.utils.AppConfigs +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.PluginRegistry +import java.io.BufferedReader +import java.io.File +import java.io.InputStreamReader +import java.util.ArrayList +import java.util.UUID +import java.util.concurrent.Executors + +/** + * Main entry point for the Flutter Vless plugin on Android. + * + * ReVolt keeps this bridge deliberately narrow: start/stop, session deadline, + * permission handling, core version and private runtime status only. + */ +class FlutterVlessPlugin : FlutterPlugin, ActivityAware, PluginRegistry.ActivityResultListener, MethodChannel.MethodCallHandler { + + private val executor = Executors.newSingleThreadExecutor() + private lateinit var vpnControlMethod: MethodChannel + private lateinit var vpnStatusEvent: EventChannel + private var vpnStatusSink: EventChannel.EventSink? = null + private var activity: Activity? = null + private var xrayReceiver: BroadcastReceiver? = null + private var pendingResult: MethodChannel.Result? = null + private lateinit var context: Context + private var expectedRuntimeToken: String? = null + private var pendingStopResult: MethodChannel.Result? = null + private val mainHandler = Handler(Looper.getMainLooper()) + private val stopTimeoutRunnable = Runnable { + val pending = pendingStopResult ?: return@Runnable + pendingStopResult = null + pending.error("STOP_TIMEOUT", "VPN service did not confirm shutdown", null) + } + + companion object { + private const val TAG = "FlutterVlessPlugin" + private const val REQUEST_CODE_VPN_PERMISSION = 24 + private const val REQUEST_CODE_POST_NOTIFICATIONS = 1 + } + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + context = binding.applicationContext + vpnControlMethod = MethodChannel(binding.binaryMessenger, "flutter_vless") + vpnStatusEvent = EventChannel(binding.binaryMessenger, "flutter_vless/status") + + vpnControlMethod.setMethodCallHandler(this) + vpnStatusEvent.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + vpnStatusSink = events + registerReceiver() + } + + override fun onCancel(arguments: Any?) { + vpnStatusSink = null + unregisterReceiver() + } + }) + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "startVless" -> { + if (pendingStopResult != null) { + result.error("STOP_IN_PROGRESS", "VPN shutdown is still in progress", null) + return + } + + val config = XrayConfig() + val runtimeToken = UUID.randomUUID().toString() + expectedRuntimeToken = runtimeToken + config.RUNTIME_TOKEN = runtimeToken + config.REMARK = call.argument("remark") ?: "" + config.V2RAY_FULL_JSON_CONFIG = call.argument("config") ?: "" + config.BLOCKED_APPS = call.argument>("blocked_apps") ?: ArrayList() + config.BYPASS_SUBNETS = call.argument>("bypass_subnets") ?: ArrayList() + config.NOTIFICATION_DISCONNECT_BUTTON_NAME = call.argument("notificationDisconnectButtonName") ?: "Disconnect" + + if (AppConfigs.NOTIFICATION_ICON_RESOURCE_NAME.isNotEmpty() && AppConfigs.NOTIFICATION_ICON_RESOURCE_TYPE.isNotEmpty()) { + config.NOTIFICATION_ICON_RESOURCE_NAME = AppConfigs.NOTIFICATION_ICON_RESOURCE_NAME + config.NOTIFICATION_ICON_RESOURCE_TYPE = AppConfigs.NOTIFICATION_ICON_RESOURCE_TYPE + val resId = context.resources.getIdentifier( + AppConfigs.NOTIFICATION_ICON_RESOURCE_NAME, + AppConfigs.NOTIFICATION_ICON_RESOURCE_TYPE, + context.packageName + ) + config.APPLICATION_ICON = resId + } + + if (call.argument("proxy_only") == true) { + AppConfigs.V2RAY_CONNECTION_MODE = AppConfigs.V2RAY_CONNECTION_MODES.PROXY_ONLY + } else { + AppConfigs.V2RAY_CONNECTION_MODE = AppConfigs.V2RAY_CONNECTION_MODES.VPN_TUN + } + + try { + val jsonConfig = org.json.JSONObject(config.V2RAY_FULL_JSON_CONFIG) + val outbounds = jsonConfig.optJSONArray("outbounds") + if (outbounds != null && outbounds.length() > 0) { + val firstOutbound = outbounds.getJSONObject(0) + val settings = firstOutbound.optJSONObject("settings") + val vnext = settings?.optJSONArray("vnext") + if (vnext != null && vnext.length() > 0) { + val server = vnext.getJSONObject(0) + config.CONNECTED_V2RAY_SERVER_ADDRESS = server.optString("address", "") + config.CONNECTED_V2RAY_SERVER_PORT = server.optInt("port", 0).toString() + } else if (settings != null) { + config.CONNECTED_V2RAY_SERVER_ADDRESS = settings.optString("address", "") + config.CONNECTED_V2RAY_SERVER_PORT = settings.optInt("port", 0).toString() + } + } + } catch (_: Exception) { + // Optional metadata only; runtime config validation happens downstream. + } + + val intent = Intent(context, XrayVPNService::class.java) + intent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.START_SERVICE) + intent.putExtra("V2RAY_CONFIG", config) + intent.putExtra("PROXY_ONLY", call.argument("proxy_only") ?: false) + + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + result.success(null) + } catch (e: Exception) { + if (expectedRuntimeToken == runtimeToken) expectedRuntimeToken = null + result.error("START_FAILED", e.message ?: "Could not start VPN service", null) + } + } + + "stopVless" -> { + if (pendingStopResult != null) { + result.error("STOP_IN_PROGRESS", "VPN shutdown is already in progress", null) + return + } + + // A STOP without a generation token can kill a newer runtime after + // UI/process churn. No token means this bridge owns nothing to stop. + val token = expectedRuntimeToken + if (token == null) { + result.success(null) + return + } + + val intent = Intent(context, XrayVPNService::class.java) + intent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE) + intent.putExtra("RUNTIME_TOKEN", token) + + try { + context.startService(intent) + pendingStopResult = result + mainHandler.removeCallbacks(stopTimeoutRunnable) + mainHandler.postDelayed(stopTimeoutRunnable, 6_000L) + } catch (e: Exception) { + pendingStopResult = null + mainHandler.removeCallbacks(stopTimeoutRunnable) + result.error("STOP_FAILED", e.message ?: "Could not stop VPN service", null) + } + } + + "setSessionDeadline" -> { + val token = expectedRuntimeToken + val remainingSeconds = call.argument("remainingSeconds")?.toLong() + if (token == null) { + result.error("NO_RUNTIME", "No active runtime token", null) + return + } + if (remainingSeconds == null || remainingSeconds < 0) { + result.error("INVALID_DEADLINE", "remainingSeconds must be non-negative", null) + return + } + + val intent = Intent(context, XrayVPNService::class.java) + intent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.UPDATE_SESSION_DEADLINE) + intent.putExtra("RUNTIME_TOKEN", token) + intent.putExtra("REMAINING_SECONDS", remainingSeconds) + try { + context.startService(intent) + result.success(null) + } catch (e: Exception) { + result.error("DEADLINE_FAILED", e.message ?: "Could not update session deadline", null) + } + } + + "initializeVless" -> { + val iconResourceName = call.argument("notificationIconResourceName") + val iconResourceType = call.argument("notificationIconResourceType") + if (iconResourceName != null && iconResourceType != null) { + AppConfigs.NOTIFICATION_ICON_RESOURCE_NAME = iconResourceName + AppConfigs.NOTIFICATION_ICON_RESOURCE_TYPE = iconResourceType + } + result.success(null) + } + + "getCoreVersion" -> { + executor.submit { + try { + val nativeLibraryDir = context.applicationInfo.nativeLibraryDir + val xrayExecutable = File(nativeLibraryDir, "libxray.so") + if (xrayExecutable.exists()) { + val p = Runtime.getRuntime().exec(arrayOf(xrayExecutable.absolutePath, "-version")) + val reader = BufferedReader(InputStreamReader(p.inputStream)) + val version = reader.readLine() + result.success(version) + } else { + result.success("Xray not found") + } + } catch (e: Exception) { + result.success("Error: ${e.message}") + } + } + } + + "requestPermission" -> { + val currentActivity = activity + if (currentActivity == null) { + result.error("NO_ACTIVITY", "Activity is not attached", null) + return + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ActivityCompat.checkSelfPermission(currentActivity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(currentActivity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQUEST_CODE_POST_NOTIFICATIONS) + } + } + val request = VpnService.prepare(currentActivity) + if (request != null) { + pendingResult = result + currentActivity.startActivityForResult(request, REQUEST_CODE_VPN_PERMISSION) + } else { + result.success(true) + } + } + + else -> result.notImplemented() + } + } + + private fun registerReceiver() { + if (activity == null) return + if (xrayReceiver == null) { + xrayReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent == null || vpnStatusSink == null) return + val state = intent.getSerializableExtra("STATE") as? AppConfigs.V2RAY_STATES + val runtimeToken = intent.getStringExtra("RUNTIME_TOKEN").orEmpty() + val runtimeReady = intent.getBooleanExtra("RUNTIME_READY", false) + val duration = intent.getStringExtra("DURATION") + val uploadSpeed = intent.getLongExtra("UPLOAD_SPEED", 0) + val downloadSpeed = intent.getLongExtra("DOWNLOAD_SPEED", 0) + val uploadTraffic = intent.getLongExtra("UPLOAD_TRAFFIC", 0) + val downloadTraffic = intent.getLongExtra("DOWNLOAD_TRAFFIC", 0) + + val currentToken = expectedRuntimeToken + if (currentToken == null) { + if (runtimeToken.isNotEmpty() && + (state == AppConfigs.V2RAY_STATES.V2RAY_CONNECTED || + state == AppConfigs.V2RAY_STATES.V2RAY_CONNECTING) + ) { + expectedRuntimeToken = runtimeToken + } else { + return + } + } else if (runtimeToken != currentToken) { + return + } + + val stateName = when (state) { + AppConfigs.V2RAY_STATES.V2RAY_CONNECTED -> if (runtimeReady) "CONNECTED" else "CONNECTING" + AppConfigs.V2RAY_STATES.V2RAY_CONNECTING -> "CONNECTING" + else -> "DISCONNECTED" + } + + val data = ArrayList() + data.add(duration ?: "0") + data.add(uploadSpeed.toString()) + data.add(downloadSpeed.toString()) + data.add(uploadTraffic.toString()) + data.add(downloadTraffic.toString()) + data.add(stateName) + + if (state == AppConfigs.V2RAY_STATES.V2RAY_DISCONNECTED && + runtimeToken.isNotEmpty() && runtimeToken == expectedRuntimeToken + ) { + expectedRuntimeToken = null + mainHandler.removeCallbacks(stopTimeoutRunnable) + pendingStopResult?.success(null) + pendingStopResult = null + } + + vpnStatusSink?.success(data) + } + } + } + + val filter = IntentFilter(AppConfigs.V2RAY_CONNECTION_INFO) + val currentActivity = activity ?: return + ContextCompat.registerReceiver( + currentActivity, + xrayReceiver, + filter, + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + } + + private fun unregisterReceiver() { + val currentActivity = activity + val receiver = xrayReceiver + if (currentActivity != null && receiver != null) { + try { + currentActivity.unregisterReceiver(receiver) + } catch (e: Exception) { + Log.w(TAG, "Receiver was already detached during lifecycle cleanup", e) + } + } + xrayReceiver = null + } + + private fun failPendingPermission(code: String, message: String) { + pendingResult?.error(code, message, null) + pendingResult = null + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + mainHandler.removeCallbacks(stopTimeoutRunnable) + pendingStopResult?.error("ENGINE_DETACHED", "Flutter engine detached during VPN shutdown", null) + pendingStopResult = null + failPendingPermission("ENGINE_DETACHED", "Flutter engine detached during VPN permission request") + unregisterReceiver() + vpnControlMethod.setMethodCallHandler(null) + vpnStatusEvent.setStreamHandler(null) + executor.shutdown() + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + activity = binding.activity + binding.addActivityResultListener(this) + if (vpnStatusSink != null) { + registerReceiver() + } + } + + override fun onDetachedFromActivityForConfigChanges() { + unregisterReceiver() + activity = null + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + activity = binding.activity + binding.addActivityResultListener(this) + if (vpnStatusSink != null) { + registerReceiver() + } + } + + override fun onDetachedFromActivity() { + failPendingPermission("NO_ACTIVITY", "Activity detached during VPN permission request") + unregisterReceiver() + activity = null + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { + if (requestCode == REQUEST_CODE_VPN_PERMISSION) { + if (resultCode == Activity.RESULT_OK) { + pendingResult?.success(true) + } else { + pendingResult?.success(false) + } + pendingResult = null + return true + } + return false + } +} diff --git a/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/core/XrayCoreManager.kt b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/core/XrayCoreManager.kt new file mode 100644 index 0000000..54975b6 --- /dev/null +++ b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/core/XrayCoreManager.kt @@ -0,0 +1,642 @@ +package com.github.tfox.flutter_vless.xray.core + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Color +import android.os.Build +import android.os.CountDownTimer +import android.util.Log +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import com.github.tfox.flutter_vless.xray.dto.XrayConfig +import com.github.tfox.flutter_vless.xray.service.XrayVPNService +import com.github.tfox.flutter_vless.xray.utils.AppConfigs +import com.github.tfox.flutter_vless.xray.utils.Utilities +import org.json.JSONObject +import org.json.JSONArray +import java.io.File +import java.io.FileOutputStream + +/** + * Manages the Xray Core process (libxray.so). + * + * This singleton object is responsible for: + * 1. Generating the final Xray configuration file (config.json). + * 2. Injecting necessary inbounds (SOCKS, HTTP, API) into the user-provided config. + * 3. Starting and monitoring the Xray process. + * 4. Collecting traffic statistics via the Xray API. + * 5. Showing the persistent foreground notification. + */ +object XrayCoreManager { + + private const val NOTIFICATION_ID = 1 + private const val TAG = "XrayCoreManager" + private var xrayProcess: Process? = null + private var countDownTimer: CountDownTimer? = null + private var seconds = 0 + + private fun deleteEphemeralConfigOnFailure(filesDir: File) { + try { + val config = File(filesDir, "config.json") + if (config.exists() && !config.delete()) { + Log.w(TAG, "Could not delete failed ephemeral Xray config") + } + } catch (e: Exception) { + Log.w(TAG, "Could not delete failed ephemeral Xray config", e) + } + } + + private fun nextFreePort(preferredPort: Int, usedPorts: Set): Int { + var port = preferredPort + while (usedPorts.contains(port)) { + port++ + } + return port + } + + private fun uniqueInboundTag(inbounds: JSONArray, preferredTag: String): String { + val tags = mutableSetOf() + for (i in 0 until inbounds.length()) { + val tag = inbounds.optJSONObject(i)?.optString("tag").orEmpty() + if (tag.isNotEmpty()) tags.add(tag) + } + + if (!tags.contains(preferredTag)) return preferredTag + + var index = 1 + var candidate = "${preferredTag}_$index" + while (tags.contains(candidate)) { + index++ + candidate = "${preferredTag}_$index" + } + return candidate + } + + private fun normalizeRuntimeConfig(value: Any?): Any? { + return when (value) { + is JSONObject -> { + val normalized = JSONObject() + val aliases = mapOf( + "xHTTPSettings" to "xhttpSettings", + "httpUpgradeSettings" to "httpupgradeSettings", + "splitHTTPSettings" to "splithttpSettings" + ) + val keys = value.keys() + while (keys.hasNext()) { + val key = keys.next() + if (key == "allowInsecure") continue + + val targetKey = aliases[key] ?: key + if (aliases.containsKey(key) && value.has(targetKey)) continue + + val normalizedValue = normalizeRuntimeConfig(value.opt(key)) + if (targetKey == "network" && normalizedValue is String) { + normalized.put(targetKey, normalizedValue.lowercase()) + } else { + normalized.put(targetKey, normalizedValue) + } + } + normalized + } + is JSONArray -> { + val normalized = JSONArray() + for (i in 0 until value.length()) { + normalized.put(normalizeRuntimeConfig(value.opt(i))) + } + normalized + } + else -> value + } + } + + private fun normalizeVlessOutbounds(configJson: JSONObject) { + val outbounds = configJson.optJSONArray("outbounds") ?: return + for (i in 0 until outbounds.length()) { + val outbound = outbounds.optJSONObject(i) ?: continue + if (outbound.optString("protocol") != "vless") continue + + val settings = outbound.optJSONObject("settings") ?: continue + if (settings.has("vnext")) continue + + val address = settings.optString("address") + val id = settings.optString("id") + val port = settings.optInt("port", 0) + if (address.isEmpty() || id.isEmpty() || port <= 0) continue + + val user = JSONObject() + user.put("id", id) + user.put("encryption", settings.optString("encryption", "none")) + user.put("flow", settings.optString("flow", "")) + user.put("level", settings.optInt("level", 8)) + + val server = JSONObject() + server.put("address", address) + server.put("port", port) + server.put("users", JSONArray().put(user)) + + val normalizedSettings = JSONObject() + normalizedSettings.put("vnext", JSONArray().put(server)) + outbound.put("settings", normalizedSettings) + Log.d(TAG, "Normalized flat VLESS outbound settings for $address:$port") + } + } + + private fun sanitizeLogPaths(configJson: JSONObject, filesDir: File) { + val log = configJson.optJSONObject("log") ?: return + + // The VPN client never needs destination/access logs. A server-provided + // config must not silently enable browsing metadata collection locally. + log.remove("access") + try { File(filesDir, "access.log").delete() } catch (_: Exception) {} + + val errorPath = log.optString("error") + if (errorPath.isNotEmpty()) { + log.put("error", File(filesDir, "error.log").absolutePath) + } + } + + internal fun buildRuntimeConfigJson(config: XrayConfig, filesDir: File): JSONObject { + val configJson = normalizeRuntimeConfig( + JSONObject(config.V2RAY_FULL_JSON_CONFIG) + ) as JSONObject + normalizeVlessOutbounds(configJson) + sanitizeLogPaths(configJson, filesDir) + + // ReVolt needs only its authenticated local SOCKS ingress. + val inbounds = configJson.optJSONArray("inbounds") ?: JSONArray() + var secureSocksEnabled = false + for (i in 0 until inbounds.length()) { + val inbound = inbounds.optJSONObject(i) ?: continue + if (inbound.optString("tag") != "revolt-secure-socks") continue + + val settings = inbound.optJSONObject("settings") + ?: throw IllegalStateException("Secure SOCKS5 settings are missing") + val users = settings.optJSONArray("users") + ?: throw IllegalStateException("Secure SOCKS5 users are missing") + val account = users.optJSONObject(0) + ?: throw IllegalStateException("Secure SOCKS5 account is missing") + val port = inbound.optInt("port", -1) + + if (inbound.optString("protocol") != "socks" || + inbound.optString("listen") != "127.0.0.1" || + port <= 1024 || port > 65535 || + settings.optString("auth") != "password" || + account.optString("user").isEmpty() || + account.optString("pass").isEmpty() + ) { + throw IllegalStateException("Secure SOCKS5 session is invalid") + } + + config.LOCAL_SOCKS5_PORT = port + config.LOCAL_HTTP_PORT = 0 + secureSocksEnabled = true + break + } + + val usedPorts = mutableSetOf() + var hasSocksOnLocalPort = false + var hasHttpOnLocalPort = false + for (i in 0 until inbounds.length()) { + val inbound = inbounds.getJSONObject(i) + val protocol = inbound.optString("protocol") + val port = inbound.optInt("port", -1) + if (port > 0) usedPorts.add(port) + if (protocol == "socks" && port == config.LOCAL_SOCKS5_PORT) hasSocksOnLocalPort = true + if (protocol == "http" && port == config.LOCAL_HTTP_PORT) hasHttpOnLocalPort = true + } + + if (!hasSocksOnLocalPort) { + if (usedPorts.contains(config.LOCAL_SOCKS5_PORT)) { + config.LOCAL_SOCKS5_PORT = nextFreePort(config.LOCAL_SOCKS5_PORT, usedPorts) + } + val socksInbound = JSONObject() + socksInbound.put("tag", uniqueInboundTag(inbounds, "socks")) + socksInbound.put("port", config.LOCAL_SOCKS5_PORT) + socksInbound.put("listen", "127.0.0.1") + socksInbound.put("protocol", "socks") + socksInbound.put("settings", JSONObject().put("auth", "noauth").put("udp", true)) + socksInbound.put( + "sniffing", + JSONObject().put("enabled", true).put("destOverride", JSONArray().put("http").put("tls")) + ) + inbounds.put(socksInbound) + usedPorts.add(config.LOCAL_SOCKS5_PORT) + Log.d(TAG, "Injected SOCKS inbound on port ${config.LOCAL_SOCKS5_PORT}") + } + + if (!secureSocksEnabled && !hasHttpOnLocalPort) { + if (usedPorts.contains(config.LOCAL_HTTP_PORT)) { + config.LOCAL_HTTP_PORT = nextFreePort(config.LOCAL_HTTP_PORT, usedPorts) + } + val httpInbound = JSONObject() + httpInbound.put("tag", uniqueInboundTag(inbounds, "http")) + httpInbound.put("port", config.LOCAL_HTTP_PORT) + httpInbound.put("listen", "127.0.0.1") + httpInbound.put("protocol", "http") + inbounds.put(httpInbound) + usedPorts.add(config.LOCAL_HTTP_PORT) + Log.d(TAG, "Injected HTTP inbound on port ${config.LOCAL_HTTP_PORT}") + } + + return configJson + } + + internal fun buildDelayConfigJson(configJson: String, proxyPort: Int, filesDir: File): Pair { + val delayConfig = XrayConfig( + V2RAY_FULL_JSON_CONFIG = configJson, + LOCAL_SOCKS5_PORT = proxyPort, + LOCAL_HTTP_PORT = proxyPort + 1, + LOCAL_API_PORT = proxyPort + 2 + ) + val runtimeJson = buildRuntimeConfigJson(delayConfig, filesDir) + return Pair(runtimeJson, delayConfig.LOCAL_SOCKS5_PORT) + } + + /** + * Starts the Xray Core process. + * + * @param context The service context (needed for file access and notifications). + * @param config The configuration object containing the user's settings. + * @return true if started successfully, false otherwise. + */ + fun startCore(context: Service, config: XrayConfig): Boolean { + AppConfigs.RUNTIME_READY = false + AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTING + AppConfigs.V2RAY_CONFIG = config + + // 1. Prepare the configuration file + val configFilesDir = context.filesDir + + try { + val configJson = buildRuntimeConfigJson(config, configFilesDir) + val configFile = File(context.filesDir, "config.json") + configFile.writeText(configJson.toString()) + } catch (e: Exception) { + Log.e(TAG, "Failed to write config file", e) + deleteEphemeralConfigOnFailure(configFilesDir) + return false + } + + // 2. Find Xray executable (libxray.so) + val nativeLibraryDir = context.applicationInfo.nativeLibraryDir + val xrayExecutable = File(nativeLibraryDir, "libxray.so") + if (!xrayExecutable.exists()) { + Log.e(TAG, "Xray executable not found at ${xrayExecutable.absolutePath}") + deleteEphemeralConfigOnFailure(configFilesDir) + return false + } + + // 3. Prepare assets (geoip, geosite) + try { + Utilities.copyAssets(context) + } catch (e: Exception) { + Log.e(TAG, "Failed to prepare Xray assets", e) + deleteEphemeralConfigOnFailure(configFilesDir) + return false + } + + // 4. Run Xray + try { + val cmd = listOf( + xrayExecutable.absolutePath, + "-config", File(configFilesDir, "config.json").absolutePath + ) + val pb = ProcessBuilder(cmd) + pb.directory(configFilesDir) + pb.redirectErrorStream(true) + + // Set environment variables (XRAY_LOCATION_ASSET is crucial for finding geoip/geosite) + val env = pb.environment() + env["XRAY_LOCATION_ASSET"] = Utilities.getUserAssetsPath(context) + + xrayProcess = pb.start() + Thread.sleep(300) + if (xrayProcess?.isAlive != true) { + val output = xrayProcess?.inputStream?.bufferedReader()?.readText().orEmpty() + Log.e(TAG, "Xray process exited during startup. Output: $output") + xrayProcess = null + AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_DISCONNECTED + deleteEphemeralConfigOnFailure(configFilesDir) + return false + } + + // config.json contains per-session SOCKS credentials. Xray has + // consumed it after the startup liveness check, so remove it now. + try { + val ephemeralConfig = File(configFilesDir, "config.json") + if (ephemeralConfig.exists() && !ephemeralConfig.delete()) { + Log.w(TAG, "Could not delete ephemeral Xray config") + } + } catch (e: Exception) { + Log.w(TAG, "Could not delete ephemeral Xray config", e) + } + + AppConfigs.RUNTIME_READY = false + AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTING + startTimer(context) + showNotification(context, config) + sendStatusBroadcast(context, config) + + val process = xrayProcess ?: return false + // Monitor process in a separate thread to detect crash + Thread { + try { + process.inputStream.bufferedReader().use { reader -> + reader.forEachLine { _ -> } + } + + val exitCode = process.waitFor() + Log.e(TAG, "Xray process exited with code $exitCode") + if (xrayProcess === process && + AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN == config.RUNTIME_TOKEN && + (AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTED || + AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTING) + ) { + // Hold the Android TUN while the core is recovered. Killing + // the VPN service here would allow direct-network fallback. + xrayProcess = null + AppConfigs.RUNTIME_READY = false + AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTING + sendStatusBroadcast(context, config) + if (context is XrayVPNService) { + context.handleXrayCoreExit(config) + } else { + stopCore(context) + } + } + } catch (e: java.io.InterruptedIOException) { + // Expected when stopping + } catch (e: InterruptedException) { + // Expected when stopping + } catch (e: Exception) { + Log.e(TAG, "Error reading xray output", e) + } + }.start() + + return true + + } catch (e: Exception) { + Log.e(TAG, "Failed to start Xray process", e) + deleteEphemeralConfigOnFailure(configFilesDir) + return false + } + } + + /** + * Stops the Xray Core process and cleans up notifications. + */ + fun stopCore(context: Service, confirmationToken: String? = AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN) { + try { + xrayProcess?.destroy() + xrayProcess = null + } catch (e: Exception) { + Log.e(TAG, "Failed to destroy Xray process", e) + } + + AppConfigs.RUNTIME_READY = false + AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_DISCONNECTED + stopTimer() + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(NOTIFICATION_ID) + sendDisconnectedBroadcast(context, confirmationToken.orEmpty()) + AppConfigs.V2RAY_CONFIG = null + } + + fun markRuntimeConnecting(context: Context, config: XrayConfig) { + if (AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN != config.RUNTIME_TOKEN) return + AppConfigs.RUNTIME_READY = false + AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTING + sendStatusBroadcast(context, config) + } + + fun markRuntimeReady(context: Service, config: XrayConfig) { + if (AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN != config.RUNTIME_TOKEN) return + AppConfigs.RUNTIME_READY = true + AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTED + showNotification(context, config) + sendStatusBroadcast(context, config) + } + + fun isXrayRunning(): Boolean { + // Check state instead of process because VPN runs in separate service process + return AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTED || + AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTING + } + + private fun startTimer(context: Context) { + countDownTimer?.cancel() + seconds = 0 + countDownTimer = object : CountDownTimer(Long.MAX_VALUE, 1000) { + override fun onTick(millisUntilFinished: Long) { + seconds++ + val intent = Intent(AppConfigs.V2RAY_CONNECTION_INFO).setPackage(context.packageName) + intent.putExtra("STATE", AppConfigs.V2RAY_STATE) + intent.putExtra("RUNTIME_TOKEN", AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN.orEmpty()) + intent.putExtra("RUNTIME_READY", AppConfigs.RUNTIME_READY) + intent.putExtra("DURATION", seconds.toString()) + + intent.putExtra("UPLOAD_SPEED", 0L) + intent.putExtra("DOWNLOAD_SPEED", 0L) + intent.putExtra("UPLOAD_TRAFFIC", 0L) + intent.putExtra("DOWNLOAD_TRAFFIC", 0L) + + context.sendBroadcast(intent) + } + + override fun onFinish() {} + }.start() + } + + private fun stopTimer() { + countDownTimer?.cancel() + countDownTimer = null + seconds = 0 + } + + fun sendStatusBroadcast(context: Context, config: XrayConfig) { + val intent = Intent(AppConfigs.V2RAY_CONNECTION_INFO).setPackage(context.packageName) + intent.putExtra("STATE", AppConfigs.V2RAY_STATE) + intent.putExtra("RUNTIME_TOKEN", config.RUNTIME_TOKEN) + intent.putExtra("RUNTIME_READY", AppConfigs.RUNTIME_READY) + intent.putExtra("DURATION", seconds.toString()) + intent.putExtra("UPLOAD_SPEED", 0L) + intent.putExtra("DOWNLOAD_SPEED", 0L) + intent.putExtra("UPLOAD_TRAFFIC", 0L) + intent.putExtra("DOWNLOAD_TRAFFIC", 0L) + context.sendBroadcast(intent) + } + + private fun sendDisconnectedBroadcast(context: Context, runtimeToken: String) { + val intent = Intent(AppConfigs.V2RAY_CONNECTION_INFO).setPackage(context.packageName) + intent.putExtra("STATE", AppConfigs.V2RAY_STATES.V2RAY_DISCONNECTED) + intent.putExtra("RUNTIME_TOKEN", runtimeToken) + intent.putExtra("RUNTIME_READY", false) + intent.putExtra("DURATION", "0") + intent.putExtra("UPLOAD_SPEED", 0L) + intent.putExtra("DOWNLOAD_SPEED", 0L) + intent.putExtra("UPLOAD_TRAFFIC", 0L) + intent.putExtra("DOWNLOAD_TRAFFIC", 0L) + context.sendBroadcast(intent) + } + + private fun showNotification(context: Service, config: XrayConfig) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ActivityCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + return + } + } + + val channelId = createNotificationChannel(context, config.APPLICATION_NAME) + + val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) + launchIntent?.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK + + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_UPDATE_CURRENT + val contentPendingIntent = PendingIntent.getActivity(context, 0, launchIntent, flags) + + val stopIntent = Intent(context, XrayVPNService::class.java) + stopIntent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE) + stopIntent.putExtra("RUNTIME_TOKEN", config.RUNTIME_TOKEN) + val stopPendingIntent = PendingIntent.getService(context, 0, stopIntent, flags) + val smallIcon = if (config.APPLICATION_ICON != 0) config.APPLICATION_ICON else android.R.drawable.ic_dialog_info + + val builder = NotificationCompat.Builder(context, channelId) + .setSmallIcon(smallIcon) + .setContentTitle(config.REMARK) + .setContentText(if (AppConfigs.RUNTIME_READY) "Connected" else "Connecting") + .addAction(0, config.NOTIFICATION_DISCONNECT_BUTTON_NAME, stopPendingIntent) + .setContentIntent(contentPendingIntent) + .setPriority(NotificationCompat.PRIORITY_MIN) + .setOngoing(true) + .setShowWhen(true) + + context.startForeground(NOTIFICATION_ID, builder.build()) + } + + fun getConnectedV2rayServerDelay(context: Context, url: String): Long { + // Use the configured SOCKS port (default 10807) + val port = AppConfigs.V2RAY_CONFIG?.LOCAL_SOCKS5_PORT ?: 10807 + Log.d(TAG, "getConnectedV2rayServerDelay: Testing delay to $url via SOCKS port $port") + + return try { + val start = System.currentTimeMillis() + val proxy = java.net.Proxy(java.net.Proxy.Type.SOCKS, java.net.InetSocketAddress("127.0.0.1", port)) + val connection = java.net.URL(url).openConnection(proxy) as java.net.HttpURLConnection + connection.connectTimeout = 5000 + connection.readTimeout = 5000 + connection.requestMethod = "HEAD" + val responseCode = connection.responseCode + val end = System.currentTimeMillis() + val delay = end - start + connection.disconnect() + Log.d(TAG, "getConnectedV2rayServerDelay: Success! Response code: $responseCode, Delay: ${delay}ms") + delay + } catch (e: Exception) { + Log.e(TAG, "getConnectedV2rayServerDelay: Failed to measure delay (VPN may not be running)", e) + -1L + } + } + + /** + * Measure delay for a server configuration (when not connected). + * Temporarily starts Xray with the provided config, measures delay, then stops it. + */ + fun getServerDelay(context: Context, configJson: String, url: String): Long { + Log.d(TAG, "getServerDelay: Starting temporary Xray instance") + + var tempProcess: Process? = null + try { + // Find a random free port to avoid conflict with running VPN + val freePort = try { + val socket = java.net.ServerSocket(0) + val port = socket.localPort + socket.close() + port + } catch (e: Exception) { + 10806 // Fallback + } + + val (json, socksPort) = buildDelayConfigJson(configJson, freePort, context.filesDir) + Log.d(TAG, "getServerDelay: Using SOCKS port $socksPort") + + // Write temp config file + val tempConfigFile = File(context.filesDir, "temp_delay_config.json") + tempConfigFile.writeText(json.toString()) + + // Copy assets + Utilities.copyAssets(context) + + // Start Xray process + val xrayExecutable = File(context.applicationInfo.nativeLibraryDir, "libxray.so") + if (!xrayExecutable.exists()) { + Log.e(TAG, "getServerDelay: Xray executable not found") + return -1L + } + + val cmd = listOf( + xrayExecutable.absolutePath, + "-config", tempConfigFile.absolutePath + ) + + val pb = ProcessBuilder(cmd) + pb.directory(context.filesDir) + val env = pb.environment() + env["XRAY_LOCATION_ASSET"] = Utilities.getUserAssetsPath(context) + + tempProcess = pb.start() + + // Wait a bit for Xray to start + Thread.sleep(1000) + + // Measure delay + val delay = try { + val start = System.currentTimeMillis() + val proxy = java.net.Proxy(java.net.Proxy.Type.SOCKS, java.net.InetSocketAddress("127.0.0.1", socksPort)) + val connection = java.net.URL(url).openConnection(proxy) as java.net.HttpURLConnection + connection.connectTimeout = 5000 + connection.readTimeout = 5000 + connection.requestMethod = "HEAD" + val responseCode = connection.responseCode + val end = System.currentTimeMillis() + connection.disconnect() + val result = end - start + Log.d(TAG, "getServerDelay: Success! Response code: $responseCode, Delay: ${result}ms") + result + } catch (e: Exception) { + Log.e(TAG, "getServerDelay: Failed to measure delay", e) + -1L + } + + // Stop temp process + tempProcess?.destroy() + tempConfigFile.delete() + + return delay + + } catch (e: Exception) { + Log.e(TAG, "getServerDelay: Error starting temp Xray", e) + tempProcess?.destroy() + return -1L + } + } + + private fun createNotificationChannel(context: Context, appName: String): String { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channelId = "REVOLT_VPN_SERVICE" + val channelName = "$appName Background Service" + val channel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW) + channel.lightColor = Color.BLUE + channel.lockscreenVisibility = Notification.VISIBILITY_PRIVATE + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.createNotificationChannel(channel) + return channelId + } + return "" + } +} diff --git a/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/dto/XrayConfig.kt b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/dto/XrayConfig.kt new file mode 100644 index 0000000..aed207c --- /dev/null +++ b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/dto/XrayConfig.kt @@ -0,0 +1,71 @@ +package com.github.tfox.flutter_vless.xray.dto + +import java.io.Serializable +import java.util.ArrayList + +/** + * Data class representing the Xray configuration and app settings. + * + * This class is passed from Flutter to Android and contains: + * 1. The full Xray JSON configuration (V2RAY_FULL_JSON_CONFIG). + * 2. App-specific settings like blocked apps, notification text, etc. + * 3. Connection parameters (server address, ports). + */ +data class XrayConfig( + /** The IP address of the connected server (used to exclude it from VPN routing). */ + var CONNECTED_V2RAY_SERVER_ADDRESS: String = "", + + /** The port of the connected server. */ + var CONNECTED_V2RAY_SERVER_PORT: String = "", + + /** Local port for SOCKS5 proxy (default: 10807). used by tun2socks. */ + var LOCAL_SOCKS5_PORT: Int = 10807, + + /** Local port for HTTP proxy (default: 10808). */ + var LOCAL_HTTP_PORT: Int = 10808, + + /** Local port for Xray API (stats, etc.) (default: 10809). */ + var LOCAL_API_PORT: Int = 10809, + + /** Timeout for connections in milliseconds. */ + var ALLOWED_TIMEOUT_MS: Int = 0, + + /** List of subnets to bypass (not routed through VPN). */ + var BYPASS_SUBNETS: ArrayList = ArrayList(), + + /** List of app package names to exclude from VPN. */ + var BLOCKED_APPS: ArrayList = ArrayList(), + + /** The raw Xray configuration JSON string. */ + var V2RAY_FULL_JSON_CONFIG: String = "", + + /** Whether to enable traffic statistics collection. */ + var ENABLE_TRAFFIC_STATICS: Boolean = false, + + /** Remark/Name of the server configuration. */ + var REMARK: String = "", + + /** Name of the application (displayed in notification). */ + var APPLICATION_NAME: String = "Flutter Vless", + + /** Resource ID of the notification icon. */ + var APPLICATION_ICON: Int = 0, + + /** Resource name of the notification icon (e.g. "ic_notification"). */ + var NOTIFICATION_ICON_RESOURCE_NAME: String = "", + + /** Resource type of the notification icon (e.g. "drawable"). */ + var NOTIFICATION_ICON_RESOURCE_TYPE: String = "", + + /** Text for the disconnect button in the notification. */ + var NOTIFICATION_DISCONNECT_BUTTON_NAME: String = "Disconnect", + + /** Domain resolution strategy (e.g. "IPIfNonMatch"). */ + var DOMAIN_STRATEGY: String = "", + + /** Routing domain strategy. */ + var ROUTING_DOMAIN_STRATEGY: String = "", + + /** Unique generation token used to reject stale native runtime events. */ + var RUNTIME_TOKEN: String = "" +) : Serializable diff --git a/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/service/XrayVPNService.kt b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/service/XrayVPNService.kt new file mode 100644 index 0000000..a1c06e1 --- /dev/null +++ b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/service/XrayVPNService.kt @@ -0,0 +1,685 @@ +package com.github.tfox.flutter_vless.xray.service + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.net.LocalSocket +import android.net.LocalSocketAddress +import android.net.VpnService +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.ParcelFileDescriptor +import android.os.SystemClock +import android.util.Log +import com.github.tfox.flutter_vless.xray.core.XrayCoreManager +import com.github.tfox.flutter_vless.xray.dto.XrayConfig +import com.github.tfox.flutter_vless.xray.utils.AppConfigs +import org.json.JSONObject +import java.io.File + +/** + * Android VPN Service implementation. + * + * This service owns the TUN interface and keeps runtime state generation-scoped. + * Session deadlines are enforced both by an in-process monotonic timer and by + * AlarmManager, while only the non-secret token/deadline pair is persisted. + * Per-session VLESS/SOCKS credentials remain memory-only. + */ +class XrayVPNService : VpnService() { + + private var mInterface: ParcelFileDescriptor? = null + private var tun2socksProcess: Process? = null + @Volatile private var isRunning = false + @Volatile private var recoveringXray = false + @Volatile private var recoveringTun2socks = false + private var tun2socksRecoveryAttempt = 0 + private var currentConfig: XrayConfig? = null + private var currentProxyOnly = false + private var shuttingDownIntentionally = false + + private val deadlineHandler = Handler(Looper.getMainLooper()) + private var sessionDeadlineElapsed: Long? = null + private var sessionDeadlineToken: String? = null + private val sessionDeadlineRunnable = Runnable { enforceSessionDeadline() } + + override fun onCreate() { + super.onCreate() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent == null) { + if (isRunning && currentConfig != null) return START_REDELIVER_INTENT + + // Session credentials intentionally stay memory-only. If Android + // cannot redeliver the original START intent, fail closed instead + // of rebuilding a tunnel from sensitive material stored on disk. + clearPersistedDeadline() + cancelSessionExpiryAlarm() + stopSelf() + return START_NOT_STICKY + } + + if (intent.getBooleanExtra(EXTRA_SESSION_EXPIRED, false)) { + enforcePersistedDeadline(intent.getStringExtra("RUNTIME_TOKEN").orEmpty()) + return START_NOT_STICKY + } + + val command = if (Build.VERSION.SDK_INT >= 33) { + intent.getSerializableExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS::class.java) + } else { + @Suppress("DEPRECATION") + intent.getSerializableExtra("COMMAND") as? AppConfigs.V2RAY_SERVICE_COMMANDS + } + + if (command == AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE) { + val requestedToken = intent.getStringExtra("RUNTIME_TOKEN").orEmpty() + val activeToken = currentConfig?.RUNTIME_TOKEN.orEmpty() + if (requestedToken.isEmpty() || + (activeToken.isNotEmpty() && requestedToken != activeToken) + ) { + Log.w(TAG, "Ignoring STOP_SERVICE without matching runtime generation") + return START_NOT_STICKY + } + stopAll(requestedToken) + return START_NOT_STICKY + } + + if (command == AppConfigs.V2RAY_SERVICE_COMMANDS.UPDATE_SESSION_DEADLINE) { + updateSessionDeadline(intent) + return if (isRunning) START_REDELIVER_INTENT else START_NOT_STICKY + } + + 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) + clearPersistedDeadline() + cancelSessionExpiryAlarm() + stopSelf() + return START_NOT_STICKY + } + + if (command == AppConfigs.V2RAY_SERVICE_COMMANDS.START_SERVICE) { + val config = if (Build.VERSION.SDK_INT >= 33) { + intent.getSerializableExtra("V2RAY_CONFIG", XrayConfig::class.java) + } else { + @Suppress("DEPRECATION") + intent.getSerializableExtra("V2RAY_CONFIG") as? XrayConfig + } + + if (config != null) { + if (XrayCoreManager.isXrayRunning()) { + Log.w(TAG, "Stopping stale Xray core before restart") + XrayCoreManager.stopCore(this) + } + cleanup(clearDeadline = true) + shuttingDownIntentionally = false + currentConfig = config + + val proxyOnly = intent.getBooleanExtra("PROXY_ONLY", false) + currentProxyOnly = proxyOnly + armSessionDeadline(config, BOOTSTRAP_SESSION_SECONDS) + if (shuttingDownIntentionally) return START_NOT_STICKY + + if (XrayCoreManager.startCore(this, config)) { + if (!proxyOnly) { + setupVpn(config) + } else { + isRunning = true + Log.d(TAG, "Starting in PROXY_ONLY mode") + XrayCoreManager.markRuntimeReady(this, config) + } + } else { + stopAll(config.RUNTIME_TOKEN) + return START_NOT_STICKY + } + } else { + Log.e(TAG, "START_SERVICE missing V2RAY_CONFIG") + stopAll() + return START_NOT_STICKY + } + } else { + stopAll() + return START_NOT_STICKY + } + + return START_REDELIVER_INTENT + } + + private fun setupVpn(config: XrayConfig) { + try { + if (mInterface != null) { + mInterface?.close() + mInterface = null + } + + val builder = Builder() + builder.setSession(config.REMARK) + builder.setMtu(1500) + builder.addAddress("26.26.26.1", 30) + builder.addAddress("fd00:26:26::1", 126) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + builder.setMetered(false) + } + + if (config.BLOCKED_APPS.isNotEmpty()) { + throw IllegalStateException("Per-app VPN bypass is disabled in ReVolt") + } + try { + builder.addDisallowedApplication(packageName) + } catch (e: Exception) { + throw IllegalStateException("Failed to exclude ReVolt from its own VPN", e) + } + + builder.addRoute("0.0.0.0", 0) + builder.addRoute("::", 0) + + try { + builder.addDnsServer("8.8.8.8") + builder.addDnsServer("1.1.1.1") + } catch (e: Exception) { + throw IllegalStateException("Failed to configure VPN DNS", e) + } + + mInterface = builder.establish() + ?: throw IllegalStateException("Android refused to establish VPN interface") + isRunning = true + runTun2socks(config) + + } catch (e: Exception) { + Log.e(TAG, "Failed to setup VPN", e) + stopAll() + } + } + + private data class SecureSocksCredentials( + val port: Int, + val username: String, + val password: String, + ) + + private fun secureSocksCredentials(config: XrayConfig): SecureSocksCredentials? { + val inbounds = try { + JSONObject(config.V2RAY_FULL_JSON_CONFIG).optJSONArray("inbounds") + } catch (_: Exception) { + null + } ?: return null + + for (i in 0 until inbounds.length()) { + val inbound = inbounds.optJSONObject(i) ?: continue + if (inbound.optString("tag") != "revolt-secure-socks") continue + if (inbound.optString("protocol") != "socks" || + inbound.optString("listen") != "127.0.0.1" + ) return null + + val settings = inbound.optJSONObject("settings") ?: return null + if (settings.optString("auth") != "password") return null + val user = settings.optJSONArray("users")?.optJSONObject(0) ?: return null + val port = inbound.optInt("port", -1) + val username = user.optString("user") + val password = user.optString("pass") + if (port <= 1024 || port > 65535 || username.isEmpty() || password.isEmpty()) { + return null + } + return SecureSocksCredentials(port, username, password) + } + return null + } + + private fun scheduleTun2socksRecovery(config: XrayConfig, reason: String) { + if (!isRunning || currentConfig !== config || recoveringTun2socks) return + XrayCoreManager.markRuntimeConnecting(this, config) + recoveringTun2socks = true + val attempt = ++tun2socksRecoveryAttempt + val shift = (attempt - 1).coerceIn(0, 6) + val delayMs = (500L * (1L shl shift)).coerceAtMost(30_000L) + Log.w(TAG, "Scheduling tun2socks recovery attempt $attempt in ${delayMs}ms: $reason") + + Thread { + try { + Thread.sleep(delayMs) + } catch (_: InterruptedException) { + recoveringTun2socks = false + return@Thread + } + + recoveringTun2socks = false + if (isRunning && currentConfig === config) { + runTun2socks(config) + } + }.start() + } + + private fun runTun2socks(config: XrayConfig) { + 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 + + val secure = secureSocksCredentials(config) + ?: throw IllegalStateException("Authenticated ReVolt SOCKS5 inbound missing") + config.LOCAL_SOCKS5_PORT = secure.port + + val cmd = arrayListOf( + tun2socksPath, + "-sock-path", sockPath, + "-proxy", "socks5://${secure.username}:${secure.password}@127.0.0.1:${secure.port}", + "-mtu", "1500", + "-loglevel", "debug" + ) + + Log.d(TAG, "Starting tun2socks with authenticated loopback SOCKS5") + + try { + val pb = ProcessBuilder(cmd) + pb.redirectErrorStream(true) + pb.directory(filesDir) + val process = pb.start() + tun2socksProcess = process + + Thread { + try { + process.inputStream.bufferedReader().use { reader -> + reader.forEachLine { _ -> } + } + + process.waitFor() + if (isRunning && tun2socksProcess === process && currentConfig === config) { + Log.e(TAG, "tun2socks exited unexpectedly; keeping TUN fail-closed") + tun2socksProcess = null + scheduleTun2socksRecovery(config, "process exited") + } + } catch (_: java.io.InterruptedIOException) { + // Expected when stopping + } catch (_: InterruptedException) { + // Expected when stopping + } catch (e: Exception) { + Log.e(TAG, "Error reading tun2socks output", e) + } + }.start() + + sendFd(process, config) + + } catch (e: Exception) { + Log.e(TAG, "Failed to start tun2socks; keeping TUN fail-closed", e) + scheduleTun2socksRecovery(config, "start failure") + } + } + + private fun sendFd(process: Process, config: XrayConfig) { + val fd = mInterface?.fileDescriptor ?: return + val sockFile = File(filesDir, "sock_path").absolutePath + + Thread { + var tries = 0 + while (tries < 10 && isRunning && tun2socksProcess === process && process.isAlive) { + var localSocket: LocalSocket? = null + try { + Thread.sleep(500) + localSocket = LocalSocket() + localSocket.connect(LocalSocketAddress(sockFile, LocalSocketAddress.Namespace.FILESYSTEM)) + val out = localSocket.outputStream + localSocket.setFileDescriptorsForSend(arrayOf(fd)) + out.write(32) + out.flush() + localSocket.setFileDescriptorsForSend(null) + localSocket.shutdownOutput() + localSocket.close() + if (isRunning && currentConfig === config && tun2socksProcess === process) { + tun2socksRecoveryAttempt = 0 + XrayCoreManager.markRuntimeReady(this, config) + } + return@Thread + } catch (_: Exception) { + tries++ + try { + localSocket?.close() + } catch (closeError: Exception) { + Log.w(TAG, "Failed to close tun2socks handoff socket", closeError) + } + } + } + + if (isRunning && tun2socksProcess === process) { + Log.e(TAG, "FD handoff failed; recycling tun2socks without dropping TUN") + try { + process.destroy() + } catch (e: Exception) { + Log.w(TAG, "Failed to recycle tun2socks after FD handoff failure", e) + } + } + }.start() + } + + fun handleXrayCoreExit(config: XrayConfig) { + if (!isRunning || currentConfig !== config || recoveringXray) return + XrayCoreManager.markRuntimeConnecting(this, config) + recoveringXray = true + + Thread { + var attempt = 0 + try { + while (isRunning && currentConfig === config) { + attempt++ + val shift = (attempt - 1).coerceIn(0, 6) + val delayMs = (500L * (1L shl shift)).coerceAtMost(30_000L) + Log.w(TAG, "Recovering Xray core attempt $attempt in ${delayMs}ms") + Thread.sleep(delayMs) + if (!isRunning || currentConfig !== config) break + + if (XrayCoreManager.startCore(this, config)) { + Log.w(TAG, "Xray core recovered without dropping TUN") + if (currentProxyOnly || (mInterface != null && tun2socksProcess?.isAlive == true)) { + XrayCoreManager.markRuntimeReady(this, config) + } + return@Thread + } + } + } catch (_: InterruptedException) { + // Expected when stopping + } finally { + recoveringXray = false + } + }.start() + } + + private fun cleanup(clearDeadline: Boolean = true) { + isRunning = false + if (clearDeadline) { + clearSessionDeadline() + } else { + deadlineHandler.removeCallbacks(sessionDeadlineRunnable) + sessionDeadlineElapsed = null + sessionDeadlineToken = null + } + recoveringXray = false + recoveringTun2socks = false + tun2socksRecoveryAttempt = 0 + currentProxyOnly = false + AppConfigs.RUNTIME_READY = false + tun2socksProcess?.destroy() + tun2socksProcess = null + try { + mInterface?.close() + mInterface = null + } catch (e: Exception) { + Log.w(TAG, "Failed to close VPN interface during cleanup", e) + } + } + + private fun stopAll(confirmationToken: String = currentConfig?.RUNTIME_TOKEN.orEmpty()) { + if (shuttingDownIntentionally) return + shuttingDownIntentionally = true + cleanup(clearDeadline = true) + currentConfig = null + XrayCoreManager.stopCore(this, confirmationToken) + stopForeground(true) + stopSelf() + } + + private fun armSessionDeadline(config: XrayConfig, remainingSeconds: Long) { + val boundedSeconds = remainingSeconds + .coerceAtLeast(0L) + .coerceAtMost(Long.MAX_VALUE / 1000L) + val delayMs = boundedSeconds * 1000L + val nowEpochMs = System.currentTimeMillis() + val epochDeadline = if (Long.MAX_VALUE - nowEpochMs < delayMs) { + Long.MAX_VALUE + } else { + nowEpochMs + delayMs + } + + sessionDeadlineToken = config.RUNTIME_TOKEN + sessionDeadlineElapsed = SystemClock.elapsedRealtime() + delayMs + + if (!persistDeadline(config.RUNTIME_TOKEN, epochDeadline) || + !scheduleSessionExpiryAlarm(config.RUNTIME_TOKEN, epochDeadline) + ) { + Log.e(TAG, "Could not arm OS-level session expiry; stopping fail-closed") + stopAll(config.RUNTIME_TOKEN) + return + } + + deadlineHandler.removeCallbacks(sessionDeadlineRunnable) + if (delayMs == 0L) deadlineHandler.post(sessionDeadlineRunnable) + else deadlineHandler.postDelayed(sessionDeadlineRunnable, delayMs) + } + + private fun clearSessionDeadline() { + deadlineHandler.removeCallbacks(sessionDeadlineRunnable) + sessionDeadlineElapsed = null + sessionDeadlineToken = null + clearPersistedDeadline() + cancelSessionExpiryAlarm() + } + + private fun enforceSessionDeadline() { + val token = sessionDeadlineToken ?: return + val deadline = sessionDeadlineElapsed ?: return + val config = currentConfig ?: return + if (config.RUNTIME_TOKEN != token) return + val remaining = deadline - SystemClock.elapsedRealtime() + if (remaining > 0L) { + deadlineHandler.postDelayed(sessionDeadlineRunnable, remaining) + return + } + Log.w(TAG, "Native session deadline expired; stopping runtime fail-closed") + stopAll(token) + } + + private fun updateSessionDeadline(intent: Intent) { + val config = currentConfig ?: return + val token = intent.getStringExtra("RUNTIME_TOKEN").orEmpty() + if (token.isEmpty() || token != config.RUNTIME_TOKEN) { + Log.w(TAG, "Ignoring session deadline update for stale runtime generation") + return + } + val remainingSeconds = intent.getLongExtra("REMAINING_SECONDS", -1L) + if (remainingSeconds < 0L) { + Log.w(TAG, "Ignoring invalid negative session deadline") + return + } + armSessionDeadline(config, remainingSeconds) + } + + private fun persistDeadline(runtimeToken: String, expiresAtEpochMs: Long): Boolean { + return try { + getSharedPreferences(DEADLINE_PREFS, Context.MODE_PRIVATE) + .edit() + .putString(PREF_RUNTIME_TOKEN, runtimeToken) + .putLong(PREF_EXPIRES_AT_MS, expiresAtEpochMs) + .commit() + } catch (e: Exception) { + Log.e(TAG, "Failed to persist non-secret session deadline", e) + false + } + } + + private fun clearPersistedDeadline() { + try { + getSharedPreferences(DEADLINE_PREFS, Context.MODE_PRIVATE) + .edit() + .remove(PREF_RUNTIME_TOKEN) + .remove(PREF_EXPIRES_AT_MS) + .apply() + } catch (e: Exception) { + Log.w(TAG, "Failed to clear persisted session deadline", e) + } + } + + private fun enforcePersistedDeadline(alarmToken: String) { + val stored = try { + val prefs = getSharedPreferences(DEADLINE_PREFS, Context.MODE_PRIVATE) + prefs.getString(PREF_RUNTIME_TOKEN, null).orEmpty() to + prefs.getLong(PREF_EXPIRES_AT_MS, 0L) + } catch (e: Exception) { + Log.e(TAG, "Failed to read persisted session deadline", e) + clearPersistedDeadline() + cancelSessionExpiryAlarm() + stopSelf() + return + } + + val storedToken = stored.first + val storedDeadline = stored.second + if (alarmToken.isEmpty() || storedToken.isEmpty() || alarmToken != storedToken) { + Log.w(TAG, "Ignoring stale session-expiry alarm") + if (currentConfig == null) stopSelf() + return + } + + val remaining = storedDeadline - System.currentTimeMillis() + if (storedDeadline > 0L && remaining > 0L) { + if (!scheduleSessionExpiryAlarm(storedToken, storedDeadline)) { + Log.e(TAG, "Failed to re-arm early session-expiry alarm") + val activeToken = currentConfig?.RUNTIME_TOKEN.orEmpty() + if (activeToken == storedToken) stopAll(storedToken) else stopSelf() + return + } + // An alarm can wake a fresh service process after clock changes. + // If there is no live runtime in this process, do not leave a + // background service instance hanging around just to hold a timer. + if (currentConfig == null) stopSelf() + return + } + + Log.w(TAG, "OS session-expiry alarm fired; stopping runtime fail-closed") + val activeToken = currentConfig?.RUNTIME_TOKEN.orEmpty() + if (activeToken.isNotEmpty() && activeToken == storedToken) { + stopAll(storedToken) + } else { + clearPersistedDeadline() + cancelSessionExpiryAlarm() + stopForeground(true) + stopSelf() + } + } + + private fun scheduleSessionExpiryAlarm(runtimeToken: String, expiresAtEpochMs: Long): Boolean { + cancelSessionExpiryAlarm() + if (runtimeToken.isEmpty() || expiresAtEpochMs <= 0L) return false + + return try { + val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager + val pendingIntent = sessionExpiryPendingIntent(runtimeToken) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + try { + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + expiresAtEpochMs, + pendingIntent, + ) + } catch (_: SecurityException) { + Log.w(TAG, "Exact alarm unavailable; using inexact idle-capable expiry alarm") + alarmManager.setAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + expiresAtEpochMs, + pendingIntent, + ) + } + } else { + alarmManager.setExact(AlarmManager.RTC_WAKEUP, expiresAtEpochMs, pendingIntent) + } + true + } catch (e: Exception) { + Log.e(TAG, "Failed to schedule OS session-expiry alarm", e) + false + } + } + + private fun cancelSessionExpiryAlarm() { + try { + val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager + alarmManager.cancel(sessionExpiryPendingIntent("")) + } catch (e: Exception) { + Log.w(TAG, "Failed to cancel session-expiry alarm", e) + } + } + + private fun sessionExpiryPendingIntent(runtimeToken: String): PendingIntent { + val intent = Intent(this, XrayVPNService::class.java) + .putExtra(EXTRA_SESSION_EXPIRED, true) + .putExtra("RUNTIME_TOKEN", runtimeToken) + val flags = PendingIntent.FLAG_UPDATE_CURRENT or + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 + return PendingIntent.getService(this, SESSION_EXPIRY_REQUEST_CODE, intent, flags) + } + + override fun onDestroy() { + if (!shuttingDownIntentionally) { + val confirmationToken = currentConfig?.RUNTIME_TOKEN.orEmpty() + cleanup(clearDeadline = false) + currentConfig = null + XrayCoreManager.stopCore(this, confirmationToken) + stopForeground(true) + } + super.onDestroy() + } + + override fun onRevoke() { + stopAll() + super.onRevoke() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channelId = "REVOLT_VPN_SERVICE" + val channelName = "VPN Service" + val channel = android.app.NotificationChannel( + channelId, + channelName, + android.app.NotificationManager.IMPORTANCE_LOW + ) + val manager = getSystemService(android.app.NotificationManager::class.java) + manager?.createNotificationChannel(channel) + } + } + + private fun createNotification(content: String): android.app.Notification { + val channelId = "REVOLT_VPN_SERVICE" + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + android.app.Notification.Builder(this, channelId) + } else { + @Suppress("DEPRECATION") + android.app.Notification.Builder(this) + } + + val icon = android.R.drawable.ic_dialog_info + + val notification = builder + .setContentTitle("VPN Service") + .setContentText(content) + .setSmallIcon(icon) + .setOngoing(true) + .setAutoCancel(false) + .setOnlyAlertOnce(true) + .build() + + notification.flags = notification.flags or + android.app.Notification.FLAG_ONGOING_EVENT or + android.app.Notification.FLAG_NO_CLEAR + return notification + } + + companion object { + private const val TAG = "XrayVPNService" + private const val FOREGROUND_SERVICE_TYPE_SPECIAL_USE = 0x40000000 + private const val BOOTSTRAP_SESSION_SECONDS = 120L + private const val DEADLINE_PREFS = "revolt_session_deadline" + private const val PREF_RUNTIME_TOKEN = "runtime_token" + private const val PREF_EXPIRES_AT_MS = "expires_at_ms" + private const val EXTRA_SESSION_EXPIRED = "SESSION_EXPIRED" + private const val SESSION_EXPIRY_REQUEST_CODE = 1001 + } +} diff --git a/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/utils/AppConfigs.kt b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/utils/AppConfigs.kt new file mode 100644 index 0000000..04bbc0b --- /dev/null +++ b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/utils/AppConfigs.kt @@ -0,0 +1,49 @@ +package com.github.tfox.flutter_vless.xray.utils + +import com.github.tfox.flutter_vless.xray.dto.XrayConfig +import java.io.Serializable + +/** + * Global application configuration and state. + * + * This object holds: + * 1. Constants for Intent actions and Service commands. + * 2. Enums for connection states and modes. + * 3. Global state variables (current connection state, active config). + * 4. Static storage for notification icon resources (passed from Flutter). + */ +object AppConfigs { + const val V2RAY_CONNECTION_INFO = "com.github.tfox.flutter_vless.xray.V2RAY_CONNECTION_INFO" + + /** + * Commands sent to the XrayVPNService via Intent. + */ + enum class V2RAY_SERVICE_COMMANDS : Serializable { + START_SERVICE, STOP_SERVICE, RESTART_SERVICE, UPDATE_SESSION_DEADLINE + } + + /** + * Connection states for the Xray Core. + */ + enum class V2RAY_STATES : Serializable { + V2RAY_CONNECTED, V2RAY_DISCONNECTED, V2RAY_CONNECTING + } + + /** + * Connection modes: + * - VPN_TUN: Standard VPN mode using Android VpnService (TUN device). + * - PROXY_ONLY: Runs Xray as a local SOCKS/HTTP proxy without establishing a VPN tunnel. + */ + enum class V2RAY_CONNECTION_MODES : Serializable { + VPN_TUN, PROXY_ONLY + } + + var V2RAY_STATE: V2RAY_STATES = V2RAY_STATES.V2RAY_DISCONNECTED + @Volatile var RUNTIME_READY: Boolean = false + var V2RAY_CONFIG: XrayConfig? = null + var V2RAY_CONNECTION_MODE: V2RAY_CONNECTION_MODES = V2RAY_CONNECTION_MODES.VPN_TUN + + // Stores the resource name and type for the custom notification icon + var NOTIFICATION_ICON_RESOURCE_NAME: String = "" + var NOTIFICATION_ICON_RESOURCE_TYPE: String = "" +} diff --git a/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/utils/Utilities.kt b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/utils/Utilities.kt new file mode 100644 index 0000000..d2b9576 --- /dev/null +++ b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/utils/Utilities.kt @@ -0,0 +1,62 @@ +package com.github.tfox.flutter_vless.xray.utils + +import android.content.Context +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +object Utilities { + + fun getUserAssetsPath(context: Context): String { + val dir = context.filesDir + if (!dir.exists()) { + dir.mkdirs() + } + return dir.absolutePath + } + + fun copyAssets(context: Context) { + val assets = context.assets + val files = assets.list("") ?: return + for (filename in files) { + if (filename == "geoip.dat" || filename == "geosite.dat") { + var `in`: InputStream? = null + var out: OutputStream? = null + try { + `in` = assets.open(filename) + val outFile = File(getUserAssetsPath(context), filename) + out = FileOutputStream(outFile) + copyFile(`in`, out) + } catch (e: IOException) { + e.printStackTrace() + } finally { + if (`in` != null) { + try { + `in`.close() + } catch (e: IOException) { + e.printStackTrace() + } + } + if (out != null) { + try { + out.close() + } catch (e: IOException) { + e.printStackTrace() + } + } + } + } + } + } + + @Throws(IOException::class) + private fun copyFile(`in`: InputStream, out: OutputStream) { + val buffer = ByteArray(1024) + var read: Int + while (`in`.read(buffer).also { read = it } != -1) { + out.write(buffer, 0, read) + } + } +} diff --git a/local_packages/flutter_vless_android-1.1.5/android/src/test/kotlin/com/github/tfox/flutter_vless/xray/core/XrayCoreManagerTest.kt b/local_packages/flutter_vless_android-1.1.5/android/src/test/kotlin/com/github/tfox/flutter_vless/xray/core/XrayCoreManagerTest.kt new file mode 100644 index 0000000..3b340f6 --- /dev/null +++ b/local_packages/flutter_vless_android-1.1.5/android/src/test/kotlin/com/github/tfox/flutter_vless/xray/core/XrayCoreManagerTest.kt @@ -0,0 +1,178 @@ +package com.github.tfox.flutter_vless.xray.core + +import com.github.tfox.flutter_vless.xray.dto.XrayConfig +import java.io.File +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +class XrayCoreManagerTest { + @Test + fun buildRuntimeConfigJson_keepsSecureSocksAndSanitizesLogs() { + val filesDir = File("build/test-files/runtime-secure") + val config = XrayConfig( + V2RAY_FULL_JSON_CONFIG = runtimeConfig( + extraLog = """ + "log": { + "access": "/tmp/access.log", + "error": "/tmp/error.log" + }, + """.trimIndent(), + ) + ) + + val output = XrayCoreManager.buildRuntimeConfigJson(config, filesDir) + val log = output.getJSONObject("log") + val inbound = output.getJSONArray("inbounds").getJSONObject(0) + val user = output + .getJSONArray("outbounds") + .getJSONObject(0) + .getJSONObject("settings") + .getJSONArray("vnext") + .getJSONObject(0) + .getJSONArray("users") + .getJSONObject(0) + + assertFalse(log.has("access")) + assertEquals(File(filesDir, "error.log").absolutePath, log.getString("error")) + assertEquals("revolt-secure-socks", inbound.getString("tag")) + assertEquals("password", inbound.getJSONObject("settings").getString("auth")) + assertEquals(19080, config.LOCAL_SOCKS5_PORT) + assertEquals(0, config.LOCAL_HTTP_PORT) + assertEquals(vlessEncryption, user.getString("encryption")) + assertEquals("xtls-rprx-vision", user.getString("flow")) + } + + @Test + fun buildRuntimeConfigJson_rejectsInvalidSecureSocks() { + val config = XrayConfig( + V2RAY_FULL_JSON_CONFIG = """ + { + "inbounds": [ + { + "tag": "revolt-secure-socks", + "listen": "127.0.0.1", + "port": 19080, + "protocol": "socks", + "settings": { "auth": "noauth", "users": [] } + } + ], + "outbounds": [] + } + """.trimIndent() + ) + + try { + XrayCoreManager.buildRuntimeConfigJson( + config, + File("build/test-files/runtime-invalid-socks"), + ) + fail("Invalid secure SOCKS5 configuration must fail closed") + } catch (expected: IllegalStateException) { + assertTrue(expected.message.orEmpty().contains("Secure SOCKS5")) + } + } + + @Test + fun buildRuntimeConfigJson_normalizesXrayRuntimeAliases() { + val config = XrayConfig( + V2RAY_FULL_JSON_CONFIG = """ + { + "inbounds": [ + { + "tag": "revolt-secure-socks", + "listen": "127.0.0.1", + "port": 19080, + "protocol": "socks", + "settings": { + "auth": "password", + "users": [{"user": "runtime-user", "pass": "runtime-pass"}] + } + } + ], + "outbounds": [ + { + "protocol": "vless", + "settings": { + "vnext": [ + { + "address": "example.com", + "port": 443, + "users": [{"id": "11111111-1111-4111-8111-111111111111", "encryption": "none"}] + } + ] + }, + "streamSettings": { + "network": "XHTTP", + "security": "tls", + "tlsSettings": {"allowInsecure": false, "serverName": "example.com"}, + "xHTTPSettings": {"path": "/", "mode": "auto"}, + "httpUpgradeSettings": {"path": "/upgrade"}, + "splitHTTPSettings": {"path": "/split"} + } + } + ] + } + """.trimIndent() + ) + + val output = XrayCoreManager.buildRuntimeConfigJson( + config, + File("build/test-files/runtime-aliases"), + ) + val stream = output + .getJSONArray("outbounds") + .getJSONObject(0) + .getJSONObject("streamSettings") + + assertEquals("xhttp", stream.getString("network")) + assertTrue(stream.has("xhttpSettings")) + assertTrue(stream.has("httpupgradeSettings")) + assertTrue(stream.has("splithttpSettings")) + assertFalse(stream.has("xHTTPSettings")) + assertFalse(stream.has("httpUpgradeSettings")) + assertFalse(stream.has("splitHTTPSettings")) + assertFalse(stream.getJSONObject("tlsSettings").has("allowInsecure")) + } + + private fun runtimeConfig(extraLog: String = ""): String = """ + { + $extraLog + "inbounds": [ + { + "tag": "revolt-secure-socks", + "listen": "127.0.0.1", + "port": 19080, + "protocol": "socks", + "settings": { + "auth": "password", + "users": [{"user": "runtime-user", "pass": "runtime-pass"}] + } + } + ], + "outbounds": [ + { + "tag": "proxy", + "protocol": "vless", + "settings": { + "address": "xhttp.example.com", + "port": 443, + "id": "b94da146-a56e-49d7-af4c-a68c9065cbfd", + "encryption": "$vlessEncryption", + "flow": "xtls-rprx-vision", + "level": 8 + }, + "streamSettings": {"network": "xhttp", "security": "none"} + } + ] + } + """.trimIndent() + + private companion object { + const val vlessEncryption = + "mlkem768x25519plus.native.1rtt.100-500-2000.75-0-100.80-0-5000.gtmOXB2AN_r905czmOIr6dKq_YDdEJB8RWGqfsXurns" + } +} diff --git a/local_packages/flutter_vless_android-1.1.5/lib/flutter_vless_android.dart b/local_packages/flutter_vless_android-1.1.5/lib/flutter_vless_android.dart new file mode 100644 index 0000000..8e30737 --- /dev/null +++ b/local_packages/flutter_vless_android-1.1.5/lib/flutter_vless_android.dart @@ -0,0 +1,13 @@ +// Copyright (c) 2024-2026 13FOX Studio / tfox.dev. +// SPDX-License-Identifier: MIT + +import 'package:flutter_vless_platform_interface/flutter_vless_platform_interface.dart'; + +/// Android implementation of [VlessPlatform] using MethodChannel. +class FlutterVlessAndroid extends VlessMethodChannelAdapter { + /// Registers this class as the platform implementation. + /// This is called automatically by Flutter's plugin registry. + static void registerWith() { + VlessPlatform.instance = FlutterVlessAndroid(); + } +} diff --git a/local_packages/flutter_vless_android-1.1.5/pubspec.yaml b/local_packages/flutter_vless_android-1.1.5/pubspec.yaml new file mode 100644 index 0000000..abd0c63 --- /dev/null +++ b/local_packages/flutter_vless_android-1.1.5/pubspec.yaml @@ -0,0 +1,31 @@ +name: flutter_vless_android +description: Android implementation of the flutter_vless plugin. +version: 1.1.5 +homepage: https://tfox.dev +repository: https://github.com/XIIIFOX/flutter_vless +issue_tracker: https://github.com/XIIIFOX/flutter_vless/issues +documentation: https://github.com/XIIIFOX/flutter_vless/blob/main/doc/platform/android.md +funding: + - https://tfox.dev + +environment: + sdk: '>=3.4.0 <4.0.0' + flutter: '>=3.3.0' + +dependencies: + flutter: + sdk: flutter + flutter_vless_platform_interface: ^1.1.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + plugin: + implements: flutter_vless_platform_interface + platforms: + android: + package: com.github.tfox.flutter_vless + pluginClass: FlutterVlessPlugin diff --git a/pubspec.lock b/pubspec.lock index 1369a33..2af4fa8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -65,14 +65,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" - url: "https://pub.dev" - source: hosted - version: "1.0.9" fake_async: dependency: transitive description: @@ -180,12 +172,11 @@ packages: source: hosted version: "1.1.5" flutter_vless_android: - dependency: transitive + dependency: "direct overridden" description: - name: flutter_vless_android - sha256: "5e6fe73d3606ed8c73e87fac15efa7631e12ce18d39a4bd4e13b6c097c6d52b9" - url: "https://pub.dev" - source: hosted + path: "local_packages/flutter_vless_android-1.1.5" + relative: true + source: path version: "1.1.5" flutter_vless_macos: dependency: transitive @@ -216,14 +207,6 @@ packages: description: flutter source: sdk version: "0.0.0" - google_fonts: - dependency: "direct main" - description: - name: google_fonts - sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 - url: "https://pub.dev" - source: hosted - version: "6.3.3" google_mobile_ads: dependency: "direct main" description: @@ -316,10 +299,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -332,10 +315,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" nested: dependency: transitive description: @@ -385,7 +368,7 @@ packages: source: hosted version: "1.9.1" path_provider: - dependency: "direct main" + dependency: transitive description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" @@ -577,10 +560,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" typed_data: dependency: transitive description: @@ -665,10 +648,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" vm_service: dependency: transitive description: @@ -742,5 +725,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.3 <4.0.0" + dart: ">=3.11.0-0 <4.0.0" flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index 4308f0d..0c60cc9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,20 +1,8 @@ name: revoltvpn description: ReVolt VPN — Secure VLESS tunnel for Android -# The following line prevents the package from being accidentally published to -# pub.dev using `pub publish`. This is preferred for private packages. -publish_to: "none" # Remove this line if you wish to publish to pub.dev +publish_to: "none" -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# 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.4+30 environment: @@ -24,10 +12,6 @@ dependencies: flutter: sdk: flutter - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.5 - google_fonts: ^6.1.0 provider: ^6.1.1 google_mobile_ads: ^4.0.0 flutter_vless: 1.1.5 @@ -36,7 +20,6 @@ dependencies: url_launcher: ^6.3.2 flutter_secure_storage: ^10.3.1 uuid: ^4.5.3 - path_provider: ^2.1.5 package_info_plus: ^6.0.0 dev_dependencies: @@ -44,42 +27,13 @@ dev_dependencies: sdk: flutter flutter_lints: ^3.0.0 -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter. flutter: - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. uses-material-design: true - - # To add assets to your application, add an assets section, like this: assets: - - assets/ + - assets/background.png + - assets/brand.png + - assets/finland_flag_256.png - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware. - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/assets-and-images/#from-packages - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/custom-fonts/#from-packages +dependency_overrides: + flutter_vless_android: + path: local_packages/flutter_vless_android-1.1.5 diff --git a/tool/patch_flutter_vless_runtime_recovery.dart b/tool/patch_flutter_vless_runtime_recovery.dart deleted file mode 100644 index cdc676d..0000000 --- a/tool/patch_flutter_vless_runtime_recovery.dart +++ /dev/null @@ -1,119 +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 recovery patch] $message'); - exit(1); -} - -Directory packageRoot() { - final configFile = File('.dart_tool/package_config.json'); - if (!configFile.existsSync()) { - fail('.dart_tool/package_config.json is missing; run flutter pub get first'); - } - - final decoded = jsonDecode(configFile.readAsStringSync()); - if (decoded is! Map || decoded['packages'] is! List) { - fail('package_config.json is invalid'); - } - - Map? package; - for (final item in decoded['packages'] as List) { - if (item is Map && item['name'] == packageName) { - package = Map.from(item); - break; - } - } - if (package == null) fail('$packageName was not found'); - - final rootUriValue = package['rootUri']; - if (rootUriValue is! String || rootUriValue.isEmpty) { - fail('$packageName has no rootUri'); - } - - final rootUri = Uri.parse(rootUriValue); - final resolved = rootUri.hasScheme - ? rootUri - : configFile.parent.absolute.uri.resolveUri(rootUri); - if (resolved.scheme != 'file') fail('unsupported package URI: $resolved'); - - final root = Directory.fromUri(resolved); - final normalized = root.absolute.path.replaceAll('\\', '/'); - if (!normalized.contains(expectedVersionFragment)) { - fail('expected flutter_vless_android 1.1.5, got ${root.path}'); - } - if (!root.existsSync()) fail('package root does not exist: ${root.path}'); - return root; -} - -void replaceOnce( - File file, - String oldValue, - String newValue, - String marker, -) { - if (!file.existsSync()) fail('missing upstream source: ${file.path}'); - final text = file.readAsStringSync(); - if (text.contains(marker)) { - stdout.writeln('[flutter_vless recovery patch] already current: ${file.path}'); - return; - } - if (!text.contains(oldValue)) { - fail('upstream source changed; expected block not found in ${file.path}'); - } - file.writeAsStringSync(text.replaceFirst(oldValue, newValue)); - stdout.writeln('[flutter_vless recovery patch] patched: ${file.path}'); -} - -void main() { - final root = packageRoot(); - final kotlin = Directory( - '${root.path}/android/src/main/kotlin/com/github/tfox/flutter_vless', - ); - - final serviceFile = File('${kotlin.path}/xray/service/XrayVPNService.kt'); - if (!serviceFile.existsSync()) { - fail('pinned Android runtime service source is missing'); - } - - // A fast reconnect must not leave the previous Xray process owning the local - // SOCKS/HTTP ports. - replaceOnce( - serviceFile, - ''' if (config != null) { - // Ensure clean state before starting - cleanup() -''', - ''' if (config != null) { - // A fast reconnect must not leave the previous Xray process - // owning the local SOCKS/HTTP ports. - if (XrayCoreManager.isXrayRunning()) { - Log.w(TAG, "Stopping stale Xray core before restart") - XrayCoreManager.stopCore(this) - } - cleanup() -''', - 'Stopping stale Xray core before restart', - ); - - // The tun2socks Unix socket must not survive a previous session. - replaceOnce( - serviceFile, - ''' val tun2socksPath = File(applicationInfo.nativeLibraryDir, "libtun2socks.so").absolutePath - val sockPath = File(filesDir, "sock_path").absolutePath -''', - ''' val tun2socksPath = File(applicationInfo.nativeLibraryDir, "libtun2socks.so").absolutePath - val socketFile = File(filesDir, "sock_path") - if (socketFile.exists() && !socketFile.delete()) { - Log.w(TAG, "Could not delete stale tun2socks socket") - } - val sockPath = socketFile.absolutePath -''', - 'Could not delete stale tun2socks socket', - ); - - stdout.writeln('[flutter_vless recovery patch] runtime recovery patch is ready'); -} diff --git a/tool/patch_flutter_vless_secure_socks_scope_fix.dart b/tool/patch_flutter_vless_secure_socks_scope_fix.dart deleted file mode 100644 index c157c5a..0000000 --- a/tool/patch_flutter_vless_secure_socks_scope_fix.dart +++ /dev/null @@ -1,154 +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('[secure-socks scope fix] $message'); - exit(1); -} - -Directory packageRoot() { - final configFile = File('.dart_tool/package_config.json'); - if (!configFile.existsSync()) { - fail('.dart_tool/package_config.json is missing; run flutter pub get first'); - } - - final decoded = jsonDecode(configFile.readAsStringSync()); - if (decoded is! Map || decoded['packages'] is! List) { - fail('package_config.json is invalid'); - } - - Map? package; - for (final item in decoded['packages'] as List) { - if (item is Map && item['name'] == packageName) { - package = Map.from(item); - break; - } - } - if (package == null) fail('$packageName was not found'); - - final rootUriValue = package['rootUri']; - if (rootUriValue is! String || rootUriValue.isEmpty) { - fail('$packageName has no rootUri'); - } - - final rootUri = Uri.parse(rootUriValue); - final resolved = rootUri.hasScheme - ? rootUri - : configFile.parent.absolute.uri.resolveUri(rootUri); - if (resolved.scheme != 'file') fail('unsupported package URI: $resolved'); - - final root = Directory.fromUri(resolved); - final normalized = root.absolute.path.replaceAll('\\', '/'); - if (!normalized.contains(expectedVersionFragment)) { - fail('expected flutter_vless_android 1.1.5, got ${root.path}'); - } - return root; -} - -void patchCoreScope(File core) { - final before = core.readAsStringSync(); - - // The primary secure patcher used a file-wide check for - // `val process = pb.start()`. Stock 1.1.5 already contains that exact line - // in getV2rayTraffic(), so the startCore() capture could be skipped while - // its monitor was still rewritten to use `process`. Capture the active Xray - // process immediately before its monitor thread instead. This is deliberately - // a scope-only correction and does not alter the stable runtime sequence. - const directCapture = - ' val process = pb.start()\n xrayProcess = process\n'; - const scopedCapture = - ' val process = xrayProcess ?: return false\n // Monitor process in a separate thread to detect crash\n'; - const monitorMarker = - ' // Monitor process in a separate thread to detect crash\n'; - - if (before.contains(directCapture) || before.contains(scopedCapture)) { - stdout.writeln('[secure-socks scope fix] core already current: ${core.path}'); - return; - } - - if (!before.contains('process.inputStream.bufferedReader().use { reader ->') || - !before.contains('val exitCode = process.waitFor()') || - !before.contains('xrayProcess === process')) { - fail('secure Xray monitor patch is not in the expected state'); - } - if (!before.contains(' xrayProcess = pb.start()\n')) { - fail('stock Xray start assignment is missing'); - } - if (!before.contains(monitorMarker)) { - fail('Xray monitor insertion point is missing'); - } - - final after = before.replaceFirst(monitorMarker, scopedCapture); - core.writeAsStringSync(after); - stdout.writeln('[secure-socks scope fix] captured Xray process for monitor scope'); -} - -void patchOngoingNotification(File service) { - final before = service.readAsStringSync(); - - const marker = '.setOngoing(true)'; - if (before.contains(marker) && - before.contains('Notification.FLAG_NO_CLEAR') && - before.contains('.setAutoCancel(false)')) { - stdout.writeln('[secure-socks scope fix] notification already ongoing: ${service.path}'); - return; - } - - const oldBlock = r''' return builder - .setContentTitle("VPN Service") - .setContentText(content) - .setSmallIcon(icon) - .build() -'''; - - const newBlock = r''' val notification = builder - .setContentTitle("VPN Service") - .setContentText(content) - .setSmallIcon(icon) - .setOngoing(true) - .setAutoCancel(false) - .setOnlyAlertOnce(true) - .build() - - // Keep the active VPN notification attached to the foreground service - // and resistant to bulk clearing on Android versions that honor these - // flags. Android 14+ may still allow an explicit user swipe while the - // device is unlocked; apps are not allowed to override that system UI. - notification.flags = notification.flags or - android.app.Notification.FLAG_ONGOING_EVENT or - android.app.Notification.FLAG_NO_CLEAR - return notification -'''; - - if (!before.contains(oldBlock)) { - fail('VPN notification builder is not in the expected stock 1.1.5 state'); - } - - final after = before.replaceFirst(oldBlock, newBlock); - if (!after.contains(marker) || - !after.contains('Notification.FLAG_NO_CLEAR') || - !after.contains('.setAutoCancel(false)')) { - fail('ongoing VPN notification invariant failed'); - } - - service.writeAsStringSync(after); - stdout.writeln('[secure-socks scope fix] VPN notification marked ongoing/no-clear'); -} - -void main() { - final root = packageRoot(); - final kotlin = - '${root.path}/android/src/main/kotlin/com/github/tfox/flutter_vless'; - final core = File('$kotlin/xray/core/XrayCoreManager.kt'); - final service = File('$kotlin/xray/service/XrayVPNService.kt'); - - if (!core.existsSync()) fail('XrayCoreManager.kt is missing'); - if (!service.existsSync()) fail('XrayVPNService.kt is missing'); - - patchCoreScope(core); - patchOngoingNotification(service); - stdout.writeln('[secure-socks scope fix] scope and notification patch ready'); -} diff --git a/tool/patch_flutter_vless_secure_socks_v2.dart b/tool/patch_flutter_vless_secure_socks_v2.dart deleted file mode 100644 index c68deb4..0000000 --- a/tool/patch_flutter_vless_secure_socks_v2.dart +++ /dev/null @@ -1,746 +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('[secure-socks patch] $message'); - exit(1); -} - -Directory packageRoot() { - final configFile = File('.dart_tool/package_config.json'); - if (!configFile.existsSync()) { - fail('.dart_tool/package_config.json is missing; run flutter pub get first'); - } - final decoded = jsonDecode(configFile.readAsStringSync()); - if (decoded is! Map || decoded['packages'] is! List) { - fail('package_config.json is invalid'); - } - - Map? package; - for (final item in decoded['packages'] as List) { - if (item is Map && item['name'] == packageName) { - package = Map.from(item); - break; - } - } - if (package == null) fail('$packageName was not found'); - - final rootUriValue = package['rootUri']; - if (rootUriValue is! String || rootUriValue.isEmpty) { - fail('$packageName has no rootUri'); - } - final rootUri = Uri.parse(rootUriValue); - final resolved = rootUri.hasScheme - ? rootUri - : configFile.parent.absolute.uri.resolveUri(rootUri); - if (resolved.scheme != 'file') fail('unsupported package URI: $resolved'); - - final root = Directory.fromUri(resolved); - final normalized = root.absolute.path.replaceAll('\\', '/'); - if (!normalized.contains(expectedVersionFragment)) { - fail('expected flutter_vless_android 1.1.5, got ${root.path}'); - } - return root; -} - -String replaceOnce( - String text, - String oldValue, - String newValue, - String label, -) { - if (text.contains(newValue)) return text; - if (!text.contains(oldValue)) fail('missing expected source block: $label'); - return text.replaceFirst(oldValue, newValue); -} - -void writeIfChanged(File file, String before, String after) { - if (before == after) { - stdout.writeln('[secure-socks patch] already current: ${file.path}'); - return; - } - file.writeAsStringSync(after); - stdout.writeln('[secure-socks patch] patched: ${file.path}'); -} - -void patchCore(File file) { - final before = file.readAsStringSync(); - var text = before; - - const secureValidation = r''' var secureSocksEnabled = false - for (i in 0 until inbounds.length()) { - val inbound = inbounds.optJSONObject(i) ?: continue - if (inbound.optString("tag") != "revolt-secure-socks") continue - - val settings = inbound.optJSONObject("settings") - ?: throw IllegalStateException("Secure SOCKS5 settings are missing") - val users = settings.optJSONArray("users") - ?: throw IllegalStateException("Secure SOCKS5 users are missing") - val account = users.optJSONObject(0) - ?: throw IllegalStateException("Secure SOCKS5 account is missing") - val port = inbound.optInt("port", -1) - - if (inbound.optString("protocol") != "socks" || - inbound.optString("listen") != "127.0.0.1" || - port <= 1024 || port > 65535 || - settings.optString("auth") != "password" || - account.optString("user").isEmpty() || - account.optString("pass").isEmpty() - ) { - throw IllegalStateException("Secure SOCKS5 session is invalid") - } - - config.LOCAL_SOCKS5_PORT = port - config.LOCAL_HTTP_PORT = 0 - secureSocksEnabled = true - break - } - -'''; - - if (!text.contains('var secureSocksEnabled = false')) { - text = replaceOnce( - text, - ' val usedPorts = mutableSetOf()\n', - '$secureValidation val usedPorts = mutableSetOf()\n', - 'secure SOCKS validation insertion', - ); - } - - text = replaceOnce( - text, - ' if (!hasHttpOnLocalPort) {\n', - ' if (!secureSocksEnabled && !hasHttpOnLocalPort) {\n', - 'disable unauthenticated HTTP proxy in secure mode', - ); - - if (!text.contains('val process = pb.start()')) { - text = replaceOnce( - text, - ' xrayProcess = pb.start()\n', - ' val process = pb.start()\n xrayProcess = process\n', - 'capture Xray process identity', - ); - text = replaceOnce( - text, - ' if (xrayProcess?.isAlive != true) {\n', - ' if (!process.isAlive) {\n', - 'Xray startup liveness', - ); - text = replaceOnce( - text, - ' val output = xrayProcess?.inputStream?.bufferedReader()?.readText().orEmpty()\n', - ' val output = process.inputStream.bufferedReader().readText()\n', - 'Xray startup output', - ); - } - - if (!text.contains('Could not delete ephemeral Xray config')) { - const connectedLine = - ' AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTED\n'; - const deleteConfig = r''' // config.json contains per-session SOCKS credentials. Xray has - // consumed it after the startup liveness check, so remove it now. - try { - val ephemeralConfig = File(configFilesDir, "config.json") - if (ephemeralConfig.exists() && !ephemeralConfig.delete()) { - Log.w(TAG, "Could not delete ephemeral Xray config") - } - } catch (e: Exception) { - Log.w(TAG, "Could not delete ephemeral Xray config", e) - } - -'''; - text = replaceOnce( - text, - connectedLine, - '$deleteConfig$connectedLine', - 'ephemeral config deletion', - ); - } - - if (!text.contains('context.handleXrayCoreExit(config)')) { - text = replaceOnce( - text, - ' xrayProcess?.inputStream?.bufferedReader()?.use { reader ->\n', - ' process.inputStream.bufferedReader().use { reader ->\n', - 'Xray monitor process stream', - ); - text = replaceOnce( - text, - ' val exitCode = xrayProcess?.waitFor()\n', - ' val exitCode = process.waitFor()\n', - 'Xray monitor process wait', - ); - - const oldExit = r''' if (AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTED) { - // Unexpected exit - stopCore(context) - } -'''; - const newExit = r''' if (xrayProcess === process && - AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTED - ) { - // Hold the Android TUN while the core is recovered. Killing - // the VPN service here would allow direct-network fallback. - xrayProcess = null - AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTING - if (context is XrayVPNService) { - context.handleXrayCoreExit(config) - } else { - stopCore(context) - } - } -'''; - text = replaceOnce(text, oldExit, newExit, 'Xray fail-closed recovery'); - } - - writeIfChanged(file, before, text); -} - -void patchService(File file) { - final before = file.readAsStringSync(); - var text = before; - - if (!text.contains('private var recoveringXray = false')) { - text = replaceOnce( - text, - ' private var isRunning = false\n', - ' @Volatile private var isRunning = false\n' - ' @Volatile private var recoveringXray = false\n' - ' private var currentConfig: XrayConfig? = null\n', - 'service recovery fields', - ); - } - - 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, - ' cleanup()\n', - ' cleanup()\n currentConfig = config\n', - 'runtime config snapshot', - ); - } - - text = replaceOnce( - text, - ' 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, - val username: String, - val password: String, - ) - - private fun secureSocksCredentials(config: XrayConfig): SecureSocksCredentials? { - val inbounds = try { - JSONObject(config.V2RAY_FULL_JSON_CONFIG).optJSONArray("inbounds") - } catch (_: Exception) { - null - } ?: return null - - for (i in 0 until inbounds.length()) { - val inbound = inbounds.optJSONObject(i) ?: continue - if (inbound.optString("tag") != "revolt-secure-socks") continue - if (inbound.optString("protocol") != "socks" || - inbound.optString("listen") != "127.0.0.1" - ) return null - - val settings = inbound.optJSONObject("settings") ?: return null - if (settings.optString("auth") != "password") return null - val user = settings.optJSONArray("users")?.optJSONObject(0) ?: return null - val port = inbound.optInt("port", -1) - val username = user.optString("user") - val password = user.optString("pass") - if (port <= 1024 || port > 65535 || username.isEmpty() || password.isEmpty()) { - return null - } - return SecureSocksCredentials(port, username, password) - } - return null - } - -'''; - const runComment = r''' /** - * Starts the tun2socks process and initiates the FD transfer. - */ - private fun runTun2socks(config: XrayConfig) { -'''; - text = replaceOnce( - text, - runComment, - '$credentialsHelper$runComment', - 'secure credentials helper', - ); - } - - if (!text.contains('Authenticated ReVolt SOCKS5 inbound missing')) { - const commandComment = r''' // Command to start tun2socks. - // Note: We pass -sock-path to tell it where to listen for the FD. -'''; - const securePrelude = r''' val secure = secureSocksCredentials(config) - ?: throw IllegalStateException("Authenticated ReVolt SOCKS5 inbound missing") - config.LOCAL_SOCKS5_PORT = secure.port - -'''; - text = replaceOnce( - text, - commandComment, - '$securePrelude$commandComment', - 'secure tun2socks credentials', - ); - text = replaceOnce( - text, - r''' "-proxy", "socks5://127.0.0.1:${config.LOCAL_SOCKS5_PORT}", -''', - r''' "-proxy", "socks5://${secure.username}:${secure.password}@127.0.0.1:${secure.port}", -''', - 'authenticated tun2socks URL', - ); - text = replaceOnce( - text, - r''' Log.d(TAG, "tun2socks command: ${cmd.joinToString(" ")}") -''', - ' Log.d(TAG, "Starting tun2socks with authenticated loopback SOCKS5")\n', - 'credential-safe logging', - ); - } - - if (!text.contains('val process = pb.start()\n tun2socksProcess = process')) { - text = replaceOnce( - text, - ' tun2socksProcess = pb.start()\n', - ' val process = pb.start()\n tun2socksProcess = process\n', - 'capture tun2socks process identity', - ); - text = replaceOnce( - text, - ' tun2socksProcess?.inputStream?.bufferedReader()?.use { reader ->\n', - ' process.inputStream.bufferedReader().use { reader ->\n', - 'tun2socks monitor stream', - ); - text = replaceOnce( - text, - ' tun2socksProcess?.waitFor()\n', - ' process.waitFor()\n', - 'tun2socks monitor wait', - ); - - const oldRestart = r''' if (isRunning) { - // Restart if crashed and still supposed to be running - Log.e(TAG, "tun2socks exited unexpectedly, restarting...") - runTun2socks(config) - } -'''; - const newRestart = r''' if (isRunning && tun2socksProcess === process && currentConfig === config) { - Log.e(TAG, "tun2socks exited unexpectedly, recycling without dropping TUN") - Thread.sleep(350) - if (isRunning && currentConfig === config) runTun2socks(config) - } -'''; - text = replaceOnce( - text, - oldRestart, - newRestart, - 'tun2socks crash recovery', - ); - text = replaceOnce( - text, - ' sendFd()\n', - ' sendFd(process)\n', - 'FD handoff process identity', - ); - } - - const oldStartFailure = r''' } catch (e: Exception) { - Log.e(TAG, "Failed to start tun2socks", e) - stopAll() - } -'''; - const newStartFailure = r''' } catch (e: Exception) { - Log.e(TAG, "Failed to start tun2socks; retrying with TUN held", e) - if (isRunning && currentConfig === config) { - Thread { - try { - Thread.sleep(600) - if (isRunning && currentConfig === config) runTun2socks(config) - } catch (_: InterruptedException) {} - }.start() - } - } -'''; - text = replaceOnce( - text, - oldStartFailure, - newStartFailure, - 'tun2socks start failure recovery', - ); - - if (!text.contains('FD handoff failed; recycling tun2socks without dropping TUN')) { - const oldSendFd = r''' private fun sendFd() { - val fd = mInterface?.fileDescriptor ?: return - val sockFile = File(filesDir, "sock_path").absolutePath - - Thread { - var tries = 0 - while (tries < 10) { - try { - Thread.sleep(500) - val localSocket = LocalSocket() - localSocket.connect(LocalSocketAddress(sockFile, LocalSocketAddress.Namespace.FILESYSTEM)) - val out = localSocket.outputStream - // This magic call attaches the FD to the socket message - localSocket.setFileDescriptorsForSend(arrayOf(fd)) - out.write(32) // Send a dummy byte to trigger the transfer - localSocket.setFileDescriptorsForSend(null) - localSocket.shutdownOutput() - localSocket.close() - break - } catch (e: Exception) { - tries++ - } - } - }.start() - } -'''; - const newSendFd = r''' private fun sendFd(process: Process) { - val fd = mInterface?.fileDescriptor ?: return - val sockFile = File(filesDir, "sock_path").absolutePath - - Thread { - var tries = 0 - while (tries < 10 && isRunning && tun2socksProcess === process && process.isAlive) { - var localSocket: LocalSocket? = null - try { - Thread.sleep(500) - localSocket = LocalSocket() - localSocket.connect(LocalSocketAddress(sockFile, LocalSocketAddress.Namespace.FILESYSTEM)) - val out = localSocket.outputStream - localSocket.setFileDescriptorsForSend(arrayOf(fd)) - out.write(32) - out.flush() - localSocket.setFileDescriptorsForSend(null) - localSocket.shutdownOutput() - localSocket.close() - return@Thread - } catch (_: Exception) { - tries++ - try { localSocket?.close() } catch (_: Exception) {} - } - } - - if (isRunning && tun2socksProcess === process) { - Log.e(TAG, "FD handoff failed; recycling tun2socks without dropping TUN") - try { process.destroy() } catch (_: Exception) {} - } - }.start() - } -'''; - text = replaceOnce(text, oldSendFd, newSendFd, 'bounded FD recovery'); - } - - if (!text.contains('fun handleXrayCoreExit(config: XrayConfig)')) { - const cleanupComment = r''' /** - * Cleans up resources (tun2socks process, VPN interface) without stopping the service completely. - * Used when restarting or switching configurations. - */ -'''; - const recovery = r''' fun handleXrayCoreExit(config: XrayConfig) { - if (!isRunning || currentConfig !== config || recoveringXray) return - recoveringXray = true - - Thread { - var attempt = 0 - try { - while (isRunning && currentConfig === config) { - attempt++ - Thread.sleep(400L * attempt.coerceAtMost(5)) - if (!isRunning || currentConfig !== config) break - - Log.w(TAG, "Recovering Xray core attempt $attempt") - if (XrayCoreManager.startCore(this, config)) { - Log.w(TAG, "Xray core recovered without dropping TUN") - return@Thread - } - - if (attempt >= 3) { - attempt = 0 - Thread.sleep(3000) - } - } - } catch (_: InterruptedException) { - } finally { - recoveringXray = false - } - }.start() - } - -'''; - text = replaceOnce( - text, - cleanupComment, - '$recovery$cleanupComment', - 'Xray recovery loop', - ); - } - - if (!text.contains(' cancelExpiryAlarm()\n tun2socksProcess?.destroy()')) { - text = replaceOnce( - text, - ' isRunning = false\n tun2socksProcess?.destroy()\n', - ' isRunning = false\n recoveringXray = false\n expiryHandler.removeCallbacks(expiryRunnable)\n cancelExpiryAlarm()\n tun2socksProcess?.destroy()\n', - 'cancel recovery and expiry on cleanup', - ); - } - - if (!text.contains(' currentConfig = null\n XrayCoreManager.stopCore(this)')) { - text = replaceOnce( - text, - ' cleanup()\n XrayCoreManager.stopCore(this)\n', - ' cleanup()\n currentConfig = null\n XrayCoreManager.stopCore(this)\n', - 'clear config on explicit stop', - ); - } - - writeIfChanged(file, before, text); -} - -void main() { - final root = packageRoot(); - final kotlin = Directory( - '${root.path}/android/src/main/kotlin/com/github/tfox/flutter_vless', - ); - final core = File('${kotlin.path}/xray/core/XrayCoreManager.kt'); - final service = File('${kotlin.path}/xray/service/XrayVPNService.kt'); - if (!core.existsSync() || !service.existsSync()) { - fail('pinned Android runtime sources are missing'); - } - - patchCore(core); - patchService(service); - stdout.writeln('[secure-socks patch] stable-core security patch is ready'); -}