chore(android): vendor pinned flutter_vless runtime
This commit is contained in:
1 parent
0692bb83e1
commit
e15744db99
31 files changed
+2967
-1275
No files matched your search
+10
-44
@@ -1,48 +1,18 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# ReVoltVPN — .gitignore
|
# ReVoltVPN
|
||||||
#
|
|
||||||
# Type-based by design. This repo ships Dart, Kotlin, XML and image assets;
|
|
||||||
# everything else is excluded by extension or directory rather than by being
|
|
||||||
# listed individually. A new file is therefore ignored by default instead of
|
|
||||||
# needing to be named here, and this file does not enumerate what it protects.
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# ── Secrets ────────────────────────────────────────────────────────────────
|
# Secrets / machine-local release material
|
||||||
*.keystore
|
*.keystore
|
||||||
*.jks
|
*.jks
|
||||||
android/key.properties
|
android/key.properties
|
||||||
android/admob.properties
|
android/admob.properties
|
||||||
|
|
||||||
# Real IPs and IDs. app_config.example.dart is the committed template.
|
# This file may contain the private AdMob unit id. Network trust is verified
|
||||||
|
# independently at release time; do not replace this with a broad extension rule.
|
||||||
lib/logic/app_config.dart
|
lib/logic/app_config.dart
|
||||||
|
|
||||||
# ── File types the shipped app does not contain ────────────────────────────
|
# Working directories that are intentionally outside the shipped client
|
||||||
*.py
|
|
||||||
*.pyc
|
|
||||||
*.json
|
|
||||||
*.conf
|
|
||||||
*.sh
|
|
||||||
*.txt
|
|
||||||
*.html
|
|
||||||
*.zip
|
|
||||||
*.log
|
|
||||||
*.class
|
|
||||||
|
|
||||||
# ── Documentation ──────────────────────────────────────────────────────────
|
|
||||||
# The published set is allow-listed; anything else, at any depth, is not.
|
|
||||||
# Leading slashes anchor these to the repo root so a same-named file in a
|
|
||||||
# subdirectory is NOT re-included by the negation.
|
|
||||||
*.md
|
|
||||||
!/README.md
|
|
||||||
!/CHANGELOG.md
|
|
||||||
!/CONTRIBUTING.md
|
|
||||||
!/PRIVACY_POLICY.md
|
|
||||||
|
|
||||||
# ── Working directories ────────────────────────────────────────────────────
|
|
||||||
# Material that is not part of the app. These need directory rules rather
|
|
||||||
# than type rules, because they can hold source-file types the rules above
|
|
||||||
# deliberately do not cover.
|
|
||||||
.local/
|
.local/
|
||||||
internal/
|
internal/
|
||||||
hivemind/
|
hivemind/
|
||||||
@@ -51,8 +21,9 @@ backup/
|
|||||||
backups/
|
backups/
|
||||||
lib/logic/backup/
|
lib/logic/backup/
|
||||||
server_files/
|
server_files/
|
||||||
|
whatsthis
|
||||||
|
|
||||||
# ── Flutter / Dart build output ────────────────────────────────────────────
|
# Flutter / Dart generated output
|
||||||
.dart_tool/
|
.dart_tool/
|
||||||
*.dart_tool/
|
*.dart_tool/
|
||||||
.flutter-plugins
|
.flutter-plugins
|
||||||
@@ -66,20 +37,17 @@ local_packages/*/.dart_tool/
|
|||||||
**/doc/api/
|
**/doc/api/
|
||||||
**/ios/Flutter/.last_build_id
|
**/ios/Flutter/.last_build_id
|
||||||
|
|
||||||
# ── Android build output ───────────────────────────────────────────────────
|
# Android generated output / local SDK state
|
||||||
**/android/.gradle
|
**/android/.gradle
|
||||||
**/android/.cxx/
|
**/android/.cxx/
|
||||||
**/android/app/build/
|
**/android/app/build/
|
||||||
**/android/captures/
|
**/android/captures/
|
||||||
**/android/gradlew
|
|
||||||
**/android/gradlew.bat
|
|
||||||
**/android/local.properties
|
**/android/local.properties
|
||||||
**/android/**/gradle-wrapper.jar
|
|
||||||
**/android/**/GeneratedPluginRegistrant.java
|
**/android/**/GeneratedPluginRegistrant.java
|
||||||
**/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java
|
**/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java
|
||||||
**/android/app/crashlytics-build.properties
|
**/android/app/crashlytics-build.properties
|
||||||
|
|
||||||
# ── Editors, tooling & OS ──────────────────────────────────────────────────
|
# Editors / tooling / OS noise
|
||||||
.claude/
|
.claude/
|
||||||
.codewhale/
|
.codewhale/
|
||||||
.idea/
|
.idea/
|
||||||
@@ -90,8 +58,6 @@ local_packages/*/.dart_tool/
|
|||||||
.java-version
|
.java-version
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.swp
|
*.swp
|
||||||
|
*.iml
|
||||||
__pycache__/
|
__pycache__/
|
||||||
devtools_options.yaml
|
devtools_options.yaml
|
||||||
|
|
||||||
#why are you even looking at this file? it's a secret. go away.
|
|
||||||
whatsthis
|
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
gradle-wrapper.jar
|
|
||||||
/.gradle
|
/.gradle
|
||||||
/captures/
|
/captures/
|
||||||
/gradlew
|
|
||||||
/gradlew.bat
|
|
||||||
/local.properties
|
/local.properties
|
||||||
GeneratedPluginRegistrant.java
|
GeneratedPluginRegistrant.java
|
||||||
|
|
||||||
|
|||||||
+71
-125
@@ -1,132 +1,16 @@
|
|||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
val projectRootDir = rootProject.projectDir.parentFile
|
fun sha256Hex(file: File): String {
|
||||||
val localPropertiesFile = rootProject.file("local.properties")
|
val digest = MessageDigest.getInstance("SHA-256").digest(file.readBytes())
|
||||||
val localProperties = Properties().apply {
|
return digest.joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||||
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<Exec>("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<Exec>("patchFlutterVlessSecureSocks") {
|
|
||||||
group = "build setup"
|
|
||||||
description = "Add authenticated ephemeral SOCKS5 and fail-closed process recovery to pinned flutter_vless_android 1.1.5"
|
|
||||||
workingDir(projectRootDir)
|
|
||||||
dependsOn(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<Exec>("patchFlutterVlessSecureSocksScopeFix") {
|
|
||||||
group = "build setup"
|
|
||||||
description = "Repair the secure Xray process monitor capture without changing stock 1.1.5 runtime sequencing"
|
|
||||||
workingDir(projectRootDir)
|
|
||||||
dependsOn(patchFlutterVlessSecureSocks)
|
|
||||||
|
|
||||||
doFirst {
|
|
||||||
if (!dartExecutable.isFile) {
|
|
||||||
throw GradleException("Flutter Dart executable not found: ${dartExecutable.absolutePath}")
|
|
||||||
}
|
|
||||||
if (!flutterVlessSecureSocksScopeFixScript.isFile) {
|
|
||||||
throw GradleException("secure SOCKS scope fix script missing: ${flutterVlessSecureSocksScopeFixScript.absolutePath}")
|
|
||||||
}
|
|
||||||
if (!File(projectRootDir, ".dart_tool/package_config.json").isFile) {
|
|
||||||
throw GradleException("Flutter package metadata missing. Run `flutter pub get` before Android compilation.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
commandLine(
|
|
||||||
dartExecutable.absolutePath,
|
|
||||||
"run",
|
|
||||||
flutterVlessSecureSocksScopeFixScript.absolutePath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// App compilation and the transitive Android plugin compilation must both wait
|
|
||||||
// for the pinned runtime patches. Plain `flutter build apk` must behave like CI
|
|
||||||
// instead of relying on a hidden manual pre-build step.
|
|
||||||
tasks.configureEach {
|
|
||||||
if (name == "preBuild") {
|
|
||||||
dependsOn(patchFlutterVlessSecureSocksScopeFix)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
gradle.projectsEvaluated {
|
|
||||||
rootProject.findProject(":flutter_vless_android")
|
|
||||||
?.tasks
|
|
||||||
?.matching { it.name == "preBuild" }
|
|
||||||
?.configureEach {
|
|
||||||
dependsOn(patchFlutterVlessSecureSocksScopeFix)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -140,10 +24,6 @@ android {
|
|||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
packaging {
|
|
||||||
resources.excludes.add("META-INF/**")
|
|
||||||
}
|
|
||||||
|
|
||||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
val keystoreProperties = Properties()
|
val keystoreProperties = Properties()
|
||||||
if (keystorePropertiesFile.exists()) {
|
if (keystorePropertiesFile.exists()) {
|
||||||
@@ -156,6 +36,68 @@ android {
|
|||||||
admobProperties.load(FileInputStream(admobPropertiesFile))
|
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 {
|
defaultConfig {
|
||||||
applicationId = "com.paladinvpn.app"
|
applicationId = "com.paladinvpn.app"
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
@@ -176,7 +118,11 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
signingConfig = signingConfigs.getByName("release")
|
signingConfig = if (ciReleaseSmoke) {
|
||||||
|
signingConfigs.getByName("debug")
|
||||||
|
} else {
|
||||||
|
signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle
|
||||||
|
/local.properties
|
||||||
|
/.idea/workspace.xml
|
||||||
|
/.idea/libraries
|
||||||
|
.DS_Store
|
||||||
|
/build
|
||||||
|
/captures
|
||||||
|
.cxx
|
||||||
@@ -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.
|
||||||
@@ -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'}"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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."
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
rootProject.name = 'flutter_vless_android'
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="com.github.tfox.flutter_vless">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"
|
||||||
|
android:minSdkVersion="34" />
|
||||||
|
|
||||||
|
<application>
|
||||||
|
<service
|
||||||
|
android:name="com.github.tfox.flutter_vless.xray.service.XrayVPNService"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="specialUse"
|
||||||
|
android:permission="android.permission.BIND_VPN_SERVICE"
|
||||||
|
android:process=":RunSoLibXrayDaemon">
|
||||||
|
<property
|
||||||
|
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||||
|
android:value="vpn" />
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.net.VpnService" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.net.VpnService.SUPPORTS_ALWAYS_ON"
|
||||||
|
android:value="false" />
|
||||||
|
</service>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
+402
@@ -0,0 +1,402 @@
|
|||||||
|
// Copyright (c) 2024-2026 13FOX Studio / tfox.dev.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package com.github.tfox.flutter_vless
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.VpnService
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.github.tfox.flutter_vless.xray.dto.XrayConfig
|
||||||
|
import com.github.tfox.flutter_vless.xray.service.XrayVPNService
|
||||||
|
import com.github.tfox.flutter_vless.xray.utils.AppConfigs
|
||||||
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
|
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||||
|
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||||
|
import io.flutter.plugin.common.EventChannel
|
||||||
|
import io.flutter.plugin.common.MethodCall
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import io.flutter.plugin.common.PluginRegistry
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.File
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.util.ArrayList
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main entry point for the Flutter Vless plugin on Android.
|
||||||
|
*
|
||||||
|
* ReVolt keeps this bridge deliberately narrow: start/stop, session deadline,
|
||||||
|
* permission handling, core version and private runtime status only.
|
||||||
|
*/
|
||||||
|
class FlutterVlessPlugin : FlutterPlugin, ActivityAware, PluginRegistry.ActivityResultListener, MethodChannel.MethodCallHandler {
|
||||||
|
|
||||||
|
private val executor = Executors.newSingleThreadExecutor()
|
||||||
|
private lateinit var vpnControlMethod: MethodChannel
|
||||||
|
private lateinit var vpnStatusEvent: EventChannel
|
||||||
|
private var vpnStatusSink: EventChannel.EventSink? = null
|
||||||
|
private var activity: Activity? = null
|
||||||
|
private var xrayReceiver: BroadcastReceiver? = null
|
||||||
|
private var pendingResult: MethodChannel.Result? = null
|
||||||
|
private lateinit var context: Context
|
||||||
|
private var expectedRuntimeToken: String? = null
|
||||||
|
private var pendingStopResult: MethodChannel.Result? = null
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
private val stopTimeoutRunnable = Runnable {
|
||||||
|
val pending = pendingStopResult ?: return@Runnable
|
||||||
|
pendingStopResult = null
|
||||||
|
pending.error("STOP_TIMEOUT", "VPN service did not confirm shutdown", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "FlutterVlessPlugin"
|
||||||
|
private const val REQUEST_CODE_VPN_PERMISSION = 24
|
||||||
|
private const val REQUEST_CODE_POST_NOTIFICATIONS = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
context = binding.applicationContext
|
||||||
|
vpnControlMethod = MethodChannel(binding.binaryMessenger, "flutter_vless")
|
||||||
|
vpnStatusEvent = EventChannel(binding.binaryMessenger, "flutter_vless/status")
|
||||||
|
|
||||||
|
vpnControlMethod.setMethodCallHandler(this)
|
||||||
|
vpnStatusEvent.setStreamHandler(object : EventChannel.StreamHandler {
|
||||||
|
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||||
|
vpnStatusSink = events
|
||||||
|
registerReceiver()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCancel(arguments: Any?) {
|
||||||
|
vpnStatusSink = null
|
||||||
|
unregisterReceiver()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
when (call.method) {
|
||||||
|
"startVless" -> {
|
||||||
|
if (pendingStopResult != null) {
|
||||||
|
result.error("STOP_IN_PROGRESS", "VPN shutdown is still in progress", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val config = XrayConfig()
|
||||||
|
val runtimeToken = UUID.randomUUID().toString()
|
||||||
|
expectedRuntimeToken = runtimeToken
|
||||||
|
config.RUNTIME_TOKEN = runtimeToken
|
||||||
|
config.REMARK = call.argument("remark") ?: ""
|
||||||
|
config.V2RAY_FULL_JSON_CONFIG = call.argument("config") ?: ""
|
||||||
|
config.BLOCKED_APPS = call.argument<ArrayList<String>>("blocked_apps") ?: ArrayList()
|
||||||
|
config.BYPASS_SUBNETS = call.argument<ArrayList<String>>("bypass_subnets") ?: ArrayList()
|
||||||
|
config.NOTIFICATION_DISCONNECT_BUTTON_NAME = call.argument("notificationDisconnectButtonName") ?: "Disconnect"
|
||||||
|
|
||||||
|
if (AppConfigs.NOTIFICATION_ICON_RESOURCE_NAME.isNotEmpty() && AppConfigs.NOTIFICATION_ICON_RESOURCE_TYPE.isNotEmpty()) {
|
||||||
|
config.NOTIFICATION_ICON_RESOURCE_NAME = AppConfigs.NOTIFICATION_ICON_RESOURCE_NAME
|
||||||
|
config.NOTIFICATION_ICON_RESOURCE_TYPE = AppConfigs.NOTIFICATION_ICON_RESOURCE_TYPE
|
||||||
|
val resId = context.resources.getIdentifier(
|
||||||
|
AppConfigs.NOTIFICATION_ICON_RESOURCE_NAME,
|
||||||
|
AppConfigs.NOTIFICATION_ICON_RESOURCE_TYPE,
|
||||||
|
context.packageName
|
||||||
|
)
|
||||||
|
config.APPLICATION_ICON = resId
|
||||||
|
}
|
||||||
|
|
||||||
|
if (call.argument<Boolean>("proxy_only") == true) {
|
||||||
|
AppConfigs.V2RAY_CONNECTION_MODE = AppConfigs.V2RAY_CONNECTION_MODES.PROXY_ONLY
|
||||||
|
} else {
|
||||||
|
AppConfigs.V2RAY_CONNECTION_MODE = AppConfigs.V2RAY_CONNECTION_MODES.VPN_TUN
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val jsonConfig = org.json.JSONObject(config.V2RAY_FULL_JSON_CONFIG)
|
||||||
|
val outbounds = jsonConfig.optJSONArray("outbounds")
|
||||||
|
if (outbounds != null && outbounds.length() > 0) {
|
||||||
|
val firstOutbound = outbounds.getJSONObject(0)
|
||||||
|
val settings = firstOutbound.optJSONObject("settings")
|
||||||
|
val vnext = settings?.optJSONArray("vnext")
|
||||||
|
if (vnext != null && vnext.length() > 0) {
|
||||||
|
val server = vnext.getJSONObject(0)
|
||||||
|
config.CONNECTED_V2RAY_SERVER_ADDRESS = server.optString("address", "")
|
||||||
|
config.CONNECTED_V2RAY_SERVER_PORT = server.optInt("port", 0).toString()
|
||||||
|
} else if (settings != null) {
|
||||||
|
config.CONNECTED_V2RAY_SERVER_ADDRESS = settings.optString("address", "")
|
||||||
|
config.CONNECTED_V2RAY_SERVER_PORT = settings.optInt("port", 0).toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Optional metadata only; runtime config validation happens downstream.
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(context, XrayVPNService::class.java)
|
||||||
|
intent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.START_SERVICE)
|
||||||
|
intent.putExtra("V2RAY_CONFIG", config)
|
||||||
|
intent.putExtra("PROXY_ONLY", call.argument<Boolean>("proxy_only") ?: false)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (expectedRuntimeToken == runtimeToken) expectedRuntimeToken = null
|
||||||
|
result.error("START_FAILED", e.message ?: "Could not start VPN service", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"stopVless" -> {
|
||||||
|
if (pendingStopResult != null) {
|
||||||
|
result.error("STOP_IN_PROGRESS", "VPN shutdown is already in progress", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// A STOP without a generation token can kill a newer runtime after
|
||||||
|
// UI/process churn. No token means this bridge owns nothing to stop.
|
||||||
|
val token = expectedRuntimeToken
|
||||||
|
if (token == null) {
|
||||||
|
result.success(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(context, XrayVPNService::class.java)
|
||||||
|
intent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE)
|
||||||
|
intent.putExtra("RUNTIME_TOKEN", token)
|
||||||
|
|
||||||
|
try {
|
||||||
|
context.startService(intent)
|
||||||
|
pendingStopResult = result
|
||||||
|
mainHandler.removeCallbacks(stopTimeoutRunnable)
|
||||||
|
mainHandler.postDelayed(stopTimeoutRunnable, 6_000L)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
pendingStopResult = null
|
||||||
|
mainHandler.removeCallbacks(stopTimeoutRunnable)
|
||||||
|
result.error("STOP_FAILED", e.message ?: "Could not stop VPN service", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"setSessionDeadline" -> {
|
||||||
|
val token = expectedRuntimeToken
|
||||||
|
val remainingSeconds = call.argument<Number>("remainingSeconds")?.toLong()
|
||||||
|
if (token == null) {
|
||||||
|
result.error("NO_RUNTIME", "No active runtime token", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (remainingSeconds == null || remainingSeconds < 0) {
|
||||||
|
result.error("INVALID_DEADLINE", "remainingSeconds must be non-negative", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(context, XrayVPNService::class.java)
|
||||||
|
intent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.UPDATE_SESSION_DEADLINE)
|
||||||
|
intent.putExtra("RUNTIME_TOKEN", token)
|
||||||
|
intent.putExtra("REMAINING_SECONDS", remainingSeconds)
|
||||||
|
try {
|
||||||
|
context.startService(intent)
|
||||||
|
result.success(null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("DEADLINE_FAILED", e.message ?: "Could not update session deadline", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"initializeVless" -> {
|
||||||
|
val iconResourceName = call.argument<String>("notificationIconResourceName")
|
||||||
|
val iconResourceType = call.argument<String>("notificationIconResourceType")
|
||||||
|
if (iconResourceName != null && iconResourceType != null) {
|
||||||
|
AppConfigs.NOTIFICATION_ICON_RESOURCE_NAME = iconResourceName
|
||||||
|
AppConfigs.NOTIFICATION_ICON_RESOURCE_TYPE = iconResourceType
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
"getCoreVersion" -> {
|
||||||
|
executor.submit {
|
||||||
|
try {
|
||||||
|
val nativeLibraryDir = context.applicationInfo.nativeLibraryDir
|
||||||
|
val xrayExecutable = File(nativeLibraryDir, "libxray.so")
|
||||||
|
if (xrayExecutable.exists()) {
|
||||||
|
val p = Runtime.getRuntime().exec(arrayOf(xrayExecutable.absolutePath, "-version"))
|
||||||
|
val reader = BufferedReader(InputStreamReader(p.inputStream))
|
||||||
|
val version = reader.readLine()
|
||||||
|
result.success(version)
|
||||||
|
} else {
|
||||||
|
result.success("Xray not found")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.success("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"requestPermission" -> {
|
||||||
|
val currentActivity = activity
|
||||||
|
if (currentActivity == null) {
|
||||||
|
result.error("NO_ACTIVITY", "Activity is not attached", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
if (ActivityCompat.checkSelfPermission(currentActivity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
ActivityCompat.requestPermissions(currentActivity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQUEST_CODE_POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val request = VpnService.prepare(currentActivity)
|
||||||
|
if (request != null) {
|
||||||
|
pendingResult = result
|
||||||
|
currentActivity.startActivityForResult(request, REQUEST_CODE_VPN_PERMISSION)
|
||||||
|
} else {
|
||||||
|
result.success(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerReceiver() {
|
||||||
|
if (activity == null) return
|
||||||
|
if (xrayReceiver == null) {
|
||||||
|
xrayReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
if (intent == null || vpnStatusSink == null) return
|
||||||
|
val state = intent.getSerializableExtra("STATE") as? AppConfigs.V2RAY_STATES
|
||||||
|
val runtimeToken = intent.getStringExtra("RUNTIME_TOKEN").orEmpty()
|
||||||
|
val runtimeReady = intent.getBooleanExtra("RUNTIME_READY", false)
|
||||||
|
val duration = intent.getStringExtra("DURATION")
|
||||||
|
val uploadSpeed = intent.getLongExtra("UPLOAD_SPEED", 0)
|
||||||
|
val downloadSpeed = intent.getLongExtra("DOWNLOAD_SPEED", 0)
|
||||||
|
val uploadTraffic = intent.getLongExtra("UPLOAD_TRAFFIC", 0)
|
||||||
|
val downloadTraffic = intent.getLongExtra("DOWNLOAD_TRAFFIC", 0)
|
||||||
|
|
||||||
|
val currentToken = expectedRuntimeToken
|
||||||
|
if (currentToken == null) {
|
||||||
|
if (runtimeToken.isNotEmpty() &&
|
||||||
|
(state == AppConfigs.V2RAY_STATES.V2RAY_CONNECTED ||
|
||||||
|
state == AppConfigs.V2RAY_STATES.V2RAY_CONNECTING)
|
||||||
|
) {
|
||||||
|
expectedRuntimeToken = runtimeToken
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if (runtimeToken != currentToken) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val stateName = when (state) {
|
||||||
|
AppConfigs.V2RAY_STATES.V2RAY_CONNECTED -> if (runtimeReady) "CONNECTED" else "CONNECTING"
|
||||||
|
AppConfigs.V2RAY_STATES.V2RAY_CONNECTING -> "CONNECTING"
|
||||||
|
else -> "DISCONNECTED"
|
||||||
|
}
|
||||||
|
|
||||||
|
val data = ArrayList<String>()
|
||||||
|
data.add(duration ?: "0")
|
||||||
|
data.add(uploadSpeed.toString())
|
||||||
|
data.add(downloadSpeed.toString())
|
||||||
|
data.add(uploadTraffic.toString())
|
||||||
|
data.add(downloadTraffic.toString())
|
||||||
|
data.add(stateName)
|
||||||
|
|
||||||
|
if (state == AppConfigs.V2RAY_STATES.V2RAY_DISCONNECTED &&
|
||||||
|
runtimeToken.isNotEmpty() && runtimeToken == expectedRuntimeToken
|
||||||
|
) {
|
||||||
|
expectedRuntimeToken = null
|
||||||
|
mainHandler.removeCallbacks(stopTimeoutRunnable)
|
||||||
|
pendingStopResult?.success(null)
|
||||||
|
pendingStopResult = null
|
||||||
|
}
|
||||||
|
|
||||||
|
vpnStatusSink?.success(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val filter = IntentFilter(AppConfigs.V2RAY_CONNECTION_INFO)
|
||||||
|
val currentActivity = activity ?: return
|
||||||
|
ContextCompat.registerReceiver(
|
||||||
|
currentActivity,
|
||||||
|
xrayReceiver,
|
||||||
|
filter,
|
||||||
|
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unregisterReceiver() {
|
||||||
|
val currentActivity = activity
|
||||||
|
val receiver = xrayReceiver
|
||||||
|
if (currentActivity != null && receiver != null) {
|
||||||
|
try {
|
||||||
|
currentActivity.unregisterReceiver(receiver)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Receiver was already detached during lifecycle cleanup", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
xrayReceiver = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun failPendingPermission(code: String, message: String) {
|
||||||
|
pendingResult?.error(code, message, null)
|
||||||
|
pendingResult = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
mainHandler.removeCallbacks(stopTimeoutRunnable)
|
||||||
|
pendingStopResult?.error("ENGINE_DETACHED", "Flutter engine detached during VPN shutdown", null)
|
||||||
|
pendingStopResult = null
|
||||||
|
failPendingPermission("ENGINE_DETACHED", "Flutter engine detached during VPN permission request")
|
||||||
|
unregisterReceiver()
|
||||||
|
vpnControlMethod.setMethodCallHandler(null)
|
||||||
|
vpnStatusEvent.setStreamHandler(null)
|
||||||
|
executor.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||||
|
activity = binding.activity
|
||||||
|
binding.addActivityResultListener(this)
|
||||||
|
if (vpnStatusSink != null) {
|
||||||
|
registerReceiver()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromActivityForConfigChanges() {
|
||||||
|
unregisterReceiver()
|
||||||
|
activity = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||||
|
activity = binding.activity
|
||||||
|
binding.addActivityResultListener(this)
|
||||||
|
if (vpnStatusSink != null) {
|
||||||
|
registerReceiver()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromActivity() {
|
||||||
|
failPendingPermission("NO_ACTIVITY", "Activity detached during VPN permission request")
|
||||||
|
unregisterReceiver()
|
||||||
|
activity = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||||
|
if (requestCode == REQUEST_CODE_VPN_PERMISSION) {
|
||||||
|
if (resultCode == Activity.RESULT_OK) {
|
||||||
|
pendingResult?.success(true)
|
||||||
|
} else {
|
||||||
|
pendingResult?.success(false)
|
||||||
|
}
|
||||||
|
pendingResult = null
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
+642
@@ -0,0 +1,642 @@
|
|||||||
|
package com.github.tfox.flutter_vless.xray.core
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.CountDownTimer
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import com.github.tfox.flutter_vless.xray.dto.XrayConfig
|
||||||
|
import com.github.tfox.flutter_vless.xray.service.XrayVPNService
|
||||||
|
import com.github.tfox.flutter_vless.xray.utils.AppConfigs
|
||||||
|
import com.github.tfox.flutter_vless.xray.utils.Utilities
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.json.JSONArray
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the Xray Core process (libxray.so).
|
||||||
|
*
|
||||||
|
* This singleton object is responsible for:
|
||||||
|
* 1. Generating the final Xray configuration file (config.json).
|
||||||
|
* 2. Injecting necessary inbounds (SOCKS, HTTP, API) into the user-provided config.
|
||||||
|
* 3. Starting and monitoring the Xray process.
|
||||||
|
* 4. Collecting traffic statistics via the Xray API.
|
||||||
|
* 5. Showing the persistent foreground notification.
|
||||||
|
*/
|
||||||
|
object XrayCoreManager {
|
||||||
|
|
||||||
|
private const val NOTIFICATION_ID = 1
|
||||||
|
private const val TAG = "XrayCoreManager"
|
||||||
|
private var xrayProcess: Process? = null
|
||||||
|
private var countDownTimer: CountDownTimer? = null
|
||||||
|
private var seconds = 0
|
||||||
|
|
||||||
|
private fun deleteEphemeralConfigOnFailure(filesDir: File) {
|
||||||
|
try {
|
||||||
|
val config = File(filesDir, "config.json")
|
||||||
|
if (config.exists() && !config.delete()) {
|
||||||
|
Log.w(TAG, "Could not delete failed ephemeral Xray config")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Could not delete failed ephemeral Xray config", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nextFreePort(preferredPort: Int, usedPorts: Set<Int>): Int {
|
||||||
|
var port = preferredPort
|
||||||
|
while (usedPorts.contains(port)) {
|
||||||
|
port++
|
||||||
|
}
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun uniqueInboundTag(inbounds: JSONArray, preferredTag: String): String {
|
||||||
|
val tags = mutableSetOf<String>()
|
||||||
|
for (i in 0 until inbounds.length()) {
|
||||||
|
val tag = inbounds.optJSONObject(i)?.optString("tag").orEmpty()
|
||||||
|
if (tag.isNotEmpty()) tags.add(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tags.contains(preferredTag)) return preferredTag
|
||||||
|
|
||||||
|
var index = 1
|
||||||
|
var candidate = "${preferredTag}_$index"
|
||||||
|
while (tags.contains(candidate)) {
|
||||||
|
index++
|
||||||
|
candidate = "${preferredTag}_$index"
|
||||||
|
}
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeRuntimeConfig(value: Any?): Any? {
|
||||||
|
return when (value) {
|
||||||
|
is JSONObject -> {
|
||||||
|
val normalized = JSONObject()
|
||||||
|
val aliases = mapOf(
|
||||||
|
"xHTTPSettings" to "xhttpSettings",
|
||||||
|
"httpUpgradeSettings" to "httpupgradeSettings",
|
||||||
|
"splitHTTPSettings" to "splithttpSettings"
|
||||||
|
)
|
||||||
|
val keys = value.keys()
|
||||||
|
while (keys.hasNext()) {
|
||||||
|
val key = keys.next()
|
||||||
|
if (key == "allowInsecure") continue
|
||||||
|
|
||||||
|
val targetKey = aliases[key] ?: key
|
||||||
|
if (aliases.containsKey(key) && value.has(targetKey)) continue
|
||||||
|
|
||||||
|
val normalizedValue = normalizeRuntimeConfig(value.opt(key))
|
||||||
|
if (targetKey == "network" && normalizedValue is String) {
|
||||||
|
normalized.put(targetKey, normalizedValue.lowercase())
|
||||||
|
} else {
|
||||||
|
normalized.put(targetKey, normalizedValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
is JSONArray -> {
|
||||||
|
val normalized = JSONArray()
|
||||||
|
for (i in 0 until value.length()) {
|
||||||
|
normalized.put(normalizeRuntimeConfig(value.opt(i)))
|
||||||
|
}
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
else -> value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeVlessOutbounds(configJson: JSONObject) {
|
||||||
|
val outbounds = configJson.optJSONArray("outbounds") ?: return
|
||||||
|
for (i in 0 until outbounds.length()) {
|
||||||
|
val outbound = outbounds.optJSONObject(i) ?: continue
|
||||||
|
if (outbound.optString("protocol") != "vless") continue
|
||||||
|
|
||||||
|
val settings = outbound.optJSONObject("settings") ?: continue
|
||||||
|
if (settings.has("vnext")) continue
|
||||||
|
|
||||||
|
val address = settings.optString("address")
|
||||||
|
val id = settings.optString("id")
|
||||||
|
val port = settings.optInt("port", 0)
|
||||||
|
if (address.isEmpty() || id.isEmpty() || port <= 0) continue
|
||||||
|
|
||||||
|
val user = JSONObject()
|
||||||
|
user.put("id", id)
|
||||||
|
user.put("encryption", settings.optString("encryption", "none"))
|
||||||
|
user.put("flow", settings.optString("flow", ""))
|
||||||
|
user.put("level", settings.optInt("level", 8))
|
||||||
|
|
||||||
|
val server = JSONObject()
|
||||||
|
server.put("address", address)
|
||||||
|
server.put("port", port)
|
||||||
|
server.put("users", JSONArray().put(user))
|
||||||
|
|
||||||
|
val normalizedSettings = JSONObject()
|
||||||
|
normalizedSettings.put("vnext", JSONArray().put(server))
|
||||||
|
outbound.put("settings", normalizedSettings)
|
||||||
|
Log.d(TAG, "Normalized flat VLESS outbound settings for $address:$port")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sanitizeLogPaths(configJson: JSONObject, filesDir: File) {
|
||||||
|
val log = configJson.optJSONObject("log") ?: return
|
||||||
|
|
||||||
|
// The VPN client never needs destination/access logs. A server-provided
|
||||||
|
// config must not silently enable browsing metadata collection locally.
|
||||||
|
log.remove("access")
|
||||||
|
try { File(filesDir, "access.log").delete() } catch (_: Exception) {}
|
||||||
|
|
||||||
|
val errorPath = log.optString("error")
|
||||||
|
if (errorPath.isNotEmpty()) {
|
||||||
|
log.put("error", File(filesDir, "error.log").absolutePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun buildRuntimeConfigJson(config: XrayConfig, filesDir: File): JSONObject {
|
||||||
|
val configJson = normalizeRuntimeConfig(
|
||||||
|
JSONObject(config.V2RAY_FULL_JSON_CONFIG)
|
||||||
|
) as JSONObject
|
||||||
|
normalizeVlessOutbounds(configJson)
|
||||||
|
sanitizeLogPaths(configJson, filesDir)
|
||||||
|
|
||||||
|
// ReVolt needs only its authenticated local SOCKS ingress.
|
||||||
|
val inbounds = configJson.optJSONArray("inbounds") ?: JSONArray()
|
||||||
|
var secureSocksEnabled = false
|
||||||
|
for (i in 0 until inbounds.length()) {
|
||||||
|
val inbound = inbounds.optJSONObject(i) ?: continue
|
||||||
|
if (inbound.optString("tag") != "revolt-secure-socks") continue
|
||||||
|
|
||||||
|
val settings = inbound.optJSONObject("settings")
|
||||||
|
?: throw IllegalStateException("Secure SOCKS5 settings are missing")
|
||||||
|
val users = settings.optJSONArray("users")
|
||||||
|
?: throw IllegalStateException("Secure SOCKS5 users are missing")
|
||||||
|
val account = users.optJSONObject(0)
|
||||||
|
?: throw IllegalStateException("Secure SOCKS5 account is missing")
|
||||||
|
val port = inbound.optInt("port", -1)
|
||||||
|
|
||||||
|
if (inbound.optString("protocol") != "socks" ||
|
||||||
|
inbound.optString("listen") != "127.0.0.1" ||
|
||||||
|
port <= 1024 || port > 65535 ||
|
||||||
|
settings.optString("auth") != "password" ||
|
||||||
|
account.optString("user").isEmpty() ||
|
||||||
|
account.optString("pass").isEmpty()
|
||||||
|
) {
|
||||||
|
throw IllegalStateException("Secure SOCKS5 session is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
config.LOCAL_SOCKS5_PORT = port
|
||||||
|
config.LOCAL_HTTP_PORT = 0
|
||||||
|
secureSocksEnabled = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
val usedPorts = mutableSetOf<Int>()
|
||||||
|
var hasSocksOnLocalPort = false
|
||||||
|
var hasHttpOnLocalPort = false
|
||||||
|
for (i in 0 until inbounds.length()) {
|
||||||
|
val inbound = inbounds.getJSONObject(i)
|
||||||
|
val protocol = inbound.optString("protocol")
|
||||||
|
val port = inbound.optInt("port", -1)
|
||||||
|
if (port > 0) usedPorts.add(port)
|
||||||
|
if (protocol == "socks" && port == config.LOCAL_SOCKS5_PORT) hasSocksOnLocalPort = true
|
||||||
|
if (protocol == "http" && port == config.LOCAL_HTTP_PORT) hasHttpOnLocalPort = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasSocksOnLocalPort) {
|
||||||
|
if (usedPorts.contains(config.LOCAL_SOCKS5_PORT)) {
|
||||||
|
config.LOCAL_SOCKS5_PORT = nextFreePort(config.LOCAL_SOCKS5_PORT, usedPorts)
|
||||||
|
}
|
||||||
|
val socksInbound = JSONObject()
|
||||||
|
socksInbound.put("tag", uniqueInboundTag(inbounds, "socks"))
|
||||||
|
socksInbound.put("port", config.LOCAL_SOCKS5_PORT)
|
||||||
|
socksInbound.put("listen", "127.0.0.1")
|
||||||
|
socksInbound.put("protocol", "socks")
|
||||||
|
socksInbound.put("settings", JSONObject().put("auth", "noauth").put("udp", true))
|
||||||
|
socksInbound.put(
|
||||||
|
"sniffing",
|
||||||
|
JSONObject().put("enabled", true).put("destOverride", JSONArray().put("http").put("tls"))
|
||||||
|
)
|
||||||
|
inbounds.put(socksInbound)
|
||||||
|
usedPorts.add(config.LOCAL_SOCKS5_PORT)
|
||||||
|
Log.d(TAG, "Injected SOCKS inbound on port ${config.LOCAL_SOCKS5_PORT}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!secureSocksEnabled && !hasHttpOnLocalPort) {
|
||||||
|
if (usedPorts.contains(config.LOCAL_HTTP_PORT)) {
|
||||||
|
config.LOCAL_HTTP_PORT = nextFreePort(config.LOCAL_HTTP_PORT, usedPorts)
|
||||||
|
}
|
||||||
|
val httpInbound = JSONObject()
|
||||||
|
httpInbound.put("tag", uniqueInboundTag(inbounds, "http"))
|
||||||
|
httpInbound.put("port", config.LOCAL_HTTP_PORT)
|
||||||
|
httpInbound.put("listen", "127.0.0.1")
|
||||||
|
httpInbound.put("protocol", "http")
|
||||||
|
inbounds.put(httpInbound)
|
||||||
|
usedPorts.add(config.LOCAL_HTTP_PORT)
|
||||||
|
Log.d(TAG, "Injected HTTP inbound on port ${config.LOCAL_HTTP_PORT}")
|
||||||
|
}
|
||||||
|
|
||||||
|
return configJson
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun buildDelayConfigJson(configJson: String, proxyPort: Int, filesDir: File): Pair<JSONObject, Int> {
|
||||||
|
val delayConfig = XrayConfig(
|
||||||
|
V2RAY_FULL_JSON_CONFIG = configJson,
|
||||||
|
LOCAL_SOCKS5_PORT = proxyPort,
|
||||||
|
LOCAL_HTTP_PORT = proxyPort + 1,
|
||||||
|
LOCAL_API_PORT = proxyPort + 2
|
||||||
|
)
|
||||||
|
val runtimeJson = buildRuntimeConfigJson(delayConfig, filesDir)
|
||||||
|
return Pair(runtimeJson, delayConfig.LOCAL_SOCKS5_PORT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the Xray Core process.
|
||||||
|
*
|
||||||
|
* @param context The service context (needed for file access and notifications).
|
||||||
|
* @param config The configuration object containing the user's settings.
|
||||||
|
* @return true if started successfully, false otherwise.
|
||||||
|
*/
|
||||||
|
fun startCore(context: Service, config: XrayConfig): Boolean {
|
||||||
|
AppConfigs.RUNTIME_READY = false
|
||||||
|
AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTING
|
||||||
|
AppConfigs.V2RAY_CONFIG = config
|
||||||
|
|
||||||
|
// 1. Prepare the configuration file
|
||||||
|
val configFilesDir = context.filesDir
|
||||||
|
|
||||||
|
try {
|
||||||
|
val configJson = buildRuntimeConfigJson(config, configFilesDir)
|
||||||
|
val configFile = File(context.filesDir, "config.json")
|
||||||
|
configFile.writeText(configJson.toString())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to write config file", e)
|
||||||
|
deleteEphemeralConfigOnFailure(configFilesDir)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Find Xray executable (libxray.so)
|
||||||
|
val nativeLibraryDir = context.applicationInfo.nativeLibraryDir
|
||||||
|
val xrayExecutable = File(nativeLibraryDir, "libxray.so")
|
||||||
|
if (!xrayExecutable.exists()) {
|
||||||
|
Log.e(TAG, "Xray executable not found at ${xrayExecutable.absolutePath}")
|
||||||
|
deleteEphemeralConfigOnFailure(configFilesDir)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Prepare assets (geoip, geosite)
|
||||||
|
try {
|
||||||
|
Utilities.copyAssets(context)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to prepare Xray assets", e)
|
||||||
|
deleteEphemeralConfigOnFailure(configFilesDir)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Run Xray
|
||||||
|
try {
|
||||||
|
val cmd = listOf(
|
||||||
|
xrayExecutable.absolutePath,
|
||||||
|
"-config", File(configFilesDir, "config.json").absolutePath
|
||||||
|
)
|
||||||
|
val pb = ProcessBuilder(cmd)
|
||||||
|
pb.directory(configFilesDir)
|
||||||
|
pb.redirectErrorStream(true)
|
||||||
|
|
||||||
|
// Set environment variables (XRAY_LOCATION_ASSET is crucial for finding geoip/geosite)
|
||||||
|
val env = pb.environment()
|
||||||
|
env["XRAY_LOCATION_ASSET"] = Utilities.getUserAssetsPath(context)
|
||||||
|
|
||||||
|
xrayProcess = pb.start()
|
||||||
|
Thread.sleep(300)
|
||||||
|
if (xrayProcess?.isAlive != true) {
|
||||||
|
val output = xrayProcess?.inputStream?.bufferedReader()?.readText().orEmpty()
|
||||||
|
Log.e(TAG, "Xray process exited during startup. Output: $output")
|
||||||
|
xrayProcess = null
|
||||||
|
AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_DISCONNECTED
|
||||||
|
deleteEphemeralConfigOnFailure(configFilesDir)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// config.json contains per-session SOCKS credentials. Xray has
|
||||||
|
// consumed it after the startup liveness check, so remove it now.
|
||||||
|
try {
|
||||||
|
val ephemeralConfig = File(configFilesDir, "config.json")
|
||||||
|
if (ephemeralConfig.exists() && !ephemeralConfig.delete()) {
|
||||||
|
Log.w(TAG, "Could not delete ephemeral Xray config")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Could not delete ephemeral Xray config", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
AppConfigs.RUNTIME_READY = false
|
||||||
|
AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTING
|
||||||
|
startTimer(context)
|
||||||
|
showNotification(context, config)
|
||||||
|
sendStatusBroadcast(context, config)
|
||||||
|
|
||||||
|
val process = xrayProcess ?: return false
|
||||||
|
// Monitor process in a separate thread to detect crash
|
||||||
|
Thread {
|
||||||
|
try {
|
||||||
|
process.inputStream.bufferedReader().use { reader ->
|
||||||
|
reader.forEachLine { _ -> }
|
||||||
|
}
|
||||||
|
|
||||||
|
val exitCode = process.waitFor()
|
||||||
|
Log.e(TAG, "Xray process exited with code $exitCode")
|
||||||
|
if (xrayProcess === process &&
|
||||||
|
AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN == config.RUNTIME_TOKEN &&
|
||||||
|
(AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTED ||
|
||||||
|
AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTING)
|
||||||
|
) {
|
||||||
|
// Hold the Android TUN while the core is recovered. Killing
|
||||||
|
// the VPN service here would allow direct-network fallback.
|
||||||
|
xrayProcess = null
|
||||||
|
AppConfigs.RUNTIME_READY = false
|
||||||
|
AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTING
|
||||||
|
sendStatusBroadcast(context, config)
|
||||||
|
if (context is XrayVPNService) {
|
||||||
|
context.handleXrayCoreExit(config)
|
||||||
|
} else {
|
||||||
|
stopCore(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: java.io.InterruptedIOException) {
|
||||||
|
// Expected when stopping
|
||||||
|
} catch (e: InterruptedException) {
|
||||||
|
// Expected when stopping
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error reading xray output", e)
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
|
||||||
|
return true
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to start Xray process", e)
|
||||||
|
deleteEphemeralConfigOnFailure(configFilesDir)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the Xray Core process and cleans up notifications.
|
||||||
|
*/
|
||||||
|
fun stopCore(context: Service, confirmationToken: String? = AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN) {
|
||||||
|
try {
|
||||||
|
xrayProcess?.destroy()
|
||||||
|
xrayProcess = null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to destroy Xray process", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
AppConfigs.RUNTIME_READY = false
|
||||||
|
AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_DISCONNECTED
|
||||||
|
stopTimer()
|
||||||
|
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
notificationManager.cancel(NOTIFICATION_ID)
|
||||||
|
sendDisconnectedBroadcast(context, confirmationToken.orEmpty())
|
||||||
|
AppConfigs.V2RAY_CONFIG = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markRuntimeConnecting(context: Context, config: XrayConfig) {
|
||||||
|
if (AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN != config.RUNTIME_TOKEN) return
|
||||||
|
AppConfigs.RUNTIME_READY = false
|
||||||
|
AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTING
|
||||||
|
sendStatusBroadcast(context, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markRuntimeReady(context: Service, config: XrayConfig) {
|
||||||
|
if (AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN != config.RUNTIME_TOKEN) return
|
||||||
|
AppConfigs.RUNTIME_READY = true
|
||||||
|
AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTED
|
||||||
|
showNotification(context, config)
|
||||||
|
sendStatusBroadcast(context, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isXrayRunning(): Boolean {
|
||||||
|
// Check state instead of process because VPN runs in separate service process
|
||||||
|
return AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTED ||
|
||||||
|
AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTING
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startTimer(context: Context) {
|
||||||
|
countDownTimer?.cancel()
|
||||||
|
seconds = 0
|
||||||
|
countDownTimer = object : CountDownTimer(Long.MAX_VALUE, 1000) {
|
||||||
|
override fun onTick(millisUntilFinished: Long) {
|
||||||
|
seconds++
|
||||||
|
val intent = Intent(AppConfigs.V2RAY_CONNECTION_INFO).setPackage(context.packageName)
|
||||||
|
intent.putExtra("STATE", AppConfigs.V2RAY_STATE)
|
||||||
|
intent.putExtra("RUNTIME_TOKEN", AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN.orEmpty())
|
||||||
|
intent.putExtra("RUNTIME_READY", AppConfigs.RUNTIME_READY)
|
||||||
|
intent.putExtra("DURATION", seconds.toString())
|
||||||
|
|
||||||
|
intent.putExtra("UPLOAD_SPEED", 0L)
|
||||||
|
intent.putExtra("DOWNLOAD_SPEED", 0L)
|
||||||
|
intent.putExtra("UPLOAD_TRAFFIC", 0L)
|
||||||
|
intent.putExtra("DOWNLOAD_TRAFFIC", 0L)
|
||||||
|
|
||||||
|
context.sendBroadcast(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFinish() {}
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopTimer() {
|
||||||
|
countDownTimer?.cancel()
|
||||||
|
countDownTimer = null
|
||||||
|
seconds = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendStatusBroadcast(context: Context, config: XrayConfig) {
|
||||||
|
val intent = Intent(AppConfigs.V2RAY_CONNECTION_INFO).setPackage(context.packageName)
|
||||||
|
intent.putExtra("STATE", AppConfigs.V2RAY_STATE)
|
||||||
|
intent.putExtra("RUNTIME_TOKEN", config.RUNTIME_TOKEN)
|
||||||
|
intent.putExtra("RUNTIME_READY", AppConfigs.RUNTIME_READY)
|
||||||
|
intent.putExtra("DURATION", seconds.toString())
|
||||||
|
intent.putExtra("UPLOAD_SPEED", 0L)
|
||||||
|
intent.putExtra("DOWNLOAD_SPEED", 0L)
|
||||||
|
intent.putExtra("UPLOAD_TRAFFIC", 0L)
|
||||||
|
intent.putExtra("DOWNLOAD_TRAFFIC", 0L)
|
||||||
|
context.sendBroadcast(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendDisconnectedBroadcast(context: Context, runtimeToken: String) {
|
||||||
|
val intent = Intent(AppConfigs.V2RAY_CONNECTION_INFO).setPackage(context.packageName)
|
||||||
|
intent.putExtra("STATE", AppConfigs.V2RAY_STATES.V2RAY_DISCONNECTED)
|
||||||
|
intent.putExtra("RUNTIME_TOKEN", runtimeToken)
|
||||||
|
intent.putExtra("RUNTIME_READY", false)
|
||||||
|
intent.putExtra("DURATION", "0")
|
||||||
|
intent.putExtra("UPLOAD_SPEED", 0L)
|
||||||
|
intent.putExtra("DOWNLOAD_SPEED", 0L)
|
||||||
|
intent.putExtra("UPLOAD_TRAFFIC", 0L)
|
||||||
|
intent.putExtra("DOWNLOAD_TRAFFIC", 0L)
|
||||||
|
context.sendBroadcast(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showNotification(context: Service, config: XrayConfig) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
if (ActivityCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val channelId = createNotificationChannel(context, config.APPLICATION_NAME)
|
||||||
|
|
||||||
|
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
||||||
|
launchIntent?.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
|
||||||
|
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
val contentPendingIntent = PendingIntent.getActivity(context, 0, launchIntent, flags)
|
||||||
|
|
||||||
|
val stopIntent = Intent(context, XrayVPNService::class.java)
|
||||||
|
stopIntent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE)
|
||||||
|
stopIntent.putExtra("RUNTIME_TOKEN", config.RUNTIME_TOKEN)
|
||||||
|
val stopPendingIntent = PendingIntent.getService(context, 0, stopIntent, flags)
|
||||||
|
val smallIcon = if (config.APPLICATION_ICON != 0) config.APPLICATION_ICON else android.R.drawable.ic_dialog_info
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(context, channelId)
|
||||||
|
.setSmallIcon(smallIcon)
|
||||||
|
.setContentTitle(config.REMARK)
|
||||||
|
.setContentText(if (AppConfigs.RUNTIME_READY) "Connected" else "Connecting")
|
||||||
|
.addAction(0, config.NOTIFICATION_DISCONNECT_BUTTON_NAME, stopPendingIntent)
|
||||||
|
.setContentIntent(contentPendingIntent)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setShowWhen(true)
|
||||||
|
|
||||||
|
context.startForeground(NOTIFICATION_ID, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getConnectedV2rayServerDelay(context: Context, url: String): Long {
|
||||||
|
// Use the configured SOCKS port (default 10807)
|
||||||
|
val port = AppConfigs.V2RAY_CONFIG?.LOCAL_SOCKS5_PORT ?: 10807
|
||||||
|
Log.d(TAG, "getConnectedV2rayServerDelay: Testing delay to $url via SOCKS port $port")
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val start = System.currentTimeMillis()
|
||||||
|
val proxy = java.net.Proxy(java.net.Proxy.Type.SOCKS, java.net.InetSocketAddress("127.0.0.1", port))
|
||||||
|
val connection = java.net.URL(url).openConnection(proxy) as java.net.HttpURLConnection
|
||||||
|
connection.connectTimeout = 5000
|
||||||
|
connection.readTimeout = 5000
|
||||||
|
connection.requestMethod = "HEAD"
|
||||||
|
val responseCode = connection.responseCode
|
||||||
|
val end = System.currentTimeMillis()
|
||||||
|
val delay = end - start
|
||||||
|
connection.disconnect()
|
||||||
|
Log.d(TAG, "getConnectedV2rayServerDelay: Success! Response code: $responseCode, Delay: ${delay}ms")
|
||||||
|
delay
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "getConnectedV2rayServerDelay: Failed to measure delay (VPN may not be running)", e)
|
||||||
|
-1L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure delay for a server configuration (when not connected).
|
||||||
|
* Temporarily starts Xray with the provided config, measures delay, then stops it.
|
||||||
|
*/
|
||||||
|
fun getServerDelay(context: Context, configJson: String, url: String): Long {
|
||||||
|
Log.d(TAG, "getServerDelay: Starting temporary Xray instance")
|
||||||
|
|
||||||
|
var tempProcess: Process? = null
|
||||||
|
try {
|
||||||
|
// Find a random free port to avoid conflict with running VPN
|
||||||
|
val freePort = try {
|
||||||
|
val socket = java.net.ServerSocket(0)
|
||||||
|
val port = socket.localPort
|
||||||
|
socket.close()
|
||||||
|
port
|
||||||
|
} catch (e: Exception) {
|
||||||
|
10806 // Fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
val (json, socksPort) = buildDelayConfigJson(configJson, freePort, context.filesDir)
|
||||||
|
Log.d(TAG, "getServerDelay: Using SOCKS port $socksPort")
|
||||||
|
|
||||||
|
// Write temp config file
|
||||||
|
val tempConfigFile = File(context.filesDir, "temp_delay_config.json")
|
||||||
|
tempConfigFile.writeText(json.toString())
|
||||||
|
|
||||||
|
// Copy assets
|
||||||
|
Utilities.copyAssets(context)
|
||||||
|
|
||||||
|
// Start Xray process
|
||||||
|
val xrayExecutable = File(context.applicationInfo.nativeLibraryDir, "libxray.so")
|
||||||
|
if (!xrayExecutable.exists()) {
|
||||||
|
Log.e(TAG, "getServerDelay: Xray executable not found")
|
||||||
|
return -1L
|
||||||
|
}
|
||||||
|
|
||||||
|
val cmd = listOf(
|
||||||
|
xrayExecutable.absolutePath,
|
||||||
|
"-config", tempConfigFile.absolutePath
|
||||||
|
)
|
||||||
|
|
||||||
|
val pb = ProcessBuilder(cmd)
|
||||||
|
pb.directory(context.filesDir)
|
||||||
|
val env = pb.environment()
|
||||||
|
env["XRAY_LOCATION_ASSET"] = Utilities.getUserAssetsPath(context)
|
||||||
|
|
||||||
|
tempProcess = pb.start()
|
||||||
|
|
||||||
|
// Wait a bit for Xray to start
|
||||||
|
Thread.sleep(1000)
|
||||||
|
|
||||||
|
// Measure delay
|
||||||
|
val delay = try {
|
||||||
|
val start = System.currentTimeMillis()
|
||||||
|
val proxy = java.net.Proxy(java.net.Proxy.Type.SOCKS, java.net.InetSocketAddress("127.0.0.1", socksPort))
|
||||||
|
val connection = java.net.URL(url).openConnection(proxy) as java.net.HttpURLConnection
|
||||||
|
connection.connectTimeout = 5000
|
||||||
|
connection.readTimeout = 5000
|
||||||
|
connection.requestMethod = "HEAD"
|
||||||
|
val responseCode = connection.responseCode
|
||||||
|
val end = System.currentTimeMillis()
|
||||||
|
connection.disconnect()
|
||||||
|
val result = end - start
|
||||||
|
Log.d(TAG, "getServerDelay: Success! Response code: $responseCode, Delay: ${result}ms")
|
||||||
|
result
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "getServerDelay: Failed to measure delay", e)
|
||||||
|
-1L
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop temp process
|
||||||
|
tempProcess?.destroy()
|
||||||
|
tempConfigFile.delete()
|
||||||
|
|
||||||
|
return delay
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "getServerDelay: Error starting temp Xray", e)
|
||||||
|
tempProcess?.destroy()
|
||||||
|
return -1L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannel(context: Context, appName: String): String {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channelId = "REVOLT_VPN_SERVICE"
|
||||||
|
val channelName = "$appName Background Service"
|
||||||
|
val channel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW)
|
||||||
|
channel.lightColor = Color.BLUE
|
||||||
|
channel.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
|
||||||
|
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
return channelId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
package com.github.tfox.flutter_vless.xray.dto
|
||||||
|
|
||||||
|
import java.io.Serializable
|
||||||
|
import java.util.ArrayList
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data class representing the Xray configuration and app settings.
|
||||||
|
*
|
||||||
|
* This class is passed from Flutter to Android and contains:
|
||||||
|
* 1. The full Xray JSON configuration (V2RAY_FULL_JSON_CONFIG).
|
||||||
|
* 2. App-specific settings like blocked apps, notification text, etc.
|
||||||
|
* 3. Connection parameters (server address, ports).
|
||||||
|
*/
|
||||||
|
data class XrayConfig(
|
||||||
|
/** The IP address of the connected server (used to exclude it from VPN routing). */
|
||||||
|
var CONNECTED_V2RAY_SERVER_ADDRESS: String = "",
|
||||||
|
|
||||||
|
/** The port of the connected server. */
|
||||||
|
var CONNECTED_V2RAY_SERVER_PORT: String = "",
|
||||||
|
|
||||||
|
/** Local port for SOCKS5 proxy (default: 10807). used by tun2socks. */
|
||||||
|
var LOCAL_SOCKS5_PORT: Int = 10807,
|
||||||
|
|
||||||
|
/** Local port for HTTP proxy (default: 10808). */
|
||||||
|
var LOCAL_HTTP_PORT: Int = 10808,
|
||||||
|
|
||||||
|
/** Local port for Xray API (stats, etc.) (default: 10809). */
|
||||||
|
var LOCAL_API_PORT: Int = 10809,
|
||||||
|
|
||||||
|
/** Timeout for connections in milliseconds. */
|
||||||
|
var ALLOWED_TIMEOUT_MS: Int = 0,
|
||||||
|
|
||||||
|
/** List of subnets to bypass (not routed through VPN). */
|
||||||
|
var BYPASS_SUBNETS: ArrayList<String> = ArrayList(),
|
||||||
|
|
||||||
|
/** List of app package names to exclude from VPN. */
|
||||||
|
var BLOCKED_APPS: ArrayList<String> = ArrayList(),
|
||||||
|
|
||||||
|
/** The raw Xray configuration JSON string. */
|
||||||
|
var V2RAY_FULL_JSON_CONFIG: String = "",
|
||||||
|
|
||||||
|
/** Whether to enable traffic statistics collection. */
|
||||||
|
var ENABLE_TRAFFIC_STATICS: Boolean = false,
|
||||||
|
|
||||||
|
/** Remark/Name of the server configuration. */
|
||||||
|
var REMARK: String = "",
|
||||||
|
|
||||||
|
/** Name of the application (displayed in notification). */
|
||||||
|
var APPLICATION_NAME: String = "Flutter Vless",
|
||||||
|
|
||||||
|
/** Resource ID of the notification icon. */
|
||||||
|
var APPLICATION_ICON: Int = 0,
|
||||||
|
|
||||||
|
/** Resource name of the notification icon (e.g. "ic_notification"). */
|
||||||
|
var NOTIFICATION_ICON_RESOURCE_NAME: String = "",
|
||||||
|
|
||||||
|
/** Resource type of the notification icon (e.g. "drawable"). */
|
||||||
|
var NOTIFICATION_ICON_RESOURCE_TYPE: String = "",
|
||||||
|
|
||||||
|
/** Text for the disconnect button in the notification. */
|
||||||
|
var NOTIFICATION_DISCONNECT_BUTTON_NAME: String = "Disconnect",
|
||||||
|
|
||||||
|
/** Domain resolution strategy (e.g. "IPIfNonMatch"). */
|
||||||
|
var DOMAIN_STRATEGY: String = "",
|
||||||
|
|
||||||
|
/** Routing domain strategy. */
|
||||||
|
var ROUTING_DOMAIN_STRATEGY: String = "",
|
||||||
|
|
||||||
|
/** Unique generation token used to reject stale native runtime events. */
|
||||||
|
var RUNTIME_TOKEN: String = ""
|
||||||
|
) : Serializable
|
||||||
+685
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -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 = ""
|
||||||
|
}
|
||||||
+62
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+309
@@ -0,0 +1,309 @@
|
|||||||
|
package com.github.tfox.flutter_vless.xray.core
|
||||||
|
|
||||||
|
import com.github.tfox.flutter_vless.xray.dto.XrayConfig
|
||||||
|
import java.io.File
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class XrayCoreManagerTest {
|
||||||
|
@Test
|
||||||
|
fun buildRuntimeConfigJson_normalizesFlatVlessAndKeepsEncryption() {
|
||||||
|
val filesDir = File("build/test-files/android-normalize")
|
||||||
|
val config = XrayConfig(
|
||||||
|
V2RAY_FULL_JSON_CONFIG = """
|
||||||
|
{
|
||||||
|
"log": {
|
||||||
|
"access": "/Users/dev/Desktop/access.log",
|
||||||
|
"error": "/Users/dev/Desktop/error.log"
|
||||||
|
},
|
||||||
|
"inbounds": [],
|
||||||
|
"outbounds": [
|
||||||
|
{
|
||||||
|
"tag": "proxy",
|
||||||
|
"protocol": "vless",
|
||||||
|
"settings": {
|
||||||
|
"address": "xhttp.example.com",
|
||||||
|
"port": 2043,
|
||||||
|
"id": "b94da146-a56e-49d7-af4c-a68c9065cbfd",
|
||||||
|
"encryption": "$vlessEncryption",
|
||||||
|
"flow": "xtls-rprx-vision",
|
||||||
|
"level": 8
|
||||||
|
},
|
||||||
|
"streamSettings": {
|
||||||
|
"network": "xhttp",
|
||||||
|
"security": "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"routing": { "rules": [] }
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
val output = XrayCoreManager.buildRuntimeConfigJson(config, filesDir)
|
||||||
|
val user = output
|
||||||
|
.getJSONArray("outbounds")
|
||||||
|
.getJSONObject(0)
|
||||||
|
.getJSONObject("settings")
|
||||||
|
.getJSONArray("vnext")
|
||||||
|
.getJSONObject(0)
|
||||||
|
.getJSONArray("users")
|
||||||
|
.getJSONObject(0)
|
||||||
|
val log = output.getJSONObject("log")
|
||||||
|
|
||||||
|
assertEquals(vlessEncryption, user.getString("encryption"))
|
||||||
|
assertEquals("xtls-rprx-vision", user.getString("flow"))
|
||||||
|
assertEquals(File(filesDir, "access.log").absolutePath, log.getString("access"))
|
||||||
|
assertEquals(File(filesDir, "error.log").absolutePath, log.getString("error"))
|
||||||
|
assertTrue(output.has("api"))
|
||||||
|
assertTrue(output.has("stats"))
|
||||||
|
assertTrue(output.getJSONObject("policy").getJSONObject("system").getBoolean("statsOutboundDownlink"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildRuntimeConfigJson_injectsUniqueInboundsAndApiRouteOnPortConflicts() {
|
||||||
|
val config = XrayConfig(
|
||||||
|
LOCAL_SOCKS5_PORT = 10807,
|
||||||
|
LOCAL_HTTP_PORT = 10808,
|
||||||
|
LOCAL_API_PORT = 10809,
|
||||||
|
V2RAY_FULL_JSON_CONFIG = """
|
||||||
|
{
|
||||||
|
"inbounds": [
|
||||||
|
{ "tag": "socks", "protocol": "dokodemo-door", "port": 10807 },
|
||||||
|
{ "tag": "http", "protocol": "dokodemo-door", "port": 10808 },
|
||||||
|
{ "tag": "api", "protocol": "dokodemo-door", "port": 10809 }
|
||||||
|
],
|
||||||
|
"outbounds": [
|
||||||
|
{
|
||||||
|
"tag": "proxy",
|
||||||
|
"protocol": "shadowsocks",
|
||||||
|
"settings": {
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"address": "ss.example.com",
|
||||||
|
"port": 8388,
|
||||||
|
"method": "2022-blake3-aes-128-gcm",
|
||||||
|
"password": "secret"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
val output = XrayCoreManager.buildRuntimeConfigJson(config, File("build/test-files/android-conflicts"))
|
||||||
|
val inbounds = output.getJSONArray("inbounds")
|
||||||
|
val socks = findInbound(inbounds, "socks_1")
|
||||||
|
val http = findInbound(inbounds, "http_1")
|
||||||
|
val api = findInbound(inbounds, "api_1")
|
||||||
|
val routingRules = output.getJSONObject("routing").getJSONArray("rules")
|
||||||
|
val apiRule = routingRules.getJSONObject(routingRules.length() - 1)
|
||||||
|
|
||||||
|
assertEquals(10810, config.LOCAL_SOCKS5_PORT)
|
||||||
|
assertEquals(10811, config.LOCAL_HTTP_PORT)
|
||||||
|
assertEquals(10812, config.LOCAL_API_PORT)
|
||||||
|
assertEquals("socks", socks.getString("protocol"))
|
||||||
|
assertEquals(10810, socks.getInt("port"))
|
||||||
|
assertEquals("http", http.getString("protocol"))
|
||||||
|
assertEquals(10811, http.getInt("port"))
|
||||||
|
assertEquals("dokodemo-door", api.getString("protocol"))
|
||||||
|
assertEquals(10812, api.getInt("port"))
|
||||||
|
assertEquals("api", apiRule.getString("outboundTag"))
|
||||||
|
assertEquals("api_1", apiRule.getJSONArray("inboundTag").getString(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildRuntimeConfigJson_doesNotDuplicateExistingLocalSocksAndHttpInbounds() {
|
||||||
|
val config = XrayConfig(
|
||||||
|
LOCAL_SOCKS5_PORT = 10807,
|
||||||
|
LOCAL_HTTP_PORT = 10808,
|
||||||
|
V2RAY_FULL_JSON_CONFIG = """
|
||||||
|
{
|
||||||
|
"inbounds": [
|
||||||
|
{ "tag": "socks", "protocol": "socks", "port": 10807 },
|
||||||
|
{ "tag": "http", "protocol": "http", "port": 10808 }
|
||||||
|
],
|
||||||
|
"outbounds": [
|
||||||
|
{
|
||||||
|
"tag": "proxy",
|
||||||
|
"protocol": "freedom"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
val output = XrayCoreManager.buildRuntimeConfigJson(config, File("build/test-files/android-existing"))
|
||||||
|
val inbounds = output.getJSONArray("inbounds")
|
||||||
|
|
||||||
|
assertEquals(3, inbounds.length())
|
||||||
|
assertEquals(1, countInboundsByProtocol(inbounds, "socks"))
|
||||||
|
assertEquals(1, countInboundsByProtocol(inbounds, "http"))
|
||||||
|
assertFalse(output.toString().contains("socks_1"))
|
||||||
|
assertFalse(output.toString().contains("http_1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildDelayConfigJson_reusesRuntimeNormalizationForHappJson() {
|
||||||
|
val (output, socksPort) = XrayCoreManager.buildDelayConfigJson(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"dns": { "queryStrategy": "UseIPv4" },
|
||||||
|
"log": {
|
||||||
|
"access": "/Users/dev/Library/access.log",
|
||||||
|
"error": "/Users/dev/Library/error.log"
|
||||||
|
},
|
||||||
|
"inbounds": [
|
||||||
|
{ "tag": "socks-in", "protocol": "socks", "port": 10808 }
|
||||||
|
],
|
||||||
|
"outbounds": [
|
||||||
|
{
|
||||||
|
"tag": "proxy",
|
||||||
|
"protocol": "vless",
|
||||||
|
"settings": {
|
||||||
|
"vnext": [
|
||||||
|
{
|
||||||
|
"address": "reality.example.com",
|
||||||
|
"port": 443,
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"id": "b94da146-a56e-49d7-af4c-a68c9065cbfd",
|
||||||
|
"encryption": "none",
|
||||||
|
"flow": "xtls-rprx-vision"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"streamSettings": {
|
||||||
|
"network": "tcp",
|
||||||
|
"security": "reality"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
proxyPort = 19080,
|
||||||
|
filesDir = File("build/test-files/android-delay")
|
||||||
|
)
|
||||||
|
val inbounds = output.getJSONArray("inbounds")
|
||||||
|
val delaySocks = findInbound(inbounds, "socks")
|
||||||
|
val user = output
|
||||||
|
.getJSONArray("outbounds")
|
||||||
|
.getJSONObject(0)
|
||||||
|
.getJSONObject("settings")
|
||||||
|
.getJSONArray("vnext")
|
||||||
|
.getJSONObject(0)
|
||||||
|
.getJSONArray("users")
|
||||||
|
.getJSONObject(0)
|
||||||
|
|
||||||
|
assertEquals(19080, socksPort)
|
||||||
|
assertEquals(19080, delaySocks.getInt("port"))
|
||||||
|
assertEquals("none", user.getString("encryption"))
|
||||||
|
assertEquals("xtls-rprx-vision", user.getString("flow"))
|
||||||
|
assertTrue(output.getJSONObject("log").getString("access").endsWith("access.log"))
|
||||||
|
assertTrue(output.has("api"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildRuntimeConfigJson_normalizesXray26RuntimeAliases() {
|
||||||
|
val filesDir = File("build/test-files/android-aliases")
|
||||||
|
val config = XrayConfig(
|
||||||
|
V2RAY_FULL_JSON_CONFIG = """
|
||||||
|
{
|
||||||
|
"log": {
|
||||||
|
"access": "/Users/example/access.log",
|
||||||
|
"error": "/Users/example/error.log"
|
||||||
|
},
|
||||||
|
"inbounds": [],
|
||||||
|
"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 runtime = XrayCoreManager.buildRuntimeConfigJson(config, filesDir)
|
||||||
|
val stream = runtime
|
||||||
|
.getJSONArray("outbounds")
|
||||||
|
.getJSONObject(0)
|
||||||
|
.getJSONObject("streamSettings")
|
||||||
|
|
||||||
|
assertEquals("xhttp", stream.getString("network"))
|
||||||
|
assertNotNull(stream.optJSONObject("xhttpSettings"))
|
||||||
|
assertNotNull(stream.optJSONObject("httpupgradeSettings"))
|
||||||
|
assertNotNull(stream.optJSONObject("splithttpSettings"))
|
||||||
|
assertFalse(stream.has("xHTTPSettings"))
|
||||||
|
assertFalse(stream.has("httpUpgradeSettings"))
|
||||||
|
assertFalse(stream.has("splitHTTPSettings"))
|
||||||
|
assertFalse(stream.getJSONObject("tlsSettings").has("allowInsecure"))
|
||||||
|
assertEquals(File(filesDir, "access.log").absolutePath, runtime.getJSONObject("log").getString("access"))
|
||||||
|
assertEquals(File(filesDir, "error.log").absolutePath, runtime.getJSONObject("log").getString("error"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findInbound(inbounds: JSONArray, tag: String): JSONObject {
|
||||||
|
for (i in 0 until inbounds.length()) {
|
||||||
|
val inbound = inbounds.getJSONObject(i)
|
||||||
|
if (inbound.optString("tag") == tag) {
|
||||||
|
return inbound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
error("Inbound $tag not found in $inbounds")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun countInboundsByProtocol(inbounds: JSONArray, protocol: String): Int {
|
||||||
|
var count = 0
|
||||||
|
for (i in 0 until inbounds.length()) {
|
||||||
|
if (inbounds.getJSONObject(i).optString("protocol") == protocol) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val vlessEncryption =
|
||||||
|
"mlkem768x25519plus.native.1rtt.100-500-2000.75-0-100.80-0-5000.gtmOXB2AN_r905czmOIr6dKq_YDdEJB8RWGqfsXurns"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
+14
-31
@@ -65,14 +65,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
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:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -180,12 +172,11 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.5"
|
version: "1.1.5"
|
||||||
flutter_vless_android:
|
flutter_vless_android:
|
||||||
dependency: transitive
|
dependency: "direct overridden"
|
||||||
description:
|
description:
|
||||||
name: flutter_vless_android
|
path: "local_packages/flutter_vless_android-1.1.5"
|
||||||
sha256: "5e6fe73d3606ed8c73e87fac15efa7631e12ce18d39a4bd4e13b6c097c6d52b9"
|
relative: true
|
||||||
url: "https://pub.dev"
|
source: path
|
||||||
source: hosted
|
|
||||||
version: "1.1.5"
|
version: "1.1.5"
|
||||||
flutter_vless_macos:
|
flutter_vless_macos:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
@@ -216,14 +207,6 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
google_mobile_ads:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -316,10 +299,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.19"
|
version: "0.12.20"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -332,10 +315,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.19.0"
|
||||||
nested:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -385,7 +368,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
path_provider:
|
path_provider:
|
||||||
dependency: "direct main"
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider
|
name: path_provider
|
||||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||||
@@ -577,10 +560,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.12"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -665,10 +648,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vector_math
|
name: vector_math
|
||||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.4.2"
|
||||||
vm_service:
|
vm_service:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -742,5 +725,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.10.3 <4.0.0"
|
dart: ">=3.11.0-0 <4.0.0"
|
||||||
flutter: ">=3.38.4"
|
flutter: ">=3.38.4"
|
||||||
+7
-53
@@ -1,20 +1,8 @@
|
|||||||
name: revoltvpn
|
name: revoltvpn
|
||||||
description: ReVolt VPN — Secure VLESS tunnel for Android
|
description: ReVolt VPN — Secure VLESS tunnel for Android
|
||||||
|
|
||||||
# The following line prevents the package from being accidentally published to
|
publish_to: "none"
|
||||||
# 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
|
|
||||||
|
|
||||||
# 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.4+30
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
@@ -24,10 +12,6 @@ dependencies:
|
|||||||
flutter:
|
flutter:
|
||||||
sdk: 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
|
provider: ^6.1.1
|
||||||
google_mobile_ads: ^4.0.0
|
google_mobile_ads: ^4.0.0
|
||||||
flutter_vless: 1.1.5
|
flutter_vless: 1.1.5
|
||||||
@@ -36,7 +20,6 @@ dependencies:
|
|||||||
url_launcher: ^6.3.2
|
url_launcher: ^6.3.2
|
||||||
flutter_secure_storage: ^10.3.1
|
flutter_secure_storage: ^10.3.1
|
||||||
uuid: ^4.5.3
|
uuid: ^4.5.3
|
||||||
path_provider: ^2.1.5
|
|
||||||
package_info_plus: ^6.0.0
|
package_info_plus: ^6.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
@@ -44,42 +27,13 @@ dev_dependencies:
|
|||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^3.0.0
|
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:
|
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
|
uses-material-design: true
|
||||||
|
|
||||||
# To add assets to your application, add an assets section, like this:
|
|
||||||
assets:
|
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
|
dependency_overrides:
|
||||||
# https://flutter.dev/assets-and-images/#resolution-aware.
|
flutter_vless_android:
|
||||||
|
path: local_packages/flutter_vless_android-1.1.5
|
||||||
# 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
|
|
||||||
@@ -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<String, dynamic> || decoded['packages'] is! List) {
|
|
||||||
fail('package_config.json is invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic>? package;
|
|
||||||
for (final item in decoded['packages'] as List) {
|
|
||||||
if (item is Map && item['name'] == packageName) {
|
|
||||||
package = Map<String, dynamic>.from(item);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (package == null) fail('$packageName was not found');
|
|
||||||
|
|
||||||
final rootUriValue = package['rootUri'];
|
|
||||||
if (rootUriValue is! String || rootUriValue.isEmpty) {
|
|
||||||
fail('$packageName has no rootUri');
|
|
||||||
}
|
|
||||||
|
|
||||||
final rootUri = Uri.parse(rootUriValue);
|
|
||||||
final resolved = rootUri.hasScheme
|
|
||||||
? rootUri
|
|
||||||
: configFile.parent.absolute.uri.resolveUri(rootUri);
|
|
||||||
if (resolved.scheme != 'file') fail('unsupported package URI: $resolved');
|
|
||||||
|
|
||||||
final root = Directory.fromUri(resolved);
|
|
||||||
final normalized = root.absolute.path.replaceAll('\\', '/');
|
|
||||||
if (!normalized.contains(expectedVersionFragment)) {
|
|
||||||
fail('expected flutter_vless_android 1.1.5, got ${root.path}');
|
|
||||||
}
|
|
||||||
if (!root.existsSync()) fail('package root does not exist: ${root.path}');
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
void replaceOnce(
|
|
||||||
File file,
|
|
||||||
String oldValue,
|
|
||||||
String newValue,
|
|
||||||
String marker,
|
|
||||||
) {
|
|
||||||
if (!file.existsSync()) fail('missing upstream source: ${file.path}');
|
|
||||||
final text = file.readAsStringSync();
|
|
||||||
if (text.contains(marker)) {
|
|
||||||
stdout.writeln('[flutter_vless 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');
|
|
||||||
}
|
|
||||||
@@ -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<String, dynamic> || decoded['packages'] is! List) {
|
|
||||||
fail('package_config.json is invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic>? package;
|
|
||||||
for (final item in decoded['packages'] as List) {
|
|
||||||
if (item is Map && item['name'] == packageName) {
|
|
||||||
package = Map<String, dynamic>.from(item);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (package == null) fail('$packageName was not found');
|
|
||||||
|
|
||||||
final rootUriValue = package['rootUri'];
|
|
||||||
if (rootUriValue is! String || rootUriValue.isEmpty) {
|
|
||||||
fail('$packageName has no rootUri');
|
|
||||||
}
|
|
||||||
|
|
||||||
final rootUri = Uri.parse(rootUriValue);
|
|
||||||
final resolved = rootUri.hasScheme
|
|
||||||
? rootUri
|
|
||||||
: configFile.parent.absolute.uri.resolveUri(rootUri);
|
|
||||||
if (resolved.scheme != 'file') fail('unsupported package URI: $resolved');
|
|
||||||
|
|
||||||
final root = Directory.fromUri(resolved);
|
|
||||||
final normalized = root.absolute.path.replaceAll('\\', '/');
|
|
||||||
if (!normalized.contains(expectedVersionFragment)) {
|
|
||||||
fail('expected flutter_vless_android 1.1.5, got ${root.path}');
|
|
||||||
}
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
void patchCoreScope(File core) {
|
|
||||||
final before = core.readAsStringSync();
|
|
||||||
|
|
||||||
// The primary secure patcher used a file-wide check for
|
|
||||||
// `val process = pb.start()`. Stock 1.1.5 already contains that exact line
|
|
||||||
// in getV2rayTraffic(), so the startCore() capture could be skipped while
|
|
||||||
// its monitor was still rewritten to use `process`. Capture the active Xray
|
|
||||||
// process immediately before its monitor thread instead. This is deliberately
|
|
||||||
// a scope-only correction and does not alter the stable runtime sequence.
|
|
||||||
const directCapture =
|
|
||||||
' val process = pb.start()\n xrayProcess = process\n';
|
|
||||||
const scopedCapture =
|
|
||||||
' val process = xrayProcess ?: return false\n // Monitor process in a separate thread to detect crash\n';
|
|
||||||
const monitorMarker =
|
|
||||||
' // Monitor process in a separate thread to detect crash\n';
|
|
||||||
|
|
||||||
if (before.contains(directCapture) || before.contains(scopedCapture)) {
|
|
||||||
stdout.writeln('[secure-socks scope fix] core already current: ${core.path}');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!before.contains('process.inputStream.bufferedReader().use { reader ->') ||
|
|
||||||
!before.contains('val exitCode = process.waitFor()') ||
|
|
||||||
!before.contains('xrayProcess === process')) {
|
|
||||||
fail('secure Xray monitor patch is not in the expected state');
|
|
||||||
}
|
|
||||||
if (!before.contains(' xrayProcess = pb.start()\n')) {
|
|
||||||
fail('stock Xray start assignment is missing');
|
|
||||||
}
|
|
||||||
if (!before.contains(monitorMarker)) {
|
|
||||||
fail('Xray monitor insertion point is missing');
|
|
||||||
}
|
|
||||||
|
|
||||||
final after = before.replaceFirst(monitorMarker, scopedCapture);
|
|
||||||
core.writeAsStringSync(after);
|
|
||||||
stdout.writeln('[secure-socks scope fix] captured Xray process for monitor scope');
|
|
||||||
}
|
|
||||||
|
|
||||||
void patchOngoingNotification(File service) {
|
|
||||||
final before = service.readAsStringSync();
|
|
||||||
|
|
||||||
const marker = '.setOngoing(true)';
|
|
||||||
if (before.contains(marker) &&
|
|
||||||
before.contains('Notification.FLAG_NO_CLEAR') &&
|
|
||||||
before.contains('.setAutoCancel(false)')) {
|
|
||||||
stdout.writeln('[secure-socks scope fix] notification already ongoing: ${service.path}');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldBlock = r''' return builder
|
|
||||||
.setContentTitle("VPN Service")
|
|
||||||
.setContentText(content)
|
|
||||||
.setSmallIcon(icon)
|
|
||||||
.build()
|
|
||||||
''';
|
|
||||||
|
|
||||||
const newBlock = r''' val notification = builder
|
|
||||||
.setContentTitle("VPN Service")
|
|
||||||
.setContentText(content)
|
|
||||||
.setSmallIcon(icon)
|
|
||||||
.setOngoing(true)
|
|
||||||
.setAutoCancel(false)
|
|
||||||
.setOnlyAlertOnce(true)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
// Keep the active VPN notification attached to the foreground service
|
|
||||||
// and resistant to bulk clearing on Android versions that honor these
|
|
||||||
// flags. Android 14+ may still allow an explicit user swipe while the
|
|
||||||
// device is unlocked; apps are not allowed to override that system UI.
|
|
||||||
notification.flags = notification.flags or
|
|
||||||
android.app.Notification.FLAG_ONGOING_EVENT or
|
|
||||||
android.app.Notification.FLAG_NO_CLEAR
|
|
||||||
return notification
|
|
||||||
''';
|
|
||||||
|
|
||||||
if (!before.contains(oldBlock)) {
|
|
||||||
fail('VPN notification builder is not in the expected stock 1.1.5 state');
|
|
||||||
}
|
|
||||||
|
|
||||||
final after = before.replaceFirst(oldBlock, newBlock);
|
|
||||||
if (!after.contains(marker) ||
|
|
||||||
!after.contains('Notification.FLAG_NO_CLEAR') ||
|
|
||||||
!after.contains('.setAutoCancel(false)')) {
|
|
||||||
fail('ongoing VPN notification invariant failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
service.writeAsStringSync(after);
|
|
||||||
stdout.writeln('[secure-socks scope fix] VPN notification marked ongoing/no-clear');
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
final root = packageRoot();
|
|
||||||
final kotlin =
|
|
||||||
'${root.path}/android/src/main/kotlin/com/github/tfox/flutter_vless';
|
|
||||||
final core = File('$kotlin/xray/core/XrayCoreManager.kt');
|
|
||||||
final service = File('$kotlin/xray/service/XrayVPNService.kt');
|
|
||||||
|
|
||||||
if (!core.existsSync()) fail('XrayCoreManager.kt is missing');
|
|
||||||
if (!service.existsSync()) fail('XrayVPNService.kt is missing');
|
|
||||||
|
|
||||||
patchCoreScope(core);
|
|
||||||
patchOngoingNotification(service);
|
|
||||||
stdout.writeln('[secure-socks scope fix] scope and notification patch ready');
|
|
||||||
}
|
|
||||||
@@ -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<String, dynamic> || decoded['packages'] is! List) {
|
|
||||||
fail('package_config.json is invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic>? package;
|
|
||||||
for (final item in decoded['packages'] as List) {
|
|
||||||
if (item is Map && item['name'] == packageName) {
|
|
||||||
package = Map<String, dynamic>.from(item);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (package == null) fail('$packageName was not found');
|
|
||||||
|
|
||||||
final rootUriValue = package['rootUri'];
|
|
||||||
if (rootUriValue is! String || rootUriValue.isEmpty) {
|
|
||||||
fail('$packageName has no rootUri');
|
|
||||||
}
|
|
||||||
final rootUri = Uri.parse(rootUriValue);
|
|
||||||
final resolved = rootUri.hasScheme
|
|
||||||
? rootUri
|
|
||||||
: configFile.parent.absolute.uri.resolveUri(rootUri);
|
|
||||||
if (resolved.scheme != 'file') fail('unsupported package URI: $resolved');
|
|
||||||
|
|
||||||
final root = Directory.fromUri(resolved);
|
|
||||||
final normalized = root.absolute.path.replaceAll('\\', '/');
|
|
||||||
if (!normalized.contains(expectedVersionFragment)) {
|
|
||||||
fail('expected flutter_vless_android 1.1.5, got ${root.path}');
|
|
||||||
}
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
String replaceOnce(
|
|
||||||
String text,
|
|
||||||
String oldValue,
|
|
||||||
String newValue,
|
|
||||||
String label,
|
|
||||||
) {
|
|
||||||
if (text.contains(newValue)) return text;
|
|
||||||
if (!text.contains(oldValue)) fail('missing expected source block: $label');
|
|
||||||
return text.replaceFirst(oldValue, newValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
void writeIfChanged(File file, String before, String after) {
|
|
||||||
if (before == after) {
|
|
||||||
stdout.writeln('[secure-socks patch] already current: ${file.path}');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
file.writeAsStringSync(after);
|
|
||||||
stdout.writeln('[secure-socks patch] patched: ${file.path}');
|
|
||||||
}
|
|
||||||
|
|
||||||
void patchCore(File file) {
|
|
||||||
final before = file.readAsStringSync();
|
|
||||||
var text = before;
|
|
||||||
|
|
||||||
const secureValidation = r''' var secureSocksEnabled = false
|
|
||||||
for (i in 0 until inbounds.length()) {
|
|
||||||
val inbound = inbounds.optJSONObject(i) ?: continue
|
|
||||||
if (inbound.optString("tag") != "revolt-secure-socks") continue
|
|
||||||
|
|
||||||
val settings = inbound.optJSONObject("settings")
|
|
||||||
?: throw IllegalStateException("Secure SOCKS5 settings are missing")
|
|
||||||
val users = settings.optJSONArray("users")
|
|
||||||
?: throw IllegalStateException("Secure SOCKS5 users are missing")
|
|
||||||
val account = users.optJSONObject(0)
|
|
||||||
?: throw IllegalStateException("Secure SOCKS5 account is missing")
|
|
||||||
val port = inbound.optInt("port", -1)
|
|
||||||
|
|
||||||
if (inbound.optString("protocol") != "socks" ||
|
|
||||||
inbound.optString("listen") != "127.0.0.1" ||
|
|
||||||
port <= 1024 || port > 65535 ||
|
|
||||||
settings.optString("auth") != "password" ||
|
|
||||||
account.optString("user").isEmpty() ||
|
|
||||||
account.optString("pass").isEmpty()
|
|
||||||
) {
|
|
||||||
throw IllegalStateException("Secure SOCKS5 session is invalid")
|
|
||||||
}
|
|
||||||
|
|
||||||
config.LOCAL_SOCKS5_PORT = port
|
|
||||||
config.LOCAL_HTTP_PORT = 0
|
|
||||||
secureSocksEnabled = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
''';
|
|
||||||
|
|
||||||
if (!text.contains('var secureSocksEnabled = false')) {
|
|
||||||
text = replaceOnce(
|
|
||||||
text,
|
|
||||||
' val usedPorts = mutableSetOf<Int>()\n',
|
|
||||||
'$secureValidation val usedPorts = mutableSetOf<Int>()\n',
|
|
||||||
'secure SOCKS validation insertion',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
text = replaceOnce(
|
|
||||||
text,
|
|
||||||
' if (!hasHttpOnLocalPort) {\n',
|
|
||||||
' if (!secureSocksEnabled && !hasHttpOnLocalPort) {\n',
|
|
||||||
'disable unauthenticated HTTP proxy in secure mode',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!text.contains('val process = pb.start()')) {
|
|
||||||
text = replaceOnce(
|
|
||||||
text,
|
|
||||||
' xrayProcess = pb.start()\n',
|
|
||||||
' val process = pb.start()\n xrayProcess = process\n',
|
|
||||||
'capture Xray process identity',
|
|
||||||
);
|
|
||||||
text = replaceOnce(
|
|
||||||
text,
|
|
||||||
' if (xrayProcess?.isAlive != true) {\n',
|
|
||||||
' if (!process.isAlive) {\n',
|
|
||||||
'Xray startup liveness',
|
|
||||||
);
|
|
||||||
text = replaceOnce(
|
|
||||||
text,
|
|
||||||
' val output = xrayProcess?.inputStream?.bufferedReader()?.readText().orEmpty()\n',
|
|
||||||
' val output = process.inputStream.bufferedReader().readText()\n',
|
|
||||||
'Xray startup output',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!text.contains('Could not delete ephemeral Xray config')) {
|
|
||||||
const connectedLine =
|
|
||||||
' AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTED\n';
|
|
||||||
const deleteConfig = r''' // config.json contains per-session SOCKS credentials. Xray has
|
|
||||||
// consumed it after the startup liveness check, so remove it now.
|
|
||||||
try {
|
|
||||||
val ephemeralConfig = File(configFilesDir, "config.json")
|
|
||||||
if (ephemeralConfig.exists() && !ephemeralConfig.delete()) {
|
|
||||||
Log.w(TAG, "Could not delete ephemeral Xray config")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Could not delete ephemeral Xray config", e)
|
|
||||||
}
|
|
||||||
|
|
||||||
''';
|
|
||||||
text = replaceOnce(
|
|
||||||
text,
|
|
||||||
connectedLine,
|
|
||||||
'$deleteConfig$connectedLine',
|
|
||||||
'ephemeral config deletion',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!text.contains('context.handleXrayCoreExit(config)')) {
|
|
||||||
text = replaceOnce(
|
|
||||||
text,
|
|
||||||
' xrayProcess?.inputStream?.bufferedReader()?.use { reader ->\n',
|
|
||||||
' process.inputStream.bufferedReader().use { reader ->\n',
|
|
||||||
'Xray monitor process stream',
|
|
||||||
);
|
|
||||||
text = replaceOnce(
|
|
||||||
text,
|
|
||||||
' val exitCode = xrayProcess?.waitFor()\n',
|
|
||||||
' val exitCode = process.waitFor()\n',
|
|
||||||
'Xray monitor process wait',
|
|
||||||
);
|
|
||||||
|
|
||||||
const oldExit = r''' if (AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTED) {
|
|
||||||
// Unexpected exit
|
|
||||||
stopCore(context)
|
|
||||||
}
|
|
||||||
''';
|
|
||||||
const newExit = r''' if (xrayProcess === process &&
|
|
||||||
AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTED
|
|
||||||
) {
|
|
||||||
// Hold the Android TUN while the core is recovered. Killing
|
|
||||||
// the VPN service here would allow direct-network fallback.
|
|
||||||
xrayProcess = null
|
|
||||||
AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTING
|
|
||||||
if (context is XrayVPNService) {
|
|
||||||
context.handleXrayCoreExit(config)
|
|
||||||
} else {
|
|
||||||
stopCore(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
''';
|
|
||||||
text = replaceOnce(text, oldExit, newExit, 'Xray fail-closed recovery');
|
|
||||||
}
|
|
||||||
|
|
||||||
writeIfChanged(file, before, text);
|
|
||||||
}
|
|
||||||
|
|
||||||
void patchService(File file) {
|
|
||||||
final before = file.readAsStringSync();
|
|
||||||
var text = before;
|
|
||||||
|
|
||||||
if (!text.contains('private var recoveringXray = false')) {
|
|
||||||
text = replaceOnce(
|
|
||||||
text,
|
|
||||||
' private var isRunning = false\n',
|
|
||||||
' @Volatile private var isRunning = false\n'
|
|
||||||
' @Volatile private var recoveringXray = false\n'
|
|
||||||
' private var currentConfig: XrayConfig? = null\n',
|
|
||||||
'service recovery fields',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user