feat(app): release 3.3.1 with secure routing and runtime resilience #2

Merged
Naveax merged 6 commits from release/3.3.1 into main 2026-08-31 23:30:43 +03:00
38 changed files with 4101 additions and 423 deletions

No files matched your search

+59
View File
@@ -5,6 +5,65 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH
--- ---
## [3.3.1] — 2026-08-31
### Added
- **Explicit TUN and SOCKS5 connection modes.** The previous Auto mode is removed so
routing behavior is selected deliberately instead of inferred at runtime.
- **Per-app routing** with All apps, Exclude apps and Selected only policies. Android
enumerates launcher applications without requesting `QUERY_ALL_PACKAGES`.
- **Authenticated per-session Local SOCKS5.** Every connection creates a loopback-only
SOCKS ingress with a random high port plus fresh username/password credentials.
Credentials stay in memory and the diagnostic test uses the active session instead
of fixed `10807`/`10808` ports.
- **Network resilience controls** with Standard and Extreme profiles plus Android
physical-network monitoring that ignores VPN-created network callback loops.
- Native Android bridges for installed-app discovery, network state and haptic feedback.
- Regression tests for legacy Auto/ASS migrations, secure SOCKS session generation and
the patched stock Xray SOCKS authentication schema.
### Changed
- Legacy `auto` preferences migrate to TUN. Legacy `ass` preferences migrate to SOCKS5
while preserving selected-app package lists.
- Keep the hosted `flutter_vless_android` **1.1.5** runtime instead of restoring the
experimental vendored Android service. Required routing, recovery and authenticated
SOCKS behavior is applied by idempotent build-time patchers.
- Session countdown reconciliation now uses elapsed wall-clock time, re-syncs immediately
on resume and preserves the existing 5 s foreground / 30 s background poll policy.
- Haptic feedback is initialized at startup and routed through a native Android channel;
duplicate toggle feedback is removed and the setting remains persistent.
- VPN notification uses a dedicated 24 dp status icon, remains ongoing/no-clear while the
foreground service is active and keeps session metadata out of public lock-screen previews.
- Hivemind session parsing is split into typed validation steps and nonce generation uses
`Random.secure()` without logging nonce values.
- Increase Gradle release-build heap to 4 GiB and cap workers at two so Flutter/Jetifier
transforms do not fail on the release classpath under constrained CI runners.
- App version is now `3.3.1+27`.
### Fixed
- Recoverable Xray/tun2socks startup and readiness failures no longer follow the unstable
vendored-runtime self-stop path used during development.
- SOCKS5 readiness checks authenticate against the exact active session instead of merely
checking whether a fixed local port accepts connections.
- Returning from Android background state reconciles the session timer and server status,
reducing stale countdowns and false `Syncing…` presentation after Dart throttling.
- Sidebar update checks retain valid navigator/messenger handles after the drawer closes.
- Settings startup uses the persistent haptic manager instead of the removed legacy loader.
### Security & privacy
- Local SOCKS5 listens only on `127.0.0.1`, requires per-session authentication and does not
persist or log generated credentials.
- The app picker relies on launcher-app queries rather than broad package visibility.
- Session time/speed notification metadata is marked private for lock-screen previews.
- Existing AdMob bypass/callback behavior is intentionally unchanged by this release.
- Server, nginx and Reality configuration are not modified by this client-side release.
### Validation
- `flutter analyze` completed without fatal warnings or infos.
- All four Flutter regression tests passed.
- Android release Kotlin compilation passed.
- The 3.3.1 release APK built successfully and the packaged VPN runtime verification passed.
## [3.3.0] — 2026-08-30 ## [3.3.0] — 2026-08-30
### Added ### Added
+123
View File
@@ -1,4 +1,5 @@
import java.util.Properties import java.util.Properties
import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
plugins { plugins {
@@ -6,6 +7,128 @@ plugins {
id("dev.flutter.flutter-gradle-plugin") 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<Exec>("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<Exec>("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<Exec>("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 { android {
namespace = "com.paladinvpn.app" namespace = "com.paladinvpn.app"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
+6
View File
@@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
<uses-permission android:name="android.permission.VIBRATE"/> <uses-permission android:name="android.permission.VIBRATE"/>
<!-- flutter_vless 1.1.5 declares this legacy permission. ReVolt never needs it. --> <!-- flutter_vless 1.1.5 declares this legacy permission. ReVolt never needs it. -->
@@ -17,6 +18,11 @@
<action android:name="android.intent.action.VIEW"/> <action android:name="android.intent.action.VIEW"/>
<data android:scheme="https"/> <data android:scheme="https"/>
</intent> </intent>
<!-- App routing only enumerates launcher apps; QUERY_ALL_PACKAGES is not required. -->
<intent>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent>
</queries> </queries>
<application <application
@@ -1,30 +1,44 @@
package com.paladinvpn.app package com.paladinvpn.app
import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.graphics.Bitmap
import android.graphics.Canvas
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import com.github.tfox.flutter_vless.xray.service.XrayVPNService import com.github.tfox.flutter_vless.xray.service.XrayVPNService
import com.github.tfox.flutter_vless.xray.utils.AppConfigs import com.github.tfox.flutter_vless.xray.utils.AppConfigs
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import java.io.ByteArrayOutputStream
import kotlin.math.roundToInt
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
// Channel id only — not the application id, which is com.paladinvpn.app.
// Must match the string in lib/logic/updater.dart.
private val installerChannel = "com.revoltvpn.app/installer" private val installerChannel = "com.revoltvpn.app/installer"
// Must match lib/logic/notification_service.dart.
private val notificationChannel = "com.revoltvpn.app/notification" private val notificationChannel = "com.revoltvpn.app/notification"
private val hapticsChannel = "com.revoltvpn.app/haptics"
private val appsChannel = "com.revoltvpn.app/apps"
private val networkChannel = "com.revoltvpn.app/network"
private var notificationChannelReady = false private var notificationChannelReady = false
private var networkCallback: ConnectivityManager.NetworkCallback? = null
private var networkEvents: EventChannel.EventSink? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
@@ -50,16 +64,45 @@ class MainActivity : FlutterActivity() {
result.notImplemented() result.notImplemented()
} }
} }
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, hapticsChannel)
.setMethodCallHandler { call, result ->
if (call.method == "impact") {
val kind = call.argument<String>("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) { private fun updateVpnNotification(title: String, text: String, actionLabel: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) !=
@@ -76,58 +119,52 @@ class MainActivity : FlutterActivity() {
PendingIntent.FLAG_UPDATE_CURRENT 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) val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
launchIntent?.action = "FROM_DISCONNECT_BTN" launchIntent?.action = "FROM_DISCONNECT_BTN"
launchIntent?.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or launchIntent?.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or
Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_NEW_TASK Intent.FLAG_ACTIVITY_NEW_TASK
// Tapping "Disconnect" stops the tunnel without opening the app.
val stopIntent = Intent(this, XrayVPNService::class.java) val stopIntent = Intent(this, XrayVPNService::class.java)
stopIntent.putExtra( stopIntent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE)
"COMMAND",
AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE
)
val stopPendingIntent = PendingIntent.getService(this, 0, stopIntent, flags) val stopPendingIntent = PendingIntent.getService(this, 0, stopIntent, flags)
val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.notification_icon) .setSmallIcon(R.drawable.notification_status_icon)
.setContentTitle(title) .setContentTitle(title)
.setContentText(text) .setContentText(text)
.addAction(0, actionLabel, stopPendingIntent) .addAction(0, actionLabel, stopPendingIntent)
.setColorized(true) .setColorized(true)
.setColor(0xFF0D1117.toInt()) .setColor(0xFF0D1117.toInt())
.setOngoing(true) .setOngoing(true)
.setAutoCancel(false)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setSilent(true) .setSilent(true)
.setShowWhen(false) .setShowWhen(false)
.setPriority(NotificationCompat.PRIORITY_LOW) .setPriority(NotificationCompat.PRIORITY_LOW)
if (launchIntent != null) { if (launchIntent != null) {
builder.setContentIntent( builder.setContentIntent(PendingIntent.getActivity(this, 0, launchIntent, flags))
PendingIntent.getActivity(this, 0, launchIntent, flags)
)
} }
try { 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) { } 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() { private fun ensureNotificationChannel() {
if (notificationChannelReady) return if (notificationChannelReady) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel( val channel = NotificationChannel(
NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID,
"Revolt VPN Background Service", "Revolt VPN Background Service",
NotificationManager.IMPORTANCE_LOW NotificationManager.IMPORTANCE_LOW,
) )
channel.setShowBadge(false) channel.setShowBadge(false)
manager.createNotificationChannel(channel) manager.createNotificationChannel(channel)
@@ -135,11 +172,134 @@ class MainActivity : FlutterActivity() {
notificationChannelReady = true notificationChannelReady = true
} }
/** private fun startNetworkMonitor() {
* Package name of whatever installed us ("com.android.vending" for Play if (networkCallback != null) return
* Store), or null when unknown. Without this the Dart side always falls
* back to sideload and points every user at GitHub releases. 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<String, Any>(
"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<String, Any>(
"reason" to reason,
"transport" to transport,
"connected" to true,
"validated" to validated,
"metered" to metered,
"timestamp" to System.currentTimeMillis(),
),
)
}
private fun emitNetworkPayload(payload: Map<String, Any>) {
runOnUiThread { networkEvents?.success(payload) }
}
private fun installerPackage(): String? = try { private fun installerPackage(): String? = try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
packageManager.getInstallSourceInfo(packageName).installingPackageName packageManager.getInstallSourceInfo(packageName).installingPackageName
@@ -147,12 +307,95 @@ class MainActivity : FlutterActivity() {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
packageManager.getInstallerPackageName(packageName) packageManager.getInstallerPackageName(packageName)
} }
} catch (e: Exception) { } catch (_: Exception) {
null null
} }
private fun launchableApps(): List<Map<String, Any>> {
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<String, Any>(
"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 { companion object {
// Must match flutter_vless's XrayCoreManager.
private const val NOTIFICATION_ID = 1 private const val NOTIFICATION_ID = 1
private const val NOTIFICATION_CHANNEL_ID = "XRAY_SERVICE_CHANNEL" private const val NOTIFICATION_CHANNEL_ID = "XRAY_SERVICE_CHANNEL"
} }
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,1.5L21,5.2V11.1C21,16.7 17.4,21 12,22.5C6.6,21 3,16.7 3,11.1V5.2L12,1.5ZM12,5L6.5,7.2V11C6.5,14.8 8.7,17.8 12,19C15.3,17.8 17.5,14.8 17.5,11V7.2L12,5ZM10.8,9H13.2V13.8H10.8V9ZM10.8,15H13.2V17.4H10.8V15Z" />
</vector>
+3 -1
View File
@@ -1,5 +1,7 @@
org.gradle.jvmargs=-Xmx1536M org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8
org.gradle.workers.max=2
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true
# These compatibility flags are managed by Flutter's Android toolchain.
android.builtInKotlin=false android.builtInKotlin=false
android.newDsl=false android.newDsl=false
+65
View File
@@ -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<AppHapticSurface> createState() => _AppHapticSurfaceState();
}
class _PointerStart {
final Offset position;
final DateTime time;
const _PointerStart(this.position, this.time);
}
class _AppHapticSurfaceState extends State<AppHapticSurface> {
static const double _maxTapTravel = 18;
static const Duration _maxTapDuration = Duration(milliseconds: 700);
final Map<int, _PointerStart> _starts = <int, _PointerStart>{};
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,
);
}
}
+14 -23
View File
@@ -1,11 +1,9 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:revoltvpn/logic/app_colors.dart'; import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/logic/vpn_connection.dart'; import 'package:revoltvpn/logic/vpn_connection.dart';
import 'package:revoltvpn/logic/session_timer.dart'; import 'package:revoltvpn/logic/session_timer.dart';
import 'package:revoltvpn/logic/ad_manager.dart'; import 'package:revoltvpn/logic/ad_manager.dart';
import 'package:revoltvpn/screens/settings/in_settings/haptic.dart';
class ConnectButton extends StatefulWidget { class ConnectButton extends StatefulWidget {
const ConnectButton({super.key}); const ConnectButton({super.key});
@@ -17,7 +15,7 @@ class ConnectButton extends StatefulWidget {
class _ConnectButtonState extends State<ConnectButton> class _ConnectButtonState extends State<ConnectButton>
with SingleTickerProviderStateMixin, WidgetsBindingObserver { with SingleTickerProviderStateMixin, WidgetsBindingObserver {
bool _busy = false; bool _busy = false;
DateTime _lastTap = DateTime.now(); DateTime _lastTap = DateTime.fromMillisecondsSinceEpoch(0);
AnimationController? _pulse; AnimationController? _pulse;
Animation<double>? _pulseAnim; Animation<double>? _pulseAnim;
@@ -26,12 +24,17 @@ class _ConnectButtonState extends State<ConnectButton>
@override @override
void initState() { void initState() {
super.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; _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.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<VpnConnection>().addListener(_onVpnChanged); if (mounted) context.read<VpnConnection>().addListener(_onVpnChanged);
}); });
} }
@@ -68,7 +71,6 @@ class _ConnectButtonState extends State<ConnectButton>
} }
Future<void> _handleTap() async { Future<void> _handleTap() async {
final now = DateTime.now(); final now = DateTime.now();
if (now.difference(_lastTap).inMilliseconds < 1000) return; if (now.difference(_lastTap).inMilliseconds < 1000) return;
_lastTap = now; _lastTap = now;
@@ -77,32 +79,21 @@ class _ConnectButtonState extends State<ConnectButton>
final timer = context.read<SessionTimer>(); final timer = context.read<SessionTimer>();
final ad = context.read<AdManager>(); final ad = context.read<AdManager>();
if (vpn.status == VpnStatus.connected || vpn.status == VpnStatus.connecting) { if (vpn.status == VpnStatus.connected || vpn.status == VpnStatus.connecting) {
_busy = false; _busy = false;
await timer.disconnect(); await timer.disconnect();
unawaited(Haptics.connectionChanged());
return; return;
} }
if (vpn.status == VpnStatus.disconnecting || _busy) return;
if (vpn.status == VpnStatus.disconnecting) return;
if (_busy) return;
setState(() => _busy = true); setState(() => _busy = true);
try { try {
final adWatched = await ad.showAd('main'); final adWatched = await ad.showAd('main');
if (!adWatched && AdManager.adsEnabled) { if (!adWatched && AdManager.adsEnabled) return;
setState(() => _busy = false);
return;
}
final ok = await vpn.connect(); final ok = await vpn.connect();
if (ok) { if (ok) await timer.start();
timer.start();
unawaited(Haptics.connectionChanged());
}
} finally { } finally {
if (mounted) setState(() => _busy = false); if (mounted) setState(() => _busy = false);
} }
@@ -170,7 +161,7 @@ class _ConnectButtonState extends State<ConnectButton>
color: Color.fromARGB(glowAlpha, 255, 214, 0), color: Color.fromARGB(glowAlpha, 255, 214, 0),
blurRadius: glowRadius, blurRadius: glowRadius,
spreadRadius: glowRadius * 0.3, spreadRadius: glowRadius * 0.3,
) ),
] ]
: [], : [],
), ),
+3 -1
View File
@@ -65,7 +65,9 @@ class VpnNotificationManager {
playSound: false, playSound: false,
enableVibration: false, enableVibration: false,
showWhen: false, showWhen: false,
visibility: NotificationVisibility.public, // Session time/speed is usage metadata. Keep it out of public lock-screen
// previews while leaving the ongoing VPN status notification intact.
visibility: NotificationVisibility.private,
usesChronometer: false, usesChronometer: false,
category: AndroidNotificationCategory.service, category: AndroidNotificationCategory.service,
colorized: true, colorized: true,
+4 -1
View File
@@ -141,7 +141,10 @@ class _SidebarDrawerState extends State<SidebarDrawer> {
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
Navigator.of(context).pop(); Navigator.of(context).pop();
unawaited( unawaited(
Updater.check(navigator: navigator, messenger: messenger), Updater.checkWithHandles(
navigator: navigator,
messenger: messenger,
),
); );
}, },
), ),
+20 -25
View File
@@ -7,16 +7,6 @@ import 'package:revoltvpn/logic/server_list.dart';
import 'package:revoltvpn/components/online_dot.dart'; import 'package:revoltvpn/components/online_dot.dart';
import 'package:revoltvpn/screens/server_selector/selection_screen.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 { class StatusBar extends StatelessWidget {
const StatusBar({super.key}); const StatusBar({super.key});
@@ -28,15 +18,14 @@ class StatusBar extends StatelessWidget {
final isConnecting = vpn.status == VpnStatus.connecting; final isConnecting = vpn.status == VpnStatus.connecting;
final serverHealthy = final serverHealthy =
vpn.serverReachable || (isConnected && timer.hasSyncedOnce); vpn.serverReachable || (isConnected && timer.hasSyncedOnce);
final sessionOrTunnelHealthy = timer.hasSyncedOnce || vpn.serverReachable;
final statusLabel = isConnected final statusLabel = isConnected
? (timer.hasSyncedOnce ? 'Online' : 'Syncing…') ? (sessionOrTunnelHealthy ? 'Online' : 'Syncing…')
: (isConnecting : (isConnecting
? 'Connecting…' ? 'Connecting…'
: (vpn.serverReachable ? 'Ready' : 'Offline')); : (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<ServerList>().selected; final server = context.watch<ServerList>().selected;
return Padding( return Padding(
@@ -44,7 +33,6 @@ class StatusBar extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// ── Server location ──────────────────────────────────────────
GestureDetector( GestureDetector(
onTap: () { onTap: () {
Navigator.of(context).push( Navigator.of(context).push(
@@ -56,10 +44,12 @@ class StatusBar extends StatelessWidget {
position: Tween<Offset>( position: Tween<Offset>(
begin: const Offset(1.0, 0.0), begin: const Offset(1.0, 0.0),
end: Offset.zero, end: Offset.zero,
).animate(CurvedAnimation( ).animate(
CurvedAnimation(
parent: animation, parent: animation,
curve: Curves.easeOut, curve: Curves.easeOut,
)), ),
),
child: child, child: child,
); );
}, },
@@ -79,24 +69,26 @@ class StatusBar extends StatelessWidget {
Image.asset(server.flagAsset, width: 20, height: 20), Image.asset(server.flagAsset, width: 20, height: 20),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text(server.name, child: Text(
server.name,
style: const TextStyle( style: const TextStyle(
color: AppColors.accent, color: AppColors.accent,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 13, fontSize: 13,
letterSpacing: 0.5, 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), const SizedBox(height: 8),
// ── Connection status ────────────────────────────────────────
Container( Container(
padding: padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 10), const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
@@ -113,8 +105,11 @@ class StatusBar extends StatelessWidget {
), ),
const Spacer(), const Spacer(),
if (isConnected) if (isConnected)
const Icon(Icons.lock, const Icon(
color: AppColors.green50, size: 14), Icons.lock,
color: AppColors.green50,
size: 14,
),
], ],
), ),
), ),
+34 -12
View File
@@ -20,13 +20,21 @@ class _SupportButtonState extends State<SupportButton> {
final ad = context.read<AdManager>(); final ad = context.read<AdManager>();
final timer = context.read<SessionTimer>(); final timer = context.read<SessionTimer>();
// Only available when there's an active session. // Only available when there's an active session, once the persisted
if (!timer.isRunning && !timer.hasSyncedOnce) return; // 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); setState(() => _busy = true);
try { try {
await ad.showAd('support'); final rewarded = await ad.showAd('support');
if (rewarded) {
await timer.markSupportRewardClaimed();
}
} finally { } finally {
if (mounted) setState(() => _busy = false); if (mounted) setState(() => _busy = false);
} }
@@ -34,15 +42,20 @@ class _SupportButtonState extends State<SupportButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Selector<SessionTimer, bool>( return Consumer<SessionTimer>(
selector: (_, timer) => timer.isRunning || timer.hasSyncedOnce, builder: (context, timer, _) {
builder: (context, visible, _) { if (!timer.isRunning && !timer.hasSyncedOnce) {
if (!visible) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final stateLoaded = timer.supportRewardStateLoaded;
final claimed = timer.supportRewardClaimed;
final disabled = !stateLoaded || claimed;
return GestureDetector( return GestureDetector(
onTap: _handleTap, onTap: disabled ? null : _handleTap,
child: Opacity(
opacity: disabled ? 0.55 : 1.0,
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -56,7 +69,11 @@ class _SupportButtonState extends State<SupportButton> {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.favorite_border, color: AppColors.accent, size: 20), Icon(
claimed ? Icons.check_circle_outline : Icons.favorite_border,
color: AppColors.accent,
size: 20,
),
const SizedBox(width: 8), const SizedBox(width: 8),
_busy _busy
? const SizedBox( ? const SizedBox(
@@ -67,9 +84,13 @@ class _SupportButtonState extends State<SupportButton> {
color: AppColors.accent, color: AppColors.accent,
), ),
) )
: const Text( : Text(
'Support us \u2014 30 min!', !stateLoaded
style: TextStyle( ? 'Checking support bonus…'
: claimed
? 'Support bonus used'
: 'Support us — 30 min!',
style: const TextStyle(
color: AppColors.textWhite, color: AppColors.textWhite,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -78,6 +99,7 @@ class _SupportButtonState extends State<SupportButton> {
], ],
), ),
), ),
),
); );
}, },
); );
+115
View File
@@ -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<String> _appPackages = const <String>[];
static bool _initialized = false;
static ConnectionMode get mode => _mode;
static AppRoutingMode get routingMode => _routingMode;
static ResilienceMode get resilienceMode => _resilienceMode;
static List<String> get appPackages => List.unmodifiable(_appPackages);
static Future<void> 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 <String>[];
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<void> setMode(ConnectionMode mode) async {
_mode = mode;
_initialized = true;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_modeKey, mode.name);
}
static Future<void> setRoutingMode(AppRoutingMode mode) async {
_routingMode = mode;
_initialized = true;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_routingModeKey, mode.name);
}
static Future<void> setAppPackages(Iterable<String> packages) async {
_appPackages = _cleanPackages(packages);
_initialized = true;
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(_appPackagesKey, _appPackages);
}
static Future<void> setResilienceMode(ResilienceMode mode) async {
_resilienceMode = mode;
_initialized = true;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_resilienceModeKey, mode.name);
}
static List<String> _cleanPackages(Iterable<String> packages) {
final seen = <String>{};
final result = <String>[];
for (final raw in packages) {
final value = raw.trim();
if (value.isEmpty || !seen.add(value)) continue;
result.add(value);
}
result.sort();
return result;
}
}
+7 -1
View File
@@ -4,11 +4,17 @@ import 'package:uuid/uuid.dart';
class CryptoService { class CryptoService {
static const String _deviceIdPref = 'device_uuid'; static const String _deviceIdPref = 'device_uuid';
static const _storage = FlutterSecureStorage(); 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. /// 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<String> getDeviceId() async { static Future<String> getDeviceId() async {
final existing = await _storage.read(key: _deviceIdPref); 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(); final newId = const Uuid().v4();
await _storage.write(key: _deviceIdPref, value: newId); await _storage.write(key: _deviceIdPref, value: newId);
+84
View File
@@ -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<void> 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<void> 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<void> _impact(HapticKind kind) async {
if (!_initialized) await initialize();
if (!_enabled || kIsWeb) return;
if (defaultTargetPlatform == TargetPlatform.android) {
try {
final handled = await _channel.invokeMethod<bool>(
'impact',
<String, Object>{'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;
}
}
}
+152 -53
View File
@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:revoltvpn/logic/app_config.dart'; 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 ' 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'; '(KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36';
static const _pollBackoff = <Duration>[
Duration(seconds: 1),
Duration(seconds: 2),
Duration(seconds: 3),
Duration(seconds: 5),
];
static Future<http.Response> directGet(Uri uri, {Duration timeout = const Duration(seconds: 5)}) { static Future<http.Response> directGet(
Uri uri, {
Duration timeout = const Duration(seconds: 5),
}) {
return http.get(uri, headers: {'User-Agent': _ua}).timeout(timeout); return http.get(uri, headers: {'User-Agent': _ua}).timeout(timeout);
} }
static void cancel() { static void cancel() => _currentCallId++;
_currentCallId++;
}
static void setExpectedNonce(String nonce) { static void setExpectedNonce(String nonce) {
_expectedNonce = nonce; _expectedNonce = nonce;
@@ -30,66 +38,34 @@ class HivemindService {
bool skipAdBypass = false, bool skipAdBypass = false,
}) async { }) async {
final deviceId = await CryptoService.getDeviceId(); final deviceId = await CryptoService.getDeviceId();
final callId = ++_currentCallId; final callId = ++_currentCallId;
final nonce = '${Random().nextInt(0x7FFFFFFF)}-${DateTime.now().millisecondsSinceEpoch}'; final nonce = _newNonce();
_expectedNonce = nonce; _expectedNonce = nonce;
debugPrint('[HivemindService] Call #$callId — nonce: $nonce');
final url = _publicUrl('/session/status?device_id=$deviceId');
if (!skipAdBypass) { if (!skipAdBypass) {
try { await _runConfiguredBypass(deviceId, nonce);
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 (_) {}
} }
const maxAttempts = 5; const maxAttempts = 5;
for (int i = 1; i <= maxAttempts; i++) { for (var attempt = 1; attempt <= maxAttempts; attempt++) {
if (_currentCallId != callId) throw Exception('Cancelled'); _throwIfCancelled(callId);
onAttempt?.call(attempt, maxAttempts);
onAttempt?.call(i, maxAttempts);
try { try {
final response = await directGet(url); final session = await _fetchActiveSession(deviceId, nonce);
if (response.statusCode == 200) { if (session != null) {
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; _expectedNonce = null;
return vlessUrl; return session.toVlessUrl();
}
} }
} catch (e) { } catch (e) {
if (e.toString().contains('Cancelled')) rethrow; if (_isCancelledError(e)) rethrow;
debugPrint('[HivemindService] Attempt $i failed: $e'); 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.'); throw Exception('Session not activated. Server callback may have timed out.');
@@ -97,16 +73,139 @@ class HivemindService {
static Future<bool> checkHealth() async { static Future<bool> checkHealth() async {
try { try {
final url = Uri.parse('${AppConfig.hivemindApiPublic}/health'); final response = await directGet(
final response = await directGet(url, timeout: const Duration(seconds: 3)); _publicUrl('/health'),
timeout: const Duration(seconds: 3),
);
return response.statusCode == 200; return response.statusCode == 200;
} catch (_) { } catch (_) {
return false; 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<void> _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<String, dynamic>) 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) => static Uri _publicUrl(String path) =>
Uri.parse('${AppConfig.hivemindApiPublic}$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<String, dynamic> 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<String, dynamic> 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;
}
}
+48
View File
@@ -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<List<InstalledApp>> loadLaunchableApps() async {
final raw = await _channel.invokeListMethod<dynamic>('getLaunchableApps') ??
const <dynamic>[];
final apps = <InstalledApp>[];
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;
}
}
+274
View File
@@ -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<LocalSocksTestResult> 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<LocalSocksTestResult> 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(<int>[
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<bool> _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 <int>[0x05, 0x01, 0x02]
: const <int>[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(<int>[
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<void> _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<Uint8List> _iterator;
final List<int> _buffer = <int>[];
_SocketReader(Socket socket) : _iterator = StreamIterator<Uint8List>(socket);
Future<List<int>> 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<void> close() => _iterator.cancel();
}
+42
View File
@@ -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<dynamic, dynamic> 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<NetworkSnapshot> get changes {
return _channel.receiveBroadcastStream().where((event) => event is Map).map(
(event) => NetworkSnapshot.fromMap(event as Map<dynamic, dynamic>),
);
}
}
+89
View File
@@ -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<SecureSocksSession> create(String baseConfig) async {
final decoded = jsonDecode(baseConfig);
if (decoded is! Map<String, dynamic>) {
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 = <dynamic>[];
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(<String, dynamic>{
'tag': inboundTag,
'port': port,
'listen': '127.0.0.1',
'protocol': 'socks',
'settings': <String, dynamic>{
'auth': 'password',
'udp': true,
'ip': '127.0.0.1',
'users': <Map<String, String>>[
<String, String>{'user': username, 'pass': password},
],
},
'sniffing': <String, dynamic>{
'enabled': true,
'destOverride': <String>['http', 'tls'],
},
});
decoded['inbounds'] = inbounds;
return SecureSocksSession._(
port: port,
username: username,
password: password,
configJson: jsonEncode(decoded),
);
}
static String _token(int byteCount) {
final bytes = List<int>.generate(byteCount, (_) => _random.nextInt(256));
return bytes.map((value) => value.toRadixString(16).padLeft(2, '0')).join();
}
}
+178 -55
View File
@@ -1,11 +1,13 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/widgets.dart'; 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/app_config.dart';
import 'package:revoltvpn/logic/vpn_connection.dart';
import 'package:revoltvpn/logic/crypto_service.dart'; import 'package:revoltvpn/logic/crypto_service.dart';
import 'package:revoltvpn/logic/hivemind_service.dart';
import 'package:revoltvpn/logic/notification_service.dart'; import 'package:revoltvpn/logic/notification_service.dart';
import 'package:revoltvpn/logic/vpn_connection.dart';
class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
Timer? _timer; Timer? _timer;
@@ -15,12 +17,20 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
final VpnConnection vpnConnection; final VpnConnection vpnConnection;
int _remainingSeconds = 0; int _remainingSeconds = 0;
int _remainingAtLastSync = 0;
int _usedBytes = 0; int _usedBytes = 0;
bool _hasSyncedOnce = false; bool _hasSyncedOnce = false;
int _consecutiveFailures = 0; int _consecutiveFailures = 0;
bool _isDisconnecting = false; bool _isDisconnecting = false;
bool _syncInProgress = 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 _maxConsecutiveFailures = 3;
static const int _maxOfflineSeconds = 120; static const int _maxOfflineSeconds = 120;
@@ -30,18 +40,21 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
static const int _backgroundPollIntervalSeconds = 30; static const int _backgroundPollIntervalSeconds = 30;
int _lastUsedBytes = 0; int _lastUsedBytes = 0;
DateTime? _lastSuccessfulSyncAt;
double _currentSpeedKBps = 0.0; double _currentSpeedKBps = 0.0;
DateTime? _lastSyncAt;
SessionTimer({required this.vpnConnection}) { SessionTimer({required this.vpnConnection}) {
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
vpnConnection.addListener(_onVpnConnectionChanged); vpnConnection.addListener(_onVpnConnectionChanged);
unawaited(_loadSupportRewardState());
} }
int get remaining => _remainingSeconds; int get remaining => _remainingSeconds;
bool get isRunning => _timer != null && _timer!.isActive; bool get isRunning => _timer != null && _timer!.isActive;
bool get isExpired => _remainingSeconds <= 0 && !isRunning; bool get isExpired => _remainingSeconds <= 0 && !isRunning;
bool get hasSyncedOnce => _hasSyncedOnce; bool get hasSyncedOnce => _hasSyncedOnce;
bool get supportRewardClaimed => _supportRewardClaimed;
bool get supportRewardStateLoaded => _supportRewardStateLoaded;
int get usedBytes => _usedBytes; int get usedBytes => _usedBytes;
double get currentSpeedKBps => _currentSpeedKBps; double get currentSpeedKBps => _currentSpeedKBps;
@@ -53,8 +66,42 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
return '$h:$m:$s'; return '$h:$m:$s';
} }
Future<void> _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<void> _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<void> markSupportRewardClaimed() async {
if (_supportRewardClaimed) return;
_supportRewardClaimed = true;
_supportRewardStateLoaded = true;
notifyListeners();
await _persistSupportRewardState(true);
}
void _onVpnConnectionChanged() { void _onVpnConnectionChanged() {
// Restore timer if VPN was already running at app launch.
if (vpnConnection.status == VpnStatus.connected && if (vpnConnection.status == VpnStatus.connected &&
!isRunning && !isRunning &&
vpnConnection.isStartupRestoration) { vpnConnection.isStartupRestoration) {
@@ -62,59 +109,90 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
return; return;
} }
// Resume after brief tunnel disconnection.
if (vpnConnection.status == VpnStatus.connected && if (vpnConnection.status == VpnStatus.connected &&
!isRunning && !isRunning &&
!_isDisconnecting && !_isDisconnecting &&
_hasSyncedOnce) { _hasSyncedOnce) {
debugPrint('[Timer] VPN reconnected after blip — resuming.'); debugPrint('[Timer] VPN reconnected after blip - resuming.');
_resumeTicking(); _resumeTicking();
return; return;
} }
if (vpnConnection.status == VpnStatus.disconnected && !_isDisconnecting) { final hadActiveSession = isRunning || _hasSyncedOnce;
_doDisconnect('VPN tunnel dropped'); 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'));
}
}
void _stopForVpnFailure(String reason) {
if (_isDisconnecting) return;
_isDisconnecting = true;
debugPrint('[Timer] Stopping after VPN failure: $reason');
_timer?.cancel();
_timer = null;
_currentSpeedKBps = 0.0;
_remainingSeconds = 0;
_remainingAtLastSync = 0;
_hasSyncedOnce = false;
_lastSuccessfulSyncAt = null;
_consecutiveFailures = 0;
_offlineSeconds = 0;
NotificationService.reset();
notifyListeners();
} }
Future<void> start() async { Future<void> start() async {
_remainingSeconds = 0; _remainingSeconds = 0;
_remainingAtLastSync = 0;
_usedBytes = 0; _usedBytes = 0;
_lastUsedBytes = 0; _lastUsedBytes = 0;
_lastSuccessfulSyncAt = null;
_currentSpeedKBps = 0.0; _currentSpeedKBps = 0.0;
_lastSyncAt = null;
NotificationService.reset();
_tickCount = 0; _tickCount = 0;
_hasSyncedOnce = false; _hasSyncedOnce = false;
_consecutiveFailures = 0; _consecutiveFailures = 0;
_offlineSeconds = 0; _offlineSeconds = 0;
_isDisconnecting = false; _isDisconnecting = false;
_appBackgrounded = false; _appBackgrounded = false;
NotificationService.reset();
_supportStateEpoch++;
_supportRewardClaimed = false;
_supportRewardStateLoaded = true;
unawaited(_persistSupportRewardState(false));
_timer?.cancel(); _timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), _tick); _timer = Timer.periodic(const Duration(seconds: 1), _tick);
notifyListeners(); notifyListeners();
_syncWithHivemind(); unawaited(_syncWithHivemind());
} }
void _tick(Timer t) { void _tick(Timer t) {
if (_isDisconnecting) return; if (_isDisconnecting) return;
// A local clock: poll failures are about the network, not the clock.
if (_hasSyncedOnce && _remainingSeconds > 0) { if (_hasSyncedOnce && _consecutiveFailures < _maxConsecutiveFailures) {
_remainingSeconds--; _reconcileElapsedTime();
} }
if (_consecutiveFailures >= _maxConsecutiveFailures) { if (_consecutiveFailures >= _maxConsecutiveFailures) {
_offlineSeconds++; _offlineSeconds++;
if (_offlineSeconds >= _maxOfflineSeconds) { if (_offlineSeconds >= _maxOfflineSeconds) {
_doDisconnect('Server unreachable'); unawaited(_doDisconnect('Server unreachable'));
return; return;
} }
} }
if (_hasSyncedOnce && _remainingSeconds <= 0) { if (_hasSyncedOnce && _remainingSeconds <= 0) {
_doDisconnect('Session expired'); unawaited(_doDisconnect('Session expired'));
return; return;
} }
@@ -123,16 +201,12 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
? _backgroundPollIntervalSeconds ? _backgroundPollIntervalSeconds
: _pollIntervalSeconds; : _pollIntervalSeconds;
if (_tickCount % pollInterval == 0) { if (_tickCount % pollInterval == 0) {
_syncWithHivemind(); unawaited(_syncWithHivemind());
} }
notifyListeners(); notifyListeners();
// Gated on a live tunnel. The plugin cancels the notification when the if (_hasSyncedOnce && vpnConnection.status == VpnStatus.connected) {
// 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) {
NotificationService.updateTimer(formatted); NotificationService.updateTimer(formatted);
} }
} }
@@ -150,8 +224,9 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
_timer = null; _timer = null;
_currentSpeedKBps = 0.0; _currentSpeedKBps = 0.0;
_remainingSeconds = 0; _remainingSeconds = 0;
_remainingAtLastSync = 0;
_hasSyncedOnce = false; _hasSyncedOnce = false;
_lastSyncAt = null; _lastSuccessfulSyncAt = null;
NotificationService.reset(); NotificationService.reset();
notifyListeners(); notifyListeners();
@@ -162,45 +237,72 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
notifyListeners(); 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<void> _syncWithHivemind() async { Future<void> _syncWithHivemind() async {
if (_isDisconnecting || _syncInProgress) return; if (_isDisconnecting || _syncInProgress) return;
_syncInProgress = true; _syncInProgress = true;
try { try {
final deviceId = await CryptoService.getDeviceId(); final deviceId = await CryptoService.getDeviceId();
final url = Uri.parse( final base = Uri.parse('${AppConfig.hivemindApiPublic}/session/status');
'${AppConfig.hivemindApiPublic}/session/status?device_id=$deviceId'); final url = base.replace(queryParameters: {'device_id': deviceId});
final response = await HivemindService.directGet(url); final response = await HivemindService.directGet(url);
if (_isDisconnecting) return;
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = jsonDecode(response.body); final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
debugPrint('[Timer] Invalid session payload type.');
_markSyncFailure();
return;
}
final data = decoded;
final bool active = data['active'] ?? false; final activeValue = data['active'];
if (!active) { if (activeValue is! bool) {
debugPrint('[Timer] Session payload missing boolean active state.');
_markSyncFailure();
return;
}
if (!activeValue) {
await _doDisconnect('Server ended session'); await _doDisconnect('Server ended session');
return; return;
} }
_remainingSeconds = data['expires_in_seconds'] ?? _remainingSeconds; final expiresValue = data['expires_in_seconds'];
_usedBytes = data['used_bytes'] ?? _usedBytes; if (expiresValue is! num ||
!expiresValue.isFinite ||
expiresValue < 0) {
debugPrint('[Timer] Session payload has invalid expiry.');
_markSyncFailure();
return;
}
_remainingAtLastSync = expiresValue.toInt();
_remainingSeconds = _remainingAtLastSync;
_usedBytes = _readNonNegativeInt(data['used_bytes'], _usedBytes);
// 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 now = DateTime.now();
final lastSync = _lastSyncAt; final previousSyncAt = _lastSuccessfulSyncAt;
_lastSyncAt = now; final deltaBytes = _usedBytes - _lastUsedBytes;
if (_hasSyncedOnce && previousSyncAt != null) {
final int deltaBytes = _usedBytes - _lastUsedBytes; final elapsedMs = now.difference(previousSyncAt).inMilliseconds;
if (_hasSyncedOnce && deltaBytes > 0 && lastSync != null) { if (deltaBytes > 0 && elapsedMs > 0) {
final elapsed = now.difference(lastSync).inMilliseconds / 1000.0; _currentSpeedKBps = (deltaBytes / (elapsedMs / 1000.0)) / 1000.0;
_currentSpeedKBps = } else {
elapsed > 0 ? (deltaBytes / elapsed) / 1000 : _currentSpeedKBps; _currentSpeedKBps = 0.0;
} else if (!_hasSyncedOnce) { }
} else {
_currentSpeedKBps = 0.0; _currentSpeedKBps = 0.0;
} }
_lastUsedBytes = _usedBytes; _lastUsedBytes = _usedBytes;
_lastSuccessfulSyncAt = now;
final capExhausted = data['cap_exhausted'] ?? false; if (data['cap_exhausted'] == true) {
if (capExhausted) {
await _doDisconnect('Data cap reached'); await _doDisconnect('Data cap reached');
return; return;
} }
@@ -213,8 +315,10 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
_markSyncFailure(); _markSyncFailure();
} }
} catch (e) { } catch (e) {
if (!_isDisconnecting) {
debugPrint('Hivemind sync error: $e'); debugPrint('Hivemind sync error: $e');
_markSyncFailure(); _markSyncFailure();
}
} finally { } finally {
_syncInProgress = false; _syncInProgress = false;
} }
@@ -227,27 +331,46 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
} }
} }
void _resumeTicking() { void _reconcileElapsedTime() {
_lastSyncAt = null; final syncedAt = _lastSuccessfulSyncAt;
_timer?.cancel(); if (!_hasSyncedOnce || syncedAt == null) return;
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
_isDisconnecting = false; final elapsed = DateTime.now().difference(syncedAt).inSeconds;
_syncWithHivemind(); _remainingSeconds =
notifyListeners(); (_remainingAtLastSync - elapsed).clamp(0, 1 << 31).toInt();
} }
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused || if (state == AppLifecycleState.paused ||
state == AppLifecycleState.detached) { state == AppLifecycleState.detached ||
// Keep ticking, but slow the poll — the 5 s one wakes the radio. state == AppLifecycleState.inactive ||
state == AppLifecycleState.hidden) {
_appBackgrounded = true; _appBackgrounded = true;
} else if (state == AppLifecycleState.resumed) { return;
}
if (state != AppLifecycleState.resumed || _isDisconnecting) return;
_appBackgrounded = false; _appBackgrounded = false;
if (_timer != null && _timer!.isActive) { if (vpnConnection.status != VpnStatus.connected) return;
_syncWithHivemind();
_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 @override
+159 -49
View File
@@ -13,6 +13,78 @@ enum UpdateStatus { upToDate, updateAvailable, checkFailed }
enum InstallSource { playStore, sideload } enum InstallSource { playStore, sideload }
Uri? _trustedHttpsUri(String raw, Set<String> allowedHosts) {
final uri = Uri.tryParse(raw);
if (uri == null || uri.scheme != 'https' || !allowedHosts.contains(uri.host)) {
return null;
}
return uri;
}
class _ParsedVersion {
final List<int> core;
final List<String> 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 <String>[]
: 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 { class PlayStoreUpdater {
PlayStoreUpdater._(); PlayStoreUpdater._();
@@ -20,9 +92,13 @@ class PlayStoreUpdater {
'https://play.google.com/store/apps/details?id=${AppConfig.applicationId}'; 'https://play.google.com/store/apps/details?id=${AppConfig.applicationId}';
static Future<bool> open() async { static Future<bool> open() async {
final uri = _trustedHttpsUri(_url, const {'play.google.com'});
if (uri == null) {
debugPrint('[Updater] Refusing invalid Play Store URL.');
return false;
}
try { try {
return await launchUrl(Uri.parse(_url), return await launchUrl(uri, mode: LaunchMode.externalApplication);
mode: LaunchMode.externalApplication);
} catch (e) { } catch (e) {
debugPrint('[Updater] Failed to open Play Store: $e'); debugPrint('[Updater] Failed to open Play Store: $e');
return false; return false;
@@ -36,13 +112,14 @@ class GitHubUpdater {
static String? _cachedDownloadUrl; static String? _cachedDownloadUrl;
static Future<String?> fetchLatestVersion() async { static Future<String?> fetchLatestVersion() async {
final url = final apiUri = Uri.https(
'https://api.github.com/repos/${AppConfig.githubOwner}/${AppConfig.githubRepo}/releases/latest'; 'api.github.com',
'/repos/${AppConfig.githubOwner}/${AppConfig.githubRepo}/releases/latest',
);
try { try {
final response = await http final response = await http
.get(Uri.parse(url), .get(apiUri, headers: {'Accept': 'application/vnd.github+json'})
headers: {'Accept': 'application/vnd.github+json'})
.timeout(const Duration(seconds: 8)); .timeout(const Duration(seconds: 8));
if (response.statusCode != 200) { if (response.statusCode != 200) {
@@ -50,38 +127,48 @@ class GitHubUpdater {
return null; return null;
} }
final data = jsonDecode(response.body) as Map<String, dynamic>; final decoded = jsonDecode(response.body);
_cachedDownloadUrl = data['html_url'] as String?; if (decoded is! Map<String, dynamic>) {
debugPrint('[Updater] Invalid GitHub release payload.');
return null;
}
final tag = data['tag_name'] as String? ?? '0.0.0'; final htmlUrl = decoded['html_url'];
return tag.startsWith('v') ? tag.substring(1) : tag; 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) { } catch (e) {
debugPrint('[Updater] GitHub API error: $e'); debugPrint('[Updater] GitHub API error: $e');
return null; return null;
} }
} }
static bool _isTrustedReleaseUrl(String url) { static Future<bool> open() async {
final uri = Uri.tryParse(url); final raw = _cachedDownloadUrl ?? AppConfig.githubReleasesUrl;
if (uri == null || uri.scheme != 'https' || uri.host != 'github.com') { final uri = _trustedHttpsUri(raw, const {'github.com'});
if (uri == null) {
debugPrint('[Updater] Refusing untrusted GitHub update URL.');
return false; 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<bool> 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 { try {
return await launchUrl(Uri.parse(url), return await launchUrl(uri, mode: LaunchMode.externalApplication);
mode: LaunchMode.externalApplication);
} catch (e) { } catch (e) {
debugPrint('[Updater] Failed to open GitHub: $e'); debugPrint('[Updater] Failed to open GitHub: $e');
return false; return false;
@@ -116,30 +203,41 @@ class Updater {
} }
static int _compareVersions(String a, String b) { static int _compareVersions(String a, String b) {
final aParts = a.split('.').map((p) => int.tryParse(p) ?? 0).toList(); final left = _ParsedVersion.tryParse(a);
final bParts = b.split('.').map((p) => int.tryParse(p) ?? 0).toList(); final right = _ParsedVersion.tryParse(b);
for (int i = 0; i < 3; i++) { if (left == null || right == null) {
final diff = (aParts.length > i ? aParts[i] : 0) - // Invalid local metadata should not invent an ordering.
(bParts.length > i ? bParts[i] : 0);
if (diff != 0) return diff;
}
return 0; return 0;
} }
return left.compareTo(right);
}
static String _versionLabel(String v, bool isLocal) { static String _versionLabel(String v, bool isLocal) {
final prefix = v.startsWith('v') ? '' : 'v'; final prefix = v.startsWith('v') ? '' : 'v';
return '$prefix$v${isLocal ? ' (installed)' : ''}'; return '$prefix$v${isLocal ? ' (installed)' : ''}';
} }
static Future<void> check({ static Future<void> check(BuildContext context) {
final navigator = Navigator.of(context, rootNavigator: true);
final messenger = ScaffoldMessenger.of(context);
return checkWithHandles(
navigator: navigator,
messenger: messenger,
);
}
static Future<void> checkWithHandles({
required NavigatorState navigator, required NavigatorState navigator,
required ScaffoldMessengerState messenger, required ScaffoldMessengerState messenger,
}) async { }) async {
final info = await PackageInfo.fromPlatform(); final info = await PackageInfo.fromPlatform();
final localVersion = info.version; final localVersion = info.version;
if (localVersion.isEmpty) { if (localVersion.isEmpty || _ParsedVersion.tryParse(localVersion) == null) {
_showSnackBar(messenger, 'Could not determine app version', _showSnackBar(
isError: true); messenger,
'Could not determine app version',
isError: true,
);
return; return;
} }
@@ -155,15 +253,18 @@ class Updater {
} }
final source = await installSource; final source = await installSource;
final storeName = source == InstallSource.playStore ? 'Play Store' : 'GitHub'; final storeName =
source == InstallSource.playStore ? 'Play Store' : 'GitHub';
if (!navigator.mounted) return; if (!navigator.mounted) return;
showDialog( showDialog(
context: navigator.context, context: navigator.context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
backgroundColor: AppColors.bgCard, backgroundColor: AppColors.bgCard,
title: const Text('Update available', title: const Text(
style: TextStyle(color: AppColors.textWhite)), 'Update available',
style: TextStyle(color: AppColors.textWhite),
),
content: Text( content: Text(
'${_versionLabel(latestVersion, false)} is available on $storeName.\n' '${_versionLabel(latestVersion, false)} is available on $storeName.\n'
'You have ${_versionLabel(localVersion, true)}.', 'You have ${_versionLabel(localVersion, true)}.',
@@ -172,8 +273,10 @@ class Updater {
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(ctx), onPressed: () => Navigator.pop(ctx),
child: const Text('Later', child: const Text(
style: TextStyle(color: AppColors.slate70)), 'Later',
style: TextStyle(color: AppColors.slate70),
),
), ),
TextButton( TextButton(
onPressed: () { onPressed: () {
@@ -182,24 +285,31 @@ class Updater {
? PlayStoreUpdater.open() ? PlayStoreUpdater.open()
: GitHubUpdater.open(); : GitHubUpdater.open();
}, },
child: Text('Open $storeName', child: Text(
style: const TextStyle(color: AppColors.accent)), 'Open $storeName',
style: const TextStyle(color: AppColors.accent),
),
), ),
], ],
), ),
); );
} }
static void _showSnackBar(ScaffoldMessengerState messenger, String message, static void _showSnackBar(
{bool isError = false}) { ScaffoldMessengerState messenger,
String message, {
bool isError = false,
}) {
if (!messenger.mounted) return; if (!messenger.mounted) return;
messenger.showSnackBar( messenger.showSnackBar(
SnackBar( SnackBar(
content: Row( content: Row(
children: [ children: [
Icon(isError ? Icons.error_outline : Icons.check_circle, Icon(
isError ? Icons.error_outline : Icons.check_circle,
color: isError ? AppColors.red : AppColors.green, color: isError ? AppColors.red : AppColors.green,
size: 18), size: 18,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(message), Text(message),
], ],
+523 -68
View File
@@ -1,8 +1,13 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_vless/flutter_vless.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/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 { enum VpnStatus {
disconnected, disconnected,
@@ -12,7 +17,21 @@ enum VpnStatus {
error, error,
} }
class VpnConnection extends ChangeNotifier with WidgetsBindingObserver { class _RoutingPlan {
final List<String>? 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; bool _cancelled = false;
VpnStatus _status = VpnStatus.disconnected; VpnStatus _status = VpnStatus.disconnected;
VpnStatus get status => _status; VpnStatus get status => _status;
@@ -29,7 +48,83 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
bool _serverReachable = false; bool _serverReachable = false;
bool get serverReachable => _serverReachable; 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<LocalSocksTestResult> 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? _healthTimer;
Timer? _restartTimer;
StreamSubscription<NetworkSnapshot>? _networkSubscription;
String? _lastBaseConfig;
String _lastRemark = 'Revolt VPN';
List<String>? _lastBlockedApps;
bool _lastSelectedOnly = false;
bool _lastVerifyLocalSocks = false;
SecureSocksSession? _lastSecureSocks;
bool _restartInProgress = false;
bool _userDisconnecting = false;
late final FlutterVless _vless; late final FlutterVless _vless;
bool _initialized = false; bool _initialized = false;
@@ -37,7 +132,8 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
Future<void> get ready => _readyCompleter.future; Future<void> get ready => _readyCompleter.future;
VpnConnection() { VpnConnection() {
WidgetsBinding.instance.addObserver(this); _activeMode = ConnectionSettings.mode;
_activeResilienceMode = ConnectionSettings.resilienceMode;
_init(); _init();
} }
@@ -54,10 +150,16 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
Future<void> _startEngine() async { Future<void> _startEngine() async {
if (kIsWeb) return; if (kIsWeb) return;
await ConnectionSettings.initialize();
_activeMode = ConnectionSettings.mode;
_activeResilienceMode = ConnectionSettings.resilienceMode;
_vless = FlutterVless( _vless = FlutterVless(
onStatusChanged: (status) { onStatusChanged: (status) {
debugPrint('[VPN] Status: state=${status.state} ' debugPrint(
'connection=${status.connectionState.name}'); '[VPN] Status: state=${status.state} '
'connection=${status.connectionState.name}',
);
_mapStatus(status); _mapStatus(status);
}, },
); );
@@ -66,14 +168,30 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
await _vless.initializeVless( await _vless.initializeVless(
providerBundleIdentifier: 'com.paladinvpn.app', providerBundleIdentifier: 'com.paladinvpn.app',
notificationIconResourceType: 'drawable', notificationIconResourceType: 'drawable',
notificationIconResourceName: 'notification_icon', notificationIconResourceName: 'notification_status_icon',
); );
_initialized = true; _initialized = true;
} catch (e) { } catch (e) {
debugPrint('[VPN] VLESS init error (expected on emulator): $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 { try {
final coreVersion = await _vless.getCoreVersion(); final coreVersion = await _vless.getCoreVersion();
@@ -81,53 +199,117 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
final delay = await _vless.getConnectedServerDelay(); final delay = await _vless.getConnectedServerDelay();
if (delay > 0) { if (delay > 0) {
_lastHealthLatencyMs = delay;
_isStartupRestoration = true; _isStartupRestoration = true;
_setStatus(VpnStatus.connected, 'Secured'); _restoreRoutingPresentationFromSettings();
_setStatus(VpnStatus.connected, _connectedLabel);
} }
} catch (_) {} } 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) { void _mapStatus(VlessStatus status) {
switch (status.connectionState) { switch (status.connectionState) {
case VlessConnectionState.connected: case VlessConnectionState.connected:
_setStatus(VpnStatus.connected, 'Secured'); _setStatus(VpnStatus.connected, _connectedLabel);
break; break;
case VlessConnectionState.disconnected: 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'); _setStatus(VpnStatus.disconnected, 'Tap to connect');
break; break;
case VlessConnectionState.connecting: case VlessConnectionState.connecting:
if (!_restartInProgress) {
_setStatus(VpnStatus.connecting, 'Establishing tunnel…'); _setStatus(VpnStatus.connecting, 'Establishing tunnel…');
}
break; break;
case VlessConnectionState.disconnecting: case VlessConnectionState.disconnecting:
_isStartupRestoration = false; _isStartupRestoration = false;
if (!_restartInProgress) {
_setStatus(VpnStatus.disconnecting, 'Tearing down…'); _setStatus(VpnStatus.disconnecting, 'Tearing down…');
}
break; break;
case VlessConnectionState.unknown: case VlessConnectionState.unknown:
if (_status != VpnStatus.connected && if (_status != VpnStatus.connected &&
_status != VpnStatus.disconnected) { _status != VpnStatus.disconnected &&
!_restartInProgress) {
_setStatus(VpnStatus.error, 'Connection failed'); _setStatus(VpnStatus.error, 'Connection failed');
} }
break; break;
} }
} }
// ── Connect ──────────────────────────────────────────────────────── Future<bool> connect({bool skipAdBypass = false}) async {
//
// 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<bool> connect({
bool skipAdBypass = false,
}) async {
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) { if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
return false; return false;
} }
_cancelled = 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) { if (!kIsWeb && !_initialized) {
_errorMessage = 'VPN service unavailable.'; _errorMessage = 'VPN service unavailable.';
@@ -135,6 +317,8 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
return false; 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) { if (!kIsWeb) {
final ok = await _vless.requestPermission(); final ok = await _vless.requestPermission();
if (!ok) { 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; _errorMessage = null;
if (kIsWeb) { if (kIsWeb) {
@@ -161,7 +353,9 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
skipAdBypass: skipAdBypass, skipAdBypass: skipAdBypass,
onAttempt: (attempt, total) { onAttempt: (attempt, total) {
_setStatus( _setStatus(
VpnStatus.connecting, 'Contacting server ($attempt/$total)…'); VpnStatus.connecting,
'Contacting server ($attempt/$total)…',
);
}, },
); );
} catch (e) { } 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.'; 'The server did not respond in time.\nCheck your connection and try again.';
_setStatus(VpnStatus.error, 'Server unreachable'); _setStatus(VpnStatus.error, 'Server unreachable');
} else { } else {
_errorMessage = e.toString().replaceAll('Exception: ', ''); _errorMessage = raw;
_setStatus(VpnStatus.error, 'Config fetch error'); _setStatus(VpnStatus.error, 'Config fetch error');
} }
return false; return false;
@@ -184,31 +378,299 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
return false; return false;
} }
_setStatus(VpnStatus.connecting, 'Securing connection…'); _setStatus(VpnStatus.connecting, 'Starting secure route…');
try { try {
final parsed = FlutterVless.parse(realUrl); 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( // Set this before the native callback can report CONNECTED so the first
remark: parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN', // visible label already reflects Selected only accurately.
config: parsed.getFullConfiguration(), _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) { } catch (e) {
debugPrint('[VPN] Tunnel start error: $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'); _setStatus(VpnStatus.error, 'Connection failed');
return false; return false;
} }
_setStatus(VpnStatus.connected, 'Secured'); _setStatus(VpnStatus.connected, _connectedLabel);
return true; 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<String> 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<void> _startRuntime({
required String config,
required String remark,
required List<String>? blockedApps,
}) async {
await _vless.startVless(
remark: remark,
config: config,
blockedApps: blockedApps,
proxyOnly: false,
);
}
Future<bool> _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<bool> _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<void> _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<void> _stopRuntime() async {
try {
await _vless.stopVless().timeout(const Duration(seconds: 5));
} catch (_) {}
}
Future<void> disconnect() async { Future<void> disconnect() async {
_cancelled = true; _cancelled = true;
_userDisconnecting = true;
_restartTimer?.cancel();
HivemindService.cancel(); HivemindService.cancel();
if (_status == VpnStatus.disconnected || if (_status == VpnStatus.disconnected ||
_status == VpnStatus.disconnecting) { _status == VpnStatus.disconnecting) {
_clearRuntimeSnapshot();
_userDisconnecting = false;
return; return;
} }
@@ -216,7 +678,9 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
if (kIsWeb) { if (kIsWeb) {
await Future.delayed(const Duration(milliseconds: 500)); await Future.delayed(const Duration(milliseconds: 500));
_clearRuntimeSnapshot();
_setStatus(VpnStatus.disconnected, 'Tap to connect'); _setStatus(VpnStatus.disconnected, 'Tap to connect');
_userDisconnecting = false;
return; return;
} }
@@ -229,65 +693,56 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
} catch (e) { } catch (e) {
debugPrint('[VPN] VLESS stop error: $e'); debugPrint('[VPN] VLESS stop error: $e');
_errorMessage = 'VPN shutdown error.\nPlease restart the app.'; _errorMessage = 'VPN shutdown error.\nPlease restart the app.';
_clearRuntimeSnapshot();
_setStatus(VpnStatus.error, 'Shutdown failed'); _setStatus(VpnStatus.error, 'Shutdown failed');
_userDisconnecting = false;
return; return;
} }
if (timedOut) { if (timedOut) {
debugPrint('[VPN] stopVless() timed out after 5 s — ' debugPrint('[VPN] stopVless() timed out after 5 s.');
'tunnel may still be active.'); _errorMessage = 'VPN did not shut down cleanly.\nPlease restart the app.';
_errorMessage = 'VPN did not shut down cleanly.\n' _clearRuntimeSnapshot();
'Please restart the app.';
_setStatus(VpnStatus.error, 'Shutdown failed'); _setStatus(VpnStatus.error, 'Shutdown failed');
_userDisconnecting = false;
return; return;
} }
_errorMessage = null;
_clearRuntimeSnapshot();
_setStatus(VpnStatus.disconnected, 'Tap to connect'); _setStatus(VpnStatus.disconnected, 'Tap to connect');
_userDisconnecting = false;
} }
void _setStatus(VpnStatus s, String msg) { void _clearRuntimeSnapshot() {
_status = s; _lastBaseConfig = null;
_statusMessage = msg; _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(); notifyListeners();
} }
void _startHealthPolling() {
_healthTimer?.cancel();
_checkHealthIfIdle();
_healthTimer = Timer.periodic(
const Duration(seconds: 30), (_) => _checkHealthIfIdle());
}
Future<void> _checkHealthIfIdle() async {
// A live tunnel already proves reachability.
if (_status == VpnStatus.connected) return;
await _checkHealth();
}
Future<void> _checkHealth() async { Future<void> _checkHealth() async {
_serverReachable = await HivemindService.checkHealth(); _serverReachable = await HivemindService.checkHealth();
notifyListeners(); notifyListeners();
} }
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.detached) {
_healthTimer?.cancel();
} else if (state == AppLifecycleState.resumed) {
_startHealthPolling();
}
}
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this);
_healthTimer?.cancel(); _healthTimer?.cancel();
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) { _restartTimer?.cancel();
try { _networkSubscription?.cancel();
_vless.stopVless(); // Provider/UI disposal is not a user disconnect command. Keeping teardown
} catch (_) {} // in disconnect() prevents lifecycle churn from silently dropping the VPN.
}
super.dispose(); super.dispose();
} }
} }
+23 -15
View File
@@ -1,18 +1,24 @@
import 'package:flutter/material.dart';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:revoltvpn/components/app_haptic_surface.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/logic/ad_manager.dart';
import 'package:revoltvpn/logic/server_list.dart';
import 'package:revoltvpn/logic/app_colors.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'; import 'package:revoltvpn/screens/intro.dart';
void main() {
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); await HapticSettings.initialize();
await ConnectionSettings.initialize();
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
statusBarColor: Colors.transparent, statusBarColor: Colors.transparent,
@@ -40,23 +46,25 @@ class ReVoltApp extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiProvider( return MultiProvider(
providers: [ 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), ChangeNotifierProvider(create: (_) => VpnConnection(), lazy: false),
ChangeNotifierProxyProvider<VpnConnection, SessionTimer>( ChangeNotifierProxyProvider<VpnConnection, SessionTimer>(
create: (ctx) => SessionTimer(vpnConnection: ctx.read<VpnConnection>()), create: (ctx) =>
update: (_, vpn, prev) => prev ?? SessionTimer(vpnConnection: vpn), SessionTimer(vpnConnection: ctx.read<VpnConnection>()),
update: (_, vpn, prev) =>
prev ?? SessionTimer(vpnConnection: vpn),
), ),
ChangeNotifierProvider(create: (_) => AdManager()), ChangeNotifierProvider(create: (_) => AdManager()),
ChangeNotifierProvider(create: (_) => ServerList()..init(), lazy: false), ChangeNotifierProvider(
create: (_) => ServerList()..init(),
lazy: false,
),
], ],
child: MaterialApp( child: MaterialApp(
title: 'ReVolt VPN', title: 'ReVolt VPN',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: _buildDarkTheme(), theme: _buildDarkTheme(),
builder: (context, child) =>
AppHapticSurface(child: child ?? const SizedBox.shrink()),
home: const IntroScreen(), home: const IntroScreen(),
), ),
); );
+8 -9
View File
@@ -2,11 +2,11 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:revoltvpn/logic/app_colors.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/logic/vpn_connection.dart';
import 'package:revoltvpn/screens/main_screen.dart'; import 'package:revoltvpn/screens/main_screen.dart';
import 'package:revoltvpn/screens/settings/in_settings/rain.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/lightning.dart';
import 'package:revoltvpn/screens/settings/in_settings/haptic.dart';
class IntroScreen extends StatefulWidget { class IntroScreen extends StatefulWidget {
const IntroScreen({super.key}); const IntroScreen({super.key});
@@ -45,10 +45,12 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
final elapsed = Stopwatch()..start(); final elapsed = Stopwatch()..start();
final vpn = context.read<VpnConnection>(); final vpn = context.read<VpnConnection>();
// The overlays read these in initState, so they must land before // The overlays and haptic layer read these before MainScreen mounts.
// MainScreen mounts. final settingsPrefs = Future.wait([
final settingsPrefs = Future.wait( loadRainPref(),
[loadRainPref(), loadLightningPref(), loadHapticPref()]); loadLightningPref(),
HapticSettings.initialize(),
]);
try { try {
await vpn.ready.timeout(_bootTimeout); await vpn.ready.timeout(_bootTimeout);
@@ -79,7 +81,7 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
void _navigateIfReady() { void _navigateIfReady() {
if (!mounted || _navigated || !_bootComplete) return; if (!mounted || _navigated || !_bootComplete) return;
if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.paused) { if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.paused) {
return; // resume will call back in return;
} }
_navigated = true; _navigated = true;
@@ -101,7 +103,6 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
body: Column( body: Column(
children: [ children: [
const Spacer(flex: 7), const Spacer(flex: 7),
// Image slightly below center
Center( Center(
child: Image.asset( child: Image.asset(
'assets/brand.png', 'assets/brand.png',
@@ -116,7 +117,6 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
}, },
), ),
), ),
// Spinner + text paired together below the image
const SizedBox(height: 8), const SizedBox(height: 8),
const SizedBox( const SizedBox(
width: 40, width: 40,
@@ -137,7 +137,6 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
// Says what the spinner is actually waiting on.
AnimatedSwitcher( AnimatedSwitcher(
duration: const Duration(milliseconds: 250), duration: const Duration(milliseconds: 250),
child: Text( child: Text(
@@ -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<AppRoutingTile> createState() => _AppRoutingTileState();
}
class _AppRoutingTileState extends State<AppRoutingTile> {
AppRoutingMode _mode = ConnectionSettings.routingMode;
int _selectedCount = ConnectionSettings.appPackages.length;
bool get _proxyMode => widget.connectionMode == ConnectionMode.proxy;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
await ConnectionSettings.initialize();
if (!mounted) return;
setState(() {
_mode = ConnectionSettings.routingMode;
_selectedCount = ConnectionSettings.appPackages.length;
});
}
bool _vpnBusy() {
final status = context.read<VpnConnection>().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<void> _changeMode(AppRoutingMode? next) async {
if (next == null || next == _mode) return;
if (_vpnBusy()) {
_showDisconnectFirst();
return;
}
await ConnectionSettings.setRoutingMode(next);
if (mounted) setState(() => _mode = next);
}
Future<void> _openPicker() async {
if (_mode == AppRoutingMode.all) return;
if (_vpnBusy()) {
_showDisconnectFirst();
return;
}
await Navigator.of(context).push(
MaterialPageRoute<void>(
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<AppRoutingMode>(
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<String> _selected = <String>{...ConnectionSettings.appPackages};
final TextEditingController _search = TextEditingController();
List<InstalledApp> _apps = const <InstalledApp>[];
bool _loading = true;
String? _error;
String _query = '';
@override
void initState() {
super.initState();
_loadApps();
}
@override
void dispose() {
_search.dispose();
super.dispose();
}
Future<void> _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<void> _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<InstalledApp> 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),
),
);
},
);
}
}
@@ -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<ConnectionMode>? onChanged;
const ConnectionModeTile({super.key, this.onChanged});
@override
State<ConnectionModeTile> createState() => _ConnectionModeTileState();
}
class _ConnectionModeTileState extends State<ConnectionModeTile> {
ConnectionMode _mode = ConnectionSettings.mode;
bool _testingLocalSocks = false;
LocalSocksTestResult? _lastSocksTest;
@override
void initState() {
super.initState();
_load();
}
Future<void> _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<void> _changeMode(ConnectionMode? next) async {
if (next == null || next == _mode) return;
final vpn = context.read<VpnConnection>();
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<void> _testLocalSocks() async {
if (_testingLocalSocks) return;
final vpn = context.read<VpnConnection>();
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<ConnectionMode>(
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,
),
),
),
],
],
);
}
}
+19 -49
View File
@@ -1,43 +1,6 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:revoltvpn/logic/app_colors.dart'; import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/logic/haptic_settings.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<void> 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<void> connectionChanged() async {
if (!hapticEnabled) return;
try {
await HapticFeedback.mediumImpact();
} catch (e) {
debugPrint('[Haptics] Feedback unavailable: $e');
}
}
static Future<void> previewEnabled() async {
try {
await HapticFeedback.selectionClick();
} catch (e) {
debugPrint('[Haptics] Preview unavailable: $e');
}
}
}
class HapticFeedbackTile extends StatefulWidget { class HapticFeedbackTile extends StatefulWidget {
const HapticFeedbackTile({super.key}); const HapticFeedbackTile({super.key});
@@ -47,7 +10,8 @@ class HapticFeedbackTile extends StatefulWidget {
} }
class _HapticFeedbackTileState extends State<HapticFeedbackTile> { class _HapticFeedbackTileState extends State<HapticFeedbackTile> {
bool _on = false; bool _on = HapticSettings.enabled;
bool _saving = false;
@override @override
void initState() { void initState() {
@@ -56,18 +20,24 @@ class _HapticFeedbackTileState extends State<HapticFeedbackTile> {
} }
Future<void> _load() async { Future<void> _load() async {
await loadHapticPref(); await HapticSettings.initialize();
if (mounted) setState(() => _on = hapticEnabled); 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<void> _toggle(bool value) async { Future<void> _toggle(bool value) async {
final prefs = await SharedPreferences.getInstance(); if (_saving) return;
await prefs.setBool('haptic_feedback_enabled', value); setState(() {
hapticEnabled = value; _saving = true;
if (value) unawaited(Haptics.previewEnabled()); _on = value;
setState(() => _on = value); });
try {
await HapticSettings.setEnabled(value);
} catch (_) {
if (mounted) setState(() => _on = HapticSettings.enabled);
} finally {
if (mounted) setState(() => _saving = false);
}
} }
@override @override
@@ -78,7 +48,7 @@ class _HapticFeedbackTileState extends State<HapticFeedbackTile> {
style: TextStyle(color: AppColors.textWhite, fontSize: 15), style: TextStyle(color: AppColors.textWhite, fontSize: 15),
), ),
value: _on, value: _on,
onChanged: _toggle, onChanged: _saving ? null : _toggle,
activeTrackColor: AppColors.accent.withAlpha(80), activeTrackColor: AppColors.accent.withAlpha(80),
activeThumbColor: AppColors.accent, activeThumbColor: AppColors.accent,
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
@@ -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<ResilienceModeTile> createState() => _ResilienceModeTileState();
}
class _ResilienceModeTileState extends State<ResilienceModeTile> {
ResilienceMode _mode = ConnectionSettings.resilienceMode;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
await ConnectionSettings.initialize();
if (mounted) setState(() => _mode = ConnectionSettings.resilienceMode);
}
bool _vpnBusy() {
final status = context.read<VpnConnection>().status;
return status == VpnStatus.connected ||
status == VpnStatus.connecting ||
status == VpnStatus.disconnecting;
}
Future<void> _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<VpnConnection>(
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<ResilienceMode>(
value: _mode,
dropdownColor: AppColors.bgCard,
onChanged: _change,
items: const [
DropdownMenuItem(
value: ResilienceMode.standard,
child: Text('Standard'),
),
DropdownMenuItem(
value: ResilienceMode.extreme,
child: Text('Extreme'),
),
],
),
),
);
},
);
}
}
+33 -23
View File
@@ -1,27 +1,34 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:revoltvpn/logic/app_colors.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/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/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 ──────────────────────────────── class SettingsScreen extends StatefulWidget {
// 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 {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
ConnectionMode _mode = ConnectionSettings.mode;
@override
void initState() {
super.initState();
_loadMode();
}
Future<void> _loadMode() async {
await ConnectionSettings.initialize();
if (mounted) setState(() => _mode = ConnectionSettings.mode);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -43,12 +50,15 @@ class SettingsScreen extends StatelessWidget {
), ),
body: ListView( body: ListView(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
children: const [ children: [
HapticFeedbackTile(), ConnectionModeTile(
RainToggleTile(), onChanged: (mode) => setState(() => _mode = mode),
LightningToggleTile(), ),
// ── Add setting tiles here ──────────────────────────────────────── const ResilienceModeTile(),
// Each tile imports from its own file in this folder. AppRoutingTile(connectionMode: _mode),
const HapticFeedbackTile(),
const RainToggleTile(),
const LightningToggleTile(),
], ],
), ),
); );
+1 -1
View File
@@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at # Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 3.3.0+26 version: 3.3.1+27
environment: environment:
sdk: ">=3.0.0 <4.0.0" sdk: ">=3.0.0 <4.0.0"
+20
View File
@@ -0,0 +1,20 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:revoltvpn/logic/connection_settings.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('legacy Auto connection mode migrates to TUN', () async {
SharedPreferences.setMockInitialValues(<String, Object>{
'connection_mode': 'auto',
'app_routing_mode': 'all',
});
await ConnectionSettings.initialize();
expect(ConnectionSettings.mode, ConnectionMode.tun);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString('connection_mode'), ConnectionMode.tun.name);
});
}
+27
View File
@@ -0,0 +1,27 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:revoltvpn/logic/connection_settings.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('legacy ASS migrates to SOCKS5 selected-only without losing packages', () async {
SharedPreferences.setMockInitialValues(<String, Object>{
'connection_mode': 'ass',
'app_routing_mode': 'selected',
'app_routing_packages': <String>[
'com.android.chrome',
'com.discord',
],
});
await ConnectionSettings.initialize();
expect(ConnectionSettings.mode, ConnectionMode.proxy);
expect(ConnectionSettings.routingMode, AppRoutingMode.selected);
expect(
ConnectionSettings.appPackages,
<String>['com.android.chrome', 'com.discord'],
);
});
}
+75
View File
@@ -0,0 +1,75 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:revoltvpn/logic/secure_socks_session.dart';
void main() {
test('secure SOCKS session replaces unauthenticated proxy inbounds', () async {
const baseConfig = '''
{
"inbounds": [
{
"tag": "legacy-socks",
"listen": "127.0.0.1",
"port": 10807,
"protocol": "socks",
"settings": {"auth": "noauth", "udp": true}
},
{
"tag": "legacy-http",
"listen": "127.0.0.1",
"port": 10808,
"protocol": "http"
},
{
"tag": "non-proxy-inbound",
"listen": "127.0.0.1",
"port": 12000,
"protocol": "dokodemo-door"
}
],
"outbounds": []
}
''';
final first = await SecureSocksSession.create(baseConfig);
final second = await SecureSocksSession.create(baseConfig);
expect(first.port, greaterThan(1024));
expect(first.port, lessThanOrEqualTo(65535));
expect(first.username, matches(RegExp(r'^[0-9a-f]{32}$')));
expect(first.password, matches(RegExp(r'^[0-9a-f]{64}$')));
expect(second.username, isNot(first.username));
expect(second.password, isNot(first.password));
final decoded = jsonDecode(first.configJson) as Map<String, dynamic>;
final inbounds = decoded['inbounds'] as List<dynamic>;
expect(
inbounds.where((value) => value['protocol'] == 'http'),
isEmpty,
);
final socks = inbounds
.cast<Map<String, dynamic>>()
.singleWhere((value) => value['protocol'] == 'socks');
expect(socks['tag'], SecureSocksSession.inboundTag);
expect(socks['listen'], '127.0.0.1');
expect(socks['port'], first.port);
final settings = socks['settings'] as Map<String, dynamic>;
expect(settings['auth'], 'password');
expect(settings['ip'], '127.0.0.1');
expect(settings.containsKey('accounts'), isFalse);
final users = settings['users'] as List<dynamic>;
expect(users, hasLength(1));
expect(users.single['user'], first.username);
expect(users.single['pass'], first.password);
expect(
inbounds.where((value) => value['protocol'] == 'dokodemo-door'),
hasLength(1),
);
});
}
+18
View File
@@ -0,0 +1,18 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('stock runtime patch validates the current SOCKS5 users schema', () {
final source = File(
'tool/patch_flutter_vless_secure_socks_v2.dart',
).readAsStringSync();
expect(source, contains('settings.optJSONArray("users")'));
expect(source, contains('settings.optString("auth") != "password"'));
expect(source, contains('inbound.optString("listen") != "127.0.0.1"'));
expect(source, contains('account.optString("user").isEmpty()'));
expect(source, contains('account.optString("pass").isEmpty()'));
expect(source, isNot(contains('settings.optJSONArray("accounts")')));
});
}
+293
View File
@@ -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<String, dynamic> || decoded['packages'] is! List) {
fail('package_config.json is invalid');
}
Map<String, dynamic>? package;
for (final item in decoded['packages'] as List) {
if (item is Map && item['name'] == packageName) {
package = Map<String, dynamic>.from(item);
break;
}
}
if (package == null) fail('$packageName was not found');
final rootUriValue = package['rootUri'];
if (rootUriValue is! String || rootUriValue.isEmpty) {
fail('$packageName has no rootUri');
}
final rootUri = Uri.parse(rootUriValue);
final resolved = rootUri.hasScheme
? rootUri
: configFile.parent.absolute.uri.resolveUri(rootUri);
if (resolved.scheme != 'file') fail('unsupported package URI: $resolved');
final root = Directory.fromUri(resolved);
final normalized = root.absolute.path.replaceAll('\\', '/');
if (!normalized.contains(expectedVersionFragment)) {
fail('expected flutter_vless_android 1.1.5, got ${root.path}');
}
if (!root.existsSync()) fail('package root does not exist: ${root.path}');
return root;
}
void replaceOnce(
File file,
String oldValue,
String newValue,
String marker,
) {
if (!file.existsSync()) fail('missing upstream source: ${file.path}');
final text = file.readAsStringSync();
if (text.contains(marker)) {
stdout.writeln('[flutter_vless patch] already current: ${file.path}');
return;
}
if (!text.contains(oldValue)) {
fail('upstream source changed; expected block not found in ${file.path}');
}
file.writeAsStringSync(text.replaceFirst(oldValue, newValue));
stdout.writeln('[flutter_vless patch] patched: ${file.path}');
}
void replaceOneOf(
File file,
Map<String, String> replacements,
String marker,
) {
if (!file.existsSync()) fail('missing upstream source: ${file.path}');
var text = file.readAsStringSync();
if (text.contains(marker)) {
stdout.writeln('[flutter_vless patch] already current: ${file.path}');
return;
}
for (final entry in replacements.entries) {
if (!text.contains(entry.key)) continue;
text = text.replaceFirst(entry.key, entry.value);
file.writeAsStringSync(text);
stdout.writeln('[flutter_vless patch] patched/upgraded: ${file.path}');
return;
}
fail('upstream/legacy patch shape not recognized in ${file.path}');
}
void main() {
final root = packageRoot();
final kotlin = Directory(
'${root.path}/android/src/main/kotlin/com/github/tfox/flutter_vless',
);
final configFile = File('${kotlin.path}/xray/dto/XrayConfig.kt');
replaceOnce(
configFile,
''' /** List of app package names to exclude from VPN. */
var BLOCKED_APPS: ArrayList<String> = ArrayList(),
''',
''' /** List of app package names to exclude from VPN. */
var BLOCKED_APPS: ArrayList<String> = ArrayList(),
/** List of app package names that are exclusively allowed into VPN. */
var ALLOWED_APPS: ArrayList<String> = ArrayList(),
''',
'var ALLOWED_APPS:',
);
final pluginFile = File('${kotlin.path}/FlutterVlessPlugin.kt');
replaceOnce(
pluginFile,
''' private lateinit var context: Context
''',
''' private lateinit var context: Context
private var allowedApps: ArrayList<String> = ArrayList()
''',
'private var allowedApps:',
);
const currentAllowedHandler = ''' "setAllowedApps" -> {
val requested = call.argument<ArrayList<String>>("allowed_apps") ?: ArrayList()
allowedApps = ArrayList(
requested
.map { it.trim() }
.filter { it.isNotEmpty() && it != context.packageName }
.distinct()
.sorted()
)
result.success(null)
}
''';
const legacyAllowedHandler = ''' "setAllowedApps" -> {
val requested = call.argument<ArrayList<String>>("allowed_apps") ?: ArrayList()
allowedApps = ArrayList(
requested.map { it.trim() }.filter { it.isNotEmpty() }.distinct()
)
result.success(null)
}
''';
replaceOneOf(
pluginFile,
<String, String>{
legacyAllowedHandler: currentAllowedHandler,
''' when (call.method) {
"startVless" -> {
''': ''' when (call.method) {
$currentAllowedHandler "startVless" -> {
''',
},
'it.isNotEmpty() && it != context.packageName',
);
replaceOnce(
pluginFile,
''' config.BLOCKED_APPS = call.argument<ArrayList<String>>("blocked_apps") ?: ArrayList()
config.BYPASS_SUBNETS = call.argument<ArrayList<String>>("bypass_subnets") ?: ArrayList()
''',
''' config.BLOCKED_APPS = call.argument<ArrayList<String>>("blocked_apps") ?: ArrayList()
config.ALLOWED_APPS = ArrayList(allowedApps)
config.BYPASS_SUBNETS = call.argument<ArrayList<String>>("bypass_subnets") ?: ArrayList()
''',
'config.ALLOWED_APPS = ArrayList(allowedApps)',
);
final serviceFile = File('${kotlin.path}/xray/service/XrayVPNService.kt');
replaceOnce(
serviceFile,
''' if (config != null) {
// Ensure clean state before starting
cleanup()
''',
''' if (config != null) {
// A fast reconnect must not leave the previous Xray process
// owning the local SOCKS/HTTP ports.
if (XrayCoreManager.isXrayRunning()) {
Log.w(TAG, "Stopping stale Xray core before restart")
XrayCoreManager.stopCore(this)
}
cleanup()
''',
'Stopping stale Xray core before restart',
);
replaceOnce(
serviceFile,
''' val tun2socksPath = File(applicationInfo.nativeLibraryDir, "libtun2socks.so").absolutePath
val sockPath = File(filesDir, "sock_path").absolutePath
''',
''' val tun2socksPath = File(applicationInfo.nativeLibraryDir, "libtun2socks.so").absolutePath
val socketFile = File(filesDir, "sock_path")
if (socketFile.exists() && !socketFile.delete()) {
Log.w(TAG, "Could not delete stale tun2socks socket")
}
val sockPath = socketFile.absolutePath
''',
'Could not delete stale tun2socks socket',
);
final upstreamPolicy = r''' try {
builder.addDisallowedApplication(packageName)
} catch (e: Exception) {
Log.e(TAG, \"Failed to exclude app from VPN\", e)
}
for (pkg in config.BLOCKED_APPS) {
try {
builder.addDisallowedApplication(pkg)
Log.d(TAG, \"Excluded from VPN: $pkg\")
} catch (e: Exception) {
Log.e(TAG, \"Failed to exclude $pkg from VPN\", e)
}
}
'''.replaceAll(r'\"', '"');
final legacyPatchedPolicy = r''' if (config.ALLOWED_APPS.isNotEmpty()) {
for (pkg in config.ALLOWED_APPS) {
try {
builder.addAllowedApplication(pkg)
Log.d(TAG, \"Allowed into VPN: $pkg\")
} catch (e: Exception) {
Log.e(TAG, \"Failed to allow $pkg into VPN\", e)
}
}
} else {
try {
builder.addDisallowedApplication(packageName)
} catch (e: Exception) {
Log.e(TAG, \"Failed to exclude app from VPN\", e)
}
for (pkg in config.BLOCKED_APPS) {
try {
builder.addDisallowedApplication(pkg)
Log.d(TAG, \"Excluded from VPN: $pkg\")
} catch (e: Exception) {
Log.e(TAG, \"Failed to exclude $pkg from VPN\", e)
}
}
}
'''.replaceAll(r'\"', '"');
final hardenedPolicy = r''' if (config.ALLOWED_APPS.isNotEmpty() && config.BLOCKED_APPS.isNotEmpty()) {
throw IllegalStateException(\"Allowed and blocked app policies cannot be mixed\")
}
if (config.ALLOWED_APPS.isNotEmpty()) {
var allowedCount = 0
for (pkg in config.ALLOWED_APPS) {
try {
builder.addAllowedApplication(pkg)
allowedCount++
Log.d(TAG, \"Allowed into VPN: $pkg\")
} catch (e: Exception) {
throw IllegalStateException(\"Failed to allow selected app $pkg\", e)
}
}
if (allowedCount == 0) {
throw IllegalStateException(\"Selected-only routing has no valid applications\")
}
} else {
try {
builder.addDisallowedApplication(packageName)
} catch (e: Exception) {
Log.e(TAG, \"Failed to exclude app from VPN\", e)
}
for (pkg in config.BLOCKED_APPS) {
try {
builder.addDisallowedApplication(pkg)
Log.d(TAG, \"Excluded from VPN: $pkg\")
} catch (e: Exception) {
Log.e(TAG, \"Failed to exclude $pkg from VPN\", e)
}
}
}
'''.replaceAll(r'\"', '"');
// Support both pristine 1.1.5 and the earlier local allowlist patch.
replaceOneOf(
serviceFile,
<String, String>{
legacyPatchedPolicy: hardenedPolicy,
upstreamPolicy: hardenedPolicy,
},
'Allowed and blocked app policies cannot be mixed',
);
stdout.writeln('[flutter_vless patch] allowed-app routing patch is ready');
}
@@ -0,0 +1,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<String, dynamic> || decoded['packages'] is! List) {
fail('package_config.json is invalid');
}
Map<String, dynamic>? package;
for (final item in decoded['packages'] as List) {
if (item is Map && item['name'] == packageName) {
package = Map<String, dynamic>.from(item);
break;
}
}
if (package == null) fail('$packageName was not found');
final rootUriValue = package['rootUri'];
if (rootUriValue is! String || rootUriValue.isEmpty) {
fail('$packageName has no rootUri');
}
final rootUri = Uri.parse(rootUriValue);
final resolved = rootUri.hasScheme
? rootUri
: configFile.parent.absolute.uri.resolveUri(rootUri);
if (resolved.scheme != 'file') fail('unsupported package URI: $resolved');
final root = Directory.fromUri(resolved);
final normalized = root.absolute.path.replaceAll('\\', '/');
if (!normalized.contains(expectedVersionFragment)) {
fail('expected flutter_vless_android 1.1.5, got ${root.path}');
}
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');
}
@@ -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<String, dynamic> || decoded['packages'] is! List) {
fail('package_config.json is invalid');
}
Map<String, dynamic>? package;
for (final item in decoded['packages'] as List) {
if (item is Map && item['name'] == packageName) {
package = Map<String, dynamic>.from(item);
break;
}
}
if (package == null) fail('$packageName was not found');
final rootUriValue = package['rootUri'];
if (rootUriValue is! String || rootUriValue.isEmpty) {
fail('$packageName has no rootUri');
}
final rootUri = Uri.parse(rootUriValue);
final resolved = rootUri.hasScheme
? rootUri
: configFile.parent.absolute.uri.resolveUri(rootUri);
if (resolved.scheme != 'file') fail('unsupported package URI: $resolved');
final root = Directory.fromUri(resolved);
final normalized = root.absolute.path.replaceAll('\\', '/');
if (!normalized.contains(expectedVersionFragment)) {
fail('expected flutter_vless_android 1.1.5, got ${root.path}');
}
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<Int>()\n',
'$secureValidation val usedPorts = mutableSetOf<Int>()\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');
}