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