Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f26c459f6c | ||
|
|
8cd450d5b1 | ||
|
|
9ac236a981 | ||
|
|
dfeb7f161c | ||
|
|
186dc906da |
No files matched your search
@@ -5,6 +5,92 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [3.3.4] — 2026-09-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **OS-level session-expiry enforcement.** The VPN service now holds an absolute
|
||||||
|
deadline and tears the tunnel down on its own clock — a main-looper handler
|
||||||
|
plus an `AlarmManager` exact alarm (falling back to an inexact alarm on
|
||||||
|
Android 13+ when exact alarms aren't granted). The app pushes
|
||||||
|
`now + expires_in_seconds` on each session sync, so a session ends even if the
|
||||||
|
UI process has been killed.
|
||||||
|
- **Session survival across process death.** The service persists the active
|
||||||
|
session and re-establishes it when Android restarts it (`START_STICKY`).
|
||||||
|
- **Support-ad extension reflected immediately.** A rewarded "Support us" ad now
|
||||||
|
forces an immediate server sync, so the countdown and expiry deadline update
|
||||||
|
without waiting for the next heartbeat.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Session-status beacon slowed.** Foreground poll: 5 s → 60 s; background poll:
|
||||||
|
30 s → 60 s.
|
||||||
|
- **Background-reliability guidance.** The "Always-on VPN" settings deep-link was
|
||||||
|
removed; the battery-optimisation exemption remains, with expiry now enforced
|
||||||
|
by the service itself.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Legacy `ass` connection-mode migration — unrecognized persisted modes now
|
||||||
|
default to TUN.
|
||||||
|
|
||||||
|
### Security & privacy
|
||||||
|
- Session credentials and the per-user VLESS UUID are now held app-private on
|
||||||
|
disk (`active_session.bin`) for the session's lifetime and removed on
|
||||||
|
disconnect or expiry. Previously they were memory-only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.3.3] — 2026-09-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **The session clock stayed at 00:00:00 on a live tunnel, and the notification
|
||||||
|
countdown froze with it.** `SessionTimer` is built lazily, so when the app reattaches
|
||||||
|
to a tunnel that outlived its UI process, `VpnConnection` had already reported
|
||||||
|
"connected" before the timer existed — and a change listener does not replay events
|
||||||
|
it was not present for. The timer now evaluates the current connection state once at
|
||||||
|
construction instead of waiting for a transition, and is created eagerly alongside
|
||||||
|
`VpnConnection`. Because the timer is what pushes text to the foreground
|
||||||
|
notification, a stopped clock also meant a frozen countdown; both come from this.
|
||||||
|
- Resuming the clock no longer requires `adoptedRunningRuntime` or a previous
|
||||||
|
successful sync, both of which are false in a freshly started process. A connected
|
||||||
|
tunnel with a stopped clock is now always treated as something to resume.
|
||||||
|
|
||||||
|
## [3.3.2] — 2026-09-01
|
||||||
|
|
||||||
|
Background reliability. 3.3.1 could show "connected" against a tunnel that was no
|
||||||
|
longer running; this release fixes the cause rather than the symptom, and removes the
|
||||||
|
machinery that existed to work around it.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Background reliability settings.** Android stops background services of apps that
|
||||||
|
are not exempt from Doze, and tears down the process group when a task is swiped
|
||||||
|
away. Two OS mechanisms prevent that, and the app now surfaces both: a one-tap
|
||||||
|
**battery optimisation exemption**, and a deep link to Android's **Always-on VPN**
|
||||||
|
settings (with a note about "Block connections without VPN"). Neither can be enabled
|
||||||
|
programmatically — each needs a single user tap, once.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **The app could report "connected" with no traffic flowing.** Startup state came from
|
||||||
|
`getConnectedServerDelay()`, which measures through
|
||||||
|
`AppConfigs.V2RAY_CONFIG?.LOCAL_SOCKS5_PORT ?: 10807`. `AppConfigs` lives in the VPN's
|
||||||
|
own process, so from the UI process it is always null and the port always fell back to
|
||||||
|
10807. That was accidentally correct while the SOCKS port was fixed; 3.3.1's
|
||||||
|
per-session ephemeral ports made it permanently wrong. Liveness is now determined from
|
||||||
|
the OS — whether our VPN service process is running, and whether a VPN transport is
|
||||||
|
actually present.
|
||||||
|
- **A frozen countdown could outlive the tunnel.** The foreground notification (id 1) is
|
||||||
|
owned by the VPN service via `startForeground`, but the UI process was posting to the
|
||||||
|
same id. When that process died, its notification stayed on screen with nothing left to
|
||||||
|
update it. The app no longer posts for a runtime that is not alive, and clears a stale
|
||||||
|
notification instead.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- **Standard/Extreme resilience modes** and the runtime-restart machinery behind them.
|
||||||
|
Recovery ran off a disconnect broadcast delivered to the app process, so it could never
|
||||||
|
fire in the case that actually matters — Android killing that process. It was
|
||||||
|
complexity substituting for background persistence.
|
||||||
|
- **Startup restoration**, including the config-snapshot rebuild. With the tunnel kept
|
||||||
|
alive by the OS, there is nothing to restore; the app simply reports what is running.
|
||||||
|
- The `resilience_mode` preference and its settings tile.
|
||||||
|
|
||||||
## [3.3.1] — 2026-08-31
|
## [3.3.1] — 2026-08-31
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
# Privacy Policy
|
# Privacy Policy
|
||||||
|
|
||||||
**Last updated:** August 2026
|
**Last updated:** September 2026
|
||||||
|
|
||||||
ReVoltVPN is a free VPN service built and operated by ReVolt Team.
|
ReVoltVPN is a free VPN service built and operated by ReVolt Team.
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ Support ads (the "Support us" button) extend an active session by 30 minutes and
|
|||||||
|
|
||||||
## Operational logs
|
## Operational logs
|
||||||
|
|
||||||
We keep a short in-memory log buffer (last 200 lines) for debugging and server health monitoring. These logs include truncated device IDs (first 8 characters only), session start/stop events, and error messages. Logs never leave the server, are not retained after a server restart, and are only accessible to the server operator via local console. We also maintain an aggregate total of all data ever transferred (in GB) for capacity planning — this counter is not tied to any individual device.
|
Server logs are written to the systemd journal (`journalctl -u hivemind`). These logs include truncated device IDs (first 8 characters only), session start/stop events, and error messages. Logs stay on the server and are accessible only to the server operator via local console; retention follows the server's systemd journal settings. We also maintain an aggregate total of all data ever transferred (in GB) for capacity planning — this counter is not tied to any individual device.
|
||||||
|
|
||||||
## What we do not collect
|
## What we do not collect
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ No email. No password. No payment. The ad pays for the server.
|
|||||||
## Protocol
|
## Protocol
|
||||||
|
|
||||||
- **Transport** — VLESS over XHTTP (HTTP/2 multiplexed, path-hidden behind `/revolt`)
|
- **Transport** — VLESS over XHTTP (HTTP/2 multiplexed, path-hidden behind `/revolt`)
|
||||||
- **Obfuscation** — Xray Reality spoofs `some website's` TLS. To any DPI box, your tunnel looks like a browser visiting that website
|
- **Obfuscation** — Xray Reality spoofs a real website's TLS. To any DPI box, your tunnel looks like a browser visiting that website
|
||||||
- **Encryption** — TLS 1.3 with borrowed certificate (Reality). No certbot, no domain ownership required
|
- **Encryption** — TLS 1.3 with borrowed certificate (Reality). No certbot, no domain ownership required
|
||||||
- **API** — Standard HTTPS to a separate domain. Tunnel destination is IP-pinned — domain compromise is DoS only
|
- **API** — Standard HTTPS to a separate domain. Tunnel destination is IP-pinned — domain compromise is DoS only
|
||||||
|
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ val dartExecutable = File(
|
|||||||
"bin/cache/dart-sdk/bin/dart"
|
"bin/cache/dart-sdk/bin/dart"
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
val flutterVlessPatchScript = File(
|
val flutterVlessRecoveryPatchScript = File(
|
||||||
projectRootDir,
|
projectRootDir,
|
||||||
"tool/patch_flutter_vless_allowed_apps.dart",
|
"tool/patch_flutter_vless_runtime_recovery.dart",
|
||||||
)
|
)
|
||||||
val flutterVlessSecureSocksPatchScript = File(
|
val flutterVlessSecureSocksPatchScript = File(
|
||||||
projectRootDir,
|
projectRootDir,
|
||||||
@@ -37,17 +37,17 @@ val flutterVlessSecureSocksScopeFixScript = File(
|
|||||||
"tool/patch_flutter_vless_secure_socks_scope_fix.dart",
|
"tool/patch_flutter_vless_secure_socks_scope_fix.dart",
|
||||||
)
|
)
|
||||||
|
|
||||||
val patchFlutterVlessAllowedApps = tasks.register<Exec>("patchFlutterVlessAllowedApps") {
|
val patchFlutterVlessRuntimeRecovery = tasks.register<Exec>("patchFlutterVlessRuntimeRecovery") {
|
||||||
group = "build setup"
|
group = "build setup"
|
||||||
description = "Patch pinned flutter_vless_android 1.1.5 with ReVolt routing/runtime hardening"
|
description = "Recover stale Xray core and tun2socks socket before restart"
|
||||||
workingDir(projectRootDir)
|
workingDir(projectRootDir)
|
||||||
|
|
||||||
doFirst {
|
doFirst {
|
||||||
if (!dartExecutable.isFile) {
|
if (!dartExecutable.isFile) {
|
||||||
throw GradleException("Flutter Dart executable not found: ${dartExecutable.absolutePath}")
|
throw GradleException("Flutter Dart executable not found: ${dartExecutable.absolutePath}")
|
||||||
}
|
}
|
||||||
if (!flutterVlessPatchScript.isFile) {
|
if (!flutterVlessRecoveryPatchScript.isFile) {
|
||||||
throw GradleException("flutter_vless patch script missing: ${flutterVlessPatchScript.absolutePath}")
|
throw GradleException("flutter_vless recovery patch script missing: ${flutterVlessRecoveryPatchScript.absolutePath}")
|
||||||
}
|
}
|
||||||
if (!File(projectRootDir, ".dart_tool/package_config.json").isFile) {
|
if (!File(projectRootDir, ".dart_tool/package_config.json").isFile) {
|
||||||
throw GradleException("Flutter package metadata missing. Run `flutter pub get` before Android compilation.")
|
throw GradleException("Flutter package metadata missing. Run `flutter pub get` before Android compilation.")
|
||||||
@@ -57,7 +57,7 @@ val patchFlutterVlessAllowedApps = tasks.register<Exec>("patchFlutterVlessAllowe
|
|||||||
commandLine(
|
commandLine(
|
||||||
dartExecutable.absolutePath,
|
dartExecutable.absolutePath,
|
||||||
"run",
|
"run",
|
||||||
flutterVlessPatchScript.absolutePath,
|
flutterVlessRecoveryPatchScript.absolutePath,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ val patchFlutterVlessSecureSocks = tasks.register<Exec>("patchFlutterVlessSecure
|
|||||||
group = "build setup"
|
group = "build setup"
|
||||||
description = "Add authenticated ephemeral SOCKS5 and fail-closed process recovery to pinned flutter_vless_android 1.1.5"
|
description = "Add authenticated ephemeral SOCKS5 and fail-closed process recovery to pinned flutter_vless_android 1.1.5"
|
||||||
workingDir(projectRootDir)
|
workingDir(projectRootDir)
|
||||||
dependsOn(patchFlutterVlessAllowedApps)
|
dependsOn(patchFlutterVlessRuntimeRecovery)
|
||||||
|
|
||||||
doFirst {
|
doFirst {
|
||||||
if (!dartExecutable.isFile) {
|
if (!dartExecutable.isFile) {
|
||||||
|
|||||||
@@ -7,6 +7,12 @@
|
|||||||
<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.FOREGROUND_SERVICE_SPECIAL_USE"/>
|
||||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
|
<!-- Doze/App Standby stop background services of non-exempt apps. A VPN
|
||||||
|
cannot stay up without this; Play allows it for VPN functionality. -->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
||||||
|
<!-- OS-level session-expiry alarm. Auto-granted on 31-32; on 33+ the daemon
|
||||||
|
falls back to setAndAllowWhileIdle if exact alarms aren't granted. -->
|
||||||
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
|
||||||
|
|
||||||
<!-- 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. -->
|
||||||
<uses-permission
|
<uses-permission
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.paladinvpn.app
|
|||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
|
import android.app.ActivityManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -11,10 +12,13 @@ import android.net.ConnectivityManager
|
|||||||
import android.net.Network
|
import android.net.Network
|
||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
import android.net.NetworkRequest
|
import android.net.NetworkRequest
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.PowerManager
|
||||||
import android.os.VibrationEffect
|
import android.os.VibrationEffect
|
||||||
import android.os.Vibrator
|
import android.os.Vibrator
|
||||||
import android.os.VibratorManager
|
import android.os.VibratorManager
|
||||||
|
import android.provider.Settings
|
||||||
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
|
||||||
@@ -29,6 +33,7 @@ class MainActivity : FlutterActivity() {
|
|||||||
private val notificationChannel = "com.revoltvpn.app/notification"
|
private val notificationChannel = "com.revoltvpn.app/notification"
|
||||||
private val hapticsChannel = "com.revoltvpn.app/haptics"
|
private val hapticsChannel = "com.revoltvpn.app/haptics"
|
||||||
private val networkChannel = "com.revoltvpn.app/network"
|
private val networkChannel = "com.revoltvpn.app/network"
|
||||||
|
private val powerChannel = "com.revoltvpn.app/power"
|
||||||
|
|
||||||
private var notificationChannelReady = false
|
private var notificationChannelReady = false
|
||||||
private var networkCallback: ConnectivityManager.NetworkCallback? = null
|
private var networkCallback: ConnectivityManager.NetworkCallback? = null
|
||||||
@@ -46,6 +51,31 @@ class MainActivity : FlutterActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, powerChannel)
|
||||||
|
.setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
// Doze and App Standby stop background services of apps that
|
||||||
|
// are not exempt. A VPN has to be exempt to survive the screen
|
||||||
|
// going off, so surface the real state rather than guessing.
|
||||||
|
"isIgnoringBatteryOptimizations" ->
|
||||||
|
result.success(isIgnoringBatteryOptimizations())
|
||||||
|
"requestIgnoreBatteryOptimizations" ->
|
||||||
|
result.success(requestIgnoreBatteryOptimizations())
|
||||||
|
"setSessionExpiry" -> {
|
||||||
|
val expiresAtMs = call.argument<Number>("expiresAtMs")?.toLong() ?: 0L
|
||||||
|
if (expiresAtMs > 0) {
|
||||||
|
sendSessionExpiryToDaemon(expiresAtMs)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
// Is our VPN runtime actually alive? The service lives in
|
||||||
|
// :RunSoLibXrayDaemon, a separate process, so its statics are
|
||||||
|
// NOT visible here -- this is the only honest check.
|
||||||
|
"isVpnRuntimeAlive" -> result.success(isVpnRuntimeAlive())
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, notificationChannel)
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, notificationChannel)
|
||||||
.setMethodCallHandler { call, result ->
|
.setMethodCallHandler { call, result ->
|
||||||
if (call.method == "updateNotificationText") {
|
if (call.method == "updateNotificationText") {
|
||||||
@@ -88,6 +118,96 @@ class MainActivity : FlutterActivity() {
|
|||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when the OS has exempted us from Doze / App Standby. */
|
||||||
|
private fun isIgnoringBatteryOptimizations(): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
|
||||||
|
return try {
|
||||||
|
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
pm.isIgnoringBatteryOptimizations(packageName)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the system exemption prompt. Android shows this as a single dialog;
|
||||||
|
* it cannot be granted silently, so the caller must treat a false return as
|
||||||
|
* "ask again later" rather than a denial.
|
||||||
|
*/
|
||||||
|
private fun requestIgnoreBatteryOptimizations(): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
|
||||||
|
if (isIgnoringBatteryOptimizations()) return true
|
||||||
|
return try {
|
||||||
|
startActivity(
|
||||||
|
Intent(
|
||||||
|
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
|
||||||
|
Uri.parse("package:$packageName"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
true
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Some OEM builds hide the direct intent; fall back to the list.
|
||||||
|
try {
|
||||||
|
startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
|
||||||
|
true
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pushes the session-expiry deadline to the VPN service process. */
|
||||||
|
private fun sendSessionExpiryToDaemon(expiresAtMs: Long) {
|
||||||
|
try {
|
||||||
|
val intent = Intent(this, XrayVPNService::class.java)
|
||||||
|
intent.putExtra("SESSION_EXPIRES_AT_MS", expiresAtMs)
|
||||||
|
startService(intent)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Daemon not reachable; its persisted expiry still applies.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the VPN runtime is genuinely alive.
|
||||||
|
*
|
||||||
|
* The Xray service runs in :RunSoLibXrayDaemon, a separate process, so its
|
||||||
|
* statics (AppConfigs.V2RAY_CONFIG, V2RAY_STATE) are NOT visible from here --
|
||||||
|
* reading them yields a fresh, empty copy. getRunningAppProcesses still
|
||||||
|
* returns our *own* processes, which makes it the one reliable signal.
|
||||||
|
*/
|
||||||
|
private fun isVpnRuntimeAlive(): Map<String, Any> {
|
||||||
|
var processAlive = false
|
||||||
|
try {
|
||||||
|
val am = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||||
|
processAlive = am.runningAppProcesses
|
||||||
|
?.any { it.processName.endsWith(VPN_PROCESS_SUFFIX) } == true
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
|
||||||
|
var vpnTransport = false
|
||||||
|
try {
|
||||||
|
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
vpnTransport = cm.allNetworks.any { network ->
|
||||||
|
cm.getNetworkCapabilities(network)
|
||||||
|
?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapOf(
|
||||||
|
"processAlive" to processAlive,
|
||||||
|
"vpnTransport" to vpnTransport,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes a notification left behind by a runtime that is no longer alive. */
|
||||||
|
private fun cancelVpnNotification() {
|
||||||
|
try {
|
||||||
|
NotificationManagerCompat.from(this).cancel(NOTIFICATION_ID)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun updateVpnNotification(title: String, text: String, actionLabel: String) {
|
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) !=
|
||||||
@@ -96,6 +216,15 @@ class MainActivity : FlutterActivity() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The foreground notification (id 1) is owned by XrayVPNService via
|
||||||
|
// startForeground. Posting to it from this process while the service is
|
||||||
|
// gone leaves a frozen countdown nothing will ever update again.
|
||||||
|
val alive = isVpnRuntimeAlive()["processAlive"] == true
|
||||||
|
if (!alive) {
|
||||||
|
cancelVpnNotification()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ensureNotificationChannel()
|
ensureNotificationChannel()
|
||||||
|
|
||||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
@@ -344,6 +473,7 @@ class MainActivity : FlutterActivity() {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val NOTIFICATION_ID = 1
|
private const val NOTIFICATION_ID = 1
|
||||||
|
private const val VPN_PROCESS_SUFFIX = ":RunSoLibXrayDaemon"
|
||||||
private const val NOTIFICATION_CHANNEL_ID = "XRAY_SERVICE_CHANNEL"
|
private const val NOTIFICATION_CHANNEL_ID = "XRAY_SERVICE_CHANNEL"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,6 +34,7 @@ class _SupportButtonState extends State<SupportButton> {
|
|||||||
final rewarded = await ad.showAd('support');
|
final rewarded = await ad.showAd('support');
|
||||||
if (rewarded) {
|
if (rewarded) {
|
||||||
await timer.markSupportRewardClaimed();
|
await timer.markSupportRewardClaimed();
|
||||||
|
await timer.syncNow();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _busy = false);
|
if (mounted) setState(() => _busy = false);
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
enum ConnectionMode { tun, proxy }
|
enum ConnectionMode { tun, proxy }
|
||||||
enum ResilienceMode { standard, extreme }
|
|
||||||
|
|
||||||
abstract final class ConnectionSettings {
|
abstract final class ConnectionSettings {
|
||||||
ConnectionSettings._();
|
ConnectionSettings._();
|
||||||
|
|
||||||
static const String _modeKey = 'connection_mode';
|
static const String _modeKey = 'connection_mode';
|
||||||
static const String _resilienceModeKey = 'resilience_mode';
|
|
||||||
|
|
||||||
static ConnectionMode _mode = ConnectionMode.tun;
|
static ConnectionMode _mode = ConnectionMode.tun;
|
||||||
static ResilienceMode _resilienceMode = ResilienceMode.standard;
|
|
||||||
static bool _initialized = false;
|
static bool _initialized = false;
|
||||||
|
|
||||||
static ConnectionMode get mode => _mode;
|
static ConnectionMode get mode => _mode;
|
||||||
static ResilienceMode get resilienceMode => _resilienceMode;
|
|
||||||
|
|
||||||
static Future<void> initialize() async {
|
static Future<void> initialize() async {
|
||||||
if (_initialized) return;
|
if (_initialized) return;
|
||||||
@@ -22,35 +19,37 @@ abstract final class ConnectionSettings {
|
|||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final storedMode = prefs.getString(_modeKey);
|
final storedMode = prefs.getString(_modeKey);
|
||||||
|
|
||||||
// Legacy 'ass' was the pre-V3 SOCKS5 label; map it to proxy. Everything
|
// Only the explicit current proxy value restores SOCKS5. Unknown and
|
||||||
// else (including the old 'auto') is TUN — there is no runtime auto mode.
|
// obsolete values, including the pre-V3 'auto' label, fail closed to
|
||||||
_mode = storedMode == ConnectionMode.proxy.name || storedMode == 'ass'
|
// full-tunnel TUN instead of silently selecting a weaker route.
|
||||||
|
_mode = storedMode == ConnectionMode.proxy.name
|
||||||
? ConnectionMode.proxy
|
? ConnectionMode.proxy
|
||||||
: ConnectionMode.tun;
|
: ConnectionMode.tun;
|
||||||
|
|
||||||
_resilienceMode =
|
|
||||||
prefs.getString(_resilienceModeKey) == ResilienceMode.extreme.name
|
|
||||||
? ResilienceMode.extreme
|
|
||||||
: ResilienceMode.standard;
|
|
||||||
|
|
||||||
if (storedMode != _mode.name) {
|
if (storedMode != _mode.name) {
|
||||||
await prefs.setString(_modeKey, _mode.name);
|
// Best-effort. _mode is already the safe TUN default in memory, so a
|
||||||
|
// failed write leaves nothing insecure — it only means the migration
|
||||||
|
// is retried next launch. initialize() runs from main() before
|
||||||
|
// runApp(), with no zone guard, so throwing here would black-screen
|
||||||
|
// the app on the fresh-install path for no security gain.
|
||||||
|
final migrated = await prefs.setString(_modeKey, _mode.name);
|
||||||
|
if (!migrated) {
|
||||||
|
debugPrint('[ConnectionSettings] Could not persist migrated mode.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> setMode(ConnectionMode mode) async {
|
static Future<bool> setMode(ConnectionMode mode) async {
|
||||||
|
if (_initialized && _mode == mode) return true;
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final saved = await prefs.setString(_modeKey, mode.name);
|
||||||
|
if (!saved) return false;
|
||||||
|
|
||||||
_mode = mode;
|
_mode = mode;
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
final prefs = await SharedPreferences.getInstance();
|
return true;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// Background-survival controls.
|
||||||
|
///
|
||||||
|
/// Android stops background services of apps that are not exempt from Doze and
|
||||||
|
/// App Standby, and it tears down the whole process group when a task is swiped
|
||||||
|
/// away. Those are OS behaviours, not app bugs; the battery-optimisation
|
||||||
|
/// exemption is the one sanctioned way an app can ask for around them, and it
|
||||||
|
/// still needs one user tap.
|
||||||
|
abstract final class PowerSettings {
|
||||||
|
PowerSettings._();
|
||||||
|
|
||||||
|
static const MethodChannel _channel = MethodChannel(
|
||||||
|
'com.revoltvpn.app/power',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Whether the OS has exempted this app from Doze / App Standby.
|
||||||
|
static Future<bool> isBatteryOptimisationDisabled() async {
|
||||||
|
if (kIsWeb) return true;
|
||||||
|
try {
|
||||||
|
return await _channel.invokeMethod<bool>(
|
||||||
|
'isIgnoringBatteryOptimizations',
|
||||||
|
) ??
|
||||||
|
false;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows the system exemption dialog. Returns false only when no system UI
|
||||||
|
/// could be opened at all — a user declining still returns true.
|
||||||
|
static Future<bool> requestDisableBatteryOptimisation() async {
|
||||||
|
if (kIsWeb) return true;
|
||||||
|
try {
|
||||||
|
return await _channel.invokeMethod<bool>(
|
||||||
|
'requestIgnoreBatteryOptimizations',
|
||||||
|
) ??
|
||||||
|
false;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the VPN runtime is genuinely alive.
|
||||||
|
///
|
||||||
|
/// The Xray service runs in a separate process, so nothing it stores in
|
||||||
|
/// statics is visible to us. This asks the OS instead: is our service process
|
||||||
|
/// running, and (for TUN) is there an active VPN transport?
|
||||||
|
static Future<VpnRuntimeState> runtimeState() async {
|
||||||
|
if (kIsWeb) return const VpnRuntimeState(processAlive: false, vpnTransport: false);
|
||||||
|
try {
|
||||||
|
final result = await _channel.invokeMapMethod<String, dynamic>(
|
||||||
|
'isVpnRuntimeAlive',
|
||||||
|
);
|
||||||
|
return VpnRuntimeState(
|
||||||
|
processAlive: result?['processAlive'] == true,
|
||||||
|
vpnTransport: result?['vpnTransport'] == true,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return const VpnRuntimeState(processAlive: false, vpnTransport: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pushes the absolute session-expiry timestamp (epoch ms) to the VPN
|
||||||
|
/// service so it can tear the tunnel down even when the UI process is gone.
|
||||||
|
static Future<void> setSessionExpiry(int expiresAtMs) async {
|
||||||
|
if (kIsWeb) return;
|
||||||
|
try {
|
||||||
|
await _channel.invokeMethod<void>('setSessionExpiry', {
|
||||||
|
'expiresAtMs': expiresAtMs,
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// Best-effort. The daemon also keeps its last persisted expiry.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class VpnRuntimeState {
|
||||||
|
final bool processAlive;
|
||||||
|
final bool vpnTransport;
|
||||||
|
|
||||||
|
const VpnRuntimeState({
|
||||||
|
required this.processAlive,
|
||||||
|
required this.vpnTransport,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// TUN needs both a live service and a real VPN interface. Proxy mode has no
|
||||||
|
/// VPN interface at all, so the service process is the whole signal.
|
||||||
|
bool aliveFor({required bool tunMode}) =>
|
||||||
|
processAlive && (!tunMode || vpnTransport);
|
||||||
|
}
|
||||||
@@ -7,11 +7,13 @@ import 'package:revoltvpn/logic/app_config.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/hivemind_service.dart';
|
||||||
import 'package:revoltvpn/logic/notification_service.dart';
|
import 'package:revoltvpn/logic/notification_service.dart';
|
||||||
|
import 'package:revoltvpn/logic/power_settings.dart';
|
||||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||||
|
|
||||||
class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
int _tickCount = 0;
|
int _tickCount = 0;
|
||||||
|
int _lastPushedExpiryAtMs = 0;
|
||||||
bool _appBackgrounded = false;
|
bool _appBackgrounded = false;
|
||||||
|
|
||||||
final VpnConnection vpnConnection;
|
final VpnConnection vpnConnection;
|
||||||
@@ -36,8 +38,12 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
static const int _maxOfflineSeconds = 120;
|
static const int _maxOfflineSeconds = 120;
|
||||||
int _offlineSeconds = 0;
|
int _offlineSeconds = 0;
|
||||||
|
|
||||||
static const int _pollIntervalSeconds = 5;
|
// The countdown ticks locally every second; these only drive the slow
|
||||||
static const int _backgroundPollIntervalSeconds = 30;
|
// server heartbeat that catches server-side changes (support-ad extension,
|
||||||
|
// 10 GB cap, session-ended). Keeping it slow avoids a chatty /session/status
|
||||||
|
// beacon (see internal/LAUNCH_READINESS.md "cut the beacon rate").
|
||||||
|
static const int _pollIntervalSeconds = 60;
|
||||||
|
static const int _backgroundPollIntervalSeconds = 60;
|
||||||
|
|
||||||
int _lastUsedBytes = 0;
|
int _lastUsedBytes = 0;
|
||||||
DateTime? _lastSuccessfulSyncAt;
|
DateTime? _lastSuccessfulSyncAt;
|
||||||
@@ -47,6 +53,11 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
vpnConnection.addListener(_onVpnConnectionChanged);
|
vpnConnection.addListener(_onVpnConnectionChanged);
|
||||||
unawaited(_loadSupportRewardState());
|
unawaited(_loadSupportRewardState());
|
||||||
|
// This provider is built lazily, so the tunnel may already be up and
|
||||||
|
// VpnConnection may already have fired its change before we attached.
|
||||||
|
// A listener does not replay history -- evaluate the current state once.
|
||||||
|
// Deferred so we never notify during the build that created us.
|
||||||
|
unawaited(Future.microtask(_onVpnConnectionChanged));
|
||||||
}
|
}
|
||||||
|
|
||||||
int get remaining => _remainingSeconds;
|
int get remaining => _remainingSeconds;
|
||||||
@@ -102,18 +113,15 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onVpnConnectionChanged() {
|
void _onVpnConnectionChanged() {
|
||||||
|
// A live tunnel with a stopped clock is always wrong, however we got here:
|
||||||
|
// adopting a tunnel that outlived the UI process, recovering from a blip, or
|
||||||
|
// attaching late to one that was already up. Previously this required either
|
||||||
|
// adoptedRunningRuntime or _hasSyncedOnce, both false on a fresh process, so
|
||||||
|
// the clock stayed at 00:00:00 and the notification countdown froze with it.
|
||||||
if (vpnConnection.status == VpnStatus.connected &&
|
if (vpnConnection.status == VpnStatus.connected &&
|
||||||
!isRunning &&
|
!isRunning &&
|
||||||
vpnConnection.isStartupRestoration) {
|
!_isDisconnecting) {
|
||||||
_resumeTicking();
|
debugPrint('[Timer] Connected with a stopped clock - resuming.');
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (vpnConnection.status == VpnStatus.connected &&
|
|
||||||
!isRunning &&
|
|
||||||
!_isDisconnecting &&
|
|
||||||
_hasSyncedOnce) {
|
|
||||||
debugPrint('[Timer] VPN reconnected after blip - resuming.');
|
|
||||||
_resumeTicking();
|
_resumeTicking();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -157,6 +165,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
_lastSuccessfulSyncAt = null;
|
_lastSuccessfulSyncAt = null;
|
||||||
_currentSpeedKBps = 0.0;
|
_currentSpeedKBps = 0.0;
|
||||||
_tickCount = 0;
|
_tickCount = 0;
|
||||||
|
_lastPushedExpiryAtMs = 0;
|
||||||
_hasSyncedOnce = false;
|
_hasSyncedOnce = false;
|
||||||
_consecutiveFailures = 0;
|
_consecutiveFailures = 0;
|
||||||
_offlineSeconds = 0;
|
_offlineSeconds = 0;
|
||||||
@@ -211,6 +220,10 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Force an immediate server sync (e.g. after a support-ad extension so the
|
||||||
|
/// countdown reflects the new expiry without waiting for the next heartbeat).
|
||||||
|
Future<void> syncNow() => _syncWithHivemind();
|
||||||
|
|
||||||
Future<void> disconnect({String reason = 'User requested'}) async {
|
Future<void> disconnect({String reason = 'User requested'}) async {
|
||||||
await _doDisconnect(reason);
|
await _doDisconnect(reason);
|
||||||
}
|
}
|
||||||
@@ -284,6 +297,14 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
|
|
||||||
_remainingAtLastSync = expiresValue.toInt();
|
_remainingAtLastSync = expiresValue.toInt();
|
||||||
_remainingSeconds = _remainingAtLastSync;
|
_remainingSeconds = _remainingAtLastSync;
|
||||||
|
// Push the absolute deadline to the VPN service so it can tear the
|
||||||
|
// tunnel down on its own clock, even if this UI process is gone.
|
||||||
|
final expiresAtMs =
|
||||||
|
DateTime.now().millisecondsSinceEpoch + _remainingSeconds * 1000;
|
||||||
|
if ((expiresAtMs - _lastPushedExpiryAtMs).abs() > 5000) {
|
||||||
|
_lastPushedExpiryAtMs = expiresAtMs;
|
||||||
|
unawaited(PowerSettings.setSessionExpiry(expiresAtMs));
|
||||||
|
}
|
||||||
_usedBytes = _readNonNegativeInt(data['used_bytes'], _usedBytes);
|
_usedBytes = _readNonNegativeInt(data['used_bytes'], _usedBytes);
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
|
|||||||
+27
-185
@@ -6,6 +6,7 @@ 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/local_socks_tester.dart';
|
||||||
import 'package:revoltvpn/logic/network_monitor.dart';
|
import 'package:revoltvpn/logic/network_monitor.dart';
|
||||||
|
import 'package:revoltvpn/logic/power_settings.dart';
|
||||||
import 'package:revoltvpn/logic/secure_socks_session.dart';
|
import 'package:revoltvpn/logic/secure_socks_session.dart';
|
||||||
|
|
||||||
enum VpnStatus {
|
enum VpnStatus {
|
||||||
@@ -17,8 +18,6 @@ enum VpnStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class VpnConnection extends ChangeNotifier {
|
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,8 +28,10 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
String? get errorMessage => _errorMessage;
|
String? get errorMessage => _errorMessage;
|
||||||
|
|
||||||
bool _isStartupRestoration = false;
|
/// True when the UI attached to a tunnel that was already running, rather
|
||||||
bool get isStartupRestoration => _isStartupRestoration;
|
/// than starting one itself. The session timer uses this to resume ticking.
|
||||||
|
bool _adoptedRunningRuntime = false;
|
||||||
|
bool get adoptedRunningRuntime => _adoptedRunningRuntime;
|
||||||
|
|
||||||
bool _serverReachable = false;
|
bool _serverReachable = false;
|
||||||
bool get serverReachable => _serverReachable;
|
bool get serverReachable => _serverReachable;
|
||||||
@@ -38,9 +39,6 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
ConnectionMode _activeMode = ConnectionMode.tun;
|
ConnectionMode _activeMode = ConnectionMode.tun;
|
||||||
ConnectionMode get activeMode => _activeMode;
|
ConnectionMode get activeMode => _activeMode;
|
||||||
|
|
||||||
ResilienceMode _activeResilienceMode = ResilienceMode.standard;
|
|
||||||
ResilienceMode get activeResilienceMode => _activeResilienceMode;
|
|
||||||
|
|
||||||
String _networkTransport = 'unknown';
|
String _networkTransport = 'unknown';
|
||||||
String get networkTransport => _networkTransport;
|
String get networkTransport => _networkTransport;
|
||||||
|
|
||||||
@@ -70,16 +68,10 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Timer? _healthTimer;
|
Timer? _healthTimer;
|
||||||
Timer? _restartTimer;
|
|
||||||
StreamSubscription<NetworkSnapshot>? _networkSubscription;
|
StreamSubscription<NetworkSnapshot>? _networkSubscription;
|
||||||
|
|
||||||
String? _lastBaseConfig;
|
|
||||||
String _lastRemark = 'Revolt VPN';
|
|
||||||
bool _lastVerifyLocalSocks = false;
|
|
||||||
SecureSocksSession? _lastSecureSocks;
|
SecureSocksSession? _lastSecureSocks;
|
||||||
bool _restartInProgress = false;
|
|
||||||
bool _userDisconnecting = false;
|
bool _userDisconnecting = false;
|
||||||
int _connectionEpoch = 0;
|
|
||||||
|
|
||||||
late final FlutterVless _vless;
|
late final FlutterVless _vless;
|
||||||
bool _initialized = false;
|
bool _initialized = false;
|
||||||
@@ -88,7 +80,6 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
|
|
||||||
VpnConnection() {
|
VpnConnection() {
|
||||||
_activeMode = ConnectionSettings.mode;
|
_activeMode = ConnectionSettings.mode;
|
||||||
_activeResilienceMode = ConnectionSettings.resilienceMode;
|
|
||||||
_init();
|
_init();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +98,6 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
|
|
||||||
await ConnectionSettings.initialize();
|
await ConnectionSettings.initialize();
|
||||||
_activeMode = ConnectionSettings.mode;
|
_activeMode = ConnectionSettings.mode;
|
||||||
_activeResilienceMode = ConnectionSettings.resilienceMode;
|
|
||||||
|
|
||||||
_vless = FlutterVless(
|
_vless = FlutterVless(
|
||||||
onStatusChanged: (status) {
|
onStatusChanged: (status) {
|
||||||
@@ -150,59 +140,30 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
final coreVersion = await _vless.getCoreVersion();
|
final coreVersion = await _vless.getCoreVersion();
|
||||||
debugPrint('[VPN] Xray core version: $coreVersion');
|
debugPrint('[VPN] Xray core version: $coreVersion');
|
||||||
|
|
||||||
final delay = await _vless.getConnectedServerDelay();
|
|
||||||
if (delay > 0) {
|
|
||||||
_isStartupRestoration = true;
|
|
||||||
_lastVerifyLocalSocks = _activeMode == ConnectionMode.proxy;
|
|
||||||
_setStatus(VpnStatus.connected, _connectedLabel);
|
|
||||||
unawaited(_restoreRecoverySnapshot());
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
|
await _adoptRunningRuntime();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Android can kill the Dart process while the VPN foreground service keeps
|
/// Reflect a tunnel that is already up when the UI starts.
|
||||||
/// 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
|
/// This asks the OS whether our VPN service process is alive, rather than
|
||||||
/// because only tun2socks consumes them. In proxy mode those credentials are
|
/// probing a local SOCKS port. The old probe called getConnectedServerDelay(),
|
||||||
/// the user's own client configuration, so rotating them behind their back
|
/// which reads AppConfigs.V2RAY_CONFIG?.LOCAL_SOCKS5_PORT ?: 10807 — but
|
||||||
/// would silently break it — that case is surfaced in the UI instead.
|
/// AppConfigs lives in the VPN's own process, so from here it is always null
|
||||||
Future<void> _restoreRecoverySnapshot() async {
|
/// and the port always fell back to 10807. That was correct only while the
|
||||||
if (kIsWeb || _activeMode != ConnectionMode.tun) return;
|
/// SOCKS port was fixed; per-session ephemeral ports made it permanently
|
||||||
|
/// wrong, so the app reported "connected" against a dead tunnel.
|
||||||
final epoch = _connectionEpoch;
|
Future<void> _adoptRunningRuntime() async {
|
||||||
bool stale() =>
|
if (kIsWeb) return;
|
||||||
_connectionEpoch != epoch ||
|
|
||||||
_userDisconnecting ||
|
|
||||||
_status != VpnStatus.connected;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final realUrl = await HivemindService.fetchConfigDirectly(
|
final state = await PowerSettings.runtimeState();
|
||||||
skipAdBypass: true,
|
if (state.aliveFor(tunMode: _activeMode == ConnectionMode.tun)) {
|
||||||
);
|
_adoptedRunningRuntime = true;
|
||||||
// A real connect() may have landed while the fetch was in flight; never
|
_setStatus(VpnStatus.connected, _connectedLabel);
|
||||||
// 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) {
|
} catch (e) {
|
||||||
// Best-effort: without it the tunnel still works, only Extreme recovery
|
debugPrint('[VPN] Runtime liveness check failed: $e');
|
||||||
// stays unavailable until the next manual connect.
|
|
||||||
debugPrint('[VPN] Could not rebuild recovery snapshot: $e');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,40 +183,21 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case VlessConnectionState.disconnected:
|
case VlessConnectionState.disconnected:
|
||||||
// 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();
|
_clearRuntimeSnapshot();
|
||||||
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case VlessConnectionState.connecting:
|
case VlessConnectionState.connecting:
|
||||||
if (!_restartInProgress) {
|
_setStatus(VpnStatus.connecting, 'Establishing tunnel…');
|
||||||
_setStatus(VpnStatus.connecting, 'Establishing tunnel…');
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case VlessConnectionState.disconnecting:
|
case VlessConnectionState.disconnecting:
|
||||||
_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;
|
||||||
@@ -269,12 +211,9 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
|
|
||||||
_cancelled = false;
|
_cancelled = false;
|
||||||
_userDisconnecting = false;
|
_userDisconnecting = false;
|
||||||
_restartTimer?.cancel();
|
|
||||||
_connectionEpoch++;
|
|
||||||
|
|
||||||
await ConnectionSettings.initialize();
|
await ConnectionSettings.initialize();
|
||||||
_activeMode = ConnectionSettings.mode;
|
_activeMode = ConnectionSettings.mode;
|
||||||
_activeResilienceMode = ConnectionSettings.resilienceMode;
|
|
||||||
|
|
||||||
if (!kIsWeb && !_initialized) {
|
if (!kIsWeb && !_initialized) {
|
||||||
_errorMessage = 'VPN service unavailable.';
|
_errorMessage = 'VPN service unavailable.';
|
||||||
@@ -369,10 +308,7 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_lastBaseConfig = secureSocks.configJson;
|
|
||||||
_lastSecureSocks = secureSocks;
|
_lastSecureSocks = secureSocks;
|
||||||
_lastRemark = remark;
|
|
||||||
_lastVerifyLocalSocks = verifyLocalSocks;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[VPN] Tunnel start error: $e');
|
debugPrint('[VPN] Tunnel start error: $e');
|
||||||
await _stopRuntime();
|
await _stopRuntime();
|
||||||
@@ -436,96 +372,6 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
return _status == VpnStatus.connected;
|
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 {
|
Future<void> _stopRuntime() async {
|
||||||
try {
|
try {
|
||||||
await _vless.stopVless().timeout(const Duration(seconds: 5));
|
await _vless.stopVless().timeout(const Duration(seconds: 5));
|
||||||
@@ -535,7 +381,6 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
Future<void> disconnect() async {
|
Future<void> disconnect() async {
|
||||||
_cancelled = true;
|
_cancelled = true;
|
||||||
_userDisconnecting = true;
|
_userDisconnecting = true;
|
||||||
_restartTimer?.cancel();
|
|
||||||
HivemindService.cancel();
|
HivemindService.cancel();
|
||||||
|
|
||||||
if (_status == VpnStatus.disconnected ||
|
if (_status == VpnStatus.disconnected ||
|
||||||
@@ -586,10 +431,8 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _clearRuntimeSnapshot() {
|
void _clearRuntimeSnapshot() {
|
||||||
_lastBaseConfig = null;
|
|
||||||
_lastVerifyLocalSocks = false;
|
|
||||||
_lastSecureSocks = null;
|
_lastSecureSocks = null;
|
||||||
_isStartupRestoration = false;
|
_adoptedRunningRuntime = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _setStatus(VpnStatus status, String message) {
|
void _setStatus(VpnStatus status, String message) {
|
||||||
@@ -609,7 +452,6 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_healthTimer?.cancel();
|
_healthTimer?.cancel();
|
||||||
_restartTimer?.cancel();
|
|
||||||
_networkSubscription?.cancel();
|
_networkSubscription?.cancel();
|
||||||
// Provider/UI disposal is not a user disconnect command. Keeping teardown
|
// Provider/UI disposal is not a user disconnect command. Keeping teardown
|
||||||
// in disconnect() prevents lifecycle churn from silently dropping the VPN.
|
// in disconnect() prevents lifecycle churn from silently dropping the VPN.
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ class ReVoltApp extends StatelessWidget {
|
|||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider(create: (_) => VpnConnection(), lazy: false),
|
ChangeNotifierProvider(create: (_) => VpnConnection(), lazy: false),
|
||||||
ChangeNotifierProxyProvider<VpnConnection, SessionTimer>(
|
ChangeNotifierProxyProvider<VpnConnection, SessionTimer>(
|
||||||
|
// Eager, like VpnConnection: the timer has to be listening before the
|
||||||
|
// engine reports an already-running tunnel, not after the intro.
|
||||||
|
lazy: false,
|
||||||
create: (ctx) =>
|
create: (ctx) =>
|
||||||
SessionTimer(vpnConnection: ctx.read<VpnConnection>()),
|
SessionTimer(vpnConnection: ctx.read<VpnConnection>()),
|
||||||
update: (_, vpn, prev) =>
|
update: (_, vpn, prev) =>
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:revoltvpn/logic/app_colors.dart';
|
||||||
|
import 'package:revoltvpn/logic/power_settings.dart';
|
||||||
|
|
||||||
|
/// Keeping the tunnel alive is an OS concern, not an app one.
|
||||||
|
///
|
||||||
|
/// Android stops background services of apps that are not exempt from Doze, and
|
||||||
|
/// removes the whole process group when its task is swiped away. There are
|
||||||
|
/// exactly two sanctioned ways around that, both requiring one user tap, and
|
||||||
|
/// neither can be enabled programmatically. This tile surfaces both.
|
||||||
|
class BackgroundReliabilityTile extends StatefulWidget {
|
||||||
|
const BackgroundReliabilityTile({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BackgroundReliabilityTile> createState() =>
|
||||||
|
_BackgroundReliabilityTileState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BackgroundReliabilityTileState extends State<BackgroundReliabilityTile>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
|
bool? _batteryExempt;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
_refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
// The exemption is granted in a system dialog, so the only reliable moment
|
||||||
|
// to re-read it is when we come back to the foreground.
|
||||||
|
if (state == AppLifecycleState.resumed) _refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refresh() async {
|
||||||
|
final exempt = await PowerSettings.isBatteryOptimisationDisabled();
|
||||||
|
if (mounted) setState(() => _batteryExempt = exempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _requestExemption() async {
|
||||||
|
final opened = await PowerSettings.requestDisableBatteryOptimisation();
|
||||||
|
if (!mounted || opened) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Could not open battery settings on this device.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final exempt = _batteryExempt;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const ListTile(
|
||||||
|
title: Text(
|
||||||
|
'Background reliability',
|
||||||
|
style: TextStyle(color: AppColors.textWhite, fontSize: 15),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'Android stops background apps to save power. Disabling battery '
|
||||||
|
'optimisation is what keeps the VPN running when the app is closed.',
|
||||||
|
style: TextStyle(color: AppColors.textDim, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
exempt == null
|
||||||
|
? 'Battery optimisation: checking…'
|
||||||
|
: exempt
|
||||||
|
? 'Battery optimisation: disabled for Revolt VPN ✓'
|
||||||
|
: 'Battery optimisation: still active — the system may '
|
||||||
|
'stop the VPN in the background.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: exempt == true ? AppColors.accent : AppColors.textMuted,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (exempt == false) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _requestExemption,
|
||||||
|
icon: const Icon(Icons.battery_saver),
|
||||||
|
label: const Text('Allow background activity'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,8 +51,14 @@ class _ConnectionModeTileState extends State<ConnectionModeTile> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ConnectionSettings.setMode(next);
|
final saved = await ConnectionSettings.setMode(next);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
if (!saved) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Could not save connection mode.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_mode = next;
|
_mode = next;
|
||||||
@@ -94,7 +100,7 @@ class _ConnectionModeTileState extends State<ConnectionModeTile> {
|
|||||||
|
|
||||||
if (vpn.status != VpnStatus.connected || !vpn.canTestActiveLocalSocks) {
|
if (vpn.status != VpnStatus.connected || !vpn.canTestActiveLocalSocks) {
|
||||||
final orphaned =
|
final orphaned =
|
||||||
vpn.status == VpnStatus.connected && vpn.isStartupRestoration;
|
vpn.status == VpnStatus.connected && vpn.adoptedRunningRuntime;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
@@ -174,7 +180,7 @@ class _ConnectionModeTileState extends State<ConnectionModeTile> {
|
|||||||
// memory that died with it. Saying "Connect" there would be a
|
// memory that died with it. Saying "Connect" there would be a
|
||||||
// lie — the user is already connected.
|
// lie — the user is already connected.
|
||||||
final orphaned = vpn.status == VpnStatus.connected &&
|
final orphaned = vpn.status == VpnStatus.connected &&
|
||||||
vpn.isStartupRestoration;
|
vpn.adoptedRunningRuntime;
|
||||||
if (orphaned) {
|
if (orphaned) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
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,10 +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/background_reliability.dart';
|
||||||
import 'package:revoltvpn/screens/settings/in_settings/connection_mode.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/lightning.dart';
|
import 'package:revoltvpn/screens/settings/in_settings/lightning.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/resilience.dart';
|
|
||||||
|
|
||||||
class SettingsScreen extends StatelessWidget {
|
class SettingsScreen extends StatelessWidget {
|
||||||
const SettingsScreen({super.key});
|
const SettingsScreen({super.key});
|
||||||
@@ -32,7 +32,7 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
children: const [
|
children: const [
|
||||||
ConnectionModeTile(),
|
ConnectionModeTile(),
|
||||||
ResilienceModeTile(),
|
BackgroundReliabilityTile(),
|
||||||
HapticFeedbackTile(),
|
HapticFeedbackTile(),
|
||||||
RainToggleTile(),
|
RainToggleTile(),
|
||||||
LightningToggleTile(),
|
LightningToggleTile(),
|
||||||
|
|||||||
+1
-1
@@ -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.1+27
|
version: 3.3.4+30
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.0.0 <4.0.0"
|
sdk: ">=3.0.0 <4.0.0"
|
||||||
|
|||||||
@@ -1,293 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
const packageName = 'flutter_vless_android';
|
|
||||||
const expectedVersionFragment = 'flutter_vless_android-1.1.5';
|
|
||||||
|
|
||||||
Never fail(String message) {
|
|
||||||
stderr.writeln('[flutter_vless 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,119 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
const packageName = 'flutter_vless_android';
|
||||||
|
const expectedVersionFragment = 'flutter_vless_android-1.1.5';
|
||||||
|
|
||||||
|
Never fail(String message) {
|
||||||
|
stderr.writeln('[flutter_vless recovery patch] $message');
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory packageRoot() {
|
||||||
|
final configFile = File('.dart_tool/package_config.json');
|
||||||
|
if (!configFile.existsSync()) {
|
||||||
|
fail('.dart_tool/package_config.json is missing; run flutter pub get first');
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(configFile.readAsStringSync());
|
||||||
|
if (decoded is! Map<String, dynamic> || decoded['packages'] is! List) {
|
||||||
|
fail('package_config.json is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? package;
|
||||||
|
for (final item in decoded['packages'] as List) {
|
||||||
|
if (item is Map && item['name'] == packageName) {
|
||||||
|
package = Map<String, dynamic>.from(item);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (package == null) fail('$packageName was not found');
|
||||||
|
|
||||||
|
final rootUriValue = package['rootUri'];
|
||||||
|
if (rootUriValue is! String || rootUriValue.isEmpty) {
|
||||||
|
fail('$packageName has no rootUri');
|
||||||
|
}
|
||||||
|
|
||||||
|
final rootUri = Uri.parse(rootUriValue);
|
||||||
|
final resolved = rootUri.hasScheme
|
||||||
|
? rootUri
|
||||||
|
: configFile.parent.absolute.uri.resolveUri(rootUri);
|
||||||
|
if (resolved.scheme != 'file') fail('unsupported package URI: $resolved');
|
||||||
|
|
||||||
|
final root = Directory.fromUri(resolved);
|
||||||
|
final normalized = root.absolute.path.replaceAll('\\', '/');
|
||||||
|
if (!normalized.contains(expectedVersionFragment)) {
|
||||||
|
fail('expected flutter_vless_android 1.1.5, got ${root.path}');
|
||||||
|
}
|
||||||
|
if (!root.existsSync()) fail('package root does not exist: ${root.path}');
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
void replaceOnce(
|
||||||
|
File file,
|
||||||
|
String oldValue,
|
||||||
|
String newValue,
|
||||||
|
String marker,
|
||||||
|
) {
|
||||||
|
if (!file.existsSync()) fail('missing upstream source: ${file.path}');
|
||||||
|
final text = file.readAsStringSync();
|
||||||
|
if (text.contains(marker)) {
|
||||||
|
stdout.writeln('[flutter_vless recovery patch] already current: ${file.path}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!text.contains(oldValue)) {
|
||||||
|
fail('upstream source changed; expected block not found in ${file.path}');
|
||||||
|
}
|
||||||
|
file.writeAsStringSync(text.replaceFirst(oldValue, newValue));
|
||||||
|
stdout.writeln('[flutter_vless recovery patch] patched: ${file.path}');
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final root = packageRoot();
|
||||||
|
final kotlin = Directory(
|
||||||
|
'${root.path}/android/src/main/kotlin/com/github/tfox/flutter_vless',
|
||||||
|
);
|
||||||
|
|
||||||
|
final serviceFile = File('${kotlin.path}/xray/service/XrayVPNService.kt');
|
||||||
|
if (!serviceFile.existsSync()) {
|
||||||
|
fail('pinned Android runtime service source is missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fast reconnect must not leave the previous Xray process owning the local
|
||||||
|
// SOCKS/HTTP ports.
|
||||||
|
replaceOnce(
|
||||||
|
serviceFile,
|
||||||
|
''' if (config != null) {
|
||||||
|
// Ensure clean state before starting
|
||||||
|
cleanup()
|
||||||
|
''',
|
||||||
|
''' if (config != null) {
|
||||||
|
// A fast reconnect must not leave the previous Xray process
|
||||||
|
// owning the local SOCKS/HTTP ports.
|
||||||
|
if (XrayCoreManager.isXrayRunning()) {
|
||||||
|
Log.w(TAG, "Stopping stale Xray core before restart")
|
||||||
|
XrayCoreManager.stopCore(this)
|
||||||
|
}
|
||||||
|
cleanup()
|
||||||
|
''',
|
||||||
|
'Stopping stale Xray core before restart',
|
||||||
|
);
|
||||||
|
|
||||||
|
// The tun2socks Unix socket must not survive a previous session.
|
||||||
|
replaceOnce(
|
||||||
|
serviceFile,
|
||||||
|
''' val tun2socksPath = File(applicationInfo.nativeLibraryDir, "libtun2socks.so").absolutePath
|
||||||
|
val sockPath = File(filesDir, "sock_path").absolutePath
|
||||||
|
''',
|
||||||
|
''' val tun2socksPath = File(applicationInfo.nativeLibraryDir, "libtun2socks.so").absolutePath
|
||||||
|
val socketFile = File(filesDir, "sock_path")
|
||||||
|
if (socketFile.exists() && !socketFile.delete()) {
|
||||||
|
Log.w(TAG, "Could not delete stale tun2socks socket")
|
||||||
|
}
|
||||||
|
val sockPath = socketFile.absolutePath
|
||||||
|
''',
|
||||||
|
'Could not delete stale tun2socks socket',
|
||||||
|
);
|
||||||
|
|
||||||
|
stdout.writeln('[flutter_vless recovery patch] runtime recovery patch is ready');
|
||||||
|
}
|
||||||
@@ -215,24 +215,156 @@ void patchService(File file) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const oldNullIntent = r''' if (intent == null) {
|
if (!text.contains('private data class PersistedSession(')) {
|
||||||
stopSelf()
|
const persistenceHelpers = r''' private data class PersistedSession(
|
||||||
return START_NOT_STICKY
|
val proxyOnly: Boolean,
|
||||||
}
|
val config: XrayConfig,
|
||||||
''';
|
val sessionExpiresAtMs: Long,
|
||||||
const newNullIntent = r''' if (intent == null) {
|
) : java.io.Serializable
|
||||||
if (isRunning && currentConfig != null) return START_REDELIVER_INTENT
|
|
||||||
stopSelf()
|
|
||||||
return START_NOT_STICKY
|
|
||||||
}
|
|
||||||
''';
|
|
||||||
text = replaceOnce(
|
|
||||||
text,
|
|
||||||
oldNullIntent,
|
|
||||||
newNullIntent,
|
|
||||||
'null intent lifecycle',
|
|
||||||
);
|
|
||||||
|
|
||||||
|
private var proxyOnlyMode = false
|
||||||
|
private var sessionExpiresAtMs = 0L
|
||||||
|
|
||||||
|
private val expiryHandler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||||
|
private val expiryRunnable = Runnable {
|
||||||
|
if (sessionExpiresAtMs > 0 && System.currentTimeMillis() >= sessionExpiresAtMs) {
|
||||||
|
Log.w(TAG, "Session expired; tearing down VPN")
|
||||||
|
sessionExpiresAtMs = 0L
|
||||||
|
cancelExpiryAlarm()
|
||||||
|
postSessionExpiredNotification()
|
||||||
|
deletePersistedSession()
|
||||||
|
stopAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleExpiryCheck() {
|
||||||
|
expiryHandler.removeCallbacks(expiryRunnable)
|
||||||
|
if (sessionExpiresAtMs > 0) {
|
||||||
|
val delay = (sessionExpiresAtMs - System.currentTimeMillis()).coerceAtLeast(0)
|
||||||
|
expiryHandler.postDelayed(expiryRunnable, delay)
|
||||||
|
}
|
||||||
|
scheduleExpiryAlarm()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleExpiryAlarm() {
|
||||||
|
cancelExpiryAlarm()
|
||||||
|
if (sessionExpiresAtMs <= 0) return
|
||||||
|
try {
|
||||||
|
val alarmManager = getSystemService(android.content.Context.ALARM_SERVICE) as android.app.AlarmManager
|
||||||
|
try {
|
||||||
|
alarmManager.setExactAndAllowWhileIdle(
|
||||||
|
android.app.AlarmManager.RTC_WAKEUP,
|
||||||
|
sessionExpiresAtMs,
|
||||||
|
sessionExpiredPendingIntent(),
|
||||||
|
)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
alarmManager.setAndAllowWhileIdle(
|
||||||
|
android.app.AlarmManager.RTC_WAKEUP,
|
||||||
|
sessionExpiresAtMs,
|
||||||
|
sessionExpiredPendingIntent(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to schedule expiry alarm", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelExpiryAlarm() {
|
||||||
|
try {
|
||||||
|
val alarmManager = getSystemService(android.content.Context.ALARM_SERVICE) as android.app.AlarmManager
|
||||||
|
alarmManager.cancel(sessionExpiredPendingIntent())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sessionExpiredPendingIntent(): android.app.PendingIntent {
|
||||||
|
val intent = Intent(this, XrayVPNService::class.java)
|
||||||
|
intent.putExtra("SESSION_EXPIRED", true)
|
||||||
|
val flags = android.app.PendingIntent.FLAG_UPDATE_CURRENT or
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
android.app.PendingIntent.FLAG_IMMUTABLE
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
return android.app.PendingIntent.getService(this, 1001, intent, flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persistSession() {
|
||||||
|
val config = currentConfig ?: return
|
||||||
|
try {
|
||||||
|
java.io.ObjectOutputStream(
|
||||||
|
java.io.FileOutputStream(File(filesDir, "active_session.bin")),
|
||||||
|
).use { it.writeObject(PersistedSession(proxyOnlyMode, config, sessionExpiresAtMs)) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to persist active session", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadPersistedSession(): PersistedSession? {
|
||||||
|
return try {
|
||||||
|
val file = File(filesDir, "active_session.bin")
|
||||||
|
if (!file.exists()) return null
|
||||||
|
java.io.ObjectInputStream(java.io.FileInputStream(file)).use {
|
||||||
|
it.readObject() as? PersistedSession
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to restore persisted session", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deletePersistedSession() {
|
||||||
|
try {
|
||||||
|
File(filesDir, "active_session.bin").delete()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to delete persisted session", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun postSessionExpiredNotification() {
|
||||||
|
try {
|
||||||
|
createNotificationChannel()
|
||||||
|
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
|
||||||
|
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
android.app.PendingIntent.FLAG_IMMUTABLE or android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
} else {
|
||||||
|
android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
}
|
||||||
|
val contentIntent = if (launchIntent != null) {
|
||||||
|
android.app.PendingIntent.getActivity(this, 0, launchIntent, flags)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
android.app.Notification.Builder(this, "vpn_service_channel")
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
android.app.Notification.Builder(this)
|
||||||
|
}
|
||||||
|
builder
|
||||||
|
.setContentTitle("Revolt VPN")
|
||||||
|
.setContentText("Session expired. Reconnect to continue.")
|
||||||
|
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
if (contentIntent != null) {
|
||||||
|
builder.setContentIntent(contentIntent)
|
||||||
|
}
|
||||||
|
val manager = getSystemService(android.app.NotificationManager::class.java)
|
||||||
|
manager?.notify(2, builder.build())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to post session expired notification", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
' override fun onCreate() {\n super.onCreate()\n }\n',
|
||||||
|
' override fun onCreate() {\n super.onCreate()\n }\n\n$persistenceHelpers',
|
||||||
|
'session persistence helpers',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot the running config so a START_STICKY restart can re-establish it.
|
||||||
if (!text.contains(' currentConfig = config\n')) {
|
if (!text.contains(' currentConfig = config\n')) {
|
||||||
text = replaceOnce(
|
text = replaceOnce(
|
||||||
text,
|
text,
|
||||||
@@ -244,11 +376,79 @@ void patchService(File file) {
|
|||||||
|
|
||||||
text = replaceOnce(
|
text = replaceOnce(
|
||||||
text,
|
text,
|
||||||
' return START_STICKY\n',
|
' val proxyOnly = intent.getBooleanExtra("PROXY_ONLY", false)\n',
|
||||||
' return START_REDELIVER_INTENT\n',
|
' val proxyOnly = intent.getBooleanExtra("PROXY_ONLY", false)\n proxyOnlyMode = proxyOnly\n persistSession()\n',
|
||||||
'redeliver START command',
|
'persist session snapshot',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
' if (command == AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE) {\n stopAll()\n return START_NOT_STICKY\n }\n',
|
||||||
|
' if (command == AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE) {\n deletePersistedSession()\n stopAll()\n return START_NOT_STICKY\n }\n',
|
||||||
|
'delete session on explicit stop',
|
||||||
|
);
|
||||||
|
|
||||||
|
const oldNullIntent = r''' if (intent == null) {
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
const newNullIntent = r''' if (intent == null) {
|
||||||
|
if (isRunning && currentConfig != null) return START_STICKY
|
||||||
|
val persisted = loadPersistedSession()
|
||||||
|
if (persisted != null) {
|
||||||
|
Log.w(TAG, "Re-establishing VPN from persisted session")
|
||||||
|
createNotificationChannel()
|
||||||
|
val notification = createNotification("VPN Service Running")
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= 34) {
|
||||||
|
startForeground(1, notification, FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
|
||||||
|
} else {
|
||||||
|
startForeground(1, notification)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to start foreground", e)
|
||||||
|
}
|
||||||
|
if (XrayCoreManager.isXrayRunning()) {
|
||||||
|
Log.w(TAG, "Stopping stale Xray core before restart")
|
||||||
|
XrayCoreManager.stopCore(this)
|
||||||
|
}
|
||||||
|
cleanup()
|
||||||
|
currentConfig = persisted.config
|
||||||
|
proxyOnlyMode = persisted.proxyOnly
|
||||||
|
sessionExpiresAtMs = persisted.sessionExpiresAtMs
|
||||||
|
if (XrayCoreManager.startCore(this, persisted.config)) {
|
||||||
|
if (!persisted.proxyOnly) {
|
||||||
|
setupVpn(persisted.config)
|
||||||
|
} else {
|
||||||
|
isRunning = true
|
||||||
|
}
|
||||||
|
scheduleExpiryCheck()
|
||||||
|
} else {
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
oldNullIntent,
|
||||||
|
newNullIntent,
|
||||||
|
'null intent lifecycle',
|
||||||
|
);
|
||||||
|
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
' val command = if (Build.VERSION.SDK_INT >= 33) {\n',
|
||||||
|
' if (intent.getBooleanExtra("SESSION_EXPIRED", false)) {\n Log.w(TAG, "Session-expiry alarm fired; tearing down VPN")\n sessionExpiresAtMs = 0L\n cancelExpiryAlarm()\n deletePersistedSession()\n postSessionExpiredNotification()\n stopAll()\n return START_NOT_STICKY\n }\n\n if (intent.hasExtra("SESSION_EXPIRES_AT_MS")) {\n sessionExpiresAtMs = intent.getLongExtra("SESSION_EXPIRES_AT_MS", 0L)\n scheduleExpiryCheck()\n persistSession()\n return if (isRunning && currentConfig != null) START_STICKY else START_NOT_STICKY\n }\n\n val command = if (Build.VERSION.SDK_INT >= 33) {\n',
|
||||||
|
'session expiry update',
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
if (!text.contains('private data class SecureSocksCredentials(')) {
|
if (!text.contains('private data class SecureSocksCredentials(')) {
|
||||||
const credentialsHelper = r''' private data class SecureSocksCredentials(
|
const credentialsHelper = r''' private data class SecureSocksCredentials(
|
||||||
val port: Int,
|
val port: Int,
|
||||||
@@ -508,12 +708,12 @@ void patchService(File file) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!text.contains(' recoveringXray = false\n tun2socksProcess?.destroy()')) {
|
if (!text.contains(' cancelExpiryAlarm()\n tun2socksProcess?.destroy()')) {
|
||||||
text = replaceOnce(
|
text = replaceOnce(
|
||||||
text,
|
text,
|
||||||
' isRunning = false\n tun2socksProcess?.destroy()\n',
|
' isRunning = false\n tun2socksProcess?.destroy()\n',
|
||||||
' isRunning = false\n recoveringXray = false\n tun2socksProcess?.destroy()\n',
|
' isRunning = false\n recoveringXray = false\n expiryHandler.removeCallbacks(expiryRunnable)\n cancelExpiryAlarm()\n tun2socksProcess?.destroy()\n',
|
||||||
'cancel recovery on cleanup',
|
'cancel recovery and expiry on cleanup',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user