Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac52681452 | ||
|
|
ebaa2fc516 | ||
|
|
6fcde9dd7b | ||
|
|
a71161e7e5 | ||
|
|
da82a833cb | ||
|
|
0ab008b80b | ||
|
|
f7f425517c | ||
|
|
76bf84453f | ||
|
|
6c99366602 | ||
|
|
8ead3ff249 | ||
|
|
371db8da9f |
No files matched your search
+1
-3
@@ -18,9 +18,6 @@ android/admob.properties
|
|||||||
lib/logic/app_config.dart
|
lib/logic/app_config.dart
|
||||||
|
|
||||||
# ── File types the shipped app does not contain ────────────────────────────
|
# ── File types the shipped app does not contain ────────────────────────────
|
||||||
# Server code, infrastructure configs, captures, archives, dumps and logs.
|
|
||||||
# None of these extensions are tracked, so a blanket rule is safe and stays
|
|
||||||
# safe — it covers files that do not exist yet.
|
|
||||||
*.py
|
*.py
|
||||||
*.pyc
|
*.pyc
|
||||||
*.json
|
*.json
|
||||||
@@ -53,6 +50,7 @@ scraped/
|
|||||||
backup/
|
backup/
|
||||||
backups/
|
backups/
|
||||||
lib/logic/backup/
|
lib/logic/backup/
|
||||||
|
server_files/
|
||||||
|
|
||||||
# ── Flutter / Dart build output ────────────────────────────────────────────
|
# ── Flutter / Dart build output ────────────────────────────────────────────
|
||||||
.dart_tool/
|
.dart_tool/
|
||||||
|
|||||||
+107
@@ -5,6 +5,113 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [3.3.1] — 2026-08-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Explicit TUN and SOCKS5 connection modes.** The previous Auto mode is gone, so
|
||||||
|
routing is chosen deliberately instead of inferred at runtime. TUN is the default.
|
||||||
|
SOCKS5 runs a local authenticated proxy with no VPN interface, and therefore never
|
||||||
|
requests the Android VPN permission.
|
||||||
|
- **Authenticated per-session Local SOCKS5.** Every connection binds an ephemeral
|
||||||
|
loopback port and mints fresh 16-byte username / 32-byte password credentials.
|
||||||
|
Android loopback is reachable by every app on the device, so this authentication is
|
||||||
|
load-bearing rather than decorative. Credentials stay in memory. Imported
|
||||||
|
`socks`/`http` inbounds are dropped so no unauthenticated listener can survive
|
||||||
|
alongside the session one.
|
||||||
|
- **Network resilience profiles** — Standard and Extreme. Extreme restarts a failed
|
||||||
|
Xray/tun2socks runtime up to twice before giving up.
|
||||||
|
- **Android physical-network monitoring**, filtered on `NET_CAPABILITY_NOT_VPN` so the
|
||||||
|
VPN's own network cannot feed back a reconnect loop. Informational only; the active
|
||||||
|
transport (Wi-Fi / cellular / ethernet) now appears in the status bar.
|
||||||
|
- **Local SOCKS5 diagnostics.** "Test Local SOCKS" authenticates against the exact
|
||||||
|
active session over loopback — a readiness handshake plus a full CONNECT — instead
|
||||||
|
of checking whether a fixed port accepts connections.
|
||||||
|
- Native Android bridges for network state, haptic feedback and install-source
|
||||||
|
detection.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Extreme recovery now survives an app-process restart.** Android can kill the Dart
|
||||||
|
process while the VPN foreground service keeps running, leaving the app attached to
|
||||||
|
a live tunnel it holds no configuration for — so recovery had nothing to restart
|
||||||
|
from. In TUN mode the app rebuilds that snapshot from the still-active session,
|
||||||
|
without opening a new session, showing an ad, or interrupting traffic.
|
||||||
|
- **The proxy settings tile no longer misreports state after a process restart.** Its
|
||||||
|
credentials genuinely cannot be recovered — they are random, generated on device and
|
||||||
|
never transmitted — so instead of telling an already-connected user to "Connect", it
|
||||||
|
now says the credentials were lost and offers a Reconnect action.
|
||||||
|
- **The first tap on the connect button was ignored.** The debounce timestamp was
|
||||||
|
initialized to the current time instead of the epoch, so the first press was always
|
||||||
|
swallowed.
|
||||||
|
- **The health probe polled `/health` every 30 s even while connected.** Restored to
|
||||||
|
idle-only — a live tunnel already proves reachability, and the extra direct requests
|
||||||
|
were unwanted.
|
||||||
|
- **A user reconnect could race an in-flight Extreme recovery.** A connection
|
||||||
|
generation counter now aborts stale recovery attempts.
|
||||||
|
- Recoverable Xray/tun2socks startup and readiness failures no longer follow the
|
||||||
|
unstable self-stop path used during development.
|
||||||
|
- Returning from Android background state reconciles the session countdown against
|
||||||
|
elapsed wall-clock time and re-syncs immediately, reducing stale countdowns and false
|
||||||
|
`Syncing…` presentation after Dart throttling.
|
||||||
|
- Sidebar update checks retain valid navigator/messenger handles after the drawer
|
||||||
|
closes.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **The pinned `flutter_vless_android` 1.1.5 runtime is patched at build time** instead
|
||||||
|
of vendored. Three idempotent patchers are chained into `preBuild`, so a plain
|
||||||
|
`flutter build apk` behaves like CI rather than depending on a manual step against
|
||||||
|
the pub cache. They are fail-closed: an upstream change breaks the build loudly
|
||||||
|
instead of silently producing an unpatched runtime.
|
||||||
|
- Legacy `auto` preferences migrate to TUN; legacy `ass` preferences migrate to SOCKS5.
|
||||||
|
- Haptic feedback is a single confirmation on connect/disconnect, routed through the
|
||||||
|
native Android vibration channel, and **defaults to off**.
|
||||||
|
- The VPN notification uses a dedicated 24 dp status icon and stays ongoing/no-clear
|
||||||
|
while the foreground service is alive.
|
||||||
|
- Hivemind session parsing is split into typed validation steps; a malformed port now
|
||||||
|
fails instead of silently falling back.
|
||||||
|
- The update checker parses semver with prerelease ordering and validates release URLs
|
||||||
|
against an HTTPS host allowlist.
|
||||||
|
- The support button shows a claimed/disabled state once the session bonus is used.
|
||||||
|
- The status bar distinguishes `Ready` from `Syncing…` and folds in server
|
||||||
|
reachability.
|
||||||
|
- Gradle release heap raised to 4 GiB with `MaxMetaspaceSize=1g` and `workers.max=2`,
|
||||||
|
so Flutter/Jetifier transforms do not fail on the release classpath under constrained
|
||||||
|
runners.
|
||||||
|
- App version is now `3.3.1+27`.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- **Per-app routing**, including the app picker, the installed-apps native bridge and
|
||||||
|
the blocked-apps mechanism. It only ever functioned in TUN — the underlying
|
||||||
|
`addDisallowedApplication` call lives inside the VPN-interface setup that SOCKS5 mode
|
||||||
|
skips entirely — and its allowlist half was never wired to any caller. Removed rather
|
||||||
|
than finished.
|
||||||
|
- The legacy `auto` connection mode.
|
||||||
|
- `flutter_local_notifications`, and the unused notification manager it backed.
|
||||||
|
- A global touch listener that fired a haptic on every tap.
|
||||||
|
- Unused runtime telemetry fields.
|
||||||
|
|
||||||
|
### Security & privacy
|
||||||
|
- **Session metadata is hidden from public lock-screen previews.** The live foreground
|
||||||
|
notification is built with `VISIBILITY_PRIVATE` plus a generic public version, so the
|
||||||
|
countdown and speed are visible only after unlock. *(An earlier attempt at this
|
||||||
|
landed in an unreferenced class and never took effect.)*
|
||||||
|
- Local SOCKS5 listens only on `127.0.0.1`, requires per-session authentication, and
|
||||||
|
its credentials are neither persisted nor logged.
|
||||||
|
- The app requests no package visibility at all. `QUERY_ALL_PACKAGES` was never used,
|
||||||
|
and the launcher-enumeration query was removed along with the app picker.
|
||||||
|
- Session nonces use `Random.secure()` and are never logged.
|
||||||
|
- Device UUIDs are validated as v4 and regenerated if corrupt, so tampered identifiers
|
||||||
|
cannot reach the API.
|
||||||
|
- `FOREGROUND_SERVICE_SPECIAL_USE` is declared for Android 14+; the merged service
|
||||||
|
declaration carries the `vpn` special-use subtype.
|
||||||
|
- Existing AdMob bypass/callback behavior is intentionally unchanged by this release.
|
||||||
|
- Server, nginx and Reality configuration are not modified by this client-side release.
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
- `flutter analyze` — 0 errors, 0 warnings, 7 style infos (6 of them pre-existing).
|
||||||
|
- All three build-time patchers verified against a clean pub cache, including that each
|
||||||
|
applies independently of the others.
|
||||||
|
- **Not yet verified:** Android release Kotlin compilation and APK build.
|
||||||
|
|
||||||
## [3.3.0] — 2026-08-30
|
## [3.3.0] — 2026-08-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
@@ -6,6 +7,128 @@ plugins {
|
|||||||
id("dev.flutter.flutter-gradle-plugin")
|
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 flutterVlessPatchScript = File(
|
||||||
|
projectRootDir,
|
||||||
|
"tool/patch_flutter_vless_allowed_apps.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 patchFlutterVlessAllowedApps = tasks.register<Exec>("patchFlutterVlessAllowedApps") {
|
||||||
|
group = "build setup"
|
||||||
|
description = "Patch pinned flutter_vless_android 1.1.5 with ReVolt routing/runtime hardening"
|
||||||
|
workingDir(projectRootDir)
|
||||||
|
|
||||||
|
doFirst {
|
||||||
|
if (!dartExecutable.isFile) {
|
||||||
|
throw GradleException("Flutter Dart executable not found: ${dartExecutable.absolutePath}")
|
||||||
|
}
|
||||||
|
if (!flutterVlessPatchScript.isFile) {
|
||||||
|
throw GradleException("flutter_vless patch script missing: ${flutterVlessPatchScript.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",
|
||||||
|
flutterVlessPatchScript.absolutePath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val patchFlutterVlessSecureSocks = tasks.register<Exec>("patchFlutterVlessSecureSocks") {
|
||||||
|
group = "build setup"
|
||||||
|
description = "Add authenticated ephemeral SOCKS5 and fail-closed process recovery to pinned flutter_vless_android 1.1.5"
|
||||||
|
workingDir(projectRootDir)
|
||||||
|
dependsOn(patchFlutterVlessAllowedApps)
|
||||||
|
|
||||||
|
doFirst {
|
||||||
|
if (!dartExecutable.isFile) {
|
||||||
|
throw GradleException("Flutter Dart executable not found: ${dartExecutable.absolutePath}")
|
||||||
|
}
|
||||||
|
if (!flutterVlessSecureSocksPatchScript.isFile) {
|
||||||
|
throw GradleException("secure SOCKS patch script missing: ${flutterVlessSecureSocksPatchScript.absolutePath}")
|
||||||
|
}
|
||||||
|
if (!File(projectRootDir, ".dart_tool/package_config.json").isFile) {
|
||||||
|
throw GradleException("Flutter package metadata missing. Run `flutter pub get` before Android compilation.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
commandLine(
|
||||||
|
dartExecutable.absolutePath,
|
||||||
|
"run",
|
||||||
|
flutterVlessSecureSocksPatchScript.absolutePath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val patchFlutterVlessSecureSocksScopeFix = tasks.register<Exec>("patchFlutterVlessSecureSocksScopeFix") {
|
||||||
|
group = "build setup"
|
||||||
|
description = "Repair the secure Xray process monitor capture without changing stock 1.1.5 runtime sequencing"
|
||||||
|
workingDir(projectRootDir)
|
||||||
|
dependsOn(patchFlutterVlessSecureSocks)
|
||||||
|
|
||||||
|
doFirst {
|
||||||
|
if (!dartExecutable.isFile) {
|
||||||
|
throw GradleException("Flutter Dart executable not found: ${dartExecutable.absolutePath}")
|
||||||
|
}
|
||||||
|
if (!flutterVlessSecureSocksScopeFixScript.isFile) {
|
||||||
|
throw GradleException("secure SOCKS scope fix script missing: ${flutterVlessSecureSocksScopeFixScript.absolutePath}")
|
||||||
|
}
|
||||||
|
if (!File(projectRootDir, ".dart_tool/package_config.json").isFile) {
|
||||||
|
throw GradleException("Flutter package metadata missing. Run `flutter pub get` before Android compilation.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
commandLine(
|
||||||
|
dartExecutable.absolutePath,
|
||||||
|
"run",
|
||||||
|
flutterVlessSecureSocksScopeFixScript.absolutePath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// App compilation and the transitive Android plugin compilation must both wait
|
||||||
|
// for the pinned runtime patches. Plain `flutter build apk` must behave like CI
|
||||||
|
// instead of relying on a hidden manual pre-build step.
|
||||||
|
tasks.configureEach {
|
||||||
|
if (name == "preBuild") {
|
||||||
|
dependsOn(patchFlutterVlessSecureSocksScopeFix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gradle.projectsEvaluated {
|
||||||
|
rootProject.findProject(":flutter_vless_android")
|
||||||
|
?.tasks
|
||||||
|
?.matching { it.name == "preBuild" }
|
||||||
|
?.configureEach {
|
||||||
|
dependsOn(patchFlutterVlessSecureSocksScopeFix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.paladinvpn.app"
|
namespace = "com.paladinvpn.app"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
|
||||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
|
|
||||||
<!-- flutter_vless 1.1.5 declares this legacy permission. ReVolt never needs it. -->
|
<!-- flutter_vless 1.1.5 declares this legacy permission. ReVolt never needs it. -->
|
||||||
|
|||||||
@@ -1,30 +1,38 @@
|
|||||||
package com.paladinvpn.app
|
package com.paladinvpn.app
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.NetworkRequest
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.VibrationEffect
|
||||||
|
import android.os.Vibrator
|
||||||
|
import android.os.VibratorManager
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import com.github.tfox.flutter_vless.xray.service.XrayVPNService
|
import com.github.tfox.flutter_vless.xray.service.XrayVPNService
|
||||||
import com.github.tfox.flutter_vless.xray.utils.AppConfigs
|
import com.github.tfox.flutter_vless.xray.utils.AppConfigs
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.EventChannel
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
class MainActivity : FlutterActivity() {
|
class MainActivity : FlutterActivity() {
|
||||||
|
|
||||||
// Channel id only — not the application id, which is com.paladinvpn.app.
|
|
||||||
// Must match the string in lib/logic/updater.dart.
|
|
||||||
private val installerChannel = "com.revoltvpn.app/installer"
|
private val installerChannel = "com.revoltvpn.app/installer"
|
||||||
|
|
||||||
// Must match lib/logic/notification_service.dart.
|
|
||||||
private val notificationChannel = "com.revoltvpn.app/notification"
|
private val notificationChannel = "com.revoltvpn.app/notification"
|
||||||
|
private val hapticsChannel = "com.revoltvpn.app/haptics"
|
||||||
|
private val networkChannel = "com.revoltvpn.app/network"
|
||||||
|
|
||||||
private var notificationChannelReady = false
|
private var notificationChannelReady = false
|
||||||
|
private var networkCallback: ConnectivityManager.NetworkCallback? = null
|
||||||
|
private var networkEvents: EventChannel.EventSink? = null
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
@@ -50,16 +58,36 @@ class MainActivity : FlutterActivity() {
|
|||||||
result.notImplemented()
|
result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, hapticsChannel)
|
||||||
|
.setMethodCallHandler { call, result ->
|
||||||
|
if (call.method == "impact") {
|
||||||
|
val kind = call.argument<String>("kind") ?: "tap"
|
||||||
|
result.success(performHaptic(kind))
|
||||||
|
} else {
|
||||||
|
result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EventChannel(flutterEngine.dartExecutor.binaryMessenger, networkChannel)
|
||||||
|
.setStreamHandler(object : EventChannel.StreamHandler {
|
||||||
|
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||||
|
networkEvents = events
|
||||||
|
startNetworkMonitor()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCancel(arguments: Any?) {
|
||||||
|
networkEvents = null
|
||||||
|
stopNetworkMonitor()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
stopNetworkMonitor()
|
||||||
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the session countdown as the VPN notification's content text.
|
|
||||||
*
|
|
||||||
* notify() is per-package, so re-posting flutter_vless's notification id
|
|
||||||
* replaces it wholesale — every field the plugin set has to be rebuilt
|
|
||||||
* here or it is dropped, the Disconnect action most of all. Keep in sync
|
|
||||||
* with XrayCoreManager.showNotification().
|
|
||||||
*/
|
|
||||||
private fun updateVpnNotification(title: String, text: String, actionLabel: String) {
|
private fun updateVpnNotification(title: String, text: String, actionLabel: String) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) !=
|
checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) !=
|
||||||
@@ -76,58 +104,62 @@ class MainActivity : FlutterActivity() {
|
|||||||
PendingIntent.FLAG_UPDATE_CURRENT
|
PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same intent shape as the plugin, so a tap brings the running task
|
|
||||||
// forward instead of starting a second one.
|
|
||||||
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
|
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
|
||||||
launchIntent?.action = "FROM_DISCONNECT_BTN"
|
launchIntent?.action = "FROM_DISCONNECT_BTN"
|
||||||
launchIntent?.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or
|
launchIntent?.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or
|
||||||
Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
||||||
Intent.FLAG_ACTIVITY_NEW_TASK
|
Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
|
||||||
// Tapping "Disconnect" stops the tunnel without opening the app.
|
|
||||||
val stopIntent = Intent(this, XrayVPNService::class.java)
|
val stopIntent = Intent(this, XrayVPNService::class.java)
|
||||||
stopIntent.putExtra(
|
stopIntent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE)
|
||||||
"COMMAND",
|
|
||||||
AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE
|
|
||||||
)
|
|
||||||
val stopPendingIntent = PendingIntent.getService(this, 0, stopIntent, flags)
|
val stopPendingIntent = PendingIntent.getService(this, 0, stopIntent, flags)
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
.setContentText("VPN is active")
|
||||||
|
.build()
|
||||||
|
|
||||||
val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||||
.setSmallIcon(R.drawable.notification_icon)
|
.setSmallIcon(R.drawable.notification_status_icon)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText(text)
|
.setContentText(text)
|
||||||
.addAction(0, actionLabel, stopPendingIntent)
|
.addAction(0, actionLabel, stopPendingIntent)
|
||||||
.setColorized(true)
|
.setColorized(true)
|
||||||
.setColor(0xFF0D1117.toInt())
|
.setColor(0xFF0D1117.toInt())
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
|
.setAutoCancel(false)
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
.setSilent(true)
|
.setSilent(true)
|
||||||
.setShowWhen(false)
|
.setShowWhen(false)
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||||
|
.setPublicVersion(publicVersion)
|
||||||
|
|
||||||
if (launchIntent != null) {
|
if (launchIntent != null) {
|
||||||
builder.setContentIntent(
|
builder.setContentIntent(PendingIntent.getActivity(this, 0, launchIntent, flags))
|
||||||
PendingIntent.getActivity(this, 0, launchIntent, flags)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
NotificationManagerCompat.from(this).notify(NOTIFICATION_ID, builder.build())
|
val notification = builder.build()
|
||||||
|
notification.flags = notification.flags or
|
||||||
|
Notification.FLAG_ONGOING_EVENT or Notification.FLAG_NO_CLEAR
|
||||||
|
NotificationManagerCompat.from(this).notify(NOTIFICATION_ID, notification)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// The foreground notification may not be up yet — the update is cosmetic.
|
// The native foreground notification may not be ready yet.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A binder round-trip, and the caller runs once a second — so, once per process. */
|
|
||||||
private fun ensureNotificationChannel() {
|
private fun ensureNotificationChannel() {
|
||||||
if (notificationChannelReady) return
|
if (notificationChannelReady) return
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val manager =
|
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
||||||
val channel = NotificationChannel(
|
val channel = NotificationChannel(
|
||||||
NOTIFICATION_CHANNEL_ID,
|
NOTIFICATION_CHANNEL_ID,
|
||||||
"Revolt VPN Background Service",
|
"Revolt VPN Background Service",
|
||||||
NotificationManager.IMPORTANCE_LOW
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
)
|
)
|
||||||
channel.setShowBadge(false)
|
channel.setShowBadge(false)
|
||||||
manager.createNotificationChannel(channel)
|
manager.createNotificationChannel(channel)
|
||||||
@@ -135,11 +167,134 @@ class MainActivity : FlutterActivity() {
|
|||||||
notificationChannelReady = true
|
notificationChannelReady = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun startNetworkMonitor() {
|
||||||
* Package name of whatever installed us ("com.android.vending" for Play
|
if (networkCallback != null) return
|
||||||
* Store), or null when unknown. Without this the Dart side always falls
|
|
||||||
* back to sideload and points every user at GitHub releases.
|
val manager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
*/
|
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||||
|
override fun onAvailable(network: Network) {
|
||||||
|
emitBestPhysicalNetwork(manager, "available")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLost(network: Network) {
|
||||||
|
emitBestPhysicalNetwork(manager, "lost")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCapabilitiesChanged(
|
||||||
|
network: Network,
|
||||||
|
capabilities: NetworkCapabilities,
|
||||||
|
) {
|
||||||
|
emitBestPhysicalNetwork(manager, "changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val request = NetworkRequest.Builder()
|
||||||
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||||
|
.build()
|
||||||
|
manager.registerNetworkCallback(request, callback)
|
||||||
|
networkCallback = callback
|
||||||
|
emitBestPhysicalNetwork(manager, "initial")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
networkEvents?.error("NETWORK_MONITOR", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopNetworkMonitor() {
|
||||||
|
val callback = networkCallback ?: return
|
||||||
|
val manager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
try {
|
||||||
|
manager.unregisterNetworkCallback(callback)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
networkCallback = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emitBestPhysicalNetwork(
|
||||||
|
manager: ConnectivityManager,
|
||||||
|
reason: String,
|
||||||
|
) {
|
||||||
|
val best = manager.allNetworks
|
||||||
|
.mapNotNull { network ->
|
||||||
|
val capabilities = manager.getNetworkCapabilities(network)
|
||||||
|
?: return@mapNotNull null
|
||||||
|
if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) ||
|
||||||
|
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
|
||||||
|
) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
Triple(network, capabilities, physicalNetworkScore(capabilities))
|
||||||
|
}
|
||||||
|
.maxByOrNull { it.third }
|
||||||
|
|
||||||
|
if (best == null) {
|
||||||
|
emitNetworkPayload(
|
||||||
|
mapOf<String, Any>(
|
||||||
|
"reason" to reason,
|
||||||
|
"transport" to "none",
|
||||||
|
"connected" to false,
|
||||||
|
"validated" to false,
|
||||||
|
"metered" to false,
|
||||||
|
"timestamp" to System.currentTimeMillis(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emitNetworkState(best.first, best.second, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun physicalNetworkScore(capabilities: NetworkCapabilities): Int {
|
||||||
|
var score = 0
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M ||
|
||||||
|
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||||
|
) {
|
||||||
|
score += 100
|
||||||
|
}
|
||||||
|
score += when {
|
||||||
|
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> 40
|
||||||
|
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> 30
|
||||||
|
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> 20
|
||||||
|
else -> 10
|
||||||
|
}
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emitNetworkState(
|
||||||
|
network: Network,
|
||||||
|
capabilities: NetworkCapabilities,
|
||||||
|
reason: String,
|
||||||
|
) {
|
||||||
|
val transport = when {
|
||||||
|
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
|
||||||
|
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
|
||||||
|
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
|
||||||
|
else -> "other"
|
||||||
|
}
|
||||||
|
val validated = Build.VERSION.SDK_INT < Build.VERSION_CODES.M ||
|
||||||
|
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||||
|
val metered = !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||||
|
|
||||||
|
emitNetworkPayload(
|
||||||
|
mapOf<String, Any>(
|
||||||
|
"reason" to reason,
|
||||||
|
"transport" to transport,
|
||||||
|
"connected" to true,
|
||||||
|
"validated" to validated,
|
||||||
|
"metered" to metered,
|
||||||
|
"timestamp" to System.currentTimeMillis(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emitNetworkPayload(payload: Map<String, Any>) {
|
||||||
|
runOnUiThread { networkEvents?.success(payload) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun installerPackage(): String? = try {
|
private fun installerPackage(): String? = try {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
packageManager.getInstallSourceInfo(packageName).installingPackageName
|
packageManager.getInstallSourceInfo(packageName).installingPackageName
|
||||||
@@ -147,12 +302,47 @@ class MainActivity : FlutterActivity() {
|
|||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
packageManager.getInstallerPackageName(packageName)
|
packageManager.getInstallerPackageName(packageName)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun performHaptic(kind: String): Boolean {
|
||||||
|
return try {
|
||||||
|
val vibrator: Vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
val manager =
|
||||||
|
getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
|
||||||
|
manager.defaultVibrator
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!vibrator.hasVibrator()) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
val effect = when (kind) {
|
||||||
|
"selection" -> VibrationEffect.EFFECT_TICK
|
||||||
|
"success" -> VibrationEffect.EFFECT_HEAVY_CLICK
|
||||||
|
else -> VibrationEffect.EFFECT_CLICK
|
||||||
|
}
|
||||||
|
vibrator.vibrate(VibrationEffect.createPredefined(effect))
|
||||||
|
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val durationMs = if (kind == "success") 24L else 14L
|
||||||
|
val amplitude = if (kind == "success") 110 else 70
|
||||||
|
vibrator.vibrate(VibrationEffect.createOneShot(durationMs, amplitude))
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
vibrator.vibrate(if (kind == "success") 24L else 14L)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
// Must match flutter_vless's XrayCoreManager.
|
|
||||||
private const val NOTIFICATION_ID = 1
|
private const val NOTIFICATION_ID = 1
|
||||||
private const val NOTIFICATION_CHANNEL_ID = "XRAY_SERVICE_CHANNEL"
|
private const val NOTIFICATION_CHANNEL_ID = "XRAY_SERVICE_CHANNEL"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M12,1.5L21,5.2V11.1C21,16.7 17.4,21 12,22.5C6.6,21 3,16.7 3,11.1V5.2L12,1.5ZM12,5L6.5,7.2V11C6.5,14.8 8.7,17.8 12,19C15.3,17.8 17.5,14.8 17.5,11V7.2L12,5ZM10.8,9H13.2V13.8H10.8V9ZM10.8,15H13.2V17.4H10.8V15Z" />
|
||||||
|
</vector>
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
org.gradle.jvmargs=-Xmx1536M
|
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.workers.max=2
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
|
# These compatibility flags are managed by Flutter's Android toolchain.
|
||||||
android.builtInKotlin=false
|
android.builtInKotlin=false
|
||||||
android.newDsl=false
|
android.newDsl=false
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:revoltvpn/logic/app_colors.dart';
|
import 'package:revoltvpn/logic/app_colors.dart';
|
||||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||||
import 'package:revoltvpn/logic/session_timer.dart';
|
import 'package:revoltvpn/logic/session_timer.dart';
|
||||||
import 'package:revoltvpn/logic/ad_manager.dart';
|
import 'package:revoltvpn/logic/ad_manager.dart';
|
||||||
import 'package:revoltvpn/screens/settings/in_settings/haptic.dart';
|
import 'package:revoltvpn/logic/haptic_settings.dart';
|
||||||
|
|
||||||
class ConnectButton extends StatefulWidget {
|
class ConnectButton extends StatefulWidget {
|
||||||
const ConnectButton({super.key});
|
const ConnectButton({super.key});
|
||||||
@@ -17,7 +16,7 @@ class ConnectButton extends StatefulWidget {
|
|||||||
class _ConnectButtonState extends State<ConnectButton>
|
class _ConnectButtonState extends State<ConnectButton>
|
||||||
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
|
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
|
||||||
bool _busy = false;
|
bool _busy = false;
|
||||||
DateTime _lastTap = DateTime.now();
|
DateTime _lastTap = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
AnimationController? _pulse;
|
AnimationController? _pulse;
|
||||||
Animation<double>? _pulseAnim;
|
Animation<double>? _pulseAnim;
|
||||||
|
|
||||||
@@ -26,12 +25,17 @@ class _ConnectButtonState extends State<ConnectButton>
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
final pulse = AnimationController(vsync: this, duration: const Duration(milliseconds: 2000));
|
final pulse = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 2000),
|
||||||
|
);
|
||||||
_pulse = pulse;
|
_pulse = pulse;
|
||||||
_pulseAnim = Tween(begin: 0.0, end: 1.0).animate(CurvedAnimation(parent: pulse, curve: Curves.easeInOut));
|
_pulseAnim = Tween(begin: 0.0, end: 1.0).animate(
|
||||||
|
CurvedAnimation(parent: pulse, curve: Curves.easeInOut),
|
||||||
|
);
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
context.read<VpnConnection>().addListener(_onVpnChanged);
|
if (mounted) context.read<VpnConnection>().addListener(_onVpnChanged);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +72,6 @@ class _ConnectButtonState extends State<ConnectButton>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleTap() async {
|
Future<void> _handleTap() async {
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
if (now.difference(_lastTap).inMilliseconds < 1000) return;
|
if (now.difference(_lastTap).inMilliseconds < 1000) return;
|
||||||
_lastTap = now;
|
_lastTap = now;
|
||||||
@@ -77,31 +80,24 @@ class _ConnectButtonState extends State<ConnectButton>
|
|||||||
final timer = context.read<SessionTimer>();
|
final timer = context.read<SessionTimer>();
|
||||||
final ad = context.read<AdManager>();
|
final ad = context.read<AdManager>();
|
||||||
|
|
||||||
|
|
||||||
if (vpn.status == VpnStatus.connected || vpn.status == VpnStatus.connecting) {
|
if (vpn.status == VpnStatus.connected || vpn.status == VpnStatus.connecting) {
|
||||||
_busy = false;
|
_busy = false;
|
||||||
await timer.disconnect();
|
await timer.disconnect();
|
||||||
unawaited(Haptics.connectionChanged());
|
HapticSettings.success();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (vpn.status == VpnStatus.disconnecting || _busy) return;
|
||||||
if (vpn.status == VpnStatus.disconnecting) return;
|
|
||||||
|
|
||||||
|
|
||||||
if (_busy) return;
|
|
||||||
|
|
||||||
setState(() => _busy = true);
|
setState(() => _busy = true);
|
||||||
try {
|
try {
|
||||||
final adWatched = await ad.showAd('main');
|
final adWatched = await ad.showAd('main');
|
||||||
if (!adWatched && AdManager.adsEnabled) {
|
if (!adWatched && AdManager.adsEnabled) return;
|
||||||
setState(() => _busy = false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final ok = await vpn.connect();
|
final ok = await vpn.connect();
|
||||||
if (ok) {
|
if (ok) {
|
||||||
timer.start();
|
await timer.start();
|
||||||
unawaited(Haptics.connectionChanged());
|
HapticSettings.success();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _busy = false);
|
if (mounted) setState(() => _busy = false);
|
||||||
@@ -170,7 +166,7 @@ class _ConnectButtonState extends State<ConnectButton>
|
|||||||
color: Color.fromARGB(glowAlpha, 255, 214, 0),
|
color: Color.fromARGB(glowAlpha, 255, 214, 0),
|
||||||
blurRadius: glowRadius,
|
blurRadius: glowRadius,
|
||||||
spreadRadius: glowRadius * 0.3,
|
spreadRadius: glowRadius * 0.3,
|
||||||
)
|
),
|
||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
import 'dart:io';
|
|
||||||
import 'dart:ui' as ui;
|
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
|
||||||
|
|
||||||
class VpnNotificationManager {
|
|
||||||
static final FlutterLocalNotificationsPlugin _plugin =
|
|
||||||
FlutterLocalNotificationsPlugin();
|
|
||||||
static bool _initialized = false;
|
|
||||||
static String? _lastTimeLeft;
|
|
||||||
static String? _lastSpeedKbps;
|
|
||||||
|
|
||||||
// Android channels are immutable — bump this if settings change.
|
|
||||||
static const _channelId = 'revolt_vpn_v4';
|
|
||||||
static const _channelName = 'Revolt VPN';
|
|
||||||
static const _notifId = 888;
|
|
||||||
|
|
||||||
static Future<void> init() async {
|
|
||||||
if (_initialized) return;
|
|
||||||
|
|
||||||
const androidSettings =
|
|
||||||
AndroidInitializationSettings('@drawable/notification_icon');
|
|
||||||
const initSettings = InitializationSettings(android: androidSettings);
|
|
||||||
|
|
||||||
await _plugin.initialize(initSettings);
|
|
||||||
|
|
||||||
if (Platform.isAndroid) {
|
|
||||||
final androidPlugin = _plugin
|
|
||||||
.resolvePlatformSpecificImplementation<
|
|
||||||
AndroidFlutterLocalNotificationsPlugin>();
|
|
||||||
await androidPlugin?.requestNotificationsPermission();
|
|
||||||
}
|
|
||||||
|
|
||||||
_initialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<void> showOrUpdateStatus({
|
|
||||||
required String timeLeft,
|
|
||||||
required String speedKbps,
|
|
||||||
}) async {
|
|
||||||
if (timeLeft == _lastTimeLeft && speedKbps == _lastSpeedKbps) return;
|
|
||||||
_lastTimeLeft = timeLeft;
|
|
||||||
_lastSpeedKbps = speedKbps;
|
|
||||||
|
|
||||||
if (!_initialized) await init();
|
|
||||||
|
|
||||||
final bodyText = 'Time: $timeLeft • $speedKbps';
|
|
||||||
|
|
||||||
final bigPictureStyle = BigPictureStyleInformation(
|
|
||||||
const DrawableResourceAndroidBitmap('notification_banner'),
|
|
||||||
largeIcon: const DrawableResourceAndroidBitmap('notification_icon'),
|
|
||||||
contentTitle: 'Revolt VPN',
|
|
||||||
summaryText: bodyText,
|
|
||||||
htmlFormatContentTitle: false,
|
|
||||||
htmlFormatSummaryText: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
final androidDetails = AndroidNotificationDetails(
|
|
||||||
_channelId,
|
|
||||||
_channelName,
|
|
||||||
channelDescription: 'Session timer and speed',
|
|
||||||
importance: Importance.low,
|
|
||||||
priority: Priority.low,
|
|
||||||
ongoing: true,
|
|
||||||
autoCancel: false,
|
|
||||||
playSound: false,
|
|
||||||
enableVibration: false,
|
|
||||||
showWhen: false,
|
|
||||||
visibility: NotificationVisibility.public,
|
|
||||||
usesChronometer: false,
|
|
||||||
category: AndroidNotificationCategory.service,
|
|
||||||
colorized: true,
|
|
||||||
color: const ui.Color(0xFF0D1117),
|
|
||||||
styleInformation: bigPictureStyle,
|
|
||||||
);
|
|
||||||
|
|
||||||
final details = NotificationDetails(android: androidDetails);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await _plugin.show(_notifId, _channelName, bodyText, details);
|
|
||||||
} catch (e) {
|
|
||||||
_initialized = false;
|
|
||||||
try {
|
|
||||||
await init();
|
|
||||||
await _plugin.show(_notifId, _channelName, bodyText, details);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<void> cancel() async {
|
|
||||||
if (!_initialized) return;
|
|
||||||
await _plugin.cancel(_notifId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -141,7 +141,10 @@ class _SidebarDrawerState extends State<SidebarDrawer> {
|
|||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
unawaited(
|
unawaited(
|
||||||
Updater.check(navigator: navigator, messenger: messenger),
|
Updater.checkWithHandles(
|
||||||
|
navigator: navigator,
|
||||||
|
messenger: messenger,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -7,16 +7,6 @@ import 'package:revoltvpn/logic/server_list.dart';
|
|||||||
import 'package:revoltvpn/components/online_dot.dart';
|
import 'package:revoltvpn/components/online_dot.dart';
|
||||||
import 'package:revoltvpn/screens/server_selector/selection_screen.dart';
|
import 'package:revoltvpn/screens/server_selector/selection_screen.dart';
|
||||||
|
|
||||||
// ── lib/components/status_bar.dart ──────────────────────────────────────────
|
|
||||||
// Two-card layout at the bottom of the main screen.
|
|
||||||
//
|
|
||||||
// Top card: server location from ServerList provider (flag + city).
|
|
||||||
// Tappable → opens server selector. Always visible.
|
|
||||||
// Bottom card: connection status (health dot + label + lock icon).
|
|
||||||
//
|
|
||||||
// Separated into two rows — no more RenderFlex overflow and ready for
|
|
||||||
// multi-server picker.
|
|
||||||
|
|
||||||
class StatusBar extends StatelessWidget {
|
class StatusBar extends StatelessWidget {
|
||||||
const StatusBar({super.key});
|
const StatusBar({super.key});
|
||||||
|
|
||||||
@@ -28,15 +18,14 @@ class StatusBar extends StatelessWidget {
|
|||||||
final isConnecting = vpn.status == VpnStatus.connecting;
|
final isConnecting = vpn.status == VpnStatus.connecting;
|
||||||
final serverHealthy =
|
final serverHealthy =
|
||||||
vpn.serverReachable || (isConnected && timer.hasSyncedOnce);
|
vpn.serverReachable || (isConnected && timer.hasSyncedOnce);
|
||||||
|
final sessionOrTunnelHealthy = timer.hasSyncedOnce || vpn.serverReachable;
|
||||||
|
|
||||||
final statusLabel = isConnected
|
final statusLabel = isConnected
|
||||||
? (timer.hasSyncedOnce ? 'Online' : 'Syncing…')
|
? (sessionOrTunnelHealthy ? 'Online' : 'Syncing…')
|
||||||
: (isConnecting
|
: (isConnecting
|
||||||
? 'Connecting…'
|
? 'Connecting…'
|
||||||
: (vpn.serverReachable ? 'Ready' : 'Offline'));
|
: (vpn.serverReachable ? 'Ready' : 'Offline'));
|
||||||
|
|
||||||
// Read from the ServerList provider — the source of truth for
|
|
||||||
// which server is selected and what it looks like.
|
|
||||||
final server = context.watch<ServerList>().selected;
|
final server = context.watch<ServerList>().selected;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -44,7 +33,6 @@ class StatusBar extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// ── Server location ──────────────────────────────────────────
|
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
@@ -56,10 +44,12 @@ class StatusBar extends StatelessWidget {
|
|||||||
position: Tween<Offset>(
|
position: Tween<Offset>(
|
||||||
begin: const Offset(1.0, 0.0),
|
begin: const Offset(1.0, 0.0),
|
||||||
end: Offset.zero,
|
end: Offset.zero,
|
||||||
).animate(CurvedAnimation(
|
).animate(
|
||||||
parent: animation,
|
CurvedAnimation(
|
||||||
curve: Curves.easeOut,
|
parent: animation,
|
||||||
)),
|
curve: Curves.easeOut,
|
||||||
|
),
|
||||||
|
),
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -79,24 +69,26 @@ class StatusBar extends StatelessWidget {
|
|||||||
Image.asset(server.flagAsset, width: 20, height: 20),
|
Image.asset(server.flagAsset, width: 20, height: 20),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(server.name,
|
child: Text(
|
||||||
style: const TextStyle(
|
server.name,
|
||||||
color: AppColors.accent,
|
style: const TextStyle(
|
||||||
fontWeight: FontWeight.w600,
|
color: AppColors.accent,
|
||||||
fontSize: 13,
|
fontWeight: FontWeight.w600,
|
||||||
letterSpacing: 0.5,
|
fontSize: 13,
|
||||||
)),
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
color: AppColors.textDim,
|
||||||
|
size: 20,
|
||||||
),
|
),
|
||||||
const Icon(Icons.chevron_right,
|
|
||||||
color: AppColors.textDim, size: 20),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
// ── Connection status ────────────────────────────────────────
|
|
||||||
Container(
|
Container(
|
||||||
padding:
|
padding:
|
||||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||||
@@ -112,9 +104,24 @@ class StatusBar extends StatelessWidget {
|
|||||||
label: statusLabel,
|
label: statusLabel,
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
|
if (vpn.networkTransport != 'unknown' &&
|
||||||
|
vpn.networkTransport != 'none')
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: Text(
|
||||||
|
vpn.networkTransport,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.textDim,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
if (isConnected)
|
if (isConnected)
|
||||||
const Icon(Icons.lock,
|
const Icon(
|
||||||
color: AppColors.green50, size: 14),
|
Icons.lock,
|
||||||
|
color: AppColors.green50,
|
||||||
|
size: 14,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -20,13 +20,21 @@ class _SupportButtonState extends State<SupportButton> {
|
|||||||
final ad = context.read<AdManager>();
|
final ad = context.read<AdManager>();
|
||||||
final timer = context.read<SessionTimer>();
|
final timer = context.read<SessionTimer>();
|
||||||
|
|
||||||
// Only available when there's an active session.
|
// Only available when there's an active session, once the persisted
|
||||||
if (!timer.isRunning && !timer.hasSyncedOnce) return;
|
// support state is known, and only once per client-side VPN session.
|
||||||
|
if ((!timer.isRunning && !timer.hasSyncedOnce) ||
|
||||||
|
!timer.supportRewardStateLoaded ||
|
||||||
|
timer.supportRewardClaimed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setState(() => _busy = true);
|
setState(() => _busy = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ad.showAd('support');
|
final rewarded = await ad.showAd('support');
|
||||||
|
if (rewarded) {
|
||||||
|
await timer.markSupportRewardClaimed();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _busy = false);
|
if (mounted) setState(() => _busy = false);
|
||||||
}
|
}
|
||||||
@@ -34,48 +42,62 @@ class _SupportButtonState extends State<SupportButton> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Selector<SessionTimer, bool>(
|
return Consumer<SessionTimer>(
|
||||||
selector: (_, timer) => timer.isRunning || timer.hasSyncedOnce,
|
builder: (context, timer, _) {
|
||||||
builder: (context, visible, _) {
|
if (!timer.isRunning && !timer.hasSyncedOnce) {
|
||||||
if (!visible) {
|
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final stateLoaded = timer.supportRewardStateLoaded;
|
||||||
|
final claimed = timer.supportRewardClaimed;
|
||||||
|
final disabled = !stateLoaded || claimed;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: _handleTap,
|
onTap: disabled ? null : _handleTap,
|
||||||
child: Container(
|
child: Opacity(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
opacity: disabled ? 0.55 : 1.0,
|
||||||
decoration: BoxDecoration(
|
child: Container(
|
||||||
color: AppColors.glassBg,
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||||
borderRadius: BorderRadius.circular(24),
|
decoration: BoxDecoration(
|
||||||
border: Border.all(
|
color: AppColors.glassBg,
|
||||||
color: AppColors.accent.withAlpha(100),
|
borderRadius: BorderRadius.circular(24),
|
||||||
width: 1,
|
border: Border.all(
|
||||||
|
color: AppColors.accent.withAlpha(100),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
child: Row(
|
||||||
child: Row(
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisSize: MainAxisSize.min,
|
children: [
|
||||||
children: [
|
Icon(
|
||||||
const Icon(Icons.favorite_border, color: AppColors.accent, size: 20),
|
claimed ? Icons.check_circle_outline : Icons.favorite_border,
|
||||||
const SizedBox(width: 8),
|
color: AppColors.accent,
|
||||||
_busy
|
size: 20,
|
||||||
? const SizedBox(
|
),
|
||||||
width: 18,
|
const SizedBox(width: 8),
|
||||||
height: 18,
|
_busy
|
||||||
child: CircularProgressIndicator(
|
? const SizedBox(
|
||||||
strokeWidth: 2,
|
width: 18,
|
||||||
color: AppColors.accent,
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: AppColors.accent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
!stateLoaded
|
||||||
|
? 'Checking support bonus…'
|
||||||
|
: claimed
|
||||||
|
? 'Support bonus used'
|
||||||
|
: 'Support us — 30 min!',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.textWhite,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
],
|
||||||
: const Text(
|
),
|
||||||
'Support us \u2014 30 min!',
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppColors.textWhite,
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ abstract final class AppConfig {
|
|||||||
// ── Updates ────────────────────────────────────────────────────────────
|
// ── Updates ────────────────────────────────────────────────────────────
|
||||||
/// Used by the updater.
|
/// Used by the updater.
|
||||||
static const String applicationId = 'com.paladinvpn.app'; // ◄── REPLACE
|
static const String applicationId = 'com.paladinvpn.app'; // ◄── REPLACE
|
||||||
static const String githubOwner = 'YOUR_USERNAME'; // ◄── REPLACE
|
static const String githubOwner = 'YOUR_GITHUB_USERNAME'; // ◄── REPLACE (current: 'esefxdz')
|
||||||
static const String githubRepo = 'revoltvpn'; // ◄── REPLACE
|
static const String githubRepo = 'YOUR_REPO_NAME'; // ◄── REPLACE (current: 'ReVoltVPN')
|
||||||
|
|
||||||
/// Fallback URL if the GitHub API call fails.
|
/// Fallback URL if the GitHub API call fails.
|
||||||
static const String githubReleasesUrl = 'https://github.com/$githubOwner/$githubRepo/releases/latest';
|
static const String githubReleasesUrl = 'https://github.com/$githubOwner/$githubRepo/releases/latest';
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
enum ConnectionMode { tun, proxy }
|
||||||
|
enum ResilienceMode { standard, extreme }
|
||||||
|
|
||||||
|
abstract final class ConnectionSettings {
|
||||||
|
ConnectionSettings._();
|
||||||
|
|
||||||
|
static const String _modeKey = 'connection_mode';
|
||||||
|
static const String _resilienceModeKey = 'resilience_mode';
|
||||||
|
|
||||||
|
static ConnectionMode _mode = ConnectionMode.tun;
|
||||||
|
static ResilienceMode _resilienceMode = ResilienceMode.standard;
|
||||||
|
static bool _initialized = false;
|
||||||
|
|
||||||
|
static ConnectionMode get mode => _mode;
|
||||||
|
static ResilienceMode get resilienceMode => _resilienceMode;
|
||||||
|
|
||||||
|
static Future<void> initialize() async {
|
||||||
|
if (_initialized) return;
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final storedMode = prefs.getString(_modeKey);
|
||||||
|
|
||||||
|
// Legacy 'ass' was the pre-V3 SOCKS5 label; map it to proxy. Everything
|
||||||
|
// else (including the old 'auto') is TUN — there is no runtime auto mode.
|
||||||
|
_mode = storedMode == ConnectionMode.proxy.name || storedMode == 'ass'
|
||||||
|
? ConnectionMode.proxy
|
||||||
|
: ConnectionMode.tun;
|
||||||
|
|
||||||
|
_resilienceMode =
|
||||||
|
prefs.getString(_resilienceModeKey) == ResilienceMode.extreme.name
|
||||||
|
? ResilienceMode.extreme
|
||||||
|
: ResilienceMode.standard;
|
||||||
|
|
||||||
|
if (storedMode != _mode.name) {
|
||||||
|
await prefs.setString(_modeKey, _mode.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
_initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setMode(ConnectionMode mode) async {
|
||||||
|
_mode = mode;
|
||||||
|
_initialized = true;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_modeKey, mode.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setResilienceMode(ResilienceMode mode) async {
|
||||||
|
_resilienceMode = mode;
|
||||||
|
_initialized = true;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_resilienceModeKey, mode.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,17 @@ import 'package:uuid/uuid.dart';
|
|||||||
class CryptoService {
|
class CryptoService {
|
||||||
static const String _deviceIdPref = 'device_uuid';
|
static const String _deviceIdPref = 'device_uuid';
|
||||||
static const _storage = FlutterSecureStorage();
|
static const _storage = FlutterSecureStorage();
|
||||||
|
static final RegExp _uuidV4 = RegExp(
|
||||||
|
r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
|
||||||
/// Get or create a persistent device UUID for server-side session tracking.
|
/// Get or create a persistent device UUID for server-side session tracking.
|
||||||
|
/// Corrupted or tampered storage values are replaced instead of being sent
|
||||||
|
/// to the public API as arbitrary query data.
|
||||||
static Future<String> getDeviceId() async {
|
static Future<String> getDeviceId() async {
|
||||||
final existing = await _storage.read(key: _deviceIdPref);
|
final existing = await _storage.read(key: _deviceIdPref);
|
||||||
if (existing != null) return existing;
|
if (existing != null && _uuidV4.hasMatch(existing)) return existing;
|
||||||
|
|
||||||
final newId = const Uuid().v4();
|
final newId = const Uuid().v4();
|
||||||
await _storage.write(key: _deviceIdPref, value: newId);
|
await _storage.write(key: _deviceIdPref, value: newId);
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
abstract final class HapticSettings {
|
||||||
|
HapticSettings._();
|
||||||
|
|
||||||
|
static const String _prefKey = 'haptic_feedback_enabled';
|
||||||
|
static const MethodChannel _channel =
|
||||||
|
MethodChannel('com.revoltvpn.app/haptics');
|
||||||
|
|
||||||
|
static bool _enabled = false;
|
||||||
|
static bool _initialized = false;
|
||||||
|
|
||||||
|
static bool get enabled => _enabled;
|
||||||
|
|
||||||
|
static Future<void> initialize() async {
|
||||||
|
if (_initialized) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_enabled = prefs.getBool(_prefKey) ?? false;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[Haptics] Failed to load preference: $e');
|
||||||
|
_enabled = false;
|
||||||
|
} finally {
|
||||||
|
_initialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setEnabled(bool value) async {
|
||||||
|
final previous = _enabled;
|
||||||
|
_enabled = value;
|
||||||
|
_initialized = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final saved = await prefs.setBool(_prefKey, value);
|
||||||
|
if (!saved) throw StateError('Could not save haptic setting');
|
||||||
|
} catch (e) {
|
||||||
|
_enabled = previous;
|
||||||
|
debugPrint('[Haptics] Failed to save preference: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confirmation haptic for a successful connect/disconnect. Best-effort: a
|
||||||
|
/// vibration failure must never block or fail the VPN action.
|
||||||
|
static void success() => unawaited(_impact());
|
||||||
|
|
||||||
|
static Future<void> _impact() async {
|
||||||
|
if (!_initialized) await initialize();
|
||||||
|
if (!_enabled || kIsWeb) return;
|
||||||
|
|
||||||
|
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||||
|
try {
|
||||||
|
final handled = await _channel.invokeMethod<bool>(
|
||||||
|
'impact',
|
||||||
|
<String, Object>{'kind': 'success'},
|
||||||
|
) ??
|
||||||
|
false;
|
||||||
|
if (handled) return;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[Haptics] Native vibration unavailable: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await HapticFeedback.mediumImpact();
|
||||||
|
}
|
||||||
|
}
|
||||||
+153
-54
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:revoltvpn/logic/app_config.dart';
|
import 'package:revoltvpn/logic/app_config.dart';
|
||||||
@@ -12,14 +13,21 @@ class HivemindService {
|
|||||||
|
|
||||||
static const _ua = 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 '
|
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';
|
'(KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36';
|
||||||
|
static const _pollBackoff = <Duration>[
|
||||||
|
Duration(seconds: 1),
|
||||||
|
Duration(seconds: 2),
|
||||||
|
Duration(seconds: 3),
|
||||||
|
Duration(seconds: 5),
|
||||||
|
];
|
||||||
|
|
||||||
static Future<http.Response> directGet(Uri uri, {Duration timeout = const Duration(seconds: 5)}) {
|
static Future<http.Response> directGet(
|
||||||
|
Uri uri, {
|
||||||
|
Duration timeout = const Duration(seconds: 5),
|
||||||
|
}) {
|
||||||
return http.get(uri, headers: {'User-Agent': _ua}).timeout(timeout);
|
return http.get(uri, headers: {'User-Agent': _ua}).timeout(timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void cancel() {
|
static void cancel() => _currentCallId++;
|
||||||
_currentCallId++;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void setExpectedNonce(String nonce) {
|
static void setExpectedNonce(String nonce) {
|
||||||
_expectedNonce = nonce;
|
_expectedNonce = nonce;
|
||||||
@@ -30,66 +38,34 @@ class HivemindService {
|
|||||||
bool skipAdBypass = false,
|
bool skipAdBypass = false,
|
||||||
}) async {
|
}) async {
|
||||||
final deviceId = await CryptoService.getDeviceId();
|
final deviceId = await CryptoService.getDeviceId();
|
||||||
|
|
||||||
final callId = ++_currentCallId;
|
final callId = ++_currentCallId;
|
||||||
final nonce = '${Random().nextInt(0x7FFFFFFF)}-${DateTime.now().millisecondsSinceEpoch}';
|
final nonce = _newNonce();
|
||||||
_expectedNonce = nonce;
|
_expectedNonce = nonce;
|
||||||
debugPrint('[HivemindService] Call #$callId — nonce: $nonce');
|
|
||||||
|
|
||||||
final url = _publicUrl('/session/status?device_id=$deviceId');
|
|
||||||
|
|
||||||
if (!skipAdBypass) {
|
if (!skipAdBypass) {
|
||||||
try {
|
await _runConfiguredBypass(deviceId, nonce);
|
||||||
final customData = jsonEncode({'device_id': deviceId, 'nonce': nonce});
|
|
||||||
final fakeUrl = _publicUrl(
|
|
||||||
'/admob/callback?signature=test&key_id=test&custom_data=${Uri.encodeComponent(customData)}');
|
|
||||||
await directGet(fakeUrl, timeout: const Duration(seconds: 8));
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxAttempts = 5;
|
const maxAttempts = 5;
|
||||||
for (int i = 1; i <= maxAttempts; i++) {
|
for (var attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
if (_currentCallId != callId) throw Exception('Cancelled');
|
_throwIfCancelled(callId);
|
||||||
|
onAttempt?.call(attempt, maxAttempts);
|
||||||
|
|
||||||
onAttempt?.call(i, maxAttempts);
|
|
||||||
try {
|
try {
|
||||||
final response = await directGet(url);
|
final session = await _fetchActiveSession(deviceId, nonce);
|
||||||
if (response.statusCode == 200) {
|
if (session != null) {
|
||||||
final data = jsonDecode(response.body);
|
_expectedNonce = null;
|
||||||
|
return session.toVlessUrl();
|
||||||
final serverNonce = data['nonce'] as String?;
|
|
||||||
if (_expectedNonce != null && serverNonce != null && serverNonce != _expectedNonce) {
|
|
||||||
debugPrint('[HivemindService] Nonce mismatch — retrying…');
|
|
||||||
} else if (data['active'] == true && data['vless_uuid'] != null) {
|
|
||||||
final vlessUuid = data['vless_uuid'];
|
|
||||||
final vlessIp = data['vless_ip'] ?? AppConfig.serverIp;
|
|
||||||
final vlessPort = data['vless_port'] ?? 443;
|
|
||||||
final pbk = data['reality_pbk'] ?? '';
|
|
||||||
final sid = data['reality_sid'] ?? '';
|
|
||||||
final sni = data['reality_sni'];
|
|
||||||
if (sni == null) throw Exception('Server did not provide reality_sni');
|
|
||||||
final fp = data['reality_fp'] ?? AppConfig.realityFp;
|
|
||||||
final xhttpPath = data['xhttp_path'] ?? AppConfig.vlessPath;
|
|
||||||
|
|
||||||
final vlessUrl = 'vless://$vlessUuid@$vlessIp:$vlessPort'
|
|
||||||
'?security=${AppConfig.vlessSecurity}'
|
|
||||||
'&type=${AppConfig.vlessType}'
|
|
||||||
'&path=$xhttpPath'
|
|
||||||
'&pbk=${Uri.encodeComponent(pbk)}'
|
|
||||||
'&sni=${Uri.encodeComponent(sni)}'
|
|
||||||
'&sid=${Uri.encodeComponent(sid)}'
|
|
||||||
'&fp=${Uri.encodeComponent(fp)}'
|
|
||||||
'#Revolt VPN';
|
|
||||||
_expectedNonce = null;
|
|
||||||
return vlessUrl;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.toString().contains('Cancelled')) rethrow;
|
if (_isCancelledError(e)) rethrow;
|
||||||
debugPrint('[HivemindService] Attempt $i failed: $e');
|
debugPrint('[Hivemind] session attempt $attempt failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (i < maxAttempts) await Future.delayed(const Duration(seconds: 1));
|
if (attempt < maxAttempts) {
|
||||||
|
await Future.delayed(_pollBackoff[attempt - 1]);
|
||||||
|
_throwIfCancelled(callId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw Exception('Session not activated. Server callback may have timed out.');
|
throw Exception('Session not activated. Server callback may have timed out.');
|
||||||
@@ -97,16 +73,139 @@ class HivemindService {
|
|||||||
|
|
||||||
static Future<bool> checkHealth() async {
|
static Future<bool> checkHealth() async {
|
||||||
try {
|
try {
|
||||||
final url = Uri.parse('${AppConfig.hivemindApiPublic}/health');
|
final response = await directGet(
|
||||||
final response = await directGet(url, timeout: const Duration(seconds: 3));
|
_publicUrl('/health'),
|
||||||
|
timeout: const Duration(seconds: 3),
|
||||||
|
);
|
||||||
return response.statusCode == 200;
|
return response.statusCode == 200;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── URL builders ──────────────────────────────────────────────────
|
static String _newNonce() {
|
||||||
|
final random = Random.secure();
|
||||||
|
final high = random.nextInt(0x7FFFFFFF);
|
||||||
|
final low = random.nextInt(0x7FFFFFFF);
|
||||||
|
return '$high-$low-${DateTime.now().microsecondsSinceEpoch}';
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _runConfiguredBypass(String deviceId, String nonce) async {
|
||||||
|
try {
|
||||||
|
final customData = jsonEncode({'device_id': deviceId, 'nonce': nonce});
|
||||||
|
final callback = _publicUrl(
|
||||||
|
'/admob/callback?signature=test&key_id=test&custom_data=${Uri.encodeComponent(customData)}',
|
||||||
|
);
|
||||||
|
await directGet(callback, timeout: const Duration(seconds: 8));
|
||||||
|
} catch (_) {
|
||||||
|
// Validation bypass behavior is intentionally best-effort in this build.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<_HivemindSessionConfig?> _fetchActiveSession(
|
||||||
|
String deviceId,
|
||||||
|
String nonce,
|
||||||
|
) async {
|
||||||
|
final statusUri = _publicUrl('/session/status').replace(
|
||||||
|
queryParameters: {'device_id': deviceId},
|
||||||
|
);
|
||||||
|
final response = await directGet(statusUri);
|
||||||
|
if (response.statusCode != 200) return null;
|
||||||
|
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is! Map<String, dynamic>) return null;
|
||||||
|
|
||||||
|
final serverNonce = decoded['nonce'];
|
||||||
|
if (_expectedNonce != null &&
|
||||||
|
serverNonce is String &&
|
||||||
|
serverNonce != nonce) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decoded['active'] != true || decoded['vless_uuid'] == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _HivemindSessionConfig.fromJson(decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _throwIfCancelled(int callId) {
|
||||||
|
if (_currentCallId != callId) throw Exception('Cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _isCancelledError(Object error) =>
|
||||||
|
error.toString().contains('Cancelled');
|
||||||
|
|
||||||
static Uri _publicUrl(String path) =>
|
static Uri _publicUrl(String path) =>
|
||||||
Uri.parse('${AppConfig.hivemindApiPublic}$path');
|
Uri.parse('${AppConfig.hivemindApiPublic}$path');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _HivemindSessionConfig {
|
||||||
|
final String uuid;
|
||||||
|
final String host;
|
||||||
|
final int port;
|
||||||
|
final String publicKey;
|
||||||
|
final String shortId;
|
||||||
|
final String sni;
|
||||||
|
final String fingerprint;
|
||||||
|
final String path;
|
||||||
|
|
||||||
|
const _HivemindSessionConfig({
|
||||||
|
required this.uuid,
|
||||||
|
required this.host,
|
||||||
|
required this.port,
|
||||||
|
required this.publicKey,
|
||||||
|
required this.shortId,
|
||||||
|
required this.sni,
|
||||||
|
required this.fingerprint,
|
||||||
|
required this.path,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory _HivemindSessionConfig.fromJson(Map<String, dynamic> 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');
|
||||||
|
|
||||||
|
return _HivemindSessionConfig(
|
||||||
|
uuid: uuid,
|
||||||
|
host: host,
|
||||||
|
port: port,
|
||||||
|
publicKey: _stringOr(json['reality_pbk'], ''),
|
||||||
|
shortId: _stringOr(json['reality_sid'], ''),
|
||||||
|
sni: sni,
|
||||||
|
fingerprint: _stringOr(json['reality_fp'], AppConfig.realityFp),
|
||||||
|
path: _stringOr(json['xhttp_path'], AppConfig.vlessPath),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _requiredString(Map<String, dynamic> json, String key) {
|
||||||
|
final value = json[key];
|
||||||
|
if (value is String && value.trim().isNotEmpty) return value.trim();
|
||||||
|
throw FormatException('Missing required session field: $key');
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _stringOr(Object? value, String fallback) {
|
||||||
|
return value is String && value.isNotEmpty ? value : 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');
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
class LocalSocksTestResult {
|
||||||
|
final bool ok;
|
||||||
|
final int? latencyMs;
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
const LocalSocksTestResult({
|
||||||
|
required this.ok,
|
||||||
|
required this.latencyMs,
|
||||||
|
required this.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract final class LocalSocksTester {
|
||||||
|
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.
|
||||||
|
static Future<LocalSocksTestResult> testListener({
|
||||||
|
String host = '127.0.0.1',
|
||||||
|
int port = 10807,
|
||||||
|
String? username,
|
||||||
|
String? password,
|
||||||
|
}) async {
|
||||||
|
final stopwatch = Stopwatch()..start();
|
||||||
|
Socket? socket;
|
||||||
|
_SocketReader? reader;
|
||||||
|
|
||||||
|
try {
|
||||||
|
socket = await Socket.connect(
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
timeout: const Duration(seconds: 1),
|
||||||
|
);
|
||||||
|
socket.setOption(SocketOption.tcpNoDelay, true);
|
||||||
|
reader = _SocketReader(socket);
|
||||||
|
|
||||||
|
final authenticated = await _authenticate(
|
||||||
|
reader,
|
||||||
|
socket,
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
timeout: const Duration(seconds: 1),
|
||||||
|
);
|
||||||
|
if (!authenticated) {
|
||||||
|
return const LocalSocksTestResult(
|
||||||
|
ok: false,
|
||||||
|
latencyMs: null,
|
||||||
|
message: 'Local SOCKS5 listener rejected authentication.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopwatch.stop();
|
||||||
|
return LocalSocksTestResult(
|
||||||
|
ok: true,
|
||||||
|
latencyMs: stopwatch.elapsedMilliseconds,
|
||||||
|
message: 'Local SOCKS5 listener is ready.',
|
||||||
|
);
|
||||||
|
} on TimeoutException {
|
||||||
|
return const LocalSocksTestResult(
|
||||||
|
ok: false,
|
||||||
|
latencyMs: null,
|
||||||
|
message: 'Local SOCKS5 listener timed out.',
|
||||||
|
);
|
||||||
|
} on SocketException {
|
||||||
|
return LocalSocksTestResult(
|
||||||
|
ok: false,
|
||||||
|
latencyMs: null,
|
||||||
|
message: 'Local SOCKS5 is not listening on $host:$port.',
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return const LocalSocksTestResult(
|
||||||
|
ok: false,
|
||||||
|
latencyMs: null,
|
||||||
|
message: 'Local SOCKS5 listener check failed.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await reader?.close();
|
||||||
|
await socket?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
static Future<LocalSocksTestResult> test({
|
||||||
|
String host = '127.0.0.1',
|
||||||
|
int port = 10807,
|
||||||
|
String? username,
|
||||||
|
String? password,
|
||||||
|
String targetHost = 'paladinvpn.duckdns.org',
|
||||||
|
int targetPort = 443,
|
||||||
|
}) async {
|
||||||
|
final stopwatch = Stopwatch()..start();
|
||||||
|
Socket? socket;
|
||||||
|
_SocketReader? reader;
|
||||||
|
|
||||||
|
try {
|
||||||
|
socket = await Socket.connect(
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
timeout: const Duration(seconds: 3),
|
||||||
|
);
|
||||||
|
socket.setOption(SocketOption.tcpNoDelay, true);
|
||||||
|
reader = _SocketReader(socket);
|
||||||
|
|
||||||
|
final authenticated = await _authenticate(
|
||||||
|
reader,
|
||||||
|
socket,
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
if (!authenticated) {
|
||||||
|
return const LocalSocksTestResult(
|
||||||
|
ok: false,
|
||||||
|
latencyMs: null,
|
||||||
|
message: 'Local SOCKS5 listener rejected authentication.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final hostBytes = utf8.encode(targetHost);
|
||||||
|
if (hostBytes.isEmpty || hostBytes.length > 255) {
|
||||||
|
return const LocalSocksTestResult(
|
||||||
|
ok: false,
|
||||||
|
latencyMs: null,
|
||||||
|
message: 'SOCKS5 test target is invalid.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.add(<int>[
|
||||||
|
0x05,
|
||||||
|
0x01,
|
||||||
|
0x00,
|
||||||
|
0x03,
|
||||||
|
hostBytes.length,
|
||||||
|
...hostBytes,
|
||||||
|
(targetPort >> 8) & 0xff,
|
||||||
|
targetPort & 0xff,
|
||||||
|
]);
|
||||||
|
await socket.flush();
|
||||||
|
|
||||||
|
final replyHead = await reader.readExactly(4);
|
||||||
|
if (replyHead[0] != 0x05 || replyHead[1] != 0x00) {
|
||||||
|
return const LocalSocksTestResult(
|
||||||
|
ok: false,
|
||||||
|
latencyMs: null,
|
||||||
|
message:
|
||||||
|
'SOCKS5 is listening, but the ReVolt route could not reach the test target.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _consumeAddress(reader, replyHead[3]);
|
||||||
|
stopwatch.stop();
|
||||||
|
return LocalSocksTestResult(
|
||||||
|
ok: true,
|
||||||
|
latencyMs: stopwatch.elapsedMilliseconds,
|
||||||
|
message: 'Local SOCKS5 and the ReVolt outbound are reachable.',
|
||||||
|
);
|
||||||
|
} on TimeoutException {
|
||||||
|
return const LocalSocksTestResult(
|
||||||
|
ok: false,
|
||||||
|
latencyMs: null,
|
||||||
|
message: 'Local SOCKS5 test timed out.',
|
||||||
|
);
|
||||||
|
} on SocketException {
|
||||||
|
return LocalSocksTestResult(
|
||||||
|
ok: false,
|
||||||
|
latencyMs: null,
|
||||||
|
message: 'Local SOCKS5 is not listening on $host:$port.',
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return const LocalSocksTestResult(
|
||||||
|
ok: false,
|
||||||
|
latencyMs: null,
|
||||||
|
message: 'Local SOCKS5 test failed.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await reader?.close();
|
||||||
|
await socket?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> _authenticate(
|
||||||
|
_SocketReader reader,
|
||||||
|
Socket socket, {
|
||||||
|
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 <int>[0x05, 0x01, 0x02]
|
||||||
|
: const <int>[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 ||
|
||||||
|
passBytes.isEmpty ||
|
||||||
|
userBytes.length > 255 ||
|
||||||
|
passBytes.length > 255) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.add(<int>[
|
||||||
|
0x01,
|
||||||
|
userBytes.length,
|
||||||
|
...userBytes,
|
||||||
|
passBytes.length,
|
||||||
|
...passBytes,
|
||||||
|
]);
|
||||||
|
await socket.flush();
|
||||||
|
|
||||||
|
final authReply = await reader.readExactly(2, timeout: timeout);
|
||||||
|
return authReply[0] == 0x01 && authReply[1] == 0x00;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _consumeAddress(
|
||||||
|
_SocketReader reader, int addressType) async {
|
||||||
|
if (addressType == 0x01) {
|
||||||
|
await reader.readExactly(4 + 2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (addressType == 0x04) {
|
||||||
|
await reader.readExactly(16 + 2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (addressType == 0x03) {
|
||||||
|
final length = (await reader.readExactly(1)).first;
|
||||||
|
await reader.readExactly(length + 2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw const FormatException('Unsupported SOCKS5 address type');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SocketReader {
|
||||||
|
final StreamIterator<Uint8List> _iterator;
|
||||||
|
final List<int> _buffer = <int>[];
|
||||||
|
|
||||||
|
_SocketReader(Socket socket) : _iterator = StreamIterator<Uint8List>(socket);
|
||||||
|
|
||||||
|
Future<List<int>> readExactly(
|
||||||
|
int count, {
|
||||||
|
Duration timeout = const Duration(seconds: 4),
|
||||||
|
}) async {
|
||||||
|
while (_buffer.length < count) {
|
||||||
|
final hasNext = await _iterator.moveNext().timeout(timeout);
|
||||||
|
if (!hasNext) {
|
||||||
|
throw const SocketException('Socket closed early');
|
||||||
|
}
|
||||||
|
_buffer.addAll(_iterator.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = _buffer.sublist(0, count);
|
||||||
|
_buffer.removeRange(0, count);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> close() => _iterator.cancel();
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
class NetworkSnapshot {
|
||||||
|
final String reason;
|
||||||
|
final String transport;
|
||||||
|
final bool connected;
|
||||||
|
final bool validated;
|
||||||
|
final bool metered;
|
||||||
|
final int timestamp;
|
||||||
|
|
||||||
|
const NetworkSnapshot({
|
||||||
|
required this.reason,
|
||||||
|
required this.transport,
|
||||||
|
required this.connected,
|
||||||
|
required this.validated,
|
||||||
|
required this.metered,
|
||||||
|
required this.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory NetworkSnapshot.fromMap(Map<dynamic, dynamic> map) {
|
||||||
|
return NetworkSnapshot(
|
||||||
|
reason: map['reason'] as String? ?? 'changed',
|
||||||
|
transport: map['transport'] as String? ?? 'unknown',
|
||||||
|
connected: map['connected'] == true,
|
||||||
|
validated: map['validated'] == true,
|
||||||
|
metered: map['metered'] == true,
|
||||||
|
timestamp: (map['timestamp'] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract final class NetworkMonitor {
|
||||||
|
NetworkMonitor._();
|
||||||
|
|
||||||
|
static const EventChannel _channel = EventChannel('com.revoltvpn.app/network');
|
||||||
|
|
||||||
|
static Stream<NetworkSnapshot> get changes {
|
||||||
|
return _channel.receiveBroadcastStream().where((event) => event is Map).map(
|
||||||
|
(event) => NetworkSnapshot.fromMap(event as Map<dynamic, dynamic>),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
class SecureSocksSession {
|
||||||
|
static const String inboundTag = 'revolt-secure-socks';
|
||||||
|
static final Random _random = Random.secure();
|
||||||
|
|
||||||
|
final int port;
|
||||||
|
final String username;
|
||||||
|
final String password;
|
||||||
|
final String configJson;
|
||||||
|
|
||||||
|
const SecureSocksSession._({
|
||||||
|
required this.port,
|
||||||
|
required this.username,
|
||||||
|
required this.password,
|
||||||
|
required this.configJson,
|
||||||
|
});
|
||||||
|
|
||||||
|
static Future<SecureSocksSession> create(String baseConfig) async {
|
||||||
|
final decoded = jsonDecode(baseConfig);
|
||||||
|
if (decoded is! Map<String, dynamic>) {
|
||||||
|
throw const FormatException('VLESS configuration must be a JSON object.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final reservation = await ServerSocket.bind(
|
||||||
|
InternetAddress.loopbackIPv4,
|
||||||
|
0,
|
||||||
|
shared: false,
|
||||||
|
);
|
||||||
|
final port = reservation.port;
|
||||||
|
await reservation.close();
|
||||||
|
|
||||||
|
if (port <= 1024 || port > 65535) {
|
||||||
|
throw StateError('Could not allocate a safe local SOCKS5 port.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final username = _token(16);
|
||||||
|
final password = _token(32);
|
||||||
|
|
||||||
|
final sourceInbounds = decoded['inbounds'];
|
||||||
|
final inbounds = <dynamic>[];
|
||||||
|
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(<String, dynamic>{
|
||||||
|
'tag': inboundTag,
|
||||||
|
'port': port,
|
||||||
|
'listen': '127.0.0.1',
|
||||||
|
'protocol': 'socks',
|
||||||
|
'settings': <String, dynamic>{
|
||||||
|
'auth': 'password',
|
||||||
|
'udp': true,
|
||||||
|
'ip': '127.0.0.1',
|
||||||
|
'users': <Map<String, String>>[
|
||||||
|
<String, String>{'user': username, 'pass': password},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'sniffing': <String, dynamic>{
|
||||||
|
'enabled': true,
|
||||||
|
'destOverride': <String>['http', 'tls'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
decoded['inbounds'] = inbounds;
|
||||||
|
|
||||||
|
return SecureSocksSession._(
|
||||||
|
port: port,
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
configJson: jsonEncode(decoded),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _token(int byteCount) {
|
||||||
|
final bytes = List<int>.generate(byteCount, (_) => _random.nextInt(256));
|
||||||
|
return bytes.map((value) => value.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
}
|
||||||
|
}
|
||||||
+204
-81
@@ -1,11 +1,13 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:revoltvpn/logic/hivemind_service.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:revoltvpn/logic/app_config.dart';
|
import 'package:revoltvpn/logic/app_config.dart';
|
||||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
|
||||||
import 'package:revoltvpn/logic/crypto_service.dart';
|
import 'package:revoltvpn/logic/crypto_service.dart';
|
||||||
|
import 'package:revoltvpn/logic/hivemind_service.dart';
|
||||||
import 'package:revoltvpn/logic/notification_service.dart';
|
import 'package:revoltvpn/logic/notification_service.dart';
|
||||||
|
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||||
|
|
||||||
class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
@@ -15,12 +17,20 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
final VpnConnection vpnConnection;
|
final VpnConnection vpnConnection;
|
||||||
|
|
||||||
int _remainingSeconds = 0;
|
int _remainingSeconds = 0;
|
||||||
int _usedBytes = 0;
|
int _remainingAtLastSync = 0;
|
||||||
|
int _usedBytes = 0;
|
||||||
|
|
||||||
bool _hasSyncedOnce = false;
|
bool _hasSyncedOnce = false;
|
||||||
int _consecutiveFailures = 0;
|
int _consecutiveFailures = 0;
|
||||||
bool _isDisconnecting = false;
|
bool _isDisconnecting = false;
|
||||||
bool _syncInProgress = false;
|
bool _syncInProgress = false;
|
||||||
|
bool _supportRewardClaimed = false;
|
||||||
|
bool _supportRewardStateLoaded = false;
|
||||||
|
int _supportStateEpoch = 0;
|
||||||
|
|
||||||
|
static const String _supportRewardClaimKey =
|
||||||
|
'support_reward_claimed_active_session';
|
||||||
|
static const FlutterSecureStorage _supportStorage = FlutterSecureStorage();
|
||||||
|
|
||||||
static const int _maxConsecutiveFailures = 3;
|
static const int _maxConsecutiveFailures = 3;
|
||||||
static const int _maxOfflineSeconds = 120;
|
static const int _maxOfflineSeconds = 120;
|
||||||
@@ -29,21 +39,24 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
static const int _pollIntervalSeconds = 5;
|
static const int _pollIntervalSeconds = 5;
|
||||||
static const int _backgroundPollIntervalSeconds = 30;
|
static const int _backgroundPollIntervalSeconds = 30;
|
||||||
|
|
||||||
int _lastUsedBytes = 0;
|
int _lastUsedBytes = 0;
|
||||||
double _currentSpeedKBps = 0.0;
|
DateTime? _lastSuccessfulSyncAt;
|
||||||
DateTime? _lastSyncAt;
|
double _currentSpeedKBps = 0.0;
|
||||||
|
|
||||||
SessionTimer({required this.vpnConnection}) {
|
SessionTimer({required this.vpnConnection}) {
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
vpnConnection.addListener(_onVpnConnectionChanged);
|
vpnConnection.addListener(_onVpnConnectionChanged);
|
||||||
|
unawaited(_loadSupportRewardState());
|
||||||
}
|
}
|
||||||
|
|
||||||
int get remaining => _remainingSeconds;
|
int get remaining => _remainingSeconds;
|
||||||
bool get isRunning => _timer != null && _timer!.isActive;
|
bool get isRunning => _timer != null && _timer!.isActive;
|
||||||
bool get isExpired => _remainingSeconds <= 0 && !isRunning;
|
bool get isExpired => _remainingSeconds <= 0 && !isRunning;
|
||||||
bool get hasSyncedOnce => _hasSyncedOnce;
|
bool get hasSyncedOnce => _hasSyncedOnce;
|
||||||
|
bool get supportRewardClaimed => _supportRewardClaimed;
|
||||||
|
bool get supportRewardStateLoaded => _supportRewardStateLoaded;
|
||||||
|
|
||||||
int get usedBytes => _usedBytes;
|
int get usedBytes => _usedBytes;
|
||||||
double get currentSpeedKBps => _currentSpeedKBps;
|
double get currentSpeedKBps => _currentSpeedKBps;
|
||||||
|
|
||||||
String get formatted {
|
String get formatted {
|
||||||
@@ -53,8 +66,42 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
return '$h:$m:$s';
|
return '$h:$m:$s';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSupportRewardState() async {
|
||||||
|
final epoch = _supportStateEpoch;
|
||||||
|
var claimed = false;
|
||||||
|
try {
|
||||||
|
claimed =
|
||||||
|
(await _supportStorage.read(key: _supportRewardClaimKey)) == '1';
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[Timer] Failed to load support reward state: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (epoch != _supportStateEpoch) return;
|
||||||
|
_supportRewardClaimed = claimed;
|
||||||
|
_supportRewardStateLoaded = true;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistSupportRewardState(bool value) async {
|
||||||
|
try {
|
||||||
|
await _supportStorage.write(
|
||||||
|
key: _supportRewardClaimKey,
|
||||||
|
value: value ? '1' : '0',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[Timer] Failed to persist support reward state: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> markSupportRewardClaimed() async {
|
||||||
|
if (_supportRewardClaimed) return;
|
||||||
|
_supportRewardClaimed = true;
|
||||||
|
_supportRewardStateLoaded = true;
|
||||||
|
notifyListeners();
|
||||||
|
await _persistSupportRewardState(true);
|
||||||
|
}
|
||||||
|
|
||||||
void _onVpnConnectionChanged() {
|
void _onVpnConnectionChanged() {
|
||||||
// Restore timer if VPN was already running at app launch.
|
|
||||||
if (vpnConnection.status == VpnStatus.connected &&
|
if (vpnConnection.status == VpnStatus.connected &&
|
||||||
!isRunning &&
|
!isRunning &&
|
||||||
vpnConnection.isStartupRestoration) {
|
vpnConnection.isStartupRestoration) {
|
||||||
@@ -62,59 +109,90 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resume after brief tunnel disconnection.
|
|
||||||
if (vpnConnection.status == VpnStatus.connected &&
|
if (vpnConnection.status == VpnStatus.connected &&
|
||||||
!isRunning &&
|
!isRunning &&
|
||||||
!_isDisconnecting &&
|
!_isDisconnecting &&
|
||||||
_hasSyncedOnce) {
|
_hasSyncedOnce) {
|
||||||
debugPrint('[Timer] VPN reconnected after blip — resuming.');
|
debugPrint('[Timer] VPN reconnected after blip - resuming.');
|
||||||
_resumeTicking();
|
_resumeTicking();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (vpnConnection.status == VpnStatus.disconnected && !_isDisconnecting) {
|
final hadActiveSession = isRunning || _hasSyncedOnce;
|
||||||
_doDisconnect('VPN tunnel dropped');
|
if (!hadActiveSession || _isDisconnecting) return;
|
||||||
|
|
||||||
|
if (vpnConnection.status == VpnStatus.error) {
|
||||||
|
_stopForVpnFailure('VPN entered error state');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vpnConnection.status == VpnStatus.disconnected) {
|
||||||
|
unawaited(_doDisconnect('VPN tunnel dropped'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> start() async {
|
void _stopForVpnFailure(String reason) {
|
||||||
_remainingSeconds = 0;
|
if (_isDisconnecting) return;
|
||||||
_usedBytes = 0;
|
_isDisconnecting = true;
|
||||||
_lastUsedBytes = 0;
|
debugPrint('[Timer] Stopping after VPN failure: $reason');
|
||||||
_currentSpeedKBps = 0.0;
|
|
||||||
_lastSyncAt = null;
|
_timer?.cancel();
|
||||||
NotificationService.reset();
|
_timer = null;
|
||||||
_tickCount = 0;
|
_currentSpeedKBps = 0.0;
|
||||||
_hasSyncedOnce = false;
|
_remainingSeconds = 0;
|
||||||
|
_remainingAtLastSync = 0;
|
||||||
|
_hasSyncedOnce = false;
|
||||||
|
_lastSuccessfulSyncAt = null;
|
||||||
_consecutiveFailures = 0;
|
_consecutiveFailures = 0;
|
||||||
_offlineSeconds = 0;
|
_offlineSeconds = 0;
|
||||||
_isDisconnecting = false;
|
NotificationService.reset();
|
||||||
_appBackgrounded = false;
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> start() async {
|
||||||
|
_remainingSeconds = 0;
|
||||||
|
_remainingAtLastSync = 0;
|
||||||
|
_usedBytes = 0;
|
||||||
|
_lastUsedBytes = 0;
|
||||||
|
_lastSuccessfulSyncAt = null;
|
||||||
|
_currentSpeedKBps = 0.0;
|
||||||
|
_tickCount = 0;
|
||||||
|
_hasSyncedOnce = false;
|
||||||
|
_consecutiveFailures = 0;
|
||||||
|
_offlineSeconds = 0;
|
||||||
|
_isDisconnecting = false;
|
||||||
|
_appBackgrounded = false;
|
||||||
|
NotificationService.reset();
|
||||||
|
|
||||||
|
_supportStateEpoch++;
|
||||||
|
_supportRewardClaimed = false;
|
||||||
|
_supportRewardStateLoaded = true;
|
||||||
|
unawaited(_persistSupportRewardState(false));
|
||||||
|
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_syncWithHivemind();
|
unawaited(_syncWithHivemind());
|
||||||
}
|
}
|
||||||
|
|
||||||
void _tick(Timer t) {
|
void _tick(Timer t) {
|
||||||
if (_isDisconnecting) return;
|
if (_isDisconnecting) return;
|
||||||
// A local clock: poll failures are about the network, not the clock.
|
|
||||||
if (_hasSyncedOnce && _remainingSeconds > 0) {
|
if (_hasSyncedOnce && _consecutiveFailures < _maxConsecutiveFailures) {
|
||||||
_remainingSeconds--;
|
_reconcileElapsedTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_consecutiveFailures >= _maxConsecutiveFailures) {
|
if (_consecutiveFailures >= _maxConsecutiveFailures) {
|
||||||
_offlineSeconds++;
|
_offlineSeconds++;
|
||||||
if (_offlineSeconds >= _maxOfflineSeconds) {
|
if (_offlineSeconds >= _maxOfflineSeconds) {
|
||||||
_doDisconnect('Server unreachable');
|
unawaited(_doDisconnect('Server unreachable'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_hasSyncedOnce && _remainingSeconds <= 0) {
|
if (_hasSyncedOnce && _remainingSeconds <= 0) {
|
||||||
_doDisconnect('Session expired');
|
unawaited(_doDisconnect('Session expired'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,16 +201,12 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
? _backgroundPollIntervalSeconds
|
? _backgroundPollIntervalSeconds
|
||||||
: _pollIntervalSeconds;
|
: _pollIntervalSeconds;
|
||||||
if (_tickCount % pollInterval == 0) {
|
if (_tickCount % pollInterval == 0) {
|
||||||
_syncWithHivemind();
|
unawaited(_syncWithHivemind());
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
// Gated on a live tunnel. The plugin cancels the notification when the
|
if (_hasSyncedOnce && vpnConnection.status == VpnStatus.connected) {
|
||||||
// service stops, and re-posting after that leaves an ongoing notification
|
|
||||||
// the user cannot swipe away below Android 14.
|
|
||||||
if (_hasSyncedOnce &&
|
|
||||||
vpnConnection.status == VpnStatus.connected) {
|
|
||||||
NotificationService.updateTimer(formatted);
|
NotificationService.updateTimer(formatted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,8 +224,9 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
_timer = null;
|
_timer = null;
|
||||||
_currentSpeedKBps = 0.0;
|
_currentSpeedKBps = 0.0;
|
||||||
_remainingSeconds = 0;
|
_remainingSeconds = 0;
|
||||||
|
_remainingAtLastSync = 0;
|
||||||
_hasSyncedOnce = false;
|
_hasSyncedOnce = false;
|
||||||
_lastSyncAt = null;
|
_lastSuccessfulSyncAt = null;
|
||||||
NotificationService.reset();
|
NotificationService.reset();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
@@ -162,45 +237,72 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _readNonNegativeInt(dynamic value, int fallback) {
|
||||||
|
if (value is int) return value >= 0 ? value : fallback;
|
||||||
|
if (value is num && value >= 0) return value.toInt();
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _syncWithHivemind() async {
|
Future<void> _syncWithHivemind() async {
|
||||||
if (_isDisconnecting || _syncInProgress) return;
|
if (_isDisconnecting || _syncInProgress) return;
|
||||||
_syncInProgress = true;
|
_syncInProgress = true;
|
||||||
try {
|
try {
|
||||||
final deviceId = await CryptoService.getDeviceId();
|
final deviceId = await CryptoService.getDeviceId();
|
||||||
final url = Uri.parse(
|
final base = Uri.parse('${AppConfig.hivemindApiPublic}/session/status');
|
||||||
'${AppConfig.hivemindApiPublic}/session/status?device_id=$deviceId');
|
final url = base.replace(queryParameters: {'device_id': deviceId});
|
||||||
final response = await HivemindService.directGet(url);
|
final response = await HivemindService.directGet(url);
|
||||||
|
if (_isDisconnecting) return;
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final data = jsonDecode(response.body);
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is! Map<String, dynamic>) {
|
||||||
|
debugPrint('[Timer] Invalid session payload type.');
|
||||||
|
_markSyncFailure();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final data = decoded;
|
||||||
|
|
||||||
final bool active = data['active'] ?? false;
|
final activeValue = data['active'];
|
||||||
if (!active) {
|
if (activeValue is! bool) {
|
||||||
|
debugPrint('[Timer] Session payload missing boolean active state.');
|
||||||
|
_markSyncFailure();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!activeValue) {
|
||||||
await _doDisconnect('Server ended session');
|
await _doDisconnect('Server ended session');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_remainingSeconds = data['expires_in_seconds'] ?? _remainingSeconds;
|
final expiresValue = data['expires_in_seconds'];
|
||||||
_usedBytes = data['used_bytes'] ?? _usedBytes;
|
if (expiresValue is! num ||
|
||||||
|
!expiresValue.isFinite ||
|
||||||
|
expiresValue < 0) {
|
||||||
|
debugPrint('[Timer] Session payload has invalid expiry.');
|
||||||
|
_markSyncFailure();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Measure the real gap between polls: the interval is 5 s in the
|
_remainingAtLastSync = expiresValue.toInt();
|
||||||
// foreground, 30 s in the background, and arbitrary on a resume sync.
|
_remainingSeconds = _remainingAtLastSync;
|
||||||
final now = DateTime.now();
|
_usedBytes = _readNonNegativeInt(data['used_bytes'], _usedBytes);
|
||||||
final lastSync = _lastSyncAt;
|
|
||||||
_lastSyncAt = now;
|
|
||||||
|
|
||||||
final int deltaBytes = _usedBytes - _lastUsedBytes;
|
final now = DateTime.now();
|
||||||
if (_hasSyncedOnce && deltaBytes > 0 && lastSync != null) {
|
final previousSyncAt = _lastSuccessfulSyncAt;
|
||||||
final elapsed = now.difference(lastSync).inMilliseconds / 1000.0;
|
final deltaBytes = _usedBytes - _lastUsedBytes;
|
||||||
_currentSpeedKBps =
|
if (_hasSyncedOnce && previousSyncAt != null) {
|
||||||
elapsed > 0 ? (deltaBytes / elapsed) / 1000 : _currentSpeedKBps;
|
final elapsedMs = now.difference(previousSyncAt).inMilliseconds;
|
||||||
} else if (!_hasSyncedOnce) {
|
if (deltaBytes > 0 && elapsedMs > 0) {
|
||||||
|
_currentSpeedKBps = (deltaBytes / (elapsedMs / 1000.0)) / 1000.0;
|
||||||
|
} else {
|
||||||
|
_currentSpeedKBps = 0.0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
_currentSpeedKBps = 0.0;
|
_currentSpeedKBps = 0.0;
|
||||||
}
|
}
|
||||||
_lastUsedBytes = _usedBytes;
|
_lastUsedBytes = _usedBytes;
|
||||||
|
_lastSuccessfulSyncAt = now;
|
||||||
|
|
||||||
final capExhausted = data['cap_exhausted'] ?? false;
|
if (data['cap_exhausted'] == true) {
|
||||||
if (capExhausted) {
|
|
||||||
await _doDisconnect('Data cap reached');
|
await _doDisconnect('Data cap reached');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -213,8 +315,10 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
_markSyncFailure();
|
_markSyncFailure();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Hivemind sync error: $e');
|
if (!_isDisconnecting) {
|
||||||
_markSyncFailure();
|
debugPrint('Hivemind sync error: $e');
|
||||||
|
_markSyncFailure();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
_syncInProgress = false;
|
_syncInProgress = false;
|
||||||
}
|
}
|
||||||
@@ -227,27 +331,46 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _resumeTicking() {
|
void _reconcileElapsedTime() {
|
||||||
_lastSyncAt = null;
|
final syncedAt = _lastSuccessfulSyncAt;
|
||||||
_timer?.cancel();
|
if (!_hasSyncedOnce || syncedAt == null) return;
|
||||||
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
|
||||||
_isDisconnecting = false;
|
final elapsed = DateTime.now().difference(syncedAt).inSeconds;
|
||||||
_syncWithHivemind();
|
_remainingSeconds =
|
||||||
notifyListeners();
|
(_remainingAtLastSync - elapsed).clamp(0, 1 << 31).toInt();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
if (state == AppLifecycleState.paused ||
|
if (state == AppLifecycleState.paused ||
|
||||||
state == AppLifecycleState.detached) {
|
state == AppLifecycleState.detached ||
|
||||||
// Keep ticking, but slow the poll — the 5 s one wakes the radio.
|
state == AppLifecycleState.inactive ||
|
||||||
|
state == AppLifecycleState.hidden) {
|
||||||
_appBackgrounded = true;
|
_appBackgrounded = true;
|
||||||
} else if (state == AppLifecycleState.resumed) {
|
return;
|
||||||
_appBackgrounded = false;
|
|
||||||
if (_timer != null && _timer!.isActive) {
|
|
||||||
_syncWithHivemind();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state != AppLifecycleState.resumed || _isDisconnecting) return;
|
||||||
|
_appBackgrounded = false;
|
||||||
|
if (vpnConnection.status != VpnStatus.connected) return;
|
||||||
|
|
||||||
|
_reconcileElapsedTime();
|
||||||
|
if (!isRunning) {
|
||||||
|
_resumeTicking();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unawaited(_syncWithHivemind());
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _resumeTicking() {
|
||||||
|
_reconcileElapsedTime();
|
||||||
|
_timer?.cancel();
|
||||||
|
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
||||||
|
_isDisconnecting = false;
|
||||||
|
unawaited(_syncWithHivemind());
|
||||||
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
+160
-50
@@ -13,6 +13,78 @@ enum UpdateStatus { upToDate, updateAvailable, checkFailed }
|
|||||||
|
|
||||||
enum InstallSource { playStore, sideload }
|
enum InstallSource { playStore, sideload }
|
||||||
|
|
||||||
|
Uri? _trustedHttpsUri(String raw, Set<String> allowedHosts) {
|
||||||
|
final uri = Uri.tryParse(raw);
|
||||||
|
if (uri == null || uri.scheme != 'https' || !allowedHosts.contains(uri.host)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ParsedVersion {
|
||||||
|
final List<int> core;
|
||||||
|
final List<String> preRelease;
|
||||||
|
|
||||||
|
const _ParsedVersion(this.core, this.preRelease);
|
||||||
|
|
||||||
|
static _ParsedVersion? tryParse(String input) {
|
||||||
|
var value = input.trim();
|
||||||
|
if (value.startsWith('v') || value.startsWith('V')) {
|
||||||
|
value = value.substring(1);
|
||||||
|
}
|
||||||
|
value = value.split('+').first;
|
||||||
|
final dash = value.indexOf('-');
|
||||||
|
final corePart = dash < 0 ? value : value.substring(0, dash);
|
||||||
|
final prePart = dash < 0 ? '' : value.substring(dash + 1);
|
||||||
|
final rawCore = corePart.split('.');
|
||||||
|
if (rawCore.isEmpty || rawCore.any((p) => int.tryParse(p) == null)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final core = rawCore.map(int.parse).toList(growable: false);
|
||||||
|
while (core.length < 3) {
|
||||||
|
core.add(0);
|
||||||
|
}
|
||||||
|
final pre = prePart.isEmpty
|
||||||
|
? const <String>[]
|
||||||
|
: prePart.split('.').where((e) => e.isNotEmpty).toList(growable: false);
|
||||||
|
return _ParsedVersion(core, pre);
|
||||||
|
}
|
||||||
|
|
||||||
|
int compareTo(_ParsedVersion other) {
|
||||||
|
final length = core.length > other.core.length ? core.length : other.core.length;
|
||||||
|
for (var i = 0; i < length; i++) {
|
||||||
|
final left = i < core.length ? core[i] : 0;
|
||||||
|
final right = i < other.core.length ? other.core[i] : 0;
|
||||||
|
if (left != right) return left.compareTo(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preRelease.isEmpty && other.preRelease.isEmpty) return 0;
|
||||||
|
if (preRelease.isEmpty) return 1;
|
||||||
|
if (other.preRelease.isEmpty) return -1;
|
||||||
|
|
||||||
|
final preLength = preRelease.length > other.preRelease.length
|
||||||
|
? preRelease.length
|
||||||
|
: other.preRelease.length;
|
||||||
|
for (var i = 0; i < preLength; i++) {
|
||||||
|
if (i >= preRelease.length) return -1;
|
||||||
|
if (i >= other.preRelease.length) return 1;
|
||||||
|
final left = preRelease[i];
|
||||||
|
final right = other.preRelease[i];
|
||||||
|
final leftNum = int.tryParse(left);
|
||||||
|
final rightNum = int.tryParse(right);
|
||||||
|
if (leftNum != null && rightNum != null) {
|
||||||
|
if (leftNum != rightNum) return leftNum.compareTo(rightNum);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (leftNum != null) return -1;
|
||||||
|
if (rightNum != null) return 1;
|
||||||
|
final lexical = left.compareTo(right);
|
||||||
|
if (lexical != 0) return lexical;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class PlayStoreUpdater {
|
class PlayStoreUpdater {
|
||||||
PlayStoreUpdater._();
|
PlayStoreUpdater._();
|
||||||
|
|
||||||
@@ -20,9 +92,13 @@ class PlayStoreUpdater {
|
|||||||
'https://play.google.com/store/apps/details?id=${AppConfig.applicationId}';
|
'https://play.google.com/store/apps/details?id=${AppConfig.applicationId}';
|
||||||
|
|
||||||
static Future<bool> open() async {
|
static Future<bool> open() async {
|
||||||
|
final uri = _trustedHttpsUri(_url, const {'play.google.com'});
|
||||||
|
if (uri == null) {
|
||||||
|
debugPrint('[Updater] Refusing invalid Play Store URL.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return await launchUrl(Uri.parse(_url),
|
return await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
mode: LaunchMode.externalApplication);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[Updater] Failed to open Play Store: $e');
|
debugPrint('[Updater] Failed to open Play Store: $e');
|
||||||
return false;
|
return false;
|
||||||
@@ -36,13 +112,14 @@ class GitHubUpdater {
|
|||||||
static String? _cachedDownloadUrl;
|
static String? _cachedDownloadUrl;
|
||||||
|
|
||||||
static Future<String?> fetchLatestVersion() async {
|
static Future<String?> fetchLatestVersion() async {
|
||||||
final url =
|
final apiUri = Uri.https(
|
||||||
'https://api.github.com/repos/${AppConfig.githubOwner}/${AppConfig.githubRepo}/releases/latest';
|
'api.github.com',
|
||||||
|
'/repos/${AppConfig.githubOwner}/${AppConfig.githubRepo}/releases/latest',
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await http
|
final response = await http
|
||||||
.get(Uri.parse(url),
|
.get(apiUri, headers: {'Accept': 'application/vnd.github+json'})
|
||||||
headers: {'Accept': 'application/vnd.github+json'})
|
|
||||||
.timeout(const Duration(seconds: 8));
|
.timeout(const Duration(seconds: 8));
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
@@ -50,38 +127,48 @@ class GitHubUpdater {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
final decoded = jsonDecode(response.body);
|
||||||
_cachedDownloadUrl = data['html_url'] as String?;
|
if (decoded is! Map<String, dynamic>) {
|
||||||
|
debugPrint('[Updater] Invalid GitHub release payload.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
final tag = data['tag_name'] as String? ?? '0.0.0';
|
final htmlUrl = decoded['html_url'];
|
||||||
return tag.startsWith('v') ? tag.substring(1) : tag;
|
if (htmlUrl is String &&
|
||||||
|
_trustedHttpsUri(htmlUrl, const {'github.com'}) != null) {
|
||||||
|
_cachedDownloadUrl = htmlUrl;
|
||||||
|
} else {
|
||||||
|
_cachedDownloadUrl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final rawTag = decoded['tag_name'];
|
||||||
|
if (rawTag is! String || rawTag.isEmpty || rawTag.length > 64) {
|
||||||
|
debugPrint('[Updater] Invalid release tag.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final normalized = rawTag.startsWith('v') ? rawTag.substring(1) : rawTag;
|
||||||
|
if (_ParsedVersion.tryParse(normalized) == null) {
|
||||||
|
debugPrint('[Updater] Release tag is not a supported version: $rawTag');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[Updater] GitHub API error: $e');
|
debugPrint('[Updater] GitHub API error: $e');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool _isTrustedReleaseUrl(String url) {
|
static Future<bool> open() async {
|
||||||
final uri = Uri.tryParse(url);
|
final raw = _cachedDownloadUrl ?? AppConfig.githubReleasesUrl;
|
||||||
if (uri == null || uri.scheme != 'https' || uri.host != 'github.com') {
|
final uri = _trustedHttpsUri(raw, const {'github.com'});
|
||||||
|
if (uri == null) {
|
||||||
|
debugPrint('[Updater] Refusing untrusted GitHub update URL.');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Host alone is not enough: anyone can host a release on github.com.
|
|
||||||
return uri.path
|
|
||||||
.startsWith('/${AppConfig.githubOwner}/${AppConfig.githubRepo}/');
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<bool> open() async {
|
|
||||||
final cached = _cachedDownloadUrl;
|
|
||||||
final url = (cached != null && _isTrustedReleaseUrl(cached))
|
|
||||||
? cached
|
|
||||||
: AppConfig.githubReleasesUrl;
|
|
||||||
if (cached != null && url != cached) {
|
|
||||||
debugPrint('[Updater] Rejected untrusted release URL: $cached');
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
return await launchUrl(Uri.parse(url),
|
return await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
mode: LaunchMode.externalApplication);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[Updater] Failed to open GitHub: $e');
|
debugPrint('[Updater] Failed to open GitHub: $e');
|
||||||
return false;
|
return false;
|
||||||
@@ -116,14 +203,13 @@ class Updater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static int _compareVersions(String a, String b) {
|
static int _compareVersions(String a, String b) {
|
||||||
final aParts = a.split('.').map((p) => int.tryParse(p) ?? 0).toList();
|
final left = _ParsedVersion.tryParse(a);
|
||||||
final bParts = b.split('.').map((p) => int.tryParse(p) ?? 0).toList();
|
final right = _ParsedVersion.tryParse(b);
|
||||||
for (int i = 0; i < 3; i++) {
|
if (left == null || right == null) {
|
||||||
final diff = (aParts.length > i ? aParts[i] : 0) -
|
// Invalid local metadata should not invent an ordering.
|
||||||
(bParts.length > i ? bParts[i] : 0);
|
return 0;
|
||||||
if (diff != 0) return diff;
|
|
||||||
}
|
}
|
||||||
return 0;
|
return left.compareTo(right);
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _versionLabel(String v, bool isLocal) {
|
static String _versionLabel(String v, bool isLocal) {
|
||||||
@@ -131,15 +217,27 @@ class Updater {
|
|||||||
return '$prefix$v${isLocal ? ' (installed)' : ''}';
|
return '$prefix$v${isLocal ? ' (installed)' : ''}';
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> check({
|
static Future<void> check(BuildContext context) {
|
||||||
|
final navigator = Navigator.of(context, rootNavigator: true);
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
return checkWithHandles(
|
||||||
|
navigator: navigator,
|
||||||
|
messenger: messenger,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> checkWithHandles({
|
||||||
required NavigatorState navigator,
|
required NavigatorState navigator,
|
||||||
required ScaffoldMessengerState messenger,
|
required ScaffoldMessengerState messenger,
|
||||||
}) async {
|
}) async {
|
||||||
final info = await PackageInfo.fromPlatform();
|
final info = await PackageInfo.fromPlatform();
|
||||||
final localVersion = info.version;
|
final localVersion = info.version;
|
||||||
if (localVersion.isEmpty) {
|
if (localVersion.isEmpty || _ParsedVersion.tryParse(localVersion) == null) {
|
||||||
_showSnackBar(messenger, 'Could not determine app version',
|
_showSnackBar(
|
||||||
isError: true);
|
messenger,
|
||||||
|
'Could not determine app version',
|
||||||
|
isError: true,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,15 +253,18 @@ class Updater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final source = await installSource;
|
final source = await installSource;
|
||||||
final storeName = source == InstallSource.playStore ? 'Play Store' : 'GitHub';
|
final storeName =
|
||||||
|
source == InstallSource.playStore ? 'Play Store' : 'GitHub';
|
||||||
if (!navigator.mounted) return;
|
if (!navigator.mounted) return;
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: navigator.context,
|
context: navigator.context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
backgroundColor: AppColors.bgCard,
|
backgroundColor: AppColors.bgCard,
|
||||||
title: const Text('Update available',
|
title: const Text(
|
||||||
style: TextStyle(color: AppColors.textWhite)),
|
'Update available',
|
||||||
|
style: TextStyle(color: AppColors.textWhite),
|
||||||
|
),
|
||||||
content: Text(
|
content: Text(
|
||||||
'${_versionLabel(latestVersion, false)} is available on $storeName.\n'
|
'${_versionLabel(latestVersion, false)} is available on $storeName.\n'
|
||||||
'You have ${_versionLabel(localVersion, true)}.',
|
'You have ${_versionLabel(localVersion, true)}.',
|
||||||
@@ -172,8 +273,10 @@ class Updater {
|
|||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(ctx),
|
onPressed: () => Navigator.pop(ctx),
|
||||||
child: const Text('Later',
|
child: const Text(
|
||||||
style: TextStyle(color: AppColors.slate70)),
|
'Later',
|
||||||
|
style: TextStyle(color: AppColors.slate70),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -182,24 +285,31 @@ class Updater {
|
|||||||
? PlayStoreUpdater.open()
|
? PlayStoreUpdater.open()
|
||||||
: GitHubUpdater.open();
|
: GitHubUpdater.open();
|
||||||
},
|
},
|
||||||
child: Text('Open $storeName',
|
child: Text(
|
||||||
style: const TextStyle(color: AppColors.accent)),
|
'Open $storeName',
|
||||||
|
style: const TextStyle(color: AppColors.accent),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void _showSnackBar(ScaffoldMessengerState messenger, String message,
|
static void _showSnackBar(
|
||||||
{bool isError = false}) {
|
ScaffoldMessengerState messenger,
|
||||||
|
String message, {
|
||||||
|
bool isError = false,
|
||||||
|
}) {
|
||||||
if (!messenger.mounted) return;
|
if (!messenger.mounted) return;
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Row(
|
content: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(isError ? Icons.error_outline : Icons.check_circle,
|
Icon(
|
||||||
color: isError ? AppColors.red : AppColors.green,
|
isError ? Icons.error_outline : Icons.check_circle,
|
||||||
size: 18),
|
color: isError ? AppColors.red : AppColors.green,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(message),
|
Text(message),
|
||||||
],
|
],
|
||||||
|
|||||||
+396
-71
@@ -1,8 +1,12 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:flutter_vless/flutter_vless.dart';
|
import 'package:flutter_vless/flutter_vless.dart';
|
||||||
|
import 'package:revoltvpn/logic/connection_settings.dart';
|
||||||
import 'package:revoltvpn/logic/hivemind_service.dart';
|
import 'package:revoltvpn/logic/hivemind_service.dart';
|
||||||
|
import 'package:revoltvpn/logic/local_socks_tester.dart';
|
||||||
|
import 'package:revoltvpn/logic/network_monitor.dart';
|
||||||
|
import 'package:revoltvpn/logic/secure_socks_session.dart';
|
||||||
|
|
||||||
enum VpnStatus {
|
enum VpnStatus {
|
||||||
disconnected,
|
disconnected,
|
||||||
@@ -12,7 +16,9 @@ enum VpnStatus {
|
|||||||
error,
|
error,
|
||||||
}
|
}
|
||||||
|
|
||||||
class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
class VpnConnection extends ChangeNotifier {
|
||||||
|
static const int _maxExtremeRecoveryAttempts = 2;
|
||||||
|
|
||||||
bool _cancelled = false;
|
bool _cancelled = false;
|
||||||
VpnStatus _status = VpnStatus.disconnected;
|
VpnStatus _status = VpnStatus.disconnected;
|
||||||
VpnStatus get status => _status;
|
VpnStatus get status => _status;
|
||||||
@@ -29,7 +35,51 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
bool _serverReachable = false;
|
bool _serverReachable = false;
|
||||||
bool get serverReachable => _serverReachable;
|
bool get serverReachable => _serverReachable;
|
||||||
|
|
||||||
|
ConnectionMode _activeMode = ConnectionMode.tun;
|
||||||
|
ConnectionMode get activeMode => _activeMode;
|
||||||
|
|
||||||
|
ResilienceMode _activeResilienceMode = ResilienceMode.standard;
|
||||||
|
ResilienceMode get activeResilienceMode => _activeResilienceMode;
|
||||||
|
|
||||||
|
String _networkTransport = 'unknown';
|
||||||
|
String get networkTransport => _networkTransport;
|
||||||
|
|
||||||
|
SecureSocksSession? get activeSocksSession => _lastSecureSocks;
|
||||||
|
|
||||||
|
bool get canTestActiveLocalSocks =>
|
||||||
|
_status == VpnStatus.connected &&
|
||||||
|
_activeMode == ConnectionMode.proxy &&
|
||||||
|
_lastSecureSocks != null;
|
||||||
|
|
||||||
|
Future<LocalSocksTestResult> testActiveLocalSocks() async {
|
||||||
|
final active = _lastSecureSocks;
|
||||||
|
if (!canTestActiveLocalSocks || active == null) {
|
||||||
|
return const LocalSocksTestResult(
|
||||||
|
ok: false,
|
||||||
|
latencyMs: null,
|
||||||
|
message: 'No active authenticated SOCKS5 session.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return LocalSocksTester.test(
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: active.port,
|
||||||
|
username: active.username,
|
||||||
|
password: active.password,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Timer? _healthTimer;
|
Timer? _healthTimer;
|
||||||
|
Timer? _restartTimer;
|
||||||
|
StreamSubscription<NetworkSnapshot>? _networkSubscription;
|
||||||
|
|
||||||
|
String? _lastBaseConfig;
|
||||||
|
String _lastRemark = 'Revolt VPN';
|
||||||
|
bool _lastVerifyLocalSocks = false;
|
||||||
|
SecureSocksSession? _lastSecureSocks;
|
||||||
|
bool _restartInProgress = false;
|
||||||
|
bool _userDisconnecting = false;
|
||||||
|
int _connectionEpoch = 0;
|
||||||
|
|
||||||
late final FlutterVless _vless;
|
late final FlutterVless _vless;
|
||||||
bool _initialized = false;
|
bool _initialized = false;
|
||||||
@@ -37,7 +87,8 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
Future<void> get ready => _readyCompleter.future;
|
Future<void> get ready => _readyCompleter.future;
|
||||||
|
|
||||||
VpnConnection() {
|
VpnConnection() {
|
||||||
WidgetsBinding.instance.addObserver(this);
|
_activeMode = ConnectionSettings.mode;
|
||||||
|
_activeResilienceMode = ConnectionSettings.resilienceMode;
|
||||||
_init();
|
_init();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,10 +105,16 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
Future<void> _startEngine() async {
|
Future<void> _startEngine() async {
|
||||||
if (kIsWeb) return;
|
if (kIsWeb) return;
|
||||||
|
|
||||||
|
await ConnectionSettings.initialize();
|
||||||
|
_activeMode = ConnectionSettings.mode;
|
||||||
|
_activeResilienceMode = ConnectionSettings.resilienceMode;
|
||||||
|
|
||||||
_vless = FlutterVless(
|
_vless = FlutterVless(
|
||||||
onStatusChanged: (status) {
|
onStatusChanged: (status) {
|
||||||
debugPrint('[VPN] Status: state=${status.state} '
|
debugPrint(
|
||||||
'connection=${status.connectionState.name}');
|
'[VPN] Status: state=${status.state} '
|
||||||
|
'connection=${status.connectionState.name}',
|
||||||
|
);
|
||||||
_mapStatus(status);
|
_mapStatus(status);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -66,14 +123,29 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
await _vless.initializeVless(
|
await _vless.initializeVless(
|
||||||
providerBundleIdentifier: 'com.paladinvpn.app',
|
providerBundleIdentifier: 'com.paladinvpn.app',
|
||||||
notificationIconResourceType: 'drawable',
|
notificationIconResourceType: 'drawable',
|
||||||
notificationIconResourceName: 'notification_icon',
|
notificationIconResourceName: 'notification_status_icon',
|
||||||
);
|
);
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[VPN] VLESS init error (expected on emulator): $e');
|
debugPrint('[VPN] VLESS init error (expected on emulator): $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
_startHealthPolling();
|
_networkSubscription = NetworkMonitor.changes.listen(
|
||||||
|
(snapshot) {
|
||||||
|
// ConnectivityManager is informational only. Restarting from every
|
||||||
|
// callback caused the old VPN-created-network reconnect loop.
|
||||||
|
_networkTransport = snapshot.transport;
|
||||||
|
notifyListeners();
|
||||||
|
},
|
||||||
|
onError: (Object error) {
|
||||||
|
debugPrint('[VPN] Network monitor error: $error');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
unawaited(_checkHealth());
|
||||||
|
_healthTimer = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||||
|
unawaited(_checkHealth());
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final coreVersion = await _vless.getCoreVersion();
|
final coreVersion = await _vless.getCoreVersion();
|
||||||
@@ -82,52 +154,127 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
final delay = await _vless.getConnectedServerDelay();
|
final delay = await _vless.getConnectedServerDelay();
|
||||||
if (delay > 0) {
|
if (delay > 0) {
|
||||||
_isStartupRestoration = true;
|
_isStartupRestoration = true;
|
||||||
_setStatus(VpnStatus.connected, 'Secured');
|
_lastVerifyLocalSocks = _activeMode == ConnectionMode.proxy;
|
||||||
|
_setStatus(VpnStatus.connected, _connectedLabel);
|
||||||
|
unawaited(_restoreRecoverySnapshot());
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Android can kill the Dart process while the VPN foreground service keeps
|
||||||
|
/// running, which leaves a fresh [VpnConnection] attached to a live tunnel it
|
||||||
|
/// holds no config snapshot for — so Extreme recovery has nothing to restart
|
||||||
|
/// from. The Hivemind session is still active, so `/session/status` returns
|
||||||
|
/// the same VLESS parameters and the snapshot can be rebuilt without opening
|
||||||
|
/// a new session or running the ad bypass.
|
||||||
|
///
|
||||||
|
/// TUN only. This mints *fresh* SOCKS credentials, which is invisible in TUN
|
||||||
|
/// because only tun2socks consumes them. In proxy mode those credentials are
|
||||||
|
/// the user's own client configuration, so rotating them behind their back
|
||||||
|
/// would silently break it — that case is surfaced in the UI instead.
|
||||||
|
Future<void> _restoreRecoverySnapshot() async {
|
||||||
|
if (kIsWeb || _activeMode != ConnectionMode.tun) return;
|
||||||
|
|
||||||
|
final epoch = _connectionEpoch;
|
||||||
|
bool stale() =>
|
||||||
|
_connectionEpoch != epoch ||
|
||||||
|
_userDisconnecting ||
|
||||||
|
_status != VpnStatus.connected;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final realUrl = await HivemindService.fetchConfigDirectly(
|
||||||
|
skipAdBypass: true,
|
||||||
|
);
|
||||||
|
// A real connect() may have landed while the fetch was in flight; never
|
||||||
|
// overwrite a snapshot that belongs to a live connection.
|
||||||
|
if (stale() || _lastBaseConfig != null) return;
|
||||||
|
|
||||||
|
final parsed = FlutterVless.parse(realUrl);
|
||||||
|
final secureSocks = await SecureSocksSession.create(
|
||||||
|
parsed.getFullConfiguration(),
|
||||||
|
);
|
||||||
|
if (stale() || _lastBaseConfig != null) return;
|
||||||
|
|
||||||
|
_lastBaseConfig = secureSocks.configJson;
|
||||||
|
_lastRemark = parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN';
|
||||||
|
_lastVerifyLocalSocks = false;
|
||||||
|
debugPrint('[VPN] Recovery snapshot rebuilt after process restart');
|
||||||
|
} catch (e) {
|
||||||
|
// Best-effort: without it the tunnel still works, only Extreme recovery
|
||||||
|
// stays unavailable until the next manual connect.
|
||||||
|
debugPrint('[VPN] Could not rebuild recovery snapshot: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String get _connectedLabel {
|
||||||
|
switch (_activeMode) {
|
||||||
|
case ConnectionMode.proxy:
|
||||||
|
return 'SOCKS5 gateway active';
|
||||||
|
case ConnectionMode.tun:
|
||||||
|
return 'Secured';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _mapStatus(VlessStatus status) {
|
void _mapStatus(VlessStatus status) {
|
||||||
switch (status.connectionState) {
|
switch (status.connectionState) {
|
||||||
case VlessConnectionState.connected:
|
case VlessConnectionState.connected:
|
||||||
_setStatus(VpnStatus.connected, 'Secured');
|
_setStatus(VpnStatus.connected, _connectedLabel);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case VlessConnectionState.disconnected:
|
case VlessConnectionState.disconnected:
|
||||||
_isStartupRestoration = false;
|
// Repeated DISCONNECTED broadcasts during an Extreme recovery debounce
|
||||||
|
// must not clear the config snapshot the pending recovery needs.
|
||||||
|
if (_restartInProgress || (_restartTimer?.isActive ?? false)) return;
|
||||||
|
|
||||||
|
final canRecover = !_userDisconnecting &&
|
||||||
|
_activeResilienceMode == ResilienceMode.extreme &&
|
||||||
|
_lastBaseConfig != null &&
|
||||||
|
_status == VpnStatus.connected;
|
||||||
|
if (canRecover) {
|
||||||
|
_scheduleRuntimeRestart('Runtime disconnected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearRuntimeSnapshot();
|
||||||
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case VlessConnectionState.connecting:
|
case VlessConnectionState.connecting:
|
||||||
_setStatus(VpnStatus.connecting, 'Establishing tunnel…');
|
if (!_restartInProgress) {
|
||||||
|
_setStatus(VpnStatus.connecting, 'Establishing tunnel…');
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case VlessConnectionState.disconnecting:
|
case VlessConnectionState.disconnecting:
|
||||||
_isStartupRestoration = false;
|
_isStartupRestoration = false;
|
||||||
_setStatus(VpnStatus.disconnecting, 'Tearing down…');
|
if (!_restartInProgress) {
|
||||||
|
_setStatus(VpnStatus.disconnecting, 'Tearing down…');
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case VlessConnectionState.unknown:
|
case VlessConnectionState.unknown:
|
||||||
if (_status != VpnStatus.connected &&
|
if (_status != VpnStatus.connected &&
|
||||||
_status != VpnStatus.disconnected) {
|
_status != VpnStatus.disconnected &&
|
||||||
|
!_restartInProgress) {
|
||||||
_setStatus(VpnStatus.error, 'Connection failed');
|
_setStatus(VpnStatus.error, 'Connection failed');
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Connect ────────────────────────────────────────────────────────
|
Future<bool> connect({bool skipAdBypass = false}) async {
|
||||||
//
|
|
||||||
// 1. Direct HTTPS to the public API → get per-user VLESS URL
|
|
||||||
// 2. Start tunnel directly with that URL (no bootstrap, no switch)
|
|
||||||
//
|
|
||||||
// The only hardcoded data in the APK is the Reality public key and
|
|
||||||
// shortId (needed for every Reality handshake — unavoidable).
|
|
||||||
// The tunnel UUID is per-user, assigned by Hivemind via AdMob SSV.
|
|
||||||
|
|
||||||
Future<bool> connect({
|
|
||||||
bool skipAdBypass = false,
|
|
||||||
}) async {
|
|
||||||
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
|
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_cancelled = false;
|
_cancelled = false;
|
||||||
|
_userDisconnecting = false;
|
||||||
|
_restartTimer?.cancel();
|
||||||
|
_connectionEpoch++;
|
||||||
|
|
||||||
|
await ConnectionSettings.initialize();
|
||||||
|
_activeMode = ConnectionSettings.mode;
|
||||||
|
_activeResilienceMode = ConnectionSettings.resilienceMode;
|
||||||
|
|
||||||
if (!kIsWeb && !_initialized) {
|
if (!kIsWeb && !_initialized) {
|
||||||
_errorMessage = 'VPN service unavailable.';
|
_errorMessage = 'VPN service unavailable.';
|
||||||
@@ -135,7 +282,9 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!kIsWeb) {
|
// TUN needs the Android VPN permission; SOCKS5 proxy mode runs a local
|
||||||
|
// authenticated proxy without a VpnService, so no VPN permission is needed.
|
||||||
|
if (!kIsWeb && _activeMode == ConnectionMode.tun) {
|
||||||
final ok = await _vless.requestPermission();
|
final ok = await _vless.requestPermission();
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
_errorMessage = 'VPN permission denied.';
|
_errorMessage = 'VPN permission denied.';
|
||||||
@@ -144,7 +293,11 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_setStatus(VpnStatus.connecting, 'Establishing secure channel…');
|
final startingMessage = switch (_activeMode) {
|
||||||
|
ConnectionMode.tun => 'Establishing secure channel…',
|
||||||
|
ConnectionMode.proxy => 'Starting local SOCKS5 proxy…',
|
||||||
|
};
|
||||||
|
_setStatus(VpnStatus.connecting, startingMessage);
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
|
|
||||||
if (kIsWeb) {
|
if (kIsWeb) {
|
||||||
@@ -161,7 +314,9 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
skipAdBypass: skipAdBypass,
|
skipAdBypass: skipAdBypass,
|
||||||
onAttempt: (attempt, total) {
|
onAttempt: (attempt, total) {
|
||||||
_setStatus(
|
_setStatus(
|
||||||
VpnStatus.connecting, 'Contacting server ($attempt/$total)…');
|
VpnStatus.connecting,
|
||||||
|
'Contacting server ($attempt/$total)…',
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -173,7 +328,7 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
'The server did not respond in time.\nCheck your connection and try again.';
|
'The server did not respond in time.\nCheck your connection and try again.';
|
||||||
_setStatus(VpnStatus.error, 'Server unreachable');
|
_setStatus(VpnStatus.error, 'Server unreachable');
|
||||||
} else {
|
} else {
|
||||||
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
_errorMessage = raw;
|
||||||
_setStatus(VpnStatus.error, 'Config fetch error');
|
_setStatus(VpnStatus.error, 'Config fetch error');
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -184,31 +339,209 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_setStatus(VpnStatus.connecting, 'Securing connection…');
|
_setStatus(VpnStatus.connecting, 'Starting secure route…');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final parsed = FlutterVless.parse(realUrl);
|
final parsed = FlutterVless.parse(realUrl);
|
||||||
|
final baseConfig = parsed.getFullConfiguration();
|
||||||
|
final remark = parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN';
|
||||||
|
final secureSocks = await SecureSocksSession.create(baseConfig);
|
||||||
|
final verifyLocalSocks = _activeMode == ConnectionMode.proxy;
|
||||||
|
|
||||||
await _vless.startVless(
|
await _startRuntime(
|
||||||
remark: parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN',
|
config: secureSocks.configJson,
|
||||||
config: parsed.getFullConfiguration(),
|
remark: remark,
|
||||||
|
proxyOnly: verifyLocalSocks,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
_setStatus(
|
||||||
|
VpnStatus.connecting,
|
||||||
|
verifyLocalSocks ? 'Waiting for local SOCKS5…' : 'Waiting for VPN interface…',
|
||||||
|
);
|
||||||
|
if (!await _waitForNativeConnected()) {
|
||||||
|
throw StateError('VPN runtime did not report CONNECTED');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verifyLocalSocks) {
|
||||||
|
_setStatus(VpnStatus.connecting, 'Checking Local SOCKS5…');
|
||||||
|
if (!await _waitForLocalSocksListener(secureSocks)) {
|
||||||
|
throw StateError('Local SOCKS5 listener did not become ready');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastBaseConfig = secureSocks.configJson;
|
||||||
|
_lastSecureSocks = secureSocks;
|
||||||
|
_lastRemark = remark;
|
||||||
|
_lastVerifyLocalSocks = verifyLocalSocks;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[VPN] Tunnel start error: $e');
|
debugPrint('[VPN] Tunnel start error: $e');
|
||||||
_errorMessage = 'Tunnel failed to start.\nTry reconnecting.';
|
await _stopRuntime();
|
||||||
|
_clearRuntimeSnapshot();
|
||||||
|
_errorMessage = _activeMode == ConnectionMode.proxy
|
||||||
|
? 'SOCKS5 gateway failed to start.\nTry reconnecting.'
|
||||||
|
: 'Connection failed to start.\nTry reconnecting.';
|
||||||
_setStatus(VpnStatus.error, 'Connection failed');
|
_setStatus(VpnStatus.error, 'Connection failed');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_setStatus(VpnStatus.connected, 'Secured');
|
_setStatus(VpnStatus.connected, _connectedLabel);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _startRuntime({
|
||||||
|
required String config,
|
||||||
|
required String remark,
|
||||||
|
required bool proxyOnly,
|
||||||
|
}) async {
|
||||||
|
await _vless.startVless(
|
||||||
|
remark: remark,
|
||||||
|
config: config,
|
||||||
|
proxyOnly: proxyOnly,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _waitForLocalSocksListener([
|
||||||
|
SecureSocksSession? session,
|
||||||
|
]) async {
|
||||||
|
final active = session ?? _lastSecureSocks;
|
||||||
|
if (active == null) return false;
|
||||||
|
|
||||||
|
for (var attempt = 0; attempt < 4; attempt++) {
|
||||||
|
final result = await LocalSocksTester.testListener(
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: active.port,
|
||||||
|
username: active.username,
|
||||||
|
password: active.password,
|
||||||
|
);
|
||||||
|
if (result.ok) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (attempt < 3) {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 250));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _waitForNativeConnected() async {
|
||||||
|
// startVless() queues the Android foreground service. Wait for the native
|
||||||
|
// service/core broadcast rather than treating MethodChannel completion as
|
||||||
|
// proof that a TUN interface was established.
|
||||||
|
for (var attempt = 0; attempt < 24; attempt++) {
|
||||||
|
if (_status == VpnStatus.connected) return true;
|
||||||
|
if (_status == VpnStatus.error) return false;
|
||||||
|
if (_cancelled || _userDisconnecting) return false;
|
||||||
|
await Future.delayed(const Duration(milliseconds: 250));
|
||||||
|
}
|
||||||
|
return _status == VpnStatus.connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleRuntimeRestart(String reason) {
|
||||||
|
if (_activeResilienceMode != ResilienceMode.extreme ||
|
||||||
|
_restartInProgress ||
|
||||||
|
_userDisconnecting ||
|
||||||
|
_lastBaseConfig == null ||
|
||||||
|
(_restartTimer?.isActive ?? false)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_setStatus(VpnStatus.connecting, 'Reconnecting…');
|
||||||
|
_restartTimer = Timer(const Duration(milliseconds: 1200), () {
|
||||||
|
unawaited(_restartActiveRuntime(reason));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _restartActiveRuntime(String reason) async {
|
||||||
|
if (_restartInProgress || _userDisconnecting || _lastBaseConfig == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_restartInProgress = true;
|
||||||
|
final epoch = _connectionEpoch;
|
||||||
|
Object? lastError;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (var attempt = 1; attempt <= _maxExtremeRecoveryAttempts; attempt++) {
|
||||||
|
if (_connectionEpoch != epoch || _userDisconnecting || _lastBaseConfig == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
debugPrint(
|
||||||
|
'[VPN] Extreme recovery attempt '
|
||||||
|
'$attempt/$_maxExtremeRecoveryAttempts: $reason',
|
||||||
|
);
|
||||||
|
|
||||||
|
await _stopRuntime();
|
||||||
|
if (_userDisconnecting) return;
|
||||||
|
|
||||||
|
await Future.delayed(
|
||||||
|
Duration(milliseconds: attempt == 1 ? 600 : 900),
|
||||||
|
);
|
||||||
|
if (_userDisconnecting || _lastBaseConfig == null) return;
|
||||||
|
|
||||||
|
await _startRuntime(
|
||||||
|
config: _lastBaseConfig!,
|
||||||
|
remark: _lastRemark,
|
||||||
|
proxyOnly: _activeMode == ConnectionMode.proxy,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!await _waitForNativeConnected()) {
|
||||||
|
throw StateError('VPN runtime did not recover');
|
||||||
|
}
|
||||||
|
if (_lastVerifyLocalSocks && !(await _waitForLocalSocksListener())) {
|
||||||
|
throw StateError('Local SOCKS5 listener did not recover');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_userDisconnecting) return;
|
||||||
|
|
||||||
|
_errorMessage = null;
|
||||||
|
_setStatus(VpnStatus.connected, _connectedLabel);
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
lastError = e;
|
||||||
|
debugPrint(
|
||||||
|
'[VPN] Extreme recovery attempt '
|
||||||
|
'$attempt/$_maxExtremeRecoveryAttempts failed: $e',
|
||||||
|
);
|
||||||
|
await _stopRuntime();
|
||||||
|
if (_userDisconnecting) return;
|
||||||
|
|
||||||
|
if (attempt < _maxExtremeRecoveryAttempts) {
|
||||||
|
_setStatus(
|
||||||
|
VpnStatus.connecting,
|
||||||
|
'Reconnecting… (${attempt + 1}/$_maxExtremeRecoveryAttempts)',
|
||||||
|
);
|
||||||
|
await Future.delayed(const Duration(milliseconds: 700));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_userDisconnecting) return;
|
||||||
|
|
||||||
|
debugPrint('[VPN] Extreme recovery exhausted: $lastError');
|
||||||
|
_clearRuntimeSnapshot();
|
||||||
|
_errorMessage = 'Could not restore the connection.';
|
||||||
|
_setStatus(VpnStatus.error, 'Reconnect failed');
|
||||||
|
} finally {
|
||||||
|
_restartInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _stopRuntime() async {
|
||||||
|
try {
|
||||||
|
await _vless.stopVless().timeout(const Duration(seconds: 5));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> disconnect() async {
|
Future<void> disconnect() async {
|
||||||
_cancelled = true;
|
_cancelled = true;
|
||||||
|
_userDisconnecting = true;
|
||||||
|
_restartTimer?.cancel();
|
||||||
HivemindService.cancel();
|
HivemindService.cancel();
|
||||||
|
|
||||||
if (_status == VpnStatus.disconnected ||
|
if (_status == VpnStatus.disconnected ||
|
||||||
_status == VpnStatus.disconnecting) {
|
_status == VpnStatus.disconnecting) {
|
||||||
|
_clearRuntimeSnapshot();
|
||||||
|
_userDisconnecting = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +549,9 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
|
|
||||||
if (kIsWeb) {
|
if (kIsWeb) {
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
_clearRuntimeSnapshot();
|
||||||
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
||||||
|
_userDisconnecting = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,65 +564,55 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[VPN] VLESS stop error: $e');
|
debugPrint('[VPN] VLESS stop error: $e');
|
||||||
_errorMessage = 'VPN shutdown error.\nPlease restart the app.';
|
_errorMessage = 'VPN shutdown error.\nPlease restart the app.';
|
||||||
|
_clearRuntimeSnapshot();
|
||||||
_setStatus(VpnStatus.error, 'Shutdown failed');
|
_setStatus(VpnStatus.error, 'Shutdown failed');
|
||||||
|
_userDisconnecting = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (timedOut) {
|
if (timedOut) {
|
||||||
debugPrint('[VPN] stopVless() timed out after 5 s — '
|
debugPrint('[VPN] stopVless() timed out after 5 s.');
|
||||||
'tunnel may still be active.');
|
_errorMessage = 'VPN did not shut down cleanly.\nPlease restart the app.';
|
||||||
_errorMessage = 'VPN did not shut down cleanly.\n'
|
_clearRuntimeSnapshot();
|
||||||
'Please restart the app.';
|
|
||||||
_setStatus(VpnStatus.error, 'Shutdown failed');
|
_setStatus(VpnStatus.error, 'Shutdown failed');
|
||||||
|
_userDisconnecting = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_errorMessage = null;
|
||||||
|
_clearRuntimeSnapshot();
|
||||||
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
||||||
|
_userDisconnecting = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _setStatus(VpnStatus s, String msg) {
|
void _clearRuntimeSnapshot() {
|
||||||
_status = s;
|
_lastBaseConfig = null;
|
||||||
_statusMessage = msg;
|
_lastVerifyLocalSocks = false;
|
||||||
|
_lastSecureSocks = null;
|
||||||
|
_isStartupRestoration = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setStatus(VpnStatus status, String message) {
|
||||||
|
_status = status;
|
||||||
|
_statusMessage = message;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startHealthPolling() {
|
|
||||||
_healthTimer?.cancel();
|
|
||||||
_checkHealthIfIdle();
|
|
||||||
_healthTimer = Timer.periodic(
|
|
||||||
const Duration(seconds: 30), (_) => _checkHealthIfIdle());
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _checkHealthIfIdle() async {
|
|
||||||
// A live tunnel already proves reachability.
|
|
||||||
if (_status == VpnStatus.connected) return;
|
|
||||||
await _checkHealth();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _checkHealth() async {
|
Future<void> _checkHealth() async {
|
||||||
|
// A live tunnel already proves reachability; only probe while disconnected
|
||||||
|
// so we don't add direct /health traffic while the VPN is active.
|
||||||
|
if (_status == VpnStatus.connected) return;
|
||||||
_serverReachable = await HivemindService.checkHealth();
|
_serverReachable = await HivemindService.checkHealth();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
||||||
if (state == AppLifecycleState.paused ||
|
|
||||||
state == AppLifecycleState.detached) {
|
|
||||||
_healthTimer?.cancel();
|
|
||||||
} else if (state == AppLifecycleState.resumed) {
|
|
||||||
_startHealthPolling();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
|
||||||
_healthTimer?.cancel();
|
_healthTimer?.cancel();
|
||||||
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
|
_restartTimer?.cancel();
|
||||||
try {
|
_networkSubscription?.cancel();
|
||||||
_vless.stopVless();
|
// Provider/UI disposal is not a user disconnect command. Keeping teardown
|
||||||
} catch (_) {}
|
// in disconnect() prevents lifecycle churn from silently dropping the VPN.
|
||||||
}
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+20
-15
@@ -1,18 +1,23 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
|
||||||
import 'package:revoltvpn/logic/session_timer.dart';
|
|
||||||
import 'package:revoltvpn/logic/ad_manager.dart';
|
import 'package:revoltvpn/logic/ad_manager.dart';
|
||||||
import 'package:revoltvpn/logic/server_list.dart';
|
|
||||||
import 'package:revoltvpn/logic/app_colors.dart';
|
import 'package:revoltvpn/logic/app_colors.dart';
|
||||||
|
import 'package:revoltvpn/logic/connection_settings.dart';
|
||||||
|
import 'package:revoltvpn/logic/haptic_settings.dart';
|
||||||
|
import 'package:revoltvpn/logic/server_list.dart';
|
||||||
|
import 'package:revoltvpn/logic/session_timer.dart';
|
||||||
|
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||||
import 'package:revoltvpn/screens/intro.dart';
|
import 'package:revoltvpn/screens/intro.dart';
|
||||||
void main() {
|
|
||||||
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
await HapticSettings.initialize();
|
||||||
|
await ConnectionSettings.initialize();
|
||||||
|
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
||||||
|
|
||||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||||
statusBarColor: Colors.transparent,
|
statusBarColor: Colors.transparent,
|
||||||
@@ -40,18 +45,18 @@ class ReVoltApp extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MultiProvider(
|
return MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
// lazy: false — the native VLESS engine takes real time to start, and
|
|
||||||
// providers are lazy by default, so it used to begin initialising only
|
|
||||||
// once MainScreen first read it, i.e. *after* the loading screen was
|
|
||||||
// gone. Constructing it eagerly moves that work under the intro, which
|
|
||||||
// now awaits VpnConnection.ready before navigating.
|
|
||||||
ChangeNotifierProvider(create: (_) => VpnConnection(), lazy: false),
|
ChangeNotifierProvider(create: (_) => VpnConnection(), lazy: false),
|
||||||
ChangeNotifierProxyProvider<VpnConnection, SessionTimer>(
|
ChangeNotifierProxyProvider<VpnConnection, SessionTimer>(
|
||||||
create: (ctx) => SessionTimer(vpnConnection: ctx.read<VpnConnection>()),
|
create: (ctx) =>
|
||||||
update: (_, vpn, prev) => prev ?? SessionTimer(vpnConnection: vpn),
|
SessionTimer(vpnConnection: ctx.read<VpnConnection>()),
|
||||||
|
update: (_, vpn, prev) =>
|
||||||
|
prev ?? SessionTimer(vpnConnection: vpn),
|
||||||
),
|
),
|
||||||
ChangeNotifierProvider(create: (_) => AdManager()),
|
ChangeNotifierProvider(create: (_) => AdManager()),
|
||||||
ChangeNotifierProvider(create: (_) => ServerList()..init(), lazy: false),
|
ChangeNotifierProvider(
|
||||||
|
create: (_) => ServerList()..init(),
|
||||||
|
lazy: false,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
title: 'ReVolt VPN',
|
title: 'ReVolt VPN',
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:revoltvpn/logic/app_colors.dart';
|
import 'package:revoltvpn/logic/app_colors.dart';
|
||||||
|
import 'package:revoltvpn/logic/haptic_settings.dart';
|
||||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||||
import 'package:revoltvpn/screens/main_screen.dart';
|
import 'package:revoltvpn/screens/main_screen.dart';
|
||||||
import 'package:revoltvpn/screens/settings/in_settings/rain.dart';
|
import 'package:revoltvpn/screens/settings/in_settings/rain.dart';
|
||||||
import 'package:revoltvpn/screens/settings/in_settings/lightning.dart';
|
import 'package:revoltvpn/screens/settings/in_settings/lightning.dart';
|
||||||
import 'package:revoltvpn/screens/settings/in_settings/haptic.dart';
|
|
||||||
|
|
||||||
class IntroScreen extends StatefulWidget {
|
class IntroScreen extends StatefulWidget {
|
||||||
const IntroScreen({super.key});
|
const IntroScreen({super.key});
|
||||||
@@ -45,10 +45,12 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
|
|||||||
final elapsed = Stopwatch()..start();
|
final elapsed = Stopwatch()..start();
|
||||||
final vpn = context.read<VpnConnection>();
|
final vpn = context.read<VpnConnection>();
|
||||||
|
|
||||||
// The overlays read these in initState, so they must land before
|
// The overlays and haptic layer read these before MainScreen mounts.
|
||||||
// MainScreen mounts.
|
final settingsPrefs = Future.wait([
|
||||||
final settingsPrefs = Future.wait(
|
loadRainPref(),
|
||||||
[loadRainPref(), loadLightningPref(), loadHapticPref()]);
|
loadLightningPref(),
|
||||||
|
HapticSettings.initialize(),
|
||||||
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await vpn.ready.timeout(_bootTimeout);
|
await vpn.ready.timeout(_bootTimeout);
|
||||||
@@ -79,7 +81,7 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
|
|||||||
void _navigateIfReady() {
|
void _navigateIfReady() {
|
||||||
if (!mounted || _navigated || !_bootComplete) return;
|
if (!mounted || _navigated || !_bootComplete) return;
|
||||||
if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.paused) {
|
if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.paused) {
|
||||||
return; // resume will call back in
|
return;
|
||||||
}
|
}
|
||||||
_navigated = true;
|
_navigated = true;
|
||||||
|
|
||||||
@@ -101,7 +103,6 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
|
|||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
const Spacer(flex: 7),
|
const Spacer(flex: 7),
|
||||||
// Image slightly below center
|
|
||||||
Center(
|
Center(
|
||||||
child: Image.asset(
|
child: Image.asset(
|
||||||
'assets/brand.png',
|
'assets/brand.png',
|
||||||
@@ -116,7 +117,6 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Spinner + text paired together below the image
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
width: 40,
|
width: 40,
|
||||||
@@ -137,7 +137,6 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
// Says what the spinner is actually waiting on.
|
|
||||||
AnimatedSwitcher(
|
AnimatedSwitcher(
|
||||||
duration: const Duration(milliseconds: 250),
|
duration: const Duration(milliseconds: 250),
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:revoltvpn/logic/app_colors.dart';
|
||||||
|
import 'package:revoltvpn/logic/connection_settings.dart';
|
||||||
|
import 'package:revoltvpn/logic/local_socks_tester.dart';
|
||||||
|
import 'package:revoltvpn/logic/session_timer.dart';
|
||||||
|
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||||
|
|
||||||
|
class ConnectionModeTile extends StatefulWidget {
|
||||||
|
final ValueChanged<ConnectionMode>? onChanged;
|
||||||
|
|
||||||
|
const ConnectionModeTile({super.key, this.onChanged});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ConnectionModeTile> createState() => _ConnectionModeTileState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ConnectionModeTileState extends State<ConnectionModeTile> {
|
||||||
|
ConnectionMode _mode = ConnectionSettings.mode;
|
||||||
|
bool _testingLocalSocks = false;
|
||||||
|
bool _reconnecting = false;
|
||||||
|
LocalSocksTestResult? _lastSocksTest;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
await ConnectionSettings.initialize();
|
||||||
|
if (mounted) setState(() => _mode = ConnectionSettings.mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isTunnelBusy(VpnStatus status) {
|
||||||
|
return status == VpnStatus.connected ||
|
||||||
|
status == VpnStatus.connecting ||
|
||||||
|
status == VpnStatus.disconnecting;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _changeMode(ConnectionMode? next) async {
|
||||||
|
if (next == null || next == _mode) return;
|
||||||
|
|
||||||
|
final vpn = context.read<VpnConnection>();
|
||||||
|
if (_isTunnelBusy(vpn.status)) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Disconnect before changing connection mode.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ConnectionSettings.setMode(next);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_mode = next;
|
||||||
|
_lastSocksTest = null;
|
||||||
|
});
|
||||||
|
widget.onChanged?.call(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recovers from an orphaned proxy: tear the runtime down and start a fresh
|
||||||
|
/// session so a readable set of credentials exists again. No rewarded ad —
|
||||||
|
/// the user is repairing a connection they already paid for, not opening a
|
||||||
|
/// new one.
|
||||||
|
Future<void> _reconnect() async {
|
||||||
|
if (_reconnecting) return;
|
||||||
|
final vpn = context.read<VpnConnection>();
|
||||||
|
final timer = context.read<SessionTimer>();
|
||||||
|
|
||||||
|
setState(() => _reconnecting = true);
|
||||||
|
try {
|
||||||
|
await timer.disconnect();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (await vpn.connect()) {
|
||||||
|
if (!mounted) return;
|
||||||
|
await timer.start();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_reconnecting = false;
|
||||||
|
_lastSocksTest = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _testLocalSocks() async {
|
||||||
|
if (_testingLocalSocks) return;
|
||||||
|
final vpn = context.read<VpnConnection>();
|
||||||
|
|
||||||
|
if (vpn.status != VpnStatus.connected || !vpn.canTestActiveLocalSocks) {
|
||||||
|
final orphaned =
|
||||||
|
vpn.status == VpnStatus.connected && vpn.isStartupRestoration;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
orphaned
|
||||||
|
? 'Credentials were lost when the app restarted. Reconnect first.'
|
||||||
|
: 'Connect with SOCKS5 first, then run the local test.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _testingLocalSocks = true);
|
||||||
|
final result = await vpn.testActiveLocalSocks();
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_testingLocalSocks = false;
|
||||||
|
_lastSocksTest = result;
|
||||||
|
});
|
||||||
|
|
||||||
|
final latency = result.latencyMs == null ? '' : ' (${result.latencyMs} ms)';
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('${result.message}$latency')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String get _subtitle {
|
||||||
|
switch (_mode) {
|
||||||
|
case ConnectionMode.tun:
|
||||||
|
return 'TUN: Android VPN routing for normal device traffic.';
|
||||||
|
case ConnectionMode.proxy:
|
||||||
|
return 'SOCKS5: local authenticated proxy — point apps at 127.0.0.1:<port> with the credentials shown below.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
title: const Text(
|
||||||
|
'Connection mode',
|
||||||
|
style: TextStyle(color: AppColors.textWhite, fontSize: 15),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
_subtitle,
|
||||||
|
style: const TextStyle(color: AppColors.textDim, fontSize: 12),
|
||||||
|
),
|
||||||
|
trailing: DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton<ConnectionMode>(
|
||||||
|
value: _mode,
|
||||||
|
dropdownColor: AppColors.bgCard,
|
||||||
|
onChanged: _changeMode,
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: ConnectionMode.tun,
|
||||||
|
child: Text('TUN'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: ConnectionMode.proxy,
|
||||||
|
child: Text('SOCKS5'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_mode == ConnectionMode.proxy) ...[
|
||||||
|
Consumer<VpnConnection>(
|
||||||
|
builder: (context, vpn, _) {
|
||||||
|
final session = vpn.activeSocksSession;
|
||||||
|
if (session == null) {
|
||||||
|
// The proxy can be running while this session is unknown:
|
||||||
|
// Android killed the app process, the foreground service kept
|
||||||
|
// the listener alive, and its credentials existed only in the
|
||||||
|
// memory that died with it. Saying "Connect" there would be a
|
||||||
|
// lie — the user is already connected.
|
||||||
|
final orphaned = vpn.status == VpnStatus.connected &&
|
||||||
|
vpn.isStartupRestoration;
|
||||||
|
if (orphaned) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'The proxy is still running, but its credentials were '
|
||||||
|
'lost when the app restarted. Reconnect to get a new '
|
||||||
|
'endpoint.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _reconnecting ? null : _reconnect,
|
||||||
|
icon: _reconnecting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.refresh),
|
||||||
|
label: Text(
|
||||||
|
_reconnecting ? 'Reconnecting…' : 'Reconnect',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||||
|
child: Text(
|
||||||
|
'Connect to start a local SOCKS5 proxy. Credentials change every session.',
|
||||||
|
style: TextStyle(color: AppColors.textMuted, fontSize: 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Proxy: 127.0.0.1:${session.port}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Username: ${session.username}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Password: ${session.password}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _testingLocalSocks ? null : _testLocalSocks,
|
||||||
|
icon: _testingLocalSocks
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.network_check),
|
||||||
|
label: Text(
|
||||||
|
_testingLocalSocks ? 'Testing…' : 'Test Local SOCKS',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_lastSocksTest != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
|
||||||
|
child: Text(
|
||||||
|
_lastSocksTest!.ok
|
||||||
|
? 'Last test: OK${_lastSocksTest!.latencyMs == null ? '' : ' · ${_lastSocksTest!.latencyMs} ms'}'
|
||||||
|
: 'Last test: Failed · ${_lastSocksTest!.message}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,43 +1,6 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
import 'package:revoltvpn/logic/app_colors.dart';
|
import 'package:revoltvpn/logic/app_colors.dart';
|
||||||
|
import 'package:revoltvpn/logic/haptic_settings.dart';
|
||||||
// A plain bool, not a ValueNotifier: this is only read at the moment of a tap,
|
|
||||||
// so nothing has to react to it changing. Default: false.
|
|
||||||
bool hapticEnabled = false;
|
|
||||||
|
|
||||||
/// Hydrates [hapticEnabled] from disk. Called at boot — the toggle tile below
|
|
||||||
/// only mounts once Settings is opened.
|
|
||||||
Future<void> loadHapticPref() async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
hapticEnabled = prefs.getBool('haptic_feedback_enabled') ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Haptic calls are deliberately best-effort: a vibration failure must never
|
|
||||||
/// block or fail a VPN connect/disconnect. `mediumImpact` is used instead of
|
|
||||||
/// `lightImpact` — the latter is imperceptible on many Android devices.
|
|
||||||
class Haptics {
|
|
||||||
Haptics._();
|
|
||||||
|
|
||||||
static Future<void> connectionChanged() async {
|
|
||||||
if (!hapticEnabled) return;
|
|
||||||
try {
|
|
||||||
await HapticFeedback.mediumImpact();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('[Haptics] Feedback unavailable: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<void> previewEnabled() async {
|
|
||||||
try {
|
|
||||||
await HapticFeedback.selectionClick();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('[Haptics] Preview unavailable: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class HapticFeedbackTile extends StatefulWidget {
|
class HapticFeedbackTile extends StatefulWidget {
|
||||||
const HapticFeedbackTile({super.key});
|
const HapticFeedbackTile({super.key});
|
||||||
@@ -47,7 +10,8 @@ class HapticFeedbackTile extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _HapticFeedbackTileState extends State<HapticFeedbackTile> {
|
class _HapticFeedbackTileState extends State<HapticFeedbackTile> {
|
||||||
bool _on = false;
|
bool _on = HapticSettings.enabled;
|
||||||
|
bool _saving = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -56,18 +20,24 @@ class _HapticFeedbackTileState extends State<HapticFeedbackTile> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
await loadHapticPref();
|
await HapticSettings.initialize();
|
||||||
if (mounted) setState(() => _on = hapticEnabled);
|
if (mounted) setState(() => _on = HapticSettings.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Writes to SharedPreferences AND updates the live bool so the connect
|
|
||||||
// button sees the change immediately — no restart needed.
|
|
||||||
Future<void> _toggle(bool value) async {
|
Future<void> _toggle(bool value) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
if (_saving) return;
|
||||||
await prefs.setBool('haptic_feedback_enabled', value);
|
setState(() {
|
||||||
hapticEnabled = value;
|
_saving = true;
|
||||||
if (value) unawaited(Haptics.previewEnabled());
|
_on = value;
|
||||||
setState(() => _on = value);
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await HapticSettings.setEnabled(value);
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) setState(() => _on = HapticSettings.enabled);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _saving = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -78,7 +48,7 @@ class _HapticFeedbackTileState extends State<HapticFeedbackTile> {
|
|||||||
style: TextStyle(color: AppColors.textWhite, fontSize: 15),
|
style: TextStyle(color: AppColors.textWhite, fontSize: 15),
|
||||||
),
|
),
|
||||||
value: _on,
|
value: _on,
|
||||||
onChanged: _toggle,
|
onChanged: _saving ? null : _toggle,
|
||||||
activeTrackColor: AppColors.accent.withAlpha(80),
|
activeTrackColor: AppColors.accent.withAlpha(80),
|
||||||
activeThumbColor: AppColors.accent,
|
activeThumbColor: AppColors.accent,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:revoltvpn/logic/app_colors.dart';
|
||||||
|
import 'package:revoltvpn/logic/connection_settings.dart';
|
||||||
|
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||||
|
|
||||||
|
class ResilienceModeTile extends StatefulWidget {
|
||||||
|
const ResilienceModeTile({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ResilienceModeTile> createState() => _ResilienceModeTileState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ResilienceModeTileState extends State<ResilienceModeTile> {
|
||||||
|
ResilienceMode _mode = ConnectionSettings.resilienceMode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
await ConnectionSettings.initialize();
|
||||||
|
if (mounted) setState(() => _mode = ConnectionSettings.resilienceMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _vpnBusy() {
|
||||||
|
final status = context.read<VpnConnection>().status;
|
||||||
|
return status == VpnStatus.connected ||
|
||||||
|
status == VpnStatus.connecting ||
|
||||||
|
status == VpnStatus.disconnecting;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _change(ResilienceMode? next) async {
|
||||||
|
if (next == null || next == _mode) return;
|
||||||
|
if (_vpnBusy()) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Disconnect before changing resilience mode.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ConnectionSettings.setResilienceMode(next);
|
||||||
|
if (mounted) setState(() => _mode = next);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final extreme = _mode == ResilienceMode.extreme;
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
title: const Text(
|
||||||
|
'Network resilience',
|
||||||
|
style: TextStyle(color: AppColors.textWhite, fontSize: 15),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
extreme
|
||||||
|
? 'Extreme: keeps the exact server transport unchanged and makes up to two controlled recovery attempts after each real Xray disconnect.'
|
||||||
|
: 'Standard: keeps the exact server transport unchanged and does not force health-check reconnects.',
|
||||||
|
style: const TextStyle(color: AppColors.textDim, fontSize: 12),
|
||||||
|
),
|
||||||
|
trailing: DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton<ResilienceMode>(
|
||||||
|
value: _mode,
|
||||||
|
dropdownColor: AppColors.bgCard,
|
||||||
|
onChanged: _change,
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: ResilienceMode.standard,
|
||||||
|
child: Text('Standard'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: ResilienceMode.extreme,
|
||||||
|
child: Text('Extreme'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:revoltvpn/logic/app_colors.dart';
|
import 'package:revoltvpn/logic/app_colors.dart';
|
||||||
|
import 'package:revoltvpn/screens/settings/in_settings/connection_mode.dart';
|
||||||
import 'package:revoltvpn/screens/settings/in_settings/haptic.dart';
|
import 'package:revoltvpn/screens/settings/in_settings/haptic.dart';
|
||||||
import 'package:revoltvpn/screens/settings/in_settings/rain.dart';
|
|
||||||
import 'package:revoltvpn/screens/settings/in_settings/lightning.dart';
|
import 'package:revoltvpn/screens/settings/in_settings/lightning.dart';
|
||||||
|
import 'package:revoltvpn/screens/settings/in_settings/rain.dart';
|
||||||
// ── lib/screens/settings/settings_screen.dart ────────────────────────────────
|
import 'package:revoltvpn/screens/settings/in_settings/resilience.dart';
|
||||||
// Settings shell — full-screen page reached from the sidebar.
|
|
||||||
//
|
|
||||||
// Folder layout:
|
|
||||||
// lib/screens/settings/ ← this folder: settings family
|
|
||||||
// settings_screen.dart ← shell: Scaffold + AppBar + ListView (this file)
|
|
||||||
// *_tile.dart ← one file per setting feature (add below)
|
|
||||||
// lib/components/sidebar_contents/
|
|
||||||
// settings.dart ← thin sidebar row that navigates here
|
|
||||||
//
|
|
||||||
// To add a setting:
|
|
||||||
// 1. Create a new file in this folder, e.g. appearance_tile.dart
|
|
||||||
// 2. Import it below
|
|
||||||
// 3. Append it to the children list
|
|
||||||
|
|
||||||
class SettingsScreen extends StatelessWidget {
|
class SettingsScreen extends StatelessWidget {
|
||||||
const SettingsScreen({super.key});
|
const SettingsScreen({super.key});
|
||||||
@@ -44,11 +31,11 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
children: const [
|
children: const [
|
||||||
|
ConnectionModeTile(),
|
||||||
|
ResilienceModeTile(),
|
||||||
HapticFeedbackTile(),
|
HapticFeedbackTile(),
|
||||||
RainToggleTile(),
|
RainToggleTile(),
|
||||||
LightningToggleTile(),
|
LightningToggleTile(),
|
||||||
// ── Add setting tiles here ────────────────────────────────────────
|
|
||||||
// Each tile imports from its own file in this folder.
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -73,14 +73,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.9"
|
version: "1.0.9"
|
||||||
dbus:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: dbus
|
|
||||||
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.7.12"
|
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -126,30 +118,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.2"
|
version: "3.0.2"
|
||||||
flutter_local_notifications:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: flutter_local_notifications
|
|
||||||
sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "17.2.4"
|
|
||||||
flutter_local_notifications_linux:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: flutter_local_notifications_linux
|
|
||||||
sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "4.0.1"
|
|
||||||
flutter_local_notifications_platform_interface:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: flutter_local_notifications_platform_interface
|
|
||||||
sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "7.2.0"
|
|
||||||
flutter_secure_storage:
|
flutter_secure_storage:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -464,14 +432,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.3.0"
|
||||||
petitparser:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: petitparser
|
|
||||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "7.0.2"
|
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -621,14 +581,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.11"
|
||||||
timezone:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: timezone
|
|
||||||
sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.9.4"
|
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -781,14 +733,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
xml:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: xml
|
|
||||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "6.6.1"
|
|
||||||
yaml:
|
yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+1
-2
@@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
|||||||
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
|
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
|
||||||
# Read more about iOS versioning at
|
# Read more about iOS versioning at
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
version: 3.3.0+26
|
version: 3.3.1+27
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.0.0 <4.0.0"
|
sdk: ">=3.0.0 <4.0.0"
|
||||||
@@ -36,7 +36,6 @@ dependencies:
|
|||||||
url_launcher: ^6.3.2
|
url_launcher: ^6.3.2
|
||||||
flutter_secure_storage: ^10.3.1
|
flutter_secure_storage: ^10.3.1
|
||||||
uuid: ^4.5.3
|
uuid: ^4.5.3
|
||||||
flutter_local_notifications: ^17.0.0
|
|
||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
package_info_plus: ^6.0.0
|
package_info_plus: ^6.0.0
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
const packageName = 'flutter_vless_android';
|
||||||
|
const expectedVersionFragment = 'flutter_vless_android-1.1.5';
|
||||||
|
|
||||||
|
Never fail(String message) {
|
||||||
|
stderr.writeln('[flutter_vless patch] $message');
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory packageRoot() {
|
||||||
|
final configFile = File('.dart_tool/package_config.json');
|
||||||
|
if (!configFile.existsSync()) {
|
||||||
|
fail('.dart_tool/package_config.json is missing; run flutter pub get first');
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(configFile.readAsStringSync());
|
||||||
|
if (decoded is! Map<String, dynamic> || decoded['packages'] is! List) {
|
||||||
|
fail('package_config.json is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? package;
|
||||||
|
for (final item in decoded['packages'] as List) {
|
||||||
|
if (item is Map && item['name'] == packageName) {
|
||||||
|
package = Map<String, dynamic>.from(item);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (package == null) fail('$packageName was not found');
|
||||||
|
|
||||||
|
final rootUriValue = package['rootUri'];
|
||||||
|
if (rootUriValue is! String || rootUriValue.isEmpty) {
|
||||||
|
fail('$packageName has no rootUri');
|
||||||
|
}
|
||||||
|
|
||||||
|
final rootUri = Uri.parse(rootUriValue);
|
||||||
|
final resolved = rootUri.hasScheme
|
||||||
|
? rootUri
|
||||||
|
: configFile.parent.absolute.uri.resolveUri(rootUri);
|
||||||
|
if (resolved.scheme != 'file') fail('unsupported package URI: $resolved');
|
||||||
|
|
||||||
|
final root = Directory.fromUri(resolved);
|
||||||
|
final normalized = root.absolute.path.replaceAll('\\', '/');
|
||||||
|
if (!normalized.contains(expectedVersionFragment)) {
|
||||||
|
fail('expected flutter_vless_android 1.1.5, got ${root.path}');
|
||||||
|
}
|
||||||
|
if (!root.existsSync()) fail('package root does not exist: ${root.path}');
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
void replaceOnce(
|
||||||
|
File file,
|
||||||
|
String oldValue,
|
||||||
|
String newValue,
|
||||||
|
String marker,
|
||||||
|
) {
|
||||||
|
if (!file.existsSync()) fail('missing upstream source: ${file.path}');
|
||||||
|
final text = file.readAsStringSync();
|
||||||
|
if (text.contains(marker)) {
|
||||||
|
stdout.writeln('[flutter_vless patch] already current: ${file.path}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!text.contains(oldValue)) {
|
||||||
|
fail('upstream source changed; expected block not found in ${file.path}');
|
||||||
|
}
|
||||||
|
file.writeAsStringSync(text.replaceFirst(oldValue, newValue));
|
||||||
|
stdout.writeln('[flutter_vless patch] patched: ${file.path}');
|
||||||
|
}
|
||||||
|
|
||||||
|
void replaceOneOf(
|
||||||
|
File file,
|
||||||
|
Map<String, String> replacements,
|
||||||
|
String marker,
|
||||||
|
) {
|
||||||
|
if (!file.existsSync()) fail('missing upstream source: ${file.path}');
|
||||||
|
var text = file.readAsStringSync();
|
||||||
|
if (text.contains(marker)) {
|
||||||
|
stdout.writeln('[flutter_vless patch] already current: ${file.path}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (final entry in replacements.entries) {
|
||||||
|
if (!text.contains(entry.key)) continue;
|
||||||
|
text = text.replaceFirst(entry.key, entry.value);
|
||||||
|
file.writeAsStringSync(text);
|
||||||
|
stdout.writeln('[flutter_vless patch] patched/upgraded: ${file.path}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fail('upstream/legacy patch shape not recognized in ${file.path}');
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final root = packageRoot();
|
||||||
|
final kotlin = Directory(
|
||||||
|
'${root.path}/android/src/main/kotlin/com/github/tfox/flutter_vless',
|
||||||
|
);
|
||||||
|
|
||||||
|
final configFile = File('${kotlin.path}/xray/dto/XrayConfig.kt');
|
||||||
|
replaceOnce(
|
||||||
|
configFile,
|
||||||
|
''' /** List of app package names to exclude from VPN. */
|
||||||
|
var BLOCKED_APPS: ArrayList<String> = ArrayList(),
|
||||||
|
''',
|
||||||
|
''' /** List of app package names to exclude from VPN. */
|
||||||
|
var BLOCKED_APPS: ArrayList<String> = ArrayList(),
|
||||||
|
|
||||||
|
/** List of app package names that are exclusively allowed into VPN. */
|
||||||
|
var ALLOWED_APPS: ArrayList<String> = ArrayList(),
|
||||||
|
''',
|
||||||
|
'var ALLOWED_APPS:',
|
||||||
|
);
|
||||||
|
|
||||||
|
final pluginFile = File('${kotlin.path}/FlutterVlessPlugin.kt');
|
||||||
|
replaceOnce(
|
||||||
|
pluginFile,
|
||||||
|
''' private lateinit var context: Context
|
||||||
|
''',
|
||||||
|
''' private lateinit var context: Context
|
||||||
|
private var allowedApps: ArrayList<String> = ArrayList()
|
||||||
|
''',
|
||||||
|
'private var allowedApps:',
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentAllowedHandler = ''' "setAllowedApps" -> {
|
||||||
|
val requested = call.argument<ArrayList<String>>("allowed_apps") ?: ArrayList()
|
||||||
|
allowedApps = ArrayList(
|
||||||
|
requested
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() && it != context.packageName }
|
||||||
|
.distinct()
|
||||||
|
.sorted()
|
||||||
|
)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
const legacyAllowedHandler = ''' "setAllowedApps" -> {
|
||||||
|
val requested = call.argument<ArrayList<String>>("allowed_apps") ?: ArrayList()
|
||||||
|
allowedApps = ArrayList(
|
||||||
|
requested.map { it.trim() }.filter { it.isNotEmpty() }.distinct()
|
||||||
|
)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
replaceOneOf(
|
||||||
|
pluginFile,
|
||||||
|
<String, String>{
|
||||||
|
legacyAllowedHandler: currentAllowedHandler,
|
||||||
|
''' when (call.method) {
|
||||||
|
"startVless" -> {
|
||||||
|
''': ''' when (call.method) {
|
||||||
|
$currentAllowedHandler "startVless" -> {
|
||||||
|
''',
|
||||||
|
},
|
||||||
|
'it.isNotEmpty() && it != context.packageName',
|
||||||
|
);
|
||||||
|
|
||||||
|
replaceOnce(
|
||||||
|
pluginFile,
|
||||||
|
''' config.BLOCKED_APPS = call.argument<ArrayList<String>>("blocked_apps") ?: ArrayList()
|
||||||
|
config.BYPASS_SUBNETS = call.argument<ArrayList<String>>("bypass_subnets") ?: ArrayList()
|
||||||
|
''',
|
||||||
|
''' config.BLOCKED_APPS = call.argument<ArrayList<String>>("blocked_apps") ?: ArrayList()
|
||||||
|
config.ALLOWED_APPS = ArrayList(allowedApps)
|
||||||
|
config.BYPASS_SUBNETS = call.argument<ArrayList<String>>("bypass_subnets") ?: ArrayList()
|
||||||
|
''',
|
||||||
|
'config.ALLOWED_APPS = ArrayList(allowedApps)',
|
||||||
|
);
|
||||||
|
|
||||||
|
final serviceFile = File('${kotlin.path}/xray/service/XrayVPNService.kt');
|
||||||
|
replaceOnce(
|
||||||
|
serviceFile,
|
||||||
|
''' if (config != null) {
|
||||||
|
// Ensure clean state before starting
|
||||||
|
cleanup()
|
||||||
|
''',
|
||||||
|
''' if (config != null) {
|
||||||
|
// A fast reconnect must not leave the previous Xray process
|
||||||
|
// owning the local SOCKS/HTTP ports.
|
||||||
|
if (XrayCoreManager.isXrayRunning()) {
|
||||||
|
Log.w(TAG, "Stopping stale Xray core before restart")
|
||||||
|
XrayCoreManager.stopCore(this)
|
||||||
|
}
|
||||||
|
cleanup()
|
||||||
|
''',
|
||||||
|
'Stopping stale Xray core before restart',
|
||||||
|
);
|
||||||
|
|
||||||
|
replaceOnce(
|
||||||
|
serviceFile,
|
||||||
|
''' val tun2socksPath = File(applicationInfo.nativeLibraryDir, "libtun2socks.so").absolutePath
|
||||||
|
val sockPath = File(filesDir, "sock_path").absolutePath
|
||||||
|
''',
|
||||||
|
''' val tun2socksPath = File(applicationInfo.nativeLibraryDir, "libtun2socks.so").absolutePath
|
||||||
|
val socketFile = File(filesDir, "sock_path")
|
||||||
|
if (socketFile.exists() && !socketFile.delete()) {
|
||||||
|
Log.w(TAG, "Could not delete stale tun2socks socket")
|
||||||
|
}
|
||||||
|
val sockPath = socketFile.absolutePath
|
||||||
|
''',
|
||||||
|
'Could not delete stale tun2socks socket',
|
||||||
|
);
|
||||||
|
|
||||||
|
final upstreamPolicy = r''' try {
|
||||||
|
builder.addDisallowedApplication(packageName)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, \"Failed to exclude app from VPN\", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (pkg in config.BLOCKED_APPS) {
|
||||||
|
try {
|
||||||
|
builder.addDisallowedApplication(pkg)
|
||||||
|
Log.d(TAG, \"Excluded from VPN: $pkg\")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, \"Failed to exclude $pkg from VPN\", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'''.replaceAll(r'\"', '"');
|
||||||
|
|
||||||
|
final legacyPatchedPolicy = r''' if (config.ALLOWED_APPS.isNotEmpty()) {
|
||||||
|
for (pkg in config.ALLOWED_APPS) {
|
||||||
|
try {
|
||||||
|
builder.addAllowedApplication(pkg)
|
||||||
|
Log.d(TAG, \"Allowed into VPN: $pkg\")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, \"Failed to allow $pkg into VPN\", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
builder.addDisallowedApplication(packageName)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, \"Failed to exclude app from VPN\", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (pkg in config.BLOCKED_APPS) {
|
||||||
|
try {
|
||||||
|
builder.addDisallowedApplication(pkg)
|
||||||
|
Log.d(TAG, \"Excluded from VPN: $pkg\")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, \"Failed to exclude $pkg from VPN\", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'''.replaceAll(r'\"', '"');
|
||||||
|
|
||||||
|
final hardenedPolicy = r''' if (config.ALLOWED_APPS.isNotEmpty() && config.BLOCKED_APPS.isNotEmpty()) {
|
||||||
|
throw IllegalStateException(\"Allowed and blocked app policies cannot be mixed\")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.ALLOWED_APPS.isNotEmpty()) {
|
||||||
|
var allowedCount = 0
|
||||||
|
for (pkg in config.ALLOWED_APPS) {
|
||||||
|
try {
|
||||||
|
builder.addAllowedApplication(pkg)
|
||||||
|
allowedCount++
|
||||||
|
Log.d(TAG, \"Allowed into VPN: $pkg\")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException(\"Failed to allow selected app $pkg\", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (allowedCount == 0) {
|
||||||
|
throw IllegalStateException(\"Selected-only routing has no valid applications\")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
builder.addDisallowedApplication(packageName)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, \"Failed to exclude app from VPN\", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (pkg in config.BLOCKED_APPS) {
|
||||||
|
try {
|
||||||
|
builder.addDisallowedApplication(pkg)
|
||||||
|
Log.d(TAG, \"Excluded from VPN: $pkg\")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, \"Failed to exclude $pkg from VPN\", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'''.replaceAll(r'\"', '"');
|
||||||
|
|
||||||
|
// Support both pristine 1.1.5 and the earlier local allowlist patch.
|
||||||
|
replaceOneOf(
|
||||||
|
serviceFile,
|
||||||
|
<String, String>{
|
||||||
|
legacyPatchedPolicy: hardenedPolicy,
|
||||||
|
upstreamPolicy: hardenedPolicy,
|
||||||
|
},
|
||||||
|
'Allowed and blocked app policies cannot be mixed',
|
||||||
|
);
|
||||||
|
|
||||||
|
stdout.writeln('[flutter_vless patch] allowed-app routing patch is ready');
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
const packageName = 'flutter_vless_android';
|
||||||
|
const expectedVersionFragment = 'flutter_vless_android-1.1.5';
|
||||||
|
|
||||||
|
Never fail(String message) {
|
||||||
|
stderr.writeln('[secure-socks scope fix] $message');
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory packageRoot() {
|
||||||
|
final configFile = File('.dart_tool/package_config.json');
|
||||||
|
if (!configFile.existsSync()) {
|
||||||
|
fail('.dart_tool/package_config.json is missing; run flutter pub get first');
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(configFile.readAsStringSync());
|
||||||
|
if (decoded is! Map<String, dynamic> || decoded['packages'] is! List) {
|
||||||
|
fail('package_config.json is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? package;
|
||||||
|
for (final item in decoded['packages'] as List) {
|
||||||
|
if (item is Map && item['name'] == packageName) {
|
||||||
|
package = Map<String, dynamic>.from(item);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (package == null) fail('$packageName was not found');
|
||||||
|
|
||||||
|
final rootUriValue = package['rootUri'];
|
||||||
|
if (rootUriValue is! String || rootUriValue.isEmpty) {
|
||||||
|
fail('$packageName has no rootUri');
|
||||||
|
}
|
||||||
|
|
||||||
|
final rootUri = Uri.parse(rootUriValue);
|
||||||
|
final resolved = rootUri.hasScheme
|
||||||
|
? rootUri
|
||||||
|
: configFile.parent.absolute.uri.resolveUri(rootUri);
|
||||||
|
if (resolved.scheme != 'file') fail('unsupported package URI: $resolved');
|
||||||
|
|
||||||
|
final root = Directory.fromUri(resolved);
|
||||||
|
final normalized = root.absolute.path.replaceAll('\\', '/');
|
||||||
|
if (!normalized.contains(expectedVersionFragment)) {
|
||||||
|
fail('expected flutter_vless_android 1.1.5, got ${root.path}');
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
void patchCoreScope(File core) {
|
||||||
|
final before = core.readAsStringSync();
|
||||||
|
|
||||||
|
// The primary secure patcher used a file-wide check for
|
||||||
|
// `val process = pb.start()`. Stock 1.1.5 already contains that exact line
|
||||||
|
// in getV2rayTraffic(), so the startCore() capture could be skipped while
|
||||||
|
// its monitor was still rewritten to use `process`. Capture the active Xray
|
||||||
|
// process immediately before its monitor thread instead. This is deliberately
|
||||||
|
// a scope-only correction and does not alter the stable runtime sequence.
|
||||||
|
const directCapture =
|
||||||
|
' val process = pb.start()\n xrayProcess = process\n';
|
||||||
|
const scopedCapture =
|
||||||
|
' val process = xrayProcess ?: return false\n // Monitor process in a separate thread to detect crash\n';
|
||||||
|
const monitorMarker =
|
||||||
|
' // Monitor process in a separate thread to detect crash\n';
|
||||||
|
|
||||||
|
if (before.contains(directCapture) || before.contains(scopedCapture)) {
|
||||||
|
stdout.writeln('[secure-socks scope fix] core already current: ${core.path}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!before.contains('process.inputStream.bufferedReader().use { reader ->') ||
|
||||||
|
!before.contains('val exitCode = process.waitFor()') ||
|
||||||
|
!before.contains('xrayProcess === process')) {
|
||||||
|
fail('secure Xray monitor patch is not in the expected state');
|
||||||
|
}
|
||||||
|
if (!before.contains(' xrayProcess = pb.start()\n')) {
|
||||||
|
fail('stock Xray start assignment is missing');
|
||||||
|
}
|
||||||
|
if (!before.contains(monitorMarker)) {
|
||||||
|
fail('Xray monitor insertion point is missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
final after = before.replaceFirst(monitorMarker, scopedCapture);
|
||||||
|
core.writeAsStringSync(after);
|
||||||
|
stdout.writeln('[secure-socks scope fix] captured Xray process for monitor scope');
|
||||||
|
}
|
||||||
|
|
||||||
|
void patchOngoingNotification(File service) {
|
||||||
|
final before = service.readAsStringSync();
|
||||||
|
|
||||||
|
const marker = '.setOngoing(true)';
|
||||||
|
if (before.contains(marker) &&
|
||||||
|
before.contains('Notification.FLAG_NO_CLEAR') &&
|
||||||
|
before.contains('.setAutoCancel(false)')) {
|
||||||
|
stdout.writeln('[secure-socks scope fix] notification already ongoing: ${service.path}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldBlock = r''' return builder
|
||||||
|
.setContentTitle("VPN Service")
|
||||||
|
.setContentText(content)
|
||||||
|
.setSmallIcon(icon)
|
||||||
|
.build()
|
||||||
|
''';
|
||||||
|
|
||||||
|
const newBlock = r''' val notification = builder
|
||||||
|
.setContentTitle("VPN Service")
|
||||||
|
.setContentText(content)
|
||||||
|
.setSmallIcon(icon)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setAutoCancel(false)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Keep the active VPN notification attached to the foreground service
|
||||||
|
// and resistant to bulk clearing on Android versions that honor these
|
||||||
|
// flags. Android 14+ may still allow an explicit user swipe while the
|
||||||
|
// device is unlocked; apps are not allowed to override that system UI.
|
||||||
|
notification.flags = notification.flags or
|
||||||
|
android.app.Notification.FLAG_ONGOING_EVENT or
|
||||||
|
android.app.Notification.FLAG_NO_CLEAR
|
||||||
|
return notification
|
||||||
|
''';
|
||||||
|
|
||||||
|
if (!before.contains(oldBlock)) {
|
||||||
|
fail('VPN notification builder is not in the expected stock 1.1.5 state');
|
||||||
|
}
|
||||||
|
|
||||||
|
final after = before.replaceFirst(oldBlock, newBlock);
|
||||||
|
if (!after.contains(marker) ||
|
||||||
|
!after.contains('Notification.FLAG_NO_CLEAR') ||
|
||||||
|
!after.contains('.setAutoCancel(false)')) {
|
||||||
|
fail('ongoing VPN notification invariant failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
service.writeAsStringSync(after);
|
||||||
|
stdout.writeln('[secure-socks scope fix] VPN notification marked ongoing/no-clear');
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final root = packageRoot();
|
||||||
|
final kotlin =
|
||||||
|
'${root.path}/android/src/main/kotlin/com/github/tfox/flutter_vless';
|
||||||
|
final core = File('$kotlin/xray/core/XrayCoreManager.kt');
|
||||||
|
final service = File('$kotlin/xray/service/XrayVPNService.kt');
|
||||||
|
|
||||||
|
if (!core.existsSync()) fail('XrayCoreManager.kt is missing');
|
||||||
|
if (!service.existsSync()) fail('XrayVPNService.kt is missing');
|
||||||
|
|
||||||
|
patchCoreScope(core);
|
||||||
|
patchOngoingNotification(service);
|
||||||
|
stdout.writeln('[secure-socks scope fix] scope and notification patch ready');
|
||||||
|
}
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
const packageName = 'flutter_vless_android';
|
||||||
|
const expectedVersionFragment = 'flutter_vless_android-1.1.5';
|
||||||
|
|
||||||
|
Never fail(String message) {
|
||||||
|
stderr.writeln('[secure-socks patch] $message');
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory packageRoot() {
|
||||||
|
final configFile = File('.dart_tool/package_config.json');
|
||||||
|
if (!configFile.existsSync()) {
|
||||||
|
fail('.dart_tool/package_config.json is missing; run flutter pub get first');
|
||||||
|
}
|
||||||
|
final decoded = jsonDecode(configFile.readAsStringSync());
|
||||||
|
if (decoded is! Map<String, dynamic> || decoded['packages'] is! List) {
|
||||||
|
fail('package_config.json is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? package;
|
||||||
|
for (final item in decoded['packages'] as List) {
|
||||||
|
if (item is Map && item['name'] == packageName) {
|
||||||
|
package = Map<String, dynamic>.from(item);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (package == null) fail('$packageName was not found');
|
||||||
|
|
||||||
|
final rootUriValue = package['rootUri'];
|
||||||
|
if (rootUriValue is! String || rootUriValue.isEmpty) {
|
||||||
|
fail('$packageName has no rootUri');
|
||||||
|
}
|
||||||
|
final rootUri = Uri.parse(rootUriValue);
|
||||||
|
final resolved = rootUri.hasScheme
|
||||||
|
? rootUri
|
||||||
|
: configFile.parent.absolute.uri.resolveUri(rootUri);
|
||||||
|
if (resolved.scheme != 'file') fail('unsupported package URI: $resolved');
|
||||||
|
|
||||||
|
final root = Directory.fromUri(resolved);
|
||||||
|
final normalized = root.absolute.path.replaceAll('\\', '/');
|
||||||
|
if (!normalized.contains(expectedVersionFragment)) {
|
||||||
|
fail('expected flutter_vless_android 1.1.5, got ${root.path}');
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
String replaceOnce(
|
||||||
|
String text,
|
||||||
|
String oldValue,
|
||||||
|
String newValue,
|
||||||
|
String label,
|
||||||
|
) {
|
||||||
|
if (text.contains(newValue)) return text;
|
||||||
|
if (!text.contains(oldValue)) fail('missing expected source block: $label');
|
||||||
|
return text.replaceFirst(oldValue, newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeIfChanged(File file, String before, String after) {
|
||||||
|
if (before == after) {
|
||||||
|
stdout.writeln('[secure-socks patch] already current: ${file.path}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
file.writeAsStringSync(after);
|
||||||
|
stdout.writeln('[secure-socks patch] patched: ${file.path}');
|
||||||
|
}
|
||||||
|
|
||||||
|
void patchCore(File file) {
|
||||||
|
final before = file.readAsStringSync();
|
||||||
|
var text = before;
|
||||||
|
|
||||||
|
const secureValidation = r''' var secureSocksEnabled = false
|
||||||
|
for (i in 0 until inbounds.length()) {
|
||||||
|
val inbound = inbounds.optJSONObject(i) ?: continue
|
||||||
|
if (inbound.optString("tag") != "revolt-secure-socks") continue
|
||||||
|
|
||||||
|
val settings = inbound.optJSONObject("settings")
|
||||||
|
?: throw IllegalStateException("Secure SOCKS5 settings are missing")
|
||||||
|
val users = settings.optJSONArray("users")
|
||||||
|
?: throw IllegalStateException("Secure SOCKS5 users are missing")
|
||||||
|
val account = users.optJSONObject(0)
|
||||||
|
?: throw IllegalStateException("Secure SOCKS5 account is missing")
|
||||||
|
val port = inbound.optInt("port", -1)
|
||||||
|
|
||||||
|
if (inbound.optString("protocol") != "socks" ||
|
||||||
|
inbound.optString("listen") != "127.0.0.1" ||
|
||||||
|
port <= 1024 || port > 65535 ||
|
||||||
|
settings.optString("auth") != "password" ||
|
||||||
|
account.optString("user").isEmpty() ||
|
||||||
|
account.optString("pass").isEmpty()
|
||||||
|
) {
|
||||||
|
throw IllegalStateException("Secure SOCKS5 session is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
config.LOCAL_SOCKS5_PORT = port
|
||||||
|
config.LOCAL_HTTP_PORT = 0
|
||||||
|
secureSocksEnabled = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
''';
|
||||||
|
|
||||||
|
if (!text.contains('var secureSocksEnabled = false')) {
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
' val usedPorts = mutableSetOf<Int>()\n',
|
||||||
|
'$secureValidation val usedPorts = mutableSetOf<Int>()\n',
|
||||||
|
'secure SOCKS validation insertion',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
' if (!hasHttpOnLocalPort) {\n',
|
||||||
|
' if (!secureSocksEnabled && !hasHttpOnLocalPort) {\n',
|
||||||
|
'disable unauthenticated HTTP proxy in secure mode',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!text.contains('val process = pb.start()')) {
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
' xrayProcess = pb.start()\n',
|
||||||
|
' val process = pb.start()\n xrayProcess = process\n',
|
||||||
|
'capture Xray process identity',
|
||||||
|
);
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
' if (xrayProcess?.isAlive != true) {\n',
|
||||||
|
' if (!process.isAlive) {\n',
|
||||||
|
'Xray startup liveness',
|
||||||
|
);
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
' val output = xrayProcess?.inputStream?.bufferedReader()?.readText().orEmpty()\n',
|
||||||
|
' val output = process.inputStream.bufferedReader().readText()\n',
|
||||||
|
'Xray startup output',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text.contains('Could not delete ephemeral Xray config')) {
|
||||||
|
const connectedLine =
|
||||||
|
' AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTED\n';
|
||||||
|
const deleteConfig = r''' // config.json contains per-session SOCKS credentials. Xray has
|
||||||
|
// consumed it after the startup liveness check, so remove it now.
|
||||||
|
try {
|
||||||
|
val ephemeralConfig = File(configFilesDir, "config.json")
|
||||||
|
if (ephemeralConfig.exists() && !ephemeralConfig.delete()) {
|
||||||
|
Log.w(TAG, "Could not delete ephemeral Xray config")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Could not delete ephemeral Xray config", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
''';
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
connectedLine,
|
||||||
|
'$deleteConfig$connectedLine',
|
||||||
|
'ephemeral config deletion',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text.contains('context.handleXrayCoreExit(config)')) {
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
' xrayProcess?.inputStream?.bufferedReader()?.use { reader ->\n',
|
||||||
|
' process.inputStream.bufferedReader().use { reader ->\n',
|
||||||
|
'Xray monitor process stream',
|
||||||
|
);
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
' val exitCode = xrayProcess?.waitFor()\n',
|
||||||
|
' val exitCode = process.waitFor()\n',
|
||||||
|
'Xray monitor process wait',
|
||||||
|
);
|
||||||
|
|
||||||
|
const oldExit = r''' if (AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTED) {
|
||||||
|
// Unexpected exit
|
||||||
|
stopCore(context)
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
const newExit = r''' if (xrayProcess === process &&
|
||||||
|
AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTED
|
||||||
|
) {
|
||||||
|
// Hold the Android TUN while the core is recovered. Killing
|
||||||
|
// the VPN service here would allow direct-network fallback.
|
||||||
|
xrayProcess = null
|
||||||
|
AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTING
|
||||||
|
if (context is XrayVPNService) {
|
||||||
|
context.handleXrayCoreExit(config)
|
||||||
|
} else {
|
||||||
|
stopCore(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
text = replaceOnce(text, oldExit, newExit, 'Xray fail-closed recovery');
|
||||||
|
}
|
||||||
|
|
||||||
|
writeIfChanged(file, before, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
void patchService(File file) {
|
||||||
|
final before = file.readAsStringSync();
|
||||||
|
var text = before;
|
||||||
|
|
||||||
|
if (!text.contains('private var recoveringXray = false')) {
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
' private var isRunning = false\n',
|
||||||
|
' @Volatile private var isRunning = false\n'
|
||||||
|
' @Volatile private var recoveringXray = false\n'
|
||||||
|
' private var currentConfig: XrayConfig? = null\n',
|
||||||
|
'service recovery fields',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldNullIntent = r''' if (intent == null) {
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
const newNullIntent = r''' if (intent == null) {
|
||||||
|
if (isRunning && currentConfig != null) return START_REDELIVER_INTENT
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
oldNullIntent,
|
||||||
|
newNullIntent,
|
||||||
|
'null intent lifecycle',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!text.contains(' currentConfig = config\n')) {
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
' cleanup()\n',
|
||||||
|
' cleanup()\n currentConfig = config\n',
|
||||||
|
'runtime config snapshot',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
' return START_STICKY\n',
|
||||||
|
' return START_REDELIVER_INTENT\n',
|
||||||
|
'redeliver START command',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!text.contains('private data class SecureSocksCredentials(')) {
|
||||||
|
const credentialsHelper = r''' private data class SecureSocksCredentials(
|
||||||
|
val port: Int,
|
||||||
|
val username: String,
|
||||||
|
val password: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun secureSocksCredentials(config: XrayConfig): SecureSocksCredentials? {
|
||||||
|
val inbounds = try {
|
||||||
|
JSONObject(config.V2RAY_FULL_JSON_CONFIG).optJSONArray("inbounds")
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
} ?: return null
|
||||||
|
|
||||||
|
for (i in 0 until inbounds.length()) {
|
||||||
|
val inbound = inbounds.optJSONObject(i) ?: continue
|
||||||
|
if (inbound.optString("tag") != "revolt-secure-socks") continue
|
||||||
|
if (inbound.optString("protocol") != "socks" ||
|
||||||
|
inbound.optString("listen") != "127.0.0.1"
|
||||||
|
) return null
|
||||||
|
|
||||||
|
val settings = inbound.optJSONObject("settings") ?: return null
|
||||||
|
if (settings.optString("auth") != "password") return null
|
||||||
|
val user = settings.optJSONArray("users")?.optJSONObject(0) ?: return null
|
||||||
|
val port = inbound.optInt("port", -1)
|
||||||
|
val username = user.optString("user")
|
||||||
|
val password = user.optString("pass")
|
||||||
|
if (port <= 1024 || port > 65535 || username.isEmpty() || password.isEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return SecureSocksCredentials(port, username, password)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
''';
|
||||||
|
const runComment = r''' /**
|
||||||
|
* Starts the tun2socks process and initiates the FD transfer.
|
||||||
|
*/
|
||||||
|
private fun runTun2socks(config: XrayConfig) {
|
||||||
|
''';
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
runComment,
|
||||||
|
'$credentialsHelper$runComment',
|
||||||
|
'secure credentials helper',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text.contains('Authenticated ReVolt SOCKS5 inbound missing')) {
|
||||||
|
const commandComment = r''' // Command to start tun2socks.
|
||||||
|
// Note: We pass -sock-path to tell it where to listen for the FD.
|
||||||
|
''';
|
||||||
|
const securePrelude = r''' val secure = secureSocksCredentials(config)
|
||||||
|
?: throw IllegalStateException("Authenticated ReVolt SOCKS5 inbound missing")
|
||||||
|
config.LOCAL_SOCKS5_PORT = secure.port
|
||||||
|
|
||||||
|
''';
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
commandComment,
|
||||||
|
'$securePrelude$commandComment',
|
||||||
|
'secure tun2socks credentials',
|
||||||
|
);
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
r''' "-proxy", "socks5://127.0.0.1:${config.LOCAL_SOCKS5_PORT}",
|
||||||
|
''',
|
||||||
|
r''' "-proxy", "socks5://${secure.username}:${secure.password}@127.0.0.1:${secure.port}",
|
||||||
|
''',
|
||||||
|
'authenticated tun2socks URL',
|
||||||
|
);
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
r''' Log.d(TAG, "tun2socks command: ${cmd.joinToString(" ")}")
|
||||||
|
''',
|
||||||
|
' Log.d(TAG, "Starting tun2socks with authenticated loopback SOCKS5")\n',
|
||||||
|
'credential-safe logging',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text.contains('val process = pb.start()\n tun2socksProcess = process')) {
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
' tun2socksProcess = pb.start()\n',
|
||||||
|
' val process = pb.start()\n tun2socksProcess = process\n',
|
||||||
|
'capture tun2socks process identity',
|
||||||
|
);
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
' tun2socksProcess?.inputStream?.bufferedReader()?.use { reader ->\n',
|
||||||
|
' process.inputStream.bufferedReader().use { reader ->\n',
|
||||||
|
'tun2socks monitor stream',
|
||||||
|
);
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
' tun2socksProcess?.waitFor()\n',
|
||||||
|
' process.waitFor()\n',
|
||||||
|
'tun2socks monitor wait',
|
||||||
|
);
|
||||||
|
|
||||||
|
const oldRestart = r''' if (isRunning) {
|
||||||
|
// Restart if crashed and still supposed to be running
|
||||||
|
Log.e(TAG, "tun2socks exited unexpectedly, restarting...")
|
||||||
|
runTun2socks(config)
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
const newRestart = r''' if (isRunning && tun2socksProcess === process && currentConfig === config) {
|
||||||
|
Log.e(TAG, "tun2socks exited unexpectedly, recycling without dropping TUN")
|
||||||
|
Thread.sleep(350)
|
||||||
|
if (isRunning && currentConfig === config) runTun2socks(config)
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
oldRestart,
|
||||||
|
newRestart,
|
||||||
|
'tun2socks crash recovery',
|
||||||
|
);
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
' sendFd()\n',
|
||||||
|
' sendFd(process)\n',
|
||||||
|
'FD handoff process identity',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldStartFailure = r''' } catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to start tun2socks", e)
|
||||||
|
stopAll()
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
const newStartFailure = r''' } catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to start tun2socks; retrying with TUN held", e)
|
||||||
|
if (isRunning && currentConfig === config) {
|
||||||
|
Thread {
|
||||||
|
try {
|
||||||
|
Thread.sleep(600)
|
||||||
|
if (isRunning && currentConfig === config) runTun2socks(config)
|
||||||
|
} catch (_: InterruptedException) {}
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
oldStartFailure,
|
||||||
|
newStartFailure,
|
||||||
|
'tun2socks start failure recovery',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!text.contains('FD handoff failed; recycling tun2socks without dropping TUN')) {
|
||||||
|
const oldSendFd = r''' private fun sendFd() {
|
||||||
|
val fd = mInterface?.fileDescriptor ?: return
|
||||||
|
val sockFile = File(filesDir, "sock_path").absolutePath
|
||||||
|
|
||||||
|
Thread {
|
||||||
|
var tries = 0
|
||||||
|
while (tries < 10) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(500)
|
||||||
|
val localSocket = LocalSocket()
|
||||||
|
localSocket.connect(LocalSocketAddress(sockFile, LocalSocketAddress.Namespace.FILESYSTEM))
|
||||||
|
val out = localSocket.outputStream
|
||||||
|
// This magic call attaches the FD to the socket message
|
||||||
|
localSocket.setFileDescriptorsForSend(arrayOf(fd))
|
||||||
|
out.write(32) // Send a dummy byte to trigger the transfer
|
||||||
|
localSocket.setFileDescriptorsForSend(null)
|
||||||
|
localSocket.shutdownOutput()
|
||||||
|
localSocket.close()
|
||||||
|
break
|
||||||
|
} catch (e: Exception) {
|
||||||
|
tries++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
const newSendFd = r''' private fun sendFd(process: Process) {
|
||||||
|
val fd = mInterface?.fileDescriptor ?: return
|
||||||
|
val sockFile = File(filesDir, "sock_path").absolutePath
|
||||||
|
|
||||||
|
Thread {
|
||||||
|
var tries = 0
|
||||||
|
while (tries < 10 && isRunning && tun2socksProcess === process && process.isAlive) {
|
||||||
|
var localSocket: LocalSocket? = null
|
||||||
|
try {
|
||||||
|
Thread.sleep(500)
|
||||||
|
localSocket = LocalSocket()
|
||||||
|
localSocket.connect(LocalSocketAddress(sockFile, LocalSocketAddress.Namespace.FILESYSTEM))
|
||||||
|
val out = localSocket.outputStream
|
||||||
|
localSocket.setFileDescriptorsForSend(arrayOf(fd))
|
||||||
|
out.write(32)
|
||||||
|
out.flush()
|
||||||
|
localSocket.setFileDescriptorsForSend(null)
|
||||||
|
localSocket.shutdownOutput()
|
||||||
|
localSocket.close()
|
||||||
|
return@Thread
|
||||||
|
} catch (_: Exception) {
|
||||||
|
tries++
|
||||||
|
try { localSocket?.close() } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRunning && tun2socksProcess === process) {
|
||||||
|
Log.e(TAG, "FD handoff failed; recycling tun2socks without dropping TUN")
|
||||||
|
try { process.destroy() } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
text = replaceOnce(text, oldSendFd, newSendFd, 'bounded FD recovery');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text.contains('fun handleXrayCoreExit(config: XrayConfig)')) {
|
||||||
|
const cleanupComment = r''' /**
|
||||||
|
* Cleans up resources (tun2socks process, VPN interface) without stopping the service completely.
|
||||||
|
* Used when restarting or switching configurations.
|
||||||
|
*/
|
||||||
|
''';
|
||||||
|
const recovery = r''' fun handleXrayCoreExit(config: XrayConfig) {
|
||||||
|
if (!isRunning || currentConfig !== config || recoveringXray) return
|
||||||
|
recoveringXray = true
|
||||||
|
|
||||||
|
Thread {
|
||||||
|
var attempt = 0
|
||||||
|
try {
|
||||||
|
while (isRunning && currentConfig === config) {
|
||||||
|
attempt++
|
||||||
|
Thread.sleep(400L * attempt.coerceAtMost(5))
|
||||||
|
if (!isRunning || currentConfig !== config) break
|
||||||
|
|
||||||
|
Log.w(TAG, "Recovering Xray core attempt $attempt")
|
||||||
|
if (XrayCoreManager.startCore(this, config)) {
|
||||||
|
Log.w(TAG, "Xray core recovered without dropping TUN")
|
||||||
|
return@Thread
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt >= 3) {
|
||||||
|
attempt = 0
|
||||||
|
Thread.sleep(3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
} finally {
|
||||||
|
recoveringXray = false
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
''';
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
cleanupComment,
|
||||||
|
'$recovery$cleanupComment',
|
||||||
|
'Xray recovery loop',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text.contains(' recoveringXray = false\n tun2socksProcess?.destroy()')) {
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
' isRunning = false\n tun2socksProcess?.destroy()\n',
|
||||||
|
' isRunning = false\n recoveringXray = false\n tun2socksProcess?.destroy()\n',
|
||||||
|
'cancel recovery on cleanup',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text.contains(' currentConfig = null\n XrayCoreManager.stopCore(this)')) {
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
' cleanup()\n XrayCoreManager.stopCore(this)\n',
|
||||||
|
' cleanup()\n currentConfig = null\n XrayCoreManager.stopCore(this)\n',
|
||||||
|
'clear config on explicit stop',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeIfChanged(file, before, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final root = packageRoot();
|
||||||
|
final kotlin = Directory(
|
||||||
|
'${root.path}/android/src/main/kotlin/com/github/tfox/flutter_vless',
|
||||||
|
);
|
||||||
|
final core = File('${kotlin.path}/xray/core/XrayCoreManager.kt');
|
||||||
|
final service = File('${kotlin.path}/xray/service/XrayVPNService.kt');
|
||||||
|
if (!core.existsSync() || !service.existsSync()) {
|
||||||
|
fail('pinned Android runtime sources are missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
patchCore(core);
|
||||||
|
patchService(service);
|
||||||
|
stdout.writeln('[secure-socks patch] stable-core security patch is ready');
|
||||||
|
}
|
||||||
Reference in new issue
Block a user