diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml
new file mode 100644
index 0000000..313f3c7
--- /dev/null
+++ b/.github/workflows/android-ci.yml
@@ -0,0 +1,134 @@
+name: Android CI
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+jobs:
+ android:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 45
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+
+ - name: Set up Java 17
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
+ with:
+ distribution: temurin
+ java-version: '17'
+
+ - name: Verify Gradle supply chain
+ shell: bash
+ run: |
+ set -euo pipefail
+ test -x android/gradlew
+ test -f android/gradlew.bat
+ test -f android/gradle/wrapper/gradle-wrapper.jar
+ test -s android/gradle/verification-metadata.xml
+ test "$(sha256sum android/gradle/wrapper/gradle-wrapper.jar | awk '{print $1}')" = '7d3a4ac4de1c32b59bc6a4eb8ecb8e612ccd0cf1ae1e99f66902da64df296172'
+ grep -qx 'distributionSha256Sum=efe9a3d147d948d7528a9887fa35abcf24ca1a43ad06439996490f77569b02d1' android/gradle/wrapper/gradle-wrapper.properties
+ grep -q 'true' android/gradle/verification-metadata.xml
+ ./android/gradlew --version
+
+ - name: Set up Flutter
+ uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
+ with:
+ flutter-version: '3.47.2'
+ channel: stable
+ cache: false
+
+ - name: Create CI app config
+ run: cp lib/logic/app_config.example.dart lib/logic/app_config.dart
+
+ - name: Resolve dependencies
+ run: flutter pub get
+
+ - name: Verify dependency lock is committed
+ run: git diff --exit-code -- pubspec.lock
+
+ - name: Verify vendored runtime provenance
+ shell: bash
+ run: |
+ set -euo pipefail
+ PKG='local_packages/flutter_vless_android-1.1.5'
+ test -f "$PKG/UPSTREAM"
+ grep -q 'Tag: v1.1.5' "$PKG/UPSTREAM"
+ grep -q 'path: local_packages/flutter_vless_android-1.1.5' pubspec.yaml
+ ! grep -q 'patchFlutterVless' android/app/build.gradle.kts
+
+ - name: Analyze
+ run: flutter analyze
+
+ - name: Test
+ run: flutter test
+
+ - name: Android lint
+ working-directory: android
+ run: ./gradlew :app:lintDebug --no-daemon
+
+ - name: Verify production release config fails closed
+ shell: bash
+ working-directory: android
+ run: |
+ set -euo pipefail
+ set +e
+ output="$(./gradlew :app:verifyReleaseAppConfig --no-daemon 2>&1)"
+ status=$?
+ set -e
+ printf '%s\n' "$output"
+ test "$status" -ne 0
+ grep -Fq 'REVOLT_APP_CONFIG_SHA256 is required for production release builds' <<<"$output"
+
+ - name: Cold APK build
+ run: flutter build apk --debug
+
+ - name: Verify vendored native source stayed immutable
+ run: git diff --exit-code -- local_packages/flutter_vless_android-1.1.5
+
+ - name: Warm APK build
+ run: flutter build apk --debug
+
+ - name: Release R8 smoke build
+ env:
+ REVOLT_CI_RELEASE_SMOKE: 'true'
+ run: |
+ flutter build apk --release
+ test -f build/app/outputs/flutter-apk/app-release.apk
+ test -f build/app/outputs/mapping/release/mapping.txt
+
+ - name: Verify tracked build inputs stayed immutable
+ run: |
+ git diff --exit-code -- \
+ pubspec.yaml \
+ pubspec.lock \
+ local_packages/flutter_vless_android-1.1.5 \
+ android/app/build.gradle.kts \
+ android/app/proguard-rules.pro \
+ android/gradle/verification-metadata.xml \
+ android/gradle/wrapper/gradle-wrapper.properties \
+ android/gradle/wrapper/gradle-wrapper.jar \
+ android/gradlew \
+ android/gradlew.bat
+
+ - name: Upload APK
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: revoltvpn-debug-apk
+ if-no-files-found: error
+ path: build/app/outputs/flutter-apk/app-debug.apk
+
+ - name: Upload resolved lockfile
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: revoltvpn-resolved-lockfile
+ if-no-files-found: error
+ path: pubspec.lock
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 58a17b5..8b04f1e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,47 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH
---
+## [3.3.5] — 2026-09-03
+
+### Added
+- **OS-level session-expiry enforcement.** The native VPN service now enforces the
+ server deadline with both a monotonic in-process handler and `AlarmManager`, so
+ expiry does not depend on the Flutter UI process staying alive.
+- Only the non-secret runtime generation token and absolute expiry timestamp are
+ persisted app-private for the OS alarm. Per-session VLESS UUID/SOCKS credentials
+ remain memory-only; if Android cannot redeliver the original start intent, the
+ runtime fails closed instead of reconstructing a tunnel from secrets on disk.
+- A successful support reward now requests an immediate control-plane refresh so the
+ extended deadline is pushed to the native service without waiting for the next poll.
+
+### Fixed
+- **Session clock reattach race.** `SessionTimer` is eager and evaluates the current
+ VPN state once after construction, so a UI process attaching to an already-live
+ tunnel no longer leaves the countdown at `00:00:00`.
+- Forced support sync no longer disappears behind an already-running periodic sync;
+ it waits for the active request and then performs a fresh control-plane refresh.
+- VPN permission requests now fail with `NO_ACTIVITY` during Android activity detach
+ instead of dereferencing a null Activity.
+- Receiver cleanup logs lifecycle races instead of silently swallowing them.
+- An early/stale session-expiry alarm no longer leaves an otherwise unused service
+ process running.
+
+### Changed
+- Unknown/obsolete persisted connection-mode labels, including the old `ass`/`auto`
+ values, now fail closed to TUN. Only the explicit current `proxy` value restores
+ SOCKS5 mode.
+- App version is `3.3.5+31`.
+
+### Security
+- Existing AdMob bypass/callback behavior is intentionally unchanged.
+- Build-time mutation of the global Flutter pub cache remains removed; the hardened
+ `flutter_vless_android` 1.1.5 source remains vendored and immutable during builds.
+- Full IPv4/IPv6 TUN routing, fail-closed DNS/TUN setup, authenticated ephemeral
+ loopback SOCKS5, generation-scoped STOP/state handling, production config pinning,
+ dependency verification, R8 gates and updater host allowlists remain in force.
+
+---
+
## [3.3.4] — 2026-09-02
### Added
@@ -41,17 +82,9 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH
## [3.3.3] — 2026-09-01
### Fixed
-- **The session clock stayed at 00:00:00 on a live tunnel, and the notification
- countdown froze with it.** `SessionTimer` is built lazily, so when the app reattaches
- to a tunnel that outlived its UI process, `VpnConnection` had already reported
- "connected" before the timer existed — and a change listener does not replay events
- it was not present for. The timer now evaluates the current connection state once at
- construction instead of waiting for a transition, and is created eagerly alongside
- `VpnConnection`. Because the timer is what pushes text to the foreground
- notification, a stopped clock also meant a frozen countdown; both come from this.
-- Resuming the clock no longer requires `adoptedRunningRuntime` or a previous
- successful sync, both of which are false in a freshly started process. A connected
- tunnel with a stopped clock is now always treated as something to resume.
+- The session timer now resumes when the Flutter process attaches to a tunnel that was
+ already connected before the timer listener existed. This also prevents the
+ foreground countdown from freezing after UI-process recreation.
## [3.3.2] — 2026-09-01
diff --git a/analysis_options.yaml b/analysis_options.yaml
index b9bce79..d7a1ec0 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -2,6 +2,8 @@ include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
+ - build/**
+ - android/**
- local_packages/**
- whatsthis/**
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index b302bb4..cb24e65 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -1,132 +1,16 @@
import java.util.Properties
import java.io.File
import java.io.FileInputStream
+import java.security.MessageDigest
plugins {
id("com.android.application")
id("dev.flutter.flutter-gradle-plugin")
}
-val projectRootDir = rootProject.projectDir.parentFile
-val localPropertiesFile = rootProject.file("local.properties")
-val localProperties = Properties().apply {
- if (localPropertiesFile.exists()) {
- localPropertiesFile.inputStream().use { load(it) }
- }
-}
-val flutterSdkPath = localProperties.getProperty("flutter.sdk")
- ?: throw GradleException("flutter.sdk not set in android/local.properties")
-val dartExecutable = File(
- flutterSdkPath,
- if (System.getProperty("os.name").lowercase().contains("windows")) {
- "bin/cache/dart-sdk/bin/dart.exe"
- } else {
- "bin/cache/dart-sdk/bin/dart"
- },
-)
-val flutterVlessRecoveryPatchScript = File(
- projectRootDir,
- "tool/patch_flutter_vless_runtime_recovery.dart",
-)
-val flutterVlessSecureSocksPatchScript = File(
- projectRootDir,
- "tool/patch_flutter_vless_secure_socks_v2.dart",
-)
-val flutterVlessSecureSocksScopeFixScript = File(
- projectRootDir,
- "tool/patch_flutter_vless_secure_socks_scope_fix.dart",
-)
-
-val patchFlutterVlessRuntimeRecovery = tasks.register("patchFlutterVlessRuntimeRecovery") {
- group = "build setup"
- description = "Recover stale Xray core and tun2socks socket before restart"
- workingDir(projectRootDir)
-
- doFirst {
- if (!dartExecutable.isFile) {
- throw GradleException("Flutter Dart executable not found: ${dartExecutable.absolutePath}")
- }
- if (!flutterVlessRecoveryPatchScript.isFile) {
- throw GradleException("flutter_vless recovery patch script missing: ${flutterVlessRecoveryPatchScript.absolutePath}")
- }
- if (!File(projectRootDir, ".dart_tool/package_config.json").isFile) {
- throw GradleException("Flutter package metadata missing. Run `flutter pub get` before Android compilation.")
- }
- }
-
- commandLine(
- dartExecutable.absolutePath,
- "run",
- flutterVlessRecoveryPatchScript.absolutePath,
- )
-}
-
-val patchFlutterVlessSecureSocks = tasks.register("patchFlutterVlessSecureSocks") {
- group = "build setup"
- description = "Add authenticated ephemeral SOCKS5 and fail-closed process recovery to pinned flutter_vless_android 1.1.5"
- workingDir(projectRootDir)
- dependsOn(patchFlutterVlessRuntimeRecovery)
-
- doFirst {
- if (!dartExecutable.isFile) {
- throw GradleException("Flutter Dart executable not found: ${dartExecutable.absolutePath}")
- }
- if (!flutterVlessSecureSocksPatchScript.isFile) {
- throw GradleException("secure SOCKS patch script missing: ${flutterVlessSecureSocksPatchScript.absolutePath}")
- }
- if (!File(projectRootDir, ".dart_tool/package_config.json").isFile) {
- throw GradleException("Flutter package metadata missing. Run `flutter pub get` before Android compilation.")
- }
- }
-
- commandLine(
- dartExecutable.absolutePath,
- "run",
- flutterVlessSecureSocksPatchScript.absolutePath,
- )
-}
-
-val patchFlutterVlessSecureSocksScopeFix = tasks.register("patchFlutterVlessSecureSocksScopeFix") {
- group = "build setup"
- description = "Repair the secure Xray process monitor capture without changing stock 1.1.5 runtime sequencing"
- workingDir(projectRootDir)
- dependsOn(patchFlutterVlessSecureSocks)
-
- doFirst {
- if (!dartExecutable.isFile) {
- throw GradleException("Flutter Dart executable not found: ${dartExecutable.absolutePath}")
- }
- if (!flutterVlessSecureSocksScopeFixScript.isFile) {
- throw GradleException("secure SOCKS scope fix script missing: ${flutterVlessSecureSocksScopeFixScript.absolutePath}")
- }
- if (!File(projectRootDir, ".dart_tool/package_config.json").isFile) {
- throw GradleException("Flutter package metadata missing. Run `flutter pub get` before Android compilation.")
- }
- }
-
- commandLine(
- dartExecutable.absolutePath,
- "run",
- flutterVlessSecureSocksScopeFixScript.absolutePath,
- )
-}
-
-// App compilation and the transitive Android plugin compilation must both wait
-// for the pinned runtime patches. Plain `flutter build apk` must behave like CI
-// instead of relying on a hidden manual pre-build step.
-tasks.configureEach {
- if (name == "preBuild") {
- dependsOn(patchFlutterVlessSecureSocksScopeFix)
- }
-}
-
-gradle.projectsEvaluated {
- rootProject.findProject(":flutter_vless_android")
- ?.tasks
- ?.matching { it.name == "preBuild" }
- ?.configureEach {
- dependsOn(patchFlutterVlessSecureSocksScopeFix)
- }
+fun sha256Hex(file: File): String {
+ val digest = MessageDigest.getInstance("SHA-256").digest(file.readBytes())
+ return digest.joinToString("") { "%02x".format(it.toInt() and 0xff) }
}
android {
@@ -140,10 +24,6 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
- packaging {
- resources.excludes.add("META-INF/**")
- }
-
val keystorePropertiesFile = rootProject.file("key.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
@@ -156,6 +36,68 @@ android {
admobProperties.load(FileInputStream(admobPropertiesFile))
}
+ // Explicit opt-in used only by GitHub Actions to exercise the real
+ // release/R8 graph. Production release signing and config pinning remain
+ // fail-closed outside that CI smoke path.
+ val ciReleaseSmoke =
+ System.getenv("GITHUB_ACTIONS") == "true" &&
+ System.getenv("REVOLT_CI_RELEASE_SMOKE") == "true"
+
+ val appConfigFile = rootProject.file("../lib/logic/app_config.dart")
+ val verifyReleaseAppConfig = tasks.register("verifyReleaseAppConfig") {
+ group = "verification"
+ description = "Verify the production Dart app config against REVOLT_APP_CONFIG_SHA256."
+ doLast {
+ if (ciReleaseSmoke) {
+ logger.lifecycle("Skipping production app-config pin for CI release smoke build")
+ return@doLast
+ }
+
+ val expected = System.getenv("REVOLT_APP_CONFIG_SHA256")?.trim()?.lowercase()
+ if (expected.isNullOrEmpty()) {
+ throw org.gradle.api.GradleException(
+ "REVOLT_APP_CONFIG_SHA256 is required for production release builds"
+ )
+ }
+ if (!Regex("[0-9a-f]{64}").matches(expected)) {
+ throw org.gradle.api.GradleException(
+ "REVOLT_APP_CONFIG_SHA256 must be exactly 64 lowercase hexadecimal characters"
+ )
+ }
+ if (!appConfigFile.isFile) {
+ throw org.gradle.api.GradleException(
+ "Production app config is missing: ${appConfigFile.absolutePath}"
+ )
+ }
+
+ val configText = appConfigFile.readText()
+ val templateMarkers = listOf(
+ "'0.0.0.0'",
+ "YOUR_DOMAIN",
+ "YOUR_GITHUB_USERNAME",
+ "YOUR_REPO_NAME",
+ "ca-app-pub-0000000000000000/0000000000",
+ )
+ val remainingMarker = templateMarkers.firstOrNull(configText::contains)
+ if (remainingMarker != null) {
+ throw org.gradle.api.GradleException(
+ "Production app config still contains template marker: $remainingMarker"
+ )
+ }
+
+ val actual = sha256Hex(appConfigFile)
+ if (actual != expected) {
+ throw org.gradle.api.GradleException(
+ "Production app config SHA-256 mismatch: expected $expected, got $actual"
+ )
+ }
+ }
+ }
+
+ tasks.matching { it.name == "preReleaseBuild" }.configureEach {
+ dependsOn(verifyReleaseAppConfig)
+ }
+
defaultConfig {
applicationId = "com.paladinvpn.app"
minSdk = flutter.minSdkVersion
@@ -176,7 +118,11 @@ android {
buildTypes {
release {
- signingConfig = signingConfigs.getByName("release")
+ signingConfig = if (ciReleaseSmoke) {
+ signingConfigs.getByName("debug")
+ } else {
+ signingConfigs.getByName("release")
+ }
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
index 0d60756..6ab7ded 100644
--- a/android/app/proguard-rules.pro
+++ b/android/app/proguard-rules.pro
@@ -1,4 +1,4 @@
-# PaladinVPN ProGuard / R8 Rules
+# ReVoltVPN ProGuard / R8 Rules
# ==============================================================================
# These rules prevent R8 from stripping classes and methods that are accessed
# via JNI (Java Native Interface) or reflection, which would cause
@@ -11,9 +11,8 @@
-keep class xray.** { *; }
# ── Flutter / Platform Channels ───────────────────────────────────────────────
+# io.flutter.** already includes embedding and plugin subpackages.
-keep class io.flutter.** { *; }
--keep class io.flutter.embedding.** { *; }
--keep class io.flutter.plugin.** { *; }
# ── Google Mobile Ads (AdMob) ─────────────────────────────────────────────────
-keep class com.google.android.gms.ads.** { *; }
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 1476797..0b45736 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -10,9 +10,6 @@
-
-
when (call.method) {
- // Doze and App Standby stop background services of apps that
- // are not exempt. A VPN has to be exempt to survive the screen
- // going off, so surface the real state rather than guessing.
"isIgnoringBatteryOptimizations" ->
result.success(isIgnoringBatteryOptimizations())
"requestIgnoreBatteryOptimizations" ->
result.success(requestIgnoreBatteryOptimizations())
- "setSessionExpiry" -> {
- val expiresAtMs = call.argument("expiresAtMs")?.toLong() ?: 0L
- if (expiresAtMs > 0) {
- sendSessionExpiryToDaemon(expiresAtMs)
- }
- result.success(null)
- }
- // Is our VPN runtime actually alive? The service lives in
- // :RunSoLibXrayDaemon, a separate process, so its statics are
- // NOT visible here -- this is the only honest check.
- "isVpnRuntimeAlive" -> result.success(isVpnRuntimeAlive())
else -> result.notImplemented()
}
}
@@ -118,7 +101,6 @@ class MainActivity : FlutterActivity() {
super.onDestroy()
}
- /** True when the OS has exempted us from Doze / App Standby. */
private fun isIgnoringBatteryOptimizations(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
return try {
@@ -129,11 +111,6 @@ class MainActivity : FlutterActivity() {
}
}
- /**
- * Opens the system exemption prompt. Android shows this as a single dialog;
- * it cannot be granted silently, so the caller must treat a false return as
- * "ask again later" rather than a denial.
- */
private fun requestIgnoreBatteryOptimizations(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
if (isIgnoringBatteryOptimizations()) return true
@@ -146,7 +123,6 @@ class MainActivity : FlutterActivity() {
)
true
} catch (_: Exception) {
- // Some OEM builds hide the direct intent; fall back to the list.
try {
startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
true
@@ -156,58 +132,6 @@ class MainActivity : FlutterActivity() {
}
}
- /** Pushes the session-expiry deadline to the VPN service process. */
- private fun sendSessionExpiryToDaemon(expiresAtMs: Long) {
- try {
- val intent = Intent(this, XrayVPNService::class.java)
- intent.putExtra("SESSION_EXPIRES_AT_MS", expiresAtMs)
- startService(intent)
- } catch (_: Exception) {
- // Daemon not reachable; its persisted expiry still applies.
- }
- }
-
- /**
- * Whether the VPN runtime is genuinely alive.
- *
- * The Xray service runs in :RunSoLibXrayDaemon, a separate process, so its
- * statics (AppConfigs.V2RAY_CONFIG, V2RAY_STATE) are NOT visible from here --
- * reading them yields a fresh, empty copy. getRunningAppProcesses still
- * returns our *own* processes, which makes it the one reliable signal.
- */
- private fun isVpnRuntimeAlive(): Map {
- var processAlive = false
- try {
- val am = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
- processAlive = am.runningAppProcesses
- ?.any { it.processName.endsWith(VPN_PROCESS_SUFFIX) } == true
- } catch (_: Exception) {
- }
-
- var vpnTransport = false
- try {
- val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
- vpnTransport = cm.allNetworks.any { network ->
- cm.getNetworkCapabilities(network)
- ?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true
- }
- } catch (_: Exception) {
- }
-
- return mapOf(
- "processAlive" to processAlive,
- "vpnTransport" to vpnTransport,
- )
- }
-
- /** Removes a notification left behind by a runtime that is no longer alive. */
- private fun cancelVpnNotification() {
- try {
- NotificationManagerCompat.from(this).cancel(NOTIFICATION_ID)
- } catch (_: Exception) {
- }
- }
-
private fun updateVpnNotification(title: String, text: String, actionLabel: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) !=
@@ -216,15 +140,6 @@ class MainActivity : FlutterActivity() {
return
}
- // The foreground notification (id 1) is owned by XrayVPNService via
- // startForeground. Posting to it from this process while the service is
- // gone leaves a frozen countdown nothing will ever update again.
- val alive = isVpnRuntimeAlive()["processAlive"] == true
- if (!alive) {
- cancelVpnNotification()
- return
- }
-
ensureNotificationChannel()
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
@@ -234,17 +149,28 @@ class MainActivity : FlutterActivity() {
}
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
- launchIntent?.action = "FROM_DISCONNECT_BTN"
launchIntent?.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or
Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_NEW_TASK
- val stopIntent = Intent(this, XrayVPNService::class.java)
- stopIntent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE)
- val stopPendingIntent = PendingIntent.getService(this, 0, stopIntent, flags)
+ // The VPN service owns the authoritative Disconnect PendingIntent and
+ // embeds the current runtime-generation token in it. Never recreate an
+ // unscoped STOP intent from the UI process: a stale notification could
+ // otherwise stop a newer tunnel. Reuse the live service action when the
+ // platform exposes it; on older Android versions omit the action rather
+ // than weaken generation scoping.
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val serviceStopAction = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ manager.activeNotifications
+ .firstOrNull { it.id == NOTIFICATION_ID }
+ ?.notification
+ ?.actions
+ ?.firstOrNull()
+ ?.actionIntent
+ } else {
+ null
+ }
- // Public lock-screen preview: keep the VPN status visible but hide the
- // session countdown/speed so usage metadata never reaches the lock screen.
val publicVersion = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.notification_status_icon)
.setContentTitle("Revolt VPN")
@@ -255,7 +181,6 @@ class MainActivity : FlutterActivity() {
.setSmallIcon(R.drawable.notification_status_icon)
.setContentTitle(title)
.setContentText(text)
- .addAction(0, actionLabel, stopPendingIntent)
.setColorized(true)
.setColor(0xFF0D1117.toInt())
.setOngoing(true)
@@ -267,6 +192,10 @@ class MainActivity : FlutterActivity() {
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setPublicVersion(publicVersion)
+ if (serviceStopAction != null) {
+ builder.addAction(0, actionLabel, serviceStopAction)
+ }
+
if (launchIntent != null) {
builder.setContentIntent(PendingIntent.getActivity(this, 0, launchIntent, flags))
}
@@ -473,7 +402,6 @@ class MainActivity : FlutterActivity() {
companion object {
private const val NOTIFICATION_ID = 1
- private const val VPN_PROCESS_SUFFIX = ":RunSoLibXrayDaemon"
- private const val NOTIFICATION_CHANNEL_ID = "XRAY_SERVICE_CHANNEL"
+ private const val NOTIFICATION_CHANNEL_ID = "REVOLT_VPN_SERVICE"
}
}
diff --git a/android/gradle/verification-metadata.xml b/android/gradle/verification-metadata.xml
new file mode 100644
index 0000000..590f03d
--- /dev/null
+++ b/android/gradle/verification-metadata.xml
@@ -0,0 +1,8845 @@
+
+
+
+ true
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..1b33c55
Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/gradlew b/android/gradlew
new file mode 100755
index 0000000..0f14772
--- /dev/null
+++ b/android/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/android/gradlew.bat b/android/gradlew.bat
new file mode 100644
index 0000000..8de1053
--- /dev/null
+++ b/android/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/lib/logic/notification_service.dart b/lib/logic/notification_service.dart
index d9e9f8c..88f46f1 100644
--- a/lib/logic/notification_service.dart
+++ b/lib/logic/notification_service.dart
@@ -16,16 +16,17 @@ class NotificationService {
/// Sets the notification's content text to [text].
///
/// The native side rebuilds the whole notification, so skip the round-trip
- /// when the text has not changed.
+ /// when the text has not changed. A failed native update is intentionally
+ /// not cached so the next tick can retry it.
static Future updateTimer(String text) async {
if (text == _lastText) return;
- _lastText = text;
try {
await _channel.invokeMethod('updateNotificationText', {
'title': 'Revolt VPN',
'text': text,
'disconnectLabel': 'Disconnect',
});
+ _lastText = text;
} catch (_) {
// Cosmetic.
}
diff --git a/lib/logic/session_timer.dart b/lib/logic/session_timer.dart
index 4625647..4164fb3 100644
--- a/lib/logic/session_timer.dart
+++ b/lib/logic/session_timer.dart
@@ -7,13 +7,11 @@ import 'package:revoltvpn/logic/app_config.dart';
import 'package:revoltvpn/logic/crypto_service.dart';
import 'package:revoltvpn/logic/hivemind_service.dart';
import 'package:revoltvpn/logic/notification_service.dart';
-import 'package:revoltvpn/logic/power_settings.dart';
import 'package:revoltvpn/logic/vpn_connection.dart';
class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
Timer? _timer;
int _tickCount = 0;
- int _lastPushedExpiryAtMs = 0;
bool _appBackgrounded = false;
final VpnConnection vpnConnection;
@@ -25,7 +23,10 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
bool _hasSyncedOnce = false;
int _consecutiveFailures = 0;
bool _isDisconnecting = false;
- bool _syncInProgress = false;
+ int? _syncEpochInProgress;
+ Completer? _syncCompletion;
+ int _sessionEpoch = 0;
+
bool _supportRewardClaimed = false;
bool _supportRewardStateLoaded = false;
int _supportStateEpoch = 0;
@@ -38,25 +39,21 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
static const int _maxOfflineSeconds = 120;
int _offlineSeconds = 0;
- // The countdown ticks locally every second; these only drive the slow
- // server heartbeat that catches server-side changes (support-ad extension,
- // 10 GB cap, session-ended). Keeping it slow avoids a chatty /session/status
- // beacon (see internal/LAUNCH_READINESS.md "cut the beacon rate").
- static const int _pollIntervalSeconds = 60;
- static const int _backgroundPollIntervalSeconds = 60;
+ static const int _pollIntervalSeconds = 5;
+ static const int _backgroundPollIntervalSeconds = 30;
int _lastUsedBytes = 0;
- DateTime? _lastSuccessfulSyncAt;
+ Stopwatch? _sinceLastSuccessfulSync;
double _currentSpeedKBps = 0.0;
SessionTimer({required this.vpnConnection}) {
WidgetsBinding.instance.addObserver(this);
vpnConnection.addListener(_onVpnConnectionChanged);
unawaited(_loadSupportRewardState());
- // This provider is built lazily, so the tunnel may already be up and
- // VpnConnection may already have fired its change before we attached.
- // A listener does not replay history -- evaluate the current state once.
- // Deferred so we never notify during the build that created us.
+
+ // VpnConnection is eager and can report an already-running native tunnel
+ // before this provider is created. ChangeNotifier listeners do not replay
+ // old events, so always evaluate the current state once after construction.
unawaited(Future.microtask(_onVpnConnectionChanged));
}
@@ -113,11 +110,8 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
}
void _onVpnConnectionChanged() {
- // A live tunnel with a stopped clock is always wrong, however we got here:
- // adopting a tunnel that outlived the UI process, recovering from a blip, or
- // attaching late to one that was already up. Previously this required either
- // adoptedRunningRuntime or _hasSyncedOnce, both false on a fresh process, so
- // the clock stayed at 00:00:00 and the notification countdown froze with it.
+ // A live tunnel with a stopped clock is always wrong, whether this process
+ // adopted an existing runtime, attached late, or recovered from a blip.
if (vpnConnection.status == VpnStatus.connected &&
!isRunning &&
!_isDisconnecting) {
@@ -130,7 +124,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
if (!hadActiveSession || _isDisconnecting) return;
if (vpnConnection.status == VpnStatus.error) {
- _stopForVpnFailure('VPN entered error state');
+ unawaited(_doDisconnect('VPN entered error state'));
return;
}
@@ -139,33 +133,16 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
}
}
- void _stopForVpnFailure(String reason) {
- if (_isDisconnecting) return;
- _isDisconnecting = true;
- debugPrint('[Timer] Stopping after VPN failure: $reason');
-
- _timer?.cancel();
- _timer = null;
- _currentSpeedKBps = 0.0;
- _remainingSeconds = 0;
- _remainingAtLastSync = 0;
- _hasSyncedOnce = false;
- _lastSuccessfulSyncAt = null;
- _consecutiveFailures = 0;
- _offlineSeconds = 0;
- NotificationService.reset();
- notifyListeners();
- }
-
Future start() async {
+ _sessionEpoch++;
_remainingSeconds = 0;
_remainingAtLastSync = 0;
_usedBytes = 0;
_lastUsedBytes = 0;
- _lastSuccessfulSyncAt = null;
+ _sinceLastSuccessfulSync?.stop();
+ _sinceLastSuccessfulSync = null;
_currentSpeedKBps = 0.0;
_tickCount = 0;
- _lastPushedExpiryAtMs = 0;
_hasSyncedOnce = false;
_consecutiveFailures = 0;
_offlineSeconds = 0;
@@ -188,7 +165,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
void _tick(Timer t) {
if (_isDisconnecting) return;
- if (_hasSyncedOnce && _consecutiveFailures < _maxConsecutiveFailures) {
+ if (_hasSyncedOnce) {
_reconcileElapsedTime();
}
@@ -220,9 +197,23 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
}
}
- /// Force an immediate server sync (e.g. after a support-ad extension so the
- /// countdown reflects the new expiry without waiting for the next heartbeat).
- Future syncNow() => _syncWithHivemind();
+ /// Force a control-plane refresh after an event that changes the session
+ /// server-side. If a periodic sync is already in flight, wait for it and
+ /// issue a fresh request so the event cannot be hidden behind the busy guard.
+ Future syncNow() async {
+ final requestedEpoch = _sessionEpoch;
+ while (requestedEpoch == _sessionEpoch && !_isDisconnecting) {
+ final active = _syncEpochInProgress == requestedEpoch
+ ? _syncCompletion
+ : null;
+ if (active != null) {
+ await active.future;
+ continue;
+ }
+ await _syncWithHivemind();
+ return;
+ }
+ }
Future disconnect({String reason = 'User requested'}) async {
await _doDisconnect(reason);
@@ -231,6 +222,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
Future _doDisconnect(String reason) async {
if (_isDisconnecting) return;
_isDisconnecting = true;
+ _sessionEpoch++;
debugPrint('[Timer] Disconnecting: $reason');
_timer?.cancel();
@@ -239,13 +231,13 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
_remainingSeconds = 0;
_remainingAtLastSync = 0;
_hasSyncedOnce = false;
- _lastSuccessfulSyncAt = null;
+ _sinceLastSuccessfulSync?.stop();
+ _sinceLastSuccessfulSync = null;
NotificationService.reset();
notifyListeners();
await vpnConnection.disconnect();
- if (!_isDisconnecting) return;
_isDisconnecting = false;
notifyListeners();
}
@@ -257,20 +249,28 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
}
Future _syncWithHivemind() async {
- if (_isDisconnecting || _syncInProgress) return;
- _syncInProgress = true;
+ if (_isDisconnecting) return;
+ final epoch = _sessionEpoch;
+ if (_syncEpochInProgress == epoch) return;
+
+ final completion = Completer();
+ _syncCompletion = completion;
+ _syncEpochInProgress = epoch;
+
try {
final deviceId = await CryptoService.getDeviceId();
+ if (epoch != _sessionEpoch || _isDisconnecting) return;
+
final base = Uri.parse('${AppConfig.hivemindApiPublic}/session/status');
final url = base.replace(queryParameters: {'device_id': deviceId});
- final response = await HivemindService.directGet(url);
- if (_isDisconnecting) return;
+ final response = await HivemindService.controlGet(url);
+ if (epoch != _sessionEpoch || _isDisconnecting) return;
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
if (decoded is! Map) {
debugPrint('[Timer] Invalid session payload type.');
- _markSyncFailure();
+ _markSyncFailure(epoch);
return;
}
final data = decoded;
@@ -278,7 +278,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
final activeValue = data['active'];
if (activeValue is! bool) {
debugPrint('[Timer] Session payload missing boolean active state.');
- _markSyncFailure();
+ _markSyncFailure(epoch);
return;
}
if (!activeValue) {
@@ -291,61 +291,66 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
!expiresValue.isFinite ||
expiresValue < 0) {
debugPrint('[Timer] Session payload has invalid expiry.');
- _markSyncFailure();
+ _markSyncFailure(epoch);
return;
}
+ final elapsedMs = _sinceLastSuccessfulSync?.elapsedMilliseconds;
_remainingAtLastSync = expiresValue.toInt();
_remainingSeconds = _remainingAtLastSync;
- // Push the absolute deadline to the VPN service so it can tear the
- // tunnel down on its own clock, even if this UI process is gone.
- final expiresAtMs =
- DateTime.now().millisecondsSinceEpoch + _remainingSeconds * 1000;
- if ((expiresAtMs - _lastPushedExpiryAtMs).abs() > 5000) {
- _lastPushedExpiryAtMs = expiresAtMs;
- unawaited(PowerSettings.setSessionExpiry(expiresAtMs));
- }
_usedBytes = _readNonNegativeInt(data['used_bytes'], _usedBytes);
- final now = DateTime.now();
- final previousSyncAt = _lastSuccessfulSyncAt;
final deltaBytes = _usedBytes - _lastUsedBytes;
- if (_hasSyncedOnce && previousSyncAt != null) {
- final elapsedMs = now.difference(previousSyncAt).inMilliseconds;
- if (deltaBytes > 0 && elapsedMs > 0) {
- _currentSpeedKBps = (deltaBytes / (elapsedMs / 1000.0)) / 1000.0;
- } else {
- _currentSpeedKBps = 0.0;
- }
+ if (_hasSyncedOnce && elapsedMs != null && elapsedMs > 0) {
+ _currentSpeedKBps = deltaBytes > 0
+ ? (deltaBytes / (elapsedMs / 1000.0)) / 1000.0
+ : 0.0;
} else {
_currentSpeedKBps = 0.0;
}
_lastUsedBytes = _usedBytes;
- _lastSuccessfulSyncAt = now;
+ _sinceLastSuccessfulSync?.stop();
+ _sinceLastSuccessfulSync = Stopwatch()..start();
if (data['cap_exhausted'] == true) {
await _doDisconnect('Data cap reached');
return;
}
+ if (epoch != _sessionEpoch || _isDisconnecting) return;
+ try {
+ await vpnConnection.setNativeSessionDeadline(_remainingAtLastSync);
+ } catch (e) {
+ debugPrint('[Timer] Failed to arm native session deadline: $e');
+ await _doDisconnect('Native session deadline unavailable');
+ return;
+ }
+ if (epoch != _sessionEpoch || _isDisconnecting) return;
_consecutiveFailures = 0;
_offlineSeconds = 0;
_hasSyncedOnce = true;
notifyListeners();
} else {
- _markSyncFailure();
+ _markSyncFailure(epoch);
}
} catch (e) {
- if (!_isDisconnecting) {
+ if (epoch == _sessionEpoch && !_isDisconnecting) {
debugPrint('Hivemind sync error: $e');
- _markSyncFailure();
+ _markSyncFailure(epoch);
}
} finally {
- _syncInProgress = false;
+ if (_syncEpochInProgress == epoch) {
+ _syncEpochInProgress = null;
+ }
+ if (identical(_syncCompletion, completion)) {
+ _syncCompletion = null;
+ }
+ if (!completion.isCompleted) completion.complete();
}
}
- void _markSyncFailure() {
+ void _markSyncFailure(int epoch) {
+ if (epoch != _sessionEpoch || _isDisconnecting) return;
_consecutiveFailures++;
if (_consecutiveFailures >= _maxConsecutiveFailures) {
notifyListeners();
@@ -353,10 +358,10 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
}
void _reconcileElapsedTime() {
- final syncedAt = _lastSuccessfulSyncAt;
- if (!_hasSyncedOnce || syncedAt == null) return;
+ final clock = _sinceLastSuccessfulSync;
+ if (!_hasSyncedOnce || clock == null) return;
- final elapsed = DateTime.now().difference(syncedAt).inSeconds;
+ final elapsed = clock.elapsed.inSeconds;
_remainingSeconds =
(_remainingAtLastSync - elapsed).clamp(0, 1 << 31).toInt();
}
@@ -396,9 +401,15 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
@override
void dispose() {
+ // Invalidate every asynchronous generation before removing listeners so
+ // late storage/network completions cannot notify a disposed notifier.
+ _sessionEpoch++;
+ _supportStateEpoch++;
+ _isDisconnecting = true;
WidgetsBinding.instance.removeObserver(this);
vpnConnection.removeListener(_onVpnConnectionChanged);
_timer?.cancel();
+ _sinceLastSuccessfulSync?.stop();
super.dispose();
}
}
diff --git a/lib/logic/vpn_connection.dart b/lib/logic/vpn_connection.dart
index 80d7c83..4147fe6 100644
--- a/lib/logic/vpn_connection.dart
+++ b/lib/logic/vpn_connection.dart
@@ -1,12 +1,12 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
import 'package:flutter_vless/flutter_vless.dart';
import 'package:revoltvpn/logic/connection_settings.dart';
import 'package:revoltvpn/logic/hivemind_service.dart';
import 'package:revoltvpn/logic/local_socks_tester.dart';
import 'package:revoltvpn/logic/network_monitor.dart';
-import 'package:revoltvpn/logic/power_settings.dart';
import 'package:revoltvpn/logic/secure_socks_session.dart';
enum VpnStatus {
@@ -18,7 +18,12 @@ enum VpnStatus {
}
class VpnConnection extends ChangeNotifier {
- bool _cancelled = false;
+ static const MethodChannel _nativeControl = MethodChannel('flutter_vless');
+
+ int _connectEpoch = 0;
+ bool _suppressNativeConnect = false;
+ bool _disposed = false;
+
VpnStatus _status = VpnStatus.disconnected;
VpnStatus get status => _status;
@@ -45,6 +50,7 @@ class VpnConnection extends ChangeNotifier {
SecureSocksSession? get activeSocksSession => _lastSecureSocks;
bool get canTestActiveLocalSocks =>
+ !_disposed &&
_status == VpnStatus.connected &&
_activeMode == ConnectionMode.proxy &&
_lastSecureSocks != null;
@@ -94,9 +100,10 @@ class VpnConnection extends ChangeNotifier {
}
Future _startEngine() async {
- if (kIsWeb) return;
+ if (kIsWeb || _disposed) return;
await ConnectionSettings.initialize();
+ if (_disposed) return;
_activeMode = ConnectionSettings.mode;
_vless = FlutterVless(
@@ -115,13 +122,16 @@ class VpnConnection extends ChangeNotifier {
notificationIconResourceType: 'drawable',
notificationIconResourceName: 'notification_status_icon',
);
+ if (_disposed) return;
_initialized = true;
} catch (e) {
debugPrint('[VPN] VLESS init error (expected on emulator): $e');
}
+ if (_disposed) return;
_networkSubscription = NetworkMonitor.changes.listen(
(snapshot) {
+ if (_disposed) return;
// ConnectivityManager is informational only. Restarting from every
// callback caused the old VPN-created-network reconnect loop.
_networkTransport = snapshot.transport;
@@ -139,32 +149,10 @@ class VpnConnection extends ChangeNotifier {
try {
final coreVersion = await _vless.getCoreVersion();
- debugPrint('[VPN] Xray core version: $coreVersion');
- } catch (_) {}
-
- await _adoptRunningRuntime();
- }
-
- /// Reflect a tunnel that is already up when the UI starts.
- ///
- /// This asks the OS whether our VPN service process is alive, rather than
- /// probing a local SOCKS port. The old probe called getConnectedServerDelay(),
- /// which reads AppConfigs.V2RAY_CONFIG?.LOCAL_SOCKS5_PORT ?: 10807 — but
- /// AppConfigs lives in the VPN's own process, so from here it is always null
- /// and the port always fell back to 10807. That was correct only while the
- /// SOCKS port was fixed; per-session ephemeral ports made it permanently
- /// wrong, so the app reported "connected" against a dead tunnel.
- Future _adoptRunningRuntime() async {
- if (kIsWeb) return;
- try {
- final state = await PowerSettings.runtimeState();
- if (state.aliveFor(tunMode: _activeMode == ConnectionMode.tun)) {
- _adoptedRunningRuntime = true;
- _setStatus(VpnStatus.connected, _connectedLabel);
+ if (!_disposed) {
+ debugPrint('[VPN] Xray core version: $coreVersion');
}
- } catch (e) {
- debugPrint('[VPN] Runtime liveness check failed: $e');
- }
+ } catch (_) {}
}
String get _connectedLabel {
@@ -176,9 +164,15 @@ class VpnConnection extends ChangeNotifier {
}
}
+ bool _isCurrentConnect(int epoch) =>
+ !_disposed && epoch == _connectEpoch && !_userDisconnecting;
+
void _mapStatus(VlessStatus status) {
+ if (_disposed) return;
switch (status.connectionState) {
case VlessConnectionState.connected:
+ if (_suppressNativeConnect || _userDisconnecting) return;
+ if (_connectEpoch == 0) _adoptedRunningRuntime = true;
_setStatus(VpnStatus.connected, _connectedLabel);
break;
@@ -188,6 +182,7 @@ class VpnConnection extends ChangeNotifier {
break;
case VlessConnectionState.connecting:
+ if (_suppressNativeConnect || _userDisconnecting) return;
_setStatus(VpnStatus.connecting, 'Establishing tunnel…');
break;
@@ -196,6 +191,7 @@ class VpnConnection extends ChangeNotifier {
break;
case VlessConnectionState.unknown:
+ if (_suppressNativeConnect || _userDisconnecting) return;
if (_status != VpnStatus.connected &&
_status != VpnStatus.disconnected) {
_setStatus(VpnStatus.error, 'Connection failed');
@@ -205,27 +201,34 @@ class VpnConnection extends ChangeNotifier {
}
Future connect({bool skipAdBypass = false}) async {
- if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
+ if (_disposed ||
+ _userDisconnecting ||
+ _status == VpnStatus.connected ||
+ _status == VpnStatus.connecting ||
+ _status == VpnStatus.disconnecting) {
return false;
}
- _cancelled = false;
+ final connectEpoch = ++_connectEpoch;
+ _suppressNativeConnect = false;
_userDisconnecting = false;
await ConnectionSettings.initialize();
+ if (!_isCurrentConnect(connectEpoch)) return false;
_activeMode = ConnectionSettings.mode;
if (!kIsWeb && !_initialized) {
+ _suppressNativeConnect = true;
_errorMessage = 'VPN service unavailable.';
_setStatus(VpnStatus.error, 'Service unavailable');
return false;
}
- // TUN needs the Android VPN permission; SOCKS5 proxy mode runs a local
- // authenticated proxy without a VpnService, so no VPN permission is needed.
if (!kIsWeb && _activeMode == ConnectionMode.tun) {
final ok = await _vless.requestPermission();
+ if (!_isCurrentConnect(connectEpoch)) return false;
if (!ok) {
+ _suppressNativeConnect = true;
_errorMessage = 'VPN permission denied.';
_setStatus(VpnStatus.error, 'Permission required');
return false;
@@ -241,6 +244,7 @@ class VpnConnection extends ChangeNotifier {
if (kIsWeb) {
await Future.delayed(const Duration(seconds: 1));
+ if (!_isCurrentConnect(connectEpoch)) return false;
_setStatus(VpnStatus.connected, 'Secured (dev mode)');
return true;
}
@@ -252,6 +256,7 @@ class VpnConnection extends ChangeNotifier {
realUrl = await HivemindService.fetchConfigDirectly(
skipAdBypass: skipAdBypass,
onAttempt: (attempt, total) {
+ if (!_isCurrentConnect(connectEpoch)) return;
_setStatus(
VpnStatus.connecting,
'Contacting server ($attempt/$total)…',
@@ -259,9 +264,11 @@ class VpnConnection extends ChangeNotifier {
},
);
} catch (e) {
+ if (!_isCurrentConnect(connectEpoch)) return false;
debugPrint('[VPN] Config fetch error: $e');
final raw = e.toString().replaceAll('Exception: ', '');
if (raw.contains('Cancelled')) return false;
+ _suppressNativeConnect = true;
if (raw.contains('timed out') || raw.contains('Session not activated')) {
_errorMessage =
'The server did not respond in time.\nCheck your connection and try again.';
@@ -273,11 +280,7 @@ class VpnConnection extends ChangeNotifier {
return false;
}
- if (_cancelled) {
- _setStatus(VpnStatus.disconnected, 'Tap to connect');
- return false;
- }
-
+ if (!_isCurrentConnect(connectEpoch)) return false;
_setStatus(VpnStatus.connecting, 'Starting secure route…');
try {
@@ -285,6 +288,7 @@ class VpnConnection extends ChangeNotifier {
final baseConfig = parsed.getFullConfiguration();
final remark = parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN';
final secureSocks = await SecureSocksSession.create(baseConfig);
+ if (!_isCurrentConnect(connectEpoch)) return false;
final verifyLocalSocks = _activeMode == ConnectionMode.proxy;
await _startRuntime(
@@ -292,26 +296,45 @@ class VpnConnection extends ChangeNotifier {
remark: remark,
proxyOnly: verifyLocalSocks,
);
+ if (!_isCurrentConnect(connectEpoch)) return false;
_setStatus(
VpnStatus.connecting,
- verifyLocalSocks ? 'Waiting for local SOCKS5…' : 'Waiting for VPN interface…',
+ verifyLocalSocks
+ ? 'Waiting for local SOCKS5…'
+ : 'Waiting for VPN interface…',
);
- if (!await _waitForNativeConnected()) {
+ if (!await _waitForNativeConnected(connectEpoch)) {
throw StateError('VPN runtime did not report CONNECTED');
}
if (verifyLocalSocks) {
+ if (!_isCurrentConnect(connectEpoch)) return false;
_setStatus(VpnStatus.connecting, 'Checking Local SOCKS5…');
- if (!await _waitForLocalSocksListener(secureSocks)) {
+ if (!await _waitForLocalSocksListener(
+ secureSocks,
+ connectEpoch,
+ )) {
throw StateError('Local SOCKS5 listener did not become ready');
}
}
+ if (!_isCurrentConnect(connectEpoch)) return false;
_lastSecureSocks = secureSocks;
} catch (e) {
+ if (!_isCurrentConnect(connectEpoch)) return false;
debugPrint('[VPN] Tunnel start error: $e');
- await _stopRuntime();
+ _suppressNativeConnect = true;
+ try {
+ await _stopRuntime();
+ } catch (stopError) {
+ debugPrint('[VPN] Runtime cleanup failed after start error: $stopError');
+ if (!_isCurrentConnect(connectEpoch)) return false;
+ _errorMessage = 'VPN failed to shut down cleanly.\nPlease restart the app.';
+ _setStatus(VpnStatus.error, 'Shutdown failed');
+ return false;
+ }
+ if (!_isCurrentConnect(connectEpoch)) return false;
_clearRuntimeSnapshot();
_errorMessage = _activeMode == ConnectionMode.proxy
? 'SOCKS5 gateway failed to start.\nTry reconnecting.'
@@ -320,6 +343,7 @@ class VpnConnection extends ChangeNotifier {
return false;
}
+ if (!_isCurrentConnect(connectEpoch)) return false;
_setStatus(VpnStatus.connected, _connectedLabel);
return true;
}
@@ -336,22 +360,20 @@ class VpnConnection extends ChangeNotifier {
);
}
- Future _waitForLocalSocksListener([
- SecureSocksSession? session,
- ]) async {
- final active = session ?? _lastSecureSocks;
- if (active == null) return false;
-
+ Future _waitForLocalSocksListener(
+ SecureSocksSession session,
+ int connectEpoch,
+ ) async {
for (var attempt = 0; attempt < 4; attempt++) {
+ if (!_isCurrentConnect(connectEpoch)) return false;
final result = await LocalSocksTester.testListener(
host: '127.0.0.1',
- port: active.port,
- username: active.username,
- password: active.password,
+ port: session.port,
+ username: session.username,
+ password: session.password,
);
- if (result.ok) {
- return true;
- }
+ if (!_isCurrentConnect(connectEpoch)) return false;
+ if (result.ok) return true;
if (attempt < 3) {
await Future.delayed(const Duration(milliseconds: 250));
}
@@ -359,54 +381,59 @@ class VpnConnection extends ChangeNotifier {
return false;
}
- Future _waitForNativeConnected() async {
- // startVless() queues the Android foreground service. Wait for the native
- // service/core broadcast rather than treating MethodChannel completion as
- // proof that a TUN interface was established.
+ Future _waitForNativeConnected(int connectEpoch) async {
for (var attempt = 0; attempt < 24; attempt++) {
+ if (!_isCurrentConnect(connectEpoch)) return false;
if (_status == VpnStatus.connected) return true;
- if (_status == VpnStatus.error) return false;
- if (_cancelled || _userDisconnecting) return false;
+ if (_status == VpnStatus.error || _status == VpnStatus.disconnected) {
+ return false;
+ }
await Future.delayed(const Duration(milliseconds: 250));
}
- return _status == VpnStatus.connected;
+ return _isCurrentConnect(connectEpoch) &&
+ _status == VpnStatus.connected;
}
Future _stopRuntime() async {
- try {
- await _vless.stopVless().timeout(const Duration(seconds: 5));
- } catch (_) {}
+ await _vless.stopVless().timeout(const Duration(seconds: 8));
+ }
+
+ Future setNativeSessionDeadline(int remainingSeconds) async {
+ if (kIsWeb) return;
+ if (_disposed) throw StateError('VPN connection has been disposed');
+ if (!_initialized) throw StateError('VPN native service is not initialized');
+ if (remainingSeconds < 0) {
+ throw ArgumentError.value(remainingSeconds, 'remainingSeconds');
+ }
+ await _nativeControl.invokeMethod(
+ 'setSessionDeadline',
+ {'remainingSeconds': remainingSeconds},
+ );
}
Future disconnect() async {
- _cancelled = true;
+ if (_disposed || _status == VpnStatus.disconnecting) return;
+
+ _connectEpoch++;
+ _suppressNativeConnect = true;
_userDisconnecting = true;
HivemindService.cancel();
- if (_status == VpnStatus.disconnected ||
- _status == VpnStatus.disconnecting) {
- _clearRuntimeSnapshot();
- _userDisconnecting = false;
- return;
- }
-
_setStatus(VpnStatus.disconnecting, 'Tearing down…');
if (kIsWeb) {
await Future.delayed(const Duration(milliseconds: 500));
+ if (_disposed) return;
_clearRuntimeSnapshot();
_setStatus(VpnStatus.disconnected, 'Tap to connect');
_userDisconnecting = false;
return;
}
- bool timedOut = false;
try {
- await _vless.stopVless().timeout(
- const Duration(seconds: 5),
- onTimeout: () => timedOut = true,
- );
+ await _vless.stopVless().timeout(const Duration(seconds: 8));
} catch (e) {
+ if (_disposed) return;
debugPrint('[VPN] VLESS stop error: $e');
_errorMessage = 'VPN shutdown error.\nPlease restart the app.';
_clearRuntimeSnapshot();
@@ -415,15 +442,7 @@ class VpnConnection extends ChangeNotifier {
return;
}
- if (timedOut) {
- debugPrint('[VPN] stopVless() timed out after 5 s.');
- _errorMessage = 'VPN did not shut down cleanly.\nPlease restart the app.';
- _clearRuntimeSnapshot();
- _setStatus(VpnStatus.error, 'Shutdown failed');
- _userDisconnecting = false;
- return;
- }
-
+ if (_disposed) return;
_errorMessage = null;
_clearRuntimeSnapshot();
_setStatus(VpnStatus.disconnected, 'Tap to connect');
@@ -436,21 +455,27 @@ class VpnConnection extends ChangeNotifier {
}
void _setStatus(VpnStatus status, String message) {
+ if (_disposed) return;
_status = status;
_statusMessage = message;
notifyListeners();
}
Future _checkHealth() async {
- // A live tunnel already proves reachability; only probe while disconnected
- // so we don't add direct /health traffic while the VPN is active.
- if (_status == VpnStatus.connected) return;
- _serverReachable = await HivemindService.checkHealth();
+ if (_disposed || _status == VpnStatus.connected) return;
+ final reachable = await HivemindService.checkHealth();
+ if (_disposed || _status == VpnStatus.connected) return;
+ _serverReachable = reachable;
notifyListeners();
}
@override
void dispose() {
+ _disposed = true;
+ _connectEpoch++;
+ _suppressNativeConnect = true;
+ _userDisconnecting = true;
+ HivemindService.cancel();
_healthTimer?.cancel();
_networkSubscription?.cancel();
// Provider/UI disposal is not a user disconnect command. Keeping teardown
diff --git a/local_packages/flutter_vless_android-1.1.5/.metadata b/local_packages/flutter_vless_android-1.1.5/.metadata
new file mode 100644
index 0000000..fe7c308
--- /dev/null
+++ b/local_packages/flutter_vless_android-1.1.5/.metadata
@@ -0,0 +1,30 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
+ channel: "stable"
+
+project_type: plugin
+
+# Tracks metadata for the flutter migrate command
+migration:
+ platforms:
+ - platform: root
+ create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
+ base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
+ - platform: android
+ create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
+ base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
+
+ # User provided section
+
+ # List of Local paths (relative to this file) that should be
+ # ignored by the migrate tool.
+ #
+ # Files that are not part of the templates will be ignored by default.
+ unmanaged_files:
+ - 'lib/main.dart'
+ - 'ios/Runner.xcodeproj/project.pbxproj'
diff --git a/local_packages/flutter_vless_android-1.1.5/CHANGELOG.md b/local_packages/flutter_vless_android-1.1.5/CHANGELOG.md
new file mode 100644
index 0000000..d188bf1
--- /dev/null
+++ b/local_packages/flutter_vless_android-1.1.5/CHANGELOG.md
@@ -0,0 +1,49 @@
+## 1.1.5
+
+* Updated the Android runtime dependency to `dev.tfox.fluttervless:xray-android:26.7.11`.
+* Rebuilt Android `libxray.so` binaries for `armeabi-v7a`, `arm64-v8a`, `x86`, and `x86_64` against Xray-core `v26.7.11`.
+* Fixed Android VPN app routing: all configured blocked apps are now applied to `VpnService.Builder` as disallowed applications and bypass the Xray VPN tunnel.
+
+## 1.1.4
+
+* Updated the Android runtime dependency to `dev.tfox.fluttervless:xray-android:26.6.27.1`.
+* Rebuilt Android `libxray.so` binaries for `armeabi-v7a`, `arm64-v8a`, `x86`, and `x86_64` against Xray-core `v26.6.27`.
+
+## 1.1.3
+
+* Updated the Android runtime dependency to `dev.tfox.fluttervless:xray-android:26.6.22`.
+* Rebuilt Android `libxray.so` binaries for `armeabi-v7a`, `arm64-v8a`, `x86`, and `x86_64` against Xray-core `v26.6.22`.
+
+## 1.1.2
+
+* Clarified that Android device and emulator runtime binaries are delivered through the main Maven `xray-android` AAR.
+* Kept the Android runtime dependency on `dev.tfox.fluttervless:xray-android:26.6.1.1`.
+
+## 1.1.1
+
+* Updated the Android device runtime `libxray.so` binaries to Xray-core `v26.6.1`.
+* Kept Android 15+ 16KB page-size linker alignment for the rebuilt ARM binaries.
+* Moved Android runtime binaries and geodata into the Maven `dev.tfox.fluttervless:xray-android:26.6.1.1` AAR dependency.
+* Included device and emulator ABIs (`armeabi-v7a`, `arm64-v8a`, `x86`, `x86_64`) in the main Maven runtime AAR.
+* Removed raw device `jniLibs` and geodata files from the Pub.dev wrapper package; the wrapper now resolves the runtime from Maven Central.
+
+## 1.1.0
+
+* Replaced Android Dart boilerplate with the shared `VlessMethodChannelAdapter`.
+* Removed template `getPlatformVersion` scaffold files.
+* Moved `flutter_lints` to `dev_dependencies`.
+* Removed publish-time dependency overrides from `pubspec.yaml`.
+
+## 1.0.2
+
+* XRay version up
+
+## 1.0.1
+
+* XRay version up
+
+## 1.0.0
+
+* Initial release of the Android implementation for `flutter_vless`.
+* Supports VLESS/VMESS protocols using Xray core.
+* Implements `VlessPlatform` interface.
diff --git a/local_packages/flutter_vless_android-1.1.5/LICENSE b/local_packages/flutter_vless_android-1.1.5/LICENSE
new file mode 100644
index 0000000..b560af6
--- /dev/null
+++ b/local_packages/flutter_vless_android-1.1.5/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024-2026 13FOX Studio / tfox.dev
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/local_packages/flutter_vless_android-1.1.5/NOTICE b/local_packages/flutter_vless_android-1.1.5/NOTICE
new file mode 100644
index 0000000..d687308
--- /dev/null
+++ b/local_packages/flutter_vless_android-1.1.5/NOTICE
@@ -0,0 +1,13 @@
+flutter_vless_android
+
+Copyright (c) 2024-2026 13FOX Studio / tfox.dev.
+
+This package is the Android implementation of the official flutter_vless
+project maintained by 13FOX Studio.
+
+Official project: https://github.com/XIIIFOX/flutter_vless
+Official publisher: https://pub.dev/publishers/tfox.dev
+Website: https://tfox.dev
+
+Redistributions, forks, and derived packages must preserve the copyright notice
+and MIT license notice where required by the license.
diff --git a/local_packages/flutter_vless_android-1.1.5/README.md b/local_packages/flutter_vless_android-1.1.5/README.md
new file mode 100644
index 0000000..5451e26
--- /dev/null
+++ b/local_packages/flutter_vless_android-1.1.5/README.md
@@ -0,0 +1,14 @@
+# flutter_vless_android
+
+The Android implementation of the `flutter_vless` plugin.
+
+## Runtime
+
+Android runtime binaries and geodata are delivered through the Maven Central AAR dependency `dev.tfox.fluttervless:xray-android:26.7.11`. This keeps the Pub.dev package lightweight while preserving the same packaged Xray files in the final Android app.
+
+Do not add raw `android/src/main/jniLibs` or geodata files back to this package. Rebuild runtime files in `android_runtime/xray_android/src/main`, publish the Maven AAR, then publish this wrapper package.
+
+## Emulator Support
+
+The current Maven runtime AAR includes `armeabi-v7a`, `arm64-v8a`, `x86`, and `x86_64`.
+This covers both physical Android devices and Android emulators.
diff --git a/local_packages/flutter_vless_android-1.1.5/UPSTREAM b/local_packages/flutter_vless_android-1.1.5/UPSTREAM
new file mode 100644
index 0000000..6dbe705
--- /dev/null
+++ b/local_packages/flutter_vless_android-1.1.5/UPSTREAM
@@ -0,0 +1,4 @@
+Upstream: https://github.com/XIIIFOX/flutter_vless
+Tag: v1.1.5
+Android package: flutter_vless_android 1.1.5
+ReVolt hardening is pre-applied to this vendored package.
diff --git a/local_packages/flutter_vless_android-1.1.5/analysis_options.yaml b/local_packages/flutter_vless_android-1.1.5/analysis_options.yaml
new file mode 100644
index 0000000..a5744c1
--- /dev/null
+++ b/local_packages/flutter_vless_android-1.1.5/analysis_options.yaml
@@ -0,0 +1,4 @@
+include: package:flutter_lints/flutter.yaml
+
+# Additional information about this file can be found at
+# https://dart.dev/guides/language/analysis-options
diff --git a/local_packages/flutter_vless_android-1.1.5/android/.gitignore b/local_packages/flutter_vless_android-1.1.5/android/.gitignore
new file mode 100644
index 0000000..161bdcd
--- /dev/null
+++ b/local_packages/flutter_vless_android-1.1.5/android/.gitignore
@@ -0,0 +1,9 @@
+*.iml
+.gradle
+/local.properties
+/.idea/workspace.xml
+/.idea/libraries
+.DS_Store
+/build
+/captures
+.cxx
diff --git a/local_packages/flutter_vless_android-1.1.5/android/XRAY_SETUP.md b/local_packages/flutter_vless_android-1.1.5/android/XRAY_SETUP.md
new file mode 100644
index 0000000..db32077
--- /dev/null
+++ b/local_packages/flutter_vless_android-1.1.5/android/XRAY_SETUP.md
@@ -0,0 +1,92 @@
+# Xray & Tun2socks Android Build Guide
+
+This guide explains how to build the native libraries (`libxray.so` and `libtun2socks.so`) for the Flutter Vless plugin.
+
+Android runtime files are stored in `android_runtime/xray_android/src/main` and published as the Maven Central AAR `dev.tfox.fluttervless:xray-android`. They are not stored in the `flutter_vless_android` Pub.dev package.
+
+**Key Features:**
+- ✅ **Android 15+ Support**: Builds with 16KB page size alignment.
+- ✅ **Socket FD Passing**: `tun2socks` is patched to receive the TUN file descriptor via a Unix socket (bypassing Android process restrictions).
+
+## Prerequisites
+
+1. **Go (Golang)**: Version 1.21+ installed.
+2. **Android NDK**: Version r27+ (recommended).
+3. **macOS/Linux**: Build scripts are designed for Unix-like environments.
+
+## 1. Environment Setup
+
+Export the path to your Android NDK.
+
+```bash
+# Example (Adjust path to your NDK installation)
+export ANDROID_NDK_HOME="$HOME/Library/Android/sdk/ndk/27.0.12077973"
+```
+
+## 2. Build Xray (`libxray.so`)
+
+This script builds the Xray core for Android device and emulator architectures (`arm64-v8a`, `armeabi-v7a`, `x86`, `x86_64`) by default and writes them to the Maven runtime module.
+
+```bash
+cd android
+chmod +x build_xray.sh
+./build_xray.sh
+```
+
+## 3. Build Tun2socks (`libtun2socks.so`)
+
+We use a custom Go-based build script (`build_tun2socks.sh`) instead of the old C-based one. This ensures 16KB page alignment and includes the socket-based FD passing logic.
+
+```bash
+cd android
+chmod +x build_tun2socks.sh
+./build_tun2socks.sh
+```
+
+## 4. Verification (16KB Page Size)
+
+To confirm that the libraries are compatible with Android 15+ (16KB page size), check the `LOAD` segment alignment using `llvm-readelf`.
+
+```bash
+# Check alignment (should be 0x4000)
+$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-readelf -l ../../../android_runtime/xray_android/src/main/jniLibs/arm64-v8a/libtun2socks.so | grep LOAD | head -1
+```
+
+**Expected Output:**
+```
+LOAD ... 0x4000
+```
+If you see `0x1000`, it is **NOT** compatible with 16KB devices.
+
+## 5. Package The Maven Runtime AAR
+
+The `flutter_vless_android` Pub.dev package consumes the Android device runtime through the Maven artifact:
+
+```text
+dev.tfox.fluttervless:xray-android:26.7.11
+```
+
+After rebuilding `libxray.so`, `libtun2socks.so`, or the geodata files in `android_runtime/xray_android/src/main`, build the local Maven repository from the repository root:
+
+```bash
+tool/build_android_runtime_maven.sh
+```
+
+For local Android wrapper or example builds before Maven Central publication, pass the local repository path:
+
+```bash
+cd example/android
+./gradlew :app:assembleDebug \
+ -PflutterVlessAndroidRuntimeRepo="$PWD/../../android_runtime/xray_android/build/repo"
+```
+
+To upload the signed bundle to Maven Central, configure the Central Portal and signing environment variables documented in `android_runtime/xray_android/README.md`, then run:
+
+```bash
+tool/publish_android_runtime_maven.sh
+```
+
+## 6. Troubleshooting
+
+- **"bad file descriptor"**: This means the socket FD passing failed. Ensure `XrayVPNService.kt` is correctly sending the FD to the socket path specified in `-sock-path`.
+- **"permission denied"**: Ensure the app has permissions to write to the socket file in its private directory.
diff --git a/local_packages/flutter_vless_android-1.1.5/android/build.gradle b/local_packages/flutter_vless_android-1.1.5/android/build.gradle
new file mode 100644
index 0000000..0543d9b
--- /dev/null
+++ b/local_packages/flutter_vless_android-1.1.5/android/build.gradle
@@ -0,0 +1,95 @@
+group 'com.github.tfox.flutter_vless'
+version '1.0'
+
+buildscript {
+ ext.kotlin_version = '2.2.20'
+ repositories {
+ google()
+ mavenCentral()
+ }
+
+ dependencies {
+ classpath 'com.android.tools.build:gradle:8.1.0'
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
+ }
+}
+
+rootProject.allprojects {
+ repositories {
+ def localRuntimeRepo = findProperty('flutterVlessAndroidRuntimeRepo') ?: System.getenv('FLUTTER_VLESS_ANDROID_RUNTIME_REPO')
+ if (localRuntimeRepo) {
+ maven { url uri(localRuntimeRepo) }
+ }
+ google()
+ mavenCentral()
+ }
+}
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+
+android {
+ if (project.android.hasProperty("namespace")) {
+ namespace 'com.github.tfox.flutter_vless'
+ }
+
+ compileSdkVersion 34
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ kotlinOptions {
+ jvmTarget = '1.8'
+ }
+
+ defaultConfig {
+ minSdkVersion 23
+ targetSdkVersion 35
+
+ // 16KB page size support for Android 15+
+ ndk {
+ abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
+ }
+ }
+
+ // Configure 16KB page alignment
+ buildFeatures {
+ prefab true
+ }
+
+ dependencies {
+ testImplementation 'junit:junit:4.13.2'
+ testImplementation 'org.mockito:mockito-core:5.0.0'
+ testImplementation 'org.json:json:20240303'
+ }
+
+ testOptions {
+ unitTests.returnDefaultValues = true
+ unitTests.all {
+ testLogging {
+ events "passed", "skipped", "failed", "standardOut", "standardError"
+ outputs.upToDateWhen {false}
+ showStandardStreams = true
+ }
+ }
+ }
+
+ sourceSets {
+ main {
+ java.srcDirs += 'src/main/kotlin'
+ }
+ }
+ packaging {
+ jniLibs {
+ pickFirsts += ['lib/**/libc++_shared.so']
+ }
+ }
+}
+
+dependencies {
+ implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
+ implementation 'androidx.appcompat:appcompat:1.6.1'
+ implementation 'com.google.code.gson:gson:2.10.1'
+ implementation "dev.tfox.fluttervless:xray-android:${findProperty('flutterVlessXrayRuntimeVersion') ?: '26.7.11'}"
+}
diff --git a/local_packages/flutter_vless_android-1.1.5/android/build_tun2socks.sh b/local_packages/flutter_vless_android-1.1.5/android/build_tun2socks.sh
new file mode 100755
index 0000000..f26a369
--- /dev/null
+++ b/local_packages/flutter_vless_android-1.1.5/android/build_tun2socks.sh
@@ -0,0 +1,122 @@
+#!/bin/bash
+set -euo pipefail
+
+# Build script for tun2socks (Go version) with 16KB page size support
+# This is required for Android 15+ compatibility
+
+# Configuration
+TUN2SOCKS_REPO="https://github.com/xjasonlyu/tun2socks"
+TARGET_DIR="${TARGET_DIR:-../../../android_runtime/xray_android/src/main/jniLibs}"
+NDK_PATH="${ANDROID_NDK_HOME:-$HOME/Library/Android/sdk/ndk/28.2.13676358}"
+
+# Check NDK
+if [ ! -d "$NDK_PATH" ]; then
+ echo "Error: NDK not found at $NDK_PATH"
+ echo "Please export ANDROID_NDK_HOME pointing to your NDK installation."
+ exit 1
+fi
+
+echo "Using NDK at: $NDK_PATH"
+
+case "$(uname -s)" in
+ Darwin) TOOLCHAIN="${NDK_PATH}/toolchains/llvm/prebuilt/darwin-x86_64" ;;
+ Linux) TOOLCHAIN="${NDK_PATH}/toolchains/llvm/prebuilt/linux-x86_64" ;;
+ *) echo "Error: unsupported host OS $(uname -s)"; exit 1 ;;
+esac
+if [ ! -d "$TOOLCHAIN" ]; then
+ echo "Error: NDK toolchain not found at $TOOLCHAIN"
+ echo "Please check ANDROID_NDK_HOME and host OS."
+ exit 1
+fi
+
+# Clone tun2socks if not exists
+if [ ! -d "tun2socks-go" ]; then
+ echo "Cloning tun2socks (Go version)..."
+ git clone "$TUN2SOCKS_REPO" tun2socks-go
+else
+ echo "tun2socks-go directory exists, pulling latest..."
+ cd tun2socks-go && git pull && cd ..
+fi
+
+# Build Function
+build_tun2socks() {
+ local ARCH_NAME=$1
+ local GO_ARCH=$2
+ local GO_ARM=$3
+ local ANDROID_TARGET=$4
+ local OUTPUT_DIR="${TARGET_DIR}/${ARCH_NAME}"
+
+ echo "Building tun2socks for ${ARCH_NAME}..."
+
+ mkdir -p "$OUTPUT_DIR"
+
+ export CGO_ENABLED=1
+ export GOOS=android
+ export GOARCH=$GO_ARCH
+ export GOARM=$GO_ARM
+
+ export CC="${TOOLCHAIN}/bin/${ANDROID_TARGET}-clang"
+ export CXX="${TOOLCHAIN}/bin/${ANDROID_TARGET}-clang++"
+
+ # Verify compiler exists
+ if [ ! -f "$CC" ]; then
+ echo "Error: Compiler not found at $CC"
+ return 1
+ fi
+
+ cd tun2socks-go
+
+ # Build with 16KB page alignment - same flags as xray
+ go build -v -trimpath -ldflags "-s -w -buildid= -linkmode=external -extldflags '-Wl,-z,max-page-size=16384'" -buildmode=pie -o "../${OUTPUT_DIR}/libtun2socks.so" .
+
+ echo "Success: ${OUTPUT_DIR}/libtun2socks.so created."
+
+ # Verify 16KB alignment
+ if [ -x "${TOOLCHAIN}/bin/llvm-readelf" ]; then
+ echo "Verifying 16KB alignment:"
+ ALIGN=$("${TOOLCHAIN}/bin/llvm-readelf" -l "../${OUTPUT_DIR}/libtun2socks.so" | grep "LOAD" | head -1 | awk '{print $NF}')
+ if [ "$ALIGN" = "0x4000" ]; then
+ echo "Alignment: $ALIGN (16KB) - correct"
+ else
+ echo "Warning: alignment is $ALIGN; expected 0x4000"
+ fi
+ fi
+
+ cd ..
+}
+
+# Build for Architectures
+echo "Building tun2socks (Go version) with 16KB page size support..."
+
+# ARM64
+if [ "${TUN2SOCKS_BUILD_ARM64:-1}" = "1" ]; then
+ build_tun2socks "arm64-v8a" "arm64" "" "aarch64-linux-android21"
+fi
+
+# ARMv7
+if [ "${TUN2SOCKS_BUILD_ARMV7:-1}" = "1" ]; then
+ build_tun2socks "armeabi-v7a" "arm" "7" "armv7a-linux-androideabi21"
+fi
+
+# x86 is included in the main runtime AAR.
+if [ "${TUN2SOCKS_BUILD_X86:-1}" = "1" ]; then
+ build_tun2socks "x86" "386" "" "i686-linux-android21"
+fi
+
+# x86_64 is included in the main runtime AAR.
+if [ "${TUN2SOCKS_BUILD_X86_64:-1}" = "1" ]; then
+ build_tun2socks "x86_64" "amd64" "" "x86_64-linux-android21"
+fi
+
+echo ""
+echo "========================================="
+echo "Build process finished!"
+echo "========================================="
+echo ""
+echo "Verification:"
+echo "Run this command to check all libraries:"
+echo ""
+echo "for arch in armeabi-v7a arm64-v8a x86 x86_64; do"
+echo " echo \"=== \$arch ===\";"
+echo " ${TOOLCHAIN}/bin/llvm-readelf -l ${TARGET_DIR}/\$arch/libtun2socks.so | grep LOAD | head -1;"
+echo "done"
diff --git a/local_packages/flutter_vless_android-1.1.5/android/build_xray.sh b/local_packages/flutter_vless_android-1.1.5/android/build_xray.sh
new file mode 100755
index 0000000..a743a0c
--- /dev/null
+++ b/local_packages/flutter_vless_android-1.1.5/android/build_xray.sh
@@ -0,0 +1,116 @@
+#!/bin/bash
+set -euo pipefail
+
+# Configuration
+XRAY_REPO="https://github.com/XTLS/Xray-core"
+XRAY_VERSION="${XRAY_VERSION:-v26.7.11}"
+TARGET_DIR="${TARGET_DIR:-../../../android_runtime/xray_android/src/main/jniLibs}"
+NDK_PATH="${ANDROID_NDK_HOME:-$HOME/Library/Android/sdk/ndk/28.2.13676358}"
+
+# Check NDK
+if [ ! -d "$NDK_PATH" ]; then
+ echo "Error: NDK not found at $NDK_PATH"
+ echo "Please export ANDROID_NDK_HOME pointing to your NDK installation."
+ exit 1
+fi
+
+echo "Using NDK at: $NDK_PATH"
+
+# MacOS NDK Toolchain path
+case "$(uname -s)" in
+ Darwin) TOOLCHAIN="${NDK_PATH}/toolchains/llvm/prebuilt/darwin-x86_64" ;;
+ Linux) TOOLCHAIN="${NDK_PATH}/toolchains/llvm/prebuilt/linux-x86_64" ;;
+ *) echo "Error: unsupported host OS $(uname -s)"; exit 1 ;;
+esac
+if [ ! -d "$TOOLCHAIN" ]; then
+ echo "Error: NDK toolchain not found at $TOOLCHAIN"
+ echo "Are you on macOS? If not, please edit the script to match your OS."
+ exit 1
+fi
+
+# Clone Xray if not exists
+if [ ! -d "Xray-core" ]; then
+ echo "Cloning Xray-core..."
+ git clone "$XRAY_REPO"
+else
+ echo "Xray-core directory exists, pulling latest..."
+ cd Xray-core && git fetch --tags && cd ..
+fi
+
+cd Xray-core
+git fetch --tags
+git checkout "$XRAY_VERSION"
+cd ..
+
+# Build Function
+build_xray() {
+ local ARCH_NAME=$1
+ local GO_ARCH=$2
+ local GO_ARM=$3
+ local ANDROID_TARGET=$4
+ local OUTPUT_DIR="${TARGET_DIR}/${ARCH_NAME}"
+
+ echo "Building for ${ARCH_NAME}..."
+
+ mkdir -p "$OUTPUT_DIR"
+
+ export CGO_ENABLED=1
+ export GOOS=android
+ export GOARCH=$GO_ARCH
+ export GOARM=$GO_ARM
+
+ export CC="${TOOLCHAIN}/bin/${ANDROID_TARGET}-clang"
+ export CXX="${TOOLCHAIN}/bin/${ANDROID_TARGET}-clang++"
+
+ # 16KB page size support for Android 15+
+ export LDFLAGS="-Wl,-z,max-page-size=16384"
+
+ # Verify compiler exists
+ if [ ! -f "$CC" ]; then
+ echo "Error: Compiler not found at $CC"
+ # Try older NDK naming convention if new one fails (API 21 is standard)
+ # Some NDKs use specific API levels in clang name
+ return 1
+ fi
+
+ cd Xray-core
+
+ # Build with 16KB page alignment
+ if go build -v -trimpath -buildvcs=false \
+ -gcflags "all=-l=4" \
+ -ldflags "-X github.com/xtls/xray-core/core.build=${XRAY_VERSION} -s -w -buildid= -checklinkname=0 -linkmode=external -extldflags=${LDFLAGS}" \
+ -buildmode=pie \
+ -o "../${OUTPUT_DIR}/libxray.so" ./main; then
+ echo "Success: ${OUTPUT_DIR}/libxray.so created."
+ else
+ echo "Failed to build for ${ARCH_NAME}"
+ return 1
+ fi
+
+ cd ..
+}
+
+# Build for Architectures
+# API Level 21 is generally safe for modern Flutter apps (Android 5.0+)
+
+# ARM64
+if [ "${XRAY_BUILD_ARM64:-1}" = "1" ]; then
+ build_xray "arm64-v8a" "arm64" "" "aarch64-linux-android21"
+fi
+
+# ARMv7
+if [ "${XRAY_BUILD_ARMV7:-1}" = "1" ]; then
+ build_xray "armeabi-v7a" "arm" "7" "armv7a-linux-androideabi21"
+fi
+
+# x86 is included in the main runtime AAR.
+if [ "${XRAY_BUILD_X86:-1}" = "1" ]; then
+ build_xray "x86" "386" "" "i686-linux-android21"
+fi
+
+# x86_64 is included in the main runtime AAR.
+if [ "${XRAY_BUILD_X86_64:-1}" = "1" ]; then
+ build_xray "x86_64" "amd64" "" "x86_64-linux-android21"
+fi
+
+echo "Build process finished."
diff --git a/local_packages/flutter_vless_android-1.1.5/android/settings.gradle b/local_packages/flutter_vless_android-1.1.5/android/settings.gradle
new file mode 100644
index 0000000..8df1cc5
--- /dev/null
+++ b/local_packages/flutter_vless_android-1.1.5/android/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = 'flutter_vless_android'
diff --git a/local_packages/flutter_vless_android-1.1.5/android/src/main/AndroidManifest.xml b/local_packages/flutter_vless_android-1.1.5/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..55e0011
--- /dev/null
+++ b/local_packages/flutter_vless_android-1.1.5/android/src/main/AndroidManifest.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/FlutterVlessPlugin.kt b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/FlutterVlessPlugin.kt
new file mode 100644
index 0000000..35cc1f7
--- /dev/null
+++ b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/FlutterVlessPlugin.kt
@@ -0,0 +1,402 @@
+// Copyright (c) 2024-2026 13FOX Studio / tfox.dev.
+// SPDX-License-Identifier: MIT
+
+package com.github.tfox.flutter_vless
+
+import android.Manifest
+import android.app.Activity
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.PackageManager
+import android.net.VpnService
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
+import com.github.tfox.flutter_vless.xray.dto.XrayConfig
+import com.github.tfox.flutter_vless.xray.service.XrayVPNService
+import com.github.tfox.flutter_vless.xray.utils.AppConfigs
+import io.flutter.embedding.engine.plugins.FlutterPlugin
+import io.flutter.embedding.engine.plugins.activity.ActivityAware
+import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
+import io.flutter.plugin.common.EventChannel
+import io.flutter.plugin.common.MethodCall
+import io.flutter.plugin.common.MethodChannel
+import io.flutter.plugin.common.PluginRegistry
+import java.io.BufferedReader
+import java.io.File
+import java.io.InputStreamReader
+import java.util.ArrayList
+import java.util.UUID
+import java.util.concurrent.Executors
+
+/**
+ * Main entry point for the Flutter Vless plugin on Android.
+ *
+ * ReVolt keeps this bridge deliberately narrow: start/stop, session deadline,
+ * permission handling, core version and private runtime status only.
+ */
+class FlutterVlessPlugin : FlutterPlugin, ActivityAware, PluginRegistry.ActivityResultListener, MethodChannel.MethodCallHandler {
+
+ private val executor = Executors.newSingleThreadExecutor()
+ private lateinit var vpnControlMethod: MethodChannel
+ private lateinit var vpnStatusEvent: EventChannel
+ private var vpnStatusSink: EventChannel.EventSink? = null
+ private var activity: Activity? = null
+ private var xrayReceiver: BroadcastReceiver? = null
+ private var pendingResult: MethodChannel.Result? = null
+ private lateinit var context: Context
+ private var expectedRuntimeToken: String? = null
+ private var pendingStopResult: MethodChannel.Result? = null
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private val stopTimeoutRunnable = Runnable {
+ val pending = pendingStopResult ?: return@Runnable
+ pendingStopResult = null
+ pending.error("STOP_TIMEOUT", "VPN service did not confirm shutdown", null)
+ }
+
+ companion object {
+ private const val TAG = "FlutterVlessPlugin"
+ private const val REQUEST_CODE_VPN_PERMISSION = 24
+ private const val REQUEST_CODE_POST_NOTIFICATIONS = 1
+ }
+
+ override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
+ context = binding.applicationContext
+ vpnControlMethod = MethodChannel(binding.binaryMessenger, "flutter_vless")
+ vpnStatusEvent = EventChannel(binding.binaryMessenger, "flutter_vless/status")
+
+ vpnControlMethod.setMethodCallHandler(this)
+ vpnStatusEvent.setStreamHandler(object : EventChannel.StreamHandler {
+ override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
+ vpnStatusSink = events
+ registerReceiver()
+ }
+
+ override fun onCancel(arguments: Any?) {
+ vpnStatusSink = null
+ unregisterReceiver()
+ }
+ })
+ }
+
+ override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
+ when (call.method) {
+ "startVless" -> {
+ if (pendingStopResult != null) {
+ result.error("STOP_IN_PROGRESS", "VPN shutdown is still in progress", null)
+ return
+ }
+
+ val config = XrayConfig()
+ val runtimeToken = UUID.randomUUID().toString()
+ expectedRuntimeToken = runtimeToken
+ config.RUNTIME_TOKEN = runtimeToken
+ config.REMARK = call.argument("remark") ?: ""
+ config.V2RAY_FULL_JSON_CONFIG = call.argument("config") ?: ""
+ config.BLOCKED_APPS = call.argument>("blocked_apps") ?: ArrayList()
+ config.BYPASS_SUBNETS = call.argument>("bypass_subnets") ?: ArrayList()
+ config.NOTIFICATION_DISCONNECT_BUTTON_NAME = call.argument("notificationDisconnectButtonName") ?: "Disconnect"
+
+ if (AppConfigs.NOTIFICATION_ICON_RESOURCE_NAME.isNotEmpty() && AppConfigs.NOTIFICATION_ICON_RESOURCE_TYPE.isNotEmpty()) {
+ config.NOTIFICATION_ICON_RESOURCE_NAME = AppConfigs.NOTIFICATION_ICON_RESOURCE_NAME
+ config.NOTIFICATION_ICON_RESOURCE_TYPE = AppConfigs.NOTIFICATION_ICON_RESOURCE_TYPE
+ val resId = context.resources.getIdentifier(
+ AppConfigs.NOTIFICATION_ICON_RESOURCE_NAME,
+ AppConfigs.NOTIFICATION_ICON_RESOURCE_TYPE,
+ context.packageName
+ )
+ config.APPLICATION_ICON = resId
+ }
+
+ if (call.argument("proxy_only") == true) {
+ AppConfigs.V2RAY_CONNECTION_MODE = AppConfigs.V2RAY_CONNECTION_MODES.PROXY_ONLY
+ } else {
+ AppConfigs.V2RAY_CONNECTION_MODE = AppConfigs.V2RAY_CONNECTION_MODES.VPN_TUN
+ }
+
+ try {
+ val jsonConfig = org.json.JSONObject(config.V2RAY_FULL_JSON_CONFIG)
+ val outbounds = jsonConfig.optJSONArray("outbounds")
+ if (outbounds != null && outbounds.length() > 0) {
+ val firstOutbound = outbounds.getJSONObject(0)
+ val settings = firstOutbound.optJSONObject("settings")
+ val vnext = settings?.optJSONArray("vnext")
+ if (vnext != null && vnext.length() > 0) {
+ val server = vnext.getJSONObject(0)
+ config.CONNECTED_V2RAY_SERVER_ADDRESS = server.optString("address", "")
+ config.CONNECTED_V2RAY_SERVER_PORT = server.optInt("port", 0).toString()
+ } else if (settings != null) {
+ config.CONNECTED_V2RAY_SERVER_ADDRESS = settings.optString("address", "")
+ config.CONNECTED_V2RAY_SERVER_PORT = settings.optInt("port", 0).toString()
+ }
+ }
+ } catch (_: Exception) {
+ // Optional metadata only; runtime config validation happens downstream.
+ }
+
+ val intent = Intent(context, XrayVPNService::class.java)
+ intent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.START_SERVICE)
+ intent.putExtra("V2RAY_CONFIG", config)
+ intent.putExtra("PROXY_ONLY", call.argument("proxy_only") ?: false)
+
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ result.success(null)
+ } catch (e: Exception) {
+ if (expectedRuntimeToken == runtimeToken) expectedRuntimeToken = null
+ result.error("START_FAILED", e.message ?: "Could not start VPN service", null)
+ }
+ }
+
+ "stopVless" -> {
+ if (pendingStopResult != null) {
+ result.error("STOP_IN_PROGRESS", "VPN shutdown is already in progress", null)
+ return
+ }
+
+ // A STOP without a generation token can kill a newer runtime after
+ // UI/process churn. No token means this bridge owns nothing to stop.
+ val token = expectedRuntimeToken
+ if (token == null) {
+ result.success(null)
+ return
+ }
+
+ val intent = Intent(context, XrayVPNService::class.java)
+ intent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE)
+ intent.putExtra("RUNTIME_TOKEN", token)
+
+ try {
+ context.startService(intent)
+ pendingStopResult = result
+ mainHandler.removeCallbacks(stopTimeoutRunnable)
+ mainHandler.postDelayed(stopTimeoutRunnable, 6_000L)
+ } catch (e: Exception) {
+ pendingStopResult = null
+ mainHandler.removeCallbacks(stopTimeoutRunnable)
+ result.error("STOP_FAILED", e.message ?: "Could not stop VPN service", null)
+ }
+ }
+
+ "setSessionDeadline" -> {
+ val token = expectedRuntimeToken
+ val remainingSeconds = call.argument("remainingSeconds")?.toLong()
+ if (token == null) {
+ result.error("NO_RUNTIME", "No active runtime token", null)
+ return
+ }
+ if (remainingSeconds == null || remainingSeconds < 0) {
+ result.error("INVALID_DEADLINE", "remainingSeconds must be non-negative", null)
+ return
+ }
+
+ val intent = Intent(context, XrayVPNService::class.java)
+ intent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.UPDATE_SESSION_DEADLINE)
+ intent.putExtra("RUNTIME_TOKEN", token)
+ intent.putExtra("REMAINING_SECONDS", remainingSeconds)
+ try {
+ context.startService(intent)
+ result.success(null)
+ } catch (e: Exception) {
+ result.error("DEADLINE_FAILED", e.message ?: "Could not update session deadline", null)
+ }
+ }
+
+ "initializeVless" -> {
+ val iconResourceName = call.argument("notificationIconResourceName")
+ val iconResourceType = call.argument("notificationIconResourceType")
+ if (iconResourceName != null && iconResourceType != null) {
+ AppConfigs.NOTIFICATION_ICON_RESOURCE_NAME = iconResourceName
+ AppConfigs.NOTIFICATION_ICON_RESOURCE_TYPE = iconResourceType
+ }
+ result.success(null)
+ }
+
+ "getCoreVersion" -> {
+ executor.submit {
+ try {
+ val nativeLibraryDir = context.applicationInfo.nativeLibraryDir
+ val xrayExecutable = File(nativeLibraryDir, "libxray.so")
+ if (xrayExecutable.exists()) {
+ val p = Runtime.getRuntime().exec(arrayOf(xrayExecutable.absolutePath, "-version"))
+ val reader = BufferedReader(InputStreamReader(p.inputStream))
+ val version = reader.readLine()
+ result.success(version)
+ } else {
+ result.success("Xray not found")
+ }
+ } catch (e: Exception) {
+ result.success("Error: ${e.message}")
+ }
+ }
+ }
+
+ "requestPermission" -> {
+ val currentActivity = activity
+ if (currentActivity == null) {
+ result.error("NO_ACTIVITY", "Activity is not attached", null)
+ return
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ if (ActivityCompat.checkSelfPermission(currentActivity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
+ ActivityCompat.requestPermissions(currentActivity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQUEST_CODE_POST_NOTIFICATIONS)
+ }
+ }
+ val request = VpnService.prepare(currentActivity)
+ if (request != null) {
+ pendingResult = result
+ currentActivity.startActivityForResult(request, REQUEST_CODE_VPN_PERMISSION)
+ } else {
+ result.success(true)
+ }
+ }
+
+ else -> result.notImplemented()
+ }
+ }
+
+ private fun registerReceiver() {
+ if (activity == null) return
+ if (xrayReceiver == null) {
+ xrayReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (intent == null || vpnStatusSink == null) return
+ val state = intent.getSerializableExtra("STATE") as? AppConfigs.V2RAY_STATES
+ val runtimeToken = intent.getStringExtra("RUNTIME_TOKEN").orEmpty()
+ val runtimeReady = intent.getBooleanExtra("RUNTIME_READY", false)
+ val duration = intent.getStringExtra("DURATION")
+ val uploadSpeed = intent.getLongExtra("UPLOAD_SPEED", 0)
+ val downloadSpeed = intent.getLongExtra("DOWNLOAD_SPEED", 0)
+ val uploadTraffic = intent.getLongExtra("UPLOAD_TRAFFIC", 0)
+ val downloadTraffic = intent.getLongExtra("DOWNLOAD_TRAFFIC", 0)
+
+ val currentToken = expectedRuntimeToken
+ if (currentToken == null) {
+ if (runtimeToken.isNotEmpty() &&
+ (state == AppConfigs.V2RAY_STATES.V2RAY_CONNECTED ||
+ state == AppConfigs.V2RAY_STATES.V2RAY_CONNECTING)
+ ) {
+ expectedRuntimeToken = runtimeToken
+ } else {
+ return
+ }
+ } else if (runtimeToken != currentToken) {
+ return
+ }
+
+ val stateName = when (state) {
+ AppConfigs.V2RAY_STATES.V2RAY_CONNECTED -> if (runtimeReady) "CONNECTED" else "CONNECTING"
+ AppConfigs.V2RAY_STATES.V2RAY_CONNECTING -> "CONNECTING"
+ else -> "DISCONNECTED"
+ }
+
+ val data = ArrayList()
+ data.add(duration ?: "0")
+ data.add(uploadSpeed.toString())
+ data.add(downloadSpeed.toString())
+ data.add(uploadTraffic.toString())
+ data.add(downloadTraffic.toString())
+ data.add(stateName)
+
+ if (state == AppConfigs.V2RAY_STATES.V2RAY_DISCONNECTED &&
+ runtimeToken.isNotEmpty() && runtimeToken == expectedRuntimeToken
+ ) {
+ expectedRuntimeToken = null
+ mainHandler.removeCallbacks(stopTimeoutRunnable)
+ pendingStopResult?.success(null)
+ pendingStopResult = null
+ }
+
+ vpnStatusSink?.success(data)
+ }
+ }
+ }
+
+ val filter = IntentFilter(AppConfigs.V2RAY_CONNECTION_INFO)
+ val currentActivity = activity ?: return
+ ContextCompat.registerReceiver(
+ currentActivity,
+ xrayReceiver,
+ filter,
+ ContextCompat.RECEIVER_NOT_EXPORTED,
+ )
+ }
+
+ private fun unregisterReceiver() {
+ val currentActivity = activity
+ val receiver = xrayReceiver
+ if (currentActivity != null && receiver != null) {
+ try {
+ currentActivity.unregisterReceiver(receiver)
+ } catch (e: Exception) {
+ Log.w(TAG, "Receiver was already detached during lifecycle cleanup", e)
+ }
+ }
+ xrayReceiver = null
+ }
+
+ private fun failPendingPermission(code: String, message: String) {
+ pendingResult?.error(code, message, null)
+ pendingResult = null
+ }
+
+ override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
+ mainHandler.removeCallbacks(stopTimeoutRunnable)
+ pendingStopResult?.error("ENGINE_DETACHED", "Flutter engine detached during VPN shutdown", null)
+ pendingStopResult = null
+ failPendingPermission("ENGINE_DETACHED", "Flutter engine detached during VPN permission request")
+ unregisterReceiver()
+ vpnControlMethod.setMethodCallHandler(null)
+ vpnStatusEvent.setStreamHandler(null)
+ executor.shutdown()
+ }
+
+ override fun onAttachedToActivity(binding: ActivityPluginBinding) {
+ activity = binding.activity
+ binding.addActivityResultListener(this)
+ if (vpnStatusSink != null) {
+ registerReceiver()
+ }
+ }
+
+ override fun onDetachedFromActivityForConfigChanges() {
+ unregisterReceiver()
+ activity = null
+ }
+
+ override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
+ activity = binding.activity
+ binding.addActivityResultListener(this)
+ if (vpnStatusSink != null) {
+ registerReceiver()
+ }
+ }
+
+ override fun onDetachedFromActivity() {
+ failPendingPermission("NO_ACTIVITY", "Activity detached during VPN permission request")
+ unregisterReceiver()
+ activity = null
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
+ if (requestCode == REQUEST_CODE_VPN_PERMISSION) {
+ if (resultCode == Activity.RESULT_OK) {
+ pendingResult?.success(true)
+ } else {
+ pendingResult?.success(false)
+ }
+ pendingResult = null
+ return true
+ }
+ return false
+ }
+}
diff --git a/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/core/XrayCoreManager.kt b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/core/XrayCoreManager.kt
new file mode 100644
index 0000000..54975b6
--- /dev/null
+++ b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/core/XrayCoreManager.kt
@@ -0,0 +1,642 @@
+package com.github.tfox.flutter_vless.xray.core
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.graphics.Color
+import android.os.Build
+import android.os.CountDownTimer
+import android.util.Log
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import com.github.tfox.flutter_vless.xray.dto.XrayConfig
+import com.github.tfox.flutter_vless.xray.service.XrayVPNService
+import com.github.tfox.flutter_vless.xray.utils.AppConfigs
+import com.github.tfox.flutter_vless.xray.utils.Utilities
+import org.json.JSONObject
+import org.json.JSONArray
+import java.io.File
+import java.io.FileOutputStream
+
+/**
+ * Manages the Xray Core process (libxray.so).
+ *
+ * This singleton object is responsible for:
+ * 1. Generating the final Xray configuration file (config.json).
+ * 2. Injecting necessary inbounds (SOCKS, HTTP, API) into the user-provided config.
+ * 3. Starting and monitoring the Xray process.
+ * 4. Collecting traffic statistics via the Xray API.
+ * 5. Showing the persistent foreground notification.
+ */
+object XrayCoreManager {
+
+ private const val NOTIFICATION_ID = 1
+ private const val TAG = "XrayCoreManager"
+ private var xrayProcess: Process? = null
+ private var countDownTimer: CountDownTimer? = null
+ private var seconds = 0
+
+ private fun deleteEphemeralConfigOnFailure(filesDir: File) {
+ try {
+ val config = File(filesDir, "config.json")
+ if (config.exists() && !config.delete()) {
+ Log.w(TAG, "Could not delete failed ephemeral Xray config")
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Could not delete failed ephemeral Xray config", e)
+ }
+ }
+
+ private fun nextFreePort(preferredPort: Int, usedPorts: Set): Int {
+ var port = preferredPort
+ while (usedPorts.contains(port)) {
+ port++
+ }
+ return port
+ }
+
+ private fun uniqueInboundTag(inbounds: JSONArray, preferredTag: String): String {
+ val tags = mutableSetOf()
+ for (i in 0 until inbounds.length()) {
+ val tag = inbounds.optJSONObject(i)?.optString("tag").orEmpty()
+ if (tag.isNotEmpty()) tags.add(tag)
+ }
+
+ if (!tags.contains(preferredTag)) return preferredTag
+
+ var index = 1
+ var candidate = "${preferredTag}_$index"
+ while (tags.contains(candidate)) {
+ index++
+ candidate = "${preferredTag}_$index"
+ }
+ return candidate
+ }
+
+ private fun normalizeRuntimeConfig(value: Any?): Any? {
+ return when (value) {
+ is JSONObject -> {
+ val normalized = JSONObject()
+ val aliases = mapOf(
+ "xHTTPSettings" to "xhttpSettings",
+ "httpUpgradeSettings" to "httpupgradeSettings",
+ "splitHTTPSettings" to "splithttpSettings"
+ )
+ val keys = value.keys()
+ while (keys.hasNext()) {
+ val key = keys.next()
+ if (key == "allowInsecure") continue
+
+ val targetKey = aliases[key] ?: key
+ if (aliases.containsKey(key) && value.has(targetKey)) continue
+
+ val normalizedValue = normalizeRuntimeConfig(value.opt(key))
+ if (targetKey == "network" && normalizedValue is String) {
+ normalized.put(targetKey, normalizedValue.lowercase())
+ } else {
+ normalized.put(targetKey, normalizedValue)
+ }
+ }
+ normalized
+ }
+ is JSONArray -> {
+ val normalized = JSONArray()
+ for (i in 0 until value.length()) {
+ normalized.put(normalizeRuntimeConfig(value.opt(i)))
+ }
+ normalized
+ }
+ else -> value
+ }
+ }
+
+ private fun normalizeVlessOutbounds(configJson: JSONObject) {
+ val outbounds = configJson.optJSONArray("outbounds") ?: return
+ for (i in 0 until outbounds.length()) {
+ val outbound = outbounds.optJSONObject(i) ?: continue
+ if (outbound.optString("protocol") != "vless") continue
+
+ val settings = outbound.optJSONObject("settings") ?: continue
+ if (settings.has("vnext")) continue
+
+ val address = settings.optString("address")
+ val id = settings.optString("id")
+ val port = settings.optInt("port", 0)
+ if (address.isEmpty() || id.isEmpty() || port <= 0) continue
+
+ val user = JSONObject()
+ user.put("id", id)
+ user.put("encryption", settings.optString("encryption", "none"))
+ user.put("flow", settings.optString("flow", ""))
+ user.put("level", settings.optInt("level", 8))
+
+ val server = JSONObject()
+ server.put("address", address)
+ server.put("port", port)
+ server.put("users", JSONArray().put(user))
+
+ val normalizedSettings = JSONObject()
+ normalizedSettings.put("vnext", JSONArray().put(server))
+ outbound.put("settings", normalizedSettings)
+ Log.d(TAG, "Normalized flat VLESS outbound settings for $address:$port")
+ }
+ }
+
+ private fun sanitizeLogPaths(configJson: JSONObject, filesDir: File) {
+ val log = configJson.optJSONObject("log") ?: return
+
+ // The VPN client never needs destination/access logs. A server-provided
+ // config must not silently enable browsing metadata collection locally.
+ log.remove("access")
+ try { File(filesDir, "access.log").delete() } catch (_: Exception) {}
+
+ val errorPath = log.optString("error")
+ if (errorPath.isNotEmpty()) {
+ log.put("error", File(filesDir, "error.log").absolutePath)
+ }
+ }
+
+ internal fun buildRuntimeConfigJson(config: XrayConfig, filesDir: File): JSONObject {
+ val configJson = normalizeRuntimeConfig(
+ JSONObject(config.V2RAY_FULL_JSON_CONFIG)
+ ) as JSONObject
+ normalizeVlessOutbounds(configJson)
+ sanitizeLogPaths(configJson, filesDir)
+
+ // ReVolt needs only its authenticated local SOCKS ingress.
+ val inbounds = configJson.optJSONArray("inbounds") ?: JSONArray()
+ var secureSocksEnabled = false
+ for (i in 0 until inbounds.length()) {
+ val inbound = inbounds.optJSONObject(i) ?: continue
+ if (inbound.optString("tag") != "revolt-secure-socks") continue
+
+ val settings = inbound.optJSONObject("settings")
+ ?: throw IllegalStateException("Secure SOCKS5 settings are missing")
+ val users = settings.optJSONArray("users")
+ ?: throw IllegalStateException("Secure SOCKS5 users are missing")
+ val account = users.optJSONObject(0)
+ ?: throw IllegalStateException("Secure SOCKS5 account is missing")
+ val port = inbound.optInt("port", -1)
+
+ if (inbound.optString("protocol") != "socks" ||
+ inbound.optString("listen") != "127.0.0.1" ||
+ port <= 1024 || port > 65535 ||
+ settings.optString("auth") != "password" ||
+ account.optString("user").isEmpty() ||
+ account.optString("pass").isEmpty()
+ ) {
+ throw IllegalStateException("Secure SOCKS5 session is invalid")
+ }
+
+ config.LOCAL_SOCKS5_PORT = port
+ config.LOCAL_HTTP_PORT = 0
+ secureSocksEnabled = true
+ break
+ }
+
+ val usedPorts = mutableSetOf()
+ var hasSocksOnLocalPort = false
+ var hasHttpOnLocalPort = false
+ for (i in 0 until inbounds.length()) {
+ val inbound = inbounds.getJSONObject(i)
+ val protocol = inbound.optString("protocol")
+ val port = inbound.optInt("port", -1)
+ if (port > 0) usedPorts.add(port)
+ if (protocol == "socks" && port == config.LOCAL_SOCKS5_PORT) hasSocksOnLocalPort = true
+ if (protocol == "http" && port == config.LOCAL_HTTP_PORT) hasHttpOnLocalPort = true
+ }
+
+ if (!hasSocksOnLocalPort) {
+ if (usedPorts.contains(config.LOCAL_SOCKS5_PORT)) {
+ config.LOCAL_SOCKS5_PORT = nextFreePort(config.LOCAL_SOCKS5_PORT, usedPorts)
+ }
+ val socksInbound = JSONObject()
+ socksInbound.put("tag", uniqueInboundTag(inbounds, "socks"))
+ socksInbound.put("port", config.LOCAL_SOCKS5_PORT)
+ socksInbound.put("listen", "127.0.0.1")
+ socksInbound.put("protocol", "socks")
+ socksInbound.put("settings", JSONObject().put("auth", "noauth").put("udp", true))
+ socksInbound.put(
+ "sniffing",
+ JSONObject().put("enabled", true).put("destOverride", JSONArray().put("http").put("tls"))
+ )
+ inbounds.put(socksInbound)
+ usedPorts.add(config.LOCAL_SOCKS5_PORT)
+ Log.d(TAG, "Injected SOCKS inbound on port ${config.LOCAL_SOCKS5_PORT}")
+ }
+
+ if (!secureSocksEnabled && !hasHttpOnLocalPort) {
+ if (usedPorts.contains(config.LOCAL_HTTP_PORT)) {
+ config.LOCAL_HTTP_PORT = nextFreePort(config.LOCAL_HTTP_PORT, usedPorts)
+ }
+ val httpInbound = JSONObject()
+ httpInbound.put("tag", uniqueInboundTag(inbounds, "http"))
+ httpInbound.put("port", config.LOCAL_HTTP_PORT)
+ httpInbound.put("listen", "127.0.0.1")
+ httpInbound.put("protocol", "http")
+ inbounds.put(httpInbound)
+ usedPorts.add(config.LOCAL_HTTP_PORT)
+ Log.d(TAG, "Injected HTTP inbound on port ${config.LOCAL_HTTP_PORT}")
+ }
+
+ return configJson
+ }
+
+ internal fun buildDelayConfigJson(configJson: String, proxyPort: Int, filesDir: File): Pair {
+ val delayConfig = XrayConfig(
+ V2RAY_FULL_JSON_CONFIG = configJson,
+ LOCAL_SOCKS5_PORT = proxyPort,
+ LOCAL_HTTP_PORT = proxyPort + 1,
+ LOCAL_API_PORT = proxyPort + 2
+ )
+ val runtimeJson = buildRuntimeConfigJson(delayConfig, filesDir)
+ return Pair(runtimeJson, delayConfig.LOCAL_SOCKS5_PORT)
+ }
+
+ /**
+ * Starts the Xray Core process.
+ *
+ * @param context The service context (needed for file access and notifications).
+ * @param config The configuration object containing the user's settings.
+ * @return true if started successfully, false otherwise.
+ */
+ fun startCore(context: Service, config: XrayConfig): Boolean {
+ AppConfigs.RUNTIME_READY = false
+ AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTING
+ AppConfigs.V2RAY_CONFIG = config
+
+ // 1. Prepare the configuration file
+ val configFilesDir = context.filesDir
+
+ try {
+ val configJson = buildRuntimeConfigJson(config, configFilesDir)
+ val configFile = File(context.filesDir, "config.json")
+ configFile.writeText(configJson.toString())
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to write config file", e)
+ deleteEphemeralConfigOnFailure(configFilesDir)
+ return false
+ }
+
+ // 2. Find Xray executable (libxray.so)
+ val nativeLibraryDir = context.applicationInfo.nativeLibraryDir
+ val xrayExecutable = File(nativeLibraryDir, "libxray.so")
+ if (!xrayExecutable.exists()) {
+ Log.e(TAG, "Xray executable not found at ${xrayExecutable.absolutePath}")
+ deleteEphemeralConfigOnFailure(configFilesDir)
+ return false
+ }
+
+ // 3. Prepare assets (geoip, geosite)
+ try {
+ Utilities.copyAssets(context)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to prepare Xray assets", e)
+ deleteEphemeralConfigOnFailure(configFilesDir)
+ return false
+ }
+
+ // 4. Run Xray
+ try {
+ val cmd = listOf(
+ xrayExecutable.absolutePath,
+ "-config", File(configFilesDir, "config.json").absolutePath
+ )
+ val pb = ProcessBuilder(cmd)
+ pb.directory(configFilesDir)
+ pb.redirectErrorStream(true)
+
+ // Set environment variables (XRAY_LOCATION_ASSET is crucial for finding geoip/geosite)
+ val env = pb.environment()
+ env["XRAY_LOCATION_ASSET"] = Utilities.getUserAssetsPath(context)
+
+ xrayProcess = pb.start()
+ Thread.sleep(300)
+ if (xrayProcess?.isAlive != true) {
+ val output = xrayProcess?.inputStream?.bufferedReader()?.readText().orEmpty()
+ Log.e(TAG, "Xray process exited during startup. Output: $output")
+ xrayProcess = null
+ AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_DISCONNECTED
+ deleteEphemeralConfigOnFailure(configFilesDir)
+ return false
+ }
+
+ // config.json contains per-session SOCKS credentials. Xray has
+ // consumed it after the startup liveness check, so remove it now.
+ try {
+ val ephemeralConfig = File(configFilesDir, "config.json")
+ if (ephemeralConfig.exists() && !ephemeralConfig.delete()) {
+ Log.w(TAG, "Could not delete ephemeral Xray config")
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Could not delete ephemeral Xray config", e)
+ }
+
+ AppConfigs.RUNTIME_READY = false
+ AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTING
+ startTimer(context)
+ showNotification(context, config)
+ sendStatusBroadcast(context, config)
+
+ val process = xrayProcess ?: return false
+ // Monitor process in a separate thread to detect crash
+ Thread {
+ try {
+ process.inputStream.bufferedReader().use { reader ->
+ reader.forEachLine { _ -> }
+ }
+
+ val exitCode = process.waitFor()
+ Log.e(TAG, "Xray process exited with code $exitCode")
+ if (xrayProcess === process &&
+ AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN == config.RUNTIME_TOKEN &&
+ (AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTED ||
+ AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTING)
+ ) {
+ // Hold the Android TUN while the core is recovered. Killing
+ // the VPN service here would allow direct-network fallback.
+ xrayProcess = null
+ AppConfigs.RUNTIME_READY = false
+ AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTING
+ sendStatusBroadcast(context, config)
+ if (context is XrayVPNService) {
+ context.handleXrayCoreExit(config)
+ } else {
+ stopCore(context)
+ }
+ }
+ } catch (e: java.io.InterruptedIOException) {
+ // Expected when stopping
+ } catch (e: InterruptedException) {
+ // Expected when stopping
+ } catch (e: Exception) {
+ Log.e(TAG, "Error reading xray output", e)
+ }
+ }.start()
+
+ return true
+
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to start Xray process", e)
+ deleteEphemeralConfigOnFailure(configFilesDir)
+ return false
+ }
+ }
+
+ /**
+ * Stops the Xray Core process and cleans up notifications.
+ */
+ fun stopCore(context: Service, confirmationToken: String? = AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN) {
+ try {
+ xrayProcess?.destroy()
+ xrayProcess = null
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to destroy Xray process", e)
+ }
+
+ AppConfigs.RUNTIME_READY = false
+ AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_DISCONNECTED
+ stopTimer()
+ val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ notificationManager.cancel(NOTIFICATION_ID)
+ sendDisconnectedBroadcast(context, confirmationToken.orEmpty())
+ AppConfigs.V2RAY_CONFIG = null
+ }
+
+ fun markRuntimeConnecting(context: Context, config: XrayConfig) {
+ if (AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN != config.RUNTIME_TOKEN) return
+ AppConfigs.RUNTIME_READY = false
+ AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTING
+ sendStatusBroadcast(context, config)
+ }
+
+ fun markRuntimeReady(context: Service, config: XrayConfig) {
+ if (AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN != config.RUNTIME_TOKEN) return
+ AppConfigs.RUNTIME_READY = true
+ AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTED
+ showNotification(context, config)
+ sendStatusBroadcast(context, config)
+ }
+
+ fun isXrayRunning(): Boolean {
+ // Check state instead of process because VPN runs in separate service process
+ return AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTED ||
+ AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTING
+ }
+
+ private fun startTimer(context: Context) {
+ countDownTimer?.cancel()
+ seconds = 0
+ countDownTimer = object : CountDownTimer(Long.MAX_VALUE, 1000) {
+ override fun onTick(millisUntilFinished: Long) {
+ seconds++
+ val intent = Intent(AppConfigs.V2RAY_CONNECTION_INFO).setPackage(context.packageName)
+ intent.putExtra("STATE", AppConfigs.V2RAY_STATE)
+ intent.putExtra("RUNTIME_TOKEN", AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN.orEmpty())
+ intent.putExtra("RUNTIME_READY", AppConfigs.RUNTIME_READY)
+ intent.putExtra("DURATION", seconds.toString())
+
+ intent.putExtra("UPLOAD_SPEED", 0L)
+ intent.putExtra("DOWNLOAD_SPEED", 0L)
+ intent.putExtra("UPLOAD_TRAFFIC", 0L)
+ intent.putExtra("DOWNLOAD_TRAFFIC", 0L)
+
+ context.sendBroadcast(intent)
+ }
+
+ override fun onFinish() {}
+ }.start()
+ }
+
+ private fun stopTimer() {
+ countDownTimer?.cancel()
+ countDownTimer = null
+ seconds = 0
+ }
+
+ fun sendStatusBroadcast(context: Context, config: XrayConfig) {
+ val intent = Intent(AppConfigs.V2RAY_CONNECTION_INFO).setPackage(context.packageName)
+ intent.putExtra("STATE", AppConfigs.V2RAY_STATE)
+ intent.putExtra("RUNTIME_TOKEN", config.RUNTIME_TOKEN)
+ intent.putExtra("RUNTIME_READY", AppConfigs.RUNTIME_READY)
+ intent.putExtra("DURATION", seconds.toString())
+ intent.putExtra("UPLOAD_SPEED", 0L)
+ intent.putExtra("DOWNLOAD_SPEED", 0L)
+ intent.putExtra("UPLOAD_TRAFFIC", 0L)
+ intent.putExtra("DOWNLOAD_TRAFFIC", 0L)
+ context.sendBroadcast(intent)
+ }
+
+ private fun sendDisconnectedBroadcast(context: Context, runtimeToken: String) {
+ val intent = Intent(AppConfigs.V2RAY_CONNECTION_INFO).setPackage(context.packageName)
+ intent.putExtra("STATE", AppConfigs.V2RAY_STATES.V2RAY_DISCONNECTED)
+ intent.putExtra("RUNTIME_TOKEN", runtimeToken)
+ intent.putExtra("RUNTIME_READY", false)
+ intent.putExtra("DURATION", "0")
+ intent.putExtra("UPLOAD_SPEED", 0L)
+ intent.putExtra("DOWNLOAD_SPEED", 0L)
+ intent.putExtra("UPLOAD_TRAFFIC", 0L)
+ intent.putExtra("DOWNLOAD_TRAFFIC", 0L)
+ context.sendBroadcast(intent)
+ }
+
+ private fun showNotification(context: Service, config: XrayConfig) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ if (ActivityCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
+ return
+ }
+ }
+
+ val channelId = createNotificationChannel(context, config.APPLICATION_NAME)
+
+ val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
+ launchIntent?.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
+
+ val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_UPDATE_CURRENT
+ val contentPendingIntent = PendingIntent.getActivity(context, 0, launchIntent, flags)
+
+ val stopIntent = Intent(context, XrayVPNService::class.java)
+ stopIntent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE)
+ stopIntent.putExtra("RUNTIME_TOKEN", config.RUNTIME_TOKEN)
+ val stopPendingIntent = PendingIntent.getService(context, 0, stopIntent, flags)
+ val smallIcon = if (config.APPLICATION_ICON != 0) config.APPLICATION_ICON else android.R.drawable.ic_dialog_info
+
+ val builder = NotificationCompat.Builder(context, channelId)
+ .setSmallIcon(smallIcon)
+ .setContentTitle(config.REMARK)
+ .setContentText(if (AppConfigs.RUNTIME_READY) "Connected" else "Connecting")
+ .addAction(0, config.NOTIFICATION_DISCONNECT_BUTTON_NAME, stopPendingIntent)
+ .setContentIntent(contentPendingIntent)
+ .setPriority(NotificationCompat.PRIORITY_MIN)
+ .setOngoing(true)
+ .setShowWhen(true)
+
+ context.startForeground(NOTIFICATION_ID, builder.build())
+ }
+
+ fun getConnectedV2rayServerDelay(context: Context, url: String): Long {
+ // Use the configured SOCKS port (default 10807)
+ val port = AppConfigs.V2RAY_CONFIG?.LOCAL_SOCKS5_PORT ?: 10807
+ Log.d(TAG, "getConnectedV2rayServerDelay: Testing delay to $url via SOCKS port $port")
+
+ return try {
+ val start = System.currentTimeMillis()
+ val proxy = java.net.Proxy(java.net.Proxy.Type.SOCKS, java.net.InetSocketAddress("127.0.0.1", port))
+ val connection = java.net.URL(url).openConnection(proxy) as java.net.HttpURLConnection
+ connection.connectTimeout = 5000
+ connection.readTimeout = 5000
+ connection.requestMethod = "HEAD"
+ val responseCode = connection.responseCode
+ val end = System.currentTimeMillis()
+ val delay = end - start
+ connection.disconnect()
+ Log.d(TAG, "getConnectedV2rayServerDelay: Success! Response code: $responseCode, Delay: ${delay}ms")
+ delay
+ } catch (e: Exception) {
+ Log.e(TAG, "getConnectedV2rayServerDelay: Failed to measure delay (VPN may not be running)", e)
+ -1L
+ }
+ }
+
+ /**
+ * Measure delay for a server configuration (when not connected).
+ * Temporarily starts Xray with the provided config, measures delay, then stops it.
+ */
+ fun getServerDelay(context: Context, configJson: String, url: String): Long {
+ Log.d(TAG, "getServerDelay: Starting temporary Xray instance")
+
+ var tempProcess: Process? = null
+ try {
+ // Find a random free port to avoid conflict with running VPN
+ val freePort = try {
+ val socket = java.net.ServerSocket(0)
+ val port = socket.localPort
+ socket.close()
+ port
+ } catch (e: Exception) {
+ 10806 // Fallback
+ }
+
+ val (json, socksPort) = buildDelayConfigJson(configJson, freePort, context.filesDir)
+ Log.d(TAG, "getServerDelay: Using SOCKS port $socksPort")
+
+ // Write temp config file
+ val tempConfigFile = File(context.filesDir, "temp_delay_config.json")
+ tempConfigFile.writeText(json.toString())
+
+ // Copy assets
+ Utilities.copyAssets(context)
+
+ // Start Xray process
+ val xrayExecutable = File(context.applicationInfo.nativeLibraryDir, "libxray.so")
+ if (!xrayExecutable.exists()) {
+ Log.e(TAG, "getServerDelay: Xray executable not found")
+ return -1L
+ }
+
+ val cmd = listOf(
+ xrayExecutable.absolutePath,
+ "-config", tempConfigFile.absolutePath
+ )
+
+ val pb = ProcessBuilder(cmd)
+ pb.directory(context.filesDir)
+ val env = pb.environment()
+ env["XRAY_LOCATION_ASSET"] = Utilities.getUserAssetsPath(context)
+
+ tempProcess = pb.start()
+
+ // Wait a bit for Xray to start
+ Thread.sleep(1000)
+
+ // Measure delay
+ val delay = try {
+ val start = System.currentTimeMillis()
+ val proxy = java.net.Proxy(java.net.Proxy.Type.SOCKS, java.net.InetSocketAddress("127.0.0.1", socksPort))
+ val connection = java.net.URL(url).openConnection(proxy) as java.net.HttpURLConnection
+ connection.connectTimeout = 5000
+ connection.readTimeout = 5000
+ connection.requestMethod = "HEAD"
+ val responseCode = connection.responseCode
+ val end = System.currentTimeMillis()
+ connection.disconnect()
+ val result = end - start
+ Log.d(TAG, "getServerDelay: Success! Response code: $responseCode, Delay: ${result}ms")
+ result
+ } catch (e: Exception) {
+ Log.e(TAG, "getServerDelay: Failed to measure delay", e)
+ -1L
+ }
+
+ // Stop temp process
+ tempProcess?.destroy()
+ tempConfigFile.delete()
+
+ return delay
+
+ } catch (e: Exception) {
+ Log.e(TAG, "getServerDelay: Error starting temp Xray", e)
+ tempProcess?.destroy()
+ return -1L
+ }
+ }
+
+ private fun createNotificationChannel(context: Context, appName: String): String {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channelId = "REVOLT_VPN_SERVICE"
+ val channelName = "$appName Background Service"
+ val channel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW)
+ channel.lightColor = Color.BLUE
+ channel.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ manager.createNotificationChannel(channel)
+ return channelId
+ }
+ return ""
+ }
+}
diff --git a/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/dto/XrayConfig.kt b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/dto/XrayConfig.kt
new file mode 100644
index 0000000..aed207c
--- /dev/null
+++ b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/dto/XrayConfig.kt
@@ -0,0 +1,71 @@
+package com.github.tfox.flutter_vless.xray.dto
+
+import java.io.Serializable
+import java.util.ArrayList
+
+/**
+ * Data class representing the Xray configuration and app settings.
+ *
+ * This class is passed from Flutter to Android and contains:
+ * 1. The full Xray JSON configuration (V2RAY_FULL_JSON_CONFIG).
+ * 2. App-specific settings like blocked apps, notification text, etc.
+ * 3. Connection parameters (server address, ports).
+ */
+data class XrayConfig(
+ /** The IP address of the connected server (used to exclude it from VPN routing). */
+ var CONNECTED_V2RAY_SERVER_ADDRESS: String = "",
+
+ /** The port of the connected server. */
+ var CONNECTED_V2RAY_SERVER_PORT: String = "",
+
+ /** Local port for SOCKS5 proxy (default: 10807). used by tun2socks. */
+ var LOCAL_SOCKS5_PORT: Int = 10807,
+
+ /** Local port for HTTP proxy (default: 10808). */
+ var LOCAL_HTTP_PORT: Int = 10808,
+
+ /** Local port for Xray API (stats, etc.) (default: 10809). */
+ var LOCAL_API_PORT: Int = 10809,
+
+ /** Timeout for connections in milliseconds. */
+ var ALLOWED_TIMEOUT_MS: Int = 0,
+
+ /** List of subnets to bypass (not routed through VPN). */
+ var BYPASS_SUBNETS: ArrayList = ArrayList(),
+
+ /** List of app package names to exclude from VPN. */
+ var BLOCKED_APPS: ArrayList = ArrayList(),
+
+ /** The raw Xray configuration JSON string. */
+ var V2RAY_FULL_JSON_CONFIG: String = "",
+
+ /** Whether to enable traffic statistics collection. */
+ var ENABLE_TRAFFIC_STATICS: Boolean = false,
+
+ /** Remark/Name of the server configuration. */
+ var REMARK: String = "",
+
+ /** Name of the application (displayed in notification). */
+ var APPLICATION_NAME: String = "Flutter Vless",
+
+ /** Resource ID of the notification icon. */
+ var APPLICATION_ICON: Int = 0,
+
+ /** Resource name of the notification icon (e.g. "ic_notification"). */
+ var NOTIFICATION_ICON_RESOURCE_NAME: String = "",
+
+ /** Resource type of the notification icon (e.g. "drawable"). */
+ var NOTIFICATION_ICON_RESOURCE_TYPE: String = "",
+
+ /** Text for the disconnect button in the notification. */
+ var NOTIFICATION_DISCONNECT_BUTTON_NAME: String = "Disconnect",
+
+ /** Domain resolution strategy (e.g. "IPIfNonMatch"). */
+ var DOMAIN_STRATEGY: String = "",
+
+ /** Routing domain strategy. */
+ var ROUTING_DOMAIN_STRATEGY: String = "",
+
+ /** Unique generation token used to reject stale native runtime events. */
+ var RUNTIME_TOKEN: String = ""
+) : Serializable
diff --git a/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/service/XrayVPNService.kt b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/service/XrayVPNService.kt
new file mode 100644
index 0000000..a1c06e1
--- /dev/null
+++ b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/service/XrayVPNService.kt
@@ -0,0 +1,685 @@
+package com.github.tfox.flutter_vless.xray.service
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.net.LocalSocket
+import android.net.LocalSocketAddress
+import android.net.VpnService
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.os.ParcelFileDescriptor
+import android.os.SystemClock
+import android.util.Log
+import com.github.tfox.flutter_vless.xray.core.XrayCoreManager
+import com.github.tfox.flutter_vless.xray.dto.XrayConfig
+import com.github.tfox.flutter_vless.xray.utils.AppConfigs
+import org.json.JSONObject
+import java.io.File
+
+/**
+ * Android VPN Service implementation.
+ *
+ * This service owns the TUN interface and keeps runtime state generation-scoped.
+ * Session deadlines are enforced both by an in-process monotonic timer and by
+ * AlarmManager, while only the non-secret token/deadline pair is persisted.
+ * Per-session VLESS/SOCKS credentials remain memory-only.
+ */
+class XrayVPNService : VpnService() {
+
+ private var mInterface: ParcelFileDescriptor? = null
+ private var tun2socksProcess: Process? = null
+ @Volatile private var isRunning = false
+ @Volatile private var recoveringXray = false
+ @Volatile private var recoveringTun2socks = false
+ private var tun2socksRecoveryAttempt = 0
+ private var currentConfig: XrayConfig? = null
+ private var currentProxyOnly = false
+ private var shuttingDownIntentionally = false
+
+ private val deadlineHandler = Handler(Looper.getMainLooper())
+ private var sessionDeadlineElapsed: Long? = null
+ private var sessionDeadlineToken: String? = null
+ private val sessionDeadlineRunnable = Runnable { enforceSessionDeadline() }
+
+ override fun onCreate() {
+ super.onCreate()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (intent == null) {
+ if (isRunning && currentConfig != null) return START_REDELIVER_INTENT
+
+ // Session credentials intentionally stay memory-only. If Android
+ // cannot redeliver the original START intent, fail closed instead
+ // of rebuilding a tunnel from sensitive material stored on disk.
+ clearPersistedDeadline()
+ cancelSessionExpiryAlarm()
+ stopSelf()
+ return START_NOT_STICKY
+ }
+
+ if (intent.getBooleanExtra(EXTRA_SESSION_EXPIRED, false)) {
+ enforcePersistedDeadline(intent.getStringExtra("RUNTIME_TOKEN").orEmpty())
+ return START_NOT_STICKY
+ }
+
+ val command = if (Build.VERSION.SDK_INT >= 33) {
+ intent.getSerializableExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS::class.java)
+ } else {
+ @Suppress("DEPRECATION")
+ intent.getSerializableExtra("COMMAND") as? AppConfigs.V2RAY_SERVICE_COMMANDS
+ }
+
+ if (command == AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE) {
+ val requestedToken = intent.getStringExtra("RUNTIME_TOKEN").orEmpty()
+ val activeToken = currentConfig?.RUNTIME_TOKEN.orEmpty()
+ if (requestedToken.isEmpty() ||
+ (activeToken.isNotEmpty() && requestedToken != activeToken)
+ ) {
+ Log.w(TAG, "Ignoring STOP_SERVICE without matching runtime generation")
+ return START_NOT_STICKY
+ }
+ stopAll(requestedToken)
+ return START_NOT_STICKY
+ }
+
+ if (command == AppConfigs.V2RAY_SERVICE_COMMANDS.UPDATE_SESSION_DEADLINE) {
+ updateSessionDeadline(intent)
+ return if (isRunning) START_REDELIVER_INTENT else START_NOT_STICKY
+ }
+
+ createNotificationChannel()
+ val notification = createNotification("VPN Service Running")
+ try {
+ if (Build.VERSION.SDK_INT >= 34) {
+ startForeground(1, notification, FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
+ } else {
+ startForeground(1, notification)
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to start foreground", e)
+ clearPersistedDeadline()
+ cancelSessionExpiryAlarm()
+ stopSelf()
+ return START_NOT_STICKY
+ }
+
+ if (command == AppConfigs.V2RAY_SERVICE_COMMANDS.START_SERVICE) {
+ val config = if (Build.VERSION.SDK_INT >= 33) {
+ intent.getSerializableExtra("V2RAY_CONFIG", XrayConfig::class.java)
+ } else {
+ @Suppress("DEPRECATION")
+ intent.getSerializableExtra("V2RAY_CONFIG") as? XrayConfig
+ }
+
+ if (config != null) {
+ if (XrayCoreManager.isXrayRunning()) {
+ Log.w(TAG, "Stopping stale Xray core before restart")
+ XrayCoreManager.stopCore(this)
+ }
+ cleanup(clearDeadline = true)
+ shuttingDownIntentionally = false
+ currentConfig = config
+
+ val proxyOnly = intent.getBooleanExtra("PROXY_ONLY", false)
+ currentProxyOnly = proxyOnly
+ armSessionDeadline(config, BOOTSTRAP_SESSION_SECONDS)
+ if (shuttingDownIntentionally) return START_NOT_STICKY
+
+ if (XrayCoreManager.startCore(this, config)) {
+ if (!proxyOnly) {
+ setupVpn(config)
+ } else {
+ isRunning = true
+ Log.d(TAG, "Starting in PROXY_ONLY mode")
+ XrayCoreManager.markRuntimeReady(this, config)
+ }
+ } else {
+ stopAll(config.RUNTIME_TOKEN)
+ return START_NOT_STICKY
+ }
+ } else {
+ Log.e(TAG, "START_SERVICE missing V2RAY_CONFIG")
+ stopAll()
+ return START_NOT_STICKY
+ }
+ } else {
+ stopAll()
+ return START_NOT_STICKY
+ }
+
+ return START_REDELIVER_INTENT
+ }
+
+ private fun setupVpn(config: XrayConfig) {
+ try {
+ if (mInterface != null) {
+ mInterface?.close()
+ mInterface = null
+ }
+
+ val builder = Builder()
+ builder.setSession(config.REMARK)
+ builder.setMtu(1500)
+ builder.addAddress("26.26.26.1", 30)
+ builder.addAddress("fd00:26:26::1", 126)
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ builder.setMetered(false)
+ }
+
+ if (config.BLOCKED_APPS.isNotEmpty()) {
+ throw IllegalStateException("Per-app VPN bypass is disabled in ReVolt")
+ }
+ try {
+ builder.addDisallowedApplication(packageName)
+ } catch (e: Exception) {
+ throw IllegalStateException("Failed to exclude ReVolt from its own VPN", e)
+ }
+
+ builder.addRoute("0.0.0.0", 0)
+ builder.addRoute("::", 0)
+
+ try {
+ builder.addDnsServer("8.8.8.8")
+ builder.addDnsServer("1.1.1.1")
+ } catch (e: Exception) {
+ throw IllegalStateException("Failed to configure VPN DNS", e)
+ }
+
+ mInterface = builder.establish()
+ ?: throw IllegalStateException("Android refused to establish VPN interface")
+ isRunning = true
+ runTun2socks(config)
+
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to setup VPN", e)
+ stopAll()
+ }
+ }
+
+ private data class SecureSocksCredentials(
+ val port: Int,
+ val username: String,
+ val password: String,
+ )
+
+ private fun secureSocksCredentials(config: XrayConfig): SecureSocksCredentials? {
+ val inbounds = try {
+ JSONObject(config.V2RAY_FULL_JSON_CONFIG).optJSONArray("inbounds")
+ } catch (_: Exception) {
+ null
+ } ?: return null
+
+ for (i in 0 until inbounds.length()) {
+ val inbound = inbounds.optJSONObject(i) ?: continue
+ if (inbound.optString("tag") != "revolt-secure-socks") continue
+ if (inbound.optString("protocol") != "socks" ||
+ inbound.optString("listen") != "127.0.0.1"
+ ) return null
+
+ val settings = inbound.optJSONObject("settings") ?: return null
+ if (settings.optString("auth") != "password") return null
+ val user = settings.optJSONArray("users")?.optJSONObject(0) ?: return null
+ val port = inbound.optInt("port", -1)
+ val username = user.optString("user")
+ val password = user.optString("pass")
+ if (port <= 1024 || port > 65535 || username.isEmpty() || password.isEmpty()) {
+ return null
+ }
+ return SecureSocksCredentials(port, username, password)
+ }
+ return null
+ }
+
+ private fun scheduleTun2socksRecovery(config: XrayConfig, reason: String) {
+ if (!isRunning || currentConfig !== config || recoveringTun2socks) return
+ XrayCoreManager.markRuntimeConnecting(this, config)
+ recoveringTun2socks = true
+ val attempt = ++tun2socksRecoveryAttempt
+ val shift = (attempt - 1).coerceIn(0, 6)
+ val delayMs = (500L * (1L shl shift)).coerceAtMost(30_000L)
+ Log.w(TAG, "Scheduling tun2socks recovery attempt $attempt in ${delayMs}ms: $reason")
+
+ Thread {
+ try {
+ Thread.sleep(delayMs)
+ } catch (_: InterruptedException) {
+ recoveringTun2socks = false
+ return@Thread
+ }
+
+ recoveringTun2socks = false
+ if (isRunning && currentConfig === config) {
+ runTun2socks(config)
+ }
+ }.start()
+ }
+
+ private fun runTun2socks(config: XrayConfig) {
+ val tun2socksPath = File(applicationInfo.nativeLibraryDir, "libtun2socks.so").absolutePath
+ val socketFile = File(filesDir, "sock_path")
+ if (socketFile.exists() && !socketFile.delete()) {
+ Log.w(TAG, "Could not delete stale tun2socks socket")
+ }
+ val sockPath = socketFile.absolutePath
+
+ val secure = secureSocksCredentials(config)
+ ?: throw IllegalStateException("Authenticated ReVolt SOCKS5 inbound missing")
+ config.LOCAL_SOCKS5_PORT = secure.port
+
+ val cmd = arrayListOf(
+ tun2socksPath,
+ "-sock-path", sockPath,
+ "-proxy", "socks5://${secure.username}:${secure.password}@127.0.0.1:${secure.port}",
+ "-mtu", "1500",
+ "-loglevel", "debug"
+ )
+
+ Log.d(TAG, "Starting tun2socks with authenticated loopback SOCKS5")
+
+ try {
+ val pb = ProcessBuilder(cmd)
+ pb.redirectErrorStream(true)
+ pb.directory(filesDir)
+ val process = pb.start()
+ tun2socksProcess = process
+
+ Thread {
+ try {
+ process.inputStream.bufferedReader().use { reader ->
+ reader.forEachLine { _ -> }
+ }
+
+ process.waitFor()
+ if (isRunning && tun2socksProcess === process && currentConfig === config) {
+ Log.e(TAG, "tun2socks exited unexpectedly; keeping TUN fail-closed")
+ tun2socksProcess = null
+ scheduleTun2socksRecovery(config, "process exited")
+ }
+ } catch (_: java.io.InterruptedIOException) {
+ // Expected when stopping
+ } catch (_: InterruptedException) {
+ // Expected when stopping
+ } catch (e: Exception) {
+ Log.e(TAG, "Error reading tun2socks output", e)
+ }
+ }.start()
+
+ sendFd(process, config)
+
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to start tun2socks; keeping TUN fail-closed", e)
+ scheduleTun2socksRecovery(config, "start failure")
+ }
+ }
+
+ private fun sendFd(process: Process, config: XrayConfig) {
+ val fd = mInterface?.fileDescriptor ?: return
+ val sockFile = File(filesDir, "sock_path").absolutePath
+
+ Thread {
+ var tries = 0
+ while (tries < 10 && isRunning && tun2socksProcess === process && process.isAlive) {
+ var localSocket: LocalSocket? = null
+ try {
+ Thread.sleep(500)
+ localSocket = LocalSocket()
+ localSocket.connect(LocalSocketAddress(sockFile, LocalSocketAddress.Namespace.FILESYSTEM))
+ val out = localSocket.outputStream
+ localSocket.setFileDescriptorsForSend(arrayOf(fd))
+ out.write(32)
+ out.flush()
+ localSocket.setFileDescriptorsForSend(null)
+ localSocket.shutdownOutput()
+ localSocket.close()
+ if (isRunning && currentConfig === config && tun2socksProcess === process) {
+ tun2socksRecoveryAttempt = 0
+ XrayCoreManager.markRuntimeReady(this, config)
+ }
+ return@Thread
+ } catch (_: Exception) {
+ tries++
+ try {
+ localSocket?.close()
+ } catch (closeError: Exception) {
+ Log.w(TAG, "Failed to close tun2socks handoff socket", closeError)
+ }
+ }
+ }
+
+ if (isRunning && tun2socksProcess === process) {
+ Log.e(TAG, "FD handoff failed; recycling tun2socks without dropping TUN")
+ try {
+ process.destroy()
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to recycle tun2socks after FD handoff failure", e)
+ }
+ }
+ }.start()
+ }
+
+ fun handleXrayCoreExit(config: XrayConfig) {
+ if (!isRunning || currentConfig !== config || recoveringXray) return
+ XrayCoreManager.markRuntimeConnecting(this, config)
+ recoveringXray = true
+
+ Thread {
+ var attempt = 0
+ try {
+ while (isRunning && currentConfig === config) {
+ attempt++
+ val shift = (attempt - 1).coerceIn(0, 6)
+ val delayMs = (500L * (1L shl shift)).coerceAtMost(30_000L)
+ Log.w(TAG, "Recovering Xray core attempt $attempt in ${delayMs}ms")
+ Thread.sleep(delayMs)
+ if (!isRunning || currentConfig !== config) break
+
+ if (XrayCoreManager.startCore(this, config)) {
+ Log.w(TAG, "Xray core recovered without dropping TUN")
+ if (currentProxyOnly || (mInterface != null && tun2socksProcess?.isAlive == true)) {
+ XrayCoreManager.markRuntimeReady(this, config)
+ }
+ return@Thread
+ }
+ }
+ } catch (_: InterruptedException) {
+ // Expected when stopping
+ } finally {
+ recoveringXray = false
+ }
+ }.start()
+ }
+
+ private fun cleanup(clearDeadline: Boolean = true) {
+ isRunning = false
+ if (clearDeadline) {
+ clearSessionDeadline()
+ } else {
+ deadlineHandler.removeCallbacks(sessionDeadlineRunnable)
+ sessionDeadlineElapsed = null
+ sessionDeadlineToken = null
+ }
+ recoveringXray = false
+ recoveringTun2socks = false
+ tun2socksRecoveryAttempt = 0
+ currentProxyOnly = false
+ AppConfigs.RUNTIME_READY = false
+ tun2socksProcess?.destroy()
+ tun2socksProcess = null
+ try {
+ mInterface?.close()
+ mInterface = null
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to close VPN interface during cleanup", e)
+ }
+ }
+
+ private fun stopAll(confirmationToken: String = currentConfig?.RUNTIME_TOKEN.orEmpty()) {
+ if (shuttingDownIntentionally) return
+ shuttingDownIntentionally = true
+ cleanup(clearDeadline = true)
+ currentConfig = null
+ XrayCoreManager.stopCore(this, confirmationToken)
+ stopForeground(true)
+ stopSelf()
+ }
+
+ private fun armSessionDeadline(config: XrayConfig, remainingSeconds: Long) {
+ val boundedSeconds = remainingSeconds
+ .coerceAtLeast(0L)
+ .coerceAtMost(Long.MAX_VALUE / 1000L)
+ val delayMs = boundedSeconds * 1000L
+ val nowEpochMs = System.currentTimeMillis()
+ val epochDeadline = if (Long.MAX_VALUE - nowEpochMs < delayMs) {
+ Long.MAX_VALUE
+ } else {
+ nowEpochMs + delayMs
+ }
+
+ sessionDeadlineToken = config.RUNTIME_TOKEN
+ sessionDeadlineElapsed = SystemClock.elapsedRealtime() + delayMs
+
+ if (!persistDeadline(config.RUNTIME_TOKEN, epochDeadline) ||
+ !scheduleSessionExpiryAlarm(config.RUNTIME_TOKEN, epochDeadline)
+ ) {
+ Log.e(TAG, "Could not arm OS-level session expiry; stopping fail-closed")
+ stopAll(config.RUNTIME_TOKEN)
+ return
+ }
+
+ deadlineHandler.removeCallbacks(sessionDeadlineRunnable)
+ if (delayMs == 0L) deadlineHandler.post(sessionDeadlineRunnable)
+ else deadlineHandler.postDelayed(sessionDeadlineRunnable, delayMs)
+ }
+
+ private fun clearSessionDeadline() {
+ deadlineHandler.removeCallbacks(sessionDeadlineRunnable)
+ sessionDeadlineElapsed = null
+ sessionDeadlineToken = null
+ clearPersistedDeadline()
+ cancelSessionExpiryAlarm()
+ }
+
+ private fun enforceSessionDeadline() {
+ val token = sessionDeadlineToken ?: return
+ val deadline = sessionDeadlineElapsed ?: return
+ val config = currentConfig ?: return
+ if (config.RUNTIME_TOKEN != token) return
+ val remaining = deadline - SystemClock.elapsedRealtime()
+ if (remaining > 0L) {
+ deadlineHandler.postDelayed(sessionDeadlineRunnable, remaining)
+ return
+ }
+ Log.w(TAG, "Native session deadline expired; stopping runtime fail-closed")
+ stopAll(token)
+ }
+
+ private fun updateSessionDeadline(intent: Intent) {
+ val config = currentConfig ?: return
+ val token = intent.getStringExtra("RUNTIME_TOKEN").orEmpty()
+ if (token.isEmpty() || token != config.RUNTIME_TOKEN) {
+ Log.w(TAG, "Ignoring session deadline update for stale runtime generation")
+ return
+ }
+ val remainingSeconds = intent.getLongExtra("REMAINING_SECONDS", -1L)
+ if (remainingSeconds < 0L) {
+ Log.w(TAG, "Ignoring invalid negative session deadline")
+ return
+ }
+ armSessionDeadline(config, remainingSeconds)
+ }
+
+ private fun persistDeadline(runtimeToken: String, expiresAtEpochMs: Long): Boolean {
+ return try {
+ getSharedPreferences(DEADLINE_PREFS, Context.MODE_PRIVATE)
+ .edit()
+ .putString(PREF_RUNTIME_TOKEN, runtimeToken)
+ .putLong(PREF_EXPIRES_AT_MS, expiresAtEpochMs)
+ .commit()
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to persist non-secret session deadline", e)
+ false
+ }
+ }
+
+ private fun clearPersistedDeadline() {
+ try {
+ getSharedPreferences(DEADLINE_PREFS, Context.MODE_PRIVATE)
+ .edit()
+ .remove(PREF_RUNTIME_TOKEN)
+ .remove(PREF_EXPIRES_AT_MS)
+ .apply()
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to clear persisted session deadline", e)
+ }
+ }
+
+ private fun enforcePersistedDeadline(alarmToken: String) {
+ val stored = try {
+ val prefs = getSharedPreferences(DEADLINE_PREFS, Context.MODE_PRIVATE)
+ prefs.getString(PREF_RUNTIME_TOKEN, null).orEmpty() to
+ prefs.getLong(PREF_EXPIRES_AT_MS, 0L)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to read persisted session deadline", e)
+ clearPersistedDeadline()
+ cancelSessionExpiryAlarm()
+ stopSelf()
+ return
+ }
+
+ val storedToken = stored.first
+ val storedDeadline = stored.second
+ if (alarmToken.isEmpty() || storedToken.isEmpty() || alarmToken != storedToken) {
+ Log.w(TAG, "Ignoring stale session-expiry alarm")
+ if (currentConfig == null) stopSelf()
+ return
+ }
+
+ val remaining = storedDeadline - System.currentTimeMillis()
+ if (storedDeadline > 0L && remaining > 0L) {
+ if (!scheduleSessionExpiryAlarm(storedToken, storedDeadline)) {
+ Log.e(TAG, "Failed to re-arm early session-expiry alarm")
+ val activeToken = currentConfig?.RUNTIME_TOKEN.orEmpty()
+ if (activeToken == storedToken) stopAll(storedToken) else stopSelf()
+ return
+ }
+ // An alarm can wake a fresh service process after clock changes.
+ // If there is no live runtime in this process, do not leave a
+ // background service instance hanging around just to hold a timer.
+ if (currentConfig == null) stopSelf()
+ return
+ }
+
+ Log.w(TAG, "OS session-expiry alarm fired; stopping runtime fail-closed")
+ val activeToken = currentConfig?.RUNTIME_TOKEN.orEmpty()
+ if (activeToken.isNotEmpty() && activeToken == storedToken) {
+ stopAll(storedToken)
+ } else {
+ clearPersistedDeadline()
+ cancelSessionExpiryAlarm()
+ stopForeground(true)
+ stopSelf()
+ }
+ }
+
+ private fun scheduleSessionExpiryAlarm(runtimeToken: String, expiresAtEpochMs: Long): Boolean {
+ cancelSessionExpiryAlarm()
+ if (runtimeToken.isEmpty() || expiresAtEpochMs <= 0L) return false
+
+ return try {
+ val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
+ val pendingIntent = sessionExpiryPendingIntent(runtimeToken)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ try {
+ alarmManager.setExactAndAllowWhileIdle(
+ AlarmManager.RTC_WAKEUP,
+ expiresAtEpochMs,
+ pendingIntent,
+ )
+ } catch (_: SecurityException) {
+ Log.w(TAG, "Exact alarm unavailable; using inexact idle-capable expiry alarm")
+ alarmManager.setAndAllowWhileIdle(
+ AlarmManager.RTC_WAKEUP,
+ expiresAtEpochMs,
+ pendingIntent,
+ )
+ }
+ } else {
+ alarmManager.setExact(AlarmManager.RTC_WAKEUP, expiresAtEpochMs, pendingIntent)
+ }
+ true
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to schedule OS session-expiry alarm", e)
+ false
+ }
+ }
+
+ private fun cancelSessionExpiryAlarm() {
+ try {
+ val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
+ alarmManager.cancel(sessionExpiryPendingIntent(""))
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to cancel session-expiry alarm", e)
+ }
+ }
+
+ private fun sessionExpiryPendingIntent(runtimeToken: String): PendingIntent {
+ val intent = Intent(this, XrayVPNService::class.java)
+ .putExtra(EXTRA_SESSION_EXPIRED, true)
+ .putExtra("RUNTIME_TOKEN", runtimeToken)
+ val flags = PendingIntent.FLAG_UPDATE_CURRENT or
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
+ return PendingIntent.getService(this, SESSION_EXPIRY_REQUEST_CODE, intent, flags)
+ }
+
+ override fun onDestroy() {
+ if (!shuttingDownIntentionally) {
+ val confirmationToken = currentConfig?.RUNTIME_TOKEN.orEmpty()
+ cleanup(clearDeadline = false)
+ currentConfig = null
+ XrayCoreManager.stopCore(this, confirmationToken)
+ stopForeground(true)
+ }
+ super.onDestroy()
+ }
+
+ override fun onRevoke() {
+ stopAll()
+ super.onRevoke()
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channelId = "REVOLT_VPN_SERVICE"
+ val channelName = "VPN Service"
+ val channel = android.app.NotificationChannel(
+ channelId,
+ channelName,
+ android.app.NotificationManager.IMPORTANCE_LOW
+ )
+ val manager = getSystemService(android.app.NotificationManager::class.java)
+ manager?.createNotificationChannel(channel)
+ }
+ }
+
+ private fun createNotification(content: String): android.app.Notification {
+ val channelId = "REVOLT_VPN_SERVICE"
+ val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ android.app.Notification.Builder(this, channelId)
+ } else {
+ @Suppress("DEPRECATION")
+ android.app.Notification.Builder(this)
+ }
+
+ val icon = android.R.drawable.ic_dialog_info
+
+ val notification = builder
+ .setContentTitle("VPN Service")
+ .setContentText(content)
+ .setSmallIcon(icon)
+ .setOngoing(true)
+ .setAutoCancel(false)
+ .setOnlyAlertOnce(true)
+ .build()
+
+ notification.flags = notification.flags or
+ android.app.Notification.FLAG_ONGOING_EVENT or
+ android.app.Notification.FLAG_NO_CLEAR
+ return notification
+ }
+
+ companion object {
+ private const val TAG = "XrayVPNService"
+ private const val FOREGROUND_SERVICE_TYPE_SPECIAL_USE = 0x40000000
+ private const val BOOTSTRAP_SESSION_SECONDS = 120L
+ private const val DEADLINE_PREFS = "revolt_session_deadline"
+ private const val PREF_RUNTIME_TOKEN = "runtime_token"
+ private const val PREF_EXPIRES_AT_MS = "expires_at_ms"
+ private const val EXTRA_SESSION_EXPIRED = "SESSION_EXPIRED"
+ private const val SESSION_EXPIRY_REQUEST_CODE = 1001
+ }
+}
diff --git a/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/utils/AppConfigs.kt b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/utils/AppConfigs.kt
new file mode 100644
index 0000000..04bbc0b
--- /dev/null
+++ b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/utils/AppConfigs.kt
@@ -0,0 +1,49 @@
+package com.github.tfox.flutter_vless.xray.utils
+
+import com.github.tfox.flutter_vless.xray.dto.XrayConfig
+import java.io.Serializable
+
+/**
+ * Global application configuration and state.
+ *
+ * This object holds:
+ * 1. Constants for Intent actions and Service commands.
+ * 2. Enums for connection states and modes.
+ * 3. Global state variables (current connection state, active config).
+ * 4. Static storage for notification icon resources (passed from Flutter).
+ */
+object AppConfigs {
+ const val V2RAY_CONNECTION_INFO = "com.github.tfox.flutter_vless.xray.V2RAY_CONNECTION_INFO"
+
+ /**
+ * Commands sent to the XrayVPNService via Intent.
+ */
+ enum class V2RAY_SERVICE_COMMANDS : Serializable {
+ START_SERVICE, STOP_SERVICE, RESTART_SERVICE, UPDATE_SESSION_DEADLINE
+ }
+
+ /**
+ * Connection states for the Xray Core.
+ */
+ enum class V2RAY_STATES : Serializable {
+ V2RAY_CONNECTED, V2RAY_DISCONNECTED, V2RAY_CONNECTING
+ }
+
+ /**
+ * Connection modes:
+ * - VPN_TUN: Standard VPN mode using Android VpnService (TUN device).
+ * - PROXY_ONLY: Runs Xray as a local SOCKS/HTTP proxy without establishing a VPN tunnel.
+ */
+ enum class V2RAY_CONNECTION_MODES : Serializable {
+ VPN_TUN, PROXY_ONLY
+ }
+
+ var V2RAY_STATE: V2RAY_STATES = V2RAY_STATES.V2RAY_DISCONNECTED
+ @Volatile var RUNTIME_READY: Boolean = false
+ var V2RAY_CONFIG: XrayConfig? = null
+ var V2RAY_CONNECTION_MODE: V2RAY_CONNECTION_MODES = V2RAY_CONNECTION_MODES.VPN_TUN
+
+ // Stores the resource name and type for the custom notification icon
+ var NOTIFICATION_ICON_RESOURCE_NAME: String = ""
+ var NOTIFICATION_ICON_RESOURCE_TYPE: String = ""
+}
diff --git a/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/utils/Utilities.kt b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/utils/Utilities.kt
new file mode 100644
index 0000000..d2b9576
--- /dev/null
+++ b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/utils/Utilities.kt
@@ -0,0 +1,62 @@
+package com.github.tfox.flutter_vless.xray.utils
+
+import android.content.Context
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+
+object Utilities {
+
+ fun getUserAssetsPath(context: Context): String {
+ val dir = context.filesDir
+ if (!dir.exists()) {
+ dir.mkdirs()
+ }
+ return dir.absolutePath
+ }
+
+ fun copyAssets(context: Context) {
+ val assets = context.assets
+ val files = assets.list("") ?: return
+ for (filename in files) {
+ if (filename == "geoip.dat" || filename == "geosite.dat") {
+ var `in`: InputStream? = null
+ var out: OutputStream? = null
+ try {
+ `in` = assets.open(filename)
+ val outFile = File(getUserAssetsPath(context), filename)
+ out = FileOutputStream(outFile)
+ copyFile(`in`, out)
+ } catch (e: IOException) {
+ e.printStackTrace()
+ } finally {
+ if (`in` != null) {
+ try {
+ `in`.close()
+ } catch (e: IOException) {
+ e.printStackTrace()
+ }
+ }
+ if (out != null) {
+ try {
+ out.close()
+ } catch (e: IOException) {
+ e.printStackTrace()
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @Throws(IOException::class)
+ private fun copyFile(`in`: InputStream, out: OutputStream) {
+ val buffer = ByteArray(1024)
+ var read: Int
+ while (`in`.read(buffer).also { read = it } != -1) {
+ out.write(buffer, 0, read)
+ }
+ }
+}
diff --git a/local_packages/flutter_vless_android-1.1.5/android/src/test/kotlin/com/github/tfox/flutter_vless/xray/core/XrayCoreManagerTest.kt b/local_packages/flutter_vless_android-1.1.5/android/src/test/kotlin/com/github/tfox/flutter_vless/xray/core/XrayCoreManagerTest.kt
new file mode 100644
index 0000000..3b340f6
--- /dev/null
+++ b/local_packages/flutter_vless_android-1.1.5/android/src/test/kotlin/com/github/tfox/flutter_vless/xray/core/XrayCoreManagerTest.kt
@@ -0,0 +1,178 @@
+package com.github.tfox.flutter_vless.xray.core
+
+import com.github.tfox.flutter_vless.xray.dto.XrayConfig
+import java.io.File
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Test
+
+class XrayCoreManagerTest {
+ @Test
+ fun buildRuntimeConfigJson_keepsSecureSocksAndSanitizesLogs() {
+ val filesDir = File("build/test-files/runtime-secure")
+ val config = XrayConfig(
+ V2RAY_FULL_JSON_CONFIG = runtimeConfig(
+ extraLog = """
+ "log": {
+ "access": "/tmp/access.log",
+ "error": "/tmp/error.log"
+ },
+ """.trimIndent(),
+ )
+ )
+
+ val output = XrayCoreManager.buildRuntimeConfigJson(config, filesDir)
+ val log = output.getJSONObject("log")
+ val inbound = output.getJSONArray("inbounds").getJSONObject(0)
+ val user = output
+ .getJSONArray("outbounds")
+ .getJSONObject(0)
+ .getJSONObject("settings")
+ .getJSONArray("vnext")
+ .getJSONObject(0)
+ .getJSONArray("users")
+ .getJSONObject(0)
+
+ assertFalse(log.has("access"))
+ assertEquals(File(filesDir, "error.log").absolutePath, log.getString("error"))
+ assertEquals("revolt-secure-socks", inbound.getString("tag"))
+ assertEquals("password", inbound.getJSONObject("settings").getString("auth"))
+ assertEquals(19080, config.LOCAL_SOCKS5_PORT)
+ assertEquals(0, config.LOCAL_HTTP_PORT)
+ assertEquals(vlessEncryption, user.getString("encryption"))
+ assertEquals("xtls-rprx-vision", user.getString("flow"))
+ }
+
+ @Test
+ fun buildRuntimeConfigJson_rejectsInvalidSecureSocks() {
+ val config = XrayConfig(
+ V2RAY_FULL_JSON_CONFIG = """
+ {
+ "inbounds": [
+ {
+ "tag": "revolt-secure-socks",
+ "listen": "127.0.0.1",
+ "port": 19080,
+ "protocol": "socks",
+ "settings": { "auth": "noauth", "users": [] }
+ }
+ ],
+ "outbounds": []
+ }
+ """.trimIndent()
+ )
+
+ try {
+ XrayCoreManager.buildRuntimeConfigJson(
+ config,
+ File("build/test-files/runtime-invalid-socks"),
+ )
+ fail("Invalid secure SOCKS5 configuration must fail closed")
+ } catch (expected: IllegalStateException) {
+ assertTrue(expected.message.orEmpty().contains("Secure SOCKS5"))
+ }
+ }
+
+ @Test
+ fun buildRuntimeConfigJson_normalizesXrayRuntimeAliases() {
+ val config = XrayConfig(
+ V2RAY_FULL_JSON_CONFIG = """
+ {
+ "inbounds": [
+ {
+ "tag": "revolt-secure-socks",
+ "listen": "127.0.0.1",
+ "port": 19080,
+ "protocol": "socks",
+ "settings": {
+ "auth": "password",
+ "users": [{"user": "runtime-user", "pass": "runtime-pass"}]
+ }
+ }
+ ],
+ "outbounds": [
+ {
+ "protocol": "vless",
+ "settings": {
+ "vnext": [
+ {
+ "address": "example.com",
+ "port": 443,
+ "users": [{"id": "11111111-1111-4111-8111-111111111111", "encryption": "none"}]
+ }
+ ]
+ },
+ "streamSettings": {
+ "network": "XHTTP",
+ "security": "tls",
+ "tlsSettings": {"allowInsecure": false, "serverName": "example.com"},
+ "xHTTPSettings": {"path": "/", "mode": "auto"},
+ "httpUpgradeSettings": {"path": "/upgrade"},
+ "splitHTTPSettings": {"path": "/split"}
+ }
+ }
+ ]
+ }
+ """.trimIndent()
+ )
+
+ val output = XrayCoreManager.buildRuntimeConfigJson(
+ config,
+ File("build/test-files/runtime-aliases"),
+ )
+ val stream = output
+ .getJSONArray("outbounds")
+ .getJSONObject(0)
+ .getJSONObject("streamSettings")
+
+ assertEquals("xhttp", stream.getString("network"))
+ assertTrue(stream.has("xhttpSettings"))
+ assertTrue(stream.has("httpupgradeSettings"))
+ assertTrue(stream.has("splithttpSettings"))
+ assertFalse(stream.has("xHTTPSettings"))
+ assertFalse(stream.has("httpUpgradeSettings"))
+ assertFalse(stream.has("splitHTTPSettings"))
+ assertFalse(stream.getJSONObject("tlsSettings").has("allowInsecure"))
+ }
+
+ private fun runtimeConfig(extraLog: String = ""): String = """
+ {
+ $extraLog
+ "inbounds": [
+ {
+ "tag": "revolt-secure-socks",
+ "listen": "127.0.0.1",
+ "port": 19080,
+ "protocol": "socks",
+ "settings": {
+ "auth": "password",
+ "users": [{"user": "runtime-user", "pass": "runtime-pass"}]
+ }
+ }
+ ],
+ "outbounds": [
+ {
+ "tag": "proxy",
+ "protocol": "vless",
+ "settings": {
+ "address": "xhttp.example.com",
+ "port": 443,
+ "id": "b94da146-a56e-49d7-af4c-a68c9065cbfd",
+ "encryption": "$vlessEncryption",
+ "flow": "xtls-rprx-vision",
+ "level": 8
+ },
+ "streamSettings": {"network": "xhttp", "security": "none"}
+ }
+ ]
+ }
+ """.trimIndent()
+
+ private companion object {
+ const val vlessEncryption =
+ "mlkem768x25519plus.native.1rtt.100-500-2000.75-0-100.80-0-5000.gtmOXB2AN_r905czmOIr6dKq_YDdEJB8RWGqfsXurns"
+ }
+}
diff --git a/local_packages/flutter_vless_android-1.1.5/lib/flutter_vless_android.dart b/local_packages/flutter_vless_android-1.1.5/lib/flutter_vless_android.dart
new file mode 100644
index 0000000..8e30737
--- /dev/null
+++ b/local_packages/flutter_vless_android-1.1.5/lib/flutter_vless_android.dart
@@ -0,0 +1,13 @@
+// Copyright (c) 2024-2026 13FOX Studio / tfox.dev.
+// SPDX-License-Identifier: MIT
+
+import 'package:flutter_vless_platform_interface/flutter_vless_platform_interface.dart';
+
+/// Android implementation of [VlessPlatform] using MethodChannel.
+class FlutterVlessAndroid extends VlessMethodChannelAdapter {
+ /// Registers this class as the platform implementation.
+ /// This is called automatically by Flutter's plugin registry.
+ static void registerWith() {
+ VlessPlatform.instance = FlutterVlessAndroid();
+ }
+}
diff --git a/local_packages/flutter_vless_android-1.1.5/pubspec.yaml b/local_packages/flutter_vless_android-1.1.5/pubspec.yaml
new file mode 100644
index 0000000..abd0c63
--- /dev/null
+++ b/local_packages/flutter_vless_android-1.1.5/pubspec.yaml
@@ -0,0 +1,31 @@
+name: flutter_vless_android
+description: Android implementation of the flutter_vless plugin.
+version: 1.1.5
+homepage: https://tfox.dev
+repository: https://github.com/XIIIFOX/flutter_vless
+issue_tracker: https://github.com/XIIIFOX/flutter_vless/issues
+documentation: https://github.com/XIIIFOX/flutter_vless/blob/main/doc/platform/android.md
+funding:
+ - https://tfox.dev
+
+environment:
+ sdk: '>=3.4.0 <4.0.0'
+ flutter: '>=3.3.0'
+
+dependencies:
+ flutter:
+ sdk: flutter
+ flutter_vless_platform_interface: ^1.1.0
+
+dev_dependencies:
+ flutter_test:
+ sdk: flutter
+ flutter_lints: ^6.0.0
+
+flutter:
+ plugin:
+ implements: flutter_vless_platform_interface
+ platforms:
+ android:
+ package: com.github.tfox.flutter_vless
+ pluginClass: FlutterVlessPlugin
diff --git a/pubspec.lock b/pubspec.lock
index 1369a33..2af4fa8 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -65,14 +65,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
- cupertino_icons:
- dependency: "direct main"
- description:
- name: cupertino_icons
- sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
- url: "https://pub.dev"
- source: hosted
- version: "1.0.9"
fake_async:
dependency: transitive
description:
@@ -180,12 +172,11 @@ packages:
source: hosted
version: "1.1.5"
flutter_vless_android:
- dependency: transitive
+ dependency: "direct overridden"
description:
- name: flutter_vless_android
- sha256: "5e6fe73d3606ed8c73e87fac15efa7631e12ce18d39a4bd4e13b6c097c6d52b9"
- url: "https://pub.dev"
- source: hosted
+ path: "local_packages/flutter_vless_android-1.1.5"
+ relative: true
+ source: path
version: "1.1.5"
flutter_vless_macos:
dependency: transitive
@@ -216,14 +207,6 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
- google_fonts:
- dependency: "direct main"
- description:
- name: google_fonts
- sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
- url: "https://pub.dev"
- source: hosted
- version: "6.3.3"
google_mobile_ads:
dependency: "direct main"
description:
@@ -316,10 +299,10 @@ packages:
dependency: transitive
description:
name: matcher
- sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
+ sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
- version: "0.12.19"
+ version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
@@ -332,10 +315,10 @@ packages:
dependency: transitive
description:
name: meta
- sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
+ sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.dev"
source: hosted
- version: "1.18.0"
+ version: "1.19.0"
nested:
dependency: transitive
description:
@@ -385,7 +368,7 @@ packages:
source: hosted
version: "1.9.1"
path_provider:
- dependency: "direct main"
+ dependency: transitive
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
@@ -577,10 +560,10 @@ packages:
dependency: transitive
description:
name: test_api
- sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
+ sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev"
source: hosted
- version: "0.7.11"
+ version: "0.7.12"
typed_data:
dependency: transitive
description:
@@ -665,10 +648,10 @@ packages:
dependency: transitive
description:
name: vector_math
- sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
+ sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
url: "https://pub.dev"
source: hosted
- version: "2.2.0"
+ version: "2.4.2"
vm_service:
dependency: transitive
description:
@@ -742,5 +725,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
- dart: ">=3.10.3 <4.0.0"
+ dart: ">=3.11.0-0 <4.0.0"
flutter: ">=3.38.4"
diff --git a/pubspec.yaml b/pubspec.yaml
index 4308f0d..eb335df 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,21 +1,9 @@
name: revoltvpn
description: ReVolt VPN — Secure VLESS tunnel for Android
-# The following line prevents the package from being accidentally published to
-# pub.dev using `pub publish`. This is preferred for private packages.
-publish_to: "none" # Remove this line if you wish to publish to pub.dev
+publish_to: "none"
-# The following defines the version and build number for your application.
-# A version number is three numbers separated by dots, like 1.2.43
-# followed by an optional build number separated by a +.
-# Both the version and the builder number may be overridden in flutter
-# build by specifying --build-name and --build-number, respectively.
-# In Android, build-name is used as versionName while build-number used as versionCode.
-# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
-# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
-# Read more about iOS versioning at
-# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
-version: 3.3.4+30
+version: 3.3.5+31
environment:
sdk: ">=3.0.0 <4.0.0"
@@ -24,10 +12,6 @@ dependencies:
flutter:
sdk: flutter
- # The following adds the Cupertino Icons font to your application.
- # Use with the CupertinoIcons class for iOS style icons.
- cupertino_icons: ^1.0.5
- google_fonts: ^6.1.0
provider: ^6.1.1
google_mobile_ads: ^4.0.0
flutter_vless: 1.1.5
@@ -36,7 +20,6 @@ dependencies:
url_launcher: ^6.3.2
flutter_secure_storage: ^10.3.1
uuid: ^4.5.3
- path_provider: ^2.1.5
package_info_plus: ^6.0.0
dev_dependencies:
@@ -44,42 +27,13 @@ dev_dependencies:
sdk: flutter
flutter_lints: ^3.0.0
-# For information on the generic Dart part of this file, see the
-# following page: https://dart.dev/tools/pub/pubspec
-
-# The following section is specific to Flutter.
flutter:
- # The following line ensures that the Material Icons font is
- # included with your application, so that you can use the icons in
- # the material Icons class.
uses-material-design: true
-
- # To add assets to your application, add an assets section, like this:
assets:
- - assets/
+ - assets/background.png
+ - assets/brand.png
+ - assets/finland_flag_256.png
- # An image asset can refer to one or more resolution-specific "variants", see
- # https://flutter.dev/assets-and-images/#resolution-aware.
-
- # For details regarding adding assets from package dependencies, see
- # https://flutter.dev/assets-and-images/#from-packages
-
- # To add custom fonts to your application, add a fonts section here,
- # in this "flutter" section. Each entry in this list should have a
- # "family" key with the font family name, and a "fonts" key with a
- # list giving the asset and other descriptors for the font. For
- # example:
- # fonts:
- # - family: Schyler
- # fonts:
- # - asset: fonts/Schyler-Regular.ttf
- # - asset: fonts/Schyler-Italic.ttf
- # style: italic
- # - family: Trajan Pro
- # fonts:
- # - asset: fonts/TrajanPro.ttf
- # - asset: fonts/TrajanPro_Bold.ttf
- # weight: 700
- #
- # For details regarding fonts from package dependencies,
- # see https://flutter.dev/custom-fonts/#from-packages
+dependency_overrides:
+ flutter_vless_android:
+ path: local_packages/flutter_vless_android-1.1.5
diff --git a/test/native_runtime_state_regression_test.dart b/test/native_runtime_state_regression_test.dart
new file mode 100644
index 0000000..90b6a59
--- /dev/null
+++ b/test/native_runtime_state_regression_test.dart
@@ -0,0 +1,98 @@
+import 'dart:io';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ test('native runtime is generation-scoped, readiness-gated and deadline-bound', () {
+ const root = 'local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless';
+ final plugin = File('$root/FlutterVlessPlugin.kt').readAsStringSync();
+ final config = File('$root/xray/dto/XrayConfig.kt').readAsStringSync();
+ final service = File('$root/xray/service/XrayVPNService.kt').readAsStringSync();
+ final core = File('$root/xray/core/XrayCoreManager.kt').readAsStringSync();
+ final timer = File('lib/logic/session_timer.dart').readAsStringSync();
+ final vpn = File('lib/logic/vpn_connection.dart').readAsStringSync();
+ final hivemind = File('lib/logic/hivemind_service.dart').readAsStringSync();
+ final main = File('lib/main.dart').readAsStringSync();
+ final mainActivity = File(
+ 'android/app/src/main/kotlin/com/paladinvpn/app/MainActivity.kt',
+ ).readAsStringSync();
+ final support = File('lib/components/support_button.dart').readAsStringSync();
+ final connectionSettings =
+ File('lib/logic/connection_settings.dart').readAsStringSync();
+
+ expect(config, contains('var RUNTIME_TOKEN: String = ""'));
+ expect(plugin, contains('expectedRuntimeToken'));
+ expect(plugin, contains('runtimeToken != currentToken'));
+ expect(plugin, contains('runtimeReady'));
+ expect(plugin, contains('STOP_TIMEOUT'));
+ expect(plugin, contains('setSessionDeadline'));
+ expect(plugin, contains('result.error("NO_ACTIVITY"'));
+ expect(plugin, isNot(contains('activity!!')));
+
+ expect(plugin, contains('if (token == null) {'));
+ expect(plugin, contains('intent.putExtra("RUNTIME_TOKEN", token)'));
+ expect(
+ plugin,
+ isNot(contains('if (token != null) intent.putExtra("RUNTIME_TOKEN", token)')),
+ );
+ expect(plugin, isNot(contains('"getServerDelay" ->')));
+ expect(plugin, isNot(contains('"getConnectedServerDelay" ->')));
+
+ expect(
+ service,
+ contains('val requestedToken = intent.getStringExtra("RUNTIME_TOKEN").orEmpty()'),
+ );
+ expect(service, contains('requestedToken.isEmpty() ||'));
+ expect(service, contains('requestedToken != activeToken'));
+ expect(service, contains('stopAll(requestedToken)'));
+
+ expect(hivemind, contains('..followRedirects = false;'));
+
+ expect(plugin, contains('failPendingPermission("ENGINE_DETACHED"'));
+ expect(plugin, contains('failPendingPermission("NO_ACTIVITY"'));
+
+ expect(service, contains('BOOTSTRAP_SESSION_SECONDS = 120L'));
+ expect(service, contains('UPDATE_SESSION_DEADLINE'));
+ expect(service, contains('SystemClock.elapsedRealtime()'));
+ expect(service, contains('AlarmManager.RTC_WAKEUP'));
+ expect(service, contains('setExactAndAllowWhileIdle'));
+ expect(service, contains('setAndAllowWhileIdle'));
+ expect(service, contains('PREF_EXPIRES_AT_MS'));
+ expect(service, contains('PREF_RUNTIME_TOKEN'));
+ expect(service, contains('markRuntimeReady(this, config)'));
+ expect(service, contains('if (currentConfig == null) stopSelf()'));
+ expect(service, isNot(contains('sessionDeadlineEpochMs')));
+ expect(service, isNot(contains('active_session.bin')));
+ expect(service, isNot(contains('ObjectOutputStream')));
+
+ expect(core, contains('AppConfigs.RUNTIME_READY = false'));
+ expect(core, contains('fun markRuntimeReady'));
+ expect(timer, contains('setNativeSessionDeadline(_remainingAtLastSync)'));
+ expect(timer, contains('Future.microtask(_onVpnConnectionChanged)'));
+ expect(timer, contains('Completer? _syncCompletion'));
+ expect(timer, contains('await active.future;'));
+ expect(timer, contains('_sessionEpoch++;'));
+ expect(timer, contains('_supportStateEpoch++;'));
+ expect(timer, contains('_isDisconnecting = true;'));
+
+ expect(vpn, contains('bool _disposed = false;'));
+ expect(vpn, contains('if (_disposed) return;'));
+ expect(vpn, contains('_connectEpoch++;'));
+ expect(vpn, contains('HivemindService.cancel();'));
+
+ expect(main, contains('ChangeNotifierProxyProvider('));
+ expect(main, contains('lazy: false'));
+ expect(support, contains('await timer.syncNow();'));
+ expect(vpn, contains("MethodChannel('flutter_vless')"));
+
+ expect(mainActivity, contains('manager.activeNotifications'));
+ expect(mainActivity, contains('serviceStopAction'));
+ expect(mainActivity, isNot(contains('V2RAY_SERVICE_COMMANDS.STOP_SERVICE')));
+ expect(mainActivity, isNot(contains('XrayVPNService::class.java')));
+
+ expect(
+ connectionSettings,
+ contains('storedMode == ConnectionMode.proxy.name'),
+ );
+ expect(connectionSettings, isNot(contains("storedMode == 'ass'")));
+ });
+}
diff --git a/test/runtime_boundary_regression_test.dart b/test/runtime_boundary_regression_test.dart
new file mode 100644
index 0000000..804e1fa
--- /dev/null
+++ b/test/runtime_boundary_regression_test.dart
@@ -0,0 +1,58 @@
+import 'dart:io';
+
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ test('notification updates do not trust ActivityManager process visibility', () {
+ final source = File(
+ 'android/app/src/main/kotlin/com/paladinvpn/app/MainActivity.kt',
+ ).readAsStringSync();
+
+ expect(source, isNot(contains('ActivityManager')));
+ expect(source, isNot(contains('isVpnServiceProcessAlive')));
+ expect(source, isNot(contains('VPN_PROCESS_SUFFIX')));
+ expect(source, contains('NotificationManagerCompat.from(this).notify'));
+ });
+
+ test('SOCKS reads use one total deadline instead of per-chunk timeout', () {
+ final source = File('lib/logic/local_socks_tester.dart').readAsStringSync();
+
+ expect(
+ source,
+ contains('timeout.inMicroseconds - elapsed.elapsedMicroseconds'),
+ );
+ expect(
+ source,
+ contains("throw TimeoutException('Socket read timed out', timeout);"),
+ );
+ });
+
+ test('Hivemind captures cancellation generation before first async lookup', () {
+ final source = File('lib/logic/hivemind_service.dart').readAsStringSync();
+ final callIdIndex = source.indexOf('final callId = ++_currentCallId;');
+ final deviceIdIndex =
+ source.indexOf('final deviceId = await CryptoService.getDeviceId();');
+
+ expect(callIdIndex, greaterThanOrEqualTo(0));
+ expect(deviceIdIndex, greaterThan(callIdIndex));
+ expect(
+ source.substring(deviceIdIndex),
+ contains('_throwIfCancelled(callId);'),
+ );
+ expect(
+ source,
+ contains('_currentCallId++;\n _expectedNonce = null;'),
+ );
+ });
+
+ test('notification text is cached only after native update succeeds', () {
+ final source =
+ File('lib/logic/notification_service.dart').readAsStringSync();
+ final invokeIndex =
+ source.indexOf("await _channel.invokeMethod('updateNotificationText'");
+ final cacheIndex = source.indexOf('_lastText = text;');
+
+ expect(invokeIndex, greaterThanOrEqualTo(0));
+ expect(cacheIndex, greaterThan(invokeIndex));
+ });
+}
diff --git a/test/security_regression_test.dart b/test/security_regression_test.dart
new file mode 100644
index 0000000..1a43145
--- /dev/null
+++ b/test/security_regression_test.dart
@@ -0,0 +1,230 @@
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:revoltvpn/logic/secure_socks_session.dart';
+
+void main() {
+ test('secure SOCKS session owns the entire inbound surface', () async {
+ final session = await SecureSocksSession.create(jsonEncode({
+ 'inbounds': [
+ {
+ 'tag': 'remote-listener',
+ 'listen': '0.0.0.0',
+ 'port': 9999,
+ 'protocol': 'dokodemo-door',
+ },
+ {
+ 'tag': 'old-http',
+ 'listen': '127.0.0.1',
+ 'port': 8080,
+ 'protocol': 'http',
+ },
+ ],
+ 'outbounds': [
+ {'tag': 'proxy', 'protocol': 'freedom'}
+ ],
+ }));
+
+ final decoded = jsonDecode(session.configJson) as Map;
+ final inbounds = decoded['inbounds'] as List;
+ expect(inbounds, hasLength(1));
+
+ final inbound = inbounds.single as Map;
+ expect(inbound['tag'], SecureSocksSession.inboundTag);
+ expect(inbound['listen'], '127.0.0.1');
+ expect(inbound['protocol'], 'socks');
+ expect(inbound['port'], session.port);
+
+ final settings = inbound['settings'] as Map;
+ expect(settings['auth'], 'password');
+ final users = settings['users'] as List;
+ expect(users, hasLength(1));
+ final account = users.single as Map;
+ expect(account['user'], session.username);
+ expect(account['pass'], session.password);
+ });
+
+ test('vendored native source keeps full-tunnel and private IPC invariants', () {
+ const root =
+ 'local_packages/flutter_vless_android-1.1.5/android/src/main';
+ final service = File(
+ '$root/kotlin/com/github/tfox/flutter_vless/xray/service/XrayVPNService.kt',
+ ).readAsStringSync();
+ final core = File(
+ '$root/kotlin/com/github/tfox/flutter_vless/xray/core/XrayCoreManager.kt',
+ ).readAsStringSync();
+ final plugin = File(
+ '$root/kotlin/com/github/tfox/flutter_vless/FlutterVlessPlugin.kt',
+ ).readAsStringSync();
+ final manifest = File('$root/AndroidManifest.xml').readAsStringSync();
+
+ expect(service, contains('builder.addRoute("0.0.0.0", 0)'));
+ expect(service, contains('builder.addRoute("::", 0)'));
+ expect(service, contains('Android refused to establish VPN interface'));
+ expect(service, contains('Per-app VPN bypass is disabled in ReVolt'));
+ expect(service, contains('Failed to configure VPN DNS'));
+ expect(service, contains('reader.forEachLine { _ -> }'));
+
+ expect(plugin, contains('ContextCompat.RECEIVER_NOT_EXPORTED'));
+ expect(core, contains('.setPackage(context.packageName)'));
+ expect(core, contains('REVOLT_VPN_SERVICE'));
+ expect(core, contains('NotificationManager.IMPORTANCE_LOW'));
+ expect(core, contains('deleteEphemeralConfigOnFailure'));
+ expect(core, contains('reader.forEachLine { _ -> }'));
+
+ expect(manifest, contains('android:exported="false"'));
+ expect(manifest, contains('android:permission="android.permission.BIND_VPN_SERVICE"'));
+ expect(manifest, contains('android.net.VpnService.SUPPORTS_ALWAYS_ON'));
+ expect(manifest, contains('android:value="false"'));
+ expect(manifest, isNot(contains('android.permission.CHANGE_NETWORK_STATE')));
+ expect(manifest, isNot(contains('android.permission.READ_EXTERNAL_STORAGE')));
+ });
+
+ test('native app bridge keeps runtime adoption on private heartbeat only', () {
+ final source = File(
+ 'android/app/src/main/kotlin/com/paladinvpn/app/MainActivity.kt',
+ ).readAsStringSync();
+ final vpnSource = File('lib/logic/vpn_connection.dart').readAsStringSync();
+ final powerSource = File('lib/logic/power_settings.dart').readAsStringSync();
+
+ expect(source, isNot(contains('openVpnSettings')));
+ expect(source, isNot(contains('ACTION_VPN_SETTINGS')));
+ expect(source, isNot(contains('FROM_DISCONNECT_BTN')));
+ expect(source, isNot(contains('isVpnRuntimeAlive')));
+ expect(source, isNot(contains('isVpnServiceProcessAlive')));
+ expect(source, contains('REVOLT_VPN_SERVICE'));
+
+ expect(vpnSource, contains('if (_connectEpoch == 0) _adoptedRunningRuntime = true;'));
+ expect(vpnSource, isNot(contains('PowerSettings.runtimeState')));
+ expect(vpnSource, isNot(contains('_adoptRunningRuntime')));
+ expect(powerSource, isNot(contains('runtimeState')));
+ expect(powerSource, isNot(contains('VpnRuntimeState')));
+ });
+
+ test('configured AdMob bypass contract remains intact', () {
+ final source = File('lib/logic/hivemind_service.dart').readAsStringSync();
+
+ expect(source, contains('await _runConfiguredBypass(deviceId, nonce);'));
+ expect(
+ source,
+ contains('/admob/callback?signature=test&key_id=test&custom_data='),
+ );
+ });
+
+ test('local SOCKS diagnostics cannot fall back to unauthenticated legacy mode', () {
+ final source = File('lib/logic/local_socks_tester.dart').readAsStringSync();
+
+ expect(source, contains('required int port'));
+ expect(source, contains('required String username'));
+ expect(source, contains('required String password'));
+ expect(source, contains('const [0x05, 0x01, 0x02]'));
+ expect(source, isNot(contains('const [0x05, 0x01, 0x00]')));
+ expect(source, isNot(contains('int port = 10807')));
+ expect(source, isNot(contains('paladinvpn.duckdns.org')));
+ expect(source, contains("String targetHost = 'example.com'"));
+ expect(source, contains('const totalTimeout = Duration(seconds: 8);'));
+ });
+
+ test('hivemind rejects missing nonce and stale session responses', () {
+ final source = File('lib/logic/hivemind_service.dart').readAsStringSync();
+
+ expect(source, contains('_expectedNonce != nonce ||'));
+ expect(source, contains('serverNonce is! String ||'));
+ expect(
+ source,
+ contains(
+ 'final session = await _fetchActiveSession(deviceId, nonce);\n'
+ ' _throwIfCancelled(callId);',
+ ),
+ );
+ });
+
+ test('hivemind validates and pins session fields before constructing VLESS', () {
+ final source = File('lib/logic/hivemind_service.dart').readAsStringSync();
+
+ expect(source, contains('_uuidPattern'));
+ expect(source, contains('_maxControlResponseBytes = 256 * 1024'));
+ expect(source, contains('controlGet('));
+ expect(source, contains('_validatedPublicIp(AppConfig.serverIp'));
+ expect(source, contains('const port = 443;'));
+ expect(source, contains("_requiredString(json, 'reality_pbk', maxLength: 128)"));
+ expect(source, contains('_validatedHost('));
+ expect(
+ source,
+ contains("final pinnedHost = _validatedPublicIp(AppConfig.serverIp, 'serverIp');"),
+ );
+ expect(source, contains('advertisedHost.trim() != pinnedHost'));
+ expect(
+ source,
+ isNot(contains("_stringOr(json['vless_ip'], AppConfig.serverIp)")),
+ );
+ expect(source, contains('return Uri('));
+ expect(source, contains('queryParameters: {'));
+ expect(source, isNot(contains("return 'vless://\$uuid@\$host:\$port'")));
+ });
+
+ test('VPN connect work cannot outlive its generation', () {
+ final source = File('lib/logic/vpn_connection.dart').readAsStringSync();
+
+ expect(source, contains('int _connectEpoch = 0;'));
+ expect(source, contains('final connectEpoch = ++_connectEpoch;'));
+ expect(source, contains('epoch == _connectEpoch && !_userDisconnecting'));
+ expect(source, contains('_connectEpoch++;\n _suppressNativeConnect = true;'));
+ expect(source, contains('if (_suppressNativeConnect || _userDisconnecting) return;'));
+ expect(source, isNot(contains('bool _cancelled = false;')));
+ });
+
+ test('native recovery is fail-closed without hot process restart loops', () {
+ const root =
+ 'local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless';
+ final service =
+ File('$root/xray/service/XrayVPNService.kt').readAsStringSync();
+ final core = File('$root/xray/core/XrayCoreManager.kt').readAsStringSync();
+
+ expect(service, contains('scheduleTun2socksRecovery'));
+ expect(service, contains('tun2socksRecoveryAttempt'));
+ expect(service, contains('coerceAtMost(30_000L)'));
+ expect(service, contains('recoveringTun2socks = false'));
+ expect(service, contains('tun2socksRecoveryAttempt = 0'));
+ expect(service, contains('XrayCoreManager.markRuntimeConnecting(this, config)'));
+ expect(service, isNot(contains('Thread.sleep(350)')));
+ expect(service, isNot(contains('Thread.sleep(600)')));
+ expect(
+ service,
+ isNot(contains('attempt = 0\n Thread.sleep(3000)')),
+ );
+
+ expect(core, contains('context.handleXrayCoreExit(config)'));
+ expect(core, contains('AppConfigs.RUNTIME_READY = false'));
+ });
+
+ test('data disclosure only persists an explicit acknowledged action', () {
+ final source =
+ File('lib/components/data_disclosure_dialog.dart').readAsStringSync();
+
+ expect(source, contains('final accepted = await showDialog('));
+ expect(source, contains('if (accepted != true) return;'));
+ expect(source, contains('final saved = await prefs.setBool(_key, true);'));
+ expect(source, contains('if (!saved)'));
+ expect(source, contains('Navigator.of(context).pop(true)'));
+ expect(source, isNot(contains('prefs.setBool(_key, true);\n Navigator')));
+ });
+
+ test('unsupported server picker is not wired into app state', () {
+ final mainSource = File('lib/main.dart').readAsStringSync();
+ final statusBar = File('lib/components/status_bar.dart').readAsStringSync();
+
+ expect(mainSource, isNot(contains('ServerList')));
+ expect(statusBar, isNot(contains('SelectionScreen')));
+ });
+
+ test('session timer rejects stale session work by generation', () {
+ final source = File('lib/logic/session_timer.dart').readAsStringSync();
+
+ expect(source, contains('_sessionEpoch'));
+ expect(source, contains('Stopwatch'));
+ expect(source, contains("_doDisconnect('VPN entered error state')"));
+ expect(source, isNot(contains('_stopForVpnFailure')));
+ });
+}
diff --git a/tool/patch_flutter_vless_runtime_recovery.dart b/tool/patch_flutter_vless_runtime_recovery.dart
deleted file mode 100644
index cdc676d..0000000
--- a/tool/patch_flutter_vless_runtime_recovery.dart
+++ /dev/null
@@ -1,119 +0,0 @@
-import 'dart:convert';
-import 'dart:io';
-
-const packageName = 'flutter_vless_android';
-const expectedVersionFragment = 'flutter_vless_android-1.1.5';
-
-Never fail(String message) {
- stderr.writeln('[flutter_vless recovery patch] $message');
- exit(1);
-}
-
-Directory packageRoot() {
- final configFile = File('.dart_tool/package_config.json');
- if (!configFile.existsSync()) {
- fail('.dart_tool/package_config.json is missing; run flutter pub get first');
- }
-
- final decoded = jsonDecode(configFile.readAsStringSync());
- if (decoded is! Map || decoded['packages'] is! List) {
- fail('package_config.json is invalid');
- }
-
- Map? package;
- for (final item in decoded['packages'] as List) {
- if (item is Map && item['name'] == packageName) {
- package = Map.from(item);
- break;
- }
- }
- if (package == null) fail('$packageName was not found');
-
- final rootUriValue = package['rootUri'];
- if (rootUriValue is! String || rootUriValue.isEmpty) {
- fail('$packageName has no rootUri');
- }
-
- final rootUri = Uri.parse(rootUriValue);
- final resolved = rootUri.hasScheme
- ? rootUri
- : configFile.parent.absolute.uri.resolveUri(rootUri);
- if (resolved.scheme != 'file') fail('unsupported package URI: $resolved');
-
- final root = Directory.fromUri(resolved);
- final normalized = root.absolute.path.replaceAll('\\', '/');
- if (!normalized.contains(expectedVersionFragment)) {
- fail('expected flutter_vless_android 1.1.5, got ${root.path}');
- }
- if (!root.existsSync()) fail('package root does not exist: ${root.path}');
- return root;
-}
-
-void replaceOnce(
- File file,
- String oldValue,
- String newValue,
- String marker,
-) {
- if (!file.existsSync()) fail('missing upstream source: ${file.path}');
- final text = file.readAsStringSync();
- if (text.contains(marker)) {
- stdout.writeln('[flutter_vless recovery patch] already current: ${file.path}');
- return;
- }
- if (!text.contains(oldValue)) {
- fail('upstream source changed; expected block not found in ${file.path}');
- }
- file.writeAsStringSync(text.replaceFirst(oldValue, newValue));
- stdout.writeln('[flutter_vless recovery patch] patched: ${file.path}');
-}
-
-void main() {
- final root = packageRoot();
- final kotlin = Directory(
- '${root.path}/android/src/main/kotlin/com/github/tfox/flutter_vless',
- );
-
- final serviceFile = File('${kotlin.path}/xray/service/XrayVPNService.kt');
- if (!serviceFile.existsSync()) {
- fail('pinned Android runtime service source is missing');
- }
-
- // A fast reconnect must not leave the previous Xray process owning the local
- // SOCKS/HTTP ports.
- replaceOnce(
- serviceFile,
- ''' if (config != null) {
- // Ensure clean state before starting
- cleanup()
-''',
- ''' if (config != null) {
- // A fast reconnect must not leave the previous Xray process
- // owning the local SOCKS/HTTP ports.
- if (XrayCoreManager.isXrayRunning()) {
- Log.w(TAG, "Stopping stale Xray core before restart")
- XrayCoreManager.stopCore(this)
- }
- cleanup()
-''',
- 'Stopping stale Xray core before restart',
- );
-
- // The tun2socks Unix socket must not survive a previous session.
- replaceOnce(
- serviceFile,
- ''' val tun2socksPath = File(applicationInfo.nativeLibraryDir, "libtun2socks.so").absolutePath
- val sockPath = File(filesDir, "sock_path").absolutePath
-''',
- ''' val tun2socksPath = File(applicationInfo.nativeLibraryDir, "libtun2socks.so").absolutePath
- val socketFile = File(filesDir, "sock_path")
- if (socketFile.exists() && !socketFile.delete()) {
- Log.w(TAG, "Could not delete stale tun2socks socket")
- }
- val sockPath = socketFile.absolutePath
-''',
- 'Could not delete stale tun2socks socket',
- );
-
- stdout.writeln('[flutter_vless recovery patch] runtime recovery patch is ready');
-}
diff --git a/tool/patch_flutter_vless_secure_socks_scope_fix.dart b/tool/patch_flutter_vless_secure_socks_scope_fix.dart
deleted file mode 100644
index c157c5a..0000000
--- a/tool/patch_flutter_vless_secure_socks_scope_fix.dart
+++ /dev/null
@@ -1,154 +0,0 @@
-import 'dart:convert';
-import 'dart:io';
-
-const packageName = 'flutter_vless_android';
-const expectedVersionFragment = 'flutter_vless_android-1.1.5';
-
-Never fail(String message) {
- stderr.writeln('[secure-socks scope fix] $message');
- exit(1);
-}
-
-Directory packageRoot() {
- final configFile = File('.dart_tool/package_config.json');
- if (!configFile.existsSync()) {
- fail('.dart_tool/package_config.json is missing; run flutter pub get first');
- }
-
- final decoded = jsonDecode(configFile.readAsStringSync());
- if (decoded is! Map || decoded['packages'] is! List) {
- fail('package_config.json is invalid');
- }
-
- Map? package;
- for (final item in decoded['packages'] as List) {
- if (item is Map && item['name'] == packageName) {
- package = Map.from(item);
- break;
- }
- }
- if (package == null) fail('$packageName was not found');
-
- final rootUriValue = package['rootUri'];
- if (rootUriValue is! String || rootUriValue.isEmpty) {
- fail('$packageName has no rootUri');
- }
-
- final rootUri = Uri.parse(rootUriValue);
- final resolved = rootUri.hasScheme
- ? rootUri
- : configFile.parent.absolute.uri.resolveUri(rootUri);
- if (resolved.scheme != 'file') fail('unsupported package URI: $resolved');
-
- final root = Directory.fromUri(resolved);
- final normalized = root.absolute.path.replaceAll('\\', '/');
- if (!normalized.contains(expectedVersionFragment)) {
- fail('expected flutter_vless_android 1.1.5, got ${root.path}');
- }
- return root;
-}
-
-void patchCoreScope(File core) {
- final before = core.readAsStringSync();
-
- // The primary secure patcher used a file-wide check for
- // `val process = pb.start()`. Stock 1.1.5 already contains that exact line
- // in getV2rayTraffic(), so the startCore() capture could be skipped while
- // its monitor was still rewritten to use `process`. Capture the active Xray
- // process immediately before its monitor thread instead. This is deliberately
- // a scope-only correction and does not alter the stable runtime sequence.
- const directCapture =
- ' val process = pb.start()\n xrayProcess = process\n';
- const scopedCapture =
- ' val process = xrayProcess ?: return false\n // Monitor process in a separate thread to detect crash\n';
- const monitorMarker =
- ' // Monitor process in a separate thread to detect crash\n';
-
- if (before.contains(directCapture) || before.contains(scopedCapture)) {
- stdout.writeln('[secure-socks scope fix] core already current: ${core.path}');
- return;
- }
-
- if (!before.contains('process.inputStream.bufferedReader().use { reader ->') ||
- !before.contains('val exitCode = process.waitFor()') ||
- !before.contains('xrayProcess === process')) {
- fail('secure Xray monitor patch is not in the expected state');
- }
- if (!before.contains(' xrayProcess = pb.start()\n')) {
- fail('stock Xray start assignment is missing');
- }
- if (!before.contains(monitorMarker)) {
- fail('Xray monitor insertion point is missing');
- }
-
- final after = before.replaceFirst(monitorMarker, scopedCapture);
- core.writeAsStringSync(after);
- stdout.writeln('[secure-socks scope fix] captured Xray process for monitor scope');
-}
-
-void patchOngoingNotification(File service) {
- final before = service.readAsStringSync();
-
- const marker = '.setOngoing(true)';
- if (before.contains(marker) &&
- before.contains('Notification.FLAG_NO_CLEAR') &&
- before.contains('.setAutoCancel(false)')) {
- stdout.writeln('[secure-socks scope fix] notification already ongoing: ${service.path}');
- return;
- }
-
- const oldBlock = r''' return builder
- .setContentTitle("VPN Service")
- .setContentText(content)
- .setSmallIcon(icon)
- .build()
-''';
-
- const newBlock = r''' val notification = builder
- .setContentTitle("VPN Service")
- .setContentText(content)
- .setSmallIcon(icon)
- .setOngoing(true)
- .setAutoCancel(false)
- .setOnlyAlertOnce(true)
- .build()
-
- // Keep the active VPN notification attached to the foreground service
- // and resistant to bulk clearing on Android versions that honor these
- // flags. Android 14+ may still allow an explicit user swipe while the
- // device is unlocked; apps are not allowed to override that system UI.
- notification.flags = notification.flags or
- android.app.Notification.FLAG_ONGOING_EVENT or
- android.app.Notification.FLAG_NO_CLEAR
- return notification
-''';
-
- if (!before.contains(oldBlock)) {
- fail('VPN notification builder is not in the expected stock 1.1.5 state');
- }
-
- final after = before.replaceFirst(oldBlock, newBlock);
- if (!after.contains(marker) ||
- !after.contains('Notification.FLAG_NO_CLEAR') ||
- !after.contains('.setAutoCancel(false)')) {
- fail('ongoing VPN notification invariant failed');
- }
-
- service.writeAsStringSync(after);
- stdout.writeln('[secure-socks scope fix] VPN notification marked ongoing/no-clear');
-}
-
-void main() {
- final root = packageRoot();
- final kotlin =
- '${root.path}/android/src/main/kotlin/com/github/tfox/flutter_vless';
- final core = File('$kotlin/xray/core/XrayCoreManager.kt');
- final service = File('$kotlin/xray/service/XrayVPNService.kt');
-
- if (!core.existsSync()) fail('XrayCoreManager.kt is missing');
- if (!service.existsSync()) fail('XrayVPNService.kt is missing');
-
- patchCoreScope(core);
- patchOngoingNotification(service);
- stdout.writeln('[secure-socks scope fix] scope and notification patch ready');
-}
diff --git a/tool/patch_flutter_vless_secure_socks_v2.dart b/tool/patch_flutter_vless_secure_socks_v2.dart
deleted file mode 100644
index c68deb4..0000000
--- a/tool/patch_flutter_vless_secure_socks_v2.dart
+++ /dev/null
@@ -1,746 +0,0 @@
-import 'dart:convert';
-import 'dart:io';
-
-const packageName = 'flutter_vless_android';
-const expectedVersionFragment = 'flutter_vless_android-1.1.5';
-
-Never fail(String message) {
- stderr.writeln('[secure-socks patch] $message');
- exit(1);
-}
-
-Directory packageRoot() {
- final configFile = File('.dart_tool/package_config.json');
- if (!configFile.existsSync()) {
- fail('.dart_tool/package_config.json is missing; run flutter pub get first');
- }
- final decoded = jsonDecode(configFile.readAsStringSync());
- if (decoded is! Map || decoded['packages'] is! List) {
- fail('package_config.json is invalid');
- }
-
- Map? package;
- for (final item in decoded['packages'] as List) {
- if (item is Map && item['name'] == packageName) {
- package = Map.from(item);
- break;
- }
- }
- if (package == null) fail('$packageName was not found');
-
- final rootUriValue = package['rootUri'];
- if (rootUriValue is! String || rootUriValue.isEmpty) {
- fail('$packageName has no rootUri');
- }
- final rootUri = Uri.parse(rootUriValue);
- final resolved = rootUri.hasScheme
- ? rootUri
- : configFile.parent.absolute.uri.resolveUri(rootUri);
- if (resolved.scheme != 'file') fail('unsupported package URI: $resolved');
-
- final root = Directory.fromUri(resolved);
- final normalized = root.absolute.path.replaceAll('\\', '/');
- if (!normalized.contains(expectedVersionFragment)) {
- fail('expected flutter_vless_android 1.1.5, got ${root.path}');
- }
- return root;
-}
-
-String replaceOnce(
- String text,
- String oldValue,
- String newValue,
- String label,
-) {
- if (text.contains(newValue)) return text;
- if (!text.contains(oldValue)) fail('missing expected source block: $label');
- return text.replaceFirst(oldValue, newValue);
-}
-
-void writeIfChanged(File file, String before, String after) {
- if (before == after) {
- stdout.writeln('[secure-socks patch] already current: ${file.path}');
- return;
- }
- file.writeAsStringSync(after);
- stdout.writeln('[secure-socks patch] patched: ${file.path}');
-}
-
-void patchCore(File file) {
- final before = file.readAsStringSync();
- var text = before;
-
- const secureValidation = r''' var secureSocksEnabled = false
- for (i in 0 until inbounds.length()) {
- val inbound = inbounds.optJSONObject(i) ?: continue
- if (inbound.optString("tag") != "revolt-secure-socks") continue
-
- val settings = inbound.optJSONObject("settings")
- ?: throw IllegalStateException("Secure SOCKS5 settings are missing")
- val users = settings.optJSONArray("users")
- ?: throw IllegalStateException("Secure SOCKS5 users are missing")
- val account = users.optJSONObject(0)
- ?: throw IllegalStateException("Secure SOCKS5 account is missing")
- val port = inbound.optInt("port", -1)
-
- if (inbound.optString("protocol") != "socks" ||
- inbound.optString("listen") != "127.0.0.1" ||
- port <= 1024 || port > 65535 ||
- settings.optString("auth") != "password" ||
- account.optString("user").isEmpty() ||
- account.optString("pass").isEmpty()
- ) {
- throw IllegalStateException("Secure SOCKS5 session is invalid")
- }
-
- config.LOCAL_SOCKS5_PORT = port
- config.LOCAL_HTTP_PORT = 0
- secureSocksEnabled = true
- break
- }
-
-''';
-
- if (!text.contains('var secureSocksEnabled = false')) {
- text = replaceOnce(
- text,
- ' val usedPorts = mutableSetOf()\n',
- '$secureValidation val usedPorts = mutableSetOf()\n',
- 'secure SOCKS validation insertion',
- );
- }
-
- text = replaceOnce(
- text,
- ' if (!hasHttpOnLocalPort) {\n',
- ' if (!secureSocksEnabled && !hasHttpOnLocalPort) {\n',
- 'disable unauthenticated HTTP proxy in secure mode',
- );
-
- if (!text.contains('val process = pb.start()')) {
- text = replaceOnce(
- text,
- ' xrayProcess = pb.start()\n',
- ' val process = pb.start()\n xrayProcess = process\n',
- 'capture Xray process identity',
- );
- text = replaceOnce(
- text,
- ' if (xrayProcess?.isAlive != true) {\n',
- ' if (!process.isAlive) {\n',
- 'Xray startup liveness',
- );
- text = replaceOnce(
- text,
- ' val output = xrayProcess?.inputStream?.bufferedReader()?.readText().orEmpty()\n',
- ' val output = process.inputStream.bufferedReader().readText()\n',
- 'Xray startup output',
- );
- }
-
- if (!text.contains('Could not delete ephemeral Xray config')) {
- const connectedLine =
- ' AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTED\n';
- const deleteConfig = r''' // config.json contains per-session SOCKS credentials. Xray has
- // consumed it after the startup liveness check, so remove it now.
- try {
- val ephemeralConfig = File(configFilesDir, "config.json")
- if (ephemeralConfig.exists() && !ephemeralConfig.delete()) {
- Log.w(TAG, "Could not delete ephemeral Xray config")
- }
- } catch (e: Exception) {
- Log.w(TAG, "Could not delete ephemeral Xray config", e)
- }
-
-''';
- text = replaceOnce(
- text,
- connectedLine,
- '$deleteConfig$connectedLine',
- 'ephemeral config deletion',
- );
- }
-
- if (!text.contains('context.handleXrayCoreExit(config)')) {
- text = replaceOnce(
- text,
- ' xrayProcess?.inputStream?.bufferedReader()?.use { reader ->\n',
- ' process.inputStream.bufferedReader().use { reader ->\n',
- 'Xray monitor process stream',
- );
- text = replaceOnce(
- text,
- ' val exitCode = xrayProcess?.waitFor()\n',
- ' val exitCode = process.waitFor()\n',
- 'Xray monitor process wait',
- );
-
- const oldExit = r''' if (AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTED) {
- // Unexpected exit
- stopCore(context)
- }
-''';
- const newExit = r''' if (xrayProcess === process &&
- AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTED
- ) {
- // Hold the Android TUN while the core is recovered. Killing
- // the VPN service here would allow direct-network fallback.
- xrayProcess = null
- AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTING
- if (context is XrayVPNService) {
- context.handleXrayCoreExit(config)
- } else {
- stopCore(context)
- }
- }
-''';
- text = replaceOnce(text, oldExit, newExit, 'Xray fail-closed recovery');
- }
-
- writeIfChanged(file, before, text);
-}
-
-void patchService(File file) {
- final before = file.readAsStringSync();
- var text = before;
-
- if (!text.contains('private var recoveringXray = false')) {
- text = replaceOnce(
- text,
- ' private var isRunning = false\n',
- ' @Volatile private var isRunning = false\n'
- ' @Volatile private var recoveringXray = false\n'
- ' private var currentConfig: XrayConfig? = null\n',
- 'service recovery fields',
- );
- }
-
- if (!text.contains('private data class PersistedSession(')) {
- const persistenceHelpers = r''' private data class PersistedSession(
- val proxyOnly: Boolean,
- val config: XrayConfig,
- val sessionExpiresAtMs: Long,
- ) : java.io.Serializable
-
- private var proxyOnlyMode = false
- private var sessionExpiresAtMs = 0L
-
- private val expiryHandler = android.os.Handler(android.os.Looper.getMainLooper())
- private val expiryRunnable = Runnable {
- if (sessionExpiresAtMs > 0 && System.currentTimeMillis() >= sessionExpiresAtMs) {
- Log.w(TAG, "Session expired; tearing down VPN")
- sessionExpiresAtMs = 0L
- cancelExpiryAlarm()
- postSessionExpiredNotification()
- deletePersistedSession()
- stopAll()
- }
- }
-
- private fun scheduleExpiryCheck() {
- expiryHandler.removeCallbacks(expiryRunnable)
- if (sessionExpiresAtMs > 0) {
- val delay = (sessionExpiresAtMs - System.currentTimeMillis()).coerceAtLeast(0)
- expiryHandler.postDelayed(expiryRunnable, delay)
- }
- scheduleExpiryAlarm()
- }
-
- private fun scheduleExpiryAlarm() {
- cancelExpiryAlarm()
- if (sessionExpiresAtMs <= 0) return
- try {
- val alarmManager = getSystemService(android.content.Context.ALARM_SERVICE) as android.app.AlarmManager
- try {
- alarmManager.setExactAndAllowWhileIdle(
- android.app.AlarmManager.RTC_WAKEUP,
- sessionExpiresAtMs,
- sessionExpiredPendingIntent(),
- )
- } catch (e: SecurityException) {
- alarmManager.setAndAllowWhileIdle(
- android.app.AlarmManager.RTC_WAKEUP,
- sessionExpiresAtMs,
- sessionExpiredPendingIntent(),
- )
- }
- } catch (e: Exception) {
- Log.w(TAG, "Failed to schedule expiry alarm", e)
- }
- }
-
- private fun cancelExpiryAlarm() {
- try {
- val alarmManager = getSystemService(android.content.Context.ALARM_SERVICE) as android.app.AlarmManager
- alarmManager.cancel(sessionExpiredPendingIntent())
- } catch (e: Exception) {
- }
- }
-
- private fun sessionExpiredPendingIntent(): android.app.PendingIntent {
- val intent = Intent(this, XrayVPNService::class.java)
- intent.putExtra("SESSION_EXPIRED", true)
- val flags = android.app.PendingIntent.FLAG_UPDATE_CURRENT or
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- android.app.PendingIntent.FLAG_IMMUTABLE
- } else {
- 0
- }
- return android.app.PendingIntent.getService(this, 1001, intent, flags)
- }
-
- private fun persistSession() {
- val config = currentConfig ?: return
- try {
- java.io.ObjectOutputStream(
- java.io.FileOutputStream(File(filesDir, "active_session.bin")),
- ).use { it.writeObject(PersistedSession(proxyOnlyMode, config, sessionExpiresAtMs)) }
- } catch (e: Exception) {
- Log.w(TAG, "Failed to persist active session", e)
- }
- }
-
- private fun loadPersistedSession(): PersistedSession? {
- return try {
- val file = File(filesDir, "active_session.bin")
- if (!file.exists()) return null
- java.io.ObjectInputStream(java.io.FileInputStream(file)).use {
- it.readObject() as? PersistedSession
- }
- } catch (e: Exception) {
- Log.w(TAG, "Failed to restore persisted session", e)
- null
- }
- }
-
- private fun deletePersistedSession() {
- try {
- File(filesDir, "active_session.bin").delete()
- } catch (e: Exception) {
- Log.w(TAG, "Failed to delete persisted session", e)
- }
- }
-
- private fun postSessionExpiredNotification() {
- try {
- createNotificationChannel()
- val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
- val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- android.app.PendingIntent.FLAG_IMMUTABLE or android.app.PendingIntent.FLAG_UPDATE_CURRENT
- } else {
- android.app.PendingIntent.FLAG_UPDATE_CURRENT
- }
- val contentIntent = if (launchIntent != null) {
- android.app.PendingIntent.getActivity(this, 0, launchIntent, flags)
- } else {
- null
- }
- val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- android.app.Notification.Builder(this, "vpn_service_channel")
- } else {
- @Suppress("DEPRECATION")
- android.app.Notification.Builder(this)
- }
- builder
- .setContentTitle("Revolt VPN")
- .setContentText("Session expired. Reconnect to continue.")
- .setSmallIcon(android.R.drawable.ic_dialog_info)
- .setAutoCancel(true)
- if (contentIntent != null) {
- builder.setContentIntent(contentIntent)
- }
- val manager = getSystemService(android.app.NotificationManager::class.java)
- manager?.notify(2, builder.build())
- } catch (e: Exception) {
- Log.w(TAG, "Failed to post session expired notification", e)
- }
- }
-''';
- text = replaceOnce(
- text,
- ' override fun onCreate() {\n super.onCreate()\n }\n',
- ' override fun onCreate() {\n super.onCreate()\n }\n\n$persistenceHelpers',
- 'session persistence helpers',
- );
- }
-
- // Snapshot the running config so a START_STICKY restart can re-establish it.
- if (!text.contains(' currentConfig = config\n')) {
- text = replaceOnce(
- text,
- ' cleanup()\n',
- ' cleanup()\n currentConfig = config\n',
- 'runtime config snapshot',
- );
- }
-
- text = replaceOnce(
- text,
- ' val proxyOnly = intent.getBooleanExtra("PROXY_ONLY", false)\n',
- ' val proxyOnly = intent.getBooleanExtra("PROXY_ONLY", false)\n proxyOnlyMode = proxyOnly\n persistSession()\n',
- 'persist session snapshot',
- );
-
- text = replaceOnce(
- text,
- ' if (command == AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE) {\n stopAll()\n return START_NOT_STICKY\n }\n',
- ' if (command == AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE) {\n deletePersistedSession()\n stopAll()\n return START_NOT_STICKY\n }\n',
- 'delete session on explicit stop',
- );
-
- const oldNullIntent = r''' if (intent == null) {
- stopSelf()
- return START_NOT_STICKY
- }
-''';
- const newNullIntent = r''' if (intent == null) {
- if (isRunning && currentConfig != null) return START_STICKY
- val persisted = loadPersistedSession()
- if (persisted != null) {
- Log.w(TAG, "Re-establishing VPN from persisted session")
- createNotificationChannel()
- val notification = createNotification("VPN Service Running")
- try {
- if (Build.VERSION.SDK_INT >= 34) {
- startForeground(1, notification, FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
- } else {
- startForeground(1, notification)
- }
- } catch (e: Exception) {
- Log.e(TAG, "Failed to start foreground", e)
- }
- if (XrayCoreManager.isXrayRunning()) {
- Log.w(TAG, "Stopping stale Xray core before restart")
- XrayCoreManager.stopCore(this)
- }
- cleanup()
- currentConfig = persisted.config
- proxyOnlyMode = persisted.proxyOnly
- sessionExpiresAtMs = persisted.sessionExpiresAtMs
- if (XrayCoreManager.startCore(this, persisted.config)) {
- if (!persisted.proxyOnly) {
- setupVpn(persisted.config)
- } else {
- isRunning = true
- }
- scheduleExpiryCheck()
- } else {
- stopSelf()
- return START_NOT_STICKY
- }
- return START_STICKY
- }
- stopSelf()
- return START_NOT_STICKY
- }
-''';
- text = replaceOnce(
- text,
- oldNullIntent,
- newNullIntent,
- 'null intent lifecycle',
- );
-
- text = replaceOnce(
- text,
- ' val command = if (Build.VERSION.SDK_INT >= 33) {\n',
- ' if (intent.getBooleanExtra("SESSION_EXPIRED", false)) {\n Log.w(TAG, "Session-expiry alarm fired; tearing down VPN")\n sessionExpiresAtMs = 0L\n cancelExpiryAlarm()\n deletePersistedSession()\n postSessionExpiredNotification()\n stopAll()\n return START_NOT_STICKY\n }\n\n if (intent.hasExtra("SESSION_EXPIRES_AT_MS")) {\n sessionExpiresAtMs = intent.getLongExtra("SESSION_EXPIRES_AT_MS", 0L)\n scheduleExpiryCheck()\n persistSession()\n return if (isRunning && currentConfig != null) START_STICKY else START_NOT_STICKY\n }\n\n val command = if (Build.VERSION.SDK_INT >= 33) {\n',
- 'session expiry update',
- );
-
-
- if (!text.contains('private data class SecureSocksCredentials(')) {
- const credentialsHelper = r''' private data class SecureSocksCredentials(
- val port: Int,
- val username: String,
- val password: String,
- )
-
- private fun secureSocksCredentials(config: XrayConfig): SecureSocksCredentials? {
- val inbounds = try {
- JSONObject(config.V2RAY_FULL_JSON_CONFIG).optJSONArray("inbounds")
- } catch (_: Exception) {
- null
- } ?: return null
-
- for (i in 0 until inbounds.length()) {
- val inbound = inbounds.optJSONObject(i) ?: continue
- if (inbound.optString("tag") != "revolt-secure-socks") continue
- if (inbound.optString("protocol") != "socks" ||
- inbound.optString("listen") != "127.0.0.1"
- ) return null
-
- val settings = inbound.optJSONObject("settings") ?: return null
- if (settings.optString("auth") != "password") return null
- val user = settings.optJSONArray("users")?.optJSONObject(0) ?: return null
- val port = inbound.optInt("port", -1)
- val username = user.optString("user")
- val password = user.optString("pass")
- if (port <= 1024 || port > 65535 || username.isEmpty() || password.isEmpty()) {
- return null
- }
- return SecureSocksCredentials(port, username, password)
- }
- return null
- }
-
-''';
- const runComment = r''' /**
- * Starts the tun2socks process and initiates the FD transfer.
- */
- private fun runTun2socks(config: XrayConfig) {
-''';
- text = replaceOnce(
- text,
- runComment,
- '$credentialsHelper$runComment',
- 'secure credentials helper',
- );
- }
-
- if (!text.contains('Authenticated ReVolt SOCKS5 inbound missing')) {
- const commandComment = r''' // Command to start tun2socks.
- // Note: We pass -sock-path to tell it where to listen for the FD.
-''';
- const securePrelude = r''' val secure = secureSocksCredentials(config)
- ?: throw IllegalStateException("Authenticated ReVolt SOCKS5 inbound missing")
- config.LOCAL_SOCKS5_PORT = secure.port
-
-''';
- text = replaceOnce(
- text,
- commandComment,
- '$securePrelude$commandComment',
- 'secure tun2socks credentials',
- );
- text = replaceOnce(
- text,
- r''' "-proxy", "socks5://127.0.0.1:${config.LOCAL_SOCKS5_PORT}",
-''',
- r''' "-proxy", "socks5://${secure.username}:${secure.password}@127.0.0.1:${secure.port}",
-''',
- 'authenticated tun2socks URL',
- );
- text = replaceOnce(
- text,
- r''' Log.d(TAG, "tun2socks command: ${cmd.joinToString(" ")}")
-''',
- ' Log.d(TAG, "Starting tun2socks with authenticated loopback SOCKS5")\n',
- 'credential-safe logging',
- );
- }
-
- if (!text.contains('val process = pb.start()\n tun2socksProcess = process')) {
- text = replaceOnce(
- text,
- ' tun2socksProcess = pb.start()\n',
- ' val process = pb.start()\n tun2socksProcess = process\n',
- 'capture tun2socks process identity',
- );
- text = replaceOnce(
- text,
- ' tun2socksProcess?.inputStream?.bufferedReader()?.use { reader ->\n',
- ' process.inputStream.bufferedReader().use { reader ->\n',
- 'tun2socks monitor stream',
- );
- text = replaceOnce(
- text,
- ' tun2socksProcess?.waitFor()\n',
- ' process.waitFor()\n',
- 'tun2socks monitor wait',
- );
-
- const oldRestart = r''' if (isRunning) {
- // Restart if crashed and still supposed to be running
- Log.e(TAG, "tun2socks exited unexpectedly, restarting...")
- runTun2socks(config)
- }
-''';
- const newRestart = r''' if (isRunning && tun2socksProcess === process && currentConfig === config) {
- Log.e(TAG, "tun2socks exited unexpectedly, recycling without dropping TUN")
- Thread.sleep(350)
- if (isRunning && currentConfig === config) runTun2socks(config)
- }
-''';
- text = replaceOnce(
- text,
- oldRestart,
- newRestart,
- 'tun2socks crash recovery',
- );
- text = replaceOnce(
- text,
- ' sendFd()\n',
- ' sendFd(process)\n',
- 'FD handoff process identity',
- );
- }
-
- const oldStartFailure = r''' } catch (e: Exception) {
- Log.e(TAG, "Failed to start tun2socks", e)
- stopAll()
- }
-''';
- const newStartFailure = r''' } catch (e: Exception) {
- Log.e(TAG, "Failed to start tun2socks; retrying with TUN held", e)
- if (isRunning && currentConfig === config) {
- Thread {
- try {
- Thread.sleep(600)
- if (isRunning && currentConfig === config) runTun2socks(config)
- } catch (_: InterruptedException) {}
- }.start()
- }
- }
-''';
- text = replaceOnce(
- text,
- oldStartFailure,
- newStartFailure,
- 'tun2socks start failure recovery',
- );
-
- if (!text.contains('FD handoff failed; recycling tun2socks without dropping TUN')) {
- const oldSendFd = r''' private fun sendFd() {
- val fd = mInterface?.fileDescriptor ?: return
- val sockFile = File(filesDir, "sock_path").absolutePath
-
- Thread {
- var tries = 0
- while (tries < 10) {
- try {
- Thread.sleep(500)
- val localSocket = LocalSocket()
- localSocket.connect(LocalSocketAddress(sockFile, LocalSocketAddress.Namespace.FILESYSTEM))
- val out = localSocket.outputStream
- // This magic call attaches the FD to the socket message
- localSocket.setFileDescriptorsForSend(arrayOf(fd))
- out.write(32) // Send a dummy byte to trigger the transfer
- localSocket.setFileDescriptorsForSend(null)
- localSocket.shutdownOutput()
- localSocket.close()
- break
- } catch (e: Exception) {
- tries++
- }
- }
- }.start()
- }
-''';
- const newSendFd = r''' private fun sendFd(process: Process) {
- val fd = mInterface?.fileDescriptor ?: return
- val sockFile = File(filesDir, "sock_path").absolutePath
-
- Thread {
- var tries = 0
- while (tries < 10 && isRunning && tun2socksProcess === process && process.isAlive) {
- var localSocket: LocalSocket? = null
- try {
- Thread.sleep(500)
- localSocket = LocalSocket()
- localSocket.connect(LocalSocketAddress(sockFile, LocalSocketAddress.Namespace.FILESYSTEM))
- val out = localSocket.outputStream
- localSocket.setFileDescriptorsForSend(arrayOf(fd))
- out.write(32)
- out.flush()
- localSocket.setFileDescriptorsForSend(null)
- localSocket.shutdownOutput()
- localSocket.close()
- return@Thread
- } catch (_: Exception) {
- tries++
- try { localSocket?.close() } catch (_: Exception) {}
- }
- }
-
- if (isRunning && tun2socksProcess === process) {
- Log.e(TAG, "FD handoff failed; recycling tun2socks without dropping TUN")
- try { process.destroy() } catch (_: Exception) {}
- }
- }.start()
- }
-''';
- text = replaceOnce(text, oldSendFd, newSendFd, 'bounded FD recovery');
- }
-
- if (!text.contains('fun handleXrayCoreExit(config: XrayConfig)')) {
- const cleanupComment = r''' /**
- * Cleans up resources (tun2socks process, VPN interface) without stopping the service completely.
- * Used when restarting or switching configurations.
- */
-''';
- const recovery = r''' fun handleXrayCoreExit(config: XrayConfig) {
- if (!isRunning || currentConfig !== config || recoveringXray) return
- recoveringXray = true
-
- Thread {
- var attempt = 0
- try {
- while (isRunning && currentConfig === config) {
- attempt++
- Thread.sleep(400L * attempt.coerceAtMost(5))
- if (!isRunning || currentConfig !== config) break
-
- Log.w(TAG, "Recovering Xray core attempt $attempt")
- if (XrayCoreManager.startCore(this, config)) {
- Log.w(TAG, "Xray core recovered without dropping TUN")
- return@Thread
- }
-
- if (attempt >= 3) {
- attempt = 0
- Thread.sleep(3000)
- }
- }
- } catch (_: InterruptedException) {
- } finally {
- recoveringXray = false
- }
- }.start()
- }
-
-''';
- text = replaceOnce(
- text,
- cleanupComment,
- '$recovery$cleanupComment',
- 'Xray recovery loop',
- );
- }
-
- if (!text.contains(' cancelExpiryAlarm()\n tun2socksProcess?.destroy()')) {
- text = replaceOnce(
- text,
- ' isRunning = false\n tun2socksProcess?.destroy()\n',
- ' isRunning = false\n recoveringXray = false\n expiryHandler.removeCallbacks(expiryRunnable)\n cancelExpiryAlarm()\n tun2socksProcess?.destroy()\n',
- 'cancel recovery and expiry on cleanup',
- );
- }
-
- if (!text.contains(' currentConfig = null\n XrayCoreManager.stopCore(this)')) {
- text = replaceOnce(
- text,
- ' cleanup()\n XrayCoreManager.stopCore(this)\n',
- ' cleanup()\n currentConfig = null\n XrayCoreManager.stopCore(this)\n',
- 'clear config on explicit stop',
- );
- }
-
- writeIfChanged(file, before, text);
-}
-
-void main() {
- final root = packageRoot();
- final kotlin = Directory(
- '${root.path}/android/src/main/kotlin/com/github/tfox/flutter_vless',
- );
- final core = File('${kotlin.path}/xray/core/XrayCoreManager.kt');
- final service = File('${kotlin.path}/xray/service/XrayVPNService.kt');
- if (!core.existsSync() || !service.existsSync()) {
- fail('pinned Android runtime sources are missing');
- }
-
- patchCore(core);
- patchService(service);
- stdout.writeln('[secure-socks patch] stable-core security patch is ready');
-}