diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4543fde..c4b7afa 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,4 +1,5 @@ import java.util.Properties +import java.io.File import java.io.FileInputStream plugins { @@ -6,6 +7,128 @@ 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 flutterVlessPatchScript = File( + projectRootDir, + "tool/patch_flutter_vless_allowed_apps.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 patchFlutterVlessAllowedApps = tasks.register("patchFlutterVlessAllowedApps") { + group = "build setup" + description = "Patch pinned flutter_vless_android 1.1.5 with ReVolt routing/runtime hardening" + workingDir(projectRootDir) + + doFirst { + if (!dartExecutable.isFile) { + throw GradleException("Flutter Dart executable not found: ${dartExecutable.absolutePath}") + } + if (!flutterVlessPatchScript.isFile) { + throw GradleException("flutter_vless patch script missing: ${flutterVlessPatchScript.absolutePath}") + } + if (!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", + flutterVlessPatchScript.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(patchFlutterVlessAllowedApps) + + 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 e4758ae..d7ce2b1 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + @@ -17,6 +18,11 @@ + + + + + + if (call.method == "impact") { + val kind = call.argument("kind") ?: "tap" + result.success(performHaptic(kind)) + } else { + result.notImplemented() + } + } + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, appsChannel) + .setMethodCallHandler { call, result -> + if (call.method == "getLaunchableApps") { + result.success(launchableApps()) + } else { + result.notImplemented() + } + } + + EventChannel(flutterEngine.dartExecutor.binaryMessenger, networkChannel) + .setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + networkEvents = events + startNetworkMonitor() + } + + override fun onCancel(arguments: Any?) { + networkEvents = null + stopNetworkMonitor() + } + }) + } + + override fun onDestroy() { + stopNetworkMonitor() + super.onDestroy() } - /** - * Sets the session countdown as the VPN notification's content text. - * - * notify() is per-package, so re-posting flutter_vless's notification id - * replaces it wholesale — every field the plugin set has to be rebuilt - * here or it is dropped, the Disconnect action most of all. Keep in sync - * with XrayCoreManager.showNotification(). - */ private fun updateVpnNotification(title: String, text: String, actionLabel: String) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != @@ -76,58 +119,52 @@ class MainActivity : FlutterActivity() { PendingIntent.FLAG_UPDATE_CURRENT } - // Same intent shape as the plugin, so a tap brings the running task - // forward instead of starting a second one. 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 - // Tapping "Disconnect" stops the tunnel without opening the app. val stopIntent = Intent(this, XrayVPNService::class.java) - stopIntent.putExtra( - "COMMAND", - AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE - ) + stopIntent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE) val stopPendingIntent = PendingIntent.getService(this, 0, stopIntent, flags) val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) - .setSmallIcon(R.drawable.notification_icon) + .setSmallIcon(R.drawable.notification_status_icon) .setContentTitle(title) .setContentText(text) .addAction(0, actionLabel, stopPendingIntent) .setColorized(true) .setColor(0xFF0D1117.toInt()) .setOngoing(true) + .setAutoCancel(false) .setOnlyAlertOnce(true) .setSilent(true) .setShowWhen(false) .setPriority(NotificationCompat.PRIORITY_LOW) if (launchIntent != null) { - builder.setContentIntent( - PendingIntent.getActivity(this, 0, launchIntent, flags) - ) + builder.setContentIntent(PendingIntent.getActivity(this, 0, launchIntent, flags)) } try { - NotificationManagerCompat.from(this).notify(NOTIFICATION_ID, builder.build()) + val notification = builder.build() + notification.flags = notification.flags or + Notification.FLAG_ONGOING_EVENT or Notification.FLAG_NO_CLEAR + NotificationManagerCompat.from(this).notify(NOTIFICATION_ID, notification) } catch (_: Exception) { - // The foreground notification may not be up yet — the update is cosmetic. + // The native foreground notification may not be ready yet. } } - /** A binder round-trip, and the caller runs once a second — so, once per process. */ private fun ensureNotificationChannel() { if (notificationChannelReady) return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val manager = - getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val channel = NotificationChannel( NOTIFICATION_CHANNEL_ID, "Revolt VPN Background Service", - NotificationManager.IMPORTANCE_LOW + NotificationManager.IMPORTANCE_LOW, ) channel.setShowBadge(false) manager.createNotificationChannel(channel) @@ -135,11 +172,134 @@ class MainActivity : FlutterActivity() { notificationChannelReady = true } - /** - * Package name of whatever installed us ("com.android.vending" for Play - * Store), or null when unknown. Without this the Dart side always falls - * back to sideload and points every user at GitHub releases. - */ + private fun startNetworkMonitor() { + if (networkCallback != null) return + + val manager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + emitBestPhysicalNetwork(manager, "available") + } + + override fun onLost(network: Network) { + emitBestPhysicalNetwork(manager, "lost") + } + + override fun onCapabilitiesChanged( + network: Network, + capabilities: NetworkCapabilities, + ) { + emitBestPhysicalNetwork(manager, "changed") + } + } + + try { + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + .build() + manager.registerNetworkCallback(request, callback) + networkCallback = callback + emitBestPhysicalNetwork(manager, "initial") + } catch (e: Exception) { + networkEvents?.error("NETWORK_MONITOR", e.message, null) + } + } + + private fun stopNetworkMonitor() { + val callback = networkCallback ?: return + val manager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + try { + manager.unregisterNetworkCallback(callback) + } catch (_: Exception) { + } + networkCallback = null + } + + private fun emitBestPhysicalNetwork( + manager: ConnectivityManager, + reason: String, + ) { + val best = manager.allNetworks + .mapNotNull { network -> + val capabilities = manager.getNetworkCapabilities(network) + ?: return@mapNotNull null + if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) { + return@mapNotNull null + } + if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) || + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) + ) { + return@mapNotNull null + } + Triple(network, capabilities, physicalNetworkScore(capabilities)) + } + .maxByOrNull { it.third } + + if (best == null) { + emitNetworkPayload( + mapOf( + "reason" to reason, + "transport" to "none", + "connected" to false, + "validated" to false, + "metered" to false, + "timestamp" to System.currentTimeMillis(), + ), + ) + return + } + + emitNetworkState(best.first, best.second, reason) + } + + private fun physicalNetworkScore(capabilities: NetworkCapabilities): Int { + var score = 0 + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + ) { + score += 100 + } + score += when { + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> 40 + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> 30 + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> 20 + else -> 10 + } + return score + } + + private fun emitNetworkState( + network: Network, + capabilities: NetworkCapabilities, + reason: String, + ) { + val transport = when { + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular" + else -> "other" + } + val validated = Build.VERSION.SDK_INT < Build.VERSION_CODES.M || + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + val metered = !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) + + emitNetworkPayload( + mapOf( + "reason" to reason, + "transport" to transport, + "connected" to true, + "validated" to validated, + "metered" to metered, + "timestamp" to System.currentTimeMillis(), + ), + ) + } + + private fun emitNetworkPayload(payload: Map) { + runOnUiThread { networkEvents?.success(payload) } + } + private fun installerPackage(): String? = try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { packageManager.getInstallSourceInfo(packageName).installingPackageName @@ -147,12 +307,95 @@ class MainActivity : FlutterActivity() { @Suppress("DEPRECATION") packageManager.getInstallerPackageName(packageName) } - } catch (e: Exception) { + } catch (_: Exception) { null } + private fun launchableApps(): List> { + val intent = Intent(Intent.ACTION_MAIN).apply { + addCategory(Intent.CATEGORY_LAUNCHER) + } + + val resolved = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.queryIntentActivities( + intent, + PackageManager.ResolveInfoFlags.of(0L), + ) + } else { + @Suppress("DEPRECATION") + packageManager.queryIntentActivities(intent, 0) + } + + return resolved + .mapNotNull { info -> + val appPackage = info.activityInfo?.packageName ?: return@mapNotNull null + if (appPackage == packageName) return@mapNotNull null + + val item = mutableMapOf( + "packageName" to appPackage, + "label" to info.loadLabel(packageManager).toString(), + ) + appIconPng(info)?.let { item["icon"] = it } + item + } + .distinctBy { it["packageName"] as String } + .sortedBy { (it["label"] as String).lowercase() } + } + + private fun appIconPng(info: ResolveInfo): ByteArray? = try { + val drawable = info.loadIcon(packageManager) + val density = resources.displayMetrics.density + val size = (48f * density).roundToInt().coerceIn(48, 144) + val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, size, size) + drawable.draw(canvas) + + ByteArrayOutputStream().use { output -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, output) + output.toByteArray() + } + } catch (_: Exception) { + null + } + + private fun performHaptic(kind: String): Boolean { + return try { + val vibrator: Vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val manager = + getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager + manager.defaultVibrator + } else { + @Suppress("DEPRECATION") + getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + } + + if (!vibrator.hasVibrator()) { + false + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val effect = when (kind) { + "selection" -> VibrationEffect.EFFECT_TICK + "success" -> VibrationEffect.EFFECT_HEAVY_CLICK + else -> VibrationEffect.EFFECT_CLICK + } + vibrator.vibrate(VibrationEffect.createPredefined(effect)) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val durationMs = if (kind == "success") 24L else 14L + val amplitude = if (kind == "success") 110 else 70 + vibrator.vibrate(VibrationEffect.createOneShot(durationMs, amplitude)) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(if (kind == "success") 24L else 14L) + } + true + } + } catch (_: Exception) { + false + } + } + companion object { - // Must match flutter_vless's XrayCoreManager. private const val NOTIFICATION_ID = 1 private const val NOTIFICATION_CHANNEL_ID = "XRAY_SERVICE_CHANNEL" } diff --git a/android/app/src/main/res/drawable/notification_status_icon.xml b/android/app/src/main/res/drawable/notification_status_icon.xml new file mode 100644 index 0000000..183ed91 --- /dev/null +++ b/android/app/src/main/res/drawable/notification_status_icon.xml @@ -0,0 +1,9 @@ + + + diff --git a/lib/components/app_haptic_surface.dart b/lib/components/app_haptic_surface.dart new file mode 100644 index 0000000..b969ec4 --- /dev/null +++ b/lib/components/app_haptic_surface.dart @@ -0,0 +1,65 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:revoltvpn/logic/haptic_settings.dart'; + +/// Gives short touch taps the same haptic feedback across the app. +/// Scroll gestures are ignored by checking travel distance and touch duration. +class AppHapticSurface extends StatefulWidget { + final Widget child; + + const AppHapticSurface({super.key, required this.child}); + + @override + State createState() => _AppHapticSurfaceState(); +} + +class _PointerStart { + final Offset position; + final DateTime time; + + const _PointerStart(this.position, this.time); +} + +class _AppHapticSurfaceState extends State { + static const double _maxTapTravel = 18; + static const Duration _maxTapDuration = Duration(milliseconds: 700); + + final Map _starts = {}; + + bool _supportsHaptics(PointerDeviceKind kind) { + return kind == PointerDeviceKind.touch || + kind == PointerDeviceKind.stylus || + kind == PointerDeviceKind.invertedStylus; + } + + void _onDown(PointerDownEvent event) { + if (!_supportsHaptics(event.kind)) return; + _starts[event.pointer] = _PointerStart(event.position, DateTime.now()); + } + + void _onCancel(PointerCancelEvent event) { + _starts.remove(event.pointer); + } + + void _onUp(PointerUpEvent event) { + final start = _starts.remove(event.pointer); + if (start == null || !_supportsHaptics(event.kind)) return; + + final travelled = (event.position - start.position).distance; + final elapsed = DateTime.now().difference(start.time); + if (travelled <= _maxTapTravel && elapsed <= _maxTapDuration) { + HapticSettings.tap(); + } + } + + @override + Widget build(BuildContext context) { + return Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: _onDown, + onPointerUp: _onUp, + onPointerCancel: _onCancel, + child: widget.child, + ); + } +} diff --git a/lib/components/connect_button.dart b/lib/components/connect_button.dart index 888067b..dc5cde1 100644 --- a/lib/components/connect_button.dart +++ b/lib/components/connect_button.dart @@ -1,11 +1,9 @@ -import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:revoltvpn/logic/app_colors.dart'; import 'package:revoltvpn/logic/vpn_connection.dart'; import 'package:revoltvpn/logic/session_timer.dart'; import 'package:revoltvpn/logic/ad_manager.dart'; -import 'package:revoltvpn/screens/settings/in_settings/haptic.dart'; class ConnectButton extends StatefulWidget { const ConnectButton({super.key}); @@ -17,7 +15,7 @@ class ConnectButton extends StatefulWidget { class _ConnectButtonState extends State with SingleTickerProviderStateMixin, WidgetsBindingObserver { bool _busy = false; - DateTime _lastTap = DateTime.now(); + DateTime _lastTap = DateTime.fromMillisecondsSinceEpoch(0); AnimationController? _pulse; Animation? _pulseAnim; @@ -26,12 +24,17 @@ class _ConnectButtonState extends State @override void initState() { super.initState(); - final pulse = AnimationController(vsync: this, duration: const Duration(milliseconds: 2000)); + final pulse = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 2000), + ); _pulse = pulse; - _pulseAnim = Tween(begin: 0.0, end: 1.0).animate(CurvedAnimation(parent: pulse, curve: Curves.easeInOut)); + _pulseAnim = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: pulse, curve: Curves.easeInOut), + ); WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((_) { - context.read().addListener(_onVpnChanged); + if (mounted) context.read().addListener(_onVpnChanged); }); } @@ -68,7 +71,6 @@ class _ConnectButtonState extends State } Future _handleTap() async { - final now = DateTime.now(); if (now.difference(_lastTap).inMilliseconds < 1000) return; _lastTap = now; @@ -77,32 +79,21 @@ class _ConnectButtonState extends State final timer = context.read(); final ad = context.read(); - if (vpn.status == VpnStatus.connected || vpn.status == VpnStatus.connecting) { _busy = false; await timer.disconnect(); - unawaited(Haptics.connectionChanged()); return; } - - if (vpn.status == VpnStatus.disconnecting) return; - - - if (_busy) return; + if (vpn.status == VpnStatus.disconnecting || _busy) return; setState(() => _busy = true); try { final adWatched = await ad.showAd('main'); - if (!adWatched && AdManager.adsEnabled) { - setState(() => _busy = false); - return; - } + if (!adWatched && AdManager.adsEnabled) return; + final ok = await vpn.connect(); - if (ok) { - timer.start(); - unawaited(Haptics.connectionChanged()); - } + if (ok) await timer.start(); } finally { if (mounted) setState(() => _busy = false); } @@ -170,7 +161,7 @@ class _ConnectButtonState extends State color: Color.fromARGB(glowAlpha, 255, 214, 0), blurRadius: glowRadius, spreadRadius: glowRadius * 0.3, - ) + ), ] : [], ), diff --git a/lib/components/sidebar_drawer.dart b/lib/components/sidebar_drawer.dart index afb444a..53a08aa 100644 --- a/lib/components/sidebar_drawer.dart +++ b/lib/components/sidebar_drawer.dart @@ -141,7 +141,10 @@ class _SidebarDrawerState extends State { final messenger = ScaffoldMessenger.of(context); Navigator.of(context).pop(); unawaited( - Updater.check(navigator: navigator, messenger: messenger), + Updater.checkWithHandles( + navigator: navigator, + messenger: messenger, + ), ); }, ), diff --git a/lib/components/status_bar.dart b/lib/components/status_bar.dart index b45b2b6..f197ac8 100644 --- a/lib/components/status_bar.dart +++ b/lib/components/status_bar.dart @@ -7,16 +7,6 @@ import 'package:revoltvpn/logic/server_list.dart'; import 'package:revoltvpn/components/online_dot.dart'; import 'package:revoltvpn/screens/server_selector/selection_screen.dart'; -// ── lib/components/status_bar.dart ────────────────────────────────────────── -// Two-card layout at the bottom of the main screen. -// -// Top card: server location from ServerList provider (flag + city). -// Tappable → opens server selector. Always visible. -// Bottom card: connection status (health dot + label + lock icon). -// -// Separated into two rows — no more RenderFlex overflow and ready for -// multi-server picker. - class StatusBar extends StatelessWidget { const StatusBar({super.key}); @@ -28,15 +18,14 @@ class StatusBar extends StatelessWidget { final isConnecting = vpn.status == VpnStatus.connecting; final serverHealthy = vpn.serverReachable || (isConnected && timer.hasSyncedOnce); + final sessionOrTunnelHealthy = timer.hasSyncedOnce || vpn.serverReachable; final statusLabel = isConnected - ? (timer.hasSyncedOnce ? 'Online' : 'Syncing…') + ? (sessionOrTunnelHealthy ? 'Online' : 'Syncing…') : (isConnecting ? 'Connecting…' : (vpn.serverReachable ? 'Ready' : 'Offline')); - // Read from the ServerList provider — the source of truth for - // which server is selected and what it looks like. final server = context.watch().selected; return Padding( @@ -44,7 +33,6 @@ class StatusBar extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - // ── Server location ────────────────────────────────────────── GestureDetector( onTap: () { Navigator.of(context).push( @@ -56,10 +44,12 @@ class StatusBar extends StatelessWidget { position: Tween( begin: const Offset(1.0, 0.0), end: Offset.zero, - ).animate(CurvedAnimation( - parent: animation, - curve: Curves.easeOut, - )), + ).animate( + CurvedAnimation( + parent: animation, + curve: Curves.easeOut, + ), + ), child: child, ); }, @@ -79,24 +69,26 @@ class StatusBar extends StatelessWidget { Image.asset(server.flagAsset, width: 20, height: 20), const SizedBox(width: 8), Expanded( - child: Text(server.name, - style: const TextStyle( - color: AppColors.accent, - fontWeight: FontWeight.w600, - fontSize: 13, - letterSpacing: 0.5, - )), + child: Text( + server.name, + style: const TextStyle( + color: AppColors.accent, + fontWeight: FontWeight.w600, + fontSize: 13, + letterSpacing: 0.5, + ), + ), + ), + const Icon( + Icons.chevron_right, + color: AppColors.textDim, + size: 20, ), - const Icon(Icons.chevron_right, - color: AppColors.textDim, size: 20), ], ), ), ), - const SizedBox(height: 8), - - // ── Connection status ──────────────────────────────────────── Container( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), @@ -113,8 +105,11 @@ class StatusBar extends StatelessWidget { ), const Spacer(), if (isConnected) - const Icon(Icons.lock, - color: AppColors.green50, size: 14), + const Icon( + Icons.lock, + color: AppColors.green50, + size: 14, + ), ], ), ), diff --git a/lib/components/support_button.dart b/lib/components/support_button.dart index 25e5cdb..59a1b86 100644 --- a/lib/components/support_button.dart +++ b/lib/components/support_button.dart @@ -20,13 +20,21 @@ class _SupportButtonState extends State { final ad = context.read(); final timer = context.read(); - // Only available when there's an active session. - if (!timer.isRunning && !timer.hasSyncedOnce) return; + // Only available when there's an active session, once the persisted + // support state is known, and only once per client-side VPN session. + if ((!timer.isRunning && !timer.hasSyncedOnce) || + !timer.supportRewardStateLoaded || + timer.supportRewardClaimed) { + return; + } setState(() => _busy = true); try { - await ad.showAd('support'); + final rewarded = await ad.showAd('support'); + if (rewarded) { + await timer.markSupportRewardClaimed(); + } } finally { if (mounted) setState(() => _busy = false); } @@ -34,48 +42,62 @@ class _SupportButtonState extends State { @override Widget build(BuildContext context) { - return Selector( - selector: (_, timer) => timer.isRunning || timer.hasSyncedOnce, - builder: (context, visible, _) { - if (!visible) { + return Consumer( + builder: (context, timer, _) { + if (!timer.isRunning && !timer.hasSyncedOnce) { return const SizedBox.shrink(); } + final stateLoaded = timer.supportRewardStateLoaded; + final claimed = timer.supportRewardClaimed; + final disabled = !stateLoaded || claimed; + return GestureDetector( - onTap: _handleTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), - decoration: BoxDecoration( - color: AppColors.glassBg, - borderRadius: BorderRadius.circular(24), - border: Border.all( - color: AppColors.accent.withAlpha(100), - width: 1, + onTap: disabled ? null : _handleTap, + child: Opacity( + opacity: disabled ? 0.55 : 1.0, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + decoration: BoxDecoration( + color: AppColors.glassBg, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: AppColors.accent.withAlpha(100), + width: 1, + ), ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.favorite_border, color: AppColors.accent, size: 20), - const SizedBox(width: 8), - _busy - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - color: AppColors.accent, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + claimed ? Icons.check_circle_outline : Icons.favorite_border, + color: AppColors.accent, + size: 20, + ), + const SizedBox(width: 8), + _busy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.accent, + ), + ) + : Text( + !stateLoaded + ? 'Checking support bonus…' + : claimed + ? 'Support bonus used' + : 'Support us — 30 min!', + style: const TextStyle( + color: AppColors.textWhite, + fontSize: 14, + fontWeight: FontWeight.w600, + ), ), - ) - : const Text( - 'Support us \u2014 30 min!', - style: TextStyle( - color: AppColors.textWhite, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ], + ], + ), ), ), ); diff --git a/lib/logic/connection_settings.dart b/lib/logic/connection_settings.dart new file mode 100644 index 0000000..541d826 --- /dev/null +++ b/lib/logic/connection_settings.dart @@ -0,0 +1,115 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +enum ConnectionMode { tun, proxy } +enum AppRoutingMode { all, exclude, selected } +enum ResilienceMode { standard, extreme } + +abstract final class ConnectionSettings { + ConnectionSettings._(); + + static const String _modeKey = 'connection_mode'; + static const String _routingModeKey = 'app_routing_mode'; + static const String _appPackagesKey = 'app_routing_packages'; + static const String _legacyBlockedAppsKey = 'blocked_apps'; + static const String _resilienceModeKey = 'resilience_mode'; + + static ConnectionMode _mode = ConnectionMode.tun; + static AppRoutingMode _routingMode = AppRoutingMode.all; + static ResilienceMode _resilienceMode = ResilienceMode.standard; + static List _appPackages = const []; + static bool _initialized = false; + + static ConnectionMode get mode => _mode; + static AppRoutingMode get routingMode => _routingMode; + static ResilienceMode get resilienceMode => _resilienceMode; + static List get appPackages => List.unmodifiable(_appPackages); + + static Future initialize() async { + if (_initialized) return; + + final prefs = await SharedPreferences.getInstance(); + final storedMode = prefs.getString(_modeKey); + final migrateLegacyAss = storedMode == 'ass'; + final migrateLegacyAuto = storedMode == 'auto' || storedMode == null; + + if (storedMode == ConnectionMode.proxy.name || migrateLegacyAss) { + _mode = ConnectionMode.proxy; + } else { + _mode = ConnectionMode.tun; + } + + final legacyBlocked = + prefs.getStringList(_legacyBlockedAppsKey) ?? const []; + final storedPackages = prefs.getStringList(_appPackagesKey); + _appPackages = _cleanPackages(storedPackages ?? legacyBlocked); + + final storedRoutingMode = prefs.getString(_routingModeKey); + if (migrateLegacyAss) { + _routingMode = _appPackages.isEmpty + ? AppRoutingMode.all + : AppRoutingMode.selected; + } else if (storedRoutingMode == AppRoutingMode.exclude.name) { + _routingMode = AppRoutingMode.exclude; + } else if (storedRoutingMode == AppRoutingMode.selected.name) { + _routingMode = AppRoutingMode.selected; + } else if (storedRoutingMode == null && legacyBlocked.isNotEmpty) { + _routingMode = AppRoutingMode.exclude; + } else { + _routingMode = AppRoutingMode.all; + } + + _resilienceMode = + prefs.getString(_resilienceModeKey) == ResilienceMode.extreme.name + ? ResilienceMode.extreme + : ResilienceMode.standard; + + if (migrateLegacyAss) { + await prefs.setString(_modeKey, ConnectionMode.proxy.name); + await prefs.setString(_routingModeKey, _routingMode.name); + } else if (migrateLegacyAuto) { + await prefs.setString(_modeKey, ConnectionMode.tun.name); + } + + _initialized = true; + } + + static Future setMode(ConnectionMode mode) async { + _mode = mode; + _initialized = true; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_modeKey, mode.name); + } + + static Future setRoutingMode(AppRoutingMode mode) async { + _routingMode = mode; + _initialized = true; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_routingModeKey, mode.name); + } + + static Future setAppPackages(Iterable packages) async { + _appPackages = _cleanPackages(packages); + _initialized = true; + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList(_appPackagesKey, _appPackages); + } + + static Future setResilienceMode(ResilienceMode mode) async { + _resilienceMode = mode; + _initialized = true; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_resilienceModeKey, mode.name); + } + + static List _cleanPackages(Iterable packages) { + final seen = {}; + final result = []; + for (final raw in packages) { + final value = raw.trim(); + if (value.isEmpty || !seen.add(value)) continue; + result.add(value); + } + result.sort(); + return result; + } +} diff --git a/lib/logic/crypto_service.dart b/lib/logic/crypto_service.dart index 4eb58d0..838a608 100644 --- a/lib/logic/crypto_service.dart +++ b/lib/logic/crypto_service.dart @@ -4,11 +4,17 @@ import 'package:uuid/uuid.dart'; class CryptoService { static const String _deviceIdPref = 'device_uuid'; static const _storage = FlutterSecureStorage(); + static final RegExp _uuidV4 = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', + caseSensitive: false, + ); /// Get or create a persistent device UUID for server-side session tracking. + /// Corrupted or tampered storage values are replaced instead of being sent + /// to the public API as arbitrary query data. static Future getDeviceId() async { final existing = await _storage.read(key: _deviceIdPref); - if (existing != null) return existing; + if (existing != null && _uuidV4.hasMatch(existing)) return existing; final newId = const Uuid().v4(); await _storage.write(key: _deviceIdPref, value: newId); diff --git a/lib/logic/haptic_settings.dart b/lib/logic/haptic_settings.dart new file mode 100644 index 0000000..cf3c891 --- /dev/null +++ b/lib/logic/haptic_settings.dart @@ -0,0 +1,84 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +enum HapticKind { tap, selection, success } + +abstract final class HapticSettings { + HapticSettings._(); + + static const String _prefKey = 'haptic_feedback_enabled'; + static const MethodChannel _channel = + MethodChannel('com.revoltvpn.app/haptics'); + + static bool _enabled = true; + static bool _initialized = false; + + static bool get enabled => _enabled; + + static Future initialize() async { + if (_initialized) return; + + try { + final prefs = await SharedPreferences.getInstance(); + _enabled = prefs.getBool(_prefKey) ?? true; + } catch (e) { + debugPrint('[Haptics] Failed to load preference: $e'); + _enabled = true; + } finally { + _initialized = true; + } + } + + static Future setEnabled(bool value) async { + final previous = _enabled; + _enabled = value; + _initialized = true; + + try { + final prefs = await SharedPreferences.getInstance(); + final saved = await prefs.setBool(_prefKey, value); + if (!saved) throw StateError('Could not save haptic setting'); + } catch (e) { + _enabled = previous; + debugPrint('[Haptics] Failed to save preference: $e'); + rethrow; + } + } + + static void tap() => unawaited(_impact(HapticKind.tap)); + static void selection() => unawaited(_impact(HapticKind.selection)); + static void success() => unawaited(_impact(HapticKind.success)); + + static Future _impact(HapticKind kind) async { + if (!_initialized) await initialize(); + if (!_enabled || kIsWeb) return; + + if (defaultTargetPlatform == TargetPlatform.android) { + try { + final handled = await _channel.invokeMethod( + 'impact', + {'kind': kind.name}, + ) ?? + false; + if (handled) return; + } catch (e) { + debugPrint('[Haptics] Native vibration unavailable: $e'); + } + } + + switch (kind) { + case HapticKind.tap: + await HapticFeedback.lightImpact(); + break; + case HapticKind.selection: + await HapticFeedback.selectionClick(); + break; + case HapticKind.success: + await HapticFeedback.mediumImpact(); + break; + } + } +} diff --git a/lib/logic/hivemind_service.dart b/lib/logic/hivemind_service.dart index 609e732..e1f3d8a 100644 --- a/lib/logic/hivemind_service.dart +++ b/lib/logic/hivemind_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:math'; + import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:revoltvpn/logic/app_config.dart'; @@ -12,14 +13,21 @@ class HivemindService { static const _ua = 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 ' '(KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36'; + static const _pollBackoff = [ + Duration(seconds: 1), + Duration(seconds: 2), + Duration(seconds: 3), + Duration(seconds: 5), + ]; - static Future directGet(Uri uri, {Duration timeout = const Duration(seconds: 5)}) { + static Future directGet( + Uri uri, { + Duration timeout = const Duration(seconds: 5), + }) { return http.get(uri, headers: {'User-Agent': _ua}).timeout(timeout); } - static void cancel() { - _currentCallId++; - } + static void cancel() => _currentCallId++; static void setExpectedNonce(String nonce) { _expectedNonce = nonce; @@ -30,66 +38,34 @@ class HivemindService { bool skipAdBypass = false, }) async { final deviceId = await CryptoService.getDeviceId(); - final callId = ++_currentCallId; - final nonce = '${Random().nextInt(0x7FFFFFFF)}-${DateTime.now().millisecondsSinceEpoch}'; + final nonce = _newNonce(); _expectedNonce = nonce; - debugPrint('[HivemindService] Call #$callId — nonce: $nonce'); - - final url = _publicUrl('/session/status?device_id=$deviceId'); if (!skipAdBypass) { - try { - final customData = jsonEncode({'device_id': deviceId, 'nonce': nonce}); - final fakeUrl = _publicUrl( - '/admob/callback?signature=test&key_id=test&custom_data=${Uri.encodeComponent(customData)}'); - await directGet(fakeUrl, timeout: const Duration(seconds: 8)); - } catch (_) {} + await _runConfiguredBypass(deviceId, nonce); } const maxAttempts = 5; - for (int i = 1; i <= maxAttempts; i++) { - if (_currentCallId != callId) throw Exception('Cancelled'); + for (var attempt = 1; attempt <= maxAttempts; attempt++) { + _throwIfCancelled(callId); + onAttempt?.call(attempt, maxAttempts); - onAttempt?.call(i, maxAttempts); try { - final response = await directGet(url); - if (response.statusCode == 200) { - final data = jsonDecode(response.body); - - final serverNonce = data['nonce'] as String?; - if (_expectedNonce != null && serverNonce != null && serverNonce != _expectedNonce) { - debugPrint('[HivemindService] Nonce mismatch — retrying…'); - } else if (data['active'] == true && data['vless_uuid'] != null) { - final vlessUuid = data['vless_uuid']; - final vlessIp = data['vless_ip'] ?? AppConfig.serverIp; - final vlessPort = data['vless_port'] ?? 443; - final pbk = data['reality_pbk'] ?? ''; - final sid = data['reality_sid'] ?? ''; - final sni = data['reality_sni']; - if (sni == null) throw Exception('Server did not provide reality_sni'); - final fp = data['reality_fp'] ?? AppConfig.realityFp; - final xhttpPath = data['xhttp_path'] ?? AppConfig.vlessPath; - - final vlessUrl = 'vless://$vlessUuid@$vlessIp:$vlessPort' - '?security=${AppConfig.vlessSecurity}' - '&type=${AppConfig.vlessType}' - '&path=$xhttpPath' - '&pbk=${Uri.encodeComponent(pbk)}' - '&sni=${Uri.encodeComponent(sni)}' - '&sid=${Uri.encodeComponent(sid)}' - '&fp=${Uri.encodeComponent(fp)}' - '#Revolt VPN'; - _expectedNonce = null; - return vlessUrl; - } + final session = await _fetchActiveSession(deviceId, nonce); + if (session != null) { + _expectedNonce = null; + return session.toVlessUrl(); } } catch (e) { - if (e.toString().contains('Cancelled')) rethrow; - debugPrint('[HivemindService] Attempt $i failed: $e'); + if (_isCancelledError(e)) rethrow; + debugPrint('[Hivemind] session attempt $attempt failed'); } - if (i < maxAttempts) await Future.delayed(const Duration(seconds: 1)); + if (attempt < maxAttempts) { + await Future.delayed(_pollBackoff[attempt - 1]); + _throwIfCancelled(callId); + } } throw Exception('Session not activated. Server callback may have timed out.'); @@ -97,16 +73,139 @@ class HivemindService { static Future checkHealth() async { try { - final url = Uri.parse('${AppConfig.hivemindApiPublic}/health'); - final response = await directGet(url, timeout: const Duration(seconds: 3)); + final response = await directGet( + _publicUrl('/health'), + timeout: const Duration(seconds: 3), + ); return response.statusCode == 200; } catch (_) { return false; } } - // ── URL builders ────────────────────────────────────────────────── + static String _newNonce() { + final random = Random.secure(); + final high = random.nextInt(0x7FFFFFFF); + final low = random.nextInt(0x7FFFFFFF); + return '$high-$low-${DateTime.now().microsecondsSinceEpoch}'; + } + + static Future _runConfiguredBypass(String deviceId, String nonce) async { + try { + final customData = jsonEncode({'device_id': deviceId, 'nonce': nonce}); + final callback = _publicUrl( + '/admob/callback?signature=test&key_id=test&custom_data=${Uri.encodeComponent(customData)}', + ); + await directGet(callback, timeout: const Duration(seconds: 8)); + } catch (_) { + // Validation bypass behavior is intentionally best-effort in this build. + } + } + + static Future<_HivemindSessionConfig?> _fetchActiveSession( + String deviceId, + String nonce, + ) async { + final statusUri = _publicUrl('/session/status').replace( + queryParameters: {'device_id': deviceId}, + ); + final response = await directGet(statusUri); + if (response.statusCode != 200) return null; + + final decoded = jsonDecode(response.body); + if (decoded is! Map) return null; + + final serverNonce = decoded['nonce']; + if (_expectedNonce != null && + serverNonce is String && + serverNonce != nonce) { + return null; + } + + if (decoded['active'] != true || decoded['vless_uuid'] == null) { + return null; + } + + return _HivemindSessionConfig.fromJson(decoded); + } + + static void _throwIfCancelled(int callId) { + if (_currentCallId != callId) throw Exception('Cancelled'); + } + + static bool _isCancelledError(Object error) => + error.toString().contains('Cancelled'); static Uri _publicUrl(String path) => Uri.parse('${AppConfig.hivemindApiPublic}$path'); } + +class _HivemindSessionConfig { + final String uuid; + final String host; + final int port; + final String publicKey; + final String shortId; + final String sni; + final String fingerprint; + final String path; + + const _HivemindSessionConfig({ + required this.uuid, + required this.host, + required this.port, + required this.publicKey, + required this.shortId, + required this.sni, + required this.fingerprint, + required this.path, + }); + + factory _HivemindSessionConfig.fromJson(Map json) { + final uuid = _requiredString(json, 'vless_uuid'); + final host = _stringOr(json['vless_ip'], AppConfig.serverIp); + final port = _portOr(json['vless_port'], 443); + final sni = _requiredString(json, 'reality_sni'); + + return _HivemindSessionConfig( + uuid: uuid, + host: host, + port: port, + publicKey: _stringOr(json['reality_pbk'], ''), + shortId: _stringOr(json['reality_sid'], ''), + sni: sni, + fingerprint: _stringOr(json['reality_fp'], AppConfig.realityFp), + path: _stringOr(json['xhttp_path'], AppConfig.vlessPath), + ); + } + + String toVlessUrl() { + return 'vless://$uuid@$host:$port' + '?security=${AppConfig.vlessSecurity}' + '&type=${AppConfig.vlessType}' + '&path=$path' + '&pbk=${Uri.encodeComponent(publicKey)}' + '&sni=${Uri.encodeComponent(sni)}' + '&sid=${Uri.encodeComponent(shortId)}' + '&fp=${Uri.encodeComponent(fingerprint)}' + '#Revolt VPN'; + } + + static String _requiredString(Map json, String key) { + final value = json[key]; + if (value is String && value.trim().isNotEmpty) return value.trim(); + throw FormatException('Missing required session field: $key'); + } + + static String _stringOr(Object? value, String fallback) { + return value is String && value.isNotEmpty ? value : fallback; + } + + static int _portOr(Object? value, int fallback) { + final parsed = value is num ? value.toInt() : fallback; + if (parsed <= 0 || parsed > 65535) { + throw const FormatException('Invalid VLESS port'); + } + return parsed; + } +} diff --git a/lib/logic/installed_apps_service.dart b/lib/logic/installed_apps_service.dart new file mode 100644 index 0000000..18e4e3a --- /dev/null +++ b/lib/logic/installed_apps_service.dart @@ -0,0 +1,48 @@ +import 'package:flutter/services.dart'; + +class InstalledApp { + final String packageName; + final String label; + final Uint8List? iconPng; + + const InstalledApp({ + required this.packageName, + required this.label, + required this.iconPng, + }); +} + +abstract final class InstalledAppsService { + InstalledAppsService._(); + + static const MethodChannel _channel = MethodChannel('com.revoltvpn.app/apps'); + + static Future> loadLaunchableApps() async { + final raw = await _channel.invokeListMethod('getLaunchableApps') ?? + const []; + final apps = []; + + for (final item in raw) { + if (item is! Map) continue; + + final packageName = item['packageName']; + final label = item['label']; + final icon = item['icon']; + + if (packageName is! String || packageName.isEmpty) continue; + + apps.add( + InstalledApp( + packageName: packageName, + label: label is String && label.isNotEmpty ? label : packageName, + iconPng: icon is Uint8List ? icon : null, + ), + ); + } + + apps.sort( + (a, b) => a.label.toLowerCase().compareTo(b.label.toLowerCase()), + ); + return apps; + } +} diff --git a/lib/logic/local_socks_tester.dart b/lib/logic/local_socks_tester.dart new file mode 100644 index 0000000..6e405bc --- /dev/null +++ b/lib/logic/local_socks_tester.dart @@ -0,0 +1,274 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +class LocalSocksTestResult { + final bool ok; + final int? latencyMs; + final String message; + + const LocalSocksTestResult({ + required this.ok, + required this.latencyMs, + required this.message, + }); +} + +abstract final class LocalSocksTester { + LocalSocksTester._(); + + /// Fast local readiness probe used while starting the VPN. + /// + /// This stops after SOCKS5 authentication. Startup must not depend on an + /// unrelated external host being reachable. + static Future testListener({ + String host = '127.0.0.1', + int port = 10807, + String? username, + String? password, + }) async { + final stopwatch = Stopwatch()..start(); + Socket? socket; + _SocketReader? reader; + + try { + socket = await Socket.connect( + host, + port, + timeout: const Duration(seconds: 1), + ); + socket.setOption(SocketOption.tcpNoDelay, true); + reader = _SocketReader(socket); + + final authenticated = await _authenticate( + reader, + socket, + username: username, + password: password, + timeout: const Duration(seconds: 1), + ); + if (!authenticated) { + return const LocalSocksTestResult( + ok: false, + latencyMs: null, + message: 'Local SOCKS5 listener rejected authentication.', + ); + } + + stopwatch.stop(); + return LocalSocksTestResult( + ok: true, + latencyMs: stopwatch.elapsedMilliseconds, + message: 'Local SOCKS5 listener is ready.', + ); + } on TimeoutException { + return const LocalSocksTestResult( + ok: false, + latencyMs: null, + message: 'Local SOCKS5 listener timed out.', + ); + } on SocketException { + return LocalSocksTestResult( + ok: false, + latencyMs: null, + message: 'Local SOCKS5 is not listening on $host:$port.', + ); + } catch (_) { + return const LocalSocksTestResult( + ok: false, + latencyMs: null, + message: 'Local SOCKS5 listener check failed.', + ); + } finally { + await reader?.close(); + await socket?.close(); + } + } + + /// Full user-facing test: authenticated local SOCKS handshake plus outbound + /// CONNECT. Credentials are optional so the helper remains usable for legacy + /// no-auth diagnostic builds. + static Future test({ + String host = '127.0.0.1', + int port = 10807, + String? username, + String? password, + String targetHost = 'paladinvpn.duckdns.org', + int targetPort = 443, + }) async { + final stopwatch = Stopwatch()..start(); + Socket? socket; + _SocketReader? reader; + + try { + socket = await Socket.connect( + host, + port, + timeout: const Duration(seconds: 3), + ); + socket.setOption(SocketOption.tcpNoDelay, true); + reader = _SocketReader(socket); + + final authenticated = await _authenticate( + reader, + socket, + username: username, + password: password, + ); + if (!authenticated) { + return const LocalSocksTestResult( + ok: false, + latencyMs: null, + message: 'Local SOCKS5 listener rejected authentication.', + ); + } + + final hostBytes = utf8.encode(targetHost); + if (hostBytes.isEmpty || hostBytes.length > 255) { + return const LocalSocksTestResult( + ok: false, + latencyMs: null, + message: 'SOCKS5 test target is invalid.', + ); + } + + socket.add([ + 0x05, + 0x01, + 0x00, + 0x03, + hostBytes.length, + ...hostBytes, + (targetPort >> 8) & 0xff, + targetPort & 0xff, + ]); + await socket.flush(); + + final replyHead = await reader.readExactly(4); + if (replyHead[0] != 0x05 || replyHead[1] != 0x00) { + return const LocalSocksTestResult( + ok: false, + latencyMs: null, + message: + 'SOCKS5 is listening, but the ReVolt route could not reach the test target.', + ); + } + + await _consumeAddress(reader, replyHead[3]); + stopwatch.stop(); + return LocalSocksTestResult( + ok: true, + latencyMs: stopwatch.elapsedMilliseconds, + message: 'Local SOCKS5 and the ReVolt outbound are reachable.', + ); + } on TimeoutException { + return const LocalSocksTestResult( + ok: false, + latencyMs: null, + message: 'Local SOCKS5 test timed out.', + ); + } on SocketException { + return LocalSocksTestResult( + ok: false, + latencyMs: null, + message: 'Local SOCKS5 is not listening on $host:$port.', + ); + } catch (_) { + return const LocalSocksTestResult( + ok: false, + latencyMs: null, + message: 'Local SOCKS5 test failed.', + ); + } finally { + await reader?.close(); + await socket?.close(); + } + } + + static Future _authenticate( + _SocketReader reader, + Socket socket, { + required String? username, + required String? password, + Duration timeout = const Duration(seconds: 4), + }) async { + final passwordAuth = username != null && password != null; + if ((username == null) != (password == null)) return false; + + socket.add(passwordAuth + ? const [0x05, 0x01, 0x02] + : const [0x05, 0x01, 0x00]); + await socket.flush(); + + final greeting = await reader.readExactly(2, timeout: timeout); + if (greeting[0] != 0x05) return false; + if (!passwordAuth) return greeting[1] == 0x00; + if (greeting[1] != 0x02) return false; + + final userBytes = utf8.encode(username); + final passBytes = utf8.encode(password); + if (userBytes.isEmpty || + passBytes.isEmpty || + userBytes.length > 255 || + passBytes.length > 255) { + return false; + } + + socket.add([ + 0x01, + userBytes.length, + ...userBytes, + passBytes.length, + ...passBytes, + ]); + await socket.flush(); + + final authReply = await reader.readExactly(2, timeout: timeout); + return authReply[0] == 0x01 && authReply[1] == 0x00; + } + + static Future _consumeAddress( + _SocketReader reader, int addressType) async { + if (addressType == 0x01) { + await reader.readExactly(4 + 2); + return; + } + if (addressType == 0x04) { + await reader.readExactly(16 + 2); + return; + } + if (addressType == 0x03) { + final length = (await reader.readExactly(1)).first; + await reader.readExactly(length + 2); + return; + } + throw const FormatException('Unsupported SOCKS5 address type'); + } +} + +class _SocketReader { + final StreamIterator _iterator; + final List _buffer = []; + + _SocketReader(Socket socket) : _iterator = StreamIterator(socket); + + Future> readExactly( + int count, { + Duration timeout = const Duration(seconds: 4), + }) async { + while (_buffer.length < count) { + final hasNext = await _iterator.moveNext().timeout(timeout); + if (!hasNext) { + throw const SocketException('Socket closed early'); + } + _buffer.addAll(_iterator.current); + } + + final result = _buffer.sublist(0, count); + _buffer.removeRange(0, count); + return result; + } + + Future close() => _iterator.cancel(); +} diff --git a/lib/logic/network_monitor.dart b/lib/logic/network_monitor.dart new file mode 100644 index 0000000..39177aa --- /dev/null +++ b/lib/logic/network_monitor.dart @@ -0,0 +1,42 @@ +import 'package:flutter/services.dart'; + +class NetworkSnapshot { + final String reason; + final String transport; + final bool connected; + final bool validated; + final bool metered; + final int timestamp; + + const NetworkSnapshot({ + required this.reason, + required this.transport, + required this.connected, + required this.validated, + required this.metered, + required this.timestamp, + }); + + factory NetworkSnapshot.fromMap(Map map) { + return NetworkSnapshot( + reason: map['reason'] as String? ?? 'changed', + transport: map['transport'] as String? ?? 'unknown', + connected: map['connected'] == true, + validated: map['validated'] == true, + metered: map['metered'] == true, + timestamp: (map['timestamp'] as num?)?.toInt() ?? 0, + ); + } +} + +abstract final class NetworkMonitor { + NetworkMonitor._(); + + static const EventChannel _channel = EventChannel('com.revoltvpn.app/network'); + + static Stream get changes { + return _channel.receiveBroadcastStream().where((event) => event is Map).map( + (event) => NetworkSnapshot.fromMap(event as Map), + ); + } +} diff --git a/lib/logic/secure_socks_session.dart b/lib/logic/secure_socks_session.dart new file mode 100644 index 0000000..2a88247 --- /dev/null +++ b/lib/logic/secure_socks_session.dart @@ -0,0 +1,89 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +class SecureSocksSession { + static const String inboundTag = 'revolt-secure-socks'; + static final Random _random = Random.secure(); + + final int port; + final String username; + final String password; + final String configJson; + + const SecureSocksSession._({ + required this.port, + required this.username, + required this.password, + required this.configJson, + }); + + static Future create(String baseConfig) async { + final decoded = jsonDecode(baseConfig); + if (decoded is! Map) { + throw const FormatException('VLESS configuration must be a JSON object.'); + } + + final reservation = await ServerSocket.bind( + InternetAddress.loopbackIPv4, + 0, + shared: false, + ); + final port = reservation.port; + await reservation.close(); + + if (port <= 1024 || port > 65535) { + throw StateError('Could not allocate a safe local SOCKS5 port.'); + } + + final username = _token(16); + final password = _token(32); + + final sourceInbounds = decoded['inbounds']; + final inbounds = []; + if (sourceInbounds is List) { + for (final value in sourceInbounds) { + if (value is Map) { + final protocol = value['protocol']?.toString().toLowerCase(); + // ReVolt owns local proxy ingress on Android. Drop imported SOCKS/HTTP + // listeners so a stale no-auth proxy cannot remain reachable beside + // the authenticated session listener. + if (protocol == 'socks' || protocol == 'http') continue; + } + inbounds.add(value); + } + } + + inbounds.add({ + 'tag': inboundTag, + 'port': port, + 'listen': '127.0.0.1', + 'protocol': 'socks', + 'settings': { + 'auth': 'password', + 'udp': true, + 'ip': '127.0.0.1', + 'users': >[ + {'user': username, 'pass': password}, + ], + }, + 'sniffing': { + 'enabled': true, + 'destOverride': ['http', 'tls'], + }, + }); + decoded['inbounds'] = inbounds; + + return SecureSocksSession._( + port: port, + username: username, + password: password, + configJson: jsonEncode(decoded), + ); + } + + static String _token(int byteCount) { + final bytes = List.generate(byteCount, (_) => _random.nextInt(256)); + return bytes.map((value) => value.toRadixString(16).padLeft(2, '0')).join(); + } +} diff --git a/lib/logic/session_timer.dart b/lib/logic/session_timer.dart index d14750f..7d3f784 100644 --- a/lib/logic/session_timer.dart +++ b/lib/logic/session_timer.dart @@ -1,11 +1,13 @@ import 'dart:async'; import 'dart:convert'; + import 'package:flutter/widgets.dart'; -import 'package:revoltvpn/logic/hivemind_service.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:revoltvpn/logic/app_config.dart'; -import 'package:revoltvpn/logic/vpn_connection.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/vpn_connection.dart'; class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { Timer? _timer; @@ -15,12 +17,20 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { final VpnConnection vpnConnection; int _remainingSeconds = 0; - int _usedBytes = 0; + int _remainingAtLastSync = 0; + int _usedBytes = 0; - bool _hasSyncedOnce = false; - int _consecutiveFailures = 0; - bool _isDisconnecting = false; - bool _syncInProgress = false; + bool _hasSyncedOnce = false; + int _consecutiveFailures = 0; + bool _isDisconnecting = false; + bool _syncInProgress = false; + bool _supportRewardClaimed = false; + bool _supportRewardStateLoaded = false; + int _supportStateEpoch = 0; + + static const String _supportRewardClaimKey = + 'support_reward_claimed_active_session'; + static const FlutterSecureStorage _supportStorage = FlutterSecureStorage(); static const int _maxConsecutiveFailures = 3; static const int _maxOfflineSeconds = 120; @@ -29,21 +39,24 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { static const int _pollIntervalSeconds = 5; static const int _backgroundPollIntervalSeconds = 30; - int _lastUsedBytes = 0; - double _currentSpeedKBps = 0.0; - DateTime? _lastSyncAt; + int _lastUsedBytes = 0; + DateTime? _lastSuccessfulSyncAt; + double _currentSpeedKBps = 0.0; SessionTimer({required this.vpnConnection}) { WidgetsBinding.instance.addObserver(this); vpnConnection.addListener(_onVpnConnectionChanged); + unawaited(_loadSupportRewardState()); } - int get remaining => _remainingSeconds; - bool get isRunning => _timer != null && _timer!.isActive; - bool get isExpired => _remainingSeconds <= 0 && !isRunning; - bool get hasSyncedOnce => _hasSyncedOnce; + int get remaining => _remainingSeconds; + bool get isRunning => _timer != null && _timer!.isActive; + bool get isExpired => _remainingSeconds <= 0 && !isRunning; + bool get hasSyncedOnce => _hasSyncedOnce; + bool get supportRewardClaimed => _supportRewardClaimed; + bool get supportRewardStateLoaded => _supportRewardStateLoaded; - int get usedBytes => _usedBytes; + int get usedBytes => _usedBytes; double get currentSpeedKBps => _currentSpeedKBps; String get formatted { @@ -53,8 +66,42 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { return '$h:$m:$s'; } + Future _loadSupportRewardState() async { + final epoch = _supportStateEpoch; + var claimed = false; + try { + claimed = + (await _supportStorage.read(key: _supportRewardClaimKey)) == '1'; + } catch (e) { + debugPrint('[Timer] Failed to load support reward state: $e'); + } + + if (epoch != _supportStateEpoch) return; + _supportRewardClaimed = claimed; + _supportRewardStateLoaded = true; + notifyListeners(); + } + + Future _persistSupportRewardState(bool value) async { + try { + await _supportStorage.write( + key: _supportRewardClaimKey, + value: value ? '1' : '0', + ); + } catch (e) { + debugPrint('[Timer] Failed to persist support reward state: $e'); + } + } + + Future markSupportRewardClaimed() async { + if (_supportRewardClaimed) return; + _supportRewardClaimed = true; + _supportRewardStateLoaded = true; + notifyListeners(); + await _persistSupportRewardState(true); + } + void _onVpnConnectionChanged() { - // Restore timer if VPN was already running at app launch. if (vpnConnection.status == VpnStatus.connected && !isRunning && vpnConnection.isStartupRestoration) { @@ -62,59 +109,90 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { return; } - // Resume after brief tunnel disconnection. if (vpnConnection.status == VpnStatus.connected && !isRunning && !_isDisconnecting && _hasSyncedOnce) { - debugPrint('[Timer] VPN reconnected after blip — resuming.'); + debugPrint('[Timer] VPN reconnected after blip - resuming.'); _resumeTicking(); return; } - if (vpnConnection.status == VpnStatus.disconnected && !_isDisconnecting) { - _doDisconnect('VPN tunnel dropped'); + final hadActiveSession = isRunning || _hasSyncedOnce; + if (!hadActiveSession || _isDisconnecting) return; + + if (vpnConnection.status == VpnStatus.error) { + _stopForVpnFailure('VPN entered error state'); + return; + } + + if (vpnConnection.status == VpnStatus.disconnected) { + unawaited(_doDisconnect('VPN tunnel dropped')); } } - Future start() async { - _remainingSeconds = 0; - _usedBytes = 0; - _lastUsedBytes = 0; - _currentSpeedKBps = 0.0; - _lastSyncAt = null; - NotificationService.reset(); - _tickCount = 0; - _hasSyncedOnce = false; + 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; - _isDisconnecting = false; - _appBackgrounded = false; + _offlineSeconds = 0; + NotificationService.reset(); + notifyListeners(); + } + + Future start() async { + _remainingSeconds = 0; + _remainingAtLastSync = 0; + _usedBytes = 0; + _lastUsedBytes = 0; + _lastSuccessfulSyncAt = null; + _currentSpeedKBps = 0.0; + _tickCount = 0; + _hasSyncedOnce = false; + _consecutiveFailures = 0; + _offlineSeconds = 0; + _isDisconnecting = false; + _appBackgrounded = false; + NotificationService.reset(); + + _supportStateEpoch++; + _supportRewardClaimed = false; + _supportRewardStateLoaded = true; + unawaited(_persistSupportRewardState(false)); _timer?.cancel(); _timer = Timer.periodic(const Duration(seconds: 1), _tick); notifyListeners(); - _syncWithHivemind(); + unawaited(_syncWithHivemind()); } void _tick(Timer t) { if (_isDisconnecting) return; - // A local clock: poll failures are about the network, not the clock. - if (_hasSyncedOnce && _remainingSeconds > 0) { - _remainingSeconds--; + + if (_hasSyncedOnce && _consecutiveFailures < _maxConsecutiveFailures) { + _reconcileElapsedTime(); } if (_consecutiveFailures >= _maxConsecutiveFailures) { _offlineSeconds++; if (_offlineSeconds >= _maxOfflineSeconds) { - _doDisconnect('Server unreachable'); + unawaited(_doDisconnect('Server unreachable')); return; } } if (_hasSyncedOnce && _remainingSeconds <= 0) { - _doDisconnect('Session expired'); + unawaited(_doDisconnect('Session expired')); return; } @@ -123,16 +201,12 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { ? _backgroundPollIntervalSeconds : _pollIntervalSeconds; if (_tickCount % pollInterval == 0) { - _syncWithHivemind(); + unawaited(_syncWithHivemind()); } notifyListeners(); - // Gated on a live tunnel. The plugin cancels the notification when the - // service stops, and re-posting after that leaves an ongoing notification - // the user cannot swipe away below Android 14. - if (_hasSyncedOnce && - vpnConnection.status == VpnStatus.connected) { + if (_hasSyncedOnce && vpnConnection.status == VpnStatus.connected) { NotificationService.updateTimer(formatted); } } @@ -150,8 +224,9 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { _timer = null; _currentSpeedKBps = 0.0; _remainingSeconds = 0; + _remainingAtLastSync = 0; _hasSyncedOnce = false; - _lastSyncAt = null; + _lastSuccessfulSyncAt = null; NotificationService.reset(); notifyListeners(); @@ -162,45 +237,72 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } + int _readNonNegativeInt(dynamic value, int fallback) { + if (value is int) return value >= 0 ? value : fallback; + if (value is num && value >= 0) return value.toInt(); + return fallback; + } + Future _syncWithHivemind() async { if (_isDisconnecting || _syncInProgress) return; _syncInProgress = true; try { final deviceId = await CryptoService.getDeviceId(); - final url = Uri.parse( - '${AppConfig.hivemindApiPublic}/session/status?device_id=$deviceId'); + 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; if (response.statusCode == 200) { - final data = jsonDecode(response.body); + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + debugPrint('[Timer] Invalid session payload type.'); + _markSyncFailure(); + return; + } + final data = decoded; - final bool active = data['active'] ?? false; - if (!active) { + final activeValue = data['active']; + if (activeValue is! bool) { + debugPrint('[Timer] Session payload missing boolean active state.'); + _markSyncFailure(); + return; + } + if (!activeValue) { await _doDisconnect('Server ended session'); return; } - _remainingSeconds = data['expires_in_seconds'] ?? _remainingSeconds; - _usedBytes = data['used_bytes'] ?? _usedBytes; + final expiresValue = data['expires_in_seconds']; + if (expiresValue is! num || + !expiresValue.isFinite || + expiresValue < 0) { + debugPrint('[Timer] Session payload has invalid expiry.'); + _markSyncFailure(); + return; + } - // Measure the real gap between polls: the interval is 5 s in the - // foreground, 30 s in the background, and arbitrary on a resume sync. - final now = DateTime.now(); - final lastSync = _lastSyncAt; - _lastSyncAt = now; + _remainingAtLastSync = expiresValue.toInt(); + _remainingSeconds = _remainingAtLastSync; + _usedBytes = _readNonNegativeInt(data['used_bytes'], _usedBytes); - final int deltaBytes = _usedBytes - _lastUsedBytes; - if (_hasSyncedOnce && deltaBytes > 0 && lastSync != null) { - final elapsed = now.difference(lastSync).inMilliseconds / 1000.0; - _currentSpeedKBps = - elapsed > 0 ? (deltaBytes / elapsed) / 1000 : _currentSpeedKBps; - } else if (!_hasSyncedOnce) { + 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; + } + } else { _currentSpeedKBps = 0.0; } _lastUsedBytes = _usedBytes; + _lastSuccessfulSyncAt = now; - final capExhausted = data['cap_exhausted'] ?? false; - if (capExhausted) { + if (data['cap_exhausted'] == true) { await _doDisconnect('Data cap reached'); return; } @@ -213,8 +315,10 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { _markSyncFailure(); } } catch (e) { - debugPrint('Hivemind sync error: $e'); - _markSyncFailure(); + if (!_isDisconnecting) { + debugPrint('Hivemind sync error: $e'); + _markSyncFailure(); + } } finally { _syncInProgress = false; } @@ -227,27 +331,46 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { } } - void _resumeTicking() { - _lastSyncAt = null; - _timer?.cancel(); - _timer = Timer.periodic(const Duration(seconds: 1), _tick); - _isDisconnecting = false; - _syncWithHivemind(); - notifyListeners(); + void _reconcileElapsedTime() { + final syncedAt = _lastSuccessfulSyncAt; + if (!_hasSyncedOnce || syncedAt == null) return; + + final elapsed = DateTime.now().difference(syncedAt).inSeconds; + _remainingSeconds = + (_remainingAtLastSync - elapsed).clamp(0, 1 << 31).toInt(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.paused || - state == AppLifecycleState.detached) { - // Keep ticking, but slow the poll — the 5 s one wakes the radio. + state == AppLifecycleState.detached || + state == AppLifecycleState.inactive || + state == AppLifecycleState.hidden) { _appBackgrounded = true; - } else if (state == AppLifecycleState.resumed) { - _appBackgrounded = false; - if (_timer != null && _timer!.isActive) { - _syncWithHivemind(); - } + return; } + + if (state != AppLifecycleState.resumed || _isDisconnecting) return; + _appBackgrounded = false; + if (vpnConnection.status != VpnStatus.connected) return; + + _reconcileElapsedTime(); + if (!isRunning) { + _resumeTicking(); + return; + } + + unawaited(_syncWithHivemind()); + notifyListeners(); + } + + void _resumeTicking() { + _reconcileElapsedTime(); + _timer?.cancel(); + _timer = Timer.periodic(const Duration(seconds: 1), _tick); + _isDisconnecting = false; + unawaited(_syncWithHivemind()); + notifyListeners(); } @override diff --git a/lib/logic/updater.dart b/lib/logic/updater.dart index 5a935ef..f3e55bb 100644 --- a/lib/logic/updater.dart +++ b/lib/logic/updater.dart @@ -13,6 +13,78 @@ enum UpdateStatus { upToDate, updateAvailable, checkFailed } enum InstallSource { playStore, sideload } +Uri? _trustedHttpsUri(String raw, Set allowedHosts) { + final uri = Uri.tryParse(raw); + if (uri == null || uri.scheme != 'https' || !allowedHosts.contains(uri.host)) { + return null; + } + return uri; +} + +class _ParsedVersion { + final List core; + final List preRelease; + + const _ParsedVersion(this.core, this.preRelease); + + static _ParsedVersion? tryParse(String input) { + var value = input.trim(); + if (value.startsWith('v') || value.startsWith('V')) { + value = value.substring(1); + } + value = value.split('+').first; + final dash = value.indexOf('-'); + final corePart = dash < 0 ? value : value.substring(0, dash); + final prePart = dash < 0 ? '' : value.substring(dash + 1); + final rawCore = corePart.split('.'); + if (rawCore.isEmpty || rawCore.any((p) => int.tryParse(p) == null)) { + return null; + } + final core = rawCore.map(int.parse).toList(growable: false); + while (core.length < 3) { + core.add(0); + } + final pre = prePart.isEmpty + ? const [] + : prePart.split('.').where((e) => e.isNotEmpty).toList(growable: false); + return _ParsedVersion(core, pre); + } + + int compareTo(_ParsedVersion other) { + final length = core.length > other.core.length ? core.length : other.core.length; + for (var i = 0; i < length; i++) { + final left = i < core.length ? core[i] : 0; + final right = i < other.core.length ? other.core[i] : 0; + if (left != right) return left.compareTo(right); + } + + if (preRelease.isEmpty && other.preRelease.isEmpty) return 0; + if (preRelease.isEmpty) return 1; + if (other.preRelease.isEmpty) return -1; + + final preLength = preRelease.length > other.preRelease.length + ? preRelease.length + : other.preRelease.length; + for (var i = 0; i < preLength; i++) { + if (i >= preRelease.length) return -1; + if (i >= other.preRelease.length) return 1; + final left = preRelease[i]; + final right = other.preRelease[i]; + final leftNum = int.tryParse(left); + final rightNum = int.tryParse(right); + if (leftNum != null && rightNum != null) { + if (leftNum != rightNum) return leftNum.compareTo(rightNum); + continue; + } + if (leftNum != null) return -1; + if (rightNum != null) return 1; + final lexical = left.compareTo(right); + if (lexical != 0) return lexical; + } + return 0; + } +} + class PlayStoreUpdater { PlayStoreUpdater._(); @@ -20,9 +92,13 @@ class PlayStoreUpdater { 'https://play.google.com/store/apps/details?id=${AppConfig.applicationId}'; static Future open() async { + final uri = _trustedHttpsUri(_url, const {'play.google.com'}); + if (uri == null) { + debugPrint('[Updater] Refusing invalid Play Store URL.'); + return false; + } try { - return await launchUrl(Uri.parse(_url), - mode: LaunchMode.externalApplication); + return await launchUrl(uri, mode: LaunchMode.externalApplication); } catch (e) { debugPrint('[Updater] Failed to open Play Store: $e'); return false; @@ -36,13 +112,14 @@ class GitHubUpdater { static String? _cachedDownloadUrl; static Future fetchLatestVersion() async { - final url = - 'https://api.github.com/repos/${AppConfig.githubOwner}/${AppConfig.githubRepo}/releases/latest'; + final apiUri = Uri.https( + 'api.github.com', + '/repos/${AppConfig.githubOwner}/${AppConfig.githubRepo}/releases/latest', + ); try { final response = await http - .get(Uri.parse(url), - headers: {'Accept': 'application/vnd.github+json'}) + .get(apiUri, headers: {'Accept': 'application/vnd.github+json'}) .timeout(const Duration(seconds: 8)); if (response.statusCode != 200) { @@ -50,38 +127,48 @@ class GitHubUpdater { return null; } - final data = jsonDecode(response.body) as Map; - _cachedDownloadUrl = data['html_url'] as String?; + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + debugPrint('[Updater] Invalid GitHub release payload.'); + return null; + } - final tag = data['tag_name'] as String? ?? '0.0.0'; - return tag.startsWith('v') ? tag.substring(1) : tag; + final htmlUrl = decoded['html_url']; + if (htmlUrl is String && + _trustedHttpsUri(htmlUrl, const {'github.com'}) != null) { + _cachedDownloadUrl = htmlUrl; + } else { + _cachedDownloadUrl = null; + } + + final rawTag = decoded['tag_name']; + if (rawTag is! String || rawTag.isEmpty || rawTag.length > 64) { + debugPrint('[Updater] Invalid release tag.'); + return null; + } + + final normalized = rawTag.startsWith('v') ? rawTag.substring(1) : rawTag; + if (_ParsedVersion.tryParse(normalized) == null) { + debugPrint('[Updater] Release tag is not a supported version: $rawTag'); + return null; + } + return normalized; } catch (e) { debugPrint('[Updater] GitHub API error: $e'); return null; } } - static bool _isTrustedReleaseUrl(String url) { - final uri = Uri.tryParse(url); - if (uri == null || uri.scheme != 'https' || uri.host != 'github.com') { + static Future open() async { + final raw = _cachedDownloadUrl ?? AppConfig.githubReleasesUrl; + final uri = _trustedHttpsUri(raw, const {'github.com'}); + if (uri == null) { + debugPrint('[Updater] Refusing untrusted GitHub update URL.'); return false; } - // Host alone is not enough: anyone can host a release on github.com. - return uri.path - .startsWith('/${AppConfig.githubOwner}/${AppConfig.githubRepo}/'); - } - static Future open() async { - final cached = _cachedDownloadUrl; - final url = (cached != null && _isTrustedReleaseUrl(cached)) - ? cached - : AppConfig.githubReleasesUrl; - if (cached != null && url != cached) { - debugPrint('[Updater] Rejected untrusted release URL: $cached'); - } try { - return await launchUrl(Uri.parse(url), - mode: LaunchMode.externalApplication); + return await launchUrl(uri, mode: LaunchMode.externalApplication); } catch (e) { debugPrint('[Updater] Failed to open GitHub: $e'); return false; @@ -116,14 +203,13 @@ class Updater { } static int _compareVersions(String a, String b) { - final aParts = a.split('.').map((p) => int.tryParse(p) ?? 0).toList(); - final bParts = b.split('.').map((p) => int.tryParse(p) ?? 0).toList(); - for (int i = 0; i < 3; i++) { - final diff = (aParts.length > i ? aParts[i] : 0) - - (bParts.length > i ? bParts[i] : 0); - if (diff != 0) return diff; + final left = _ParsedVersion.tryParse(a); + final right = _ParsedVersion.tryParse(b); + if (left == null || right == null) { + // Invalid local metadata should not invent an ordering. + return 0; } - return 0; + return left.compareTo(right); } static String _versionLabel(String v, bool isLocal) { @@ -131,15 +217,27 @@ class Updater { return '$prefix$v${isLocal ? ' (installed)' : ''}'; } - static Future check({ + static Future check(BuildContext context) { + final navigator = Navigator.of(context, rootNavigator: true); + final messenger = ScaffoldMessenger.of(context); + return checkWithHandles( + navigator: navigator, + messenger: messenger, + ); + } + + static Future checkWithHandles({ required NavigatorState navigator, required ScaffoldMessengerState messenger, }) async { final info = await PackageInfo.fromPlatform(); final localVersion = info.version; - if (localVersion.isEmpty) { - _showSnackBar(messenger, 'Could not determine app version', - isError: true); + if (localVersion.isEmpty || _ParsedVersion.tryParse(localVersion) == null) { + _showSnackBar( + messenger, + 'Could not determine app version', + isError: true, + ); return; } @@ -155,15 +253,18 @@ class Updater { } final source = await installSource; - final storeName = source == InstallSource.playStore ? 'Play Store' : 'GitHub'; + final storeName = + source == InstallSource.playStore ? 'Play Store' : 'GitHub'; if (!navigator.mounted) return; showDialog( context: navigator.context, builder: (ctx) => AlertDialog( backgroundColor: AppColors.bgCard, - title: const Text('Update available', - style: TextStyle(color: AppColors.textWhite)), + title: const Text( + 'Update available', + style: TextStyle(color: AppColors.textWhite), + ), content: Text( '${_versionLabel(latestVersion, false)} is available on $storeName.\n' 'You have ${_versionLabel(localVersion, true)}.', @@ -172,8 +273,10 @@ class Updater { actions: [ TextButton( onPressed: () => Navigator.pop(ctx), - child: const Text('Later', - style: TextStyle(color: AppColors.slate70)), + child: const Text( + 'Later', + style: TextStyle(color: AppColors.slate70), + ), ), TextButton( onPressed: () { @@ -182,24 +285,31 @@ class Updater { ? PlayStoreUpdater.open() : GitHubUpdater.open(); }, - child: Text('Open $storeName', - style: const TextStyle(color: AppColors.accent)), + child: Text( + 'Open $storeName', + style: const TextStyle(color: AppColors.accent), + ), ), ], ), ); } - static void _showSnackBar(ScaffoldMessengerState messenger, String message, - {bool isError = false}) { + static void _showSnackBar( + ScaffoldMessengerState messenger, + String message, { + bool isError = false, + }) { if (!messenger.mounted) return; messenger.showSnackBar( SnackBar( content: Row( children: [ - Icon(isError ? Icons.error_outline : Icons.check_circle, - color: isError ? AppColors.red : AppColors.green, - size: 18), + Icon( + isError ? Icons.error_outline : Icons.check_circle, + color: isError ? AppColors.red : AppColors.green, + size: 18, + ), const SizedBox(width: 8), Text(message), ], diff --git a/lib/logic/vpn_connection.dart b/lib/logic/vpn_connection.dart index f89ef25..d982589 100644 --- a/lib/logic/vpn_connection.dart +++ b/lib/logic/vpn_connection.dart @@ -1,8 +1,13 @@ import 'dart:async'; + import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.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/installed_apps_service.dart'; +import 'package:revoltvpn/logic/local_socks_tester.dart'; +import 'package:revoltvpn/logic/network_monitor.dart'; +import 'package:revoltvpn/logic/secure_socks_session.dart'; enum VpnStatus { disconnected, @@ -12,7 +17,21 @@ enum VpnStatus { error, } -class VpnConnection extends ChangeNotifier with WidgetsBindingObserver { +class _RoutingPlan { + final List? blockedApps; + final bool selectedOnly; + final bool verifyLocalSocks; + + const _RoutingPlan({ + required this.blockedApps, + this.selectedOnly = false, + this.verifyLocalSocks = false, + }); +} + +class VpnConnection extends ChangeNotifier { + static const int _maxExtremeRecoveryAttempts = 2; + bool _cancelled = false; VpnStatus _status = VpnStatus.disconnected; VpnStatus get status => _status; @@ -29,7 +48,83 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver { bool _serverReachable = false; bool get serverReachable => _serverReachable; + ConnectionMode _activeMode = ConnectionMode.tun; + ConnectionMode get activeMode => _activeMode; + + ResilienceMode _activeResilienceMode = ResilienceMode.standard; + ResilienceMode get activeResilienceMode => _activeResilienceMode; + + bool _selectedRoutingActive = false; + bool get selectedRoutingActive => _selectedRoutingActive; + + String _networkTransport = 'unknown'; + String get networkTransport => _networkTransport; + + bool _networkValidated = false; + bool get networkValidated => _networkValidated; + + int? _lastHealthLatencyMs; + int? get lastHealthLatencyMs => _lastHealthLatencyMs; + + int _reconnectCount = 0; + int get reconnectCount => _reconnectCount; + + int get fallbackCount => 0; + + String? _lastRecoveryReason; + String? get lastRecoveryReason => _lastRecoveryReason; + + String get activeRoutingDescription { + if (_activeMode == ConnectionMode.proxy) { + if (_selectedRoutingActive) return 'SOCKS5 · selected apps'; + if (_lastBlockedApps?.isNotEmpty == true) return 'SOCKS5 · exclude apps'; + return 'SOCKS5 · all apps'; + } + if (_selectedRoutingActive) return 'Selected apps'; + if (_lastBlockedApps?.isNotEmpty == true) return 'Exclude apps'; + return 'All apps'; + } + + String get activeTransportProfile => + _status == VpnStatus.connected || _status == VpnStatus.connecting + ? 'server' + : 'inactive'; + + bool get canTestActiveLocalSocks => + _status == VpnStatus.connected && + _activeMode == ConnectionMode.proxy && + _lastSecureSocks != null; + + Future testActiveLocalSocks() async { + final active = _lastSecureSocks; + if (!canTestActiveLocalSocks || active == null) { + return const LocalSocksTestResult( + ok: false, + latencyMs: null, + message: 'No active authenticated SOCKS5 session.', + ); + } + + return LocalSocksTester.test( + host: '127.0.0.1', + port: active.port, + username: active.username, + password: active.password, + ); + } + Timer? _healthTimer; + Timer? _restartTimer; + StreamSubscription? _networkSubscription; + + String? _lastBaseConfig; + String _lastRemark = 'Revolt VPN'; + List? _lastBlockedApps; + bool _lastSelectedOnly = false; + bool _lastVerifyLocalSocks = false; + SecureSocksSession? _lastSecureSocks; + bool _restartInProgress = false; + bool _userDisconnecting = false; late final FlutterVless _vless; bool _initialized = false; @@ -37,7 +132,8 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver { Future get ready => _readyCompleter.future; VpnConnection() { - WidgetsBinding.instance.addObserver(this); + _activeMode = ConnectionSettings.mode; + _activeResilienceMode = ConnectionSettings.resilienceMode; _init(); } @@ -54,10 +150,16 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver { Future _startEngine() async { if (kIsWeb) return; + await ConnectionSettings.initialize(); + _activeMode = ConnectionSettings.mode; + _activeResilienceMode = ConnectionSettings.resilienceMode; + _vless = FlutterVless( onStatusChanged: (status) { - debugPrint('[VPN] Status: state=${status.state} ' - 'connection=${status.connectionState.name}'); + debugPrint( + '[VPN] Status: state=${status.state} ' + 'connection=${status.connectionState.name}', + ); _mapStatus(status); }, ); @@ -66,14 +168,30 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver { await _vless.initializeVless( providerBundleIdentifier: 'com.paladinvpn.app', notificationIconResourceType: 'drawable', - notificationIconResourceName: 'notification_icon', + notificationIconResourceName: 'notification_status_icon', ); _initialized = true; } catch (e) { debugPrint('[VPN] VLESS init error (expected on emulator): $e'); } - _startHealthPolling(); + _networkSubscription = NetworkMonitor.changes.listen( + (snapshot) { + // ConnectivityManager is informational only. Restarting from every + // callback caused the old VPN-created-network reconnect loop. + _networkTransport = snapshot.transport; + _networkValidated = snapshot.validated; + notifyListeners(); + }, + onError: (Object error) { + debugPrint('[VPN] Network monitor error: $error'); + }, + ); + + unawaited(_checkHealth()); + _healthTimer = Timer.periodic(const Duration(seconds: 30), (_) { + unawaited(_checkHealth()); + }); try { final coreVersion = await _vless.getCoreVersion(); @@ -81,53 +199,117 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver { final delay = await _vless.getConnectedServerDelay(); if (delay > 0) { + _lastHealthLatencyMs = delay; _isStartupRestoration = true; - _setStatus(VpnStatus.connected, 'Secured'); + _restoreRoutingPresentationFromSettings(); + _setStatus(VpnStatus.connected, _connectedLabel); } } catch (_) {} } + void _restoreRoutingPresentationFromSettings() { + final routingMode = ConnectionSettings.routingMode; + final packages = ConnectionSettings.appPackages; + + _lastBlockedApps = null; + _lastSelectedOnly = false; + _lastVerifyLocalSocks = _activeMode == ConnectionMode.proxy; + _selectedRoutingActive = false; + + if (routingMode == AppRoutingMode.selected) { + _lastSelectedOnly = packages.isNotEmpty; + _selectedRoutingActive = packages.isNotEmpty; + return; + } + + if (routingMode == AppRoutingMode.exclude && packages.isNotEmpty) { + _lastBlockedApps = List.of(packages); + } + } + + String get _connectedLabel { + switch (_activeMode) { + case ConnectionMode.proxy: + return _selectedRoutingActive + ? 'SOCKS5 · selected apps secured' + : 'SOCKS5 gateway active'; + case ConnectionMode.tun: + return _selectedRoutingActive ? 'Selected apps secured' : 'Secured'; + } + } + void _mapStatus(VlessStatus status) { switch (status.connectionState) { case VlessConnectionState.connected: - _setStatus(VpnStatus.connected, 'Secured'); + _setStatus(VpnStatus.connected, _connectedLabel); break; + case VlessConnectionState.disconnected: - _isStartupRestoration = false; + // Repeated DISCONNECTED broadcasts during an Extreme recovery debounce + // must not clear the config snapshot the pending recovery needs. + if (_restartInProgress || (_restartTimer?.isActive ?? false)) return; + + final canRecover = !_userDisconnecting && + _activeResilienceMode == ResilienceMode.extreme && + _lastBaseConfig != null && + _status == VpnStatus.connected; + if (canRecover) { + _scheduleRuntimeRestart('Runtime disconnected'); + return; + } + + _clearRuntimeSnapshot(); _setStatus(VpnStatus.disconnected, 'Tap to connect'); break; + case VlessConnectionState.connecting: - _setStatus(VpnStatus.connecting, 'Establishing tunnel…'); + if (!_restartInProgress) { + _setStatus(VpnStatus.connecting, 'Establishing tunnel…'); + } break; + case VlessConnectionState.disconnecting: _isStartupRestoration = false; - _setStatus(VpnStatus.disconnecting, 'Tearing down…'); + if (!_restartInProgress) { + _setStatus(VpnStatus.disconnecting, 'Tearing down…'); + } break; + case VlessConnectionState.unknown: if (_status != VpnStatus.connected && - _status != VpnStatus.disconnected) { + _status != VpnStatus.disconnected && + !_restartInProgress) { _setStatus(VpnStatus.error, 'Connection failed'); } break; } } - // ── Connect ──────────────────────────────────────────────────────── - // - // 1. Direct HTTPS to the public API → get per-user VLESS URL - // 2. Start tunnel directly with that URL (no bootstrap, no switch) - // - // The only hardcoded data in the APK is the Reality public key and - // shortId (needed for every Reality handshake — unavoidable). - // The tunnel UUID is per-user, assigned by Hivemind via AdMob SSV. - - Future connect({ - bool skipAdBypass = false, - }) async { + Future connect({bool skipAdBypass = false}) async { if (_status == VpnStatus.connected || _status == VpnStatus.connecting) { return false; } + _cancelled = false; + _userDisconnecting = false; + _restartTimer?.cancel(); + _reconnectCount = 0; + _lastRecoveryReason = null; + _lastHealthLatencyMs = null; + _selectedRoutingActive = false; + + await ConnectionSettings.initialize(); + _activeMode = ConnectionSettings.mode; + _activeResilienceMode = ConnectionSettings.resilienceMode; + + late final _RoutingPlan routingPlan; + try { + routingPlan = await _resolveRoutingPlan(); + } catch (e) { + _errorMessage = e.toString().replaceAll('Bad state: ', ''); + _setStatus(VpnStatus.error, 'App routing error'); + return false; + } if (!kIsWeb && !_initialized) { _errorMessage = 'VPN service unavailable.'; @@ -135,6 +317,8 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver { return false; } + // TUN and SOCKS5 share the stable Android VpnService. SOCKS5 uses + // an authenticated per-session local ingress instead of a fixed port. if (!kIsWeb) { final ok = await _vless.requestPermission(); if (!ok) { @@ -144,7 +328,15 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver { } } - _setStatus(VpnStatus.connecting, 'Establishing secure channel…'); + final startingMessage = switch (_activeMode) { + ConnectionMode.tun => routingPlan.selectedOnly + ? 'Starting selected-app tunnel…' + : 'Establishing secure channel…', + ConnectionMode.proxy => routingPlan.selectedOnly + ? 'SOCKS5 · starting selected-app gateway…' + : 'Starting transparent SOCKS5 gateway…', + }; + _setStatus(VpnStatus.connecting, startingMessage); _errorMessage = null; if (kIsWeb) { @@ -161,7 +353,9 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver { skipAdBypass: skipAdBypass, onAttempt: (attempt, total) { _setStatus( - VpnStatus.connecting, 'Contacting server ($attempt/$total)…'); + VpnStatus.connecting, + 'Contacting server ($attempt/$total)…', + ); }, ); } catch (e) { @@ -173,7 +367,7 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver { 'The server did not respond in time.\nCheck your connection and try again.'; _setStatus(VpnStatus.error, 'Server unreachable'); } else { - _errorMessage = e.toString().replaceAll('Exception: ', ''); + _errorMessage = raw; _setStatus(VpnStatus.error, 'Config fetch error'); } return false; @@ -184,31 +378,299 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver { return false; } - _setStatus(VpnStatus.connecting, 'Securing connection…'); + _setStatus(VpnStatus.connecting, 'Starting secure route…'); try { final parsed = FlutterVless.parse(realUrl); + final baseConfig = parsed.getFullConfiguration(); + final remark = parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN'; + final secureSocks = await SecureSocksSession.create(baseConfig); - await _vless.startVless( - remark: parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN', - config: parsed.getFullConfiguration(), + // Set this before the native callback can report CONNECTED so the first + // visible label already reflects Selected only accurately. + _selectedRoutingActive = routingPlan.selectedOnly; + + await _startRuntime( + config: secureSocks.configJson, + remark: remark, + blockedApps: routingPlan.blockedApps, ); + + _setStatus(VpnStatus.connecting, 'Waiting for VPN interface…'); + if (!await _waitForNativeConnected()) { + throw StateError('VPN runtime did not report CONNECTED'); + } + + if (routingPlan.verifyLocalSocks) { + _setStatus(VpnStatus.connecting, 'Checking Local SOCKS5…'); + if (!await _waitForLocalSocksListener(secureSocks)) { + throw StateError('Local SOCKS5 listener did not become ready'); + } + } + + _lastBaseConfig = secureSocks.configJson; + _lastSecureSocks = secureSocks; + _lastRemark = remark; + _lastBlockedApps = routingPlan.blockedApps == null + ? null + : List.of(routingPlan.blockedApps!); + _lastSelectedOnly = routingPlan.selectedOnly; + _lastVerifyLocalSocks = routingPlan.verifyLocalSocks; + _selectedRoutingActive = routingPlan.selectedOnly; } catch (e) { debugPrint('[VPN] Tunnel start error: $e'); - _errorMessage = 'Tunnel failed to start.\nTry reconnecting.'; + await _stopRuntime(); + _clearRuntimeSnapshot(); + _errorMessage = _activeMode == ConnectionMode.proxy + ? 'SOCKS5 gateway failed to start.\nTry reconnecting.' + : 'Connection failed to start.\nTry reconnecting.'; _setStatus(VpnStatus.error, 'Connection failed'); return false; } - _setStatus(VpnStatus.connected, 'Secured'); + _setStatus(VpnStatus.connected, _connectedLabel); return true; } + Future<_RoutingPlan> _resolveRoutingPlan() async { + final routingMode = ConnectionSettings.routingMode; + final selected = ConnectionSettings.appPackages; + + switch (_activeMode) { + case ConnectionMode.tun: + if (routingMode == AppRoutingMode.selected) { + return _selectedCompatibilityPlan(); + } + return _tunPlan(routingMode, selected); + + case ConnectionMode.proxy: + // SOCKS5 is a transparent gateway: Android captures ordinary app + // traffic into the same local SOCKS ingress used by Xray/tun2socks. + if (routingMode == AppRoutingMode.selected) { + return _selectedCompatibilityPlan(verifyLocalSocks: true); + } + return _tunPlan( + routingMode, + selected, + verifyLocalSocks: true, + ); + } + } + + _RoutingPlan _tunPlan( + AppRoutingMode routingMode, + List selected, { + bool verifyLocalSocks = false, + }) { + if (routingMode == AppRoutingMode.exclude) { + return _RoutingPlan( + blockedApps: selected.isEmpty ? null : selected, + verifyLocalSocks: verifyLocalSocks, + ); + } + + return _RoutingPlan( + blockedApps: null, + verifyLocalSocks: verifyLocalSocks, + ); + } + + Future<_RoutingPlan> _selectedCompatibilityPlan({ + bool verifyLocalSocks = false, + }) async { + final selected = ConnectionSettings.appPackages.toSet(); + if (selected.isEmpty) { + throw StateError('Select at least one app.'); + } + + final apps = await InstalledAppsService.loadLaunchableApps(); + final availableSelected = apps + .where((app) => selected.contains(app.packageName)) + .map((app) => app.packageName) + .toSet(); + + if (availableSelected.isEmpty) { + throw StateError('None of the selected apps are installed.'); + } + + // Compatibility Selected only uses the plugin's proven blocklist path: + // every other launchable user app bypasses the VPN, while Android system + // helpers (DNS resolver, Play Services, network stack, etc.) remain able to + // support selected apps such as Discord. + final blocked = apps + .map((app) => app.packageName) + .where((packageName) => !availableSelected.contains(packageName)) + .toSet() + .toList() + ..sort(); + + return _RoutingPlan( + blockedApps: blocked.isEmpty ? null : blocked, + selectedOnly: true, + verifyLocalSocks: verifyLocalSocks, + ); + } + + Future _startRuntime({ + required String config, + required String remark, + required List? blockedApps, + }) async { + await _vless.startVless( + remark: remark, + config: config, + blockedApps: blockedApps, + proxyOnly: false, + ); + } + + Future _waitForLocalSocksListener([ + SecureSocksSession? session, + ]) async { + final active = session ?? _lastSecureSocks; + if (active == null) return false; + + for (var attempt = 0; attempt < 4; attempt++) { + final result = await LocalSocksTester.testListener( + host: '127.0.0.1', + port: active.port, + username: active.username, + password: active.password, + ); + if (result.ok) { + _lastHealthLatencyMs = result.latencyMs; + notifyListeners(); + return true; + } + if (attempt < 3) { + await Future.delayed(const Duration(milliseconds: 250)); + } + } + 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. + for (var attempt = 0; attempt < 24; attempt++) { + if (_status == VpnStatus.connected) return true; + if (_status == VpnStatus.error) return false; + if (_cancelled || _userDisconnecting) return false; + await Future.delayed(const Duration(milliseconds: 250)); + } + return _status == VpnStatus.connected; + } + + void _scheduleRuntimeRestart(String reason) { + if (_activeResilienceMode != ResilienceMode.extreme || + _restartInProgress || + _userDisconnecting || + _lastBaseConfig == null || + (_restartTimer?.isActive ?? false)) { + return; + } + + _lastRecoveryReason = reason; + _setStatus(VpnStatus.connecting, 'Reconnecting…'); + _restartTimer = Timer(const Duration(milliseconds: 1200), () { + unawaited(_restartActiveRuntime(reason)); + }); + } + + Future _restartActiveRuntime(String reason) async { + if (_restartInProgress || _userDisconnecting || _lastBaseConfig == null) { + return; + } + + _restartInProgress = true; + Object? lastError; + + try { + for (var attempt = 1; attempt <= _maxExtremeRecoveryAttempts; attempt++) { + if (_userDisconnecting || _lastBaseConfig == null) return; + + try { + debugPrint( + '[VPN] Extreme recovery attempt ' + '$attempt/$_maxExtremeRecoveryAttempts: $reason', + ); + + await _stopRuntime(); + if (_userDisconnecting) return; + + await Future.delayed( + Duration(milliseconds: attempt == 1 ? 600 : 900), + ); + if (_userDisconnecting || _lastBaseConfig == null) return; + + await _startRuntime( + config: _lastBaseConfig!, + remark: _lastRemark, + blockedApps: _lastBlockedApps, + ); + + if (!await _waitForNativeConnected()) { + throw StateError('VPN runtime did not recover'); + } + if (_lastVerifyLocalSocks && !(await _waitForLocalSocksListener())) { + throw StateError('Local SOCKS5 listener did not recover'); + } + + if (_userDisconnecting) return; + + _reconnectCount++; + _lastRecoveryReason = reason; + _selectedRoutingActive = _lastSelectedOnly; + _errorMessage = null; + _setStatus(VpnStatus.connected, _connectedLabel); + return; + } catch (e) { + lastError = e; + debugPrint( + '[VPN] Extreme recovery attempt ' + '$attempt/$_maxExtremeRecoveryAttempts failed: $e', + ); + await _stopRuntime(); + if (_userDisconnecting) return; + + if (attempt < _maxExtremeRecoveryAttempts) { + _setStatus( + VpnStatus.connecting, + 'Reconnecting… (${attempt + 1}/$_maxExtremeRecoveryAttempts)', + ); + await Future.delayed(const Duration(milliseconds: 700)); + } + } + } + + if (_userDisconnecting) return; + + debugPrint('[VPN] Extreme recovery exhausted: $lastError'); + _clearRuntimeSnapshot(); + _errorMessage = 'Could not restore the connection.'; + _setStatus(VpnStatus.error, 'Reconnect failed'); + } finally { + _restartInProgress = false; + } + } + + Future _stopRuntime() async { + try { + await _vless.stopVless().timeout(const Duration(seconds: 5)); + } catch (_) {} + } + Future disconnect() async { _cancelled = true; + _userDisconnecting = true; + _restartTimer?.cancel(); HivemindService.cancel(); + if (_status == VpnStatus.disconnected || _status == VpnStatus.disconnecting) { + _clearRuntimeSnapshot(); + _userDisconnecting = false; return; } @@ -216,7 +678,9 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver { if (kIsWeb) { await Future.delayed(const Duration(milliseconds: 500)); + _clearRuntimeSnapshot(); _setStatus(VpnStatus.disconnected, 'Tap to connect'); + _userDisconnecting = false; return; } @@ -229,65 +693,56 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver { } catch (e) { debugPrint('[VPN] VLESS stop error: $e'); _errorMessage = 'VPN shutdown error.\nPlease restart the app.'; + _clearRuntimeSnapshot(); _setStatus(VpnStatus.error, 'Shutdown failed'); + _userDisconnecting = false; return; } if (timedOut) { - debugPrint('[VPN] stopVless() timed out after 5 s — ' - 'tunnel may still be active.'); - _errorMessage = 'VPN did not shut down cleanly.\n' - 'Please restart the app.'; + 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; } + _errorMessage = null; + _clearRuntimeSnapshot(); _setStatus(VpnStatus.disconnected, 'Tap to connect'); + _userDisconnecting = false; } - void _setStatus(VpnStatus s, String msg) { - _status = s; - _statusMessage = msg; + void _clearRuntimeSnapshot() { + _lastBaseConfig = null; + _lastBlockedApps = null; + _lastSelectedOnly = false; + _lastVerifyLocalSocks = false; + _lastSecureSocks = null; + _selectedRoutingActive = false; + _lastHealthLatencyMs = null; + _isStartupRestoration = false; + } + + void _setStatus(VpnStatus status, String message) { + _status = status; + _statusMessage = message; notifyListeners(); } - void _startHealthPolling() { - _healthTimer?.cancel(); - _checkHealthIfIdle(); - _healthTimer = Timer.periodic( - const Duration(seconds: 30), (_) => _checkHealthIfIdle()); - } - - Future _checkHealthIfIdle() async { - // A live tunnel already proves reachability. - if (_status == VpnStatus.connected) return; - await _checkHealth(); - } - Future _checkHealth() async { _serverReachable = await HivemindService.checkHealth(); notifyListeners(); } - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.paused || - state == AppLifecycleState.detached) { - _healthTimer?.cancel(); - } else if (state == AppLifecycleState.resumed) { - _startHealthPolling(); - } - } - @override void dispose() { - WidgetsBinding.instance.removeObserver(this); _healthTimer?.cancel(); - if (_status == VpnStatus.connected || _status == VpnStatus.connecting) { - try { - _vless.stopVless(); - } catch (_) {} - } + _restartTimer?.cancel(); + _networkSubscription?.cancel(); + // Provider/UI disposal is not a user disconnect command. Keeping teardown + // in disconnect() prevents lifecycle churn from silently dropping the VPN. super.dispose(); } } diff --git a/lib/main.dart b/lib/main.dart index 3d4dd22..36359c5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,18 +1,24 @@ -import 'package:flutter/material.dart'; import 'dart:ui'; + +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; - -import 'package:revoltvpn/logic/vpn_connection.dart'; -import 'package:revoltvpn/logic/session_timer.dart'; +import 'package:revoltvpn/components/app_haptic_surface.dart'; import 'package:revoltvpn/logic/ad_manager.dart'; -import 'package:revoltvpn/logic/server_list.dart'; import 'package:revoltvpn/logic/app_colors.dart'; +import 'package:revoltvpn/logic/connection_settings.dart'; +import 'package:revoltvpn/logic/haptic_settings.dart'; +import 'package:revoltvpn/logic/server_list.dart'; +import 'package:revoltvpn/logic/session_timer.dart'; +import 'package:revoltvpn/logic/vpn_connection.dart'; import 'package:revoltvpn/screens/intro.dart'; -void main() { + +Future main() async { WidgetsFlutterBinding.ensureInitialized(); - SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); + await HapticSettings.initialize(); + await ConnectionSettings.initialize(); + await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( statusBarColor: Colors.transparent, @@ -40,23 +46,25 @@ class ReVoltApp extends StatelessWidget { Widget build(BuildContext context) { return MultiProvider( providers: [ - // lazy: false — the native VLESS engine takes real time to start, and - // providers are lazy by default, so it used to begin initialising only - // once MainScreen first read it, i.e. *after* the loading screen was - // gone. Constructing it eagerly moves that work under the intro, which - // now awaits VpnConnection.ready before navigating. ChangeNotifierProvider(create: (_) => VpnConnection(), lazy: false), ChangeNotifierProxyProvider( - create: (ctx) => SessionTimer(vpnConnection: ctx.read()), - update: (_, vpn, prev) => prev ?? SessionTimer(vpnConnection: vpn), + create: (ctx) => + SessionTimer(vpnConnection: ctx.read()), + update: (_, vpn, prev) => + prev ?? SessionTimer(vpnConnection: vpn), ), ChangeNotifierProvider(create: (_) => AdManager()), - ChangeNotifierProvider(create: (_) => ServerList()..init(), lazy: false), + ChangeNotifierProvider( + create: (_) => ServerList()..init(), + lazy: false, + ), ], child: MaterialApp( title: 'ReVolt VPN', debugShowCheckedModeBanner: false, theme: _buildDarkTheme(), + builder: (context, child) => + AppHapticSurface(child: child ?? const SizedBox.shrink()), home: const IntroScreen(), ), ); diff --git a/lib/screens/intro.dart b/lib/screens/intro.dart index 59c41c7..cc44177 100644 --- a/lib/screens/intro.dart +++ b/lib/screens/intro.dart @@ -2,11 +2,11 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:revoltvpn/logic/app_colors.dart'; +import 'package:revoltvpn/logic/haptic_settings.dart'; import 'package:revoltvpn/logic/vpn_connection.dart'; import 'package:revoltvpn/screens/main_screen.dart'; import 'package:revoltvpn/screens/settings/in_settings/rain.dart'; import 'package:revoltvpn/screens/settings/in_settings/lightning.dart'; -import 'package:revoltvpn/screens/settings/in_settings/haptic.dart'; class IntroScreen extends StatefulWidget { const IntroScreen({super.key}); @@ -45,10 +45,12 @@ class _IntroScreenState extends State with WidgetsBindingObserver { final elapsed = Stopwatch()..start(); final vpn = context.read(); - // The overlays read these in initState, so they must land before - // MainScreen mounts. - final settingsPrefs = Future.wait( - [loadRainPref(), loadLightningPref(), loadHapticPref()]); + // The overlays and haptic layer read these before MainScreen mounts. + final settingsPrefs = Future.wait([ + loadRainPref(), + loadLightningPref(), + HapticSettings.initialize(), + ]); try { await vpn.ready.timeout(_bootTimeout); @@ -79,7 +81,7 @@ class _IntroScreenState extends State with WidgetsBindingObserver { void _navigateIfReady() { if (!mounted || _navigated || !_bootComplete) return; if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.paused) { - return; // resume will call back in + return; } _navigated = true; @@ -101,7 +103,6 @@ class _IntroScreenState extends State with WidgetsBindingObserver { body: Column( children: [ const Spacer(flex: 7), - // Image slightly below center Center( child: Image.asset( 'assets/brand.png', @@ -116,7 +117,6 @@ class _IntroScreenState extends State with WidgetsBindingObserver { }, ), ), - // Spinner + text paired together below the image const SizedBox(height: 8), const SizedBox( width: 40, @@ -137,7 +137,6 @@ class _IntroScreenState extends State with WidgetsBindingObserver { ), ), const SizedBox(height: 10), - // Says what the spinner is actually waiting on. AnimatedSwitcher( duration: const Duration(milliseconds: 250), child: Text( diff --git a/lib/screens/settings/in_settings/app_routing.dart b/lib/screens/settings/in_settings/app_routing.dart new file mode 100644 index 0000000..00de4f6 --- /dev/null +++ b/lib/screens/settings/in_settings/app_routing.dart @@ -0,0 +1,329 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:revoltvpn/logic/app_colors.dart'; +import 'package:revoltvpn/logic/connection_settings.dart'; +import 'package:revoltvpn/logic/installed_apps_service.dart'; +import 'package:revoltvpn/logic/vpn_connection.dart'; + +class AppRoutingTile extends StatefulWidget { + final ConnectionMode connectionMode; + + const AppRoutingTile({super.key, required this.connectionMode}); + + @override + State createState() => _AppRoutingTileState(); +} + +class _AppRoutingTileState extends State { + AppRoutingMode _mode = ConnectionSettings.routingMode; + int _selectedCount = ConnectionSettings.appPackages.length; + + bool get _proxyMode => widget.connectionMode == ConnectionMode.proxy; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + await ConnectionSettings.initialize(); + if (!mounted) return; + setState(() { + _mode = ConnectionSettings.routingMode; + _selectedCount = ConnectionSettings.appPackages.length; + }); + } + + bool _vpnBusy() { + final status = context.read().status; + return status == VpnStatus.connected || + status == VpnStatus.connecting || + status == VpnStatus.disconnecting; + } + + void _showDisconnectFirst() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Disconnect before changing app routing.')), + ); + } + + Future _changeMode(AppRoutingMode? next) async { + if (next == null || next == _mode) return; + if (_vpnBusy()) { + _showDisconnectFirst(); + return; + } + + await ConnectionSettings.setRoutingMode(next); + if (mounted) setState(() => _mode = next); + } + + Future _openPicker() async { + if (_mode == AppRoutingMode.all) return; + if (_vpnBusy()) { + _showDisconnectFirst(); + return; + } + + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => _AppPicker(mode: _mode), + ), + ); + + if (mounted) { + setState(() => _selectedCount = ConnectionSettings.appPackages.length); + } + } + + String get _subtitle { + if (_proxyMode) { + switch (_mode) { + case AppRoutingMode.all: + return 'Transparent SOCKS5 routes all apps automatically.'; + case AppRoutingMode.exclude: + return _selectedCount == 0 + ? 'Transparent SOCKS5 routes all apps. No apps are excluded.' + : 'SOCKS5 routes all apps except $_selectedCount selected bypass app${_selectedCount == 1 ? '' : 's'}.'; + case AppRoutingMode.selected: + return _selectedCount == 0 + ? 'Choose apps for compatibility selected-app SOCKS routing.' + : 'SOCKS5 routes $_selectedCount selected app${_selectedCount == 1 ? '' : 's'} while other launcher apps bypass it.'; + } + } + + switch (_mode) { + case AppRoutingMode.all: + return 'All apps use the TUN VPN.'; + case AppRoutingMode.exclude: + return _selectedCount == 0 + ? 'No apps excluded from TUN.' + : '$_selectedCount app${_selectedCount == 1 ? '' : 's'} bypass the TUN VPN.'; + case AppRoutingMode.selected: + return _selectedCount == 0 + ? 'Choose at least one app for compatible Selected only routing.' + : '$_selectedCount selected user app${_selectedCount == 1 ? '' : 's'} are targeted; Android network helpers stay available.'; + } + } + + @override + Widget build(BuildContext context) { + final showPicker = _mode != AppRoutingMode.all; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ListTile( + title: const Text( + 'App routing', + style: TextStyle(color: AppColors.textWhite, fontSize: 15), + ), + subtitle: Text( + _subtitle, + style: const TextStyle(color: AppColors.textDim, fontSize: 12), + ), + trailing: DropdownButtonHideUnderline( + child: DropdownButton( + value: _mode, + dropdownColor: AppColors.bgCard, + onChanged: _changeMode, + items: const [ + DropdownMenuItem( + value: AppRoutingMode.all, + child: Text('All apps'), + ), + DropdownMenuItem( + value: AppRoutingMode.exclude, + child: Text('Exclude apps'), + ), + DropdownMenuItem( + value: AppRoutingMode.selected, + child: Text('Selected only'), + ), + ], + ), + ), + ), + if (showPicker) + ListTile( + onTap: _openPicker, + leading: const Icon(Icons.apps), + title: Text( + _mode == AppRoutingMode.selected + ? 'Choose routed apps' + : 'Choose excluded apps', + ), + subtitle: Text( + _mode == AppRoutingMode.selected + ? 'Selected user apps are targeted; Android network helpers remain available for compatibility.' + : 'Routing is applied to the selected Android package IDs.', + ), + trailing: const Icon(Icons.chevron_right), + ), + ], + ); + } +} + +class _AppPicker extends StatefulWidget { + final AppRoutingMode mode; + + const _AppPicker({required this.mode}); + + @override + State<_AppPicker> createState() => _AppPickerState(); +} + +class _AppPickerState extends State<_AppPicker> { + final Set _selected = {...ConnectionSettings.appPackages}; + final TextEditingController _search = TextEditingController(); + + List _apps = const []; + bool _loading = true; + String? _error; + String _query = ''; + + @override + void initState() { + super.initState(); + _loadApps(); + } + + @override + void dispose() { + _search.dispose(); + super.dispose(); + } + + Future _loadApps() async { + try { + final apps = await InstalledAppsService.loadLaunchableApps(); + if (!mounted) return; + setState(() { + _apps = apps; + _loading = false; + }); + } catch (_) { + if (!mounted) return; + setState(() { + _error = 'Could not load installed apps.'; + _loading = false; + }); + } + } + + Future _save() async { + if (widget.mode == AppRoutingMode.selected && _selected.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Select at least one app.')), + ); + return; + } + + await ConnectionSettings.setAppPackages(_selected); + if (mounted) Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final query = _query.trim().toLowerCase(); + final visibleApps = query.isEmpty + ? _apps + : _apps + .where( + (app) => + app.label.toLowerCase().contains(query) || + app.packageName.toLowerCase().contains(query), + ) + .toList(); + + final title = widget.mode == AppRoutingMode.exclude + ? 'Exclude apps' + : 'Selected routed apps'; + + return Scaffold( + backgroundColor: AppColors.bgDeep, + appBar: AppBar( + backgroundColor: AppColors.bgSurface, + title: Text(title), + actions: [ + TextButton( + onPressed: _save, + child: const Text('Save'), + ), + ], + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: TextField( + controller: _search, + onChanged: (value) => setState(() => _query = value), + decoration: const InputDecoration( + hintText: 'Search apps', + prefixIcon: Icon(Icons.search), + ), + ), + ), + Expanded(child: _buildList(visibleApps)), + ], + ), + ); + } + + Widget _buildList(List visibleApps) { + if (_loading) { + return const Center(child: CircularProgressIndicator()); + } + if (_error != null) { + return Center( + child: Text(_error!, style: const TextStyle(color: AppColors.textMuted)), + ); + } + if (visibleApps.isEmpty) { + return const Center( + child: Text('No apps found.', style: TextStyle(color: AppColors.textMuted)), + ); + } + + return ListView.builder( + itemCount: visibleApps.length, + itemBuilder: (context, index) { + final app = visibleApps[index]; + final selected = _selected.contains(app.packageName); + + return CheckboxListTile( + value: selected, + onChanged: (value) { + setState(() { + if (value == true) { + _selected.add(app.packageName); + } else { + _selected.remove(app.packageName); + } + }); + }, + secondary: app.iconPng == null + ? const CircleAvatar(child: Icon(Icons.android, size: 20)) + : ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.memory( + app.iconPng!, + width: 40, + height: 40, + fit: BoxFit.cover, + gaplessPlayback: true, + ), + ), + title: Text(app.label), + subtitle: Text( + app.packageName, + style: const TextStyle(color: AppColors.textDim, fontSize: 11), + ), + ); + }, + ); + } +} diff --git a/lib/screens/settings/in_settings/connection_mode.dart b/lib/screens/settings/in_settings/connection_mode.dart new file mode 100644 index 0000000..5a1041e --- /dev/null +++ b/lib/screens/settings/in_settings/connection_mode.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:revoltvpn/logic/app_colors.dart'; +import 'package:revoltvpn/logic/connection_settings.dart'; +import 'package:revoltvpn/logic/local_socks_tester.dart'; +import 'package:revoltvpn/logic/vpn_connection.dart'; + +class ConnectionModeTile extends StatefulWidget { + final ValueChanged? onChanged; + + const ConnectionModeTile({super.key, this.onChanged}); + + @override + State createState() => _ConnectionModeTileState(); +} + +class _ConnectionModeTileState extends State { + ConnectionMode _mode = ConnectionSettings.mode; + bool _testingLocalSocks = false; + LocalSocksTestResult? _lastSocksTest; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + await ConnectionSettings.initialize(); + if (mounted) setState(() => _mode = ConnectionSettings.mode); + } + + bool _isTunnelBusy(VpnStatus status) { + return status == VpnStatus.connected || + status == VpnStatus.connecting || + status == VpnStatus.disconnecting; + } + + Future _changeMode(ConnectionMode? next) async { + if (next == null || next == _mode) return; + + final vpn = context.read(); + if (_isTunnelBusy(vpn.status)) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Disconnect before changing connection mode.'), + ), + ); + return; + } + + await ConnectionSettings.setMode(next); + if (!mounted) return; + + setState(() { + _mode = next; + _lastSocksTest = null; + }); + widget.onChanged?.call(next); + } + + Future _testLocalSocks() async { + if (_testingLocalSocks) return; + final vpn = context.read(); + + if (vpn.status != VpnStatus.connected || !vpn.canTestActiveLocalSocks) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Connect with SOCKS5 first, then run the local test.'), + ), + ); + return; + } + + setState(() => _testingLocalSocks = true); + final result = await vpn.testActiveLocalSocks(); + if (!mounted) return; + + setState(() { + _testingLocalSocks = false; + _lastSocksTest = result; + }); + + final latency = result.latencyMs == null ? '' : ' (${result.latencyMs} ms)'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${result.message}$latency')), + ); + } + + String get _subtitle { + switch (_mode) { + case ConnectionMode.tun: + return 'TUN: Android VPN routing for normal device traffic.'; + case ConnectionMode.proxy: + return 'SOCKS5: authenticated per-session local gateway with transparent Android routing.'; + } + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ListTile( + title: const Text( + 'Connection mode', + style: TextStyle(color: AppColors.textWhite, fontSize: 15), + ), + subtitle: Text( + _subtitle, + style: const TextStyle(color: AppColors.textDim, fontSize: 12), + ), + trailing: DropdownButtonHideUnderline( + child: DropdownButton( + value: _mode, + dropdownColor: AppColors.bgCard, + onChanged: _changeMode, + items: const [ + DropdownMenuItem( + value: ConnectionMode.tun, + child: Text('TUN'), + ), + DropdownMenuItem( + value: ConnectionMode.proxy, + child: Text('SOCKS5'), + ), + ], + ), + ), + ), + if (_mode == ConnectionMode.proxy) ...[ + const Padding( + padding: EdgeInsets.fromLTRB(20, 0, 20, 8), + child: Text( + 'Secure Local SOCKS5 uses a private port and new credentials for every VPN session.', + style: TextStyle(color: AppColors.textMuted, fontSize: 12), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), + child: OutlinedButton.icon( + onPressed: _testingLocalSocks ? null : _testLocalSocks, + icon: _testingLocalSocks + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.network_check), + label: Text( + _testingLocalSocks ? 'Testing…' : 'Test Local SOCKS', + ), + ), + ), + if (_lastSocksTest != null) + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), + child: Text( + _lastSocksTest!.ok + ? 'Last test: OK${_lastSocksTest!.latencyMs == null ? '' : ' · ${_lastSocksTest!.latencyMs} ms'}' + : 'Last test: Failed · ${_lastSocksTest!.message}', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ), + ], + ], + ); + } +} diff --git a/lib/screens/settings/in_settings/haptic.dart b/lib/screens/settings/in_settings/haptic.dart index a5d0df7..4f87cfe 100644 --- a/lib/screens/settings/in_settings/haptic.dart +++ b/lib/screens/settings/in_settings/haptic.dart @@ -1,43 +1,6 @@ -import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:revoltvpn/logic/app_colors.dart'; - -// A plain bool, not a ValueNotifier: this is only read at the moment of a tap, -// so nothing has to react to it changing. Default: false. -bool hapticEnabled = false; - -/// Hydrates [hapticEnabled] from disk. Called at boot — the toggle tile below -/// only mounts once Settings is opened. -Future loadHapticPref() async { - final prefs = await SharedPreferences.getInstance(); - hapticEnabled = prefs.getBool('haptic_feedback_enabled') ?? false; -} - -/// Haptic calls are deliberately best-effort: a vibration failure must never -/// block or fail a VPN connect/disconnect. `mediumImpact` is used instead of -/// `lightImpact` — the latter is imperceptible on many Android devices. -class Haptics { - Haptics._(); - - static Future connectionChanged() async { - if (!hapticEnabled) return; - try { - await HapticFeedback.mediumImpact(); - } catch (e) { - debugPrint('[Haptics] Feedback unavailable: $e'); - } - } - - static Future previewEnabled() async { - try { - await HapticFeedback.selectionClick(); - } catch (e) { - debugPrint('[Haptics] Preview unavailable: $e'); - } - } -} +import 'package:revoltvpn/logic/haptic_settings.dart'; class HapticFeedbackTile extends StatefulWidget { const HapticFeedbackTile({super.key}); @@ -47,7 +10,8 @@ class HapticFeedbackTile extends StatefulWidget { } class _HapticFeedbackTileState extends State { - bool _on = false; + bool _on = HapticSettings.enabled; + bool _saving = false; @override void initState() { @@ -56,18 +20,24 @@ class _HapticFeedbackTileState extends State { } Future _load() async { - await loadHapticPref(); - if (mounted) setState(() => _on = hapticEnabled); + await HapticSettings.initialize(); + if (mounted) setState(() => _on = HapticSettings.enabled); } - // Writes to SharedPreferences AND updates the live bool so the connect - // button sees the change immediately — no restart needed. Future _toggle(bool value) async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool('haptic_feedback_enabled', value); - hapticEnabled = value; - if (value) unawaited(Haptics.previewEnabled()); - setState(() => _on = value); + if (_saving) return; + setState(() { + _saving = true; + _on = value; + }); + + try { + await HapticSettings.setEnabled(value); + } catch (_) { + if (mounted) setState(() => _on = HapticSettings.enabled); + } finally { + if (mounted) setState(() => _saving = false); + } } @override @@ -78,7 +48,7 @@ class _HapticFeedbackTileState extends State { style: TextStyle(color: AppColors.textWhite, fontSize: 15), ), value: _on, - onChanged: _toggle, + onChanged: _saving ? null : _toggle, activeTrackColor: AppColors.accent.withAlpha(80), activeThumbColor: AppColors.accent, contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), diff --git a/lib/screens/settings/in_settings/resilience.dart b/lib/screens/settings/in_settings/resilience.dart new file mode 100644 index 0000000..c09e3bc --- /dev/null +++ b/lib/screens/settings/in_settings/resilience.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:revoltvpn/logic/app_colors.dart'; +import 'package:revoltvpn/logic/connection_settings.dart'; +import 'package:revoltvpn/logic/vpn_connection.dart'; + +class ResilienceModeTile extends StatefulWidget { + const ResilienceModeTile({super.key}); + + @override + State createState() => _ResilienceModeTileState(); +} + +class _ResilienceModeTileState extends State { + ResilienceMode _mode = ConnectionSettings.resilienceMode; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + await ConnectionSettings.initialize(); + if (mounted) setState(() => _mode = ConnectionSettings.resilienceMode); + } + + bool _vpnBusy() { + final status = context.read().status; + return status == VpnStatus.connected || + status == VpnStatus.connecting || + status == VpnStatus.disconnecting; + } + + Future _change(ResilienceMode? next) async { + if (next == null || next == _mode) return; + if (_vpnBusy()) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Disconnect before changing resilience mode.'), + ), + ); + return; + } + + await ConnectionSettings.setResilienceMode(next); + if (mounted) setState(() => _mode = next); + } + + @override + Widget build(BuildContext context) { + final extreme = _mode == ResilienceMode.extreme; + + return Consumer( + builder: (context, vpn, _) { + final connected = vpn.status == VpnStatus.connected || + vpn.status == VpnStatus.connecting; + final active = connected ? ' Active: ${vpn.activeTransportProfile}.' : ''; + + return ListTile( + title: const Text( + 'Network resilience', + style: TextStyle(color: AppColors.textWhite, fontSize: 15), + ), + subtitle: Text( + extreme + ? 'Extreme: keeps the exact server transport unchanged and makes up to two controlled recovery attempts after each real Xray disconnect.$active' + : 'Standard: keeps the exact server transport unchanged and does not force health-check reconnects.$active', + style: const TextStyle(color: AppColors.textDim, fontSize: 12), + ), + trailing: DropdownButtonHideUnderline( + child: DropdownButton( + value: _mode, + dropdownColor: AppColors.bgCard, + onChanged: _change, + items: const [ + DropdownMenuItem( + value: ResilienceMode.standard, + child: Text('Standard'), + ), + DropdownMenuItem( + value: ResilienceMode.extreme, + child: Text('Extreme'), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 5301160..06d98d4 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -1,27 +1,34 @@ import 'package:flutter/material.dart'; import 'package:revoltvpn/logic/app_colors.dart'; +import 'package:revoltvpn/logic/connection_settings.dart'; +import 'package:revoltvpn/screens/settings/in_settings/app_routing.dart'; +import 'package:revoltvpn/screens/settings/in_settings/connection_mode.dart'; import 'package:revoltvpn/screens/settings/in_settings/haptic.dart'; -import 'package:revoltvpn/screens/settings/in_settings/rain.dart'; import 'package:revoltvpn/screens/settings/in_settings/lightning.dart'; +import 'package:revoltvpn/screens/settings/in_settings/rain.dart'; +import 'package:revoltvpn/screens/settings/in_settings/resilience.dart'; -// ── lib/screens/settings/settings_screen.dart ──────────────────────────────── -// Settings shell — full-screen page reached from the sidebar. -// -// Folder layout: -// lib/screens/settings/ ← this folder: settings family -// settings_screen.dart ← shell: Scaffold + AppBar + ListView (this file) -// *_tile.dart ← one file per setting feature (add below) -// lib/components/sidebar_contents/ -// settings.dart ← thin sidebar row that navigates here -// -// To add a setting: -// 1. Create a new file in this folder, e.g. appearance_tile.dart -// 2. Import it below -// 3. Append it to the children list - -class SettingsScreen extends StatelessWidget { +class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + ConnectionMode _mode = ConnectionSettings.mode; + + @override + void initState() { + super.initState(); + _loadMode(); + } + + Future _loadMode() async { + await ConnectionSettings.initialize(); + if (mounted) setState(() => _mode = ConnectionSettings.mode); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -43,12 +50,15 @@ class SettingsScreen extends StatelessWidget { ), body: ListView( padding: const EdgeInsets.symmetric(vertical: 8), - children: const [ - HapticFeedbackTile(), - RainToggleTile(), - LightningToggleTile(), - // ── Add setting tiles here ──────────────────────────────────────── - // Each tile imports from its own file in this folder. + children: [ + ConnectionModeTile( + onChanged: (mode) => setState(() => _mode = mode), + ), + const ResilienceModeTile(), + AppRoutingTile(connectionMode: _mode), + const HapticFeedbackTile(), + const RainToggleTile(), + const LightningToggleTile(), ], ), ); diff --git a/tool/patch_flutter_vless_allowed_apps.dart b/tool/patch_flutter_vless_allowed_apps.dart new file mode 100644 index 0000000..94468fd --- /dev/null +++ b/tool/patch_flutter_vless_allowed_apps.dart @@ -0,0 +1,293 @@ +import 'dart:convert'; +import 'dart:io'; + +const packageName = 'flutter_vless_android'; +const expectedVersionFragment = 'flutter_vless_android-1.1.5'; + +Never fail(String message) { + stderr.writeln('[flutter_vless patch] $message'); + exit(1); +} + +Directory packageRoot() { + final configFile = File('.dart_tool/package_config.json'); + if (!configFile.existsSync()) { + fail('.dart_tool/package_config.json is missing; run flutter pub get first'); + } + + final decoded = jsonDecode(configFile.readAsStringSync()); + if (decoded is! Map || 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 patch] already current: ${file.path}'); + return; + } + if (!text.contains(oldValue)) { + fail('upstream source changed; expected block not found in ${file.path}'); + } + file.writeAsStringSync(text.replaceFirst(oldValue, newValue)); + stdout.writeln('[flutter_vless patch] patched: ${file.path}'); +} + +void replaceOneOf( + File file, + Map replacements, + String marker, +) { + if (!file.existsSync()) fail('missing upstream source: ${file.path}'); + var text = file.readAsStringSync(); + if (text.contains(marker)) { + stdout.writeln('[flutter_vless patch] already current: ${file.path}'); + return; + } + for (final entry in replacements.entries) { + if (!text.contains(entry.key)) continue; + text = text.replaceFirst(entry.key, entry.value); + file.writeAsStringSync(text); + stdout.writeln('[flutter_vless patch] patched/upgraded: ${file.path}'); + return; + } + fail('upstream/legacy patch shape not recognized in ${file.path}'); +} + +void main() { + final root = packageRoot(); + final kotlin = Directory( + '${root.path}/android/src/main/kotlin/com/github/tfox/flutter_vless', + ); + + final configFile = File('${kotlin.path}/xray/dto/XrayConfig.kt'); + replaceOnce( + configFile, + ''' /** List of app package names to exclude from VPN. */ + var BLOCKED_APPS: ArrayList = ArrayList(), +''', + ''' /** List of app package names to exclude from VPN. */ + var BLOCKED_APPS: ArrayList = ArrayList(), + + /** List of app package names that are exclusively allowed into VPN. */ + var ALLOWED_APPS: ArrayList = ArrayList(), +''', + 'var ALLOWED_APPS:', + ); + + final pluginFile = File('${kotlin.path}/FlutterVlessPlugin.kt'); + replaceOnce( + pluginFile, + ''' private lateinit var context: Context +''', + ''' private lateinit var context: Context + private var allowedApps: ArrayList = ArrayList() +''', + 'private var allowedApps:', + ); + + const currentAllowedHandler = ''' "setAllowedApps" -> { + val requested = call.argument>("allowed_apps") ?: ArrayList() + allowedApps = ArrayList( + requested + .map { it.trim() } + .filter { it.isNotEmpty() && it != context.packageName } + .distinct() + .sorted() + ) + result.success(null) + } +'''; + const legacyAllowedHandler = ''' "setAllowedApps" -> { + val requested = call.argument>("allowed_apps") ?: ArrayList() + allowedApps = ArrayList( + requested.map { it.trim() }.filter { it.isNotEmpty() }.distinct() + ) + result.success(null) + } +'''; + replaceOneOf( + pluginFile, + { + legacyAllowedHandler: currentAllowedHandler, + ''' when (call.method) { + "startVless" -> { +''': ''' when (call.method) { +$currentAllowedHandler "startVless" -> { +''', + }, + 'it.isNotEmpty() && it != context.packageName', + ); + + replaceOnce( + pluginFile, + ''' config.BLOCKED_APPS = call.argument>("blocked_apps") ?: ArrayList() + config.BYPASS_SUBNETS = call.argument>("bypass_subnets") ?: ArrayList() +''', + ''' config.BLOCKED_APPS = call.argument>("blocked_apps") ?: ArrayList() + config.ALLOWED_APPS = ArrayList(allowedApps) + config.BYPASS_SUBNETS = call.argument>("bypass_subnets") ?: ArrayList() +''', + 'config.ALLOWED_APPS = ArrayList(allowedApps)', + ); + + final serviceFile = File('${kotlin.path}/xray/service/XrayVPNService.kt'); + replaceOnce( + serviceFile, + ''' if (config != null) { + // Ensure clean state before starting + cleanup() +''', + ''' if (config != null) { + // A fast reconnect must not leave the previous Xray process + // owning the local SOCKS/HTTP ports. + if (XrayCoreManager.isXrayRunning()) { + Log.w(TAG, "Stopping stale Xray core before restart") + XrayCoreManager.stopCore(this) + } + cleanup() +''', + 'Stopping stale Xray core before restart', + ); + + replaceOnce( + serviceFile, + ''' val tun2socksPath = File(applicationInfo.nativeLibraryDir, "libtun2socks.so").absolutePath + val sockPath = File(filesDir, "sock_path").absolutePath +''', + ''' val tun2socksPath = File(applicationInfo.nativeLibraryDir, "libtun2socks.so").absolutePath + val socketFile = File(filesDir, "sock_path") + if (socketFile.exists() && !socketFile.delete()) { + Log.w(TAG, "Could not delete stale tun2socks socket") + } + val sockPath = socketFile.absolutePath +''', + 'Could not delete stale tun2socks socket', + ); + + final upstreamPolicy = r''' try { + builder.addDisallowedApplication(packageName) +} catch (e: Exception) { + Log.e(TAG, \"Failed to exclude app from VPN\", e) +} + +for (pkg in config.BLOCKED_APPS) { + try { + builder.addDisallowedApplication(pkg) + Log.d(TAG, \"Excluded from VPN: $pkg\") + } catch (e: Exception) { + Log.e(TAG, \"Failed to exclude $pkg from VPN\", e) + } +} +'''.replaceAll(r'\"', '"'); + + final legacyPatchedPolicy = r''' if (config.ALLOWED_APPS.isNotEmpty()) { + for (pkg in config.ALLOWED_APPS) { + try { + builder.addAllowedApplication(pkg) + Log.d(TAG, \"Allowed into VPN: $pkg\") + } catch (e: Exception) { + Log.e(TAG, \"Failed to allow $pkg into VPN\", e) + } + } + } else { + try { + builder.addDisallowedApplication(packageName) + } catch (e: Exception) { + Log.e(TAG, \"Failed to exclude app from VPN\", e) + } + + for (pkg in config.BLOCKED_APPS) { + try { + builder.addDisallowedApplication(pkg) + Log.d(TAG, \"Excluded from VPN: $pkg\") + } catch (e: Exception) { + Log.e(TAG, \"Failed to exclude $pkg from VPN\", e) + } + } + } +'''.replaceAll(r'\"', '"'); + + final hardenedPolicy = r''' if (config.ALLOWED_APPS.isNotEmpty() && config.BLOCKED_APPS.isNotEmpty()) { + throw IllegalStateException(\"Allowed and blocked app policies cannot be mixed\") + } + + if (config.ALLOWED_APPS.isNotEmpty()) { + var allowedCount = 0 + for (pkg in config.ALLOWED_APPS) { + try { + builder.addAllowedApplication(pkg) + allowedCount++ + Log.d(TAG, \"Allowed into VPN: $pkg\") + } catch (e: Exception) { + throw IllegalStateException(\"Failed to allow selected app $pkg\", e) + } + } + if (allowedCount == 0) { + throw IllegalStateException(\"Selected-only routing has no valid applications\") + } + } else { + try { + builder.addDisallowedApplication(packageName) + } catch (e: Exception) { + Log.e(TAG, \"Failed to exclude app from VPN\", e) + } + + for (pkg in config.BLOCKED_APPS) { + try { + builder.addDisallowedApplication(pkg) + Log.d(TAG, \"Excluded from VPN: $pkg\") + } catch (e: Exception) { + Log.e(TAG, \"Failed to exclude $pkg from VPN\", e) + } + } + } +'''.replaceAll(r'\"', '"'); + + // Support both pristine 1.1.5 and the earlier local allowlist patch. + replaceOneOf( + serviceFile, + { + legacyPatchedPolicy: hardenedPolicy, + upstreamPolicy: hardenedPolicy, + }, + 'Allowed and blocked app policies cannot be mixed', + ); + + stdout.writeln('[flutter_vless patch] allowed-app routing 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 new file mode 100644 index 0000000..c157c5a --- /dev/null +++ b/tool/patch_flutter_vless_secure_socks_scope_fix.dart @@ -0,0 +1,154 @@ +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 new file mode 100644 index 0000000..f64d827 --- /dev/null +++ b/tool/patch_flutter_vless_secure_socks_v2.dart @@ -0,0 +1,546 @@ +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', + ); + } + + const oldNullIntent = r''' if (intent == null) { + stopSelf() + return START_NOT_STICKY + } +'''; + const newNullIntent = r''' if (intent == null) { + if (isRunning && currentConfig != null) return START_REDELIVER_INTENT + stopSelf() + return START_NOT_STICKY + } +'''; + text = replaceOnce( + text, + oldNullIntent, + newNullIntent, + 'null intent lifecycle', + ); + + if (!text.contains(' currentConfig = config\n')) { + text = replaceOnce( + text, + ' cleanup()\n', + ' cleanup()\n currentConfig = config\n', + 'runtime config snapshot', + ); + } + + text = replaceOnce( + text, + ' return START_STICKY\n', + ' return START_REDELIVER_INTENT\n', + 'redeliver START command', + ); + + 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(' recoveringXray = false\n tun2socksProcess?.destroy()')) { + text = replaceOnce( + text, + ' isRunning = false\n tun2socksProcess?.destroy()\n', + ' isRunning = false\n recoveringXray = false\n tun2socksProcess?.destroy()\n', + 'cancel recovery on cleanup', + ); + } + + 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'); +}