feat(app): add secure routing and runtime resilience

Add explicit TUN and SOCKS5 connection modes, app routing controls, authenticated per-session local SOCKS5, Android network monitoring, persistent haptics, resilient session recovery, notification hardening and client-side Hivemind cleanup.

Keep the hosted flutter_vless_android 1.1.5 runtime as the base and apply the Android compatibility/security patches at build time instead of restoring the unstable vendored runtime.
This commit is contained in:
Naveax committed 2026-08-31 19:31:12 +03:00
1 parent 371db8da9f
commit 8ead3ff249
30 files changed
+3959 -484

No files matched your search

+123
View File
@@ -1,4 +1,5 @@
import java.util.Properties
import java.io.File
import java.io.FileInputStream
plugins {
@@ -6,6 +7,128 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
val projectRootDir = rootProject.projectDir.parentFile
val localPropertiesFile = rootProject.file("local.properties")
val localProperties = Properties().apply {
if (localPropertiesFile.exists()) {
localPropertiesFile.inputStream().use { load(it) }
}
}
val flutterSdkPath = localProperties.getProperty("flutter.sdk")
?: throw GradleException("flutter.sdk not set in android/local.properties")
val dartExecutable = File(
flutterSdkPath,
if (System.getProperty("os.name").lowercase().contains("windows")) {
"bin/cache/dart-sdk/bin/dart.exe"
} else {
"bin/cache/dart-sdk/bin/dart"
},
)
val flutterVlessPatchScript = File(
projectRootDir,
"tool/patch_flutter_vless_allowed_apps.dart",
)
val flutterVlessSecureSocksPatchScript = File(
projectRootDir,
"tool/patch_flutter_vless_secure_socks_v2.dart",
)
val flutterVlessSecureSocksScopeFixScript = File(
projectRootDir,
"tool/patch_flutter_vless_secure_socks_scope_fix.dart",
)
val patchFlutterVlessAllowedApps = tasks.register<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 {
namespace = "com.paladinvpn.app"
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.POST_NOTIFICATIONS"/>
<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"/>
<!-- flutter_vless 1.1.5 declares this legacy permission. ReVolt never needs it. -->
@@ -17,6 +18,11 @@
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="https"/>
</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>
<application
@@ -1,30 +1,44 @@
package com.paladinvpn.app
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
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.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.github.tfox.flutter_vless.xray.service.XrayVPNService
import com.github.tfox.flutter_vless.xray.utils.AppConfigs
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
import java.io.ByteArrayOutputStream
import kotlin.math.roundToInt
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"
// Must match lib/logic/notification_service.dart.
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 networkCallback: ConnectivityManager.NetworkCallback? = null
private var networkEvents: EventChannel.EventSink? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
@@ -50,16 +64,45 @@ class MainActivity : FlutterActivity() {
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) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) !=
@@ -76,58 +119,52 @@ class MainActivity : FlutterActivity() {
PendingIntent.FLAG_UPDATE_CURRENT
}
// Same intent shape as the plugin, so a tap brings the running task
// forward instead of starting a second one.
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
launchIntent?.action = "FROM_DISCONNECT_BTN"
launchIntent?.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or
Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_NEW_TASK
// Tapping "Disconnect" stops the tunnel without opening the app.
val stopIntent = Intent(this, XrayVPNService::class.java)
stopIntent.putExtra(
"COMMAND",
AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE
)
stopIntent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE)
val stopPendingIntent = PendingIntent.getService(this, 0, stopIntent, flags)
val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.notification_icon)
.setSmallIcon(R.drawable.notification_status_icon)
.setContentTitle(title)
.setContentText(text)
.addAction(0, actionLabel, stopPendingIntent)
.setColorized(true)
.setColor(0xFF0D1117.toInt())
.setOngoing(true)
.setAutoCancel(false)
.setOnlyAlertOnce(true)
.setSilent(true)
.setShowWhen(false)
.setPriority(NotificationCompat.PRIORITY_LOW)
if (launchIntent != null) {
builder.setContentIntent(
PendingIntent.getActivity(this, 0, launchIntent, flags)
)
builder.setContentIntent(PendingIntent.getActivity(this, 0, launchIntent, flags))
}
try {
NotificationManagerCompat.from(this).notify(NOTIFICATION_ID, builder.build())
val notification = builder.build()
notification.flags = notification.flags or
Notification.FLAG_ONGOING_EVENT or Notification.FLAG_NO_CLEAR
NotificationManagerCompat.from(this).notify(NOTIFICATION_ID, notification)
} catch (_: Exception) {
// The foreground notification may not be up yet — the update is cosmetic.
// The native foreground notification may not be ready yet.
}
}
/** A binder round-trip, and the caller runs once a second — so, once per process. */
private fun ensureNotificationChannel() {
if (notificationChannelReady) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(
NOTIFICATION_CHANNEL_ID,
"Revolt VPN Background Service",
NotificationManager.IMPORTANCE_LOW
NotificationManager.IMPORTANCE_LOW,
)
channel.setShowBadge(false)
manager.createNotificationChannel(channel)
@@ -135,11 +172,134 @@ class MainActivity : FlutterActivity() {
notificationChannelReady = true
}
/**
* Package name of whatever installed us ("com.android.vending" for Play
* Store), or null when unknown. Without this the Dart side always falls
* back to sideload and points every user at GitHub releases.
*/
private fun startNetworkMonitor() {
if (networkCallback != null) return
val manager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
emitBestPhysicalNetwork(manager, "available")
}
override fun onLost(network: Network) {
emitBestPhysicalNetwork(manager, "lost")
}
override fun onCapabilitiesChanged(
network: Network,
capabilities: NetworkCapabilities,
) {
emitBestPhysicalNetwork(manager, "changed")
}
}
try {
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
.build()
manager.registerNetworkCallback(request, callback)
networkCallback = callback
emitBestPhysicalNetwork(manager, "initial")
} catch (e: Exception) {
networkEvents?.error("NETWORK_MONITOR", e.message, null)
}
}
private fun stopNetworkMonitor() {
val callback = networkCallback ?: return
val manager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
try {
manager.unregisterNetworkCallback(callback)
} catch (_: Exception) {
}
networkCallback = null
}
private fun emitBestPhysicalNetwork(
manager: ConnectivityManager,
reason: String,
) {
val best = manager.allNetworks
.mapNotNull { network ->
val capabilities = manager.getNetworkCapabilities(network)
?: return@mapNotNull null
if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) {
return@mapNotNull null
}
if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) ||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
) {
return@mapNotNull null
}
Triple(network, capabilities, physicalNetworkScore(capabilities))
}
.maxByOrNull { it.third }
if (best == null) {
emitNetworkPayload(
mapOf<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 {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
packageManager.getInstallSourceInfo(packageName).installingPackageName
@@ -147,12 +307,95 @@ class MainActivity : FlutterActivity() {
@Suppress("DEPRECATION")
packageManager.getInstallerPackageName(packageName)
}
} catch (e: Exception) {
} catch (_: Exception) {
null
}
private fun launchableApps(): List<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 {
// Must match flutter_vless's XrayCoreManager.
private const val NOTIFICATION_ID = 1
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>