build: vendor pinned flutter_vless Android runtime (#10)

Replace build-time pub-cache mutation with a repository-owned pinned runtime so reviewed native source is the source compiled into the app.
This commit was merged in pull request #10.
This commit is contained in:
Naveax authored and GitHub committed 2026-09-06 11:23:48 +03:00
1 parent e818d54b0e
commit 5f851f3485
29 files changed
+2742 -1147

No files matched your search

@@ -0,0 +1,31 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.github.tfox.flutter_vless">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"
android:minSdkVersion="34" />
<application>
<service
android:name="com.github.tfox.flutter_vless.xray.service.XrayVPNService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="specialUse"
android:permission="android.permission.BIND_VPN_SERVICE"
android:process=":RunSoLibXrayDaemon">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="vpn" />
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
<meta-data
android:name="android.net.VpnService.SUPPORTS_ALWAYS_ON"
android:value="false" />
</service>
</application>
</manifest>
@@ -0,0 +1,402 @@
// Copyright (c) 2024-2026 13FOX Studio / tfox.dev.
// SPDX-License-Identifier: MIT
package com.github.tfox.flutter_vless
import android.Manifest
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.net.VpnService
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.github.tfox.flutter_vless.xray.dto.XrayConfig
import com.github.tfox.flutter_vless.xray.service.XrayVPNService
import com.github.tfox.flutter_vless.xray.utils.AppConfigs
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.PluginRegistry
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader
import java.util.ArrayList
import java.util.UUID
import java.util.concurrent.Executors
/**
* Main entry point for the Flutter Vless plugin on Android.
*
* ReVolt keeps this bridge deliberately narrow: start/stop, session deadline,
* permission handling, core version and private runtime status only.
*/
class FlutterVlessPlugin : FlutterPlugin, ActivityAware, PluginRegistry.ActivityResultListener, MethodChannel.MethodCallHandler {
private val executor = Executors.newSingleThreadExecutor()
private lateinit var vpnControlMethod: MethodChannel
private lateinit var vpnStatusEvent: EventChannel
private var vpnStatusSink: EventChannel.EventSink? = null
private var activity: Activity? = null
private var xrayReceiver: BroadcastReceiver? = null
private var pendingResult: MethodChannel.Result? = null
private lateinit var context: Context
private var expectedRuntimeToken: String? = null
private var pendingStopResult: MethodChannel.Result? = null
private val mainHandler = Handler(Looper.getMainLooper())
private val stopTimeoutRunnable = Runnable {
val pending = pendingStopResult ?: return@Runnable
pendingStopResult = null
pending.error("STOP_TIMEOUT", "VPN service did not confirm shutdown", null)
}
companion object {
private const val TAG = "FlutterVlessPlugin"
private const val REQUEST_CODE_VPN_PERMISSION = 24
private const val REQUEST_CODE_POST_NOTIFICATIONS = 1
}
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
context = binding.applicationContext
vpnControlMethod = MethodChannel(binding.binaryMessenger, "flutter_vless")
vpnStatusEvent = EventChannel(binding.binaryMessenger, "flutter_vless/status")
vpnControlMethod.setMethodCallHandler(this)
vpnStatusEvent.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
vpnStatusSink = events
registerReceiver()
}
override fun onCancel(arguments: Any?) {
vpnStatusSink = null
unregisterReceiver()
}
})
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"startVless" -> {
if (pendingStopResult != null) {
result.error("STOP_IN_PROGRESS", "VPN shutdown is still in progress", null)
return
}
val config = XrayConfig()
val runtimeToken = UUID.randomUUID().toString()
expectedRuntimeToken = runtimeToken
config.RUNTIME_TOKEN = runtimeToken
config.REMARK = call.argument("remark") ?: ""
config.V2RAY_FULL_JSON_CONFIG = call.argument("config") ?: ""
config.BLOCKED_APPS = call.argument<ArrayList<String>>("blocked_apps") ?: ArrayList()
config.BYPASS_SUBNETS = call.argument<ArrayList<String>>("bypass_subnets") ?: ArrayList()
config.NOTIFICATION_DISCONNECT_BUTTON_NAME = call.argument("notificationDisconnectButtonName") ?: "Disconnect"
if (AppConfigs.NOTIFICATION_ICON_RESOURCE_NAME.isNotEmpty() && AppConfigs.NOTIFICATION_ICON_RESOURCE_TYPE.isNotEmpty()) {
config.NOTIFICATION_ICON_RESOURCE_NAME = AppConfigs.NOTIFICATION_ICON_RESOURCE_NAME
config.NOTIFICATION_ICON_RESOURCE_TYPE = AppConfigs.NOTIFICATION_ICON_RESOURCE_TYPE
val resId = context.resources.getIdentifier(
AppConfigs.NOTIFICATION_ICON_RESOURCE_NAME,
AppConfigs.NOTIFICATION_ICON_RESOURCE_TYPE,
context.packageName
)
config.APPLICATION_ICON = resId
}
if (call.argument<Boolean>("proxy_only") == true) {
AppConfigs.V2RAY_CONNECTION_MODE = AppConfigs.V2RAY_CONNECTION_MODES.PROXY_ONLY
} else {
AppConfigs.V2RAY_CONNECTION_MODE = AppConfigs.V2RAY_CONNECTION_MODES.VPN_TUN
}
try {
val jsonConfig = org.json.JSONObject(config.V2RAY_FULL_JSON_CONFIG)
val outbounds = jsonConfig.optJSONArray("outbounds")
if (outbounds != null && outbounds.length() > 0) {
val firstOutbound = outbounds.getJSONObject(0)
val settings = firstOutbound.optJSONObject("settings")
val vnext = settings?.optJSONArray("vnext")
if (vnext != null && vnext.length() > 0) {
val server = vnext.getJSONObject(0)
config.CONNECTED_V2RAY_SERVER_ADDRESS = server.optString("address", "")
config.CONNECTED_V2RAY_SERVER_PORT = server.optInt("port", 0).toString()
} else if (settings != null) {
config.CONNECTED_V2RAY_SERVER_ADDRESS = settings.optString("address", "")
config.CONNECTED_V2RAY_SERVER_PORT = settings.optInt("port", 0).toString()
}
}
} catch (_: Exception) {
// Optional metadata only; runtime config validation happens downstream.
}
val intent = Intent(context, XrayVPNService::class.java)
intent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.START_SERVICE)
intent.putExtra("V2RAY_CONFIG", config)
intent.putExtra("PROXY_ONLY", call.argument<Boolean>("proxy_only") ?: false)
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
result.success(null)
} catch (e: Exception) {
if (expectedRuntimeToken == runtimeToken) expectedRuntimeToken = null
result.error("START_FAILED", e.message ?: "Could not start VPN service", null)
}
}
"stopVless" -> {
if (pendingStopResult != null) {
result.error("STOP_IN_PROGRESS", "VPN shutdown is already in progress", null)
return
}
// A STOP without a generation token can kill a newer runtime after
// UI/process churn. No token means this bridge owns nothing to stop.
val token = expectedRuntimeToken
if (token == null) {
result.success(null)
return
}
val intent = Intent(context, XrayVPNService::class.java)
intent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE)
intent.putExtra("RUNTIME_TOKEN", token)
try {
context.startService(intent)
pendingStopResult = result
mainHandler.removeCallbacks(stopTimeoutRunnable)
mainHandler.postDelayed(stopTimeoutRunnable, 6_000L)
} catch (e: Exception) {
pendingStopResult = null
mainHandler.removeCallbacks(stopTimeoutRunnable)
result.error("STOP_FAILED", e.message ?: "Could not stop VPN service", null)
}
}
"setSessionDeadline" -> {
val token = expectedRuntimeToken
val remainingSeconds = call.argument<Number>("remainingSeconds")?.toLong()
if (token == null) {
result.error("NO_RUNTIME", "No active runtime token", null)
return
}
if (remainingSeconds == null || remainingSeconds < 0) {
result.error("INVALID_DEADLINE", "remainingSeconds must be non-negative", null)
return
}
val intent = Intent(context, XrayVPNService::class.java)
intent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.UPDATE_SESSION_DEADLINE)
intent.putExtra("RUNTIME_TOKEN", token)
intent.putExtra("REMAINING_SECONDS", remainingSeconds)
try {
context.startService(intent)
result.success(null)
} catch (e: Exception) {
result.error("DEADLINE_FAILED", e.message ?: "Could not update session deadline", null)
}
}
"initializeVless" -> {
val iconResourceName = call.argument<String>("notificationIconResourceName")
val iconResourceType = call.argument<String>("notificationIconResourceType")
if (iconResourceName != null && iconResourceType != null) {
AppConfigs.NOTIFICATION_ICON_RESOURCE_NAME = iconResourceName
AppConfigs.NOTIFICATION_ICON_RESOURCE_TYPE = iconResourceType
}
result.success(null)
}
"getCoreVersion" -> {
executor.submit {
try {
val nativeLibraryDir = context.applicationInfo.nativeLibraryDir
val xrayExecutable = File(nativeLibraryDir, "libxray.so")
if (xrayExecutable.exists()) {
val p = Runtime.getRuntime().exec(arrayOf(xrayExecutable.absolutePath, "-version"))
val reader = BufferedReader(InputStreamReader(p.inputStream))
val version = reader.readLine()
result.success(version)
} else {
result.success("Xray not found")
}
} catch (e: Exception) {
result.success("Error: ${e.message}")
}
}
}
"requestPermission" -> {
val currentActivity = activity
if (currentActivity == null) {
result.error("NO_ACTIVITY", "Activity is not attached", null)
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ActivityCompat.checkSelfPermission(currentActivity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(currentActivity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQUEST_CODE_POST_NOTIFICATIONS)
}
}
val request = VpnService.prepare(currentActivity)
if (request != null) {
pendingResult = result
currentActivity.startActivityForResult(request, REQUEST_CODE_VPN_PERMISSION)
} else {
result.success(true)
}
}
else -> result.notImplemented()
}
}
private fun registerReceiver() {
if (activity == null) return
if (xrayReceiver == null) {
xrayReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent == null || vpnStatusSink == null) return
val state = intent.getSerializableExtra("STATE") as? AppConfigs.V2RAY_STATES
val runtimeToken = intent.getStringExtra("RUNTIME_TOKEN").orEmpty()
val runtimeReady = intent.getBooleanExtra("RUNTIME_READY", false)
val duration = intent.getStringExtra("DURATION")
val uploadSpeed = intent.getLongExtra("UPLOAD_SPEED", 0)
val downloadSpeed = intent.getLongExtra("DOWNLOAD_SPEED", 0)
val uploadTraffic = intent.getLongExtra("UPLOAD_TRAFFIC", 0)
val downloadTraffic = intent.getLongExtra("DOWNLOAD_TRAFFIC", 0)
val currentToken = expectedRuntimeToken
if (currentToken == null) {
if (runtimeToken.isNotEmpty() &&
(state == AppConfigs.V2RAY_STATES.V2RAY_CONNECTED ||
state == AppConfigs.V2RAY_STATES.V2RAY_CONNECTING)
) {
expectedRuntimeToken = runtimeToken
} else {
return
}
} else if (runtimeToken != currentToken) {
return
}
val stateName = when (state) {
AppConfigs.V2RAY_STATES.V2RAY_CONNECTED -> if (runtimeReady) "CONNECTED" else "CONNECTING"
AppConfigs.V2RAY_STATES.V2RAY_CONNECTING -> "CONNECTING"
else -> "DISCONNECTED"
}
val data = ArrayList<String>()
data.add(duration ?: "0")
data.add(uploadSpeed.toString())
data.add(downloadSpeed.toString())
data.add(uploadTraffic.toString())
data.add(downloadTraffic.toString())
data.add(stateName)
if (state == AppConfigs.V2RAY_STATES.V2RAY_DISCONNECTED &&
runtimeToken.isNotEmpty() && runtimeToken == expectedRuntimeToken
) {
expectedRuntimeToken = null
mainHandler.removeCallbacks(stopTimeoutRunnable)
pendingStopResult?.success(null)
pendingStopResult = null
}
vpnStatusSink?.success(data)
}
}
}
val filter = IntentFilter(AppConfigs.V2RAY_CONNECTION_INFO)
val currentActivity = activity ?: return
ContextCompat.registerReceiver(
currentActivity,
xrayReceiver,
filter,
ContextCompat.RECEIVER_NOT_EXPORTED,
)
}
private fun unregisterReceiver() {
val currentActivity = activity
val receiver = xrayReceiver
if (currentActivity != null && receiver != null) {
try {
currentActivity.unregisterReceiver(receiver)
} catch (e: Exception) {
Log.w(TAG, "Receiver was already detached during lifecycle cleanup", e)
}
}
xrayReceiver = null
}
private fun failPendingPermission(code: String, message: String) {
pendingResult?.error(code, message, null)
pendingResult = null
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
mainHandler.removeCallbacks(stopTimeoutRunnable)
pendingStopResult?.error("ENGINE_DETACHED", "Flutter engine detached during VPN shutdown", null)
pendingStopResult = null
failPendingPermission("ENGINE_DETACHED", "Flutter engine detached during VPN permission request")
unregisterReceiver()
vpnControlMethod.setMethodCallHandler(null)
vpnStatusEvent.setStreamHandler(null)
executor.shutdown()
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
binding.addActivityResultListener(this)
if (vpnStatusSink != null) {
registerReceiver()
}
}
override fun onDetachedFromActivityForConfigChanges() {
unregisterReceiver()
activity = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activity = binding.activity
binding.addActivityResultListener(this)
if (vpnStatusSink != null) {
registerReceiver()
}
}
override fun onDetachedFromActivity() {
failPendingPermission("NO_ACTIVITY", "Activity detached during VPN permission request")
unregisterReceiver()
activity = null
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode == REQUEST_CODE_VPN_PERMISSION) {
if (resultCode == Activity.RESULT_OK) {
pendingResult?.success(true)
} else {
pendingResult?.success(false)
}
pendingResult = null
return true
}
return false
}
}
@@ -0,0 +1,642 @@
package com.github.tfox.flutter_vless.xray.core
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.os.Build
import android.os.CountDownTimer
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import com.github.tfox.flutter_vless.xray.dto.XrayConfig
import com.github.tfox.flutter_vless.xray.service.XrayVPNService
import com.github.tfox.flutter_vless.xray.utils.AppConfigs
import com.github.tfox.flutter_vless.xray.utils.Utilities
import org.json.JSONObject
import org.json.JSONArray
import java.io.File
import java.io.FileOutputStream
/**
* Manages the Xray Core process (libxray.so).
*
* This singleton object is responsible for:
* 1. Generating the final Xray configuration file (config.json).
* 2. Injecting necessary inbounds (SOCKS, HTTP, API) into the user-provided config.
* 3. Starting and monitoring the Xray process.
* 4. Collecting traffic statistics via the Xray API.
* 5. Showing the persistent foreground notification.
*/
object XrayCoreManager {
private const val NOTIFICATION_ID = 1
private const val TAG = "XrayCoreManager"
private var xrayProcess: Process? = null
private var countDownTimer: CountDownTimer? = null
private var seconds = 0
private fun deleteEphemeralConfigOnFailure(filesDir: File) {
try {
val config = File(filesDir, "config.json")
if (config.exists() && !config.delete()) {
Log.w(TAG, "Could not delete failed ephemeral Xray config")
}
} catch (e: Exception) {
Log.w(TAG, "Could not delete failed ephemeral Xray config", e)
}
}
private fun nextFreePort(preferredPort: Int, usedPorts: Set<Int>): Int {
var port = preferredPort
while (usedPorts.contains(port)) {
port++
}
return port
}
private fun uniqueInboundTag(inbounds: JSONArray, preferredTag: String): String {
val tags = mutableSetOf<String>()
for (i in 0 until inbounds.length()) {
val tag = inbounds.optJSONObject(i)?.optString("tag").orEmpty()
if (tag.isNotEmpty()) tags.add(tag)
}
if (!tags.contains(preferredTag)) return preferredTag
var index = 1
var candidate = "${preferredTag}_$index"
while (tags.contains(candidate)) {
index++
candidate = "${preferredTag}_$index"
}
return candidate
}
private fun normalizeRuntimeConfig(value: Any?): Any? {
return when (value) {
is JSONObject -> {
val normalized = JSONObject()
val aliases = mapOf(
"xHTTPSettings" to "xhttpSettings",
"httpUpgradeSettings" to "httpupgradeSettings",
"splitHTTPSettings" to "splithttpSettings"
)
val keys = value.keys()
while (keys.hasNext()) {
val key = keys.next()
if (key == "allowInsecure") continue
val targetKey = aliases[key] ?: key
if (aliases.containsKey(key) && value.has(targetKey)) continue
val normalizedValue = normalizeRuntimeConfig(value.opt(key))
if (targetKey == "network" && normalizedValue is String) {
normalized.put(targetKey, normalizedValue.lowercase())
} else {
normalized.put(targetKey, normalizedValue)
}
}
normalized
}
is JSONArray -> {
val normalized = JSONArray()
for (i in 0 until value.length()) {
normalized.put(normalizeRuntimeConfig(value.opt(i)))
}
normalized
}
else -> value
}
}
private fun normalizeVlessOutbounds(configJson: JSONObject) {
val outbounds = configJson.optJSONArray("outbounds") ?: return
for (i in 0 until outbounds.length()) {
val outbound = outbounds.optJSONObject(i) ?: continue
if (outbound.optString("protocol") != "vless") continue
val settings = outbound.optJSONObject("settings") ?: continue
if (settings.has("vnext")) continue
val address = settings.optString("address")
val id = settings.optString("id")
val port = settings.optInt("port", 0)
if (address.isEmpty() || id.isEmpty() || port <= 0) continue
val user = JSONObject()
user.put("id", id)
user.put("encryption", settings.optString("encryption", "none"))
user.put("flow", settings.optString("flow", ""))
user.put("level", settings.optInt("level", 8))
val server = JSONObject()
server.put("address", address)
server.put("port", port)
server.put("users", JSONArray().put(user))
val normalizedSettings = JSONObject()
normalizedSettings.put("vnext", JSONArray().put(server))
outbound.put("settings", normalizedSettings)
Log.d(TAG, "Normalized flat VLESS outbound settings for $address:$port")
}
}
private fun sanitizeLogPaths(configJson: JSONObject, filesDir: File) {
val log = configJson.optJSONObject("log") ?: return
// The VPN client never needs destination/access logs. A server-provided
// config must not silently enable browsing metadata collection locally.
log.remove("access")
try { File(filesDir, "access.log").delete() } catch (_: Exception) {}
val errorPath = log.optString("error")
if (errorPath.isNotEmpty()) {
log.put("error", File(filesDir, "error.log").absolutePath)
}
}
internal fun buildRuntimeConfigJson(config: XrayConfig, filesDir: File): JSONObject {
val configJson = normalizeRuntimeConfig(
JSONObject(config.V2RAY_FULL_JSON_CONFIG)
) as JSONObject
normalizeVlessOutbounds(configJson)
sanitizeLogPaths(configJson, filesDir)
// ReVolt needs only its authenticated local SOCKS ingress.
val inbounds = configJson.optJSONArray("inbounds") ?: JSONArray()
var secureSocksEnabled = false
for (i in 0 until inbounds.length()) {
val inbound = inbounds.optJSONObject(i) ?: continue
if (inbound.optString("tag") != "revolt-secure-socks") continue
val settings = inbound.optJSONObject("settings")
?: throw IllegalStateException("Secure SOCKS5 settings are missing")
val users = settings.optJSONArray("users")
?: throw IllegalStateException("Secure SOCKS5 users are missing")
val account = users.optJSONObject(0)
?: throw IllegalStateException("Secure SOCKS5 account is missing")
val port = inbound.optInt("port", -1)
if (inbound.optString("protocol") != "socks" ||
inbound.optString("listen") != "127.0.0.1" ||
port <= 1024 || port > 65535 ||
settings.optString("auth") != "password" ||
account.optString("user").isEmpty() ||
account.optString("pass").isEmpty()
) {
throw IllegalStateException("Secure SOCKS5 session is invalid")
}
config.LOCAL_SOCKS5_PORT = port
config.LOCAL_HTTP_PORT = 0
secureSocksEnabled = true
break
}
val usedPorts = mutableSetOf<Int>()
var hasSocksOnLocalPort = false
var hasHttpOnLocalPort = false
for (i in 0 until inbounds.length()) {
val inbound = inbounds.getJSONObject(i)
val protocol = inbound.optString("protocol")
val port = inbound.optInt("port", -1)
if (port > 0) usedPorts.add(port)
if (protocol == "socks" && port == config.LOCAL_SOCKS5_PORT) hasSocksOnLocalPort = true
if (protocol == "http" && port == config.LOCAL_HTTP_PORT) hasHttpOnLocalPort = true
}
if (!hasSocksOnLocalPort) {
if (usedPorts.contains(config.LOCAL_SOCKS5_PORT)) {
config.LOCAL_SOCKS5_PORT = nextFreePort(config.LOCAL_SOCKS5_PORT, usedPorts)
}
val socksInbound = JSONObject()
socksInbound.put("tag", uniqueInboundTag(inbounds, "socks"))
socksInbound.put("port", config.LOCAL_SOCKS5_PORT)
socksInbound.put("listen", "127.0.0.1")
socksInbound.put("protocol", "socks")
socksInbound.put("settings", JSONObject().put("auth", "noauth").put("udp", true))
socksInbound.put(
"sniffing",
JSONObject().put("enabled", true).put("destOverride", JSONArray().put("http").put("tls"))
)
inbounds.put(socksInbound)
usedPorts.add(config.LOCAL_SOCKS5_PORT)
Log.d(TAG, "Injected SOCKS inbound on port ${config.LOCAL_SOCKS5_PORT}")
}
if (!secureSocksEnabled && !hasHttpOnLocalPort) {
if (usedPorts.contains(config.LOCAL_HTTP_PORT)) {
config.LOCAL_HTTP_PORT = nextFreePort(config.LOCAL_HTTP_PORT, usedPorts)
}
val httpInbound = JSONObject()
httpInbound.put("tag", uniqueInboundTag(inbounds, "http"))
httpInbound.put("port", config.LOCAL_HTTP_PORT)
httpInbound.put("listen", "127.0.0.1")
httpInbound.put("protocol", "http")
inbounds.put(httpInbound)
usedPorts.add(config.LOCAL_HTTP_PORT)
Log.d(TAG, "Injected HTTP inbound on port ${config.LOCAL_HTTP_PORT}")
}
return configJson
}
internal fun buildDelayConfigJson(configJson: String, proxyPort: Int, filesDir: File): Pair<JSONObject, Int> {
val delayConfig = XrayConfig(
V2RAY_FULL_JSON_CONFIG = configJson,
LOCAL_SOCKS5_PORT = proxyPort,
LOCAL_HTTP_PORT = proxyPort + 1,
LOCAL_API_PORT = proxyPort + 2
)
val runtimeJson = buildRuntimeConfigJson(delayConfig, filesDir)
return Pair(runtimeJson, delayConfig.LOCAL_SOCKS5_PORT)
}
/**
* Starts the Xray Core process.
*
* @param context The service context (needed for file access and notifications).
* @param config The configuration object containing the user's settings.
* @return true if started successfully, false otherwise.
*/
fun startCore(context: Service, config: XrayConfig): Boolean {
AppConfigs.RUNTIME_READY = false
AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTING
AppConfigs.V2RAY_CONFIG = config
// 1. Prepare the configuration file
val configFilesDir = context.filesDir
try {
val configJson = buildRuntimeConfigJson(config, configFilesDir)
val configFile = File(context.filesDir, "config.json")
configFile.writeText(configJson.toString())
} catch (e: Exception) {
Log.e(TAG, "Failed to write config file", e)
deleteEphemeralConfigOnFailure(configFilesDir)
return false
}
// 2. Find Xray executable (libxray.so)
val nativeLibraryDir = context.applicationInfo.nativeLibraryDir
val xrayExecutable = File(nativeLibraryDir, "libxray.so")
if (!xrayExecutable.exists()) {
Log.e(TAG, "Xray executable not found at ${xrayExecutable.absolutePath}")
deleteEphemeralConfigOnFailure(configFilesDir)
return false
}
// 3. Prepare assets (geoip, geosite)
try {
Utilities.copyAssets(context)
} catch (e: Exception) {
Log.e(TAG, "Failed to prepare Xray assets", e)
deleteEphemeralConfigOnFailure(configFilesDir)
return false
}
// 4. Run Xray
try {
val cmd = listOf(
xrayExecutable.absolutePath,
"-config", File(configFilesDir, "config.json").absolutePath
)
val pb = ProcessBuilder(cmd)
pb.directory(configFilesDir)
pb.redirectErrorStream(true)
// Set environment variables (XRAY_LOCATION_ASSET is crucial for finding geoip/geosite)
val env = pb.environment()
env["XRAY_LOCATION_ASSET"] = Utilities.getUserAssetsPath(context)
xrayProcess = pb.start()
Thread.sleep(300)
if (xrayProcess?.isAlive != true) {
val output = xrayProcess?.inputStream?.bufferedReader()?.readText().orEmpty()
Log.e(TAG, "Xray process exited during startup. Output: $output")
xrayProcess = null
AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_DISCONNECTED
deleteEphemeralConfigOnFailure(configFilesDir)
return false
}
// config.json contains per-session SOCKS credentials. Xray has
// consumed it after the startup liveness check, so remove it now.
try {
val ephemeralConfig = File(configFilesDir, "config.json")
if (ephemeralConfig.exists() && !ephemeralConfig.delete()) {
Log.w(TAG, "Could not delete ephemeral Xray config")
}
} catch (e: Exception) {
Log.w(TAG, "Could not delete ephemeral Xray config", e)
}
AppConfigs.RUNTIME_READY = false
AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTING
startTimer(context)
showNotification(context, config)
sendStatusBroadcast(context, config)
val process = xrayProcess ?: return false
// Monitor process in a separate thread to detect crash
Thread {
try {
process.inputStream.bufferedReader().use { reader ->
reader.forEachLine { _ -> }
}
val exitCode = process.waitFor()
Log.e(TAG, "Xray process exited with code $exitCode")
if (xrayProcess === process &&
AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN == config.RUNTIME_TOKEN &&
(AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTED ||
AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTING)
) {
// Hold the Android TUN while the core is recovered. Killing
// the VPN service here would allow direct-network fallback.
xrayProcess = null
AppConfigs.RUNTIME_READY = false
AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTING
sendStatusBroadcast(context, config)
if (context is XrayVPNService) {
context.handleXrayCoreExit(config)
} else {
stopCore(context)
}
}
} catch (e: java.io.InterruptedIOException) {
// Expected when stopping
} catch (e: InterruptedException) {
// Expected when stopping
} catch (e: Exception) {
Log.e(TAG, "Error reading xray output", e)
}
}.start()
return true
} catch (e: Exception) {
Log.e(TAG, "Failed to start Xray process", e)
deleteEphemeralConfigOnFailure(configFilesDir)
return false
}
}
/**
* Stops the Xray Core process and cleans up notifications.
*/
fun stopCore(context: Service, confirmationToken: String? = AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN) {
try {
xrayProcess?.destroy()
xrayProcess = null
} catch (e: Exception) {
Log.e(TAG, "Failed to destroy Xray process", e)
}
AppConfigs.RUNTIME_READY = false
AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_DISCONNECTED
stopTimer()
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(NOTIFICATION_ID)
sendDisconnectedBroadcast(context, confirmationToken.orEmpty())
AppConfigs.V2RAY_CONFIG = null
}
fun markRuntimeConnecting(context: Context, config: XrayConfig) {
if (AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN != config.RUNTIME_TOKEN) return
AppConfigs.RUNTIME_READY = false
AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTING
sendStatusBroadcast(context, config)
}
fun markRuntimeReady(context: Service, config: XrayConfig) {
if (AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN != config.RUNTIME_TOKEN) return
AppConfigs.RUNTIME_READY = true
AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTED
showNotification(context, config)
sendStatusBroadcast(context, config)
}
fun isXrayRunning(): Boolean {
// Check state instead of process because VPN runs in separate service process
return AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTED ||
AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTING
}
private fun startTimer(context: Context) {
countDownTimer?.cancel()
seconds = 0
countDownTimer = object : CountDownTimer(Long.MAX_VALUE, 1000) {
override fun onTick(millisUntilFinished: Long) {
seconds++
val intent = Intent(AppConfigs.V2RAY_CONNECTION_INFO).setPackage(context.packageName)
intent.putExtra("STATE", AppConfigs.V2RAY_STATE)
intent.putExtra("RUNTIME_TOKEN", AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN.orEmpty())
intent.putExtra("RUNTIME_READY", AppConfigs.RUNTIME_READY)
intent.putExtra("DURATION", seconds.toString())
intent.putExtra("UPLOAD_SPEED", 0L)
intent.putExtra("DOWNLOAD_SPEED", 0L)
intent.putExtra("UPLOAD_TRAFFIC", 0L)
intent.putExtra("DOWNLOAD_TRAFFIC", 0L)
context.sendBroadcast(intent)
}
override fun onFinish() {}
}.start()
}
private fun stopTimer() {
countDownTimer?.cancel()
countDownTimer = null
seconds = 0
}
fun sendStatusBroadcast(context: Context, config: XrayConfig) {
val intent = Intent(AppConfigs.V2RAY_CONNECTION_INFO).setPackage(context.packageName)
intent.putExtra("STATE", AppConfigs.V2RAY_STATE)
intent.putExtra("RUNTIME_TOKEN", config.RUNTIME_TOKEN)
intent.putExtra("RUNTIME_READY", AppConfigs.RUNTIME_READY)
intent.putExtra("DURATION", seconds.toString())
intent.putExtra("UPLOAD_SPEED", 0L)
intent.putExtra("DOWNLOAD_SPEED", 0L)
intent.putExtra("UPLOAD_TRAFFIC", 0L)
intent.putExtra("DOWNLOAD_TRAFFIC", 0L)
context.sendBroadcast(intent)
}
private fun sendDisconnectedBroadcast(context: Context, runtimeToken: String) {
val intent = Intent(AppConfigs.V2RAY_CONNECTION_INFO).setPackage(context.packageName)
intent.putExtra("STATE", AppConfigs.V2RAY_STATES.V2RAY_DISCONNECTED)
intent.putExtra("RUNTIME_TOKEN", runtimeToken)
intent.putExtra("RUNTIME_READY", false)
intent.putExtra("DURATION", "0")
intent.putExtra("UPLOAD_SPEED", 0L)
intent.putExtra("DOWNLOAD_SPEED", 0L)
intent.putExtra("UPLOAD_TRAFFIC", 0L)
intent.putExtra("DOWNLOAD_TRAFFIC", 0L)
context.sendBroadcast(intent)
}
private fun showNotification(context: Service, config: XrayConfig) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ActivityCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
}
val channelId = createNotificationChannel(context, config.APPLICATION_NAME)
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
launchIntent?.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_UPDATE_CURRENT
val contentPendingIntent = PendingIntent.getActivity(context, 0, launchIntent, flags)
val stopIntent = Intent(context, XrayVPNService::class.java)
stopIntent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE)
stopIntent.putExtra("RUNTIME_TOKEN", config.RUNTIME_TOKEN)
val stopPendingIntent = PendingIntent.getService(context, 0, stopIntent, flags)
val smallIcon = if (config.APPLICATION_ICON != 0) config.APPLICATION_ICON else android.R.drawable.ic_dialog_info
val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(smallIcon)
.setContentTitle(config.REMARK)
.setContentText(if (AppConfigs.RUNTIME_READY) "Connected" else "Connecting")
.addAction(0, config.NOTIFICATION_DISCONNECT_BUTTON_NAME, stopPendingIntent)
.setContentIntent(contentPendingIntent)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setOngoing(true)
.setShowWhen(true)
context.startForeground(NOTIFICATION_ID, builder.build())
}
fun getConnectedV2rayServerDelay(context: Context, url: String): Long {
// Use the configured SOCKS port (default 10807)
val port = AppConfigs.V2RAY_CONFIG?.LOCAL_SOCKS5_PORT ?: 10807
Log.d(TAG, "getConnectedV2rayServerDelay: Testing delay to $url via SOCKS port $port")
return try {
val start = System.currentTimeMillis()
val proxy = java.net.Proxy(java.net.Proxy.Type.SOCKS, java.net.InetSocketAddress("127.0.0.1", port))
val connection = java.net.URL(url).openConnection(proxy) as java.net.HttpURLConnection
connection.connectTimeout = 5000
connection.readTimeout = 5000
connection.requestMethod = "HEAD"
val responseCode = connection.responseCode
val end = System.currentTimeMillis()
val delay = end - start
connection.disconnect()
Log.d(TAG, "getConnectedV2rayServerDelay: Success! Response code: $responseCode, Delay: ${delay}ms")
delay
} catch (e: Exception) {
Log.e(TAG, "getConnectedV2rayServerDelay: Failed to measure delay (VPN may not be running)", e)
-1L
}
}
/**
* Measure delay for a server configuration (when not connected).
* Temporarily starts Xray with the provided config, measures delay, then stops it.
*/
fun getServerDelay(context: Context, configJson: String, url: String): Long {
Log.d(TAG, "getServerDelay: Starting temporary Xray instance")
var tempProcess: Process? = null
try {
// Find a random free port to avoid conflict with running VPN
val freePort = try {
val socket = java.net.ServerSocket(0)
val port = socket.localPort
socket.close()
port
} catch (e: Exception) {
10806 // Fallback
}
val (json, socksPort) = buildDelayConfigJson(configJson, freePort, context.filesDir)
Log.d(TAG, "getServerDelay: Using SOCKS port $socksPort")
// Write temp config file
val tempConfigFile = File(context.filesDir, "temp_delay_config.json")
tempConfigFile.writeText(json.toString())
// Copy assets
Utilities.copyAssets(context)
// Start Xray process
val xrayExecutable = File(context.applicationInfo.nativeLibraryDir, "libxray.so")
if (!xrayExecutable.exists()) {
Log.e(TAG, "getServerDelay: Xray executable not found")
return -1L
}
val cmd = listOf(
xrayExecutable.absolutePath,
"-config", tempConfigFile.absolutePath
)
val pb = ProcessBuilder(cmd)
pb.directory(context.filesDir)
val env = pb.environment()
env["XRAY_LOCATION_ASSET"] = Utilities.getUserAssetsPath(context)
tempProcess = pb.start()
// Wait a bit for Xray to start
Thread.sleep(1000)
// Measure delay
val delay = try {
val start = System.currentTimeMillis()
val proxy = java.net.Proxy(java.net.Proxy.Type.SOCKS, java.net.InetSocketAddress("127.0.0.1", socksPort))
val connection = java.net.URL(url).openConnection(proxy) as java.net.HttpURLConnection
connection.connectTimeout = 5000
connection.readTimeout = 5000
connection.requestMethod = "HEAD"
val responseCode = connection.responseCode
val end = System.currentTimeMillis()
connection.disconnect()
val result = end - start
Log.d(TAG, "getServerDelay: Success! Response code: $responseCode, Delay: ${result}ms")
result
} catch (e: Exception) {
Log.e(TAG, "getServerDelay: Failed to measure delay", e)
-1L
}
// Stop temp process
tempProcess?.destroy()
tempConfigFile.delete()
return delay
} catch (e: Exception) {
Log.e(TAG, "getServerDelay: Error starting temp Xray", e)
tempProcess?.destroy()
return -1L
}
}
private fun createNotificationChannel(context: Context, appName: String): String {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channelId = "REVOLT_VPN_SERVICE"
val channelName = "$appName Background Service"
val channel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW)
channel.lightColor = Color.BLUE
channel.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(channel)
return channelId
}
return ""
}
}
@@ -0,0 +1,71 @@
package com.github.tfox.flutter_vless.xray.dto
import java.io.Serializable
import java.util.ArrayList
/**
* Data class representing the Xray configuration and app settings.
*
* This class is passed from Flutter to Android and contains:
* 1. The full Xray JSON configuration (V2RAY_FULL_JSON_CONFIG).
* 2. App-specific settings like blocked apps, notification text, etc.
* 3. Connection parameters (server address, ports).
*/
data class XrayConfig(
/** The IP address of the connected server (used to exclude it from VPN routing). */
var CONNECTED_V2RAY_SERVER_ADDRESS: String = "",
/** The port of the connected server. */
var CONNECTED_V2RAY_SERVER_PORT: String = "",
/** Local port for SOCKS5 proxy (default: 10807). used by tun2socks. */
var LOCAL_SOCKS5_PORT: Int = 10807,
/** Local port for HTTP proxy (default: 10808). */
var LOCAL_HTTP_PORT: Int = 10808,
/** Local port for Xray API (stats, etc.) (default: 10809). */
var LOCAL_API_PORT: Int = 10809,
/** Timeout for connections in milliseconds. */
var ALLOWED_TIMEOUT_MS: Int = 0,
/** List of subnets to bypass (not routed through VPN). */
var BYPASS_SUBNETS: ArrayList<String> = ArrayList(),
/** List of app package names to exclude from VPN. */
var BLOCKED_APPS: ArrayList<String> = ArrayList(),
/** The raw Xray configuration JSON string. */
var V2RAY_FULL_JSON_CONFIG: String = "",
/** Whether to enable traffic statistics collection. */
var ENABLE_TRAFFIC_STATICS: Boolean = false,
/** Remark/Name of the server configuration. */
var REMARK: String = "",
/** Name of the application (displayed in notification). */
var APPLICATION_NAME: String = "Flutter Vless",
/** Resource ID of the notification icon. */
var APPLICATION_ICON: Int = 0,
/** Resource name of the notification icon (e.g. "ic_notification"). */
var NOTIFICATION_ICON_RESOURCE_NAME: String = "",
/** Resource type of the notification icon (e.g. "drawable"). */
var NOTIFICATION_ICON_RESOURCE_TYPE: String = "",
/** Text for the disconnect button in the notification. */
var NOTIFICATION_DISCONNECT_BUTTON_NAME: String = "Disconnect",
/** Domain resolution strategy (e.g. "IPIfNonMatch"). */
var DOMAIN_STRATEGY: String = "",
/** Routing domain strategy. */
var ROUTING_DOMAIN_STRATEGY: String = "",
/** Unique generation token used to reject stale native runtime events. */
var RUNTIME_TOKEN: String = ""
) : Serializable
@@ -0,0 +1,685 @@
package com.github.tfox.flutter_vless.xray.service
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.net.LocalSocket
import android.net.LocalSocketAddress
import android.net.VpnService
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.ParcelFileDescriptor
import android.os.SystemClock
import android.util.Log
import com.github.tfox.flutter_vless.xray.core.XrayCoreManager
import com.github.tfox.flutter_vless.xray.dto.XrayConfig
import com.github.tfox.flutter_vless.xray.utils.AppConfigs
import org.json.JSONObject
import java.io.File
/**
* Android VPN Service implementation.
*
* This service owns the TUN interface and keeps runtime state generation-scoped.
* Session deadlines are enforced both by an in-process monotonic timer and by
* AlarmManager, while only the non-secret token/deadline pair is persisted.
* Per-session VLESS/SOCKS credentials remain memory-only.
*/
class XrayVPNService : VpnService() {
private var mInterface: ParcelFileDescriptor? = null
private var tun2socksProcess: Process? = null
@Volatile private var isRunning = false
@Volatile private var recoveringXray = false
@Volatile private var recoveringTun2socks = false
private var tun2socksRecoveryAttempt = 0
private var currentConfig: XrayConfig? = null
private var currentProxyOnly = false
private var shuttingDownIntentionally = false
private val deadlineHandler = Handler(Looper.getMainLooper())
private var sessionDeadlineElapsed: Long? = null
private var sessionDeadlineToken: String? = null
private val sessionDeadlineRunnable = Runnable { enforceSessionDeadline() }
override fun onCreate() {
super.onCreate()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent == null) {
if (isRunning && currentConfig != null) return START_REDELIVER_INTENT
// Session credentials intentionally stay memory-only. If Android
// cannot redeliver the original START intent, fail closed instead
// of rebuilding a tunnel from sensitive material stored on disk.
clearPersistedDeadline()
cancelSessionExpiryAlarm()
stopSelf()
return START_NOT_STICKY
}
if (intent.getBooleanExtra(EXTRA_SESSION_EXPIRED, false)) {
enforcePersistedDeadline(intent.getStringExtra("RUNTIME_TOKEN").orEmpty())
return START_NOT_STICKY
}
val command = if (Build.VERSION.SDK_INT >= 33) {
intent.getSerializableExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS::class.java)
} else {
@Suppress("DEPRECATION")
intent.getSerializableExtra("COMMAND") as? AppConfigs.V2RAY_SERVICE_COMMANDS
}
if (command == AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE) {
val requestedToken = intent.getStringExtra("RUNTIME_TOKEN").orEmpty()
val activeToken = currentConfig?.RUNTIME_TOKEN.orEmpty()
if (requestedToken.isEmpty() ||
(activeToken.isNotEmpty() && requestedToken != activeToken)
) {
Log.w(TAG, "Ignoring STOP_SERVICE without matching runtime generation")
return START_NOT_STICKY
}
stopAll(requestedToken)
return START_NOT_STICKY
}
if (command == AppConfigs.V2RAY_SERVICE_COMMANDS.UPDATE_SESSION_DEADLINE) {
updateSessionDeadline(intent)
return if (isRunning) START_REDELIVER_INTENT else START_NOT_STICKY
}
createNotificationChannel()
val notification = createNotification("VPN Service Running")
try {
if (Build.VERSION.SDK_INT >= 34) {
startForeground(1, notification, FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
} else {
startForeground(1, notification)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to start foreground", e)
clearPersistedDeadline()
cancelSessionExpiryAlarm()
stopSelf()
return START_NOT_STICKY
}
if (command == AppConfigs.V2RAY_SERVICE_COMMANDS.START_SERVICE) {
val config = if (Build.VERSION.SDK_INT >= 33) {
intent.getSerializableExtra("V2RAY_CONFIG", XrayConfig::class.java)
} else {
@Suppress("DEPRECATION")
intent.getSerializableExtra("V2RAY_CONFIG") as? XrayConfig
}
if (config != null) {
if (XrayCoreManager.isXrayRunning()) {
Log.w(TAG, "Stopping stale Xray core before restart")
XrayCoreManager.stopCore(this)
}
cleanup(clearDeadline = true)
shuttingDownIntentionally = false
currentConfig = config
val proxyOnly = intent.getBooleanExtra("PROXY_ONLY", false)
currentProxyOnly = proxyOnly
armSessionDeadline(config, BOOTSTRAP_SESSION_SECONDS)
if (shuttingDownIntentionally) return START_NOT_STICKY
if (XrayCoreManager.startCore(this, config)) {
if (!proxyOnly) {
setupVpn(config)
} else {
isRunning = true
Log.d(TAG, "Starting in PROXY_ONLY mode")
XrayCoreManager.markRuntimeReady(this, config)
}
} else {
stopAll(config.RUNTIME_TOKEN)
return START_NOT_STICKY
}
} else {
Log.e(TAG, "START_SERVICE missing V2RAY_CONFIG")
stopAll()
return START_NOT_STICKY
}
} else {
stopAll()
return START_NOT_STICKY
}
return START_REDELIVER_INTENT
}
private fun setupVpn(config: XrayConfig) {
try {
if (mInterface != null) {
mInterface?.close()
mInterface = null
}
val builder = Builder()
builder.setSession(config.REMARK)
builder.setMtu(1500)
builder.addAddress("26.26.26.1", 30)
builder.addAddress("fd00:26:26::1", 126)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
builder.setMetered(false)
}
if (config.BLOCKED_APPS.isNotEmpty()) {
throw IllegalStateException("Per-app VPN bypass is disabled in ReVolt")
}
try {
builder.addDisallowedApplication(packageName)
} catch (e: Exception) {
throw IllegalStateException("Failed to exclude ReVolt from its own VPN", e)
}
builder.addRoute("0.0.0.0", 0)
builder.addRoute("::", 0)
try {
builder.addDnsServer("8.8.8.8")
builder.addDnsServer("1.1.1.1")
} catch (e: Exception) {
throw IllegalStateException("Failed to configure VPN DNS", e)
}
mInterface = builder.establish()
?: throw IllegalStateException("Android refused to establish VPN interface")
isRunning = true
runTun2socks(config)
} catch (e: Exception) {
Log.e(TAG, "Failed to setup VPN", e)
stopAll()
}
}
private data class SecureSocksCredentials(
val port: Int,
val username: String,
val password: String,
)
private fun secureSocksCredentials(config: XrayConfig): SecureSocksCredentials? {
val inbounds = try {
JSONObject(config.V2RAY_FULL_JSON_CONFIG).optJSONArray("inbounds")
} catch (_: Exception) {
null
} ?: return null
for (i in 0 until inbounds.length()) {
val inbound = inbounds.optJSONObject(i) ?: continue
if (inbound.optString("tag") != "revolt-secure-socks") continue
if (inbound.optString("protocol") != "socks" ||
inbound.optString("listen") != "127.0.0.1"
) return null
val settings = inbound.optJSONObject("settings") ?: return null
if (settings.optString("auth") != "password") return null
val user = settings.optJSONArray("users")?.optJSONObject(0) ?: return null
val port = inbound.optInt("port", -1)
val username = user.optString("user")
val password = user.optString("pass")
if (port <= 1024 || port > 65535 || username.isEmpty() || password.isEmpty()) {
return null
}
return SecureSocksCredentials(port, username, password)
}
return null
}
private fun scheduleTun2socksRecovery(config: XrayConfig, reason: String) {
if (!isRunning || currentConfig !== config || recoveringTun2socks) return
XrayCoreManager.markRuntimeConnecting(this, config)
recoveringTun2socks = true
val attempt = ++tun2socksRecoveryAttempt
val shift = (attempt - 1).coerceIn(0, 6)
val delayMs = (500L * (1L shl shift)).coerceAtMost(30_000L)
Log.w(TAG, "Scheduling tun2socks recovery attempt $attempt in ${delayMs}ms: $reason")
Thread {
try {
Thread.sleep(delayMs)
} catch (_: InterruptedException) {
recoveringTun2socks = false
return@Thread
}
recoveringTun2socks = false
if (isRunning && currentConfig === config) {
runTun2socks(config)
}
}.start()
}
private fun runTun2socks(config: XrayConfig) {
val tun2socksPath = File(applicationInfo.nativeLibraryDir, "libtun2socks.so").absolutePath
val socketFile = File(filesDir, "sock_path")
if (socketFile.exists() && !socketFile.delete()) {
Log.w(TAG, "Could not delete stale tun2socks socket")
}
val sockPath = socketFile.absolutePath
val secure = secureSocksCredentials(config)
?: throw IllegalStateException("Authenticated ReVolt SOCKS5 inbound missing")
config.LOCAL_SOCKS5_PORT = secure.port
val cmd = arrayListOf(
tun2socksPath,
"-sock-path", sockPath,
"-proxy", "socks5://${secure.username}:${secure.password}@127.0.0.1:${secure.port}",
"-mtu", "1500",
"-loglevel", "debug"
)
Log.d(TAG, "Starting tun2socks with authenticated loopback SOCKS5")
try {
val pb = ProcessBuilder(cmd)
pb.redirectErrorStream(true)
pb.directory(filesDir)
val process = pb.start()
tun2socksProcess = process
Thread {
try {
process.inputStream.bufferedReader().use { reader ->
reader.forEachLine { _ -> }
}
process.waitFor()
if (isRunning && tun2socksProcess === process && currentConfig === config) {
Log.e(TAG, "tun2socks exited unexpectedly; keeping TUN fail-closed")
tun2socksProcess = null
scheduleTun2socksRecovery(config, "process exited")
}
} catch (_: java.io.InterruptedIOException) {
// Expected when stopping
} catch (_: InterruptedException) {
// Expected when stopping
} catch (e: Exception) {
Log.e(TAG, "Error reading tun2socks output", e)
}
}.start()
sendFd(process, config)
} catch (e: Exception) {
Log.e(TAG, "Failed to start tun2socks; keeping TUN fail-closed", e)
scheduleTun2socksRecovery(config, "start failure")
}
}
private fun sendFd(process: Process, config: XrayConfig) {
val fd = mInterface?.fileDescriptor ?: return
val sockFile = File(filesDir, "sock_path").absolutePath
Thread {
var tries = 0
while (tries < 10 && isRunning && tun2socksProcess === process && process.isAlive) {
var localSocket: LocalSocket? = null
try {
Thread.sleep(500)
localSocket = LocalSocket()
localSocket.connect(LocalSocketAddress(sockFile, LocalSocketAddress.Namespace.FILESYSTEM))
val out = localSocket.outputStream
localSocket.setFileDescriptorsForSend(arrayOf(fd))
out.write(32)
out.flush()
localSocket.setFileDescriptorsForSend(null)
localSocket.shutdownOutput()
localSocket.close()
if (isRunning && currentConfig === config && tun2socksProcess === process) {
tun2socksRecoveryAttempt = 0
XrayCoreManager.markRuntimeReady(this, config)
}
return@Thread
} catch (_: Exception) {
tries++
try {
localSocket?.close()
} catch (closeError: Exception) {
Log.w(TAG, "Failed to close tun2socks handoff socket", closeError)
}
}
}
if (isRunning && tun2socksProcess === process) {
Log.e(TAG, "FD handoff failed; recycling tun2socks without dropping TUN")
try {
process.destroy()
} catch (e: Exception) {
Log.w(TAG, "Failed to recycle tun2socks after FD handoff failure", e)
}
}
}.start()
}
fun handleXrayCoreExit(config: XrayConfig) {
if (!isRunning || currentConfig !== config || recoveringXray) return
XrayCoreManager.markRuntimeConnecting(this, config)
recoveringXray = true
Thread {
var attempt = 0
try {
while (isRunning && currentConfig === config) {
attempt++
val shift = (attempt - 1).coerceIn(0, 6)
val delayMs = (500L * (1L shl shift)).coerceAtMost(30_000L)
Log.w(TAG, "Recovering Xray core attempt $attempt in ${delayMs}ms")
Thread.sleep(delayMs)
if (!isRunning || currentConfig !== config) break
if (XrayCoreManager.startCore(this, config)) {
Log.w(TAG, "Xray core recovered without dropping TUN")
if (currentProxyOnly || (mInterface != null && tun2socksProcess?.isAlive == true)) {
XrayCoreManager.markRuntimeReady(this, config)
}
return@Thread
}
}
} catch (_: InterruptedException) {
// Expected when stopping
} finally {
recoveringXray = false
}
}.start()
}
private fun cleanup(clearDeadline: Boolean = true) {
isRunning = false
if (clearDeadline) {
clearSessionDeadline()
} else {
deadlineHandler.removeCallbacks(sessionDeadlineRunnable)
sessionDeadlineElapsed = null
sessionDeadlineToken = null
}
recoveringXray = false
recoveringTun2socks = false
tun2socksRecoveryAttempt = 0
currentProxyOnly = false
AppConfigs.RUNTIME_READY = false
tun2socksProcess?.destroy()
tun2socksProcess = null
try {
mInterface?.close()
mInterface = null
} catch (e: Exception) {
Log.w(TAG, "Failed to close VPN interface during cleanup", e)
}
}
private fun stopAll(confirmationToken: String = currentConfig?.RUNTIME_TOKEN.orEmpty()) {
if (shuttingDownIntentionally) return
shuttingDownIntentionally = true
cleanup(clearDeadline = true)
currentConfig = null
XrayCoreManager.stopCore(this, confirmationToken)
stopForeground(true)
stopSelf()
}
private fun armSessionDeadline(config: XrayConfig, remainingSeconds: Long) {
val boundedSeconds = remainingSeconds
.coerceAtLeast(0L)
.coerceAtMost(Long.MAX_VALUE / 1000L)
val delayMs = boundedSeconds * 1000L
val nowEpochMs = System.currentTimeMillis()
val epochDeadline = if (Long.MAX_VALUE - nowEpochMs < delayMs) {
Long.MAX_VALUE
} else {
nowEpochMs + delayMs
}
sessionDeadlineToken = config.RUNTIME_TOKEN
sessionDeadlineElapsed = SystemClock.elapsedRealtime() + delayMs
if (!persistDeadline(config.RUNTIME_TOKEN, epochDeadline) ||
!scheduleSessionExpiryAlarm(config.RUNTIME_TOKEN, epochDeadline)
) {
Log.e(TAG, "Could not arm OS-level session expiry; stopping fail-closed")
stopAll(config.RUNTIME_TOKEN)
return
}
deadlineHandler.removeCallbacks(sessionDeadlineRunnable)
if (delayMs == 0L) deadlineHandler.post(sessionDeadlineRunnable)
else deadlineHandler.postDelayed(sessionDeadlineRunnable, delayMs)
}
private fun clearSessionDeadline() {
deadlineHandler.removeCallbacks(sessionDeadlineRunnable)
sessionDeadlineElapsed = null
sessionDeadlineToken = null
clearPersistedDeadline()
cancelSessionExpiryAlarm()
}
private fun enforceSessionDeadline() {
val token = sessionDeadlineToken ?: return
val deadline = sessionDeadlineElapsed ?: return
val config = currentConfig ?: return
if (config.RUNTIME_TOKEN != token) return
val remaining = deadline - SystemClock.elapsedRealtime()
if (remaining > 0L) {
deadlineHandler.postDelayed(sessionDeadlineRunnable, remaining)
return
}
Log.w(TAG, "Native session deadline expired; stopping runtime fail-closed")
stopAll(token)
}
private fun updateSessionDeadline(intent: Intent) {
val config = currentConfig ?: return
val token = intent.getStringExtra("RUNTIME_TOKEN").orEmpty()
if (token.isEmpty() || token != config.RUNTIME_TOKEN) {
Log.w(TAG, "Ignoring session deadline update for stale runtime generation")
return
}
val remainingSeconds = intent.getLongExtra("REMAINING_SECONDS", -1L)
if (remainingSeconds < 0L) {
Log.w(TAG, "Ignoring invalid negative session deadline")
return
}
armSessionDeadline(config, remainingSeconds)
}
private fun persistDeadline(runtimeToken: String, expiresAtEpochMs: Long): Boolean {
return try {
getSharedPreferences(DEADLINE_PREFS, Context.MODE_PRIVATE)
.edit()
.putString(PREF_RUNTIME_TOKEN, runtimeToken)
.putLong(PREF_EXPIRES_AT_MS, expiresAtEpochMs)
.commit()
} catch (e: Exception) {
Log.e(TAG, "Failed to persist non-secret session deadline", e)
false
}
}
private fun clearPersistedDeadline() {
try {
getSharedPreferences(DEADLINE_PREFS, Context.MODE_PRIVATE)
.edit()
.remove(PREF_RUNTIME_TOKEN)
.remove(PREF_EXPIRES_AT_MS)
.apply()
} catch (e: Exception) {
Log.w(TAG, "Failed to clear persisted session deadline", e)
}
}
private fun enforcePersistedDeadline(alarmToken: String) {
val stored = try {
val prefs = getSharedPreferences(DEADLINE_PREFS, Context.MODE_PRIVATE)
prefs.getString(PREF_RUNTIME_TOKEN, null).orEmpty() to
prefs.getLong(PREF_EXPIRES_AT_MS, 0L)
} catch (e: Exception) {
Log.e(TAG, "Failed to read persisted session deadline", e)
clearPersistedDeadline()
cancelSessionExpiryAlarm()
stopSelf()
return
}
val storedToken = stored.first
val storedDeadline = stored.second
if (alarmToken.isEmpty() || storedToken.isEmpty() || alarmToken != storedToken) {
Log.w(TAG, "Ignoring stale session-expiry alarm")
if (currentConfig == null) stopSelf()
return
}
val remaining = storedDeadline - System.currentTimeMillis()
if (storedDeadline > 0L && remaining > 0L) {
if (!scheduleSessionExpiryAlarm(storedToken, storedDeadline)) {
Log.e(TAG, "Failed to re-arm early session-expiry alarm")
val activeToken = currentConfig?.RUNTIME_TOKEN.orEmpty()
if (activeToken == storedToken) stopAll(storedToken) else stopSelf()
return
}
// An alarm can wake a fresh service process after clock changes.
// If there is no live runtime in this process, do not leave a
// background service instance hanging around just to hold a timer.
if (currentConfig == null) stopSelf()
return
}
Log.w(TAG, "OS session-expiry alarm fired; stopping runtime fail-closed")
val activeToken = currentConfig?.RUNTIME_TOKEN.orEmpty()
if (activeToken.isNotEmpty() && activeToken == storedToken) {
stopAll(storedToken)
} else {
clearPersistedDeadline()
cancelSessionExpiryAlarm()
stopForeground(true)
stopSelf()
}
}
private fun scheduleSessionExpiryAlarm(runtimeToken: String, expiresAtEpochMs: Long): Boolean {
cancelSessionExpiryAlarm()
if (runtimeToken.isEmpty() || expiresAtEpochMs <= 0L) return false
return try {
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
val pendingIntent = sessionExpiryPendingIntent(runtimeToken)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
try {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
expiresAtEpochMs,
pendingIntent,
)
} catch (_: SecurityException) {
Log.w(TAG, "Exact alarm unavailable; using inexact idle-capable expiry alarm")
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
expiresAtEpochMs,
pendingIntent,
)
}
} else {
alarmManager.setExact(AlarmManager.RTC_WAKEUP, expiresAtEpochMs, pendingIntent)
}
true
} catch (e: Exception) {
Log.e(TAG, "Failed to schedule OS session-expiry alarm", e)
false
}
}
private fun cancelSessionExpiryAlarm() {
try {
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
alarmManager.cancel(sessionExpiryPendingIntent(""))
} catch (e: Exception) {
Log.w(TAG, "Failed to cancel session-expiry alarm", e)
}
}
private fun sessionExpiryPendingIntent(runtimeToken: String): PendingIntent {
val intent = Intent(this, XrayVPNService::class.java)
.putExtra(EXTRA_SESSION_EXPIRED, true)
.putExtra("RUNTIME_TOKEN", runtimeToken)
val flags = PendingIntent.FLAG_UPDATE_CURRENT or
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
return PendingIntent.getService(this, SESSION_EXPIRY_REQUEST_CODE, intent, flags)
}
override fun onDestroy() {
if (!shuttingDownIntentionally) {
val confirmationToken = currentConfig?.RUNTIME_TOKEN.orEmpty()
cleanup(clearDeadline = false)
currentConfig = null
XrayCoreManager.stopCore(this, confirmationToken)
stopForeground(true)
}
super.onDestroy()
}
override fun onRevoke() {
stopAll()
super.onRevoke()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channelId = "REVOLT_VPN_SERVICE"
val channelName = "VPN Service"
val channel = android.app.NotificationChannel(
channelId,
channelName,
android.app.NotificationManager.IMPORTANCE_LOW
)
val manager = getSystemService(android.app.NotificationManager::class.java)
manager?.createNotificationChannel(channel)
}
}
private fun createNotification(content: String): android.app.Notification {
val channelId = "REVOLT_VPN_SERVICE"
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
android.app.Notification.Builder(this, channelId)
} else {
@Suppress("DEPRECATION")
android.app.Notification.Builder(this)
}
val icon = android.R.drawable.ic_dialog_info
val notification = builder
.setContentTitle("VPN Service")
.setContentText(content)
.setSmallIcon(icon)
.setOngoing(true)
.setAutoCancel(false)
.setOnlyAlertOnce(true)
.build()
notification.flags = notification.flags or
android.app.Notification.FLAG_ONGOING_EVENT or
android.app.Notification.FLAG_NO_CLEAR
return notification
}
companion object {
private const val TAG = "XrayVPNService"
private const val FOREGROUND_SERVICE_TYPE_SPECIAL_USE = 0x40000000
private const val BOOTSTRAP_SESSION_SECONDS = 120L
private const val DEADLINE_PREFS = "revolt_session_deadline"
private const val PREF_RUNTIME_TOKEN = "runtime_token"
private const val PREF_EXPIRES_AT_MS = "expires_at_ms"
private const val EXTRA_SESSION_EXPIRED = "SESSION_EXPIRED"
private const val SESSION_EXPIRY_REQUEST_CODE = 1001
}
}
@@ -0,0 +1,49 @@
package com.github.tfox.flutter_vless.xray.utils
import com.github.tfox.flutter_vless.xray.dto.XrayConfig
import java.io.Serializable
/**
* Global application configuration and state.
*
* This object holds:
* 1. Constants for Intent actions and Service commands.
* 2. Enums for connection states and modes.
* 3. Global state variables (current connection state, active config).
* 4. Static storage for notification icon resources (passed from Flutter).
*/
object AppConfigs {
const val V2RAY_CONNECTION_INFO = "com.github.tfox.flutter_vless.xray.V2RAY_CONNECTION_INFO"
/**
* Commands sent to the XrayVPNService via Intent.
*/
enum class V2RAY_SERVICE_COMMANDS : Serializable {
START_SERVICE, STOP_SERVICE, RESTART_SERVICE, UPDATE_SESSION_DEADLINE
}
/**
* Connection states for the Xray Core.
*/
enum class V2RAY_STATES : Serializable {
V2RAY_CONNECTED, V2RAY_DISCONNECTED, V2RAY_CONNECTING
}
/**
* Connection modes:
* - VPN_TUN: Standard VPN mode using Android VpnService (TUN device).
* - PROXY_ONLY: Runs Xray as a local SOCKS/HTTP proxy without establishing a VPN tunnel.
*/
enum class V2RAY_CONNECTION_MODES : Serializable {
VPN_TUN, PROXY_ONLY
}
var V2RAY_STATE: V2RAY_STATES = V2RAY_STATES.V2RAY_DISCONNECTED
@Volatile var RUNTIME_READY: Boolean = false
var V2RAY_CONFIG: XrayConfig? = null
var V2RAY_CONNECTION_MODE: V2RAY_CONNECTION_MODES = V2RAY_CONNECTION_MODES.VPN_TUN
// Stores the resource name and type for the custom notification icon
var NOTIFICATION_ICON_RESOURCE_NAME: String = ""
var NOTIFICATION_ICON_RESOURCE_TYPE: String = ""
}
@@ -0,0 +1,62 @@
package com.github.tfox.flutter_vless.xray.utils
import android.content.Context
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
object Utilities {
fun getUserAssetsPath(context: Context): String {
val dir = context.filesDir
if (!dir.exists()) {
dir.mkdirs()
}
return dir.absolutePath
}
fun copyAssets(context: Context) {
val assets = context.assets
val files = assets.list("") ?: return
for (filename in files) {
if (filename == "geoip.dat" || filename == "geosite.dat") {
var `in`: InputStream? = null
var out: OutputStream? = null
try {
`in` = assets.open(filename)
val outFile = File(getUserAssetsPath(context), filename)
out = FileOutputStream(outFile)
copyFile(`in`, out)
} catch (e: IOException) {
e.printStackTrace()
} finally {
if (`in` != null) {
try {
`in`.close()
} catch (e: IOException) {
e.printStackTrace()
}
}
if (out != null) {
try {
out.close()
} catch (e: IOException) {
e.printStackTrace()
}
}
}
}
}
}
@Throws(IOException::class)
private fun copyFile(`in`: InputStream, out: OutputStream) {
val buffer = ByteArray(1024)
var read: Int
while (`in`.read(buffer).also { read = it } != -1) {
out.write(buffer, 0, read)
}
}
}