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/.gitignore b/.gitignore
index e59e800..1638154 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,48 +1,18 @@
# =============================================================================
-# ReVoltVPN — .gitignore
-#
-# Type-based by design. This repo ships Dart, Kotlin, XML and image assets;
-# everything else is excluded by extension or directory rather than by being
-# listed individually. A new file is therefore ignored by default instead of
-# needing to be named here, and this file does not enumerate what it protects.
-
+# ReVoltVPN
# =============================================================================
-# ── Secrets ────────────────────────────────────────────────────────────────
+# Secrets / machine-local release material
*.keystore
*.jks
android/key.properties
android/admob.properties
-# Real IPs and IDs. app_config.example.dart is the committed template.
+# This file may contain the private AdMob unit id. Network trust is verified
+# independently at release time; do not replace this with a broad extension rule.
lib/logic/app_config.dart
-# ── File types the shipped app does not contain ────────────────────────────
-*.py
-*.pyc
-*.json
-*.conf
-*.sh
-*.txt
-*.html
-*.zip
-*.log
-*.class
-
-# ── Documentation ──────────────────────────────────────────────────────────
-# The published set is allow-listed; anything else, at any depth, is not.
-# Leading slashes anchor these to the repo root so a same-named file in a
-# subdirectory is NOT re-included by the negation.
-*.md
-!/README.md
-!/CHANGELOG.md
-!/CONTRIBUTING.md
-!/PRIVACY_POLICY.md
-
-# ── Working directories ────────────────────────────────────────────────────
-# Material that is not part of the app. These need directory rules rather
-# than type rules, because they can hold source-file types the rules above
-# deliberately do not cover.
+# Working directories that are intentionally outside the shipped client
.local/
internal/
hivemind/
@@ -51,8 +21,9 @@ backup/
backups/
lib/logic/backup/
server_files/
+whatsthis
-# ── Flutter / Dart build output ────────────────────────────────────────────
+# Flutter / Dart generated output
.dart_tool/
*.dart_tool/
.flutter-plugins
@@ -66,20 +37,17 @@ local_packages/*/.dart_tool/
**/doc/api/
**/ios/Flutter/.last_build_id
-# ── Android build output ───────────────────────────────────────────────────
+# Android generated output / local SDK state
**/android/.gradle
**/android/.cxx/
**/android/app/build/
**/android/captures/
-**/android/gradlew
-**/android/gradlew.bat
**/android/local.properties
-**/android/**/gradle-wrapper.jar
**/android/**/GeneratedPluginRegistrant.java
**/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java
**/android/app/crashlytics-build.properties
-# ── Editors, tooling & OS ──────────────────────────────────────────────────
+# Editors / tooling / OS noise
.claude/
.codewhale/
.idea/
@@ -90,8 +58,6 @@ local_packages/*/.dart_tool/
.java-version
.DS_Store
*.swp
+*.iml
__pycache__/
devtools_options.yaml
-
-#why are you even looking at this file? it's a secret. go away.
-whatsthis
\ No newline at end of file
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/PRIVACY_POLICY.md b/PRIVACY_POLICY.md
index cb21628..d3e1f8e 100644
--- a/PRIVACY_POLICY.md
+++ b/PRIVACY_POLICY.md
@@ -1,6 +1,6 @@
# Privacy Policy
-**Last updated:** September 2026
+**Last updated:** August 2026
ReVoltVPN is a free VPN service built and operated by ReVolt Team.
@@ -27,7 +27,7 @@ Support ads (the "Support us" button) extend an active session by 30 minutes and
## Operational logs
-Server logs are written to the systemd journal (`journalctl -u hivemind`). These logs include truncated device IDs (first 8 characters only), session start/stop events, and error messages. Logs stay on the server and are accessible only to the server operator via local console; retention follows the server's systemd journal settings. We also maintain an aggregate total of all data ever transferred (in GB) for capacity planning — this counter is not tied to any individual device.
+We keep a short in-memory log buffer (last 200 lines) for debugging and server health monitoring. These logs include truncated device IDs (first 8 characters only), session start/stop events, and error messages. Logs never leave the server, are not retained after a server restart, and are only accessible to the server operator via local console. We also maintain an aggregate total of all data ever transferred (in GB) for capacity planning — this counter is not tied to any individual device.
## What we do not collect
diff --git a/README.md b/README.md
index 8f3ac7c..67b9e99 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@ No email. No password. No payment. The ad pays for the server.
## Protocol
- **Transport** — VLESS over XHTTP (HTTP/2 multiplexed, path-hidden behind `/revolt`)
-- **Obfuscation** — Xray Reality spoofs a real website's TLS. To any DPI box, your tunnel looks like a browser visiting that website
+- **Obfuscation** — Xray Reality spoofs `some website's` TLS. To any DPI box, your tunnel looks like a browser visiting that website
- **Encryption** — TLS 1.3 with borrowed certificate (Reality). No certbot, no domain ownership required
- **API** — Standard HTTPS to a separate domain. Tunnel destination is IP-pinned — domain compromise is DoS only
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/.gitignore b/android/.gitignore
index 0a741cb..26bd9ae 100644
--- a/android/.gitignore
+++ b/android/.gitignore
@@ -1,8 +1,5 @@
-gradle-wrapper.jar
/.gradle
/captures/
-/gradlew
-/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index b302bb4..cb24e65 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -1,132 +1,16 @@
import java.util.Properties
import java.io.File
import java.io.FileInputStream
+import java.security.MessageDigest
plugins {
id("com.android.application")
id("dev.flutter.flutter-gradle-plugin")
}
-val projectRootDir = rootProject.projectDir.parentFile
-val localPropertiesFile = rootProject.file("local.properties")
-val localProperties = Properties().apply {
- if (localPropertiesFile.exists()) {
- localPropertiesFile.inputStream().use { load(it) }
- }
-}
-val flutterSdkPath = localProperties.getProperty("flutter.sdk")
- ?: throw GradleException("flutter.sdk not set in android/local.properties")
-val dartExecutable = File(
- flutterSdkPath,
- if (System.getProperty("os.name").lowercase().contains("windows")) {
- "bin/cache/dart-sdk/bin/dart.exe"
- } else {
- "bin/cache/dart-sdk/bin/dart"
- },
-)
-val flutterVlessRecoveryPatchScript = File(
- projectRootDir,
- "tool/patch_flutter_vless_runtime_recovery.dart",
-)
-val flutterVlessSecureSocksPatchScript = File(
- projectRootDir,
- "tool/patch_flutter_vless_secure_socks_v2.dart",
-)
-val flutterVlessSecureSocksScopeFixScript = File(
- projectRootDir,
- "tool/patch_flutter_vless_secure_socks_scope_fix.dart",
-)
-
-val patchFlutterVlessRuntimeRecovery = tasks.register("patchFlutterVlessRuntimeRecovery") {
- group = "build setup"
- description = "Recover stale Xray core and tun2socks socket before restart"
- workingDir(projectRootDir)
-
- doFirst {
- if (!dartExecutable.isFile) {
- throw GradleException("Flutter Dart executable not found: ${dartExecutable.absolutePath}")
- }
- if (!flutterVlessRecoveryPatchScript.isFile) {
- throw GradleException("flutter_vless recovery patch script missing: ${flutterVlessRecoveryPatchScript.absolutePath}")
- }
- if (!File(projectRootDir, ".dart_tool/package_config.json").isFile) {
- throw GradleException("Flutter package metadata missing. Run `flutter pub get` before Android compilation.")
- }
- }
-
- commandLine(
- dartExecutable.absolutePath,
- "run",
- flutterVlessRecoveryPatchScript.absolutePath,
- )
-}
-
-val patchFlutterVlessSecureSocks = tasks.register("patchFlutterVlessSecureSocks") {
- group = "build setup"
- description = "Add authenticated ephemeral SOCKS5 and fail-closed process recovery to pinned flutter_vless_android 1.1.5"
- workingDir(projectRootDir)
- dependsOn(patchFlutterVlessRuntimeRecovery)
-
- doFirst {
- if (!dartExecutable.isFile) {
- throw GradleException("Flutter Dart executable not found: ${dartExecutable.absolutePath}")
- }
- if (!flutterVlessSecureSocksPatchScript.isFile) {
- throw GradleException("secure SOCKS patch script missing: ${flutterVlessSecureSocksPatchScript.absolutePath}")
- }
- if (!File(projectRootDir, ".dart_tool/package_config.json").isFile) {
- throw GradleException("Flutter package metadata missing. Run `flutter pub get` before Android compilation.")
- }
- }
-
- commandLine(
- dartExecutable.absolutePath,
- "run",
- flutterVlessSecureSocksPatchScript.absolutePath,
- )
-}
-
-val patchFlutterVlessSecureSocksScopeFix = tasks.register("patchFlutterVlessSecureSocksScopeFix") {
- group = "build setup"
- description = "Repair the secure Xray process monitor capture without changing stock 1.1.5 runtime sequencing"
- workingDir(projectRootDir)
- dependsOn(patchFlutterVlessSecureSocks)
-
- doFirst {
- if (!dartExecutable.isFile) {
- throw GradleException("Flutter Dart executable not found: ${dartExecutable.absolutePath}")
- }
- if (!flutterVlessSecureSocksScopeFixScript.isFile) {
- throw GradleException("secure SOCKS scope fix script missing: ${flutterVlessSecureSocksScopeFixScript.absolutePath}")
- }
- if (!File(projectRootDir, ".dart_tool/package_config.json").isFile) {
- throw GradleException("Flutter package metadata missing. Run `flutter pub get` before Android compilation.")
- }
- }
-
- commandLine(
- dartExecutable.absolutePath,
- "run",
- flutterVlessSecureSocksScopeFixScript.absolutePath,
- )
-}
-
-// App compilation and the transitive Android plugin compilation must both wait
-// for the pinned runtime patches. Plain `flutter build apk` must behave like CI
-// instead of relying on a hidden manual pre-build step.
-tasks.configureEach {
- if (name == "preBuild") {
- dependsOn(patchFlutterVlessSecureSocksScopeFix)
- }
-}
-
-gradle.projectsEvaluated {
- rootProject.findProject(":flutter_vless_android")
- ?.tasks
- ?.matching { it.name == "preBuild" }
- ?.configureEach {
- dependsOn(patchFlutterVlessSecureSocksScopeFix)
- }
+fun sha256Hex(file: File): String {
+ val digest = MessageDigest.getInstance("SHA-256").digest(file.readBytes())
+ return digest.joinToString("") { "%02x".format(it.toInt() and 0xff) }
}
android {
@@ -140,10 +24,6 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
- packaging {
- resources.excludes.add("META-INF/**")
- }
-
val keystorePropertiesFile = rootProject.file("key.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
@@ -156,6 +36,68 @@ android {
admobProperties.load(FileInputStream(admobPropertiesFile))
}
+ // Explicit opt-in used only by GitHub Actions to exercise the real
+ // release/R8 graph. Production release signing and config pinning remain
+ // fail-closed outside that CI smoke path.
+ val ciReleaseSmoke =
+ System.getenv("GITHUB_ACTIONS") == "true" &&
+ System.getenv("REVOLT_CI_RELEASE_SMOKE") == "true"
+
+ val appConfigFile = rootProject.file("../lib/logic/app_config.dart")
+ val verifyReleaseAppConfig = tasks.register("verifyReleaseAppConfig") {
+ group = "verification"
+ description = "Verify the production Dart app config against REVOLT_APP_CONFIG_SHA256."
+ doLast {
+ if (ciReleaseSmoke) {
+ logger.lifecycle("Skipping production app-config pin for CI release smoke build")
+ return@doLast
+ }
+
+ val expected = System.getenv("REVOLT_APP_CONFIG_SHA256")?.trim()?.lowercase()
+ if (expected.isNullOrEmpty()) {
+ throw org.gradle.api.GradleException(
+ "REVOLT_APP_CONFIG_SHA256 is required for production release builds"
+ )
+ }
+ if (!Regex("[0-9a-f]{64}").matches(expected)) {
+ throw org.gradle.api.GradleException(
+ "REVOLT_APP_CONFIG_SHA256 must be exactly 64 lowercase hexadecimal characters"
+ )
+ }
+ if (!appConfigFile.isFile) {
+ throw org.gradle.api.GradleException(
+ "Production app config is missing: ${appConfigFile.absolutePath}"
+ )
+ }
+
+ val configText = appConfigFile.readText()
+ val templateMarkers = listOf(
+ "'0.0.0.0'",
+ "YOUR_DOMAIN",
+ "YOUR_GITHUB_USERNAME",
+ "YOUR_REPO_NAME",
+ "ca-app-pub-0000000000000000/0000000000",
+ )
+ val remainingMarker = templateMarkers.firstOrNull(configText::contains)
+ if (remainingMarker != null) {
+ throw org.gradle.api.GradleException(
+ "Production app config still contains template marker: $remainingMarker"
+ )
+ }
+
+ val actual = sha256Hex(appConfigFile)
+ if (actual != expected) {
+ throw org.gradle.api.GradleException(
+ "Production app config SHA-256 mismatch: expected $expected, got $actual"
+ )
+ }
+ }
+ }
+
+ tasks.matching { it.name == "preReleaseBuild" }.configureEach {
+ dependsOn(verifyReleaseAppConfig)
+ }
+
defaultConfig {
applicationId = "com.paladinvpn.app"
minSdk = flutter.minSdkVersion
@@ -176,7 +118,11 @@ android {
buildTypes {
release {
- signingConfig = signingConfigs.getByName("release")
+ signingConfig = if (ciReleaseSmoke) {
+ signingConfigs.getByName("debug")
+ } else {
+ signingConfigs.getByName("release")
+ }
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
index 0d60756..6ab7ded 100644
--- a/android/app/proguard-rules.pro
+++ b/android/app/proguard-rules.pro
@@ -1,4 +1,4 @@
-# PaladinVPN ProGuard / R8 Rules
+# ReVoltVPN ProGuard / R8 Rules
# ==============================================================================
# These rules prevent R8 from stripping classes and methods that are accessed
# via JNI (Java Native Interface) or reflection, which would cause
@@ -11,9 +11,8 @@
-keep class xray.** { *; }
# ── Flutter / Platform Channels ───────────────────────────────────────────────
+# io.flutter.** already includes embedding and plugin subpackages.
-keep class io.flutter.** { *; }
--keep class io.flutter.embedding.** { *; }
--keep class io.flutter.plugin.** { *; }
# ── Google Mobile Ads (AdMob) ─────────────────────────────────────────────────
-keep class com.google.android.gms.ads.** { *; }
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 1476797..0b45736 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -10,9 +10,6 @@
-
-
when (call.method) {
- // Doze and App Standby stop background services of apps that
- // are not exempt. A VPN has to be exempt to survive the screen
- // going off, so surface the real state rather than guessing.
"isIgnoringBatteryOptimizations" ->
result.success(isIgnoringBatteryOptimizations())
"requestIgnoreBatteryOptimizations" ->
result.success(requestIgnoreBatteryOptimizations())
- "setSessionExpiry" -> {
- val expiresAtMs = call.argument("expiresAtMs")?.toLong() ?: 0L
- if (expiresAtMs > 0) {
- sendSessionExpiryToDaemon(expiresAtMs)
- }
- result.success(null)
- }
- // Is our VPN runtime actually alive? The service lives in
- // :RunSoLibXrayDaemon, a separate process, so its statics are
- // NOT visible here -- this is the only honest check.
- "isVpnRuntimeAlive" -> result.success(isVpnRuntimeAlive())
else -> result.notImplemented()
}
}
@@ -118,7 +101,6 @@ class MainActivity : FlutterActivity() {
super.onDestroy()
}
- /** True when the OS has exempted us from Doze / App Standby. */
private fun isIgnoringBatteryOptimizations(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
return try {
@@ -129,11 +111,6 @@ class MainActivity : FlutterActivity() {
}
}
- /**
- * Opens the system exemption prompt. Android shows this as a single dialog;
- * it cannot be granted silently, so the caller must treat a false return as
- * "ask again later" rather than a denial.
- */
private fun requestIgnoreBatteryOptimizations(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
if (isIgnoringBatteryOptimizations()) return true
@@ -146,7 +123,6 @@ class MainActivity : FlutterActivity() {
)
true
} catch (_: Exception) {
- // Some OEM builds hide the direct intent; fall back to the list.
try {
startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
true
@@ -156,58 +132,6 @@ class MainActivity : FlutterActivity() {
}
}
- /** Pushes the session-expiry deadline to the VPN service process. */
- private fun sendSessionExpiryToDaemon(expiresAtMs: Long) {
- try {
- val intent = Intent(this, XrayVPNService::class.java)
- intent.putExtra("SESSION_EXPIRES_AT_MS", expiresAtMs)
- startService(intent)
- } catch (_: Exception) {
- // Daemon not reachable; its persisted expiry still applies.
- }
- }
-
- /**
- * Whether the VPN runtime is genuinely alive.
- *
- * The Xray service runs in :RunSoLibXrayDaemon, a separate process, so its
- * statics (AppConfigs.V2RAY_CONFIG, V2RAY_STATE) are NOT visible from here --
- * reading them yields a fresh, empty copy. getRunningAppProcesses still
- * returns our *own* processes, which makes it the one reliable signal.
- */
- private fun isVpnRuntimeAlive(): Map {
- var processAlive = false
- try {
- val am = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
- processAlive = am.runningAppProcesses
- ?.any { it.processName.endsWith(VPN_PROCESS_SUFFIX) } == true
- } catch (_: Exception) {
- }
-
- var vpnTransport = false
- try {
- val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
- vpnTransport = cm.allNetworks.any { network ->
- cm.getNetworkCapabilities(network)
- ?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true
- }
- } catch (_: Exception) {
- }
-
- return mapOf(
- "processAlive" to processAlive,
- "vpnTransport" to vpnTransport,
- )
- }
-
- /** Removes a notification left behind by a runtime that is no longer alive. */
- private fun cancelVpnNotification() {
- try {
- NotificationManagerCompat.from(this).cancel(NOTIFICATION_ID)
- } catch (_: Exception) {
- }
- }
-
private fun updateVpnNotification(title: String, text: String, actionLabel: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) !=
@@ -216,15 +140,6 @@ class MainActivity : FlutterActivity() {
return
}
- // The foreground notification (id 1) is owned by XrayVPNService via
- // startForeground. Posting to it from this process while the service is
- // gone leaves a frozen countdown nothing will ever update again.
- val alive = isVpnRuntimeAlive()["processAlive"] == true
- if (!alive) {
- cancelVpnNotification()
- return
- }
-
ensureNotificationChannel()
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
@@ -234,17 +149,28 @@ class MainActivity : FlutterActivity() {
}
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
- launchIntent?.action = "FROM_DISCONNECT_BTN"
launchIntent?.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or
Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_NEW_TASK
- val stopIntent = Intent(this, XrayVPNService::class.java)
- stopIntent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE)
- val stopPendingIntent = PendingIntent.getService(this, 0, stopIntent, flags)
+ // The VPN service owns the authoritative Disconnect PendingIntent and
+ // embeds the current runtime-generation token in it. Never recreate an
+ // unscoped STOP intent from the UI process: a stale notification could
+ // otherwise stop a newer tunnel. Reuse the live service action when the
+ // platform exposes it; on older Android versions omit the action rather
+ // than weaken generation scoping.
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val serviceStopAction = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ manager.activeNotifications
+ .firstOrNull { it.id == NOTIFICATION_ID }
+ ?.notification
+ ?.actions
+ ?.firstOrNull()
+ ?.actionIntent
+ } else {
+ null
+ }
- // Public lock-screen preview: keep the VPN status visible but hide the
- // session countdown/speed so usage metadata never reaches the lock screen.
val publicVersion = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.notification_status_icon)
.setContentTitle("Revolt VPN")
@@ -255,7 +181,6 @@ class MainActivity : FlutterActivity() {
.setSmallIcon(R.drawable.notification_status_icon)
.setContentTitle(title)
.setContentText(text)
- .addAction(0, actionLabel, stopPendingIntent)
.setColorized(true)
.setColor(0xFF0D1117.toInt())
.setOngoing(true)
@@ -267,6 +192,10 @@ class MainActivity : FlutterActivity() {
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setPublicVersion(publicVersion)
+ if (serviceStopAction != null) {
+ builder.addAction(0, actionLabel, serviceStopAction)
+ }
+
if (launchIntent != null) {
builder.setContentIntent(PendingIntent.getActivity(this, 0, launchIntent, flags))
}
@@ -473,7 +402,6 @@ class MainActivity : FlutterActivity() {
companion object {
private const val NOTIFICATION_ID = 1
- private const val VPN_PROCESS_SUFFIX = ":RunSoLibXrayDaemon"
- private const val NOTIFICATION_CHANNEL_ID = "XRAY_SERVICE_CHANNEL"
+ private const val NOTIFICATION_CHANNEL_ID = "REVOLT_VPN_SERVICE"
}
}
diff --git a/android/app/src/main/res/drawable/notification_status_icon.xml b/android/app/src/main/res/drawable/notification_status_icon.xml
index 183ed91..f768282 100644
--- a/android/app/src/main/res/drawable/notification_status_icon.xml
+++ b/android/app/src/main/res/drawable/notification_status_icon.xml
@@ -1,9 +1,4 @@
-
-
-
+
diff --git a/android/gradle/verification-metadata.xml b/android/gradle/verification-metadata.xml
new file mode 100644
index 0000000..590f03d
--- /dev/null
+++ b/android/gradle/verification-metadata.xml
@@ -0,0 +1,8845 @@
+
+
+
+ true
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..1b33c55
Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/gradlew b/android/gradlew
new file mode 100755
index 0000000..0f14772
--- /dev/null
+++ b/android/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/android/gradlew.bat b/android/gradlew.bat
new file mode 100644
index 0000000..8de1053
--- /dev/null
+++ b/android/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/lib/components/connect_button.dart b/lib/components/connect_button.dart
index 2e739ee..60227d2 100644
--- a/lib/components/connect_button.dart
+++ b/lib/components/connect_button.dart
@@ -19,6 +19,7 @@ class _ConnectButtonState extends State
DateTime _lastTap = DateTime.fromMillisecondsSinceEpoch(0);
AnimationController? _pulse;
Animation? _pulseAnim;
+ VpnConnection? _vpn;
double get _pulseValue => _pulseAnim?.value ?? 0.0;
@@ -35,20 +36,25 @@ class _ConnectButtonState extends State
);
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
- if (mounted) context.read().addListener(_onVpnChanged);
+ if (!mounted || _vpn != null) return;
+ final vpn = context.read();
+ _vpn = vpn;
+ vpn.addListener(_onVpnChanged);
+ _onVpnChanged();
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
- context.read().removeListener(_onVpnChanged);
+ _vpn?.removeListener(_onVpnChanged);
_pulse?.dispose();
super.dispose();
}
void _onVpnChanged() {
- final vpn = context.read();
+ final vpn = _vpn;
+ if (vpn == null) return;
if (vpn.status == VpnStatus.connected) {
_pulse?.repeat(reverse: true);
} else {
@@ -60,14 +66,12 @@ class _ConnectButtonState extends State
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (!mounted) return;
- if (state == AppLifecycleState.paused ||
- state == AppLifecycleState.detached) {
+ if (state != AppLifecycleState.resumed) {
_pulse?.stop();
- } else if (state == AppLifecycleState.resumed) {
- final vpn = context.read();
- if (vpn.status == VpnStatus.connected) {
- _pulse?.repeat(reverse: true);
- }
+ return;
+ }
+ if (_vpn?.status == VpnStatus.connected) {
+ _pulse?.repeat(reverse: true);
}
}
diff --git a/lib/components/data_disclosure_dialog.dart b/lib/components/data_disclosure_dialog.dart
index 85d8132..95a59b5 100644
--- a/lib/components/data_disclosure_dialog.dart
+++ b/lib/components/data_disclosure_dialog.dart
@@ -19,23 +19,28 @@ class DataDisclosureDialog {
if (!context.mounted) return;
- await showDialog(
+ final accepted = await showDialog(
context: context,
barrierDismissible: false, // Must tap Continue — no tap-outside dismiss.
- builder: (_) => _DisclosureDialog(
- onContinue: () {
- prefs.setBool(_key, true);
- Navigator.of(context).pop(); // Dismiss the dialog.
- },
- ),
+ builder: (_) => const _DisclosureDialog(),
);
+ if (accepted != true) return;
+
+ // Persist only after the user action is complete. If storage fails, do not
+ // invent an accepted state; the disclosure will simply reappear later.
+ try {
+ final saved = await prefs.setBool(_key, true);
+ if (!saved) {
+ debugPrint('[Disclosure] Preference write was rejected');
+ }
+ } catch (e) {
+ debugPrint('[Disclosure] Failed to persist acknowledgement: $e');
+ }
}
}
class _DisclosureDialog extends StatelessWidget {
- final VoidCallback onContinue;
-
- const _DisclosureDialog({required this.onContinue});
+ const _DisclosureDialog();
@override
Widget build(BuildContext context) {
@@ -49,7 +54,6 @@ class _DisclosureDialog extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- // Title
const Text(
'Before you connect',
style: TextStyle(
@@ -59,8 +63,6 @@ class _DisclosureDialog extends StatelessWidget {
),
),
const SizedBox(height: 16),
-
- // Body
const Text(
'Revolt VPN creates a secure VPN tunnel to protect your '
'traffic. To enforce your data/time quota, we log your '
@@ -76,12 +78,10 @@ class _DisclosureDialog extends StatelessWidget {
),
),
const SizedBox(height: 24),
-
- // Continue button — full-width, accent
SizedBox(
width: double.infinity,
child: ElevatedButton(
- onPressed: onContinue,
+ onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.accent,
foregroundColor: AppColors.bgDeep,
diff --git a/lib/components/lightning.dart b/lib/components/lightning.dart
index 03a032d..0ad6e3f 100644
--- a/lib/components/lightning.dart
+++ b/lib/components/lightning.dart
@@ -5,13 +5,9 @@ import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/screens/settings/in_settings/lightning.dart';
/// 2D lightning bolt — a jagged yellow line from top to bottom with glow.
-
class LightningEffect extends StatefulWidget {
-
final int minIntervalSec;
-
final int maxIntervalSec;
-
final double doubleChance;
const LightningEffect({
@@ -26,26 +22,49 @@ class LightningEffect extends StatefulWidget {
}
class _LightningEffectState extends State
- with SingleTickerProviderStateMixin {
+ with SingleTickerProviderStateMixin, WidgetsBindingObserver {
late final AnimationController _ctrl;
Timer? _timer;
final _random = Random();
+ bool _appActive = true;
LightningBolt? _bolt;
@override
void initState() {
super.initState();
- _ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 400));
+ _ctrl = AnimationController(
+ vsync: this,
+ duration: const Duration(milliseconds: 400),
+ );
+ WidgetsBinding.instance.addObserver(this);
+ final lifecycle = WidgetsBinding.instance.lifecycleState;
+ _appActive = lifecycle == null || lifecycle == AppLifecycleState.resumed;
lightningEnabled.addListener(_onEnabledChanged);
+ if (_appActive && lightningEnabled.value) _scheduleNext();
+ }
+
+ @override
+ void didChangeAppLifecycleState(AppLifecycleState state) {
+ _appActive = state == AppLifecycleState.resumed;
+ if (!_appActive) {
+ _timer?.cancel();
+ _timer = null;
+ _ctrl.stop();
+ _bolt = null;
+ if (mounted) setState(() {});
+ return;
+ }
+
if (lightningEnabled.value) _scheduleNext();
}
void _onEnabledChanged() {
- if (lightningEnabled.value) {
+ if (lightningEnabled.value && _appActive) {
_scheduleNext();
} else {
_timer?.cancel();
+ _timer = null;
_ctrl.stop();
_bolt = null;
}
@@ -54,6 +73,7 @@ class _LightningEffectState extends State
@override
void dispose() {
+ WidgetsBinding.instance.removeObserver(this);
lightningEnabled.removeListener(_onEnabledChanged);
_timer?.cancel();
_ctrl.dispose();
@@ -61,19 +81,23 @@ class _LightningEffectState extends State
}
void _scheduleNext() {
+ _timer?.cancel();
+ if (!mounted || !_appActive || !lightningEnabled.value) return;
+
final secs = widget.minIntervalSec +
_random.nextInt(widget.maxIntervalSec - widget.minIntervalSec + 1);
_timer = Timer(Duration(seconds: secs), _strike);
}
void _strike() {
- if (!mounted || !lightningEnabled.value) return;
+ _timer = null;
+ if (!mounted || !_appActive || !lightningEnabled.value) return;
_bolt = LightningBolt.random(_random);
_ctrl.forward(from: 0.0);
if (_random.nextDouble() < widget.doubleChance) {
Future.delayed(const Duration(milliseconds: 120), () {
- if (mounted && lightningEnabled.value) {
+ if (mounted && _appActive && lightningEnabled.value) {
_bolt = LightningBolt.random(_random);
_ctrl.forward(from: 0.0);
}
@@ -83,10 +107,10 @@ class _LightningEffectState extends State
_scheduleNext();
}
- /// Sharp flash curve: quick on, quick off. No slow fade.
+ /// Sharp flash curve: quick on, quick off. No slow fade.
double _opacity(double t) {
- if (t < 0.08) return t / 0.08; // fast ramp
- return 1.0 - ((t - 0.08) / 0.92); // linear decay
+ if (t < 0.08) return t / 0.08;
+ return 1.0 - ((t - 0.08) / 0.92);
}
@override
@@ -117,37 +141,31 @@ class _LightningEffectState extends State
}
}
-
/// A single bolt defined by a list of 2D points (0.0–1.0 in width×height space).
class LightningBolt {
final List mainPath;
- final List> branches; // 0–2 forks off the main path
+ final List> branches;
const LightningBolt({required this.mainPath, this.branches = const []});
- /// Generate a random bolt. The path always starts near the top-center
+ /// Generate a random bolt. The path always starts near the top-center
/// and ends near the bottom, zigzagging left/right along the way.
factory LightningBolt.random(Random rng) {
final points = [];
- // Start: top, slight horizontal offset
double x = 0.5 + (rng.nextDouble() - 0.5) * 0.15;
points.add(Offset(x, 0.0));
- // Zigzag down — 6–10 segments
final segments = 6 + rng.nextInt(5);
for (int i = 1; i < segments; i++) {
final y = i / segments;
- // Random horizontal offset — each segment adds jitter
x += (rng.nextDouble() - 0.5) * 0.25;
- x = x.clamp(0.05, 0.95); // stay on screen
+ x = x.clamp(0.05, 0.95);
points.add(Offset(x, y));
}
- // End: bottom
points.add(Offset(x.clamp(0.1, 0.9), 1.0));
- // Branches — 0–2 forks from random points along the main path
final branches = >[];
final branchCount = rng.nextInt(3);
for (int b = 0; b < branchCount; b++) {
@@ -160,7 +178,7 @@ class LightningBolt {
double by = start.dy;
final branchLen = 2 + rng.nextInt(3);
for (int j = 0; j < branchLen; j++) {
- by += (0.05 + rng.nextDouble() * 0.12);
+ by += 0.05 + rng.nextDouble() * 0.12;
bx += (rng.nextDouble() - 0.5) * 0.3;
bx = bx.clamp(0.02, 0.98);
by = by.clamp(0.0, 1.0);
@@ -173,7 +191,6 @@ class LightningBolt {
}
}
-
class _LightningPainter extends CustomPainter {
final LightningBolt bolt;
final double opacity;
@@ -182,7 +199,8 @@ class _LightningPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
- final toScreen = (Offset p) => Offset(p.dx * size.width, p.dy * size.height);
+ Offset toScreen(Offset p) => Offset(p.dx * size.width, p.dy * size.height);
+
final scaledMain = bolt.mainPath.map(toScreen).toList();
final scaledBranches = bolt.branches
.map((b) => b.map(toScreen).toList())
@@ -191,7 +209,6 @@ class _LightningPainter extends CustomPainter {
final baseColor = AppColors.accent.withAlpha((opacity * 255).round());
final glowColor = AppColors.accent.withAlpha((opacity * 0.35 * 255).round());
- // Glow
final glowPaint = Paint()
..color = glowColor
..strokeWidth = 18
@@ -202,7 +219,6 @@ class _LightningPainter extends CustomPainter {
_drawPath(canvas, scaledMain, scaledBranches, glowPaint);
- // Core
final corePaint = Paint()
..color = baseColor
..strokeWidth = 5
@@ -212,7 +228,6 @@ class _LightningPainter extends CustomPainter {
_drawPath(canvas, scaledMain, scaledBranches, corePaint);
- // Hot center
if (opacity > 0.6) {
final hotPaint = Paint()
..color = Colors.white.withAlpha((opacity * 180).round())
@@ -225,8 +240,12 @@ class _LightningPainter extends CustomPainter {
}
}
- void _drawPath(Canvas canvas, List main,
- List> branches, Paint paint) {
+ void _drawPath(
+ Canvas canvas,
+ List main,
+ List> branches,
+ Paint paint,
+ ) {
final mainPath = Path()..moveTo(main.first.dx, main.first.dy);
for (int i = 1; i < main.length; i++) {
mainPath.lineTo(main[i].dx, main[i].dy);
diff --git a/lib/components/sidebar_contents/discord_link_button.dart b/lib/components/sidebar_contents/discord_link_button.dart
index 012322d..9f1d183 100644
--- a/lib/components/sidebar_contents/discord_link_button.dart
+++ b/lib/components/sidebar_contents/discord_link_button.dart
@@ -14,8 +14,8 @@ class DiscordLinkButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
- return IconButton(
- icon: const Icon(Icons.discord, color: AppColors.textWhite, size: 28),
+ return const IconButton(
+ icon: Icon(Icons.discord, color: AppColors.textWhite, size: 28),
onPressed: launchDiscord,
tooltip: 'Join Discord',
splashRadius: 24,
diff --git a/lib/components/sidebar_contents/gdpr_button.dart b/lib/components/sidebar_contents/gdpr_button.dart
index 696ac13..00f95d0 100644
--- a/lib/components/sidebar_contents/gdpr_button.dart
+++ b/lib/components/sidebar_contents/gdpr_button.dart
@@ -76,8 +76,8 @@ class GdprButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
- return IconButton(
- icon: const Icon(Icons.admin_panel_settings_outlined,
+ return const IconButton(
+ icon: Icon(Icons.admin_panel_settings_outlined,
color: AppColors.textWhite, size: 28),
onPressed: showConsentForm,
tooltip: 'Ad consent & privacy choices',
diff --git a/lib/components/sidebar_contents/info_button.dart b/lib/components/sidebar_contents/info_button.dart
index 9a2f575..98559ad 100644
--- a/lib/components/sidebar_contents/info_button.dart
+++ b/lib/components/sidebar_contents/info_button.dart
@@ -14,8 +14,8 @@ class InfoButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
- return IconButton(
- icon: const Icon(Icons.help_outline, color: AppColors.textWhite, size: 28),
+ return const IconButton(
+ icon: Icon(Icons.help_outline, color: AppColors.textWhite, size: 28),
onPressed: launchGitHub,
tooltip: 'About ReVoltVPN',
splashRadius: 24,
diff --git a/lib/components/sidebar_contents/privacy_policy_button.dart b/lib/components/sidebar_contents/privacy_policy_button.dart
index 09b6bd1..38ad136 100644
--- a/lib/components/sidebar_contents/privacy_policy_button.dart
+++ b/lib/components/sidebar_contents/privacy_policy_button.dart
@@ -6,7 +6,9 @@ class PrivacyPolicyButton extends StatelessWidget {
const PrivacyPolicyButton({super.key});
static Future launchPrivacyPolicy() async {
- final Uri url = Uri.parse('https://github.com/esefxdz/ReVoltVPN/blob/main/PRIVACY_POLICY.md');
+ final Uri url = Uri.parse(
+ 'https://github.com/esefxdz/ReVoltVPN/blob/main/PRIVACY_POLICY.md',
+ );
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
debugPrint('Could not launch $url');
}
@@ -14,8 +16,12 @@ class PrivacyPolicyButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
- return IconButton(
- icon: const Icon(Icons.privacy_tip_outlined, color: AppColors.textWhite, size: 28),
+ return const IconButton(
+ icon: Icon(
+ Icons.privacy_tip_outlined,
+ color: AppColors.textWhite,
+ size: 28,
+ ),
onPressed: launchPrivacyPolicy,
tooltip: 'Privacy Policy',
splashRadius: 24,
diff --git a/lib/components/sidebar_contents/website_button.dart b/lib/components/sidebar_contents/website_button.dart
index ef6496a..c5d7b0a 100644
--- a/lib/components/sidebar_contents/website_button.dart
+++ b/lib/components/sidebar_contents/website_button.dart
@@ -14,8 +14,8 @@ class WebsiteButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
- return IconButton(
- icon: const Icon(Icons.language, color: AppColors.textWhite, size: 28),
+ return const IconButton(
+ icon: Icon(Icons.language, color: AppColors.textWhite, size: 28),
onPressed: launchWebsite,
tooltip: 'Website',
splashRadius: 24,
diff --git a/lib/components/status_bar.dart b/lib/components/status_bar.dart
index 9e640b4..3eb1434 100644
--- a/lib/components/status_bar.dart
+++ b/lib/components/status_bar.dart
@@ -1,91 +1,58 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
+import 'package:revoltvpn/components/online_dot.dart';
import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/logic/vpn_connection.dart';
-import 'package:revoltvpn/logic/session_timer.dart';
-import 'package:revoltvpn/logic/server_list.dart';
-import 'package:revoltvpn/components/online_dot.dart';
-import 'package:revoltvpn/screens/server_selector/selection_screen.dart';
class StatusBar extends StatelessWidget {
const StatusBar({super.key});
@override
Widget build(BuildContext context) {
- return Consumer2(
- builder: (context, vpn, timer, _) {
+ return Consumer(
+ builder: (context, vpn, _) {
final isConnected = vpn.status == VpnStatus.connected;
- final isConnecting = vpn.status == VpnStatus.connecting;
- final serverHealthy =
- vpn.serverReachable || (isConnected && timer.hasSyncedOnce);
- final sessionOrTunnelHealthy = timer.hasSyncedOnce || vpn.serverReachable;
-
- final statusLabel = isConnected
- ? (sessionOrTunnelHealthy ? 'Online' : 'Syncing…')
- : (isConnecting
- ? 'Connecting…'
- : (vpn.serverReachable ? 'Ready' : 'Offline'));
-
- final server = context.watch().selected;
+ final statusLabel = switch (vpn.status) {
+ VpnStatus.connected => 'Connected',
+ VpnStatus.connecting => 'Connecting…',
+ VpnStatus.disconnecting => 'Disconnecting…',
+ VpnStatus.error => 'Error',
+ VpnStatus.disconnected => vpn.serverReachable ? 'Ready' : 'Offline',
+ };
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
- GestureDetector(
- onTap: () {
- Navigator.of(context).push(
- PageRouteBuilder(
- transitionDuration: const Duration(milliseconds: 300),
- pageBuilder: (_, __, ___) => const SelectionScreen(),
- transitionsBuilder: (_, animation, __, child) {
- return SlideTransition(
- position: Tween(
- begin: const Offset(1.0, 0.0),
- end: Offset.zero,
- ).animate(
- CurvedAnimation(
- parent: animation,
- curve: Curves.easeOut,
- ),
- ),
- child: child,
- );
- },
+ Container(
+ padding:
+ const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
+ decoration: BoxDecoration(
+ color: AppColors.bgCard,
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(color: AppColors.slate15),
+ ),
+ child: Row(
+ children: [
+ Image.asset(
+ 'assets/finland_flag_256.png',
+ width: 20,
+ height: 20,
),
- );
- },
- child: Container(
- padding:
- const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
- decoration: BoxDecoration(
- color: AppColors.bgCard,
- borderRadius: BorderRadius.circular(16),
- border: Border.all(color: AppColors.slate15),
- ),
- child: Row(
- children: [
- Image.asset(server.flagAsset, width: 20, height: 20),
- const SizedBox(width: 8),
- Expanded(
- child: Text(
- server.name,
- style: const TextStyle(
- color: AppColors.accent,
- fontWeight: FontWeight.w600,
- fontSize: 13,
- letterSpacing: 0.5,
- ),
+ const SizedBox(width: 8),
+ const Expanded(
+ child: Text(
+ 'Helsinki, Finland',
+ style: TextStyle(
+ color: AppColors.accent,
+ fontWeight: FontWeight.w600,
+ fontSize: 13,
+ letterSpacing: 0.5,
),
),
- const Icon(
- Icons.chevron_right,
- color: AppColors.textDim,
- size: 20,
- ),
- ],
- ),
+ ),
+ ],
),
),
const SizedBox(height: 8),
@@ -100,7 +67,7 @@ class StatusBar extends StatelessWidget {
child: Row(
children: [
OnlineDot(
- isOnline: serverHealthy,
+ isOnline: isConnected,
label: statusLabel,
),
const Spacer(),
diff --git a/lib/logic/connection_settings.dart b/lib/logic/connection_settings.dart
index 6189360..e7a6e36 100644
--- a/lib/logic/connection_settings.dart
+++ b/lib/logic/connection_settings.dart
@@ -18,23 +18,32 @@ abstract final class ConnectionSettings {
final prefs = await SharedPreferences.getInstance();
final storedMode = prefs.getString(_modeKey);
- // Any unrecognized value (including the pre-V3 'auto') is TUN. 'proxy' is
- // the only persisted value that selects the SOCKS5 mode.
+ // Only the explicit current proxy value restores SOCKS5. Unknown and
+ // obsolete values, including the former 'auto'/'ass' labels, fail closed
+ // to full-tunnel TUN instead of silently selecting a weaker route.
_mode = storedMode == ConnectionMode.proxy.name
? ConnectionMode.proxy
: ConnectionMode.tun;
if (storedMode != _mode.name) {
- await prefs.setString(_modeKey, _mode.name);
+ final migrated = await prefs.setString(_modeKey, _mode.name);
+ if (!migrated) {
+ throw StateError('Could not persist migrated connection mode.');
+ }
}
_initialized = true;
}
- static Future setMode(ConnectionMode mode) async {
+ static Future setMode(ConnectionMode mode) async {
+ if (_initialized && _mode == mode) return true;
+
+ final prefs = await SharedPreferences.getInstance();
+ final saved = await prefs.setString(_modeKey, mode.name);
+ if (!saved) return false;
+
_mode = mode;
_initialized = true;
- final prefs = await SharedPreferences.getInstance();
- await prefs.setString(_modeKey, mode.name);
+ return true;
}
}
diff --git a/lib/logic/hivemind_service.dart b/lib/logic/hivemind_service.dart
index e1f3d8a..a09a1fb 100644
--- a/lib/logic/hivemind_service.dart
+++ b/lib/logic/hivemind_service.dart
@@ -13,6 +13,7 @@ class HivemindService {
static const _ua = 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36';
+ static const int _maxControlResponseBytes = 256 * 1024;
static const _pollBackoff = [
Duration(seconds: 1),
Duration(seconds: 2),
@@ -27,7 +28,58 @@ class HivemindService {
return http.get(uri, headers: {'User-Agent': _ua}).timeout(timeout);
}
- static void cancel() => _currentCallId++;
+ static Future controlGet(
+ Uri uri, {
+ Duration timeout = const Duration(seconds: 5),
+ }) async {
+ final client = http.Client();
+ final request = http.Request('GET', uri)
+ ..headers['User-Agent'] = _ua
+ ..followRedirects = false;
+
+ Future read() async {
+ final streamed = await client.send(request);
+ final declaredLength = streamed.contentLength;
+ if (declaredLength != null && declaredLength > _maxControlResponseBytes) {
+ throw const FormatException('Control response is too large.');
+ }
+
+ final bytes = [];
+ await for (final chunk in streamed.stream) {
+ if (bytes.length + chunk.length > _maxControlResponseBytes) {
+ throw const FormatException('Control response is too large.');
+ }
+ bytes.addAll(chunk);
+ }
+
+ return http.Response.bytes(
+ bytes,
+ streamed.statusCode,
+ headers: streamed.headers,
+ isRedirect: streamed.isRedirect,
+ persistentConnection: streamed.persistentConnection,
+ reasonPhrase: streamed.reasonPhrase,
+ request: request,
+ );
+ }
+
+ try {
+ return await read().timeout(
+ timeout,
+ onTimeout: () {
+ client.close();
+ throw TimeoutException('Control request timed out.', timeout);
+ },
+ );
+ } finally {
+ client.close();
+ }
+ }
+
+ static void cancel() {
+ _currentCallId++;
+ _expectedNonce = null;
+ }
static void setExpectedNonce(String nonce) {
_expectedNonce = nonce;
@@ -37,13 +89,19 @@ class HivemindService {
void Function(int attempt, int total)? onAttempt,
bool skipAdBypass = false,
}) async {
- final deviceId = await CryptoService.getDeviceId();
+ // Capture the generation before the first await. Otherwise a disconnect
+ // that lands while secure storage is resolving the device ID can be lost
+ // and the cancelled call can resume network work with a fresh generation.
final callId = ++_currentCallId;
+ final deviceId = await CryptoService.getDeviceId();
+ _throwIfCancelled(callId);
+
final nonce = _newNonce();
_expectedNonce = nonce;
if (!skipAdBypass) {
await _runConfiguredBypass(deviceId, nonce);
+ _throwIfCancelled(callId);
}
const maxAttempts = 5;
@@ -53,6 +111,7 @@ class HivemindService {
try {
final session = await _fetchActiveSession(deviceId, nonce);
+ _throwIfCancelled(callId);
if (session != null) {
_expectedNonce = null;
return session.toVlessUrl();
@@ -73,7 +132,7 @@ class HivemindService {
static Future checkHealth() async {
try {
- final response = await directGet(
+ final response = await controlGet(
_publicUrl('/health'),
timeout: const Duration(seconds: 3),
);
@@ -109,15 +168,15 @@ class HivemindService {
final statusUri = _publicUrl('/session/status').replace(
queryParameters: {'device_id': deviceId},
);
- final response = await directGet(statusUri);
+ final response = await controlGet(statusUri);
if (response.statusCode != 200) return null;
final decoded = jsonDecode(response.body);
if (decoded is! Map) return null;
final serverNonce = decoded['nonce'];
- if (_expectedNonce != null &&
- serverNonce is String &&
+ if (_expectedNonce != nonce ||
+ serverNonce is! String ||
serverNonce != nonce) {
return null;
}
@@ -141,6 +200,13 @@ class HivemindService {
}
class _HivemindSessionConfig {
+ static final RegExp _uuidPattern = RegExp(
+ r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
+ caseSensitive: false,
+ );
+ static final RegExp _shortIdPattern =
+ RegExp(r'^[0-9a-f]{0,16}$', caseSensitive: false);
+
final String uuid;
final String host;
final int port;
@@ -162,50 +228,174 @@ class _HivemindSessionConfig {
});
factory _HivemindSessionConfig.fromJson(Map json) {
- final uuid = _requiredString(json, 'vless_uuid');
- final host = _stringOr(json['vless_ip'], AppConfig.serverIp);
- final port = _portOr(json['vless_port'], 443);
- final sni = _requiredString(json, 'reality_sni');
+ final uuid = _requiredString(json, 'vless_uuid', maxLength: 64);
+ if (!_uuidPattern.hasMatch(uuid)) {
+ throw const FormatException('Invalid VLESS UUID');
+ }
+
+ // The API may rotate credentials and Reality parameters, but it must never
+ // choose the tunnel destination. Keep that trust anchor compiled into the
+ // app so an API/domain compromise can cause denial of service, not redirect
+ // VPN traffic to an attacker-controlled server.
+ final pinnedHost = _validatedPublicIp(AppConfig.serverIp, 'serverIp');
+ final advertisedHost = json['vless_ip'];
+ if (advertisedHost != null) {
+ if (advertisedHost is! String || advertisedHost.trim() != pinnedHost) {
+ throw const FormatException(
+ 'Session VLESS host does not match compiled server pin',
+ );
+ }
+ }
+ final host = pinnedHost;
+
+ final advertisedPort = json['vless_port'];
+ if (advertisedPort != null &&
+ (advertisedPort is! num || advertisedPort.toInt() != 443)) {
+ throw const FormatException('Session VLESS port does not match compiled pin');
+ }
+ const port = 443;
+ final sni = _validatedHost(
+ _requiredString(json, 'reality_sni', maxLength: 253),
+ 'reality_sni',
+ );
+ final publicKey = _requiredString(json, 'reality_pbk', maxLength: 128);
+ final shortId = _stringOr(json['reality_sid'], '').trim();
+ if (!_shortIdPattern.hasMatch(shortId)) {
+ throw const FormatException('Invalid Reality short ID');
+ }
+
+ final fingerprint =
+ _boundedString(json['reality_fp'], AppConfig.realityFp, 32, 'reality_fp');
+ final path =
+ _boundedString(json['xhttp_path'], AppConfig.vlessPath, 2048, 'xhttp_path');
+ if (!path.startsWith('/')) {
+ throw const FormatException('Invalid XHTTP path');
+ }
return _HivemindSessionConfig(
uuid: uuid,
host: host,
port: port,
- publicKey: _stringOr(json['reality_pbk'], ''),
- shortId: _stringOr(json['reality_sid'], ''),
+ publicKey: publicKey,
+ shortId: shortId,
sni: sni,
- fingerprint: _stringOr(json['reality_fp'], AppConfig.realityFp),
- path: _stringOr(json['xhttp_path'], AppConfig.vlessPath),
+ fingerprint: fingerprint,
+ path: path,
);
}
String toVlessUrl() {
- return 'vless://$uuid@$host:$port'
- '?security=${AppConfig.vlessSecurity}'
- '&type=${AppConfig.vlessType}'
- '&path=$path'
- '&pbk=${Uri.encodeComponent(publicKey)}'
- '&sni=${Uri.encodeComponent(sni)}'
- '&sid=${Uri.encodeComponent(shortId)}'
- '&fp=${Uri.encodeComponent(fingerprint)}'
- '#Revolt VPN';
+ return Uri(
+ scheme: 'vless',
+ userInfo: uuid,
+ host: host,
+ port: port,
+ queryParameters: {
+ 'security': AppConfig.vlessSecurity,
+ 'type': AppConfig.vlessType,
+ 'path': path,
+ 'pbk': publicKey,
+ 'sni': sni,
+ 'sid': shortId,
+ 'fp': fingerprint,
+ },
+ fragment: 'Revolt VPN',
+ ).toString();
}
- static String _requiredString(Map json, String key) {
+ static String _requiredString(
+ Map json,
+ String key, {
+ int maxLength = 4096,
+ }) {
final value = json[key];
- if (value is String && value.trim().isNotEmpty) return value.trim();
- throw FormatException('Missing required session field: $key');
+ if (value is! String) {
+ throw FormatException('Missing required session field: $key');
+ }
+ final trimmed = value.trim();
+ if (trimmed.isEmpty || trimmed.length > maxLength) {
+ throw FormatException('Invalid session field: $key');
+ }
+ return trimmed;
}
static String _stringOr(Object? value, String fallback) {
- return value is String && value.isNotEmpty ? value : fallback;
+ return value is String && value.trim().isNotEmpty ? value.trim() : fallback;
}
- static int _portOr(Object? value, int fallback) {
- final parsed = value is num ? value.toInt() : fallback;
- if (parsed <= 0 || parsed > 65535) {
- throw const FormatException('Invalid VLESS port');
+ static String _boundedString(
+ Object? value,
+ String fallback,
+ int maxLength,
+ String field,
+ ) {
+ final result = _stringOr(value, fallback);
+ if (result.isEmpty || result.length > maxLength) {
+ throw FormatException('Invalid session field: $field');
}
- return parsed;
+ return result;
+ }
+
+ static String _validatedHost(String input, String field) {
+ final value = input.trim();
+ if (value.isEmpty ||
+ value.length > 253 ||
+ value.contains(RegExp(r'[\s/@?#]'))) {
+ throw FormatException('Invalid session host: $field');
+ }
+ try {
+ final probe = Uri(scheme: 'https', host: value);
+ if (probe.host.isEmpty) {
+ throw FormatException('Invalid session host: $field');
+ }
+ } on FormatException {
+ throw FormatException('Invalid session host: $field');
+ }
+ return value;
+ }
+
+ static String _validatedPublicIp(String input, String field) {
+ final value = input.trim().toLowerCase();
+ if (_isPublicIpv4(value) || _isPublicIpv6(value)) return value;
+ throw FormatException('Invalid public IP pin: $field');
+ }
+
+ static bool _isPublicIpv4(String value) {
+ final parts = value.split('.');
+ if (parts.length != 4) return false;
+ final octets = [];
+ for (final part in parts) {
+ if (part.isEmpty || (part.length > 1 && part.startsWith('0'))) return false;
+ final parsed = int.tryParse(part);
+ if (parsed == null || parsed < 0 || parsed > 255) return false;
+ octets.add(parsed);
+ }
+ final a = octets[0];
+ final b = octets[1];
+ if (a == 0 || a == 10 || a == 127 || a >= 224) return false;
+ if (a == 100 && b >= 64 && b <= 127) return false;
+ if (a == 169 && b == 254) return false;
+ if (a == 172 && b >= 16 && b <= 31) return false;
+ if (a == 192 && b == 168) return false;
+ if (a == 192 && b == 0 && octets[2] == 2) return false;
+ if (a == 198 && b == 51 && octets[2] == 100) return false;
+ if (a == 203 && b == 0 && octets[2] == 113) return false;
+ return true;
+ }
+
+ static bool _isPublicIpv6(String value) {
+ if (!value.contains(':') || value.contains('.')) return false;
+ try {
+ final parsed = Uri.parse('https://[$value]/');
+ if (parsed.host.isEmpty || !parsed.host.contains(':')) return false;
+ } on FormatException {
+ return false;
+ }
+ if (value == '::' || value == '::1') return false;
+ if (value.startsWith('fc') || value.startsWith('fd')) return false;
+ if (value.startsWith('fe8') || value.startsWith('fe9') || value.startsWith('fea') || value.startsWith('feb')) return false;
+ if (value.startsWith('ff')) return false;
+ if (value.startsWith('2001:db8:') || value == '2001:db8::') return false;
+ return true;
}
}
diff --git a/lib/logic/local_socks_tester.dart b/lib/logic/local_socks_tester.dart
index 6e405bc..b672e88 100644
--- a/lib/logic/local_socks_tester.dart
+++ b/lib/logic/local_socks_tester.dart
@@ -20,13 +20,13 @@ abstract final class LocalSocksTester {
/// Fast local readiness probe used while starting the VPN.
///
- /// This stops after SOCKS5 authentication. Startup must not depend on an
- /// unrelated external host being reachable.
+ /// This stops after authenticated SOCKS5 negotiation. Startup must not depend
+ /// on an unrelated external host being reachable.
static Future testListener({
String host = '127.0.0.1',
- int port = 10807,
- String? username,
- String? password,
+ required int port,
+ required String username,
+ required String password,
}) async {
final stopwatch = Stopwatch()..start();
Socket? socket;
@@ -86,26 +86,40 @@ abstract final class LocalSocksTester {
}
}
- /// Full user-facing test: authenticated local SOCKS handshake plus outbound
- /// CONNECT. Credentials are optional so the helper remains usable for legacy
- /// no-auth diagnostic builds.
+ /// Full user-facing test: authenticated local SOCKS handshake plus an
+ /// outbound TCP CONNECT through the active ReVolt route.
static Future test({
String host = '127.0.0.1',
- int port = 10807,
- String? username,
- String? password,
- String targetHost = 'paladinvpn.duckdns.org',
+ required int port,
+ required String username,
+ required String password,
+ String targetHost = 'example.com',
int targetPort = 443,
}) async {
+ const totalTimeout = Duration(seconds: 8);
final stopwatch = Stopwatch()..start();
Socket? socket;
_SocketReader? reader;
+ Duration remaining() {
+ final micros = totalTimeout.inMicroseconds - stopwatch.elapsedMicroseconds;
+ if (micros <= 0) throw TimeoutException('SOCKS5 test timed out.', totalTimeout);
+ return Duration(microseconds: micros);
+ }
+
try {
+ if (targetPort <= 0 || targetPort > 65535) {
+ return const LocalSocksTestResult(
+ ok: false,
+ latencyMs: null,
+ message: 'SOCKS5 test target is invalid.',
+ );
+ }
+
socket = await Socket.connect(
host,
port,
- timeout: const Duration(seconds: 3),
+ timeout: remaining(),
);
socket.setOption(SocketOption.tcpNoDelay, true);
reader = _SocketReader(socket);
@@ -115,6 +129,7 @@ abstract final class LocalSocksTester {
socket,
username: username,
password: password,
+ timeout: remaining(),
);
if (!authenticated) {
return const LocalSocksTestResult(
@@ -145,7 +160,7 @@ abstract final class LocalSocksTester {
]);
await socket.flush();
- final replyHead = await reader.readExactly(4);
+ final replyHead = await reader.readExactly(4, timeout: remaining());
if (replyHead[0] != 0x05 || replyHead[1] != 0x00) {
return const LocalSocksTestResult(
ok: false,
@@ -155,7 +170,7 @@ abstract final class LocalSocksTester {
);
}
- await _consumeAddress(reader, replyHead[3]);
+ await _consumeAddress(reader, replyHead[3], timeout: remaining());
stopwatch.stop();
return LocalSocksTestResult(
ok: true,
@@ -189,23 +204,10 @@ abstract final class LocalSocksTester {
static Future _authenticate(
_SocketReader reader,
Socket socket, {
- required String? username,
- required String? password,
+ required String username,
+ required String password,
Duration timeout = const Duration(seconds: 4),
}) async {
- final passwordAuth = username != null && password != null;
- if ((username == null) != (password == null)) return false;
-
- socket.add(passwordAuth
- ? const [0x05, 0x01, 0x02]
- : const [0x05, 0x01, 0x00]);
- await socket.flush();
-
- final greeting = await reader.readExactly(2, timeout: timeout);
- if (greeting[0] != 0x05) return false;
- if (!passwordAuth) return greeting[1] == 0x00;
- if (greeting[1] != 0x02) return false;
-
final userBytes = utf8.encode(username);
final passBytes = utf8.encode(password);
if (userBytes.isEmpty ||
@@ -215,6 +217,15 @@ abstract final class LocalSocksTester {
return false;
}
+ // ReVolt never accepts unauthenticated local SOCKS. Advertising only RFC
+ // 1929 username/password auth makes a future credential-plumbing regression
+ // fail closed instead of silently falling back to method 0x00.
+ socket.add(const [0x05, 0x01, 0x02]);
+ await socket.flush();
+
+ final greeting = await reader.readExactly(2, timeout: timeout);
+ if (greeting[0] != 0x05 || greeting[1] != 0x02) return false;
+
socket.add([
0x01,
userBytes.length,
@@ -229,18 +240,24 @@ abstract final class LocalSocksTester {
}
static Future _consumeAddress(
- _SocketReader reader, int addressType) async {
+ _SocketReader reader,
+ int addressType, {
+ required Duration timeout,
+ }) async {
if (addressType == 0x01) {
- await reader.readExactly(4 + 2);
+ await reader.readExactly(4 + 2, timeout: timeout);
return;
}
if (addressType == 0x04) {
- await reader.readExactly(16 + 2);
+ await reader.readExactly(16 + 2, timeout: timeout);
return;
}
if (addressType == 0x03) {
- final length = (await reader.readExactly(1)).first;
- await reader.readExactly(length + 2);
+ final length = (await reader.readExactly(1, timeout: timeout)).first;
+ if (length == 0) {
+ throw const FormatException('Invalid SOCKS5 domain length');
+ }
+ await reader.readExactly(length + 2, timeout: timeout);
return;
}
throw const FormatException('Unsupported SOCKS5 address type');
@@ -257,8 +274,15 @@ class _SocketReader {
int count, {
Duration timeout = const Duration(seconds: 4),
}) async {
+ final elapsed = Stopwatch()..start();
while (_buffer.length < count) {
- final hasNext = await _iterator.moveNext().timeout(timeout);
+ final remainingMicros = timeout.inMicroseconds - elapsed.elapsedMicroseconds;
+ if (remainingMicros <= 0) {
+ throw TimeoutException('Socket read timed out', timeout);
+ }
+ final hasNext = await _iterator.moveNext().timeout(
+ Duration(microseconds: remainingMicros),
+ );
if (!hasNext) {
throw const SocketException('Socket closed early');
}
diff --git a/lib/logic/notification_service.dart b/lib/logic/notification_service.dart
index d9e9f8c..88f46f1 100644
--- a/lib/logic/notification_service.dart
+++ b/lib/logic/notification_service.dart
@@ -16,16 +16,17 @@ class NotificationService {
/// Sets the notification's content text to [text].
///
/// The native side rebuilds the whole notification, so skip the round-trip
- /// when the text has not changed.
+ /// when the text has not changed. A failed native update is intentionally
+ /// not cached so the next tick can retry it.
static Future updateTimer(String text) async {
if (text == _lastText) return;
- _lastText = text;
try {
await _channel.invokeMethod('updateNotificationText', {
'title': 'Revolt VPN',
'text': text,
'disconnectLabel': 'Disconnect',
});
+ _lastText = text;
} catch (_) {
// Cosmetic.
}
diff --git a/lib/logic/power_settings.dart b/lib/logic/power_settings.dart
index cee5934..4bffc4a 100644
--- a/lib/logic/power_settings.dart
+++ b/lib/logic/power_settings.dart
@@ -1,13 +1,7 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
-/// Background-survival controls.
-///
-/// Android stops background services of apps that are not exempt from Doze and
-/// App Standby, and it tears down the whole process group when a task is swiped
-/// away. Those are OS behaviours, not app bugs; the battery-optimisation
-/// exemption is the one sanctioned way an app can ask for around them, and it
-/// still needs one user tap.
+/// Android background-survival controls that ReVolt can actually verify.
abstract final class PowerSettings {
PowerSettings._();
@@ -15,7 +9,6 @@ abstract final class PowerSettings {
'com.revoltvpn.app/power',
);
- /// Whether the OS has exempted this app from Doze / App Standby.
static Future isBatteryOptimisationDisabled() async {
if (kIsWeb) return true;
try {
@@ -28,8 +21,6 @@ abstract final class PowerSettings {
}
}
- /// Shows the system exemption dialog. Returns false only when no system UI
- /// could be opened at all — a user declining still returns true.
static Future requestDisableBatteryOptimisation() async {
if (kIsWeb) return true;
try {
@@ -41,53 +32,4 @@ abstract final class PowerSettings {
return false;
}
}
-
- /// Whether the VPN runtime is genuinely alive.
- ///
- /// The Xray service runs in a separate process, so nothing it stores in
- /// statics is visible to us. This asks the OS instead: is our service process
- /// running, and (for TUN) is there an active VPN transport?
- static Future runtimeState() async {
- if (kIsWeb) return const VpnRuntimeState(processAlive: false, vpnTransport: false);
- try {
- final result = await _channel.invokeMapMethod(
- 'isVpnRuntimeAlive',
- );
- return VpnRuntimeState(
- processAlive: result?['processAlive'] == true,
- vpnTransport: result?['vpnTransport'] == true,
- );
- } catch (_) {
- return const VpnRuntimeState(processAlive: false, vpnTransport: false);
- }
- }
-
- /// Pushes the absolute session-expiry timestamp (epoch ms) to the VPN
- /// service so it can tear the tunnel down even when the UI process is gone.
- static Future setSessionExpiry(int expiresAtMs) async {
- if (kIsWeb) return;
- try {
- await _channel.invokeMethod('setSessionExpiry', {
- 'expiresAtMs': expiresAtMs,
- });
- } catch (_) {
- // Best-effort. The daemon also keeps its last persisted expiry.
- }
- }
-}
-
-@immutable
-class VpnRuntimeState {
- final bool processAlive;
- final bool vpnTransport;
-
- const VpnRuntimeState({
- required this.processAlive,
- required this.vpnTransport,
- });
-
- /// TUN needs both a live service and a real VPN interface. Proxy mode has no
- /// VPN interface at all, so the service process is the whole signal.
- bool aliveFor({required bool tunMode}) =>
- processAlive && (!tunMode || vpnTransport);
}
diff --git a/lib/logic/secure_socks_session.dart b/lib/logic/secure_socks_session.dart
index 2a88247..8c31a31 100644
--- a/lib/logic/secure_socks_session.dart
+++ b/lib/logic/secure_socks_session.dart
@@ -39,40 +39,29 @@ class SecureSocksSession {
final username = _token(16);
final password = _token(32);
- final sourceInbounds = decoded['inbounds'];
- final inbounds = [];
- if (sourceInbounds is List) {
- for (final value in sourceInbounds) {
- if (value is Map) {
- final protocol = value['protocol']?.toString().toLowerCase();
- // ReVolt owns local proxy ingress on Android. Drop imported SOCKS/HTTP
- // listeners so a stale no-auth proxy cannot remain reachable beside
- // the authenticated session listener.
- if (protocol == 'socks' || protocol == 'http') continue;
- }
- inbounds.add(value);
- }
- }
-
- inbounds.add({
- 'tag': inboundTag,
- 'port': port,
- 'listen': '127.0.0.1',
- 'protocol': 'socks',
- 'settings': {
- 'auth': 'password',
- 'udp': true,
- 'ip': '127.0.0.1',
- 'users':