Compare commits
53
Commits
v3.3.0
...
c8617835b1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8617835b1 | ||
|
|
550402a663 | ||
|
|
52e8cd8ca1 | ||
|
|
437d998663 | ||
|
|
e67f0b93ee | ||
|
|
00b84dde61 | ||
|
|
296b087db6 | ||
|
|
eab43b0420 | ||
|
|
e7ef251882 | ||
|
|
4e5c7a02db | ||
|
|
30d96a4049 | ||
|
|
66789d49c1 | ||
|
|
8ba3b6c543 | ||
|
|
d1ea13b01a | ||
|
|
34722c751b | ||
|
|
dd1082c8de | ||
|
|
742f37683e | ||
|
|
37753c6396 | ||
|
|
e14e900276 | ||
|
|
6d8a923475 | ||
|
|
4718a5912a | ||
|
|
5d2683f354 | ||
|
|
08b21dc5d9 | ||
|
|
bbc83ea425 | ||
|
|
9d67679fa8 | ||
|
|
3f7aead263 | ||
|
|
b6ed976346 | ||
|
|
bd41a3c693 | ||
|
|
061bd41920 | ||
|
|
ba89e35442 | ||
|
|
321bbae44d | ||
|
|
5f851f3485 | ||
|
|
e818d54b0e | ||
|
|
74e522e982 | ||
|
|
d2283c07cc | ||
|
|
ecdf6e549f | ||
|
|
6da601b565 | ||
|
|
14403e1d76 | ||
|
|
915f8260d5 | ||
|
|
9ac236a981 | ||
|
|
dfeb7f161c | ||
|
|
186dc906da | ||
|
|
ac52681452 | ||
|
|
ebaa2fc516 | ||
|
|
6fcde9dd7b | ||
|
|
a71161e7e5 | ||
|
|
da82a833cb | ||
|
|
0ab008b80b | ||
|
|
f7f425517c | ||
|
|
76bf84453f | ||
|
|
6c99366602 | ||
|
|
8ead3ff249 | ||
|
|
371db8da9f |
No files matched your search
@@ -0,0 +1,135 @@
|
||||
name: Android CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
android:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
|
||||
- name: Verify Gradle supply chain
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -x android/gradlew
|
||||
test -f android/gradlew.bat
|
||||
test -f android/gradle/wrapper/gradle-wrapper.jar
|
||||
test -s android/gradle/verification-metadata.xml
|
||||
test "$(sha256sum android/gradle/wrapper/gradle-wrapper.jar | awk '{print $1}')" = '7d3a4ac4de1c32b59bc6a4eb8ecb8e612ccd0cf1ae1e99f66902da64df296172'
|
||||
grep -qx 'distributionSha256Sum=efe9a3d147d948d7528a9887fa35abcf24ca1a43ad06439996490f77569b02d1' android/gradle/wrapper/gradle-wrapper.properties
|
||||
grep -q '<verify-metadata>true</verify-metadata>' android/gradle/verification-metadata.xml
|
||||
./android/gradlew --version
|
||||
|
||||
- name: Set up Flutter
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
flutter-version: '3.47.2'
|
||||
channel: stable
|
||||
cache: false
|
||||
|
||||
- name: Create CI app config
|
||||
run: cp lib/logic/app_config.example.dart lib/logic/app_config.dart
|
||||
|
||||
- name: Resolve dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Verify dependency lock is committed
|
||||
run: git diff --exit-code -- pubspec.lock
|
||||
|
||||
- name: Verify vendored runtime provenance
|
||||
if: ${{ hashFiles('local_packages/flutter_vless_android-1.1.5/UPSTREAM') != '' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PKG='local_packages/flutter_vless_android-1.1.5'
|
||||
test -f "$PKG/UPSTREAM"
|
||||
grep -q 'Tag: v1.1.5' "$PKG/UPSTREAM"
|
||||
grep -q 'path: local_packages/flutter_vless_android-1.1.5' pubspec.yaml
|
||||
! grep -q 'patchFlutterVless' android/app/build.gradle.kts
|
||||
|
||||
- name: Analyze
|
||||
run: flutter analyze
|
||||
|
||||
- name: Test
|
||||
run: flutter test
|
||||
|
||||
- name: Android lint
|
||||
working-directory: android
|
||||
run: ./gradlew :app:lintDebug --no-daemon
|
||||
|
||||
- name: Verify production release config fails closed
|
||||
shell: bash
|
||||
working-directory: android
|
||||
run: |
|
||||
set -euo pipefail
|
||||
set +e
|
||||
output="$(./gradlew :app:verifyReleaseAppConfig --no-daemon 2>&1)"
|
||||
status=$?
|
||||
set -e
|
||||
printf '%s\n' "$output"
|
||||
test "$status" -ne 0
|
||||
grep -Fq 'REVOLT_APP_CONFIG_SHA256 is required for production release builds' <<<"$output"
|
||||
|
||||
- name: Cold APK build
|
||||
run: flutter build apk --debug
|
||||
|
||||
- name: Verify vendored native source stayed immutable
|
||||
run: git diff --exit-code -- local_packages/flutter_vless_android-1.1.5
|
||||
|
||||
- name: Warm APK build
|
||||
run: flutter build apk --debug
|
||||
|
||||
- name: Release R8 smoke build
|
||||
env:
|
||||
REVOLT_CI_RELEASE_SMOKE: 'true'
|
||||
run: |
|
||||
flutter build apk --release
|
||||
test -f build/app/outputs/flutter-apk/app-release.apk
|
||||
test -f build/app/outputs/mapping/release/mapping.txt
|
||||
|
||||
- name: Verify tracked build inputs stayed immutable
|
||||
run: |
|
||||
git diff --exit-code -- \
|
||||
pubspec.yaml \
|
||||
pubspec.lock \
|
||||
local_packages/flutter_vless_android-1.1.5 \
|
||||
android/app/build.gradle.kts \
|
||||
android/app/proguard-rules.pro \
|
||||
android/gradle/verification-metadata.xml \
|
||||
android/gradle/wrapper/gradle-wrapper.properties \
|
||||
android/gradle/wrapper/gradle-wrapper.jar \
|
||||
android/gradlew \
|
||||
android/gradlew.bat
|
||||
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: revoltvpn-debug-apk
|
||||
if-no-files-found: error
|
||||
path: build/app/outputs/flutter-apk/app-debug.apk
|
||||
|
||||
- name: Upload resolved lockfile
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: revoltvpn-resolved-lockfile
|
||||
if-no-files-found: error
|
||||
path: pubspec.lock
|
||||
+1
-3
@@ -18,9 +18,6 @@ android/admob.properties
|
||||
lib/logic/app_config.dart
|
||||
|
||||
# ── File types the shipped app does not contain ────────────────────────────
|
||||
# Server code, infrastructure configs, captures, archives, dumps and logs.
|
||||
# None of these extensions are tracked, so a blanket rule is safe and stays
|
||||
# safe — it covers files that do not exist yet.
|
||||
*.py
|
||||
*.pyc
|
||||
*.json
|
||||
@@ -53,6 +50,7 @@ scraped/
|
||||
backup/
|
||||
backups/
|
||||
lib/logic/backup/
|
||||
server_files/
|
||||
|
||||
# ── Flutter / Dart build output ────────────────────────────────────────────
|
||||
.dart_tool/
|
||||
|
||||
+309
@@ -5,6 +5,315 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH
|
||||
|
||||
---
|
||||
|
||||
## [3.3.7] — 2026-10-17
|
||||
|
||||
### Added
|
||||
- **A warning before a session ends.** The VPN service schedules it off the same
|
||||
deadline it already enforces, so it still arrives when the app is closed, and
|
||||
a support top-up moves it with the deadline. It does not promise an exact
|
||||
number of minutes: on a sleeping phone Android may hold it back, and the app
|
||||
would rather say "soon" than say "5 minutes" with one left.
|
||||
- **"Disconnected — not protected" notification** when a session ends without
|
||||
you asking: the 2-hour expiry, the data limit, the server ending the
|
||||
session, or the tunnel stopping unexpectedly.
|
||||
- **SOCKS5 mode now asks for the notification permission.** It never did,
|
||||
because it never raises the VPN consent dialog, so on Android 13+ these
|
||||
alerts would have been dropped before they were shown.
|
||||
- **The app now shows what went wrong.** Connect errors, the reason a session
|
||||
ended, and progress while connecting appear under the status bar instead of
|
||||
the button silently going back to idle.
|
||||
|
||||
### Changed
|
||||
- **A control-plane outage no longer tears down a working tunnel.** The app used
|
||||
to disconnect itself after about two minutes of failed status polls, putting
|
||||
you back on your real IP; it now keeps the tunnel up and shows
|
||||
"Can't reach the server — still connected". Expiry is still enforced by the
|
||||
VPN service's own deadline and by the server.
|
||||
|
||||
### Fixed
|
||||
- **Rewarded ads no longer break connecting.** With ads enabled, the ad's
|
||||
server-side-verification nonce and the nonce the connect flow waited for were
|
||||
minted separately, so the client rejected the very session the ad created and
|
||||
failed with "Session not activated". The ad's nonce is now passed through to
|
||||
the connect call, the fake bypass callback is skipped when a real ad was
|
||||
watched, and the client polls longer (~36 s) for Google's callback.
|
||||
- **SOCKS5 "Reconnect" re-attaches to the current session** instead of
|
||||
requesting a new one, so repairing a lost proxy no longer needs (or grants)
|
||||
a fresh session.
|
||||
- **The end-of-session warning now actually arrives when the app is closed.**
|
||||
It was scheduled only on an inexact alarm, and a sleeping phone could hold
|
||||
that alarm back past the end of the session it was warning about — so in the
|
||||
one case the warning exists for, it never came. It now also runs off the VPN
|
||||
service's own timer, the same one that already ends the session on time.
|
||||
- **Disconnecting from Android's VPN settings no longer leaves a countdown in
|
||||
the notification shade.** It used to sit there frozen, showing time remaining
|
||||
on a tunnel that was already gone.
|
||||
- **The VPN reconnects itself after the tunnel core stops.** Recovery never
|
||||
worked: every attempt failed on the same internal error, and each one left
|
||||
another copy of the core running in the background. One restart now brings it
|
||||
back without dropping the connection.
|
||||
- **Disconnecting now releases your session on the server** instead of leaving
|
||||
it open until it expires on its own.
|
||||
- **A core that fails to start now says why.** Its output was being thrown
|
||||
away, so the only trace was an exit code.
|
||||
|
||||
---
|
||||
|
||||
## [3.3.6] — 2026-10-08
|
||||
|
||||
### Fixed
|
||||
- **VPN service notification icon.** The ongoing notification now uses the
|
||||
bundled `notification_icon` drawable instead of `notification_status_icon`,
|
||||
and the vendored runtime resolves that drawable by name at runtime (falling
|
||||
back to the platform icon only if it is missing), so the foreground-service
|
||||
notification no longer shows the generic Android info icon.
|
||||
- **"Test Local SOCKS" no longer claims an outbound it never tested.** Xray
|
||||
answers a SOCKS `CONNECT` before it dials upstream, so trusting the `0x00`
|
||||
reply reported success for unroutable addresses and for hostnames that can
|
||||
never resolve — meaning the test said OK while the server was down, which is
|
||||
exactly when it gets run. It now forces a request/response round trip through
|
||||
the tunnel and reports distinctly when the outbound never replies, drops the
|
||||
connection, or returns unexpected data.
|
||||
|
||||
---
|
||||
|
||||
## [3.3.5] — 2026-09-06
|
||||
|
||||
### Added
|
||||
- **Reproducible release verification.** The release build pins the production
|
||||
`app_config.dart` by SHA-256 and fails closed on any mismatch, so a release
|
||||
APK cannot silently ship a template or wrong server config.
|
||||
|
||||
### Changed
|
||||
- **Vendored Android runtime.** The pinned `flutter_vless` Android runtime is now
|
||||
vendored in-tree instead of being patched in the pub cache at build time.
|
||||
- **Harden the control-plane trust boundary.** `/session/status` fields are
|
||||
strictly validated (nonce, destination IP, port, SNI, XHTTP path), control
|
||||
responses are capped and never follow redirects, and the tunnel destination
|
||||
is pinned to the compiled server — a compromised API can no longer redirect
|
||||
user traffic.
|
||||
- **Authenticated local SOCKS5 is now mandatory.** The ephemeral local proxy
|
||||
replaces rather than filters inbounds, so only the authenticated SOCKS5
|
||||
listener can exist.
|
||||
- **Fail closed on connection-mode changes.** The mode is persisted before it is
|
||||
applied, and a failed save leaves the UI and runtime unchanged instead of
|
||||
silently selecting a weaker route.
|
||||
|
||||
### Fixed
|
||||
- Connect button no longer leaks a listener into a disposed `AnimationController`
|
||||
during teardown.
|
||||
- Disclosure acknowledgement is only persisted once the user accepts it.
|
||||
- Session countdown now uses a monotonic clock (cannot be extended by rolling the
|
||||
device clock back) and a VPN error tears the tunnel down instead of freezing
|
||||
the timer on a live session.
|
||||
- The VPN-service notification's disconnect action and countdown re-post are
|
||||
aligned with the vendored runtime.
|
||||
|
||||
### Removed
|
||||
- Unsupported server-selector UI (single server today; design retained for
|
||||
multi-server later).
|
||||
- Unused Flutter dependencies and orphaned assets.
|
||||
- Dead Dart wrappers whose native handlers no longer exist.
|
||||
|
||||
### Security & privacy
|
||||
- IPv6 is captured by the VPN interface (`::/0` route), closing a leak where
|
||||
IPv6 traffic could bypass the tunnel on IPv6-capable carriers.
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
- **Explicit TUN and SOCKS5 connection modes.** The previous Auto mode is gone, so
|
||||
routing is chosen deliberately instead of inferred at runtime. TUN is the default.
|
||||
SOCKS5 runs a local authenticated proxy with no VPN interface, and therefore never
|
||||
requests the Android VPN permission.
|
||||
- **Authenticated per-session Local SOCKS5.** Every connection binds an ephemeral
|
||||
loopback port and mints fresh 16-byte username / 32-byte password credentials.
|
||||
Android loopback is reachable by every app on the device, so this authentication is
|
||||
load-bearing rather than decorative. Credentials stay in memory. Imported
|
||||
`socks`/`http` inbounds are dropped so no unauthenticated listener can survive
|
||||
alongside the session one.
|
||||
- **Network resilience profiles** — Standard and Extreme. Extreme restarts a failed
|
||||
Xray/tun2socks runtime up to twice before giving up.
|
||||
- **Android physical-network monitoring**, filtered on `NET_CAPABILITY_NOT_VPN` so the
|
||||
VPN's own network cannot feed back a reconnect loop. Informational only; the active
|
||||
transport (Wi-Fi / cellular / ethernet) now appears in the status bar.
|
||||
- **Local SOCKS5 diagnostics.** "Test Local SOCKS" authenticates against the exact
|
||||
active session over loopback — a readiness handshake plus a full CONNECT — instead
|
||||
of checking whether a fixed port accepts connections.
|
||||
- Native Android bridges for network state, haptic feedback and install-source
|
||||
detection.
|
||||
|
||||
### Fixed
|
||||
- **Extreme recovery now survives an app-process restart.** Android can kill the Dart
|
||||
process while the VPN foreground service keeps running, leaving the app attached to
|
||||
a live tunnel it holds no configuration for — so recovery had nothing to restart
|
||||
from. In TUN mode the app rebuilds that snapshot from the still-active session,
|
||||
without opening a new session, showing an ad, or interrupting traffic.
|
||||
- **The proxy settings tile no longer misreports state after a process restart.** Its
|
||||
credentials genuinely cannot be recovered — they are random, generated on device and
|
||||
never transmitted — so instead of telling an already-connected user to "Connect", it
|
||||
now says the credentials were lost and offers a Reconnect action.
|
||||
- **The first tap on the connect button was ignored.** The debounce timestamp was
|
||||
initialized to the current time instead of the epoch, so the first press was always
|
||||
swallowed.
|
||||
- **The health probe polled `/health` every 30 s even while connected.** Restored to
|
||||
idle-only — a live tunnel already proves reachability, and the extra direct requests
|
||||
were unwanted.
|
||||
- **A user reconnect could race an in-flight Extreme recovery.** A connection
|
||||
generation counter now aborts stale recovery attempts.
|
||||
- Recoverable Xray/tun2socks startup and readiness failures no longer follow the
|
||||
unstable self-stop path used during development.
|
||||
- Returning from Android background state reconciles the session countdown against
|
||||
elapsed wall-clock time and re-syncs immediately, reducing stale countdowns and false
|
||||
`Syncing…` presentation after Dart throttling.
|
||||
- Sidebar update checks retain valid navigator/messenger handles after the drawer
|
||||
closes.
|
||||
|
||||
### Changed
|
||||
- **The pinned `flutter_vless_android` 1.1.5 runtime is patched at build time** instead
|
||||
of vendored. Three idempotent patchers are chained into `preBuild`, so a plain
|
||||
`flutter build apk` behaves like CI rather than depending on a manual step against
|
||||
the pub cache. They are fail-closed: an upstream change breaks the build loudly
|
||||
instead of silently producing an unpatched runtime.
|
||||
- Legacy `auto` preferences migrate to TUN; legacy `ass` preferences migrate to SOCKS5.
|
||||
- Haptic feedback is a single confirmation on connect/disconnect, routed through the
|
||||
native Android vibration channel, and **defaults to off**.
|
||||
- The VPN notification uses a dedicated 24 dp status icon and stays ongoing/no-clear
|
||||
while the foreground service is alive.
|
||||
- Hivemind session parsing is split into typed validation steps; a malformed port now
|
||||
fails instead of silently falling back.
|
||||
- The update checker parses semver with prerelease ordering and validates release URLs
|
||||
against an HTTPS host allowlist.
|
||||
- The support button shows a claimed/disabled state once the session bonus is used.
|
||||
- The status bar distinguishes `Ready` from `Syncing…` and folds in server
|
||||
reachability.
|
||||
- Gradle release heap raised to 4 GiB with `MaxMetaspaceSize=1g` and `workers.max=2`,
|
||||
so Flutter/Jetifier transforms do not fail on the release classpath under constrained
|
||||
runners.
|
||||
- App version is now `3.3.1+27`.
|
||||
|
||||
### Removed
|
||||
- **Per-app routing**, including the app picker, the installed-apps native bridge and
|
||||
the blocked-apps mechanism. It only ever functioned in TUN — the underlying
|
||||
`addDisallowedApplication` call lives inside the VPN-interface setup that SOCKS5 mode
|
||||
skips entirely — and its allowlist half was never wired to any caller. Removed rather
|
||||
than finished.
|
||||
- The legacy `auto` connection mode.
|
||||
- `flutter_local_notifications`, and the unused notification manager it backed.
|
||||
- A global touch listener that fired a haptic on every tap.
|
||||
- Unused runtime telemetry fields.
|
||||
|
||||
### Security & privacy
|
||||
- **Session metadata is hidden from public lock-screen previews.** The live foreground
|
||||
notification is built with `VISIBILITY_PRIVATE` plus a generic public version, so the
|
||||
countdown and speed are visible only after unlock. *(An earlier attempt at this
|
||||
landed in an unreferenced class and never took effect.)*
|
||||
- Local SOCKS5 listens only on `127.0.0.1`, requires per-session authentication, and
|
||||
its credentials are neither persisted nor logged.
|
||||
- The app requests no package visibility at all. `QUERY_ALL_PACKAGES` was never used,
|
||||
and the launcher-enumeration query was removed along with the app picker.
|
||||
- Session nonces use `Random.secure()` and are never logged.
|
||||
- Device UUIDs are validated as v4 and regenerated if corrupt, so tampered identifiers
|
||||
cannot reach the API.
|
||||
- `FOREGROUND_SERVICE_SPECIAL_USE` is declared for Android 14+; the merged service
|
||||
declaration carries the `vpn` special-use subtype.
|
||||
- Existing AdMob bypass/callback behavior is intentionally unchanged by this release.
|
||||
- Server, nginx and Reality configuration are not modified by this client-side release.
|
||||
|
||||
### Validation
|
||||
- `flutter analyze` — 0 errors, 0 warnings, 7 style infos (6 of them pre-existing).
|
||||
- All three build-time patchers verified against a clean pub cache, including that each
|
||||
applies independently of the others.
|
||||
- **Not yet verified:** Android release Kotlin compilation and APK build.
|
||||
|
||||
## [3.3.0] — 2026-08-30
|
||||
|
||||
### Added
|
||||
|
||||
+6
-7
@@ -14,7 +14,7 @@ As a contributor, here are the guidelines we would like you to follow:
|
||||
│ │ │
|
||||
│ │ └─⫸ Summary in present tense. Not capitalized. No period at the end.
|
||||
│ │
|
||||
│ └─⫸ Commit Scope: app|server|xray|nginx|docs|config
|
||||
│ └─⫸ Commit Scope: app|docs|config
|
||||
│
|
||||
└─⫸ Commit Type: build|ci|docs|feat|fix|perf|refactor|test
|
||||
```
|
||||
@@ -40,12 +40,12 @@ Must be one of the following:
|
||||
The scope should be the part of the project affected:
|
||||
|
||||
* `app` — Flutter client (screens, components, logic)
|
||||
* `server` — Python Hivemind backend
|
||||
* `xray` — Xray Reality config or tunnel behavior
|
||||
* `nginx` — nginx routing, SSL, auth
|
||||
* `docs` — documentation files
|
||||
* `config` — app_config, .gitignore, pubspec
|
||||
|
||||
The `server`, `xray` and `nginx` components are not in this repository, so
|
||||
contributions targeting them cannot be reviewed or tested here.
|
||||
|
||||
##### Summary
|
||||
|
||||
Use the summary field to provide a succinct description of the change:
|
||||
@@ -65,10 +65,9 @@ You can include a comparison of the previous behavior with the new behavior in o
|
||||
#### Examples
|
||||
|
||||
```
|
||||
feat(server): add swarm monitoring endpoint
|
||||
feat(app): add a server selector to the settings screen
|
||||
|
||||
GET /api/swarm returns sorted drone list for the TUI dashboard.
|
||||
Persistent total GB saved to swarm_total.json, survives restarts.
|
||||
Lists the available exit locations and persists the user's choice locally.
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Privacy Policy
|
||||
|
||||
**Last updated:** August 2026
|
||||
**Last updated:** September 2026
|
||||
|
||||
ReVoltVPN is a free VPN service built and operated by ReVolt Team.
|
||||
|
||||
@@ -27,7 +27,7 @@ Support ads (the "Support us" button) extend an active session by 30 minutes and
|
||||
|
||||
## Operational logs
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ No email. No password. No payment. The ad pays for the server.
|
||||
## Protocol
|
||||
|
||||
- **Transport** — VLESS over XHTTP (HTTP/2 multiplexed, path-hidden behind `/revolt`)
|
||||
- **Obfuscation** — Xray Reality spoofs `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
|
||||
- **API** — Standard HTTPS to a separate domain. Tunnel destination is IP-pinned — domain compromise is DoS only
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ include: package:flutter_lints/flutter.yaml
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
- android/**
|
||||
- local_packages/**
|
||||
- whatsthis/**
|
||||
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import java.util.Properties
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.security.MessageDigest
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
fun sha256Hex(file: File): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(file.readBytes())
|
||||
return digest.joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.paladinvpn.app"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
@@ -17,10 +24,6 @@ android {
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources.excludes.add("META-INF/**")
|
||||
}
|
||||
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
val keystoreProperties = Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
@@ -33,6 +36,68 @@ android {
|
||||
admobProperties.load(FileInputStream(admobPropertiesFile))
|
||||
}
|
||||
|
||||
// Explicit opt-in used only by GitHub Actions to exercise the real
|
||||
// release/R8 graph. Production release signing and config pinning remain
|
||||
// fail-closed outside that CI smoke path.
|
||||
val ciReleaseSmoke =
|
||||
System.getenv("GITHUB_ACTIONS") == "true" &&
|
||||
System.getenv("REVOLT_CI_RELEASE_SMOKE") == "true"
|
||||
|
||||
val appConfigFile = rootProject.file("../lib/logic/app_config.dart")
|
||||
val verifyReleaseAppConfig = tasks.register("verifyReleaseAppConfig") {
|
||||
group = "verification"
|
||||
description = "Verify the production Dart app config against REVOLT_APP_CONFIG_SHA256."
|
||||
doLast {
|
||||
if (ciReleaseSmoke) {
|
||||
logger.lifecycle("Skipping production app-config pin for CI release smoke build")
|
||||
return@doLast
|
||||
}
|
||||
|
||||
val expected = System.getenv("REVOLT_APP_CONFIG_SHA256")?.trim()?.lowercase()
|
||||
if (expected.isNullOrEmpty()) {
|
||||
throw org.gradle.api.GradleException(
|
||||
"REVOLT_APP_CONFIG_SHA256 is required for production release builds"
|
||||
)
|
||||
}
|
||||
if (!Regex("[0-9a-f]{64}").matches(expected)) {
|
||||
throw org.gradle.api.GradleException(
|
||||
"REVOLT_APP_CONFIG_SHA256 must be exactly 64 lowercase hexadecimal characters"
|
||||
)
|
||||
}
|
||||
if (!appConfigFile.isFile) {
|
||||
throw org.gradle.api.GradleException(
|
||||
"Production app config is missing: ${appConfigFile.absolutePath}"
|
||||
)
|
||||
}
|
||||
|
||||
val configText = appConfigFile.readText()
|
||||
val templateMarkers = listOf(
|
||||
"'0.0.0.0'",
|
||||
"YOUR_DOMAIN",
|
||||
"YOUR_GITHUB_USERNAME",
|
||||
"YOUR_REPO_NAME",
|
||||
"ca-app-pub-0000000000000000/0000000000",
|
||||
)
|
||||
val remainingMarker = templateMarkers.firstOrNull(configText::contains)
|
||||
if (remainingMarker != null) {
|
||||
throw org.gradle.api.GradleException(
|
||||
"Production app config still contains template marker: $remainingMarker"
|
||||
)
|
||||
}
|
||||
|
||||
val actual = sha256Hex(appConfigFile)
|
||||
if (actual != expected) {
|
||||
throw org.gradle.api.GradleException(
|
||||
"Production app config SHA-256 mismatch: expected $expected, got $actual"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.matching { it.name == "preReleaseBuild" }.configureEach {
|
||||
dependsOn(verifyReleaseAppConfig)
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.paladinvpn.app"
|
||||
minSdk = flutter.minSdkVersion
|
||||
@@ -53,7 +118,11 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
signingConfig = if (ciReleaseSmoke) {
|
||||
signingConfigs.getByName("debug")
|
||||
} else {
|
||||
signingConfigs.getByName("release")
|
||||
}
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
|
||||
Vendored
+2
-3
@@ -1,4 +1,4 @@
|
||||
# PaladinVPN ProGuard / R8 Rules
|
||||
# ReVoltVPN ProGuard / R8 Rules
|
||||
# ==============================================================================
|
||||
# These rules prevent R8 from stripping classes and methods that are accessed
|
||||
# via JNI (Java Native Interface) or reflection, which would cause
|
||||
@@ -11,9 +11,8 @@
|
||||
-keep class xray.** { *; }
|
||||
|
||||
# ── Flutter / Platform Channels ───────────────────────────────────────────────
|
||||
# io.flutter.** already includes embedding and plugin subpackages.
|
||||
-keep class io.flutter.** { *; }
|
||||
-keep class io.flutter.embedding.** { *; }
|
||||
-keep class io.flutter.plugin.** { *; }
|
||||
|
||||
# ── Google Mobile Ads (AdMob) ─────────────────────────────────────────────────
|
||||
-keep class com.google.android.gms.ads.** { *; }
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<!-- 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"/>
|
||||
|
||||
<!-- flutter_vless 1.1.5 declares this legacy permission. ReVolt never needs it. -->
|
||||
<uses-permission
|
||||
|
||||
@@ -1,30 +1,41 @@
|
||||
package com.paladinvpn.app
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.github.tfox.flutter_vless.xray.service.XrayVPNService
|
||||
import com.github.tfox.flutter_vless.xray.utils.AppConfigs
|
||||
import com.github.tfox.flutter_vless.xray.utils.RevoltAlerts
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
|
||||
// Channel id only — not the application id, which is com.paladinvpn.app.
|
||||
// Must match the string in lib/logic/updater.dart.
|
||||
private val installerChannel = "com.revoltvpn.app/installer"
|
||||
|
||||
// Must match lib/logic/notification_service.dart.
|
||||
private val notificationChannel = "com.revoltvpn.app/notification"
|
||||
private val hapticsChannel = "com.revoltvpn.app/haptics"
|
||||
private val networkChannel = "com.revoltvpn.app/network"
|
||||
private val powerChannel = "com.revoltvpn.app/power"
|
||||
|
||||
private var notificationChannelReady = false
|
||||
private var networkCallback: ConnectivityManager.NetworkCallback? = null
|
||||
private var networkEvents: EventChannel.EventSink? = null
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
@@ -38,28 +49,108 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, powerChannel)
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"isIgnoringBatteryOptimizations" ->
|
||||
result.success(isIgnoringBatteryOptimizations())
|
||||
"requestIgnoreBatteryOptimizations" ->
|
||||
result.success(requestIgnoreBatteryOptimizations())
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, notificationChannel)
|
||||
.setMethodCallHandler { call, result ->
|
||||
if (call.method == "updateNotificationText") {
|
||||
val text = call.argument<String>("text") ?: ""
|
||||
val title = call.argument<String>("title") ?: "Revolt VPN"
|
||||
val action = call.argument<String>("disconnectLabel") ?: "Disconnect"
|
||||
updateVpnNotification(title, text, action)
|
||||
result.success(null)
|
||||
when (call.method) {
|
||||
"updateNotificationText" -> {
|
||||
val text = call.argument<String>("text") ?: ""
|
||||
val title = call.argument<String>("title") ?: "Revolt VPN"
|
||||
val action = call.argument<String>("disconnectLabel") ?: "Disconnect"
|
||||
updateVpnNotification(title, text, action)
|
||||
result.success(null)
|
||||
}
|
||||
// Session ends only Dart sees; the service posts the rest.
|
||||
"showDisconnectedAlert" -> {
|
||||
RevoltAlerts.postDisconnected(this)
|
||||
result.success(null)
|
||||
}
|
||||
// The countdown re-post is ongoing and non-dismissible, so
|
||||
// whoever posted it last has to take it down. A tick that
|
||||
// lands after the service cancelled its own foreground
|
||||
// notification otherwise strands a frozen countdown that
|
||||
// claims a protected session forever. Harmless while the
|
||||
// service is still in the foreground - the platform keeps
|
||||
// its notification regardless.
|
||||
"cancelNotification" -> {
|
||||
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
|
||||
.cancel(NOTIFICATION_ID)
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, hapticsChannel)
|
||||
.setMethodCallHandler { call, result ->
|
||||
if (call.method == "impact") {
|
||||
val kind = call.argument<String>("kind") ?: "tap"
|
||||
result.success(performHaptic(kind))
|
||||
} else {
|
||||
result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
EventChannel(flutterEngine.dartExecutor.binaryMessenger, networkChannel)
|
||||
.setStreamHandler(object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||
networkEvents = events
|
||||
startNetworkMonitor()
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
networkEvents = null
|
||||
stopNetworkMonitor()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stopNetworkMonitor()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
try {
|
||||
startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the session countdown as the VPN notification's content text.
|
||||
*
|
||||
* notify() is per-package, so re-posting flutter_vless's notification id
|
||||
* replaces it wholesale — every field the plugin set has to be rebuilt
|
||||
* here or it is dropped, the Disconnect action most of all. Keep in sync
|
||||
* with XrayCoreManager.showNotification().
|
||||
*/
|
||||
private fun updateVpnNotification(title: String, text: String, actionLabel: String) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) !=
|
||||
@@ -76,58 +167,76 @@ class MainActivity : FlutterActivity() {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
}
|
||||
|
||||
// Same intent shape as the plugin, so a tap brings the running task
|
||||
// forward instead of starting a second one.
|
||||
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
|
||||
launchIntent?.action = "FROM_DISCONNECT_BTN"
|
||||
launchIntent?.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or
|
||||
Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
|
||||
// Tapping "Disconnect" stops the tunnel without opening the app.
|
||||
val stopIntent = Intent(this, XrayVPNService::class.java)
|
||||
stopIntent.putExtra(
|
||||
"COMMAND",
|
||||
AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE
|
||||
)
|
||||
val stopPendingIntent = PendingIntent.getService(this, 0, stopIntent, flags)
|
||||
// The VPN service owns the authoritative Disconnect PendingIntent and
|
||||
// embeds the current runtime-generation token in it. Never recreate an
|
||||
// unscoped STOP intent from the UI process: a stale notification could
|
||||
// otherwise stop a newer tunnel. Reuse the live service action when the
|
||||
// platform exposes it; on older Android versions omit the action rather
|
||||
// than weaken generation scoping.
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val serviceStopAction = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
manager.activeNotifications
|
||||
.firstOrNull { it.id == NOTIFICATION_ID }
|
||||
?.notification
|
||||
?.actions
|
||||
?.firstOrNull()
|
||||
?.actionIntent
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val publicVersion = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.notification_icon)
|
||||
.setContentTitle("Revolt VPN")
|
||||
.setContentText("VPN is active")
|
||||
.build()
|
||||
|
||||
val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.notification_icon)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.addAction(0, actionLabel, stopPendingIntent)
|
||||
.setColorized(true)
|
||||
.setColor(0xFF0D1117.toInt())
|
||||
.setOngoing(true)
|
||||
.setAutoCancel(false)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setSilent(true)
|
||||
.setShowWhen(false)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.setPublicVersion(publicVersion)
|
||||
|
||||
if (serviceStopAction != null) {
|
||||
builder.addAction(0, actionLabel, serviceStopAction)
|
||||
}
|
||||
|
||||
if (launchIntent != null) {
|
||||
builder.setContentIntent(
|
||||
PendingIntent.getActivity(this, 0, launchIntent, flags)
|
||||
)
|
||||
builder.setContentIntent(PendingIntent.getActivity(this, 0, launchIntent, flags))
|
||||
}
|
||||
|
||||
try {
|
||||
NotificationManagerCompat.from(this).notify(NOTIFICATION_ID, builder.build())
|
||||
val notification = builder.build()
|
||||
notification.flags = notification.flags or
|
||||
Notification.FLAG_ONGOING_EVENT or Notification.FLAG_NO_CLEAR
|
||||
NotificationManagerCompat.from(this).notify(NOTIFICATION_ID, notification)
|
||||
} catch (_: Exception) {
|
||||
// The foreground notification may not be up yet — the update is cosmetic.
|
||||
// The native foreground notification may not be ready yet.
|
||||
}
|
||||
}
|
||||
|
||||
/** A binder round-trip, and the caller runs once a second — so, once per process. */
|
||||
private fun ensureNotificationChannel() {
|
||||
if (notificationChannelReady) return
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val manager =
|
||||
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val channel = NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID,
|
||||
"Revolt VPN Background Service",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
)
|
||||
channel.setShowBadge(false)
|
||||
manager.createNotificationChannel(channel)
|
||||
@@ -135,11 +244,134 @@ class MainActivity : FlutterActivity() {
|
||||
notificationChannelReady = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Package name of whatever installed us ("com.android.vending" for Play
|
||||
* Store), or null when unknown. Without this the Dart side always falls
|
||||
* back to sideload and points every user at GitHub releases.
|
||||
*/
|
||||
private fun startNetworkMonitor() {
|
||||
if (networkCallback != null) return
|
||||
|
||||
val manager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
emitBestPhysicalNetwork(manager, "available")
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
emitBestPhysicalNetwork(manager, "lost")
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
capabilities: NetworkCapabilities,
|
||||
) {
|
||||
emitBestPhysicalNetwork(manager, "changed")
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||
.build()
|
||||
manager.registerNetworkCallback(request, callback)
|
||||
networkCallback = callback
|
||||
emitBestPhysicalNetwork(manager, "initial")
|
||||
} catch (e: Exception) {
|
||||
networkEvents?.error("NETWORK_MONITOR", e.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopNetworkMonitor() {
|
||||
val callback = networkCallback ?: return
|
||||
val manager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
try {
|
||||
manager.unregisterNetworkCallback(callback)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
networkCallback = null
|
||||
}
|
||||
|
||||
private fun emitBestPhysicalNetwork(
|
||||
manager: ConnectivityManager,
|
||||
reason: String,
|
||||
) {
|
||||
val best = manager.allNetworks
|
||||
.mapNotNull { network ->
|
||||
val capabilities = manager.getNetworkCapabilities(network)
|
||||
?: return@mapNotNull null
|
||||
if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) ||
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
|
||||
) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
Triple(network, capabilities, physicalNetworkScore(capabilities))
|
||||
}
|
||||
.maxByOrNull { it.third }
|
||||
|
||||
if (best == null) {
|
||||
emitNetworkPayload(
|
||||
mapOf<String, Any>(
|
||||
"reason" to reason,
|
||||
"transport" to "none",
|
||||
"connected" to false,
|
||||
"validated" to false,
|
||||
"metered" to false,
|
||||
"timestamp" to System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
emitNetworkState(best.first, best.second, reason)
|
||||
}
|
||||
|
||||
private fun physicalNetworkScore(capabilities: NetworkCapabilities): Int {
|
||||
var score = 0
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M ||
|
||||
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
) {
|
||||
score += 100
|
||||
}
|
||||
score += when {
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> 40
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> 30
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> 20
|
||||
else -> 10
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
private fun emitNetworkState(
|
||||
network: Network,
|
||||
capabilities: NetworkCapabilities,
|
||||
reason: String,
|
||||
) {
|
||||
val transport = when {
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
|
||||
else -> "other"
|
||||
}
|
||||
val validated = Build.VERSION.SDK_INT < Build.VERSION_CODES.M ||
|
||||
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
val metered = !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||
|
||||
emitNetworkPayload(
|
||||
mapOf<String, Any>(
|
||||
"reason" to reason,
|
||||
"transport" to transport,
|
||||
"connected" to true,
|
||||
"validated" to validated,
|
||||
"metered" to metered,
|
||||
"timestamp" to System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun emitNetworkPayload(payload: Map<String, Any>) {
|
||||
runOnUiThread { networkEvents?.success(payload) }
|
||||
}
|
||||
|
||||
private fun installerPackage(): String? = try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
packageManager.getInstallSourceInfo(packageName).installingPackageName
|
||||
@@ -147,13 +379,48 @@ class MainActivity : FlutterActivity() {
|
||||
@Suppress("DEPRECATION")
|
||||
packageManager.getInstallerPackageName(packageName)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun performHaptic(kind: String): Boolean {
|
||||
return try {
|
||||
val vibrator: Vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val manager =
|
||||
getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
|
||||
manager.defaultVibrator
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
|
||||
}
|
||||
|
||||
if (!vibrator.hasVibrator()) {
|
||||
false
|
||||
} else {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val effect = when (kind) {
|
||||
"selection" -> VibrationEffect.EFFECT_TICK
|
||||
"success" -> VibrationEffect.EFFECT_HEAVY_CLICK
|
||||
else -> VibrationEffect.EFFECT_CLICK
|
||||
}
|
||||
vibrator.vibrate(VibrationEffect.createPredefined(effect))
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val durationMs = if (kind == "success") 24L else 14L
|
||||
val amplitude = if (kind == "success") 110 else 70
|
||||
vibrator.vibrate(VibrationEffect.createOneShot(durationMs, amplitude))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
vibrator.vibrate(if (kind == "success") 24L else 14L)
|
||||
}
|
||||
true
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Must match flutter_vless's XrayCoreManager.
|
||||
private const val NOTIFICATION_ID = 1
|
||||
private const val NOTIFICATION_CHANNEL_ID = "XRAY_SERVICE_CHANNEL"
|
||||
private const val NOTIFICATION_CHANNEL_ID = "REVOLT_VPN_SERVICE"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M12,1.5L21,5.2V11.1C21,16.7 17.4,21 12,22.5C6.6,21 3,16.7 3,11.1V5.2L12,1.5ZM12,5L6.5,7.2V11C6.5,14.8 8.7,17.8 12,19C15.3,17.8 17.5,14.8 17.5,11V7.2L12,5ZM10.8,9H13.2V13.8H10.8V9ZM10.8,15H13.2V17.4H10.8V15Z" />
|
||||
</vector>
|
||||
@@ -1,5 +1,7 @@
|
||||
org.gradle.jvmargs=-Xmx1536M
|
||||
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8
|
||||
org.gradle.workers.max=2
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
# These compatibility flags are managed by Flutter's Android toolchain.
|
||||
android.builtInKotlin=false
|
||||
android.newDsl=false
|
||||
File diff suppressed because it is too large.
Load diff
BIN
Binary file not shown.
+251
@@ -0,0 +1,251 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+94
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 492 KiB |
File renamed without changes.
Binary file not shown.
|
Before Width: | Height: | Size: 144 KiB |
@@ -1,11 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:revoltvpn/logic/app_colors.dart';
|
||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||
import 'package:revoltvpn/logic/session_timer.dart';
|
||||
import 'package:revoltvpn/logic/ad_manager.dart';
|
||||
import 'package:revoltvpn/screens/settings/in_settings/haptic.dart';
|
||||
import 'package:revoltvpn/logic/haptic_settings.dart';
|
||||
|
||||
class ConnectButton extends StatefulWidget {
|
||||
const ConnectButton({super.key});
|
||||
@@ -17,34 +16,45 @@ class ConnectButton extends StatefulWidget {
|
||||
class _ConnectButtonState extends State<ConnectButton>
|
||||
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
|
||||
bool _busy = false;
|
||||
DateTime _lastTap = DateTime.now();
|
||||
DateTime _lastTap = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
AnimationController? _pulse;
|
||||
Animation<double>? _pulseAnim;
|
||||
VpnConnection? _vpn;
|
||||
|
||||
double get _pulseValue => _pulseAnim?.value ?? 0.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final pulse = AnimationController(vsync: this, duration: const Duration(milliseconds: 2000));
|
||||
final pulse = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 2000),
|
||||
);
|
||||
_pulse = pulse;
|
||||
_pulseAnim = Tween(begin: 0.0, end: 1.0).animate(CurvedAnimation(parent: pulse, curve: Curves.easeInOut));
|
||||
_pulseAnim = Tween(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(parent: pulse, curve: Curves.easeInOut),
|
||||
);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<VpnConnection>().addListener(_onVpnChanged);
|
||||
if (!mounted || _vpn != null) return;
|
||||
final vpn = context.read<VpnConnection>();
|
||||
_vpn = vpn;
|
||||
vpn.addListener(_onVpnChanged);
|
||||
_onVpnChanged();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
context.read<VpnConnection>().removeListener(_onVpnChanged);
|
||||
_vpn?.removeListener(_onVpnChanged);
|
||||
_pulse?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onVpnChanged() {
|
||||
final vpn = context.read<VpnConnection>();
|
||||
final vpn = _vpn;
|
||||
if (vpn == null) return;
|
||||
if (vpn.status == VpnStatus.connected) {
|
||||
_pulse?.repeat(reverse: true);
|
||||
} else {
|
||||
@@ -56,19 +66,16 @@ class _ConnectButtonState extends State<ConnectButton>
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (!mounted) return;
|
||||
if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.detached) {
|
||||
if (state != AppLifecycleState.resumed) {
|
||||
_pulse?.stop();
|
||||
} else if (state == AppLifecycleState.resumed) {
|
||||
final vpn = context.read<VpnConnection>();
|
||||
if (vpn.status == VpnStatus.connected) {
|
||||
_pulse?.repeat(reverse: true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (_vpn?.status == VpnStatus.connected) {
|
||||
_pulse?.repeat(reverse: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleTap() async {
|
||||
|
||||
final now = DateTime.now();
|
||||
if (now.difference(_lastTap).inMilliseconds < 1000) return;
|
||||
_lastTap = now;
|
||||
@@ -77,31 +84,24 @@ class _ConnectButtonState extends State<ConnectButton>
|
||||
final timer = context.read<SessionTimer>();
|
||||
final ad = context.read<AdManager>();
|
||||
|
||||
|
||||
if (vpn.status == VpnStatus.connected || vpn.status == VpnStatus.connecting) {
|
||||
_busy = false;
|
||||
await timer.disconnect();
|
||||
unawaited(Haptics.connectionChanged());
|
||||
HapticSettings.success();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (vpn.status == VpnStatus.disconnecting) return;
|
||||
|
||||
|
||||
if (_busy) return;
|
||||
if (vpn.status == VpnStatus.disconnecting || _busy) return;
|
||||
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
final adWatched = await ad.showAd('main');
|
||||
if (!adWatched && AdManager.adsEnabled) {
|
||||
setState(() => _busy = false);
|
||||
return;
|
||||
}
|
||||
final ok = await vpn.connect();
|
||||
final adNonce = await ad.showAd('main');
|
||||
if (adNonce == null && AdManager.adsEnabled) return;
|
||||
|
||||
final ok = await vpn.connect(adNonce: adNonce);
|
||||
if (ok) {
|
||||
timer.start();
|
||||
unawaited(Haptics.connectionChanged());
|
||||
await timer.start();
|
||||
HapticSettings.success();
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
@@ -170,7 +170,7 @@ class _ConnectButtonState extends State<ConnectButton>
|
||||
color: Color.fromARGB(glowAlpha, 255, 214, 0),
|
||||
blurRadius: glowRadius,
|
||||
spreadRadius: glowRadius * 0.3,
|
||||
)
|
||||
),
|
||||
]
|
||||
: [],
|
||||
),
|
||||
|
||||
@@ -19,23 +19,28 @@ class DataDisclosureDialog {
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
await showDialog(
|
||||
final accepted = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false, // Must tap Continue — no tap-outside dismiss.
|
||||
builder: (_) => _DisclosureDialog(
|
||||
onContinue: () {
|
||||
prefs.setBool(_key, true);
|
||||
Navigator.of(context).pop(); // Dismiss the dialog.
|
||||
},
|
||||
),
|
||||
builder: (_) => const _DisclosureDialog(),
|
||||
);
|
||||
if (accepted != true) return;
|
||||
|
||||
// Persist only after the user action is complete. If storage fails, do not
|
||||
// invent an accepted state; the disclosure will simply reappear later.
|
||||
try {
|
||||
final saved = await prefs.setBool(_key, true);
|
||||
if (!saved) {
|
||||
debugPrint('[Disclosure] Preference write was rejected');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[Disclosure] Failed to persist acknowledgement: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _DisclosureDialog extends StatelessWidget {
|
||||
final VoidCallback onContinue;
|
||||
|
||||
const _DisclosureDialog({required this.onContinue});
|
||||
const _DisclosureDialog();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -49,7 +54,6 @@ class _DisclosureDialog extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Title
|
||||
const Text(
|
||||
'Before you connect',
|
||||
style: TextStyle(
|
||||
@@ -59,8 +63,6 @@ class _DisclosureDialog extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Body
|
||||
const Text(
|
||||
'Revolt VPN creates a secure VPN tunnel to protect your '
|
||||
'traffic. To enforce your data/time quota, we log your '
|
||||
@@ -76,12 +78,10 @@ class _DisclosureDialog extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Continue button — full-width, accent
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: onContinue,
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.accent,
|
||||
foregroundColor: AppColors.bgDeep,
|
||||
|
||||
@@ -182,7 +182,7 @@ class _LightningPainter extends CustomPainter {
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final toScreen = (Offset p) => Offset(p.dx * size.width, p.dy * size.height);
|
||||
Offset toScreen(Offset p) => Offset(p.dx * size.width, p.dy * size.height);
|
||||
final scaledMain = bolt.mainPath.map(toScreen).toList();
|
||||
final scaledBranches = bolt.branches
|
||||
.map((b) => b.map(toScreen).toList())
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
class VpnNotificationManager {
|
||||
static final FlutterLocalNotificationsPlugin _plugin =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
static bool _initialized = false;
|
||||
static String? _lastTimeLeft;
|
||||
static String? _lastSpeedKbps;
|
||||
|
||||
// Android channels are immutable — bump this if settings change.
|
||||
static const _channelId = 'revolt_vpn_v4';
|
||||
static const _channelName = 'Revolt VPN';
|
||||
static const _notifId = 888;
|
||||
|
||||
static Future<void> init() async {
|
||||
if (_initialized) return;
|
||||
|
||||
const androidSettings =
|
||||
AndroidInitializationSettings('@drawable/notification_icon');
|
||||
const initSettings = InitializationSettings(android: androidSettings);
|
||||
|
||||
await _plugin.initialize(initSettings);
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
final androidPlugin = _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>();
|
||||
await androidPlugin?.requestNotificationsPermission();
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
static Future<void> showOrUpdateStatus({
|
||||
required String timeLeft,
|
||||
required String speedKbps,
|
||||
}) async {
|
||||
if (timeLeft == _lastTimeLeft && speedKbps == _lastSpeedKbps) return;
|
||||
_lastTimeLeft = timeLeft;
|
||||
_lastSpeedKbps = speedKbps;
|
||||
|
||||
if (!_initialized) await init();
|
||||
|
||||
final bodyText = 'Time: $timeLeft • $speedKbps';
|
||||
|
||||
final bigPictureStyle = BigPictureStyleInformation(
|
||||
const DrawableResourceAndroidBitmap('notification_banner'),
|
||||
largeIcon: const DrawableResourceAndroidBitmap('notification_icon'),
|
||||
contentTitle: 'Revolt VPN',
|
||||
summaryText: bodyText,
|
||||
htmlFormatContentTitle: false,
|
||||
htmlFormatSummaryText: false,
|
||||
);
|
||||
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
_channelId,
|
||||
_channelName,
|
||||
channelDescription: 'Session timer and speed',
|
||||
importance: Importance.low,
|
||||
priority: Priority.low,
|
||||
ongoing: true,
|
||||
autoCancel: false,
|
||||
playSound: false,
|
||||
enableVibration: false,
|
||||
showWhen: false,
|
||||
visibility: NotificationVisibility.public,
|
||||
usesChronometer: false,
|
||||
category: AndroidNotificationCategory.service,
|
||||
colorized: true,
|
||||
color: const ui.Color(0xFF0D1117),
|
||||
styleInformation: bigPictureStyle,
|
||||
);
|
||||
|
||||
final details = NotificationDetails(android: androidDetails);
|
||||
|
||||
try {
|
||||
await _plugin.show(_notifId, _channelName, bodyText, details);
|
||||
} catch (e) {
|
||||
_initialized = false;
|
||||
try {
|
||||
await init();
|
||||
await _plugin.show(_notifId, _channelName, bodyText, details);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> cancel() async {
|
||||
if (!_initialized) return;
|
||||
await _plugin.cancel(_notifId);
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,8 @@ class DiscordLinkButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.discord, color: AppColors.textWhite, size: 28),
|
||||
return const IconButton(
|
||||
icon: Icon(Icons.discord, color: AppColors.textWhite, size: 28),
|
||||
onPressed: launchDiscord,
|
||||
tooltip: 'Join Discord',
|
||||
splashRadius: 24,
|
||||
|
||||
@@ -76,8 +76,8 @@ class GdprButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.admin_panel_settings_outlined,
|
||||
return const IconButton(
|
||||
icon: Icon(Icons.admin_panel_settings_outlined,
|
||||
color: AppColors.textWhite, size: 28),
|
||||
onPressed: showConsentForm,
|
||||
tooltip: 'Ad consent & privacy choices',
|
||||
|
||||
@@ -14,8 +14,8 @@ class InfoButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.help_outline, color: AppColors.textWhite, size: 28),
|
||||
return const IconButton(
|
||||
icon: Icon(Icons.help_outline, color: AppColors.textWhite, size: 28),
|
||||
onPressed: launchGitHub,
|
||||
tooltip: 'About ReVoltVPN',
|
||||
splashRadius: 24,
|
||||
|
||||
@@ -14,8 +14,8 @@ class PrivacyPolicyButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.privacy_tip_outlined, color: AppColors.textWhite, size: 28),
|
||||
return const IconButton(
|
||||
icon: Icon(Icons.privacy_tip_outlined, color: AppColors.textWhite, size: 28),
|
||||
onPressed: launchPrivacyPolicy,
|
||||
tooltip: 'Privacy Policy',
|
||||
splashRadius: 24,
|
||||
|
||||
@@ -14,8 +14,8 @@ class WebsiteButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.language, color: AppColors.textWhite, size: 28),
|
||||
return const IconButton(
|
||||
icon: Icon(Icons.language, color: AppColors.textWhite, size: 28),
|
||||
onPressed: launchWebsite,
|
||||
tooltip: 'Website',
|
||||
splashRadius: 24,
|
||||
|
||||
@@ -141,7 +141,10 @@ class _SidebarDrawerState extends State<SidebarDrawer> {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
Navigator.of(context).pop();
|
||||
unawaited(
|
||||
Updater.check(navigator: navigator, messenger: messenger),
|
||||
Updater.checkWithHandles(
|
||||
navigator: navigator,
|
||||
messenger: messenger,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1,21 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:revoltvpn/logic/app_colors.dart';
|
||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||
import 'package:revoltvpn/logic/session_timer.dart';
|
||||
import 'package:revoltvpn/logic/server_list.dart';
|
||||
import 'package:revoltvpn/components/online_dot.dart';
|
||||
import 'package:revoltvpn/screens/server_selector/selection_screen.dart';
|
||||
import 'package:revoltvpn/logic/app_colors.dart';
|
||||
import 'package:revoltvpn/logic/session_timer.dart';
|
||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||
|
||||
// ── lib/components/status_bar.dart ──────────────────────────────────────────
|
||||
// Two-card layout at the bottom of the main screen.
|
||||
//
|
||||
// Top card: server location from ServerList provider (flag + city).
|
||||
// Tappable → opens server selector. Always visible.
|
||||
// Bottom card: connection status (health dot + label + lock icon).
|
||||
//
|
||||
// Separated into two rows — no more RenderFlex overflow and ready for
|
||||
// multi-server picker.
|
||||
// The one line worth showing right now, and whether it is a problem.
|
||||
class _StatusDetail {
|
||||
const _StatusDetail(this.text, {this.isProblem = false});
|
||||
|
||||
final String text;
|
||||
final bool isProblem;
|
||||
}
|
||||
|
||||
_StatusDetail? _detail(VpnConnection vpn, SessionTimer timer) {
|
||||
final error = vpn.errorMessage;
|
||||
if (error != null && error.isNotEmpty) {
|
||||
return _StatusDetail(error.replaceAll('\n', ' '), isProblem: true);
|
||||
}
|
||||
|
||||
if (vpn.status == VpnStatus.connecting ||
|
||||
vpn.status == VpnStatus.disconnecting) {
|
||||
return _StatusDetail(vpn.statusMessage);
|
||||
}
|
||||
|
||||
// Control plane only - the tunnel still carries traffic.
|
||||
if (vpn.status == VpnStatus.connected && timer.serverUnreachable) {
|
||||
return const _StatusDetail(
|
||||
'Can\'t reach the server — still connected',
|
||||
isProblem: true,
|
||||
);
|
||||
}
|
||||
|
||||
if (vpn.status != VpnStatus.connected) {
|
||||
final ended = timer.lastEndMessage;
|
||||
if (ended != null) return _StatusDetail(ended, isProblem: true);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
class StatusBar extends StatelessWidget {
|
||||
const StatusBar({super.key});
|
||||
@@ -28,75 +51,52 @@ class StatusBar extends StatelessWidget {
|
||||
final isConnecting = vpn.status == VpnStatus.connecting;
|
||||
final serverHealthy =
|
||||
vpn.serverReachable || (isConnected && timer.hasSyncedOnce);
|
||||
final sessionOrTunnelHealthy = timer.hasSyncedOnce || vpn.serverReachable;
|
||||
|
||||
final statusLabel = isConnected
|
||||
? (timer.hasSyncedOnce ? 'Online' : 'Syncing…')
|
||||
? (sessionOrTunnelHealthy ? 'Online' : 'Syncing…')
|
||||
: (isConnecting
|
||||
? 'Connecting…'
|
||||
: (vpn.serverReachable ? 'Ready' : 'Offline'));
|
||||
|
||||
// Read from the ServerList provider — the source of truth for
|
||||
// which server is selected and what it looks like.
|
||||
final server = context.watch<ServerList>().selected;
|
||||
final detail = _detail(vpn, timer);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// ── Server location ──────────────────────────────────────────
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
pageBuilder: (_, __, ___) => const SelectionScreen(),
|
||||
transitionsBuilder: (_, animation, __, child) {
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(1.0, 0.0),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeOut,
|
||||
)),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.bgCard,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.slate15),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/finland_flag_256.png',
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.bgCard,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.slate15),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Image.asset(server.flagAsset, width: 20, height: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(server.name,
|
||||
style: const TextStyle(
|
||||
color: AppColors.accent,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
letterSpacing: 0.5,
|
||||
)),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Helsinki, Finland',
|
||||
style: TextStyle(
|
||||
color: AppColors.accent,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right,
|
||||
color: AppColors.textDim, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// ── Connection status ────────────────────────────────────────
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
@@ -112,12 +112,42 @@ class StatusBar extends StatelessWidget {
|
||||
label: statusLabel,
|
||||
),
|
||||
const Spacer(),
|
||||
if (vpn.networkTransport != 'unknown' &&
|
||||
vpn.networkTransport != 'none')
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Text(
|
||||
vpn.networkTransport,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textDim,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isConnected)
|
||||
const Icon(Icons.lock,
|
||||
color: AppColors.green50, size: 14),
|
||||
const Icon(
|
||||
Icons.lock,
|
||||
color: AppColors.green50,
|
||||
size: 14,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Errors and session-end reasons used to render nowhere.
|
||||
if (detail != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
detail.text,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: detail.isProblem
|
||||
? AppColors.red
|
||||
: AppColors.textDim,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -20,13 +20,22 @@ class _SupportButtonState extends State<SupportButton> {
|
||||
final ad = context.read<AdManager>();
|
||||
final timer = context.read<SessionTimer>();
|
||||
|
||||
// Only available when there's an active session.
|
||||
if (!timer.isRunning && !timer.hasSyncedOnce) return;
|
||||
// Only available when there's an active session, once the persisted
|
||||
// support state is known, and only once per client-side VPN session.
|
||||
if ((!timer.isRunning && !timer.hasSyncedOnce) ||
|
||||
!timer.supportRewardStateLoaded ||
|
||||
timer.supportRewardClaimed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _busy = true);
|
||||
|
||||
try {
|
||||
await ad.showAd('support');
|
||||
final rewarded = await ad.showAd('support') != null;
|
||||
if (rewarded) {
|
||||
await timer.markSupportRewardClaimed();
|
||||
await timer.syncNow();
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
@@ -34,48 +43,62 @@ class _SupportButtonState extends State<SupportButton> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Selector<SessionTimer, bool>(
|
||||
selector: (_, timer) => timer.isRunning || timer.hasSyncedOnce,
|
||||
builder: (context, visible, _) {
|
||||
if (!visible) {
|
||||
return Consumer<SessionTimer>(
|
||||
builder: (context, timer, _) {
|
||||
if (!timer.isRunning && !timer.hasSyncedOnce) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final stateLoaded = timer.supportRewardStateLoaded;
|
||||
final claimed = timer.supportRewardClaimed;
|
||||
final disabled = !stateLoaded || claimed;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: _handleTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.glassBg,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: AppColors.accent.withAlpha(100),
|
||||
width: 1,
|
||||
onTap: disabled ? null : _handleTap,
|
||||
child: Opacity(
|
||||
opacity: disabled ? 0.55 : 1.0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.glassBg,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: AppColors.accent.withAlpha(100),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.favorite_border, color: AppColors.accent, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
_busy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.accent,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
claimed ? Icons.check_circle_outline : Icons.favorite_border,
|
||||
color: AppColors.accent,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_busy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
!stateLoaded
|
||||
? 'Checking support bonus…'
|
||||
: claimed
|
||||
? 'Support bonus used'
|
||||
: 'Support us — 30 min!',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textWhite,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'Support us \u2014 30 min!',
|
||||
style: TextStyle(
|
||||
color: AppColors.textWhite,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
+14
-17
@@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
import 'package:revoltvpn/logic/crypto_service.dart';
|
||||
@@ -97,13 +96,16 @@ class AdManager extends ChangeNotifier {
|
||||
|
||||
// ── Show ad (or debug bypass) ─────────────────────────────────────
|
||||
|
||||
Future<bool> showAd(String adType) async {
|
||||
// Debug bypass: fire fake AdMob callback so support ads work in dev.
|
||||
// The server's ADMOB_BYPASS must be True for this to succeed.
|
||||
if (!adsEnabled && kDebugMode) {
|
||||
// Returns the nonce put in the SSV custom_data, null if unrewarded. connect()
|
||||
// must reuse it: minting a second nonce there rejects the ad's own session.
|
||||
Future<String?> showAd(String adType) async {
|
||||
if (!adsEnabled) {
|
||||
// Main connect fires its own bypass in fetchConfigDirectly (rule 87).
|
||||
if (adType != 'support' || !kDebugMode) return null;
|
||||
|
||||
// Needs ADMOB_BYPASS=True server-side.
|
||||
final deviceId = await CryptoService.getDeviceId();
|
||||
final nonce = '${Random().nextInt(0x7FFFFFFF)}-${DateTime.now().millisecondsSinceEpoch}';
|
||||
HivemindService.setExpectedNonce(nonce);
|
||||
final nonce = HivemindService.newNonce();
|
||||
try {
|
||||
final customData = jsonEncode({
|
||||
'device_id': deviceId,
|
||||
@@ -115,29 +117,24 @@ class AdManager extends ChangeNotifier {
|
||||
'?signature=test&key_id=test'
|
||||
'&custom_data=${Uri.encodeComponent(customData)}');
|
||||
await HivemindService.directGet(fakeUrl, timeout: const Duration(seconds: 8));
|
||||
return true;
|
||||
return nonce;
|
||||
} catch (_) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!adsEnabled) return false;
|
||||
|
||||
await ensureSdkInitialized();
|
||||
|
||||
if (!_isAdLoaded || _rewardedAd == null) {
|
||||
final loaded = await preloadAd();
|
||||
if (!loaded || _rewardedAd == null) {
|
||||
debugPrint('[AdManager] Cannot show ad, failed to load.');
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final deviceId = await CryptoService.getDeviceId();
|
||||
|
||||
final nonce = '${Random().nextInt(0x7FFFFFFF)}-${DateTime.now().millisecondsSinceEpoch}';
|
||||
HivemindService.setExpectedNonce(nonce);
|
||||
debugPrint('[AdManager] Ad nonce: $nonce');
|
||||
final nonce = HivemindService.newNonce();
|
||||
|
||||
final ssvOptions = ServerSideVerificationOptions(
|
||||
customData: jsonEncode({
|
||||
@@ -182,7 +179,7 @@ class AdManager extends ChangeNotifier {
|
||||
},
|
||||
);
|
||||
|
||||
return rewardCompleter.future;
|
||||
return await rewardCompleter.future ? nonce : null;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -29,8 +29,8 @@ abstract final class AppConfig {
|
||||
// ── Updates ────────────────────────────────────────────────────────────
|
||||
/// Used by the updater.
|
||||
static const String applicationId = 'com.paladinvpn.app'; // ◄── REPLACE
|
||||
static const String githubOwner = 'YOUR_USERNAME'; // ◄── REPLACE
|
||||
static const String githubRepo = 'revoltvpn'; // ◄── REPLACE
|
||||
static const String githubOwner = 'YOUR_GITHUB_USERNAME'; // ◄── REPLACE (current: 'esefxdz')
|
||||
static const String githubRepo = 'YOUR_REPO_NAME'; // ◄── REPLACE (current: 'ReVoltVPN')
|
||||
|
||||
/// Fallback URL if the GitHub API call fails.
|
||||
static const String githubReleasesUrl = 'https://github.com/$githubOwner/$githubRepo/releases/latest';
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
enum ConnectionMode { tun, proxy }
|
||||
|
||||
abstract final class ConnectionSettings {
|
||||
ConnectionSettings._();
|
||||
|
||||
static const String _modeKey = 'connection_mode';
|
||||
|
||||
static ConnectionMode _mode = ConnectionMode.tun;
|
||||
static bool _initialized = false;
|
||||
|
||||
static ConnectionMode get mode => _mode;
|
||||
|
||||
static Future<void> initialize() async {
|
||||
if (_initialized) return;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final storedMode = prefs.getString(_modeKey);
|
||||
|
||||
// Only the explicit current proxy value restores SOCKS5. Unknown and
|
||||
// obsolete values, including the pre-V3 'auto' label, fail closed to
|
||||
// full-tunnel TUN instead of silently selecting a weaker route.
|
||||
_mode = storedMode == ConnectionMode.proxy.name
|
||||
? ConnectionMode.proxy
|
||||
: ConnectionMode.tun;
|
||||
|
||||
if (storedMode != _mode.name) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
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;
|
||||
_initialized = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,17 @@ import 'package:uuid/uuid.dart';
|
||||
class CryptoService {
|
||||
static const String _deviceIdPref = 'device_uuid';
|
||||
static const _storage = FlutterSecureStorage();
|
||||
static final RegExp _uuidV4 = RegExp(
|
||||
r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
/// Get or create a persistent device UUID for server-side session tracking.
|
||||
/// Corrupted or tampered storage values are replaced instead of being sent
|
||||
/// to the public API as arbitrary query data.
|
||||
static Future<String> getDeviceId() async {
|
||||
final existing = await _storage.read(key: _deviceIdPref);
|
||||
if (existing != null) return existing;
|
||||
if (existing != null && _uuidV4.hasMatch(existing)) return existing;
|
||||
|
||||
final newId = const Uuid().v4();
|
||||
await _storage.write(key: _deviceIdPref, value: newId);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
abstract final class HapticSettings {
|
||||
HapticSettings._();
|
||||
|
||||
static const String _prefKey = 'haptic_feedback_enabled';
|
||||
static const MethodChannel _channel =
|
||||
MethodChannel('com.revoltvpn.app/haptics');
|
||||
|
||||
static bool _enabled = false;
|
||||
static bool _initialized = false;
|
||||
|
||||
static bool get enabled => _enabled;
|
||||
|
||||
static Future<void> initialize() async {
|
||||
if (_initialized) return;
|
||||
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_enabled = prefs.getBool(_prefKey) ?? false;
|
||||
} catch (e) {
|
||||
debugPrint('[Haptics] Failed to load preference: $e');
|
||||
_enabled = false;
|
||||
} finally {
|
||||
_initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> setEnabled(bool value) async {
|
||||
final previous = _enabled;
|
||||
_enabled = value;
|
||||
_initialized = true;
|
||||
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final saved = await prefs.setBool(_prefKey, value);
|
||||
if (!saved) throw StateError('Could not save haptic setting');
|
||||
} catch (e) {
|
||||
_enabled = previous;
|
||||
debugPrint('[Haptics] Failed to save preference: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirmation haptic for a successful connect/disconnect. Best-effort: a
|
||||
/// vibration failure must never block or fail the VPN action.
|
||||
static void success() => unawaited(_impact());
|
||||
|
||||
static Future<void> _impact() async {
|
||||
if (!_initialized) await initialize();
|
||||
if (!_enabled || kIsWeb) return;
|
||||
|
||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||
try {
|
||||
final handled = await _channel.invokeMethod<bool>(
|
||||
'impact',
|
||||
<String, Object>{'kind': 'success'},
|
||||
) ??
|
||||
false;
|
||||
if (handled) return;
|
||||
} catch (e) {
|
||||
debugPrint('[Haptics] Native vibration unavailable: $e');
|
||||
}
|
||||
}
|
||||
|
||||
await HapticFeedback.mediumImpact();
|
||||
}
|
||||
}
|
||||
+417
-60
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:revoltvpn/logic/app_config.dart';
|
||||
@@ -12,84 +13,186 @@ class HivemindService {
|
||||
|
||||
static const _ua = 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 '
|
||||
'(KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36';
|
||||
static const int _maxControlResponseBytes = 256 * 1024;
|
||||
static const _pollBackoff = <Duration>[
|
||||
Duration(seconds: 1),
|
||||
Duration(seconds: 2),
|
||||
Duration(seconds: 3),
|
||||
Duration(seconds: 5),
|
||||
];
|
||||
|
||||
static Future<http.Response> directGet(Uri uri, {Duration timeout = const Duration(seconds: 5)}) {
|
||||
return http.get(uri, headers: {'User-Agent': _ua}).timeout(timeout);
|
||||
static Future<http.Response> directGet(
|
||||
Uri uri, {
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
}) {
|
||||
return controlGet(uri, timeout: timeout);
|
||||
}
|
||||
|
||||
static Future<http.Response> controlGet(
|
||||
Uri uri, {
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
}) async {
|
||||
final client = http.Client();
|
||||
final request = http.Request('GET', uri)
|
||||
..headers['User-Agent'] = _ua
|
||||
..followRedirects = false;
|
||||
|
||||
Future<http.Response> read() async {
|
||||
final streamed = await client.send(request);
|
||||
final declaredLength = streamed.contentLength;
|
||||
if (declaredLength != null && declaredLength > _maxControlResponseBytes) {
|
||||
throw const FormatException('Control response is too large.');
|
||||
}
|
||||
|
||||
final bytes = <int>[];
|
||||
await for (final chunk in streamed.stream) {
|
||||
if (bytes.length + chunk.length > _maxControlResponseBytes) {
|
||||
throw const FormatException('Control response is too large.');
|
||||
}
|
||||
bytes.addAll(chunk);
|
||||
}
|
||||
|
||||
return http.Response.bytes(
|
||||
bytes,
|
||||
streamed.statusCode,
|
||||
headers: streamed.headers,
|
||||
isRedirect: streamed.isRedirect,
|
||||
persistentConnection: streamed.persistentConnection,
|
||||
reasonPhrase: streamed.reasonPhrase,
|
||||
request: request,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await read().timeout(
|
||||
timeout,
|
||||
onTimeout: () {
|
||||
client.close();
|
||||
throw TimeoutException('Control request timed out.', timeout);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
static Future<http.Response> controlPost(
|
||||
Uri uri,
|
||||
Map<String, dynamic> body, {
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
}) async {
|
||||
final client = http.Client();
|
||||
final request = http.Request('POST', uri)
|
||||
..headers['User-Agent'] = _ua
|
||||
..headers['Content-Type'] = 'application/json'
|
||||
..body = jsonEncode(body)
|
||||
..followRedirects = false;
|
||||
|
||||
Future<http.Response> send() async {
|
||||
final streamed = await client.send(request);
|
||||
final declaredLength = streamed.contentLength;
|
||||
if (declaredLength != null && declaredLength > _maxControlResponseBytes) {
|
||||
throw const FormatException('Control response is too large.');
|
||||
}
|
||||
|
||||
final bytes = <int>[];
|
||||
await for (final chunk in streamed.stream) {
|
||||
if (bytes.length + chunk.length > _maxControlResponseBytes) {
|
||||
throw const FormatException('Control response is too large.');
|
||||
}
|
||||
bytes.addAll(chunk);
|
||||
}
|
||||
|
||||
return http.Response.bytes(bytes, streamed.statusCode,
|
||||
headers: streamed.headers, request: request);
|
||||
}
|
||||
|
||||
try {
|
||||
return await send().timeout(
|
||||
timeout,
|
||||
onTimeout: () {
|
||||
client.close();
|
||||
throw TimeoutException('Control request timed out.', timeout);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Hands the session back so the server can drop the VLESS client instead of
|
||||
// leaving it provisioned until the window runs out.
|
||||
//
|
||||
// Only the user-initiated disconnect calls this. An expiry, a hard cap or a
|
||||
// server-ended session is already gone server-side, and /session/stop takes
|
||||
// nothing but a device_id as proof (hivemind/api.py notes this), so a
|
||||
// transient client error must never be allowed to destroy a live session.
|
||||
// Best effort throughout: the session expires on its own regardless.
|
||||
static Future<void> releaseSession() async {
|
||||
try {
|
||||
final deviceId = await CryptoService.getDeviceId();
|
||||
await controlPost(
|
||||
_publicUrl('/session/stop'),
|
||||
{'device_id': deviceId},
|
||||
timeout: const Duration(seconds: 3),
|
||||
);
|
||||
} catch (_) {
|
||||
// Nothing to recover: the server expires the session anyway.
|
||||
}
|
||||
}
|
||||
|
||||
static void cancel() {
|
||||
_currentCallId++;
|
||||
_expectedNonce = null;
|
||||
}
|
||||
|
||||
static void setExpectedNonce(String nonce) {
|
||||
_expectedNonce = nonce;
|
||||
}
|
||||
|
||||
// Fetches this device's VLESS URL.
|
||||
// adNonce — wait for the session that ad created (SSV can lag, so poll longer)
|
||||
// resume — re-attach to the session already held; any nonce accepted
|
||||
// neither — ads off: mint a nonce and fire the bypass (rule 87)
|
||||
static Future<String> fetchConfigDirectly({
|
||||
void Function(int attempt, int total)? onAttempt,
|
||||
bool skipAdBypass = false,
|
||||
String? adNonce,
|
||||
bool resume = false,
|
||||
}) async {
|
||||
final deviceId = await CryptoService.getDeviceId();
|
||||
|
||||
// Capture the generation before the first await. Otherwise a disconnect
|
||||
// that lands while secure storage is resolving the device ID can be lost
|
||||
// and the cancelled call can resume network work with a fresh generation.
|
||||
final callId = ++_currentCallId;
|
||||
final nonce = '${Random().nextInt(0x7FFFFFFF)}-${DateTime.now().millisecondsSinceEpoch}';
|
||||
final deviceId = await CryptoService.getDeviceId();
|
||||
_throwIfCancelled(callId);
|
||||
|
||||
final nonce = resume ? null : (adNonce ?? newNonce());
|
||||
_expectedNonce = nonce;
|
||||
debugPrint('[HivemindService] Call #$callId — nonce: $nonce');
|
||||
|
||||
final url = _publicUrl('/session/status?device_id=$deviceId');
|
||||
|
||||
if (!skipAdBypass) {
|
||||
try {
|
||||
final customData = jsonEncode({'device_id': deviceId, 'nonce': nonce});
|
||||
final fakeUrl = _publicUrl(
|
||||
'/admob/callback?signature=test&key_id=test&custom_data=${Uri.encodeComponent(customData)}');
|
||||
await directGet(fakeUrl, timeout: const Duration(seconds: 8));
|
||||
} catch (_) {}
|
||||
if (!resume && adNonce == null) {
|
||||
await _runConfiguredBypass(deviceId, nonce!);
|
||||
_throwIfCancelled(callId);
|
||||
}
|
||||
|
||||
const maxAttempts = 5;
|
||||
for (int i = 1; i <= maxAttempts; i++) {
|
||||
if (_currentCallId != callId) throw Exception('Cancelled');
|
||||
final maxAttempts = adNonce != null ? 10 : 5;
|
||||
for (var attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
_throwIfCancelled(callId);
|
||||
onAttempt?.call(attempt, maxAttempts);
|
||||
|
||||
onAttempt?.call(i, maxAttempts);
|
||||
try {
|
||||
final response = await directGet(url);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
|
||||
final serverNonce = data['nonce'] as String?;
|
||||
if (_expectedNonce != null && serverNonce != null && serverNonce != _expectedNonce) {
|
||||
debugPrint('[HivemindService] Nonce mismatch — retrying…');
|
||||
} else if (data['active'] == true && data['vless_uuid'] != null) {
|
||||
final vlessUuid = data['vless_uuid'];
|
||||
final vlessIp = data['vless_ip'] ?? AppConfig.serverIp;
|
||||
final vlessPort = data['vless_port'] ?? 443;
|
||||
final pbk = data['reality_pbk'] ?? '';
|
||||
final sid = data['reality_sid'] ?? '';
|
||||
final sni = data['reality_sni'];
|
||||
if (sni == null) throw Exception('Server did not provide reality_sni');
|
||||
final fp = data['reality_fp'] ?? AppConfig.realityFp;
|
||||
final xhttpPath = data['xhttp_path'] ?? AppConfig.vlessPath;
|
||||
|
||||
final vlessUrl = 'vless://$vlessUuid@$vlessIp:$vlessPort'
|
||||
'?security=${AppConfig.vlessSecurity}'
|
||||
'&type=${AppConfig.vlessType}'
|
||||
'&path=$xhttpPath'
|
||||
'&pbk=${Uri.encodeComponent(pbk)}'
|
||||
'&sni=${Uri.encodeComponent(sni)}'
|
||||
'&sid=${Uri.encodeComponent(sid)}'
|
||||
'&fp=${Uri.encodeComponent(fp)}'
|
||||
'#Revolt VPN';
|
||||
_expectedNonce = null;
|
||||
return vlessUrl;
|
||||
}
|
||||
final session = await _fetchActiveSession(deviceId, nonce);
|
||||
_throwIfCancelled(callId);
|
||||
if (session != null) {
|
||||
_expectedNonce = null;
|
||||
return session.toVlessUrl();
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.toString().contains('Cancelled')) rethrow;
|
||||
debugPrint('[HivemindService] Attempt $i failed: $e');
|
||||
if (_isCancelledError(e)) rethrow;
|
||||
debugPrint('[Hivemind] session attempt $attempt failed');
|
||||
}
|
||||
|
||||
if (i < maxAttempts) await Future.delayed(const Duration(seconds: 1));
|
||||
if (attempt < maxAttempts) {
|
||||
await Future.delayed(
|
||||
_pollBackoff[min(attempt - 1, _pollBackoff.length - 1)],
|
||||
);
|
||||
_throwIfCancelled(callId);
|
||||
}
|
||||
}
|
||||
|
||||
throw Exception('Session not activated. Server callback may have timed out.');
|
||||
@@ -97,16 +200,270 @@ class HivemindService {
|
||||
|
||||
static Future<bool> checkHealth() async {
|
||||
try {
|
||||
final url = Uri.parse('${AppConfig.hivemindApiPublic}/health');
|
||||
final response = await directGet(url, timeout: const Duration(seconds: 3));
|
||||
final response = await controlGet(
|
||||
_publicUrl('/health'),
|
||||
timeout: const Duration(seconds: 3),
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── URL builders ──────────────────────────────────────────────────
|
||||
static String newNonce() {
|
||||
final random = Random.secure();
|
||||
final high = random.nextInt(0x7FFFFFFF);
|
||||
final low = random.nextInt(0x7FFFFFFF);
|
||||
return '$high-$low-${DateTime.now().microsecondsSinceEpoch}';
|
||||
}
|
||||
|
||||
static Future<void> _runConfiguredBypass(String deviceId, String nonce) async {
|
||||
try {
|
||||
final customData = jsonEncode({'device_id': deviceId, 'nonce': nonce});
|
||||
final callback = _publicUrl(
|
||||
'/admob/callback?signature=test&key_id=test&custom_data=${Uri.encodeComponent(customData)}',
|
||||
);
|
||||
await directGet(callback, timeout: const Duration(seconds: 8));
|
||||
} catch (_) {
|
||||
// Validation bypass behavior is intentionally best-effort in this build.
|
||||
}
|
||||
}
|
||||
|
||||
// nonce == null accepts whatever nonce the server holds (resume path).
|
||||
static Future<_HivemindSessionConfig?> _fetchActiveSession(
|
||||
String deviceId,
|
||||
String? nonce,
|
||||
) async {
|
||||
final statusUri = _publicUrl('/session/status').replace(
|
||||
queryParameters: {'device_id': deviceId},
|
||||
);
|
||||
final response = await controlGet(statusUri);
|
||||
if (response.statusCode != 200) return null;
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, dynamic>) return null;
|
||||
|
||||
final serverNonce = decoded['nonce'];
|
||||
if (_expectedNonce != nonce) return null;
|
||||
if (nonce != null && (serverNonce is! String || serverNonce != nonce)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (decoded['active'] != true || decoded['vless_uuid'] == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return _HivemindSessionConfig.fromJson(decoded);
|
||||
}
|
||||
|
||||
static void _throwIfCancelled(int callId) {
|
||||
if (_currentCallId != callId) throw Exception('Cancelled');
|
||||
}
|
||||
|
||||
static bool _isCancelledError(Object error) =>
|
||||
error.toString().contains('Cancelled');
|
||||
|
||||
static Uri _publicUrl(String path) =>
|
||||
Uri.parse('${AppConfig.hivemindApiPublic}$path');
|
||||
}
|
||||
|
||||
class _HivemindSessionConfig {
|
||||
static final RegExp _uuidPattern = RegExp(
|
||||
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
static final RegExp _shortIdPattern =
|
||||
RegExp(r'^[0-9a-f]{0,16}$', caseSensitive: false);
|
||||
|
||||
final String uuid;
|
||||
final String host;
|
||||
final int port;
|
||||
final String publicKey;
|
||||
final String shortId;
|
||||
final String sni;
|
||||
final String fingerprint;
|
||||
final String path;
|
||||
|
||||
const _HivemindSessionConfig({
|
||||
required this.uuid,
|
||||
required this.host,
|
||||
required this.port,
|
||||
required this.publicKey,
|
||||
required this.shortId,
|
||||
required this.sni,
|
||||
required this.fingerprint,
|
||||
required this.path,
|
||||
});
|
||||
|
||||
factory _HivemindSessionConfig.fromJson(Map<String, dynamic> json) {
|
||||
final uuid = _requiredString(json, 'vless_uuid', maxLength: 64);
|
||||
if (!_uuidPattern.hasMatch(uuid)) {
|
||||
throw const FormatException('Invalid VLESS UUID');
|
||||
}
|
||||
|
||||
// The API may rotate credentials and Reality parameters, but it must never
|
||||
// choose the tunnel destination. Keep that trust anchor compiled into the
|
||||
// app so an API/domain compromise can cause denial of service, not redirect
|
||||
// VPN traffic to an attacker-controlled server.
|
||||
final pinnedHost = _validatedPublicIp(AppConfig.serverIp, 'serverIp');
|
||||
final advertisedHost = json['vless_ip'];
|
||||
if (advertisedHost != null) {
|
||||
if (advertisedHost is! String || advertisedHost.trim() != pinnedHost) {
|
||||
throw const FormatException(
|
||||
'Session VLESS host does not match compiled server pin',
|
||||
);
|
||||
}
|
||||
}
|
||||
final host = pinnedHost;
|
||||
|
||||
final advertisedPort = json['vless_port'];
|
||||
if (advertisedPort != null &&
|
||||
(advertisedPort is! num || advertisedPort.toInt() != 443)) {
|
||||
throw const FormatException('Session VLESS port does not match compiled pin');
|
||||
}
|
||||
const port = 443;
|
||||
final sni = _validatedHost(
|
||||
_requiredString(json, 'reality_sni', maxLength: 253),
|
||||
'reality_sni',
|
||||
);
|
||||
final publicKey = _requiredString(json, 'reality_pbk', maxLength: 128);
|
||||
final shortId = _stringOr(json['reality_sid'], '').trim();
|
||||
if (!_shortIdPattern.hasMatch(shortId)) {
|
||||
throw const FormatException('Invalid Reality short ID');
|
||||
}
|
||||
|
||||
final fingerprint =
|
||||
_boundedString(json['reality_fp'], AppConfig.realityFp, 32, 'reality_fp');
|
||||
final path =
|
||||
_boundedString(json['xhttp_path'], AppConfig.vlessPath, 2048, 'xhttp_path');
|
||||
if (!path.startsWith('/')) {
|
||||
throw const FormatException('Invalid XHTTP path');
|
||||
}
|
||||
|
||||
return _HivemindSessionConfig(
|
||||
uuid: uuid,
|
||||
host: host,
|
||||
port: port,
|
||||
publicKey: publicKey,
|
||||
shortId: shortId,
|
||||
sni: sni,
|
||||
fingerprint: fingerprint,
|
||||
path: path,
|
||||
);
|
||||
}
|
||||
|
||||
String toVlessUrl() {
|
||||
return Uri(
|
||||
scheme: 'vless',
|
||||
userInfo: uuid,
|
||||
host: host,
|
||||
port: port,
|
||||
queryParameters: <String, String>{
|
||||
'security': AppConfig.vlessSecurity,
|
||||
'type': AppConfig.vlessType,
|
||||
'path': path,
|
||||
'pbk': publicKey,
|
||||
'sni': sni,
|
||||
'sid': shortId,
|
||||
'fp': fingerprint,
|
||||
},
|
||||
fragment: 'Revolt VPN',
|
||||
).toString();
|
||||
}
|
||||
|
||||
static String _requiredString(
|
||||
Map<String, dynamic> json,
|
||||
String key, {
|
||||
int maxLength = 4096,
|
||||
}) {
|
||||
final value = json[key];
|
||||
if (value is! String) {
|
||||
throw FormatException('Missing required session field: $key');
|
||||
}
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty || trimmed.length > maxLength) {
|
||||
throw FormatException('Invalid session field: $key');
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
static String _stringOr(Object? value, String fallback) {
|
||||
return value is String && value.trim().isNotEmpty ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
static String _boundedString(
|
||||
Object? value,
|
||||
String fallback,
|
||||
int maxLength,
|
||||
String field,
|
||||
) {
|
||||
final result = _stringOr(value, fallback);
|
||||
if (result.isEmpty || result.length > maxLength) {
|
||||
throw FormatException('Invalid session field: $field');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static String _validatedHost(String input, String field) {
|
||||
final value = input.trim();
|
||||
if (value.isEmpty ||
|
||||
value.length > 253 ||
|
||||
value.contains(RegExp(r'[\s/@?#]'))) {
|
||||
throw FormatException('Invalid session host: $field');
|
||||
}
|
||||
try {
|
||||
final probe = Uri(scheme: 'https', host: value);
|
||||
if (probe.host.isEmpty) {
|
||||
throw FormatException('Invalid session host: $field');
|
||||
}
|
||||
} on FormatException {
|
||||
throw FormatException('Invalid session host: $field');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static String _validatedPublicIp(String input, String field) {
|
||||
final value = input.trim().toLowerCase();
|
||||
if (_isPublicIpv4(value) || _isPublicIpv6(value)) return value;
|
||||
throw FormatException('Invalid public IP pin: $field');
|
||||
}
|
||||
|
||||
static bool _isPublicIpv4(String value) {
|
||||
final parts = value.split('.');
|
||||
if (parts.length != 4) return false;
|
||||
final octets = <int>[];
|
||||
for (final part in parts) {
|
||||
if (part.isEmpty || (part.length > 1 && part.startsWith('0'))) return false;
|
||||
final parsed = int.tryParse(part);
|
||||
if (parsed == null || parsed < 0 || parsed > 255) return false;
|
||||
octets.add(parsed);
|
||||
}
|
||||
final a = octets[0];
|
||||
final b = octets[1];
|
||||
if (a == 0 || a == 10 || a == 127 || a >= 224) return false;
|
||||
if (a == 100 && b >= 64 && b <= 127) return false;
|
||||
if (a == 169 && b == 254) return false;
|
||||
if (a == 172 && b >= 16 && b <= 31) return false;
|
||||
if (a == 192 && b == 168) return false;
|
||||
if (a == 192 && b == 0 && octets[2] == 2) return false;
|
||||
if (a == 198 && b == 51 && octets[2] == 100) return false;
|
||||
if (a == 203 && b == 0 && octets[2] == 113) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool _isPublicIpv6(String value) {
|
||||
if (!value.contains(':') || value.contains('.')) return false;
|
||||
try {
|
||||
final parsed = Uri.parse('https://[$value]/');
|
||||
if (parsed.host.isEmpty || !parsed.host.contains(':')) return false;
|
||||
} on FormatException {
|
||||
return false;
|
||||
}
|
||||
if (value == '::' || value == '::1') return false;
|
||||
if (value.startsWith('fc') || value.startsWith('fd')) return false;
|
||||
if (value.startsWith('fe8') || value.startsWith('fe9') || value.startsWith('fea') || value.startsWith('feb')) return false;
|
||||
if (value.startsWith('ff')) return false;
|
||||
if (value.startsWith('2001:db8:') || value == '2001:db8::') return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
class LocalSocksTestResult {
|
||||
final bool ok;
|
||||
final int? latencyMs;
|
||||
final String message;
|
||||
|
||||
const LocalSocksTestResult({
|
||||
required this.ok,
|
||||
required this.latencyMs,
|
||||
required this.message,
|
||||
});
|
||||
}
|
||||
|
||||
abstract final class LocalSocksTester {
|
||||
LocalSocksTester._();
|
||||
|
||||
/// Fast local readiness probe used while starting the VPN.
|
||||
///
|
||||
/// This stops after authenticated SOCKS5 negotiation. Startup must not depend
|
||||
/// on an unrelated external host being reachable.
|
||||
static Future<LocalSocksTestResult> testListener({
|
||||
String host = '127.0.0.1',
|
||||
required int port,
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
Socket? socket;
|
||||
_SocketReader? reader;
|
||||
|
||||
try {
|
||||
socket = await Socket.connect(
|
||||
host,
|
||||
port,
|
||||
timeout: const Duration(seconds: 1),
|
||||
);
|
||||
socket.setOption(SocketOption.tcpNoDelay, true);
|
||||
reader = _SocketReader(socket);
|
||||
|
||||
final authenticated = await _authenticate(
|
||||
reader,
|
||||
socket,
|
||||
username: username,
|
||||
password: password,
|
||||
timeout: const Duration(seconds: 1),
|
||||
);
|
||||
if (!authenticated) {
|
||||
return const LocalSocksTestResult(
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
message: 'Local SOCKS5 listener rejected authentication.',
|
||||
);
|
||||
}
|
||||
|
||||
stopwatch.stop();
|
||||
return LocalSocksTestResult(
|
||||
ok: true,
|
||||
latencyMs: stopwatch.elapsedMilliseconds,
|
||||
message: 'Local SOCKS5 listener is ready.',
|
||||
);
|
||||
} on TimeoutException {
|
||||
return const LocalSocksTestResult(
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
message: 'Local SOCKS5 listener timed out.',
|
||||
);
|
||||
} on SocketException {
|
||||
return LocalSocksTestResult(
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
message: 'Local SOCKS5 is not listening on $host:$port.',
|
||||
);
|
||||
} catch (_) {
|
||||
return const LocalSocksTestResult(
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
message: 'Local SOCKS5 listener check failed.',
|
||||
);
|
||||
} finally {
|
||||
await reader?.close();
|
||||
await socket?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Full user-facing test: authenticated local SOCKS handshake, an outbound
|
||||
/// CONNECT through the active ReVolt route, and — critically — a real
|
||||
/// request/response exchange over that CONNECT.
|
||||
///
|
||||
/// The exchange is not optional politeness. Xray answers a SOCKS CONNECT
|
||||
/// before it dials upstream, so the reply code alone says nothing about the
|
||||
/// outbound: it returns success for unroutable addresses and for hostnames
|
||||
/// that can never resolve. Without a round trip this test reports OK while
|
||||
/// the server is down, which is precisely when a user runs it.
|
||||
static Future<LocalSocksTestResult> test({
|
||||
String host = '127.0.0.1',
|
||||
required int port,
|
||||
required String username,
|
||||
required String password,
|
||||
String targetHost = 'example.com',
|
||||
int targetPort = 80,
|
||||
}) async {
|
||||
const totalTimeout = Duration(seconds: 8);
|
||||
final stopwatch = Stopwatch()..start();
|
||||
Socket? socket;
|
||||
_SocketReader? reader;
|
||||
|
||||
Duration remaining() {
|
||||
final micros = totalTimeout.inMicroseconds - stopwatch.elapsedMicroseconds;
|
||||
if (micros <= 0) throw TimeoutException('SOCKS5 test timed out.', totalTimeout);
|
||||
return Duration(microseconds: micros);
|
||||
}
|
||||
|
||||
try {
|
||||
if (targetPort <= 0 || targetPort > 65535) {
|
||||
return const LocalSocksTestResult(
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
message: 'SOCKS5 test target is invalid.',
|
||||
);
|
||||
}
|
||||
|
||||
socket = await Socket.connect(
|
||||
host,
|
||||
port,
|
||||
timeout: remaining(),
|
||||
);
|
||||
socket.setOption(SocketOption.tcpNoDelay, true);
|
||||
reader = _SocketReader(socket);
|
||||
|
||||
final authenticated = await _authenticate(
|
||||
reader,
|
||||
socket,
|
||||
username: username,
|
||||
password: password,
|
||||
timeout: remaining(),
|
||||
);
|
||||
if (!authenticated) {
|
||||
return const LocalSocksTestResult(
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
message: 'Local SOCKS5 listener rejected authentication.',
|
||||
);
|
||||
}
|
||||
|
||||
final hostBytes = utf8.encode(targetHost);
|
||||
if (hostBytes.isEmpty || hostBytes.length > 255) {
|
||||
return const LocalSocksTestResult(
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
message: 'SOCKS5 test target is invalid.',
|
||||
);
|
||||
}
|
||||
|
||||
socket.add(<int>[
|
||||
0x05,
|
||||
0x01,
|
||||
0x00,
|
||||
0x03,
|
||||
hostBytes.length,
|
||||
...hostBytes,
|
||||
(targetPort >> 8) & 0xff,
|
||||
targetPort & 0xff,
|
||||
]);
|
||||
await socket.flush();
|
||||
|
||||
final replyHead = await reader.readExactly(4, timeout: remaining());
|
||||
if (replyHead[0] != 0x05 || replyHead[1] != 0x00) {
|
||||
return const LocalSocksTestResult(
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
message:
|
||||
'SOCKS5 is listening, but the ReVolt route could not reach the test target.',
|
||||
);
|
||||
}
|
||||
|
||||
await _consumeAddress(reader, replyHead[3], timeout: remaining());
|
||||
|
||||
// The CONNECT reply above is not evidence of an outbound. Force a round
|
||||
// trip and require the peer's answer before claiming the route works.
|
||||
socket.add(
|
||||
utf8.encode(
|
||||
'GET / HTTP/1.1\r\nHost: $targetHost\r\nConnection: close\r\n\r\n',
|
||||
),
|
||||
);
|
||||
await socket.flush();
|
||||
|
||||
List<int> banner;
|
||||
try {
|
||||
banner = await reader.readExactly(5, timeout: remaining());
|
||||
} on TimeoutException {
|
||||
return const LocalSocksTestResult(
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
message: 'Local SOCKS5 is up, but the ReVolt outbound never replied.',
|
||||
);
|
||||
} on SocketException {
|
||||
return const LocalSocksTestResult(
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
message:
|
||||
'Local SOCKS5 is up, but the ReVolt outbound dropped the connection.',
|
||||
);
|
||||
}
|
||||
|
||||
if (String.fromCharCodes(banner) != 'HTTP/') {
|
||||
return const LocalSocksTestResult(
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
message:
|
||||
'Local SOCKS5 is up, but the ReVolt outbound returned unexpected data.',
|
||||
);
|
||||
}
|
||||
|
||||
stopwatch.stop();
|
||||
return LocalSocksTestResult(
|
||||
ok: true,
|
||||
latencyMs: stopwatch.elapsedMilliseconds,
|
||||
message: 'Local SOCKS5 and the ReVolt outbound are reachable.',
|
||||
);
|
||||
} on TimeoutException {
|
||||
return const LocalSocksTestResult(
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
message: 'Local SOCKS5 test timed out.',
|
||||
);
|
||||
} on SocketException {
|
||||
return LocalSocksTestResult(
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
message: 'Local SOCKS5 is not listening on $host:$port.',
|
||||
);
|
||||
} catch (_) {
|
||||
return const LocalSocksTestResult(
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
message: 'Local SOCKS5 test failed.',
|
||||
);
|
||||
} finally {
|
||||
await reader?.close();
|
||||
await socket?.close();
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> _authenticate(
|
||||
_SocketReader reader,
|
||||
Socket socket, {
|
||||
required String username,
|
||||
required String password,
|
||||
Duration timeout = const Duration(seconds: 4),
|
||||
}) async {
|
||||
final userBytes = utf8.encode(username);
|
||||
final passBytes = utf8.encode(password);
|
||||
if (userBytes.isEmpty ||
|
||||
passBytes.isEmpty ||
|
||||
userBytes.length > 255 ||
|
||||
passBytes.length > 255) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ReVolt never accepts unauthenticated local SOCKS. Advertising only RFC
|
||||
// 1929 username/password auth makes a future credential-plumbing regression
|
||||
// fail closed instead of silently falling back to method 0x00.
|
||||
socket.add(const <int>[0x05, 0x01, 0x02]);
|
||||
await socket.flush();
|
||||
|
||||
final greeting = await reader.readExactly(2, timeout: timeout);
|
||||
if (greeting[0] != 0x05 || greeting[1] != 0x02) return false;
|
||||
|
||||
socket.add(<int>[
|
||||
0x01,
|
||||
userBytes.length,
|
||||
...userBytes,
|
||||
passBytes.length,
|
||||
...passBytes,
|
||||
]);
|
||||
await socket.flush();
|
||||
|
||||
final authReply = await reader.readExactly(2, timeout: timeout);
|
||||
return authReply[0] == 0x01 && authReply[1] == 0x00;
|
||||
}
|
||||
|
||||
static Future<void> _consumeAddress(
|
||||
_SocketReader reader,
|
||||
int addressType, {
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
if (addressType == 0x01) {
|
||||
await reader.readExactly(4 + 2, timeout: timeout);
|
||||
return;
|
||||
}
|
||||
if (addressType == 0x04) {
|
||||
await reader.readExactly(16 + 2, timeout: timeout);
|
||||
return;
|
||||
}
|
||||
if (addressType == 0x03) {
|
||||
final length = (await reader.readExactly(1, timeout: timeout)).first;
|
||||
if (length == 0) {
|
||||
throw const FormatException('Invalid SOCKS5 domain length');
|
||||
}
|
||||
await reader.readExactly(length + 2, timeout: timeout);
|
||||
return;
|
||||
}
|
||||
throw const FormatException('Unsupported SOCKS5 address type');
|
||||
}
|
||||
}
|
||||
|
||||
class _SocketReader {
|
||||
final StreamIterator<Uint8List> _iterator;
|
||||
final List<int> _buffer = <int>[];
|
||||
|
||||
_SocketReader(Socket socket) : _iterator = StreamIterator<Uint8List>(socket);
|
||||
|
||||
Future<List<int>> readExactly(
|
||||
int count, {
|
||||
Duration timeout = const Duration(seconds: 4),
|
||||
}) async {
|
||||
final elapsed = Stopwatch()..start();
|
||||
while (_buffer.length < count) {
|
||||
final remainingMicros = timeout.inMicroseconds - elapsed.elapsedMicroseconds;
|
||||
if (remainingMicros <= 0) {
|
||||
throw TimeoutException('Socket read timed out', timeout);
|
||||
}
|
||||
final hasNext = await _iterator.moveNext().timeout(
|
||||
Duration(microseconds: remainingMicros),
|
||||
);
|
||||
if (!hasNext) {
|
||||
throw const SocketException('Socket closed early');
|
||||
}
|
||||
_buffer.addAll(_iterator.current);
|
||||
}
|
||||
|
||||
final result = _buffer.sublist(0, count);
|
||||
_buffer.removeRange(0, count);
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> close() => _iterator.cancel();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class NetworkSnapshot {
|
||||
final String reason;
|
||||
final String transport;
|
||||
final bool connected;
|
||||
final bool validated;
|
||||
final bool metered;
|
||||
final int timestamp;
|
||||
|
||||
const NetworkSnapshot({
|
||||
required this.reason,
|
||||
required this.transport,
|
||||
required this.connected,
|
||||
required this.validated,
|
||||
required this.metered,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
factory NetworkSnapshot.fromMap(Map<dynamic, dynamic> map) {
|
||||
return NetworkSnapshot(
|
||||
reason: map['reason'] as String? ?? 'changed',
|
||||
transport: map['transport'] as String? ?? 'unknown',
|
||||
connected: map['connected'] == true,
|
||||
validated: map['validated'] == true,
|
||||
metered: map['metered'] == true,
|
||||
timestamp: (map['timestamp'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract final class NetworkMonitor {
|
||||
NetworkMonitor._();
|
||||
|
||||
static const EventChannel _channel = EventChannel('com.revoltvpn.app/network');
|
||||
|
||||
static Stream<NetworkSnapshot> get changes {
|
||||
return _channel.receiveBroadcastStream().where((event) => event is Map).map(
|
||||
(event) => NetworkSnapshot.fromMap(event as Map<dynamic, dynamic>),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,16 +16,41 @@ class NotificationService {
|
||||
/// Sets the notification's content text to [text].
|
||||
///
|
||||
/// The native side rebuilds the whole notification, so skip the round-trip
|
||||
/// when the text has not changed.
|
||||
/// when the text has not changed. A failed native update is intentionally
|
||||
/// not cached so the next tick can retry it.
|
||||
static Future<void> updateTimer(String text) async {
|
||||
if (text == _lastText) return;
|
||||
_lastText = text;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('updateNotificationText', {
|
||||
'title': 'Revolt VPN',
|
||||
'text': text,
|
||||
'disconnectLabel': 'Disconnect',
|
||||
});
|
||||
_lastText = text;
|
||||
} catch (_) {
|
||||
// Cosmetic.
|
||||
}
|
||||
}
|
||||
|
||||
// Same id as the service's own alert, so a double post replaces.
|
||||
static Future<void> showDisconnectedAlert() async {
|
||||
try {
|
||||
await _channel.invokeMethod<void>('showDisconnectedAlert');
|
||||
} catch (_) {
|
||||
// Informational only.
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes down the countdown notification.
|
||||
///
|
||||
/// [updateTimer] re-posts an ongoing, non-dismissible notification, so a tick
|
||||
/// that lands after the VPN service has cancelled its own foreground copy
|
||||
/// leaves a frozen countdown on screen that nobody owns. Call this once the
|
||||
/// tunnel is actually down.
|
||||
static Future<void> cancelTimer() async {
|
||||
_lastText = null;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('cancelNotification');
|
||||
} catch (_) {
|
||||
// Cosmetic.
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
class SecureSocksSession {
|
||||
static const String inboundTag = 'revolt-secure-socks';
|
||||
static final Random _random = Random.secure();
|
||||
|
||||
final int port;
|
||||
final String username;
|
||||
final String password;
|
||||
final String configJson;
|
||||
|
||||
const SecureSocksSession._({
|
||||
required this.port,
|
||||
required this.username,
|
||||
required this.password,
|
||||
required this.configJson,
|
||||
});
|
||||
|
||||
static Future<SecureSocksSession> create(String baseConfig) async {
|
||||
final decoded = jsonDecode(baseConfig);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw const FormatException('VLESS configuration must be a JSON object.');
|
||||
}
|
||||
|
||||
final reservation = await ServerSocket.bind(
|
||||
InternetAddress.loopbackIPv4,
|
||||
0,
|
||||
shared: false,
|
||||
);
|
||||
final port = reservation.port;
|
||||
await reservation.close();
|
||||
|
||||
if (port <= 1024 || port > 65535) {
|
||||
throw StateError('Could not allocate a safe local SOCKS5 port.');
|
||||
}
|
||||
|
||||
final username = _token(16);
|
||||
final password = _token(32);
|
||||
|
||||
// ReVolt owns local ingress. Never accept listeners supplied by a remote
|
||||
// or imported Xray config; the client exposes exactly one authenticated
|
||||
// loopback SOCKS5 listener for the current ephemeral session.
|
||||
decoded['inbounds'] = <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'tag': inboundTag,
|
||||
'port': port,
|
||||
'listen': '127.0.0.1',
|
||||
'protocol': 'socks',
|
||||
'settings': <String, dynamic>{
|
||||
'auth': 'password',
|
||||
'udp': true,
|
||||
'ip': '127.0.0.1',
|
||||
'users': <Map<String, String>>[
|
||||
<String, String>{'user': username, 'pass': password},
|
||||
],
|
||||
},
|
||||
'sniffing': <String, dynamic>{
|
||||
'enabled': true,
|
||||
'destOverride': <String>['http', 'tls'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return SecureSocksSession._(
|
||||
port: port,
|
||||
username: username,
|
||||
password: password,
|
||||
configJson: jsonEncode(decoded),
|
||||
);
|
||||
}
|
||||
|
||||
static String _token(int byteCount) {
|
||||
final bytes = List<int>.generate(byteCount, (_) => _random.nextInt(256));
|
||||
return bytes.map((value) => value.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
// ── lib/logic/server_list.dart ──────────────────────────────────────────────
|
||||
// Server data model and ChangeNotifier — shared by StatusBar (shows current
|
||||
// server name/flag) and SelectionScreen (lets user pick a server).
|
||||
//
|
||||
// Each server is defined by its tunnel IP/port. Reality credentials (public
|
||||
// key, shortId) come from /session/status per session; only the transport
|
||||
// parameters live in AppConfig.
|
||||
//
|
||||
// When multi-server ships:
|
||||
// - _all becomes a fetched list (from /api/servers or bundled config)
|
||||
// - SelectionScreen shows multiple rows
|
||||
// - select() triggers a disconnect/reconnect cycle if the tunnel is active
|
||||
|
||||
class ServerInfo {
|
||||
final String id;
|
||||
final String name;
|
||||
final String flagAsset;
|
||||
final String ip;
|
||||
final int port;
|
||||
|
||||
const ServerInfo({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.flagAsset,
|
||||
required this.ip,
|
||||
required this.port,
|
||||
});
|
||||
}
|
||||
|
||||
class ServerList extends ChangeNotifier {
|
||||
static const _all = [
|
||||
ServerInfo(
|
||||
id: 'finland',
|
||||
name: 'Helsinki, Finland',
|
||||
flagAsset: 'assets/finland_flag_256.png',
|
||||
ip: '204.168.246.88',
|
||||
port: 443,
|
||||
),
|
||||
];
|
||||
|
||||
int _selectedIndex = 0;
|
||||
|
||||
List<ServerInfo> get all => _all;
|
||||
int get selectedIndex => _selectedIndex;
|
||||
ServerInfo get selected => _all[_selectedIndex];
|
||||
|
||||
// Loads persisted selection from SharedPreferences.
|
||||
// Fire-and-forget — UI starts with default, updates when prefs load.
|
||||
Future<void> init() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final saved = prefs.getInt('selected_server_index') ?? 0;
|
||||
if (saved >= 0 && saved < _all.length && saved != _selectedIndex) {
|
||||
_selectedIndex = saved;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Switches to a different server. Persists the choice.
|
||||
Future<void> select(int index) async {
|
||||
if (index == _selectedIndex || index < 0 || index >= _all.length) return;
|
||||
_selectedIndex = index;
|
||||
notifyListeners();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt('selected_server_index', index);
|
||||
}
|
||||
}
|
||||
+340
-118
@@ -1,11 +1,13 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:revoltvpn/logic/hivemind_service.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:revoltvpn/logic/app_config.dart';
|
||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||
import 'package:revoltvpn/logic/crypto_service.dart';
|
||||
import 'package:revoltvpn/logic/hivemind_service.dart';
|
||||
import 'package:revoltvpn/logic/notification_service.dart';
|
||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||
|
||||
class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
Timer? _timer;
|
||||
@@ -15,37 +17,64 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
final VpnConnection vpnConnection;
|
||||
|
||||
int _remainingSeconds = 0;
|
||||
int _usedBytes = 0;
|
||||
int _remainingAtLastSync = 0;
|
||||
int _usedBytes = 0;
|
||||
|
||||
bool _hasSyncedOnce = false;
|
||||
int _consecutiveFailures = 0;
|
||||
bool _isDisconnecting = false;
|
||||
bool _syncInProgress = false;
|
||||
bool _hasSyncedOnce = false;
|
||||
int _consecutiveFailures = 0;
|
||||
bool _isDisconnecting = false;
|
||||
int? _syncEpochInProgress;
|
||||
Completer<void>? _syncCompletion;
|
||||
int _sessionEpoch = 0;
|
||||
|
||||
bool _supportRewardClaimed = false;
|
||||
bool _supportRewardStateLoaded = false;
|
||||
int _supportStateEpoch = 0;
|
||||
|
||||
static const String _supportRewardClaimKey =
|
||||
'support_reward_claimed_active_session';
|
||||
static const FlutterSecureStorage _supportStorage = FlutterSecureStorage();
|
||||
|
||||
static const int _maxConsecutiveFailures = 3;
|
||||
static const int _maxOfflineSeconds = 120;
|
||||
int _offlineSeconds = 0;
|
||||
|
||||
static const int _pollIntervalSeconds = 5;
|
||||
static const int _backgroundPollIntervalSeconds = 30;
|
||||
static const int _pollIntervalSeconds = 60;
|
||||
static const int _backgroundPollIntervalSeconds = 60;
|
||||
|
||||
int _lastUsedBytes = 0;
|
||||
double _currentSpeedKBps = 0.0;
|
||||
DateTime? _lastSyncAt;
|
||||
int _lastUsedBytes = 0;
|
||||
Stopwatch? _sinceLastSuccessfulSync;
|
||||
// Wall clock, because the Stopwatch is CLOCK_MONOTONIC and stops across
|
||||
// device suspend - exactly when the RTC_WAKEUP expiry alarm fires.
|
||||
DateTime? _deadlineAt;
|
||||
double _currentSpeedKBps = 0.0;
|
||||
|
||||
SessionTimer({required this.vpnConnection}) {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
vpnConnection.addListener(_onVpnConnectionChanged);
|
||||
unawaited(_loadSupportRewardState());
|
||||
|
||||
// VpnConnection is eager and can report an already-running native tunnel
|
||||
// before this provider is created. ChangeNotifier listeners do not replay
|
||||
// old events, so always evaluate the current state once after construction.
|
||||
unawaited(Future.microtask(_onVpnConnectionChanged));
|
||||
}
|
||||
|
||||
int get remaining => _remainingSeconds;
|
||||
bool get isRunning => _timer != null && _timer!.isActive;
|
||||
bool get isExpired => _remainingSeconds <= 0 && !isRunning;
|
||||
bool get hasSyncedOnce => _hasSyncedOnce;
|
||||
int get remaining => _remainingSeconds;
|
||||
bool get isRunning => _timer != null && _timer!.isActive;
|
||||
bool get isExpired => _remainingSeconds <= 0 && !isRunning;
|
||||
bool get hasSyncedOnce => _hasSyncedOnce;
|
||||
bool get supportRewardClaimed => _supportRewardClaimed;
|
||||
bool get supportRewardStateLoaded => _supportRewardStateLoaded;
|
||||
|
||||
int get usedBytes => _usedBytes;
|
||||
int get usedBytes => _usedBytes;
|
||||
double get currentSpeedKBps => _currentSpeedKBps;
|
||||
|
||||
// Display state only - an unreachable API never tears down a live tunnel.
|
||||
bool get serverUnreachable => _consecutiveFailures >= _maxConsecutiveFailures;
|
||||
|
||||
// Why the last session ended, if the user did not end it. Cleared on connect.
|
||||
String? _lastEndMessage;
|
||||
String? get lastEndMessage => _lastEndMessage;
|
||||
|
||||
String get formatted {
|
||||
final h = (_remainingSeconds ~/ 3600).toString().padLeft(2, '0');
|
||||
final m = ((_remainingSeconds % 3600) ~/ 60).toString().padLeft(2, '0');
|
||||
@@ -53,68 +82,128 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
return '$h:$m:$s';
|
||||
}
|
||||
|
||||
void _onVpnConnectionChanged() {
|
||||
// Restore timer if VPN was already running at app launch.
|
||||
if (vpnConnection.status == VpnStatus.connected &&
|
||||
!isRunning &&
|
||||
vpnConnection.isStartupRestoration) {
|
||||
_resumeTicking();
|
||||
return;
|
||||
Future<void> _loadSupportRewardState() async {
|
||||
final epoch = _supportStateEpoch;
|
||||
var claimed = false;
|
||||
try {
|
||||
claimed =
|
||||
(await _supportStorage.read(key: _supportRewardClaimKey)) == '1';
|
||||
} catch (e) {
|
||||
debugPrint('[Timer] Failed to load support reward state: $e');
|
||||
}
|
||||
|
||||
// Resume after brief tunnel disconnection.
|
||||
if (vpnConnection.status == VpnStatus.connected &&
|
||||
!isRunning &&
|
||||
!_isDisconnecting &&
|
||||
_hasSyncedOnce) {
|
||||
debugPrint('[Timer] VPN reconnected after blip — resuming.');
|
||||
_resumeTicking();
|
||||
return;
|
||||
}
|
||||
if (epoch != _supportStateEpoch) return;
|
||||
_supportRewardClaimed = claimed;
|
||||
_supportRewardStateLoaded = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
if (vpnConnection.status == VpnStatus.disconnected && !_isDisconnecting) {
|
||||
_doDisconnect('VPN tunnel dropped');
|
||||
Future<void> _persistSupportRewardState(bool value) async {
|
||||
try {
|
||||
await _supportStorage.write(
|
||||
key: _supportRewardClaimKey,
|
||||
value: value ? '1' : '0',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[Timer] Failed to persist support reward state: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> start() async {
|
||||
_remainingSeconds = 0;
|
||||
_usedBytes = 0;
|
||||
_lastUsedBytes = 0;
|
||||
_currentSpeedKBps = 0.0;
|
||||
_lastSyncAt = null;
|
||||
NotificationService.reset();
|
||||
_tickCount = 0;
|
||||
_hasSyncedOnce = false;
|
||||
Future<void> markSupportRewardClaimed() async {
|
||||
if (_supportRewardClaimed) return;
|
||||
_supportRewardClaimed = true;
|
||||
_supportRewardStateLoaded = true;
|
||||
notifyListeners();
|
||||
await _persistSupportRewardState(true);
|
||||
}
|
||||
|
||||
void _onVpnConnectionChanged() {
|
||||
// A live tunnel with a stopped clock is always wrong, whether this process
|
||||
// adopted an existing runtime, attached late, or recovered from a blip.
|
||||
if (vpnConnection.status == VpnStatus.connected &&
|
||||
!isRunning &&
|
||||
!_isDisconnecting) {
|
||||
debugPrint('[Timer] Connected with a stopped clock - resuming.');
|
||||
_resumeTicking();
|
||||
return;
|
||||
}
|
||||
|
||||
final hadActiveSession = isRunning || _hasSyncedOnce;
|
||||
if (!hadActiveSession || _isDisconnecting) return;
|
||||
|
||||
if (vpnConnection.status == VpnStatus.error) {
|
||||
unawaited(_doDisconnect(
|
||||
'VPN entered error state',
|
||||
endedMessage: 'The tunnel stopped. You are not protected.',
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
if (vpnConnection.status == VpnStatus.disconnected) {
|
||||
// The native deadline beats the 1 s tick, so expiry lands here, not in
|
||||
// _tick. postAlert is false: the service already posted, and a
|
||||
// notification-Disconnect tap is the user asking.
|
||||
final deadline = _deadlineAt;
|
||||
final expired = _hasSyncedOnce &&
|
||||
deadline != null &&
|
||||
DateTime.now().isAfter(
|
||||
deadline.subtract(const Duration(seconds: 5)),
|
||||
);
|
||||
unawaited(_doDisconnect(
|
||||
'VPN tunnel dropped',
|
||||
endedMessage:
|
||||
expired ? 'Your session ended. You are not protected.' : null,
|
||||
postAlert: false,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// resetSupportClaim is false when re-attaching to a session already held:
|
||||
// the top-up is once per server session and this flag is what enforces it.
|
||||
Future<void> start({bool resetSupportClaim = true}) async {
|
||||
_sessionEpoch++;
|
||||
_remainingSeconds = 0;
|
||||
_remainingAtLastSync = 0;
|
||||
_usedBytes = 0;
|
||||
_lastUsedBytes = 0;
|
||||
_sinceLastSuccessfulSync?.stop();
|
||||
_sinceLastSuccessfulSync = null;
|
||||
_deadlineAt = null;
|
||||
_currentSpeedKBps = 0.0;
|
||||
_tickCount = 0;
|
||||
_hasSyncedOnce = false;
|
||||
_consecutiveFailures = 0;
|
||||
_offlineSeconds = 0;
|
||||
_isDisconnecting = false;
|
||||
_appBackgrounded = false;
|
||||
_isDisconnecting = false;
|
||||
_appBackgrounded = false;
|
||||
_lastEndMessage = null;
|
||||
NotificationService.reset();
|
||||
|
||||
if (resetSupportClaim) {
|
||||
_supportStateEpoch++;
|
||||
_supportRewardClaimed = false;
|
||||
_supportRewardStateLoaded = true;
|
||||
unawaited(_persistSupportRewardState(false));
|
||||
}
|
||||
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
||||
|
||||
notifyListeners();
|
||||
_syncWithHivemind();
|
||||
unawaited(_syncWithHivemind());
|
||||
}
|
||||
|
||||
void _tick(Timer t) {
|
||||
if (_isDisconnecting) return;
|
||||
// A local clock: poll failures are about the network, not the clock.
|
||||
if (_hasSyncedOnce && _remainingSeconds > 0) {
|
||||
_remainingSeconds--;
|
||||
}
|
||||
|
||||
if (_consecutiveFailures >= _maxConsecutiveFailures) {
|
||||
_offlineSeconds++;
|
||||
if (_offlineSeconds >= _maxOfflineSeconds) {
|
||||
_doDisconnect('Server unreachable');
|
||||
return;
|
||||
}
|
||||
if (_hasSyncedOnce) {
|
||||
_reconcileElapsedTime();
|
||||
}
|
||||
|
||||
if (_hasSyncedOnce && _remainingSeconds <= 0) {
|
||||
_doDisconnect('Session expired');
|
||||
unawaited(_doDisconnect(
|
||||
'Session expired',
|
||||
endedMessage: 'Your session ended. You are not protected.',
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -123,138 +212,271 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
? _backgroundPollIntervalSeconds
|
||||
: _pollIntervalSeconds;
|
||||
if (_tickCount % pollInterval == 0) {
|
||||
_syncWithHivemind();
|
||||
unawaited(_syncWithHivemind());
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
|
||||
// Gated on a live tunnel. The plugin cancels the notification when the
|
||||
// service stops, and re-posting after that leaves an ongoing notification
|
||||
// the user cannot swipe away below Android 14.
|
||||
if (_hasSyncedOnce &&
|
||||
vpnConnection.status == VpnStatus.connected) {
|
||||
if (_hasSyncedOnce && vpnConnection.status == VpnStatus.connected) {
|
||||
NotificationService.updateTimer(formatted);
|
||||
}
|
||||
}
|
||||
|
||||
/// Force a control-plane refresh after an event that changes the session
|
||||
/// server-side. If a periodic sync is already in flight, wait for it and
|
||||
/// issue a fresh request so the event cannot be hidden behind the busy guard.
|
||||
Future<void> syncNow() async {
|
||||
final requestedEpoch = _sessionEpoch;
|
||||
while (requestedEpoch == _sessionEpoch && !_isDisconnecting) {
|
||||
final active = _syncEpochInProgress == requestedEpoch
|
||||
? _syncCompletion
|
||||
: null;
|
||||
if (active != null) {
|
||||
await active.future;
|
||||
continue;
|
||||
}
|
||||
await _syncWithHivemind();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disconnect({String reason = 'User requested'}) async {
|
||||
// Only here, not in _doDisconnect: this is the one path the user actually
|
||||
// asked for. Unawaited so a slow or unreachable API never holds up the
|
||||
// teardown.
|
||||
unawaited(HivemindService.releaseSession());
|
||||
await _doDisconnect(reason);
|
||||
}
|
||||
|
||||
Future<void> _doDisconnect(String reason) async {
|
||||
// endedMessage is what the UI shows; postAlert raises the notification.
|
||||
// A user-requested disconnect passes neither.
|
||||
Future<void> _doDisconnect(
|
||||
String reason, {
|
||||
String? endedMessage,
|
||||
bool postAlert = true,
|
||||
}) async {
|
||||
if (_isDisconnecting) return;
|
||||
_isDisconnecting = true;
|
||||
_sessionEpoch++;
|
||||
debugPrint('[Timer] Disconnecting: $reason');
|
||||
|
||||
_lastEndMessage = endedMessage;
|
||||
_deadlineAt = null;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_currentSpeedKBps = 0.0;
|
||||
_remainingSeconds = 0;
|
||||
_remainingAtLastSync = 0;
|
||||
_hasSyncedOnce = false;
|
||||
_lastSyncAt = null;
|
||||
_sinceLastSuccessfulSync?.stop();
|
||||
_sinceLastSuccessfulSync = null;
|
||||
NotificationService.reset();
|
||||
notifyListeners();
|
||||
|
||||
await vpnConnection.disconnect();
|
||||
// After the service is down, so the cancel is not swallowed by a still
|
||||
// foreground notification. Without it a revoke leaves the countdown on
|
||||
// screen, frozen, claiming a session that no longer protects anything.
|
||||
await NotificationService.cancelTimer();
|
||||
if (endedMessage != null && postAlert) {
|
||||
unawaited(NotificationService.showDisconnectedAlert());
|
||||
}
|
||||
|
||||
if (!_isDisconnecting) return;
|
||||
_isDisconnecting = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
int _readNonNegativeInt(dynamic value, int fallback) {
|
||||
if (value is int) return value >= 0 ? value : fallback;
|
||||
if (value is num && value >= 0) return value.toInt();
|
||||
return fallback;
|
||||
}
|
||||
|
||||
Future<void> _syncWithHivemind() async {
|
||||
if (_isDisconnecting || _syncInProgress) return;
|
||||
_syncInProgress = true;
|
||||
if (_isDisconnecting) return;
|
||||
final epoch = _sessionEpoch;
|
||||
if (_syncEpochInProgress == epoch) return;
|
||||
|
||||
final completion = Completer<void>();
|
||||
_syncCompletion = completion;
|
||||
_syncEpochInProgress = epoch;
|
||||
|
||||
try {
|
||||
final deviceId = await CryptoService.getDeviceId();
|
||||
final url = Uri.parse(
|
||||
'${AppConfig.hivemindApiPublic}/session/status?device_id=$deviceId');
|
||||
if (epoch != _sessionEpoch || _isDisconnecting) return;
|
||||
|
||||
final base = Uri.parse('${AppConfig.hivemindApiPublic}/session/status');
|
||||
final url = base.replace(queryParameters: {'device_id': deviceId});
|
||||
final response = await HivemindService.directGet(url);
|
||||
if (epoch != _sessionEpoch || _isDisconnecting) return;
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
debugPrint('[Timer] Invalid session payload type.');
|
||||
_markSyncFailure(epoch);
|
||||
return;
|
||||
}
|
||||
final data = decoded;
|
||||
|
||||
final bool active = data['active'] ?? false;
|
||||
if (!active) {
|
||||
await _doDisconnect('Server ended session');
|
||||
final activeValue = data['active'];
|
||||
if (activeValue is! bool) {
|
||||
debugPrint('[Timer] Session payload missing boolean active state.');
|
||||
_markSyncFailure(epoch);
|
||||
return;
|
||||
}
|
||||
if (!activeValue) {
|
||||
await _doDisconnect(
|
||||
'Server ended session',
|
||||
endedMessage:
|
||||
'The server ended your session. You are not protected.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_remainingSeconds = data['expires_in_seconds'] ?? _remainingSeconds;
|
||||
_usedBytes = data['used_bytes'] ?? _usedBytes;
|
||||
final expiresValue = data['expires_in_seconds'];
|
||||
if (expiresValue is! num ||
|
||||
!expiresValue.isFinite ||
|
||||
expiresValue < 0) {
|
||||
debugPrint('[Timer] Session payload has invalid expiry.');
|
||||
_markSyncFailure(epoch);
|
||||
return;
|
||||
}
|
||||
|
||||
// Measure the real gap between polls: the interval is 5 s in the
|
||||
// foreground, 30 s in the background, and arbitrary on a resume sync.
|
||||
final now = DateTime.now();
|
||||
final lastSync = _lastSyncAt;
|
||||
_lastSyncAt = now;
|
||||
final elapsedMs = _sinceLastSuccessfulSync?.elapsedMilliseconds;
|
||||
_remainingAtLastSync = expiresValue.toInt();
|
||||
_remainingSeconds = _remainingAtLastSync;
|
||||
_deadlineAt = DateTime.now().add(
|
||||
Duration(seconds: _remainingAtLastSync),
|
||||
);
|
||||
_usedBytes = _readNonNegativeInt(data['used_bytes'], _usedBytes);
|
||||
|
||||
final int deltaBytes = _usedBytes - _lastUsedBytes;
|
||||
if (_hasSyncedOnce && deltaBytes > 0 && lastSync != null) {
|
||||
final elapsed = now.difference(lastSync).inMilliseconds / 1000.0;
|
||||
_currentSpeedKBps =
|
||||
elapsed > 0 ? (deltaBytes / elapsed) / 1000 : _currentSpeedKBps;
|
||||
} else if (!_hasSyncedOnce) {
|
||||
final deltaBytes = _usedBytes - _lastUsedBytes;
|
||||
if (_hasSyncedOnce && elapsedMs != null && elapsedMs > 0) {
|
||||
_currentSpeedKBps = deltaBytes > 0
|
||||
? (deltaBytes / (elapsedMs / 1000.0)) / 1000.0
|
||||
: 0.0;
|
||||
} else {
|
||||
_currentSpeedKBps = 0.0;
|
||||
}
|
||||
_lastUsedBytes = _usedBytes;
|
||||
_sinceLastSuccessfulSync?.stop();
|
||||
_sinceLastSuccessfulSync = Stopwatch()..start();
|
||||
|
||||
final capExhausted = data['cap_exhausted'] ?? false;
|
||||
if (capExhausted) {
|
||||
await _doDisconnect('Data cap reached');
|
||||
if (data['cap_exhausted'] == true) {
|
||||
await _doDisconnect(
|
||||
'Data cap reached',
|
||||
endedMessage: 'Data limit reached. You are not protected.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (epoch != _sessionEpoch || _isDisconnecting) return;
|
||||
try {
|
||||
await vpnConnection.setNativeSessionDeadline(_remainingAtLastSync);
|
||||
} catch (e) {
|
||||
debugPrint('[Timer] Failed to arm native session deadline: $e');
|
||||
await _doDisconnect(
|
||||
'Native session deadline unavailable',
|
||||
endedMessage:
|
||||
'Could not arm the session timer. You are not protected.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (epoch != _sessionEpoch || _isDisconnecting) return;
|
||||
_consecutiveFailures = 0;
|
||||
_offlineSeconds = 0;
|
||||
_hasSyncedOnce = true;
|
||||
notifyListeners();
|
||||
} else {
|
||||
_markSyncFailure();
|
||||
_markSyncFailure(epoch);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Hivemind sync error: $e');
|
||||
_markSyncFailure();
|
||||
if (epoch == _sessionEpoch && !_isDisconnecting) {
|
||||
debugPrint('Hivemind sync error: $e');
|
||||
_markSyncFailure(epoch);
|
||||
}
|
||||
} finally {
|
||||
_syncInProgress = false;
|
||||
if (_syncEpochInProgress == epoch) {
|
||||
_syncEpochInProgress = null;
|
||||
}
|
||||
if (identical(_syncCompletion, completion)) {
|
||||
_syncCompletion = null;
|
||||
}
|
||||
if (!completion.isCompleted) completion.complete();
|
||||
}
|
||||
}
|
||||
|
||||
void _markSyncFailure() {
|
||||
void _markSyncFailure(int epoch) {
|
||||
if (epoch != _sessionEpoch || _isDisconnecting) return;
|
||||
_consecutiveFailures++;
|
||||
if (_consecutiveFailures >= _maxConsecutiveFailures) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void _resumeTicking() {
|
||||
_lastSyncAt = null;
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
||||
_isDisconnecting = false;
|
||||
_syncWithHivemind();
|
||||
notifyListeners();
|
||||
}
|
||||
void _reconcileElapsedTime() {
|
||||
final clock = _sinceLastSuccessfulSync;
|
||||
if (!_hasSyncedOnce || clock == null) return;
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.detached) {
|
||||
// Keep ticking, but slow the poll — the 5 s one wakes the radio.
|
||||
_appBackgrounded = true;
|
||||
} else if (state == AppLifecycleState.resumed) {
|
||||
_appBackgrounded = false;
|
||||
if (_timer != null && _timer!.isActive) {
|
||||
_syncWithHivemind();
|
||||
final elapsed = clock.elapsed.inSeconds;
|
||||
_remainingSeconds =
|
||||
(_remainingAtLastSync - elapsed).clamp(0, 1 << 31).toInt();
|
||||
|
||||
// The Stopwatch over-reports after device suspend. Take the smaller of
|
||||
// the two, never the wall clock alone - a backward clock must not extend.
|
||||
final deadline = _deadlineAt;
|
||||
if (deadline != null) {
|
||||
final byWallClock = deadline.difference(DateTime.now()).inSeconds;
|
||||
if (byWallClock < _remainingSeconds) {
|
||||
_remainingSeconds = byWallClock.clamp(0, 1 << 31).toInt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.detached ||
|
||||
state == AppLifecycleState.inactive ||
|
||||
state == AppLifecycleState.hidden) {
|
||||
_appBackgrounded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (state != AppLifecycleState.resumed || _isDisconnecting) return;
|
||||
_appBackgrounded = false;
|
||||
if (vpnConnection.status != VpnStatus.connected) return;
|
||||
|
||||
_reconcileElapsedTime();
|
||||
if (!isRunning) {
|
||||
_resumeTicking();
|
||||
return;
|
||||
}
|
||||
|
||||
unawaited(_syncWithHivemind());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _resumeTicking() {
|
||||
_reconcileElapsedTime();
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
||||
_isDisconnecting = false;
|
||||
unawaited(_syncWithHivemind());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Invalidate every asynchronous generation before removing listeners so
|
||||
// late storage/network completions cannot notify a disposed notifier.
|
||||
_sessionEpoch++;
|
||||
_supportStateEpoch++;
|
||||
_isDisconnecting = true;
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
vpnConnection.removeListener(_onVpnConnectionChanged);
|
||||
_timer?.cancel();
|
||||
_sinceLastSuccessfulSync?.stop();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
+161
-51
@@ -13,6 +13,78 @@ enum UpdateStatus { upToDate, updateAvailable, checkFailed }
|
||||
|
||||
enum InstallSource { playStore, sideload }
|
||||
|
||||
Uri? _trustedHttpsUri(String raw, Set<String> allowedHosts) {
|
||||
final uri = Uri.tryParse(raw);
|
||||
if (uri == null || uri.scheme != 'https' || !allowedHosts.contains(uri.host)) {
|
||||
return null;
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
class _ParsedVersion {
|
||||
final List<int> core;
|
||||
final List<String> preRelease;
|
||||
|
||||
const _ParsedVersion(this.core, this.preRelease);
|
||||
|
||||
static _ParsedVersion? tryParse(String input) {
|
||||
var value = input.trim();
|
||||
if (value.startsWith('v') || value.startsWith('V')) {
|
||||
value = value.substring(1);
|
||||
}
|
||||
value = value.split('+').first;
|
||||
final dash = value.indexOf('-');
|
||||
final corePart = dash < 0 ? value : value.substring(0, dash);
|
||||
final prePart = dash < 0 ? '' : value.substring(dash + 1);
|
||||
final rawCore = corePart.split('.');
|
||||
if (rawCore.isEmpty || rawCore.any((p) => int.tryParse(p) == null)) {
|
||||
return null;
|
||||
}
|
||||
final core = rawCore.map(int.parse).toList(growable: false);
|
||||
while (core.length < 3) {
|
||||
core.add(0);
|
||||
}
|
||||
final pre = prePart.isEmpty
|
||||
? const <String>[]
|
||||
: prePart.split('.').where((e) => e.isNotEmpty).toList(growable: false);
|
||||
return _ParsedVersion(core, pre);
|
||||
}
|
||||
|
||||
int compareTo(_ParsedVersion other) {
|
||||
final length = core.length > other.core.length ? core.length : other.core.length;
|
||||
for (var i = 0; i < length; i++) {
|
||||
final left = i < core.length ? core[i] : 0;
|
||||
final right = i < other.core.length ? other.core[i] : 0;
|
||||
if (left != right) return left.compareTo(right);
|
||||
}
|
||||
|
||||
if (preRelease.isEmpty && other.preRelease.isEmpty) return 0;
|
||||
if (preRelease.isEmpty) return 1;
|
||||
if (other.preRelease.isEmpty) return -1;
|
||||
|
||||
final preLength = preRelease.length > other.preRelease.length
|
||||
? preRelease.length
|
||||
: other.preRelease.length;
|
||||
for (var i = 0; i < preLength; i++) {
|
||||
if (i >= preRelease.length) return -1;
|
||||
if (i >= other.preRelease.length) return 1;
|
||||
final left = preRelease[i];
|
||||
final right = other.preRelease[i];
|
||||
final leftNum = int.tryParse(left);
|
||||
final rightNum = int.tryParse(right);
|
||||
if (leftNum != null && rightNum != null) {
|
||||
if (leftNum != rightNum) return leftNum.compareTo(rightNum);
|
||||
continue;
|
||||
}
|
||||
if (leftNum != null) return -1;
|
||||
if (rightNum != null) return 1;
|
||||
final lexical = left.compareTo(right);
|
||||
if (lexical != 0) return lexical;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
class PlayStoreUpdater {
|
||||
PlayStoreUpdater._();
|
||||
|
||||
@@ -20,9 +92,13 @@ class PlayStoreUpdater {
|
||||
'https://play.google.com/store/apps/details?id=${AppConfig.applicationId}';
|
||||
|
||||
static Future<bool> open() async {
|
||||
final uri = _trustedHttpsUri(_url, const {'play.google.com'});
|
||||
if (uri == null) {
|
||||
debugPrint('[Updater] Refusing invalid Play Store URL.');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return await launchUrl(Uri.parse(_url),
|
||||
mode: LaunchMode.externalApplication);
|
||||
return await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} catch (e) {
|
||||
debugPrint('[Updater] Failed to open Play Store: $e');
|
||||
return false;
|
||||
@@ -36,13 +112,14 @@ class GitHubUpdater {
|
||||
static String? _cachedDownloadUrl;
|
||||
|
||||
static Future<String?> fetchLatestVersion() async {
|
||||
final url =
|
||||
'https://api.github.com/repos/${AppConfig.githubOwner}/${AppConfig.githubRepo}/releases/latest';
|
||||
final apiUri = Uri.https(
|
||||
'api.github.com',
|
||||
'/repos/${AppConfig.githubOwner}/${AppConfig.githubRepo}/releases/latest',
|
||||
);
|
||||
|
||||
try {
|
||||
final response = await http
|
||||
.get(Uri.parse(url),
|
||||
headers: {'Accept': 'application/vnd.github+json'})
|
||||
.get(apiUri, headers: {'Accept': 'application/vnd.github+json'})
|
||||
.timeout(const Duration(seconds: 8));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
@@ -50,38 +127,48 @@ class GitHubUpdater {
|
||||
return null;
|
||||
}
|
||||
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
_cachedDownloadUrl = data['html_url'] as String?;
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
debugPrint('[Updater] Invalid GitHub release payload.');
|
||||
return null;
|
||||
}
|
||||
|
||||
final tag = data['tag_name'] as String? ?? '0.0.0';
|
||||
return tag.startsWith('v') ? tag.substring(1) : tag;
|
||||
final htmlUrl = decoded['html_url'];
|
||||
if (htmlUrl is String &&
|
||||
_trustedHttpsUri(htmlUrl, const {'github.com'}) != null) {
|
||||
_cachedDownloadUrl = htmlUrl;
|
||||
} else {
|
||||
_cachedDownloadUrl = null;
|
||||
}
|
||||
|
||||
final rawTag = decoded['tag_name'];
|
||||
if (rawTag is! String || rawTag.isEmpty || rawTag.length > 64) {
|
||||
debugPrint('[Updater] Invalid release tag.');
|
||||
return null;
|
||||
}
|
||||
|
||||
final normalized = rawTag.startsWith('v') ? rawTag.substring(1) : rawTag;
|
||||
if (_ParsedVersion.tryParse(normalized) == null) {
|
||||
debugPrint('[Updater] Release tag is not a supported version: $rawTag');
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
} catch (e) {
|
||||
debugPrint('[Updater] GitHub API error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static bool _isTrustedReleaseUrl(String url) {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null || uri.scheme != 'https' || uri.host != 'github.com') {
|
||||
static Future<bool> open() async {
|
||||
final raw = _cachedDownloadUrl ?? AppConfig.githubReleasesUrl;
|
||||
final uri = _trustedHttpsUri(raw, const {'github.com'});
|
||||
if (uri == null) {
|
||||
debugPrint('[Updater] Refusing untrusted GitHub update URL.');
|
||||
return false;
|
||||
}
|
||||
// Host alone is not enough: anyone can host a release on github.com.
|
||||
return uri.path
|
||||
.startsWith('/${AppConfig.githubOwner}/${AppConfig.githubRepo}/');
|
||||
}
|
||||
|
||||
static Future<bool> open() async {
|
||||
final cached = _cachedDownloadUrl;
|
||||
final url = (cached != null && _isTrustedReleaseUrl(cached))
|
||||
? cached
|
||||
: AppConfig.githubReleasesUrl;
|
||||
if (cached != null && url != cached) {
|
||||
debugPrint('[Updater] Rejected untrusted release URL: $cached');
|
||||
}
|
||||
try {
|
||||
return await launchUrl(Uri.parse(url),
|
||||
mode: LaunchMode.externalApplication);
|
||||
return await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} catch (e) {
|
||||
debugPrint('[Updater] Failed to open GitHub: $e');
|
||||
return false;
|
||||
@@ -102,7 +189,7 @@ class Updater {
|
||||
}
|
||||
|
||||
try {
|
||||
final result = await MethodChannel('com.revoltvpn.app/installer')
|
||||
final result = await const MethodChannel('com.revoltvpn.app/installer')
|
||||
.invokeMethod<String>('getInstallerPackage');
|
||||
_cachedSource = (result == 'com.android.vending')
|
||||
? InstallSource.playStore
|
||||
@@ -116,14 +203,13 @@ class Updater {
|
||||
}
|
||||
|
||||
static int _compareVersions(String a, String b) {
|
||||
final aParts = a.split('.').map((p) => int.tryParse(p) ?? 0).toList();
|
||||
final bParts = b.split('.').map((p) => int.tryParse(p) ?? 0).toList();
|
||||
for (int i = 0; i < 3; i++) {
|
||||
final diff = (aParts.length > i ? aParts[i] : 0) -
|
||||
(bParts.length > i ? bParts[i] : 0);
|
||||
if (diff != 0) return diff;
|
||||
final left = _ParsedVersion.tryParse(a);
|
||||
final right = _ParsedVersion.tryParse(b);
|
||||
if (left == null || right == null) {
|
||||
// Invalid local metadata should not invent an ordering.
|
||||
return 0;
|
||||
}
|
||||
return 0;
|
||||
return left.compareTo(right);
|
||||
}
|
||||
|
||||
static String _versionLabel(String v, bool isLocal) {
|
||||
@@ -131,15 +217,27 @@ class Updater {
|
||||
return '$prefix$v${isLocal ? ' (installed)' : ''}';
|
||||
}
|
||||
|
||||
static Future<void> check({
|
||||
static Future<void> check(BuildContext context) {
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
return checkWithHandles(
|
||||
navigator: navigator,
|
||||
messenger: messenger,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> checkWithHandles({
|
||||
required NavigatorState navigator,
|
||||
required ScaffoldMessengerState messenger,
|
||||
}) async {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
final localVersion = info.version;
|
||||
if (localVersion.isEmpty) {
|
||||
_showSnackBar(messenger, 'Could not determine app version',
|
||||
isError: true);
|
||||
if (localVersion.isEmpty || _ParsedVersion.tryParse(localVersion) == null) {
|
||||
_showSnackBar(
|
||||
messenger,
|
||||
'Could not determine app version',
|
||||
isError: true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -155,15 +253,18 @@ class Updater {
|
||||
}
|
||||
|
||||
final source = await installSource;
|
||||
final storeName = source == InstallSource.playStore ? 'Play Store' : 'GitHub';
|
||||
final storeName =
|
||||
source == InstallSource.playStore ? 'Play Store' : 'GitHub';
|
||||
if (!navigator.mounted) return;
|
||||
|
||||
showDialog(
|
||||
context: navigator.context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: AppColors.bgCard,
|
||||
title: const Text('Update available',
|
||||
style: TextStyle(color: AppColors.textWhite)),
|
||||
title: const Text(
|
||||
'Update available',
|
||||
style: TextStyle(color: AppColors.textWhite),
|
||||
),
|
||||
content: Text(
|
||||
'${_versionLabel(latestVersion, false)} is available on $storeName.\n'
|
||||
'You have ${_versionLabel(localVersion, true)}.',
|
||||
@@ -172,8 +273,10 @@ class Updater {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Later',
|
||||
style: TextStyle(color: AppColors.slate70)),
|
||||
child: const Text(
|
||||
'Later',
|
||||
style: TextStyle(color: AppColors.slate70),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
@@ -182,24 +285,31 @@ class Updater {
|
||||
? PlayStoreUpdater.open()
|
||||
: GitHubUpdater.open();
|
||||
},
|
||||
child: Text('Open $storeName',
|
||||
style: const TextStyle(color: AppColors.accent)),
|
||||
child: Text(
|
||||
'Open $storeName',
|
||||
style: const TextStyle(color: AppColors.accent),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static void _showSnackBar(ScaffoldMessengerState messenger, String message,
|
||||
{bool isError = false}) {
|
||||
static void _showSnackBar(
|
||||
ScaffoldMessengerState messenger,
|
||||
String message, {
|
||||
bool isError = false,
|
||||
}) {
|
||||
if (!messenger.mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
Icon(isError ? Icons.error_outline : Icons.check_circle,
|
||||
color: isError ? AppColors.red : AppColors.green,
|
||||
size: 18),
|
||||
Icon(
|
||||
isError ? Icons.error_outline : Icons.check_circle,
|
||||
color: isError ? AppColors.red : AppColors.green,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(message),
|
||||
],
|
||||
|
||||
+306
-102
@@ -1,8 +1,13 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_vless/flutter_vless.dart';
|
||||
import 'package:revoltvpn/logic/connection_settings.dart';
|
||||
import 'package:revoltvpn/logic/hivemind_service.dart';
|
||||
import 'package:revoltvpn/logic/local_socks_tester.dart';
|
||||
import 'package:revoltvpn/logic/network_monitor.dart';
|
||||
import 'package:revoltvpn/logic/secure_socks_session.dart';
|
||||
|
||||
enum VpnStatus {
|
||||
disconnected,
|
||||
@@ -12,8 +17,13 @@ enum VpnStatus {
|
||||
error,
|
||||
}
|
||||
|
||||
class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
||||
bool _cancelled = false;
|
||||
class VpnConnection extends ChangeNotifier {
|
||||
static const MethodChannel _nativeControl = MethodChannel('flutter_vless');
|
||||
|
||||
int _connectEpoch = 0;
|
||||
bool _suppressNativeConnect = false;
|
||||
bool _disposed = false;
|
||||
|
||||
VpnStatus _status = VpnStatus.disconnected;
|
||||
VpnStatus get status => _status;
|
||||
|
||||
@@ -23,13 +33,51 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
||||
String? _errorMessage;
|
||||
String? get errorMessage => _errorMessage;
|
||||
|
||||
bool _isStartupRestoration = false;
|
||||
bool get isStartupRestoration => _isStartupRestoration;
|
||||
/// True when the UI attached to a tunnel that was already running, rather
|
||||
/// than starting one itself. The session timer uses this to resume ticking.
|
||||
bool _adoptedRunningRuntime = false;
|
||||
bool get adoptedRunningRuntime => _adoptedRunningRuntime;
|
||||
|
||||
bool _serverReachable = false;
|
||||
bool get serverReachable => _serverReachable;
|
||||
|
||||
ConnectionMode _activeMode = ConnectionMode.tun;
|
||||
ConnectionMode get activeMode => _activeMode;
|
||||
|
||||
String _networkTransport = 'unknown';
|
||||
String get networkTransport => _networkTransport;
|
||||
|
||||
SecureSocksSession? get activeSocksSession => _lastSecureSocks;
|
||||
|
||||
bool get canTestActiveLocalSocks =>
|
||||
!_disposed &&
|
||||
_status == VpnStatus.connected &&
|
||||
_activeMode == ConnectionMode.proxy &&
|
||||
_lastSecureSocks != null;
|
||||
|
||||
Future<LocalSocksTestResult> testActiveLocalSocks() async {
|
||||
final active = _lastSecureSocks;
|
||||
if (!canTestActiveLocalSocks || active == null) {
|
||||
return const LocalSocksTestResult(
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
message: 'No active authenticated SOCKS5 session.',
|
||||
);
|
||||
}
|
||||
|
||||
return LocalSocksTester.test(
|
||||
host: '127.0.0.1',
|
||||
port: active.port,
|
||||
username: active.username,
|
||||
password: active.password,
|
||||
);
|
||||
}
|
||||
|
||||
Timer? _healthTimer;
|
||||
StreamSubscription<NetworkSnapshot>? _networkSubscription;
|
||||
|
||||
SecureSocksSession? _lastSecureSocks;
|
||||
bool _userDisconnecting = false;
|
||||
|
||||
late final FlutterVless _vless;
|
||||
bool _initialized = false;
|
||||
@@ -37,7 +85,7 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
||||
Future<void> get ready => _readyCompleter.future;
|
||||
|
||||
VpnConnection() {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_activeMode = ConnectionSettings.mode;
|
||||
_init();
|
||||
}
|
||||
|
||||
@@ -52,12 +100,18 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
Future<void> _startEngine() async {
|
||||
if (kIsWeb) return;
|
||||
if (kIsWeb || _disposed) return;
|
||||
|
||||
await ConnectionSettings.initialize();
|
||||
if (_disposed) return;
|
||||
_activeMode = ConnectionSettings.mode;
|
||||
|
||||
_vless = FlutterVless(
|
||||
onStatusChanged: (status) {
|
||||
debugPrint('[VPN] Status: state=${status.state} '
|
||||
'connection=${status.connectionState.name}');
|
||||
debugPrint(
|
||||
'[VPN] Status: state=${status.state} '
|
||||
'connection=${status.connectionState.name}',
|
||||
);
|
||||
_mapStatus(status);
|
||||
},
|
||||
);
|
||||
@@ -68,42 +122,76 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
||||
notificationIconResourceType: 'drawable',
|
||||
notificationIconResourceName: 'notification_icon',
|
||||
);
|
||||
if (_disposed) return;
|
||||
_initialized = true;
|
||||
} catch (e) {
|
||||
debugPrint('[VPN] VLESS init error (expected on emulator): $e');
|
||||
}
|
||||
if (_disposed) return;
|
||||
|
||||
_startHealthPolling();
|
||||
_networkSubscription = NetworkMonitor.changes.listen(
|
||||
(snapshot) {
|
||||
if (_disposed) return;
|
||||
// ConnectivityManager is informational only. Restarting from every
|
||||
// callback caused the old VPN-created-network reconnect loop.
|
||||
_networkTransport = snapshot.transport;
|
||||
notifyListeners();
|
||||
},
|
||||
onError: (Object error) {
|
||||
debugPrint('[VPN] Network monitor error: $error');
|
||||
},
|
||||
);
|
||||
|
||||
unawaited(_checkHealth());
|
||||
_healthTimer = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||
unawaited(_checkHealth());
|
||||
});
|
||||
|
||||
try {
|
||||
final coreVersion = await _vless.getCoreVersion();
|
||||
debugPrint('[VPN] Xray core version: $coreVersion');
|
||||
|
||||
final delay = await _vless.getConnectedServerDelay();
|
||||
if (delay > 0) {
|
||||
_isStartupRestoration = true;
|
||||
_setStatus(VpnStatus.connected, 'Secured');
|
||||
if (!_disposed) {
|
||||
debugPrint('[VPN] Xray core version: $coreVersion');
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
String get _connectedLabel {
|
||||
switch (_activeMode) {
|
||||
case ConnectionMode.proxy:
|
||||
return 'SOCKS5 gateway active';
|
||||
case ConnectionMode.tun:
|
||||
return 'Secured';
|
||||
}
|
||||
}
|
||||
|
||||
bool _isCurrentConnect(int epoch) =>
|
||||
!_disposed && epoch == _connectEpoch && !_userDisconnecting;
|
||||
|
||||
void _mapStatus(VlessStatus status) {
|
||||
if (_disposed) return;
|
||||
switch (status.connectionState) {
|
||||
case VlessConnectionState.connected:
|
||||
_setStatus(VpnStatus.connected, 'Secured');
|
||||
if (_suppressNativeConnect || _userDisconnecting) return;
|
||||
if (_connectEpoch == 0) _adoptedRunningRuntime = true;
|
||||
_setStatus(VpnStatus.connected, _connectedLabel);
|
||||
break;
|
||||
|
||||
case VlessConnectionState.disconnected:
|
||||
_isStartupRestoration = false;
|
||||
_clearRuntimeSnapshot();
|
||||
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
||||
break;
|
||||
|
||||
case VlessConnectionState.connecting:
|
||||
if (_suppressNativeConnect || _userDisconnecting) return;
|
||||
_setStatus(VpnStatus.connecting, 'Establishing tunnel…');
|
||||
break;
|
||||
|
||||
case VlessConnectionState.disconnecting:
|
||||
_isStartupRestoration = false;
|
||||
_setStatus(VpnStatus.disconnecting, 'Tearing down…');
|
||||
break;
|
||||
|
||||
case VlessConnectionState.unknown:
|
||||
if (_suppressNativeConnect || _userDisconnecting) return;
|
||||
if (_status != VpnStatus.connected &&
|
||||
_status != VpnStatus.disconnected) {
|
||||
_setStatus(VpnStatus.error, 'Connection failed');
|
||||
@@ -112,43 +200,62 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connect ────────────────────────────────────────────────────────
|
||||
//
|
||||
// 1. Direct HTTPS to the public API → get per-user VLESS URL
|
||||
// 2. Start tunnel directly with that URL (no bootstrap, no switch)
|
||||
//
|
||||
// The only hardcoded data in the APK is the Reality public key and
|
||||
// shortId (needed for every Reality handshake — unavoidable).
|
||||
// The tunnel UUID is per-user, assigned by Hivemind via AdMob SSV.
|
||||
|
||||
Future<bool> connect({
|
||||
bool skipAdBypass = false,
|
||||
}) async {
|
||||
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
|
||||
Future<bool> connect({String? adNonce, bool resume = false}) async {
|
||||
if (_disposed ||
|
||||
_userDisconnecting ||
|
||||
_status == VpnStatus.connected ||
|
||||
_status == VpnStatus.connecting ||
|
||||
_status == VpnStatus.disconnecting) {
|
||||
return false;
|
||||
}
|
||||
_cancelled = false;
|
||||
|
||||
final connectEpoch = ++_connectEpoch;
|
||||
_suppressNativeConnect = false;
|
||||
_userDisconnecting = false;
|
||||
|
||||
await ConnectionSettings.initialize();
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
_activeMode = ConnectionSettings.mode;
|
||||
|
||||
if (!kIsWeb && !_initialized) {
|
||||
_suppressNativeConnect = true;
|
||||
_errorMessage = 'VPN service unavailable.';
|
||||
_setStatus(VpnStatus.error, 'Service unavailable');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!kIsWeb) {
|
||||
// Proxy mode skips requestPermission below (VPN consent it never needs),
|
||||
// so it has to ask for POST_NOTIFICATIONS on its own.
|
||||
if (!kIsWeb && _activeMode == ConnectionMode.proxy) {
|
||||
try {
|
||||
await _nativeControl.invokeMethod<void>('requestNotificationPermission');
|
||||
} catch (e) {
|
||||
debugPrint('[VPN] Notification permission request failed: $e');
|
||||
}
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
}
|
||||
|
||||
if (!kIsWeb && _activeMode == ConnectionMode.tun) {
|
||||
final ok = await _vless.requestPermission();
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
if (!ok) {
|
||||
_suppressNativeConnect = true;
|
||||
_errorMessage = 'VPN permission denied.';
|
||||
_setStatus(VpnStatus.error, 'Permission required');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_setStatus(VpnStatus.connecting, 'Establishing secure channel…');
|
||||
final startingMessage = switch (_activeMode) {
|
||||
ConnectionMode.tun => 'Establishing secure channel…',
|
||||
ConnectionMode.proxy => 'Starting local SOCKS5 proxy…',
|
||||
};
|
||||
_setStatus(VpnStatus.connecting, startingMessage);
|
||||
_errorMessage = null;
|
||||
|
||||
if (kIsWeb) {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
_setStatus(VpnStatus.connected, 'Secured (dev mode)');
|
||||
return true;
|
||||
}
|
||||
@@ -158,136 +265,233 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
||||
String realUrl;
|
||||
try {
|
||||
realUrl = await HivemindService.fetchConfigDirectly(
|
||||
skipAdBypass: skipAdBypass,
|
||||
adNonce: adNonce,
|
||||
resume: resume,
|
||||
onAttempt: (attempt, total) {
|
||||
if (!_isCurrentConnect(connectEpoch)) return;
|
||||
_setStatus(
|
||||
VpnStatus.connecting, 'Contacting server ($attempt/$total)…');
|
||||
VpnStatus.connecting,
|
||||
'Contacting server ($attempt/$total)…',
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
debugPrint('[VPN] Config fetch error: $e');
|
||||
final raw = e.toString().replaceAll('Exception: ', '');
|
||||
if (raw.contains('Cancelled')) return false;
|
||||
_suppressNativeConnect = true;
|
||||
if (raw.contains('timed out') || raw.contains('Session not activated')) {
|
||||
_errorMessage =
|
||||
'The server did not respond in time.\nCheck your connection and try again.';
|
||||
_setStatus(VpnStatus.error, 'Server unreachable');
|
||||
} else {
|
||||
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
||||
_errorMessage = raw;
|
||||
_setStatus(VpnStatus.error, 'Config fetch error');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_cancelled) {
|
||||
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
||||
return false;
|
||||
}
|
||||
|
||||
_setStatus(VpnStatus.connecting, 'Securing connection…');
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
_setStatus(VpnStatus.connecting, 'Starting secure route…');
|
||||
|
||||
try {
|
||||
final parsed = FlutterVless.parse(realUrl);
|
||||
final baseConfig = parsed.getFullConfiguration();
|
||||
final remark = parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN';
|
||||
final secureSocks = await SecureSocksSession.create(baseConfig);
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
final verifyLocalSocks = _activeMode == ConnectionMode.proxy;
|
||||
|
||||
await _vless.startVless(
|
||||
remark: parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN',
|
||||
config: parsed.getFullConfiguration(),
|
||||
await _startRuntime(
|
||||
config: secureSocks.configJson,
|
||||
remark: remark,
|
||||
proxyOnly: verifyLocalSocks,
|
||||
);
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
|
||||
_setStatus(
|
||||
VpnStatus.connecting,
|
||||
verifyLocalSocks
|
||||
? 'Waiting for local SOCKS5…'
|
||||
: 'Waiting for VPN interface…',
|
||||
);
|
||||
if (!await _waitForNativeConnected(connectEpoch)) {
|
||||
throw StateError('VPN runtime did not report CONNECTED');
|
||||
}
|
||||
|
||||
if (verifyLocalSocks) {
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
_setStatus(VpnStatus.connecting, 'Checking Local SOCKS5…');
|
||||
if (!await _waitForLocalSocksListener(
|
||||
secureSocks,
|
||||
connectEpoch,
|
||||
)) {
|
||||
throw StateError('Local SOCKS5 listener did not become ready');
|
||||
}
|
||||
}
|
||||
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
_lastSecureSocks = secureSocks;
|
||||
} catch (e) {
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
debugPrint('[VPN] Tunnel start error: $e');
|
||||
_errorMessage = 'Tunnel failed to start.\nTry reconnecting.';
|
||||
_suppressNativeConnect = true;
|
||||
try {
|
||||
await _stopRuntime();
|
||||
} catch (stopError) {
|
||||
debugPrint('[VPN] Runtime cleanup failed after start error: $stopError');
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
_errorMessage = 'VPN failed to shut down cleanly.\nPlease restart the app.';
|
||||
_setStatus(VpnStatus.error, 'Shutdown failed');
|
||||
return false;
|
||||
}
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
_clearRuntimeSnapshot();
|
||||
_errorMessage = _activeMode == ConnectionMode.proxy
|
||||
? 'SOCKS5 gateway failed to start.\nTry reconnecting.'
|
||||
: 'Connection failed to start.\nTry reconnecting.';
|
||||
_setStatus(VpnStatus.error, 'Connection failed');
|
||||
return false;
|
||||
}
|
||||
|
||||
_setStatus(VpnStatus.connected, 'Secured');
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
_setStatus(VpnStatus.connected, _connectedLabel);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
_cancelled = true;
|
||||
HivemindService.cancel();
|
||||
if (_status == VpnStatus.disconnected ||
|
||||
_status == VpnStatus.disconnecting) {
|
||||
return;
|
||||
Future<void> _startRuntime({
|
||||
required String config,
|
||||
required String remark,
|
||||
required bool proxyOnly,
|
||||
}) async {
|
||||
await _vless.startVless(
|
||||
remark: remark,
|
||||
config: config,
|
||||
proxyOnly: proxyOnly,
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _waitForLocalSocksListener(
|
||||
SecureSocksSession session,
|
||||
int connectEpoch,
|
||||
) async {
|
||||
for (var attempt = 0; attempt < 4; attempt++) {
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
final result = await LocalSocksTester.testListener(
|
||||
host: '127.0.0.1',
|
||||
port: session.port,
|
||||
username: session.username,
|
||||
password: session.password,
|
||||
);
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
if (result.ok) return true;
|
||||
if (attempt < 3) {
|
||||
await Future.delayed(const Duration(milliseconds: 250));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> _waitForNativeConnected(int connectEpoch) async {
|
||||
for (var attempt = 0; attempt < 24; attempt++) {
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
if (_status == VpnStatus.connected) return true;
|
||||
if (_status == VpnStatus.error || _status == VpnStatus.disconnected) {
|
||||
return false;
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 250));
|
||||
}
|
||||
return _isCurrentConnect(connectEpoch) &&
|
||||
_status == VpnStatus.connected;
|
||||
}
|
||||
|
||||
Future<void> _stopRuntime() async {
|
||||
await _vless.stopVless().timeout(const Duration(seconds: 8));
|
||||
}
|
||||
|
||||
Future<void> setNativeSessionDeadline(int remainingSeconds) async {
|
||||
if (kIsWeb) return;
|
||||
if (_disposed) throw StateError('VPN connection has been disposed');
|
||||
if (!_initialized) throw StateError('VPN native service is not initialized');
|
||||
if (remainingSeconds < 0) {
|
||||
throw ArgumentError.value(remainingSeconds, 'remainingSeconds');
|
||||
}
|
||||
await _nativeControl.invokeMethod<void>(
|
||||
'setSessionDeadline',
|
||||
<String, Object>{'remainingSeconds': remainingSeconds},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
if (_disposed || _status == VpnStatus.disconnecting) return;
|
||||
|
||||
_connectEpoch++;
|
||||
_suppressNativeConnect = true;
|
||||
_userDisconnecting = true;
|
||||
HivemindService.cancel();
|
||||
|
||||
_setStatus(VpnStatus.disconnecting, 'Tearing down…');
|
||||
|
||||
if (kIsWeb) {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
if (_disposed) return;
|
||||
_clearRuntimeSnapshot();
|
||||
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
||||
_userDisconnecting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
bool timedOut = false;
|
||||
try {
|
||||
await _vless.stopVless().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => timedOut = true,
|
||||
);
|
||||
await _vless.stopVless().timeout(const Duration(seconds: 8));
|
||||
} catch (e) {
|
||||
if (_disposed) return;
|
||||
debugPrint('[VPN] VLESS stop error: $e');
|
||||
_errorMessage = 'VPN shutdown error.\nPlease restart the app.';
|
||||
_clearRuntimeSnapshot();
|
||||
_setStatus(VpnStatus.error, 'Shutdown failed');
|
||||
_userDisconnecting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (timedOut) {
|
||||
debugPrint('[VPN] stopVless() timed out after 5 s — '
|
||||
'tunnel may still be active.');
|
||||
_errorMessage = 'VPN did not shut down cleanly.\n'
|
||||
'Please restart the app.';
|
||||
_setStatus(VpnStatus.error, 'Shutdown failed');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_disposed) return;
|
||||
_errorMessage = null;
|
||||
_clearRuntimeSnapshot();
|
||||
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
||||
_userDisconnecting = false;
|
||||
}
|
||||
|
||||
void _setStatus(VpnStatus s, String msg) {
|
||||
_status = s;
|
||||
_statusMessage = msg;
|
||||
void _clearRuntimeSnapshot() {
|
||||
_lastSecureSocks = null;
|
||||
_adoptedRunningRuntime = false;
|
||||
}
|
||||
|
||||
void _setStatus(VpnStatus status, String message) {
|
||||
if (_disposed) return;
|
||||
_status = status;
|
||||
_statusMessage = message;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _startHealthPolling() {
|
||||
_healthTimer?.cancel();
|
||||
_checkHealthIfIdle();
|
||||
_healthTimer = Timer.periodic(
|
||||
const Duration(seconds: 30), (_) => _checkHealthIfIdle());
|
||||
}
|
||||
|
||||
Future<void> _checkHealthIfIdle() async {
|
||||
// A live tunnel already proves reachability.
|
||||
if (_status == VpnStatus.connected) return;
|
||||
await _checkHealth();
|
||||
}
|
||||
|
||||
Future<void> _checkHealth() async {
|
||||
_serverReachable = await HivemindService.checkHealth();
|
||||
if (_disposed || _status == VpnStatus.connected) return;
|
||||
final reachable = await HivemindService.checkHealth();
|
||||
if (_disposed || _status == VpnStatus.connected) return;
|
||||
_serverReachable = reachable;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.detached) {
|
||||
_healthTimer?.cancel();
|
||||
} else if (state == AppLifecycleState.resumed) {
|
||||
_startHealthPolling();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_disposed = true;
|
||||
_connectEpoch++;
|
||||
_suppressNativeConnect = true;
|
||||
_userDisconnecting = true;
|
||||
HivemindService.cancel();
|
||||
_healthTimer?.cancel();
|
||||
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
|
||||
try {
|
||||
_vless.stopVless();
|
||||
} catch (_) {}
|
||||
}
|
||||
_networkSubscription?.cancel();
|
||||
// Provider/UI disposal is not a user disconnect command. Keeping teardown
|
||||
// in disconnect() prevents lifecycle churn from silently dropping the VPN.
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
+18
-15
@@ -1,18 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||
import 'package:revoltvpn/logic/session_timer.dart';
|
||||
import 'package:revoltvpn/logic/ad_manager.dart';
|
||||
import 'package:revoltvpn/logic/server_list.dart';
|
||||
import 'package:revoltvpn/logic/app_colors.dart';
|
||||
import 'package:revoltvpn/logic/connection_settings.dart';
|
||||
import 'package:revoltvpn/logic/haptic_settings.dart';
|
||||
import 'package:revoltvpn/logic/session_timer.dart';
|
||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||
import 'package:revoltvpn/screens/intro.dart';
|
||||
void main() {
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
||||
await HapticSettings.initialize();
|
||||
await ConnectionSettings.initialize();
|
||||
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
||||
|
||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
@@ -40,18 +44,17 @@ class ReVoltApp extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
// lazy: false — the native VLESS engine takes real time to start, and
|
||||
// providers are lazy by default, so it used to begin initialising only
|
||||
// once MainScreen first read it, i.e. *after* the loading screen was
|
||||
// gone. Constructing it eagerly moves that work under the intro, which
|
||||
// now awaits VpnConnection.ready before navigating.
|
||||
ChangeNotifierProvider(create: (_) => VpnConnection(), lazy: false),
|
||||
ChangeNotifierProxyProvider<VpnConnection, SessionTimer>(
|
||||
create: (ctx) => SessionTimer(vpnConnection: ctx.read<VpnConnection>()),
|
||||
update: (_, vpn, prev) => prev ?? SessionTimer(vpnConnection: vpn),
|
||||
// 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) =>
|
||||
SessionTimer(vpnConnection: ctx.read<VpnConnection>()),
|
||||
update: (_, vpn, prev) =>
|
||||
prev ?? SessionTimer(vpnConnection: vpn),
|
||||
),
|
||||
ChangeNotifierProvider(create: (_) => AdManager()),
|
||||
ChangeNotifierProvider(create: (_) => ServerList()..init(), lazy: false),
|
||||
],
|
||||
child: MaterialApp(
|
||||
title: 'ReVolt VPN',
|
||||
|
||||
@@ -2,11 +2,11 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:revoltvpn/logic/app_colors.dart';
|
||||
import 'package:revoltvpn/logic/haptic_settings.dart';
|
||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||
import 'package:revoltvpn/screens/main_screen.dart';
|
||||
import 'package:revoltvpn/screens/settings/in_settings/rain.dart';
|
||||
import 'package:revoltvpn/screens/settings/in_settings/lightning.dart';
|
||||
import 'package:revoltvpn/screens/settings/in_settings/haptic.dart';
|
||||
|
||||
class IntroScreen extends StatefulWidget {
|
||||
const IntroScreen({super.key});
|
||||
@@ -45,10 +45,12 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
|
||||
final elapsed = Stopwatch()..start();
|
||||
final vpn = context.read<VpnConnection>();
|
||||
|
||||
// The overlays read these in initState, so they must land before
|
||||
// MainScreen mounts.
|
||||
final settingsPrefs = Future.wait(
|
||||
[loadRainPref(), loadLightningPref(), loadHapticPref()]);
|
||||
// The overlays and haptic layer read these before MainScreen mounts.
|
||||
final settingsPrefs = Future.wait([
|
||||
loadRainPref(),
|
||||
loadLightningPref(),
|
||||
HapticSettings.initialize(),
|
||||
]);
|
||||
|
||||
try {
|
||||
await vpn.ready.timeout(_bootTimeout);
|
||||
@@ -79,7 +81,7 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
|
||||
void _navigateIfReady() {
|
||||
if (!mounted || _navigated || !_bootComplete) return;
|
||||
if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.paused) {
|
||||
return; // resume will call back in
|
||||
return;
|
||||
}
|
||||
_navigated = true;
|
||||
|
||||
@@ -101,7 +103,6 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
|
||||
body: Column(
|
||||
children: [
|
||||
const Spacer(flex: 7),
|
||||
// Image slightly below center
|
||||
Center(
|
||||
child: Image.asset(
|
||||
'assets/brand.png',
|
||||
@@ -116,7 +117,6 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
|
||||
},
|
||||
),
|
||||
),
|
||||
// Spinner + text paired together below the image
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(
|
||||
width: 40,
|
||||
@@ -137,7 +137,6 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// Says what the spinner is actually waiting on.
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: Text(
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:revoltvpn/logic/app_colors.dart';
|
||||
import 'package:revoltvpn/logic/server_list.dart';
|
||||
|
||||
// ── lib/screens/server_selector/selection_screen.dart ──────────────────────
|
||||
// Server picker — reads the server list from the ServerList provider.
|
||||
// Each row shows a flag + city name. Tapping a row persists the choice
|
||||
// and pops back. No latency measurement — the app runs inside a Reality
|
||||
// tunnel and must not emit separate probe traffic.
|
||||
|
||||
class SelectionScreen extends StatelessWidget {
|
||||
const SelectionScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final serverList = context.watch<ServerList>();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.bgDeep,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.bgSurface,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: AppColors.textWhite),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: const Text(
|
||||
'Select Server',
|
||||
style: TextStyle(
|
||||
color: AppColors.textWhite,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: ListView.builder(
|
||||
itemCount: serverList.all.length,
|
||||
itemBuilder: (context, index) {
|
||||
final server = serverList.all[index];
|
||||
final isSelected = index == serverList.selectedIndex;
|
||||
|
||||
return ListTile(
|
||||
leading: Image.asset(server.flagAsset, width: 28, height: 28),
|
||||
title: Text(server.name,
|
||||
style: TextStyle(
|
||||
color: isSelected ? AppColors.accent : AppColors.textWhite,
|
||||
fontWeight: FontWeight.w600,
|
||||
)),
|
||||
trailing: isSelected
|
||||
? const Icon(Icons.check, color: AppColors.accent)
|
||||
: null,
|
||||
onTap: () {
|
||||
serverList.select(index);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:revoltvpn/logic/app_colors.dart';
|
||||
import 'package:revoltvpn/logic/connection_settings.dart';
|
||||
import 'package:revoltvpn/logic/local_socks_tester.dart';
|
||||
import 'package:revoltvpn/logic/session_timer.dart';
|
||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||
|
||||
class ConnectionModeTile extends StatefulWidget {
|
||||
final ValueChanged<ConnectionMode>? onChanged;
|
||||
|
||||
const ConnectionModeTile({super.key, this.onChanged});
|
||||
|
||||
@override
|
||||
State<ConnectionModeTile> createState() => _ConnectionModeTileState();
|
||||
}
|
||||
|
||||
class _ConnectionModeTileState extends State<ConnectionModeTile> {
|
||||
ConnectionMode _mode = ConnectionSettings.mode;
|
||||
bool _testingLocalSocks = false;
|
||||
bool _reconnecting = false;
|
||||
LocalSocksTestResult? _lastSocksTest;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
await ConnectionSettings.initialize();
|
||||
if (mounted) setState(() => _mode = ConnectionSettings.mode);
|
||||
}
|
||||
|
||||
bool _isTunnelBusy(VpnStatus status) {
|
||||
return status == VpnStatus.connected ||
|
||||
status == VpnStatus.connecting ||
|
||||
status == VpnStatus.disconnecting;
|
||||
}
|
||||
|
||||
Future<void> _changeMode(ConnectionMode? next) async {
|
||||
if (next == null || next == _mode) return;
|
||||
|
||||
final vpn = context.read<VpnConnection>();
|
||||
if (_isTunnelBusy(vpn.status)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Disconnect before changing connection mode.'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final saved = await ConnectionSettings.setMode(next);
|
||||
if (!mounted) return;
|
||||
if (!saved) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Could not save connection mode.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_mode = next;
|
||||
_lastSocksTest = null;
|
||||
});
|
||||
widget.onChanged?.call(next);
|
||||
}
|
||||
|
||||
// Recovers an orphaned proxy: tear the runtime down and re-attach to the
|
||||
// session already held. No ad, no new session.
|
||||
Future<void> _reconnect() async {
|
||||
if (_reconnecting) return;
|
||||
final vpn = context.read<VpnConnection>();
|
||||
final timer = context.read<SessionTimer>();
|
||||
|
||||
setState(() => _reconnecting = true);
|
||||
try {
|
||||
await timer.disconnect();
|
||||
if (!mounted) return;
|
||||
if (await vpn.connect(resume: true)) {
|
||||
if (!mounted) return;
|
||||
await timer.start(resetSupportClaim: false);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_reconnecting = false;
|
||||
_lastSocksTest = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _testLocalSocks() async {
|
||||
if (_testingLocalSocks) return;
|
||||
final vpn = context.read<VpnConnection>();
|
||||
|
||||
if (vpn.status != VpnStatus.connected || !vpn.canTestActiveLocalSocks) {
|
||||
final orphaned =
|
||||
vpn.status == VpnStatus.connected && vpn.adoptedRunningRuntime;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
orphaned
|
||||
? 'Credentials were lost when the app restarted. Reconnect first.'
|
||||
: 'Connect with SOCKS5 first, then run the local test.',
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _testingLocalSocks = true);
|
||||
final result = await vpn.testActiveLocalSocks();
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_testingLocalSocks = false;
|
||||
_lastSocksTest = result;
|
||||
});
|
||||
|
||||
final latency = result.latencyMs == null ? '' : ' (${result.latencyMs} ms)';
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${result.message}$latency')),
|
||||
);
|
||||
}
|
||||
|
||||
String get _subtitle {
|
||||
switch (_mode) {
|
||||
case ConnectionMode.tun:
|
||||
return 'TUN: Android VPN routing for normal device traffic.';
|
||||
case ConnectionMode.proxy:
|
||||
return 'SOCKS5: local authenticated proxy — point apps at 127.0.0.1:<port> with the credentials shown below.';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ListTile(
|
||||
title: const Text(
|
||||
'Connection mode',
|
||||
style: TextStyle(color: AppColors.textWhite, fontSize: 15),
|
||||
),
|
||||
subtitle: Text(
|
||||
_subtitle,
|
||||
style: const TextStyle(color: AppColors.textDim, fontSize: 12),
|
||||
),
|
||||
trailing: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<ConnectionMode>(
|
||||
value: _mode,
|
||||
dropdownColor: AppColors.bgCard,
|
||||
onChanged: _changeMode,
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: ConnectionMode.tun,
|
||||
child: Text('TUN'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: ConnectionMode.proxy,
|
||||
child: Text('SOCKS5'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_mode == ConnectionMode.proxy) ...[
|
||||
Consumer<VpnConnection>(
|
||||
builder: (context, vpn, _) {
|
||||
final session = vpn.activeSocksSession;
|
||||
if (session == null) {
|
||||
// The proxy can be running while this session is unknown:
|
||||
// Android killed the app process, the foreground service kept
|
||||
// the listener alive, and its credentials existed only in the
|
||||
// memory that died with it. Saying "Connect" there would be a
|
||||
// lie — the user is already connected.
|
||||
final orphaned = vpn.status == VpnStatus.connected &&
|
||||
vpn.adoptedRunningRuntime;
|
||||
if (orphaned) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'The proxy is still running, but its credentials were '
|
||||
'lost when the app restarted. Reconnect to get a new '
|
||||
'endpoint.',
|
||||
style: TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _reconnecting ? null : _reconnect,
|
||||
icon: _reconnecting
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.refresh),
|
||||
label: Text(
|
||||
_reconnecting ? 'Reconnecting…' : 'Reconnect',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||
child: Text(
|
||||
'Connect to start a local SOCKS5 proxy. Credentials change every session.',
|
||||
style: TextStyle(color: AppColors.textMuted, fontSize: 12),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Proxy: 127.0.0.1:${session.port}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Username: ${session.username}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Password: ${session.password}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _testingLocalSocks ? null : _testLocalSocks,
|
||||
icon: _testingLocalSocks
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.network_check),
|
||||
label: Text(
|
||||
_testingLocalSocks ? 'Testing…' : 'Test Local SOCKS',
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_lastSocksTest != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
|
||||
child: Text(
|
||||
_lastSocksTest!.ok
|
||||
? 'Last test: OK${_lastSocksTest!.latencyMs == null ? '' : ' · ${_lastSocksTest!.latencyMs} ms'}'
|
||||
: 'Last test: Failed · ${_lastSocksTest!.message}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:revoltvpn/logic/app_colors.dart';
|
||||
|
||||
// A plain bool, not a ValueNotifier: this is only read at the moment of a tap,
|
||||
// so nothing has to react to it changing. Default: false.
|
||||
bool hapticEnabled = false;
|
||||
|
||||
/// Hydrates [hapticEnabled] from disk. Called at boot — the toggle tile below
|
||||
/// only mounts once Settings is opened.
|
||||
Future<void> loadHapticPref() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
hapticEnabled = prefs.getBool('haptic_feedback_enabled') ?? false;
|
||||
}
|
||||
|
||||
/// Haptic calls are deliberately best-effort: a vibration failure must never
|
||||
/// block or fail a VPN connect/disconnect. `mediumImpact` is used instead of
|
||||
/// `lightImpact` — the latter is imperceptible on many Android devices.
|
||||
class Haptics {
|
||||
Haptics._();
|
||||
|
||||
static Future<void> connectionChanged() async {
|
||||
if (!hapticEnabled) return;
|
||||
try {
|
||||
await HapticFeedback.mediumImpact();
|
||||
} catch (e) {
|
||||
debugPrint('[Haptics] Feedback unavailable: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> previewEnabled() async {
|
||||
try {
|
||||
await HapticFeedback.selectionClick();
|
||||
} catch (e) {
|
||||
debugPrint('[Haptics] Preview unavailable: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
import 'package:revoltvpn/logic/haptic_settings.dart';
|
||||
|
||||
class HapticFeedbackTile extends StatefulWidget {
|
||||
const HapticFeedbackTile({super.key});
|
||||
@@ -47,7 +10,8 @@ class HapticFeedbackTile extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _HapticFeedbackTileState extends State<HapticFeedbackTile> {
|
||||
bool _on = false;
|
||||
bool _on = HapticSettings.enabled;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -56,18 +20,24 @@ class _HapticFeedbackTileState extends State<HapticFeedbackTile> {
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
await loadHapticPref();
|
||||
if (mounted) setState(() => _on = hapticEnabled);
|
||||
await HapticSettings.initialize();
|
||||
if (mounted) setState(() => _on = HapticSettings.enabled);
|
||||
}
|
||||
|
||||
// Writes to SharedPreferences AND updates the live bool so the connect
|
||||
// button sees the change immediately — no restart needed.
|
||||
Future<void> _toggle(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('haptic_feedback_enabled', value);
|
||||
hapticEnabled = value;
|
||||
if (value) unawaited(Haptics.previewEnabled());
|
||||
setState(() => _on = value);
|
||||
if (_saving) return;
|
||||
setState(() {
|
||||
_saving = true;
|
||||
_on = value;
|
||||
});
|
||||
|
||||
try {
|
||||
await HapticSettings.setEnabled(value);
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _on = HapticSettings.enabled);
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -78,7 +48,7 @@ class _HapticFeedbackTileState extends State<HapticFeedbackTile> {
|
||||
style: TextStyle(color: AppColors.textWhite, fontSize: 15),
|
||||
),
|
||||
value: _on,
|
||||
onChanged: _toggle,
|
||||
onChanged: _saving ? null : _toggle,
|
||||
activeTrackColor: AppColors.accent.withAlpha(80),
|
||||
activeThumbColor: AppColors.accent,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
|
||||
|
||||
@@ -1,23 +1,10 @@
|
||||
import 'package:flutter/material.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/haptic.dart';
|
||||
import 'package:revoltvpn/screens/settings/in_settings/rain.dart';
|
||||
import 'package:revoltvpn/screens/settings/in_settings/lightning.dart';
|
||||
|
||||
// ── lib/screens/settings/settings_screen.dart ────────────────────────────────
|
||||
// Settings shell — full-screen page reached from the sidebar.
|
||||
//
|
||||
// Folder layout:
|
||||
// lib/screens/settings/ ← this folder: settings family
|
||||
// settings_screen.dart ← shell: Scaffold + AppBar + ListView (this file)
|
||||
// *_tile.dart ← one file per setting feature (add below)
|
||||
// lib/components/sidebar_contents/
|
||||
// settings.dart ← thin sidebar row that navigates here
|
||||
//
|
||||
// To add a setting:
|
||||
// 1. Create a new file in this folder, e.g. appearance_tile.dart
|
||||
// 2. Import it below
|
||||
// 3. Append it to the children list
|
||||
import 'package:revoltvpn/screens/settings/in_settings/rain.dart';
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
const SettingsScreen({super.key});
|
||||
@@ -44,11 +31,11 @@ class SettingsScreen extends StatelessWidget {
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
children: const [
|
||||
ConnectionModeTile(),
|
||||
BackgroundReliabilityTile(),
|
||||
HapticFeedbackTile(),
|
||||
RainToggleTile(),
|
||||
LightningToggleTile(),
|
||||
// ── Add setting tiles here ────────────────────────────────────────
|
||||
// Each tile imports from its own file in this folder.
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
|
||||
channel: "stable"
|
||||
|
||||
project_type: plugin
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
- platform: android
|
||||
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
@@ -0,0 +1,49 @@
|
||||
## 1.1.5
|
||||
|
||||
* Updated the Android runtime dependency to `dev.tfox.fluttervless:xray-android:26.7.11`.
|
||||
* Rebuilt Android `libxray.so` binaries for `armeabi-v7a`, `arm64-v8a`, `x86`, and `x86_64` against Xray-core `v26.7.11`.
|
||||
* Fixed Android VPN app routing: all configured blocked apps are now applied to `VpnService.Builder` as disallowed applications and bypass the Xray VPN tunnel.
|
||||
|
||||
## 1.1.4
|
||||
|
||||
* Updated the Android runtime dependency to `dev.tfox.fluttervless:xray-android:26.6.27.1`.
|
||||
* Rebuilt Android `libxray.so` binaries for `armeabi-v7a`, `arm64-v8a`, `x86`, and `x86_64` against Xray-core `v26.6.27`.
|
||||
|
||||
## 1.1.3
|
||||
|
||||
* Updated the Android runtime dependency to `dev.tfox.fluttervless:xray-android:26.6.22`.
|
||||
* Rebuilt Android `libxray.so` binaries for `armeabi-v7a`, `arm64-v8a`, `x86`, and `x86_64` against Xray-core `v26.6.22`.
|
||||
|
||||
## 1.1.2
|
||||
|
||||
* Clarified that Android device and emulator runtime binaries are delivered through the main Maven `xray-android` AAR.
|
||||
* Kept the Android runtime dependency on `dev.tfox.fluttervless:xray-android:26.6.1.1`.
|
||||
|
||||
## 1.1.1
|
||||
|
||||
* Updated the Android device runtime `libxray.so` binaries to Xray-core `v26.6.1`.
|
||||
* Kept Android 15+ 16KB page-size linker alignment for the rebuilt ARM binaries.
|
||||
* Moved Android runtime binaries and geodata into the Maven `dev.tfox.fluttervless:xray-android:26.6.1.1` AAR dependency.
|
||||
* Included device and emulator ABIs (`armeabi-v7a`, `arm64-v8a`, `x86`, `x86_64`) in the main Maven runtime AAR.
|
||||
* Removed raw device `jniLibs` and geodata files from the Pub.dev wrapper package; the wrapper now resolves the runtime from Maven Central.
|
||||
|
||||
## 1.1.0
|
||||
|
||||
* Replaced Android Dart boilerplate with the shared `VlessMethodChannelAdapter`.
|
||||
* Removed template `getPlatformVersion` scaffold files.
|
||||
* Moved `flutter_lints` to `dev_dependencies`.
|
||||
* Removed publish-time dependency overrides from `pubspec.yaml`.
|
||||
|
||||
## 1.0.2
|
||||
|
||||
* XRay version up
|
||||
|
||||
## 1.0.1
|
||||
|
||||
* XRay version up
|
||||
|
||||
## 1.0.0
|
||||
|
||||
* Initial release of the Android implementation for `flutter_vless`.
|
||||
* Supports VLESS/VMESS protocols using Xray core.
|
||||
* Implements `VlessPlatform` interface.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-2026 13FOX Studio / tfox.dev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,13 @@
|
||||
flutter_vless_android
|
||||
|
||||
Copyright (c) 2024-2026 13FOX Studio / tfox.dev.
|
||||
|
||||
This package is the Android implementation of the official flutter_vless
|
||||
project maintained by 13FOX Studio.
|
||||
|
||||
Official project: https://github.com/XIIIFOX/flutter_vless
|
||||
Official publisher: https://pub.dev/publishers/tfox.dev
|
||||
Website: https://tfox.dev
|
||||
|
||||
Redistributions, forks, and derived packages must preserve the copyright notice
|
||||
and MIT license notice where required by the license.
|
||||
@@ -0,0 +1,14 @@
|
||||
# flutter_vless_android
|
||||
|
||||
The Android implementation of the `flutter_vless` plugin.
|
||||
|
||||
## Runtime
|
||||
|
||||
Android runtime binaries and geodata are delivered through the Maven Central AAR dependency `dev.tfox.fluttervless:xray-android:26.7.11`. This keeps the Pub.dev package lightweight while preserving the same packaged Xray files in the final Android app.
|
||||
|
||||
Do not add raw `android/src/main/jniLibs` or geodata files back to this package. Rebuild runtime files in `android_runtime/xray_android/src/main`, publish the Maven AAR, then publish this wrapper package.
|
||||
|
||||
## Emulator Support
|
||||
|
||||
The current Maven runtime AAR includes `armeabi-v7a`, `arm64-v8a`, `x86`, and `x86_64`.
|
||||
This covers both physical Android devices and Android emulators.
|
||||
@@ -0,0 +1,4 @@
|
||||
Upstream: https://github.com/XIIIFOX/flutter_vless
|
||||
Tag: v1.1.5
|
||||
Android package: flutter_vless_android 1.1.5
|
||||
ReVolt hardening is pre-applied to this vendored package.
|
||||
@@ -0,0 +1,4 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
@@ -0,0 +1,9 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/workspace.xml
|
||||
/.idea/libraries
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.cxx
|
||||
@@ -0,0 +1,92 @@
|
||||
# Xray & Tun2socks Android Build Guide
|
||||
|
||||
This guide explains how to build the native libraries (`libxray.so` and `libtun2socks.so`) for the Flutter Vless plugin.
|
||||
|
||||
Android runtime files are stored in `android_runtime/xray_android/src/main` and published as the Maven Central AAR `dev.tfox.fluttervless:xray-android`. They are not stored in the `flutter_vless_android` Pub.dev package.
|
||||
|
||||
**Key Features:**
|
||||
- ✅ **Android 15+ Support**: Builds with 16KB page size alignment.
|
||||
- ✅ **Socket FD Passing**: `tun2socks` is patched to receive the TUN file descriptor via a Unix socket (bypassing Android process restrictions).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Go (Golang)**: Version 1.21+ installed.
|
||||
2. **Android NDK**: Version r27+ (recommended).
|
||||
3. **macOS/Linux**: Build scripts are designed for Unix-like environments.
|
||||
|
||||
## 1. Environment Setup
|
||||
|
||||
Export the path to your Android NDK.
|
||||
|
||||
```bash
|
||||
# Example (Adjust path to your NDK installation)
|
||||
export ANDROID_NDK_HOME="$HOME/Library/Android/sdk/ndk/27.0.12077973"
|
||||
```
|
||||
|
||||
## 2. Build Xray (`libxray.so`)
|
||||
|
||||
This script builds the Xray core for Android device and emulator architectures (`arm64-v8a`, `armeabi-v7a`, `x86`, `x86_64`) by default and writes them to the Maven runtime module.
|
||||
|
||||
```bash
|
||||
cd android
|
||||
chmod +x build_xray.sh
|
||||
./build_xray.sh
|
||||
```
|
||||
|
||||
## 3. Build Tun2socks (`libtun2socks.so`)
|
||||
|
||||
We use a custom Go-based build script (`build_tun2socks.sh`) instead of the old C-based one. This ensures 16KB page alignment and includes the socket-based FD passing logic.
|
||||
|
||||
```bash
|
||||
cd android
|
||||
chmod +x build_tun2socks.sh
|
||||
./build_tun2socks.sh
|
||||
```
|
||||
|
||||
## 4. Verification (16KB Page Size)
|
||||
|
||||
To confirm that the libraries are compatible with Android 15+ (16KB page size), check the `LOAD` segment alignment using `llvm-readelf`.
|
||||
|
||||
```bash
|
||||
# Check alignment (should be 0x4000)
|
||||
$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-readelf -l ../../../android_runtime/xray_android/src/main/jniLibs/arm64-v8a/libtun2socks.so | grep LOAD | head -1
|
||||
```
|
||||
|
||||
**Expected Output:**
|
||||
```
|
||||
LOAD ... 0x4000
|
||||
```
|
||||
If you see `0x1000`, it is **NOT** compatible with 16KB devices.
|
||||
|
||||
## 5. Package The Maven Runtime AAR
|
||||
|
||||
The `flutter_vless_android` Pub.dev package consumes the Android device runtime through the Maven artifact:
|
||||
|
||||
```text
|
||||
dev.tfox.fluttervless:xray-android:26.7.11
|
||||
```
|
||||
|
||||
After rebuilding `libxray.so`, `libtun2socks.so`, or the geodata files in `android_runtime/xray_android/src/main`, build the local Maven repository from the repository root:
|
||||
|
||||
```bash
|
||||
tool/build_android_runtime_maven.sh
|
||||
```
|
||||
|
||||
For local Android wrapper or example builds before Maven Central publication, pass the local repository path:
|
||||
|
||||
```bash
|
||||
cd example/android
|
||||
./gradlew :app:assembleDebug \
|
||||
-PflutterVlessAndroidRuntimeRepo="$PWD/../../android_runtime/xray_android/build/repo"
|
||||
```
|
||||
|
||||
To upload the signed bundle to Maven Central, configure the Central Portal and signing environment variables documented in `android_runtime/xray_android/README.md`, then run:
|
||||
|
||||
```bash
|
||||
tool/publish_android_runtime_maven.sh
|
||||
```
|
||||
|
||||
## 6. Troubleshooting
|
||||
|
||||
- **"bad file descriptor"**: This means the socket FD passing failed. Ensure `XrayVPNService.kt` is correctly sending the FD to the socket path specified in `-sock-path`.
|
||||
- **"permission denied"**: Ensure the app has permissions to write to the socket file in its private directory.
|
||||
@@ -0,0 +1,95 @@
|
||||
group 'com.github.tfox.flutter_vless'
|
||||
version '1.0'
|
||||
|
||||
buildscript {
|
||||
ext.kotlin_version = '2.2.20'
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.1.0'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.allprojects {
|
||||
repositories {
|
||||
def localRuntimeRepo = findProperty('flutterVlessAndroidRuntimeRepo') ?: System.getenv('FLUTTER_VLESS_ANDROID_RUNTIME_REPO')
|
||||
if (localRuntimeRepo) {
|
||||
maven { url uri(localRuntimeRepo) }
|
||||
}
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: 'com.android.library'
|
||||
apply plugin: 'kotlin-android'
|
||||
|
||||
android {
|
||||
if (project.android.hasProperty("namespace")) {
|
||||
namespace 'com.github.tfox.flutter_vless'
|
||||
}
|
||||
|
||||
compileSdkVersion 34
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdkVersion 23
|
||||
targetSdkVersion 35
|
||||
|
||||
// 16KB page size support for Android 15+
|
||||
ndk {
|
||||
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
|
||||
}
|
||||
}
|
||||
|
||||
// Configure 16KB page alignment
|
||||
buildFeatures {
|
||||
prefab true
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.mockito:mockito-core:5.0.0'
|
||||
testImplementation 'org.json:json:20240303'
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.returnDefaultValues = true
|
||||
unitTests.all {
|
||||
testLogging {
|
||||
events "passed", "skipped", "failed", "standardOut", "standardError"
|
||||
outputs.upToDateWhen {false}
|
||||
showStandardStreams = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main {
|
||||
java.srcDirs += 'src/main/kotlin'
|
||||
}
|
||||
}
|
||||
packaging {
|
||||
jniLibs {
|
||||
pickFirsts += ['lib/**/libc++_shared.so']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'com.google.code.gson:gson:2.10.1'
|
||||
implementation "dev.tfox.fluttervless:xray-android:${findProperty('flutterVlessXrayRuntimeVersion') ?: '26.7.11'}"
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Build script for tun2socks (Go version) with 16KB page size support
|
||||
# This is required for Android 15+ compatibility
|
||||
|
||||
# Configuration
|
||||
TUN2SOCKS_REPO="https://github.com/xjasonlyu/tun2socks"
|
||||
TARGET_DIR="${TARGET_DIR:-../../../android_runtime/xray_android/src/main/jniLibs}"
|
||||
NDK_PATH="${ANDROID_NDK_HOME:-$HOME/Library/Android/sdk/ndk/28.2.13676358}"
|
||||
|
||||
# Check NDK
|
||||
if [ ! -d "$NDK_PATH" ]; then
|
||||
echo "Error: NDK not found at $NDK_PATH"
|
||||
echo "Please export ANDROID_NDK_HOME pointing to your NDK installation."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Using NDK at: $NDK_PATH"
|
||||
|
||||
case "$(uname -s)" in
|
||||
Darwin) TOOLCHAIN="${NDK_PATH}/toolchains/llvm/prebuilt/darwin-x86_64" ;;
|
||||
Linux) TOOLCHAIN="${NDK_PATH}/toolchains/llvm/prebuilt/linux-x86_64" ;;
|
||||
*) echo "Error: unsupported host OS $(uname -s)"; exit 1 ;;
|
||||
esac
|
||||
if [ ! -d "$TOOLCHAIN" ]; then
|
||||
echo "Error: NDK toolchain not found at $TOOLCHAIN"
|
||||
echo "Please check ANDROID_NDK_HOME and host OS."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clone tun2socks if not exists
|
||||
if [ ! -d "tun2socks-go" ]; then
|
||||
echo "Cloning tun2socks (Go version)..."
|
||||
git clone "$TUN2SOCKS_REPO" tun2socks-go
|
||||
else
|
||||
echo "tun2socks-go directory exists, pulling latest..."
|
||||
cd tun2socks-go && git pull && cd ..
|
||||
fi
|
||||
|
||||
# Build Function
|
||||
build_tun2socks() {
|
||||
local ARCH_NAME=$1
|
||||
local GO_ARCH=$2
|
||||
local GO_ARM=$3
|
||||
local ANDROID_TARGET=$4
|
||||
local OUTPUT_DIR="${TARGET_DIR}/${ARCH_NAME}"
|
||||
|
||||
echo "Building tun2socks for ${ARCH_NAME}..."
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
export CGO_ENABLED=1
|
||||
export GOOS=android
|
||||
export GOARCH=$GO_ARCH
|
||||
export GOARM=$GO_ARM
|
||||
|
||||
export CC="${TOOLCHAIN}/bin/${ANDROID_TARGET}-clang"
|
||||
export CXX="${TOOLCHAIN}/bin/${ANDROID_TARGET}-clang++"
|
||||
|
||||
# Verify compiler exists
|
||||
if [ ! -f "$CC" ]; then
|
||||
echo "Error: Compiler not found at $CC"
|
||||
return 1
|
||||
fi
|
||||
|
||||
cd tun2socks-go
|
||||
|
||||
# Build with 16KB page alignment - same flags as xray
|
||||
go build -v -trimpath -ldflags "-s -w -buildid= -linkmode=external -extldflags '-Wl,-z,max-page-size=16384'" -buildmode=pie -o "../${OUTPUT_DIR}/libtun2socks.so" .
|
||||
|
||||
echo "Success: ${OUTPUT_DIR}/libtun2socks.so created."
|
||||
|
||||
# Verify 16KB alignment
|
||||
if [ -x "${TOOLCHAIN}/bin/llvm-readelf" ]; then
|
||||
echo "Verifying 16KB alignment:"
|
||||
ALIGN=$("${TOOLCHAIN}/bin/llvm-readelf" -l "../${OUTPUT_DIR}/libtun2socks.so" | grep "LOAD" | head -1 | awk '{print $NF}')
|
||||
if [ "$ALIGN" = "0x4000" ]; then
|
||||
echo "Alignment: $ALIGN (16KB) - correct"
|
||||
else
|
||||
echo "Warning: alignment is $ALIGN; expected 0x4000"
|
||||
fi
|
||||
fi
|
||||
|
||||
cd ..
|
||||
}
|
||||
|
||||
# Build for Architectures
|
||||
echo "Building tun2socks (Go version) with 16KB page size support..."
|
||||
|
||||
# ARM64
|
||||
if [ "${TUN2SOCKS_BUILD_ARM64:-1}" = "1" ]; then
|
||||
build_tun2socks "arm64-v8a" "arm64" "" "aarch64-linux-android21"
|
||||
fi
|
||||
|
||||
# ARMv7
|
||||
if [ "${TUN2SOCKS_BUILD_ARMV7:-1}" = "1" ]; then
|
||||
build_tun2socks "armeabi-v7a" "arm" "7" "armv7a-linux-androideabi21"
|
||||
fi
|
||||
|
||||
# x86 is included in the main runtime AAR.
|
||||
if [ "${TUN2SOCKS_BUILD_X86:-1}" = "1" ]; then
|
||||
build_tun2socks "x86" "386" "" "i686-linux-android21"
|
||||
fi
|
||||
|
||||
# x86_64 is included in the main runtime AAR.
|
||||
if [ "${TUN2SOCKS_BUILD_X86_64:-1}" = "1" ]; then
|
||||
build_tun2socks "x86_64" "amd64" "" "x86_64-linux-android21"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo "Build process finished!"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
echo "Verification:"
|
||||
echo "Run this command to check all libraries:"
|
||||
echo ""
|
||||
echo "for arch in armeabi-v7a arm64-v8a x86 x86_64; do"
|
||||
echo " echo \"=== \$arch ===\";"
|
||||
echo " ${TOOLCHAIN}/bin/llvm-readelf -l ${TARGET_DIR}/\$arch/libtun2socks.so | grep LOAD | head -1;"
|
||||
echo "done"
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Configuration
|
||||
XRAY_REPO="https://github.com/XTLS/Xray-core"
|
||||
XRAY_VERSION="${XRAY_VERSION:-v26.7.11}"
|
||||
TARGET_DIR="${TARGET_DIR:-../../../android_runtime/xray_android/src/main/jniLibs}"
|
||||
NDK_PATH="${ANDROID_NDK_HOME:-$HOME/Library/Android/sdk/ndk/28.2.13676358}"
|
||||
|
||||
# Check NDK
|
||||
if [ ! -d "$NDK_PATH" ]; then
|
||||
echo "Error: NDK not found at $NDK_PATH"
|
||||
echo "Please export ANDROID_NDK_HOME pointing to your NDK installation."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Using NDK at: $NDK_PATH"
|
||||
|
||||
# MacOS NDK Toolchain path
|
||||
case "$(uname -s)" in
|
||||
Darwin) TOOLCHAIN="${NDK_PATH}/toolchains/llvm/prebuilt/darwin-x86_64" ;;
|
||||
Linux) TOOLCHAIN="${NDK_PATH}/toolchains/llvm/prebuilt/linux-x86_64" ;;
|
||||
*) echo "Error: unsupported host OS $(uname -s)"; exit 1 ;;
|
||||
esac
|
||||
if [ ! -d "$TOOLCHAIN" ]; then
|
||||
echo "Error: NDK toolchain not found at $TOOLCHAIN"
|
||||
echo "Are you on macOS? If not, please edit the script to match your OS."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clone Xray if not exists
|
||||
if [ ! -d "Xray-core" ]; then
|
||||
echo "Cloning Xray-core..."
|
||||
git clone "$XRAY_REPO"
|
||||
else
|
||||
echo "Xray-core directory exists, pulling latest..."
|
||||
cd Xray-core && git fetch --tags && cd ..
|
||||
fi
|
||||
|
||||
cd Xray-core
|
||||
git fetch --tags
|
||||
git checkout "$XRAY_VERSION"
|
||||
cd ..
|
||||
|
||||
# Build Function
|
||||
build_xray() {
|
||||
local ARCH_NAME=$1
|
||||
local GO_ARCH=$2
|
||||
local GO_ARM=$3
|
||||
local ANDROID_TARGET=$4
|
||||
local OUTPUT_DIR="${TARGET_DIR}/${ARCH_NAME}"
|
||||
|
||||
echo "Building for ${ARCH_NAME}..."
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
export CGO_ENABLED=1
|
||||
export GOOS=android
|
||||
export GOARCH=$GO_ARCH
|
||||
export GOARM=$GO_ARM
|
||||
|
||||
export CC="${TOOLCHAIN}/bin/${ANDROID_TARGET}-clang"
|
||||
export CXX="${TOOLCHAIN}/bin/${ANDROID_TARGET}-clang++"
|
||||
|
||||
# 16KB page size support for Android 15+
|
||||
export LDFLAGS="-Wl,-z,max-page-size=16384"
|
||||
|
||||
# Verify compiler exists
|
||||
if [ ! -f "$CC" ]; then
|
||||
echo "Error: Compiler not found at $CC"
|
||||
# Try older NDK naming convention if new one fails (API 21 is standard)
|
||||
# Some NDKs use specific API levels in clang name
|
||||
return 1
|
||||
fi
|
||||
|
||||
cd Xray-core
|
||||
|
||||
# Build with 16KB page alignment
|
||||
if go build -v -trimpath -buildvcs=false \
|
||||
-gcflags "all=-l=4" \
|
||||
-ldflags "-X github.com/xtls/xray-core/core.build=${XRAY_VERSION} -s -w -buildid= -checklinkname=0 -linkmode=external -extldflags=${LDFLAGS}" \
|
||||
-buildmode=pie \
|
||||
-o "../${OUTPUT_DIR}/libxray.so" ./main; then
|
||||
echo "Success: ${OUTPUT_DIR}/libxray.so created."
|
||||
else
|
||||
echo "Failed to build for ${ARCH_NAME}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
cd ..
|
||||
}
|
||||
|
||||
# Build for Architectures
|
||||
# API Level 21 is generally safe for modern Flutter apps (Android 5.0+)
|
||||
|
||||
# ARM64
|
||||
if [ "${XRAY_BUILD_ARM64:-1}" = "1" ]; then
|
||||
build_xray "arm64-v8a" "arm64" "" "aarch64-linux-android21"
|
||||
fi
|
||||
|
||||
# ARMv7
|
||||
if [ "${XRAY_BUILD_ARMV7:-1}" = "1" ]; then
|
||||
build_xray "armeabi-v7a" "arm" "7" "armv7a-linux-androideabi21"
|
||||
fi
|
||||
|
||||
# x86 is included in the main runtime AAR.
|
||||
if [ "${XRAY_BUILD_X86:-1}" = "1" ]; then
|
||||
build_xray "x86" "386" "" "i686-linux-android21"
|
||||
fi
|
||||
|
||||
# x86_64 is included in the main runtime AAR.
|
||||
if [ "${XRAY_BUILD_X86_64:-1}" = "1" ]; then
|
||||
build_xray "x86_64" "amd64" "" "x86_64-linux-android21"
|
||||
fi
|
||||
|
||||
echo "Build process finished."
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = 'flutter_vless_android'
|
||||
@@ -0,0 +1,31 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.github.tfox.flutter_vless">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission
|
||||
android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"
|
||||
android:minSdkVersion="34" />
|
||||
|
||||
<application>
|
||||
<service
|
||||
android:name="com.github.tfox.flutter_vless.xray.service.XrayVPNService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE"
|
||||
android:process=":RunSoLibXrayDaemon">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="vpn" />
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.net.VpnService.SUPPORTS_ALWAYS_ON"
|
||||
android:value="false" />
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
+424
@@ -0,0 +1,424 @@
|
||||
// Copyright (c) 2024-2026 13FOX Studio / tfox.dev.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package com.github.tfox.flutter_vless
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.github.tfox.flutter_vless.xray.dto.XrayConfig
|
||||
import com.github.tfox.flutter_vless.xray.service.XrayVPNService
|
||||
import com.github.tfox.flutter_vless.xray.utils.AppConfigs
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.PluginRegistry
|
||||
import java.io.BufferedReader
|
||||
import java.io.File
|
||||
import java.io.InputStreamReader
|
||||
import java.util.ArrayList
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Main entry point for the Flutter Vless plugin on Android.
|
||||
*
|
||||
* ReVolt keeps this bridge deliberately narrow: start/stop, session deadline,
|
||||
* permission handling, core version and private runtime status only.
|
||||
*/
|
||||
class FlutterVlessPlugin : FlutterPlugin, ActivityAware, PluginRegistry.ActivityResultListener, MethodChannel.MethodCallHandler {
|
||||
|
||||
private val executor = Executors.newSingleThreadExecutor()
|
||||
private lateinit var vpnControlMethod: MethodChannel
|
||||
private lateinit var vpnStatusEvent: EventChannel
|
||||
private var vpnStatusSink: EventChannel.EventSink? = null
|
||||
private var activity: Activity? = null
|
||||
private var xrayReceiver: BroadcastReceiver? = null
|
||||
private var pendingResult: MethodChannel.Result? = null
|
||||
private lateinit var context: Context
|
||||
private var expectedRuntimeToken: String? = null
|
||||
private var pendingStopResult: MethodChannel.Result? = null
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val stopTimeoutRunnable = Runnable {
|
||||
val pending = pendingStopResult ?: return@Runnable
|
||||
pendingStopResult = null
|
||||
pending.error("STOP_TIMEOUT", "VPN service did not confirm shutdown", null)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "FlutterVlessPlugin"
|
||||
private const val REQUEST_CODE_VPN_PERMISSION = 24
|
||||
private const val REQUEST_CODE_POST_NOTIFICATIONS = 1
|
||||
}
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
context = binding.applicationContext
|
||||
vpnControlMethod = MethodChannel(binding.binaryMessenger, "flutter_vless")
|
||||
vpnStatusEvent = EventChannel(binding.binaryMessenger, "flutter_vless/status")
|
||||
|
||||
vpnControlMethod.setMethodCallHandler(this)
|
||||
vpnStatusEvent.setStreamHandler(object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||
vpnStatusSink = events
|
||||
registerReceiver()
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
vpnStatusSink = null
|
||||
unregisterReceiver()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"startVless" -> {
|
||||
if (pendingStopResult != null) {
|
||||
result.error("STOP_IN_PROGRESS", "VPN shutdown is still in progress", null)
|
||||
return
|
||||
}
|
||||
|
||||
val config = XrayConfig()
|
||||
val runtimeToken = UUID.randomUUID().toString()
|
||||
expectedRuntimeToken = runtimeToken
|
||||
config.RUNTIME_TOKEN = runtimeToken
|
||||
config.REMARK = call.argument("remark") ?: ""
|
||||
config.V2RAY_FULL_JSON_CONFIG = call.argument("config") ?: ""
|
||||
config.BLOCKED_APPS = call.argument<ArrayList<String>>("blocked_apps") ?: ArrayList()
|
||||
config.BYPASS_SUBNETS = call.argument<ArrayList<String>>("bypass_subnets") ?: ArrayList()
|
||||
config.NOTIFICATION_DISCONNECT_BUTTON_NAME = call.argument("notificationDisconnectButtonName") ?: "Disconnect"
|
||||
|
||||
if (AppConfigs.NOTIFICATION_ICON_RESOURCE_NAME.isNotEmpty() && AppConfigs.NOTIFICATION_ICON_RESOURCE_TYPE.isNotEmpty()) {
|
||||
config.NOTIFICATION_ICON_RESOURCE_NAME = AppConfigs.NOTIFICATION_ICON_RESOURCE_NAME
|
||||
config.NOTIFICATION_ICON_RESOURCE_TYPE = AppConfigs.NOTIFICATION_ICON_RESOURCE_TYPE
|
||||
val resId = context.resources.getIdentifier(
|
||||
AppConfigs.NOTIFICATION_ICON_RESOURCE_NAME,
|
||||
AppConfigs.NOTIFICATION_ICON_RESOURCE_TYPE,
|
||||
context.packageName
|
||||
)
|
||||
config.APPLICATION_ICON = resId
|
||||
}
|
||||
|
||||
if (call.argument<Boolean>("proxy_only") == true) {
|
||||
AppConfigs.V2RAY_CONNECTION_MODE = AppConfigs.V2RAY_CONNECTION_MODES.PROXY_ONLY
|
||||
} else {
|
||||
AppConfigs.V2RAY_CONNECTION_MODE = AppConfigs.V2RAY_CONNECTION_MODES.VPN_TUN
|
||||
}
|
||||
|
||||
try {
|
||||
val jsonConfig = org.json.JSONObject(config.V2RAY_FULL_JSON_CONFIG)
|
||||
val outbounds = jsonConfig.optJSONArray("outbounds")
|
||||
if (outbounds != null && outbounds.length() > 0) {
|
||||
val firstOutbound = outbounds.getJSONObject(0)
|
||||
val settings = firstOutbound.optJSONObject("settings")
|
||||
val vnext = settings?.optJSONArray("vnext")
|
||||
if (vnext != null && vnext.length() > 0) {
|
||||
val server = vnext.getJSONObject(0)
|
||||
config.CONNECTED_V2RAY_SERVER_ADDRESS = server.optString("address", "")
|
||||
config.CONNECTED_V2RAY_SERVER_PORT = server.optInt("port", 0).toString()
|
||||
} else if (settings != null) {
|
||||
config.CONNECTED_V2RAY_SERVER_ADDRESS = settings.optString("address", "")
|
||||
config.CONNECTED_V2RAY_SERVER_PORT = settings.optInt("port", 0).toString()
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Optional metadata only; runtime config validation happens downstream.
|
||||
}
|
||||
|
||||
val intent = Intent(context, XrayVPNService::class.java)
|
||||
intent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.START_SERVICE)
|
||||
intent.putExtra("V2RAY_CONFIG", config)
|
||||
intent.putExtra("PROXY_ONLY", call.argument<Boolean>("proxy_only") ?: false)
|
||||
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
result.success(null)
|
||||
} catch (e: Exception) {
|
||||
if (expectedRuntimeToken == runtimeToken) expectedRuntimeToken = null
|
||||
result.error("START_FAILED", e.message ?: "Could not start VPN service", null)
|
||||
}
|
||||
}
|
||||
|
||||
"stopVless" -> {
|
||||
if (pendingStopResult != null) {
|
||||
result.error("STOP_IN_PROGRESS", "VPN shutdown is already in progress", null)
|
||||
return
|
||||
}
|
||||
|
||||
// A STOP without a generation token can kill a newer runtime after
|
||||
// UI/process churn. No token means this bridge owns nothing to stop.
|
||||
val token = expectedRuntimeToken
|
||||
if (token == null) {
|
||||
result.success(null)
|
||||
return
|
||||
}
|
||||
|
||||
val intent = Intent(context, XrayVPNService::class.java)
|
||||
intent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE)
|
||||
intent.putExtra("RUNTIME_TOKEN", token)
|
||||
|
||||
try {
|
||||
context.startService(intent)
|
||||
pendingStopResult = result
|
||||
mainHandler.removeCallbacks(stopTimeoutRunnable)
|
||||
mainHandler.postDelayed(stopTimeoutRunnable, 6_000L)
|
||||
} catch (e: Exception) {
|
||||
pendingStopResult = null
|
||||
mainHandler.removeCallbacks(stopTimeoutRunnable)
|
||||
result.error("STOP_FAILED", e.message ?: "Could not stop VPN service", null)
|
||||
}
|
||||
}
|
||||
|
||||
"setSessionDeadline" -> {
|
||||
val token = expectedRuntimeToken
|
||||
val remainingSeconds = call.argument<Number>("remainingSeconds")?.toLong()
|
||||
if (token == null) {
|
||||
result.error("NO_RUNTIME", "No active runtime token", null)
|
||||
return
|
||||
}
|
||||
if (remainingSeconds == null || remainingSeconds < 0) {
|
||||
result.error("INVALID_DEADLINE", "remainingSeconds must be non-negative", null)
|
||||
return
|
||||
}
|
||||
|
||||
val intent = Intent(context, XrayVPNService::class.java)
|
||||
intent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.UPDATE_SESSION_DEADLINE)
|
||||
intent.putExtra("RUNTIME_TOKEN", token)
|
||||
intent.putExtra("REMAINING_SECONDS", remainingSeconds)
|
||||
try {
|
||||
context.startService(intent)
|
||||
result.success(null)
|
||||
} catch (e: Exception) {
|
||||
result.error("DEADLINE_FAILED", e.message ?: "Could not update session deadline", null)
|
||||
}
|
||||
}
|
||||
|
||||
"initializeVless" -> {
|
||||
val iconResourceName = call.argument<String>("notificationIconResourceName")
|
||||
val iconResourceType = call.argument<String>("notificationIconResourceType")
|
||||
if (iconResourceName != null && iconResourceType != null) {
|
||||
AppConfigs.NOTIFICATION_ICON_RESOURCE_NAME = iconResourceName
|
||||
AppConfigs.NOTIFICATION_ICON_RESOURCE_TYPE = iconResourceType
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
"getCoreVersion" -> {
|
||||
executor.submit {
|
||||
try {
|
||||
val nativeLibraryDir = context.applicationInfo.nativeLibraryDir
|
||||
val xrayExecutable = File(nativeLibraryDir, "libxray.so")
|
||||
if (xrayExecutable.exists()) {
|
||||
val p = Runtime.getRuntime().exec(arrayOf(xrayExecutable.absolutePath, "-version"))
|
||||
val reader = BufferedReader(InputStreamReader(p.inputStream))
|
||||
val version = reader.readLine()
|
||||
result.success(version)
|
||||
} else {
|
||||
result.success("Xray not found")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
result.success("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// POST_NOTIFICATIONS without VpnService.prepare, for proxy mode.
|
||||
"requestNotificationPermission" -> {
|
||||
val currentActivity = activity
|
||||
if (currentActivity == null) {
|
||||
result.error("NO_ACTIVITY", "Activity is not attached", null)
|
||||
return
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
ActivityCompat.checkSelfPermission(
|
||||
currentActivity,
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
ActivityCompat.requestPermissions(
|
||||
currentActivity,
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
REQUEST_CODE_POST_NOTIFICATIONS,
|
||||
)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
"requestPermission" -> {
|
||||
val currentActivity = activity
|
||||
if (currentActivity == null) {
|
||||
result.error("NO_ACTIVITY", "Activity is not attached", null)
|
||||
return
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (ActivityCompat.checkSelfPermission(currentActivity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
ActivityCompat.requestPermissions(currentActivity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQUEST_CODE_POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
val request = VpnService.prepare(currentActivity)
|
||||
if (request != null) {
|
||||
pendingResult = result
|
||||
currentActivity.startActivityForResult(request, REQUEST_CODE_VPN_PERMISSION)
|
||||
} else {
|
||||
result.success(true)
|
||||
}
|
||||
}
|
||||
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerReceiver() {
|
||||
if (activity == null) return
|
||||
if (xrayReceiver == null) {
|
||||
xrayReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent == null || vpnStatusSink == null) return
|
||||
val state = intent.getSerializableExtra("STATE") as? AppConfigs.V2RAY_STATES
|
||||
val runtimeToken = intent.getStringExtra("RUNTIME_TOKEN").orEmpty()
|
||||
val runtimeReady = intent.getBooleanExtra("RUNTIME_READY", false)
|
||||
val duration = intent.getStringExtra("DURATION")
|
||||
val uploadSpeed = intent.getLongExtra("UPLOAD_SPEED", 0)
|
||||
val downloadSpeed = intent.getLongExtra("DOWNLOAD_SPEED", 0)
|
||||
val uploadTraffic = intent.getLongExtra("UPLOAD_TRAFFIC", 0)
|
||||
val downloadTraffic = intent.getLongExtra("DOWNLOAD_TRAFFIC", 0)
|
||||
|
||||
val currentToken = expectedRuntimeToken
|
||||
if (currentToken == null) {
|
||||
if (runtimeToken.isNotEmpty() &&
|
||||
(state == AppConfigs.V2RAY_STATES.V2RAY_CONNECTED ||
|
||||
state == AppConfigs.V2RAY_STATES.V2RAY_CONNECTING)
|
||||
) {
|
||||
expectedRuntimeToken = runtimeToken
|
||||
} else {
|
||||
return
|
||||
}
|
||||
} else if (runtimeToken != currentToken) {
|
||||
return
|
||||
}
|
||||
|
||||
val stateName = when (state) {
|
||||
AppConfigs.V2RAY_STATES.V2RAY_CONNECTED -> if (runtimeReady) "CONNECTED" else "CONNECTING"
|
||||
AppConfigs.V2RAY_STATES.V2RAY_CONNECTING -> "CONNECTING"
|
||||
else -> "DISCONNECTED"
|
||||
}
|
||||
|
||||
val data = ArrayList<String>()
|
||||
data.add(duration ?: "0")
|
||||
data.add(uploadSpeed.toString())
|
||||
data.add(downloadSpeed.toString())
|
||||
data.add(uploadTraffic.toString())
|
||||
data.add(downloadTraffic.toString())
|
||||
data.add(stateName)
|
||||
|
||||
if (state == AppConfigs.V2RAY_STATES.V2RAY_DISCONNECTED &&
|
||||
runtimeToken.isNotEmpty() && runtimeToken == expectedRuntimeToken
|
||||
) {
|
||||
expectedRuntimeToken = null
|
||||
mainHandler.removeCallbacks(stopTimeoutRunnable)
|
||||
pendingStopResult?.success(null)
|
||||
pendingStopResult = null
|
||||
}
|
||||
|
||||
vpnStatusSink?.success(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val filter = IntentFilter(AppConfigs.V2RAY_CONNECTION_INFO)
|
||||
val currentActivity = activity ?: return
|
||||
ContextCompat.registerReceiver(
|
||||
currentActivity,
|
||||
xrayReceiver,
|
||||
filter,
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
}
|
||||
|
||||
private fun unregisterReceiver() {
|
||||
val currentActivity = activity
|
||||
val receiver = xrayReceiver
|
||||
if (currentActivity != null && receiver != null) {
|
||||
try {
|
||||
currentActivity.unregisterReceiver(receiver)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Receiver was already detached during lifecycle cleanup", e)
|
||||
}
|
||||
}
|
||||
xrayReceiver = null
|
||||
}
|
||||
|
||||
private fun failPendingPermission(code: String, message: String) {
|
||||
pendingResult?.error(code, message, null)
|
||||
pendingResult = null
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
mainHandler.removeCallbacks(stopTimeoutRunnable)
|
||||
pendingStopResult?.error("ENGINE_DETACHED", "Flutter engine detached during VPN shutdown", null)
|
||||
pendingStopResult = null
|
||||
failPendingPermission("ENGINE_DETACHED", "Flutter engine detached during VPN permission request")
|
||||
unregisterReceiver()
|
||||
vpnControlMethod.setMethodCallHandler(null)
|
||||
vpnStatusEvent.setStreamHandler(null)
|
||||
executor.shutdown()
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activity = binding.activity
|
||||
binding.addActivityResultListener(this)
|
||||
if (vpnStatusSink != null) {
|
||||
registerReceiver()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
unregisterReceiver()
|
||||
activity = null
|
||||
}
|
||||
|
||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||
activity = binding.activity
|
||||
binding.addActivityResultListener(this)
|
||||
if (vpnStatusSink != null) {
|
||||
registerReceiver()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
failPendingPermission("NO_ACTIVITY", "Activity detached during VPN permission request")
|
||||
unregisterReceiver()
|
||||
activity = null
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||
if (requestCode == REQUEST_CODE_VPN_PERMISSION) {
|
||||
if (resultCode == Activity.RESULT_OK) {
|
||||
pendingResult?.success(true)
|
||||
} else {
|
||||
pendingResult?.success(false)
|
||||
}
|
||||
pendingResult = null
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
+700
@@ -0,0 +1,700 @@
|
||||
package com.github.tfox.flutter_vless.xray.core
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.os.CountDownTimer
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.github.tfox.flutter_vless.xray.dto.XrayConfig
|
||||
import com.github.tfox.flutter_vless.xray.service.XrayVPNService
|
||||
import com.github.tfox.flutter_vless.xray.utils.AppConfigs
|
||||
import com.github.tfox.flutter_vless.xray.utils.Utilities
|
||||
import org.json.JSONObject
|
||||
import org.json.JSONArray
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
/**
|
||||
* Manages the Xray Core process (libxray.so).
|
||||
*
|
||||
* This singleton object is responsible for:
|
||||
* 1. Generating the final Xray configuration file (config.json).
|
||||
* 2. Injecting necessary inbounds (SOCKS, HTTP, API) into the user-provided config.
|
||||
* 3. Starting and monitoring the Xray process.
|
||||
* 4. Collecting traffic statistics via the Xray API.
|
||||
* 5. Showing the persistent foreground notification.
|
||||
*/
|
||||
object XrayCoreManager {
|
||||
|
||||
private const val NOTIFICATION_ID = 1
|
||||
private const val TAG = "XrayCoreManager"
|
||||
|
||||
// The core's output is kept only for its first seconds, and only to explain
|
||||
// a non-zero exit. A core that has been up longer than this may be carrying
|
||||
// traffic, and its log can name destinations, which must never reach the
|
||||
// device log (rule 95) - so past the window the buffer is dropped.
|
||||
private const val STARTUP_LOG_WINDOW_MS = 10_000L
|
||||
private const val STARTUP_LOG_MAX_LINES = 40
|
||||
private var xrayProcess: Process? = null
|
||||
private var countDownTimer: CountDownTimer? = null
|
||||
private var seconds = 0
|
||||
|
||||
private fun deleteEphemeralConfigOnFailure(filesDir: File) {
|
||||
try {
|
||||
val config = File(filesDir, "config.json")
|
||||
if (config.exists() && !config.delete()) {
|
||||
Log.w(TAG, "Could not delete failed ephemeral Xray config")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not delete failed ephemeral Xray config", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun nextFreePort(preferredPort: Int, usedPorts: Set<Int>): Int {
|
||||
var port = preferredPort
|
||||
while (usedPorts.contains(port)) {
|
||||
port++
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
private fun uniqueInboundTag(inbounds: JSONArray, preferredTag: String): String {
|
||||
val tags = mutableSetOf<String>()
|
||||
for (i in 0 until inbounds.length()) {
|
||||
val tag = inbounds.optJSONObject(i)?.optString("tag").orEmpty()
|
||||
if (tag.isNotEmpty()) tags.add(tag)
|
||||
}
|
||||
|
||||
if (!tags.contains(preferredTag)) return preferredTag
|
||||
|
||||
var index = 1
|
||||
var candidate = "${preferredTag}_$index"
|
||||
while (tags.contains(candidate)) {
|
||||
index++
|
||||
candidate = "${preferredTag}_$index"
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
private fun normalizeRuntimeConfig(value: Any?): Any? {
|
||||
return when (value) {
|
||||
is JSONObject -> {
|
||||
val normalized = JSONObject()
|
||||
val aliases = mapOf(
|
||||
"xHTTPSettings" to "xhttpSettings",
|
||||
"httpUpgradeSettings" to "httpupgradeSettings",
|
||||
"splitHTTPSettings" to "splithttpSettings"
|
||||
)
|
||||
val keys = value.keys()
|
||||
while (keys.hasNext()) {
|
||||
val key = keys.next()
|
||||
if (key == "allowInsecure") continue
|
||||
|
||||
val targetKey = aliases[key] ?: key
|
||||
if (aliases.containsKey(key) && value.has(targetKey)) continue
|
||||
|
||||
val normalizedValue = normalizeRuntimeConfig(value.opt(key))
|
||||
if (targetKey == "network" && normalizedValue is String) {
|
||||
normalized.put(targetKey, normalizedValue.lowercase())
|
||||
} else {
|
||||
normalized.put(targetKey, normalizedValue)
|
||||
}
|
||||
}
|
||||
normalized
|
||||
}
|
||||
is JSONArray -> {
|
||||
val normalized = JSONArray()
|
||||
for (i in 0 until value.length()) {
|
||||
normalized.put(normalizeRuntimeConfig(value.opt(i)))
|
||||
}
|
||||
normalized
|
||||
}
|
||||
else -> value
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeVlessOutbounds(configJson: JSONObject) {
|
||||
val outbounds = configJson.optJSONArray("outbounds") ?: return
|
||||
for (i in 0 until outbounds.length()) {
|
||||
val outbound = outbounds.optJSONObject(i) ?: continue
|
||||
if (outbound.optString("protocol") != "vless") continue
|
||||
|
||||
val settings = outbound.optJSONObject("settings") ?: continue
|
||||
if (settings.has("vnext")) continue
|
||||
|
||||
val address = settings.optString("address")
|
||||
val id = settings.optString("id")
|
||||
val port = settings.optInt("port", 0)
|
||||
if (address.isEmpty() || id.isEmpty() || port <= 0) continue
|
||||
|
||||
val user = JSONObject()
|
||||
user.put("id", id)
|
||||
user.put("encryption", settings.optString("encryption", "none"))
|
||||
user.put("flow", settings.optString("flow", ""))
|
||||
user.put("level", settings.optInt("level", 8))
|
||||
|
||||
val server = JSONObject()
|
||||
server.put("address", address)
|
||||
server.put("port", port)
|
||||
server.put("users", JSONArray().put(user))
|
||||
|
||||
val normalizedSettings = JSONObject()
|
||||
normalizedSettings.put("vnext", JSONArray().put(server))
|
||||
outbound.put("settings", normalizedSettings)
|
||||
Log.d(TAG, "Normalized flat VLESS outbound settings for $address:$port")
|
||||
}
|
||||
}
|
||||
|
||||
private fun sanitizeLogPaths(configJson: JSONObject, filesDir: File) {
|
||||
val log = configJson.optJSONObject("log") ?: return
|
||||
|
||||
// The VPN client never needs destination/access logs. A server-provided
|
||||
// config must not silently enable browsing metadata collection locally.
|
||||
log.remove("access")
|
||||
try { File(filesDir, "access.log").delete() } catch (_: Exception) {}
|
||||
|
||||
val errorPath = log.optString("error")
|
||||
if (errorPath.isNotEmpty()) {
|
||||
log.put("error", File(filesDir, "error.log").absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun buildRuntimeConfigJson(config: XrayConfig, filesDir: File): JSONObject {
|
||||
val configJson = normalizeRuntimeConfig(
|
||||
JSONObject(config.V2RAY_FULL_JSON_CONFIG)
|
||||
) as JSONObject
|
||||
normalizeVlessOutbounds(configJson)
|
||||
sanitizeLogPaths(configJson, filesDir)
|
||||
|
||||
// ReVolt needs only its authenticated local SOCKS ingress.
|
||||
val inbounds = configJson.optJSONArray("inbounds") ?: JSONArray()
|
||||
var secureSocksEnabled = false
|
||||
for (i in 0 until inbounds.length()) {
|
||||
val inbound = inbounds.optJSONObject(i) ?: continue
|
||||
if (inbound.optString("tag") != "revolt-secure-socks") continue
|
||||
|
||||
val settings = inbound.optJSONObject("settings")
|
||||
?: throw IllegalStateException("Secure SOCKS5 settings are missing")
|
||||
val users = settings.optJSONArray("users")
|
||||
?: throw IllegalStateException("Secure SOCKS5 users are missing")
|
||||
val account = users.optJSONObject(0)
|
||||
?: throw IllegalStateException("Secure SOCKS5 account is missing")
|
||||
val port = inbound.optInt("port", -1)
|
||||
|
||||
if (inbound.optString("protocol") != "socks" ||
|
||||
inbound.optString("listen") != "127.0.0.1" ||
|
||||
port <= 1024 || port > 65535 ||
|
||||
settings.optString("auth") != "password" ||
|
||||
account.optString("user").isEmpty() ||
|
||||
account.optString("pass").isEmpty()
|
||||
) {
|
||||
throw IllegalStateException("Secure SOCKS5 session is invalid")
|
||||
}
|
||||
|
||||
config.LOCAL_SOCKS5_PORT = port
|
||||
config.LOCAL_HTTP_PORT = 0
|
||||
secureSocksEnabled = true
|
||||
break
|
||||
}
|
||||
|
||||
val usedPorts = mutableSetOf<Int>()
|
||||
var hasSocksOnLocalPort = false
|
||||
var hasHttpOnLocalPort = false
|
||||
for (i in 0 until inbounds.length()) {
|
||||
val inbound = inbounds.getJSONObject(i)
|
||||
val protocol = inbound.optString("protocol")
|
||||
val port = inbound.optInt("port", -1)
|
||||
if (port > 0) usedPorts.add(port)
|
||||
if (protocol == "socks" && port == config.LOCAL_SOCKS5_PORT) hasSocksOnLocalPort = true
|
||||
if (protocol == "http" && port == config.LOCAL_HTTP_PORT) hasHttpOnLocalPort = true
|
||||
}
|
||||
|
||||
if (!hasSocksOnLocalPort) {
|
||||
if (usedPorts.contains(config.LOCAL_SOCKS5_PORT)) {
|
||||
config.LOCAL_SOCKS5_PORT = nextFreePort(config.LOCAL_SOCKS5_PORT, usedPorts)
|
||||
}
|
||||
val socksInbound = JSONObject()
|
||||
socksInbound.put("tag", uniqueInboundTag(inbounds, "socks"))
|
||||
socksInbound.put("port", config.LOCAL_SOCKS5_PORT)
|
||||
socksInbound.put("listen", "127.0.0.1")
|
||||
socksInbound.put("protocol", "socks")
|
||||
socksInbound.put("settings", JSONObject().put("auth", "noauth").put("udp", true))
|
||||
socksInbound.put(
|
||||
"sniffing",
|
||||
JSONObject().put("enabled", true).put("destOverride", JSONArray().put("http").put("tls"))
|
||||
)
|
||||
inbounds.put(socksInbound)
|
||||
usedPorts.add(config.LOCAL_SOCKS5_PORT)
|
||||
Log.d(TAG, "Injected SOCKS inbound on port ${config.LOCAL_SOCKS5_PORT}")
|
||||
}
|
||||
|
||||
if (!secureSocksEnabled && !hasHttpOnLocalPort) {
|
||||
if (usedPorts.contains(config.LOCAL_HTTP_PORT)) {
|
||||
config.LOCAL_HTTP_PORT = nextFreePort(config.LOCAL_HTTP_PORT, usedPorts)
|
||||
}
|
||||
val httpInbound = JSONObject()
|
||||
httpInbound.put("tag", uniqueInboundTag(inbounds, "http"))
|
||||
httpInbound.put("port", config.LOCAL_HTTP_PORT)
|
||||
httpInbound.put("listen", "127.0.0.1")
|
||||
httpInbound.put("protocol", "http")
|
||||
inbounds.put(httpInbound)
|
||||
usedPorts.add(config.LOCAL_HTTP_PORT)
|
||||
Log.d(TAG, "Injected HTTP inbound on port ${config.LOCAL_HTTP_PORT}")
|
||||
}
|
||||
|
||||
return configJson
|
||||
}
|
||||
|
||||
internal fun buildDelayConfigJson(configJson: String, proxyPort: Int, filesDir: File): Pair<JSONObject, Int> {
|
||||
val delayConfig = XrayConfig(
|
||||
V2RAY_FULL_JSON_CONFIG = configJson,
|
||||
LOCAL_SOCKS5_PORT = proxyPort,
|
||||
LOCAL_HTTP_PORT = proxyPort + 1,
|
||||
LOCAL_API_PORT = proxyPort + 2
|
||||
)
|
||||
val runtimeJson = buildRuntimeConfigJson(delayConfig, filesDir)
|
||||
return Pair(runtimeJson, delayConfig.LOCAL_SOCKS5_PORT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the Xray Core process.
|
||||
*
|
||||
* @param context The service context (needed for file access and notifications).
|
||||
* @param config The configuration object containing the user's settings.
|
||||
* @return true if started successfully, false otherwise.
|
||||
*/
|
||||
fun startCore(context: Service, config: XrayConfig): Boolean {
|
||||
AppConfigs.RUNTIME_READY = false
|
||||
AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTING
|
||||
AppConfigs.V2RAY_CONFIG = config
|
||||
|
||||
// 1. Prepare the configuration file
|
||||
val configFilesDir = context.filesDir
|
||||
|
||||
try {
|
||||
val configJson = buildRuntimeConfigJson(config, configFilesDir)
|
||||
val configFile = File(context.filesDir, "config.json")
|
||||
configFile.writeText(configJson.toString())
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to write config file", e)
|
||||
deleteEphemeralConfigOnFailure(configFilesDir)
|
||||
return false
|
||||
}
|
||||
|
||||
// 2. Find Xray executable (libxray.so)
|
||||
val nativeLibraryDir = context.applicationInfo.nativeLibraryDir
|
||||
val xrayExecutable = File(nativeLibraryDir, "libxray.so")
|
||||
if (!xrayExecutable.exists()) {
|
||||
Log.e(TAG, "Xray executable not found at ${xrayExecutable.absolutePath}")
|
||||
deleteEphemeralConfigOnFailure(configFilesDir)
|
||||
return false
|
||||
}
|
||||
|
||||
// 3. Prepare assets (geoip, geosite)
|
||||
try {
|
||||
Utilities.copyAssets(context)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to prepare Xray assets", e)
|
||||
deleteEphemeralConfigOnFailure(configFilesDir)
|
||||
return false
|
||||
}
|
||||
|
||||
// 4. Run Xray
|
||||
try {
|
||||
val cmd = listOf(
|
||||
xrayExecutable.absolutePath,
|
||||
"-config", File(configFilesDir, "config.json").absolutePath
|
||||
)
|
||||
val pb = ProcessBuilder(cmd)
|
||||
pb.directory(configFilesDir)
|
||||
pb.redirectErrorStream(true)
|
||||
|
||||
// Set environment variables (XRAY_LOCATION_ASSET is crucial for finding geoip/geosite)
|
||||
val env = pb.environment()
|
||||
env["XRAY_LOCATION_ASSET"] = Utilities.getUserAssetsPath(context)
|
||||
|
||||
xrayProcess = pb.start()
|
||||
Thread.sleep(300)
|
||||
if (xrayProcess?.isAlive != true) {
|
||||
val output = xrayProcess?.inputStream?.bufferedReader()?.readText().orEmpty()
|
||||
Log.e(TAG, "Xray process exited during startup. Output: $output")
|
||||
xrayProcess = null
|
||||
AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_DISCONNECTED
|
||||
deleteEphemeralConfigOnFailure(configFilesDir)
|
||||
return false
|
||||
}
|
||||
|
||||
// config.json contains per-session SOCKS credentials. Xray has
|
||||
// consumed it after the startup liveness check, so remove it now.
|
||||
try {
|
||||
val ephemeralConfig = File(configFilesDir, "config.json")
|
||||
if (ephemeralConfig.exists() && !ephemeralConfig.delete()) {
|
||||
Log.w(TAG, "Could not delete ephemeral Xray config")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not delete ephemeral Xray config", e)
|
||||
}
|
||||
|
||||
AppConfigs.RUNTIME_READY = false
|
||||
AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTING
|
||||
startTimer(context)
|
||||
showNotification(context, config)
|
||||
sendStatusBroadcast(context, config)
|
||||
|
||||
val process = xrayProcess ?: return false
|
||||
// Monitor process in a separate thread to detect crash
|
||||
Thread {
|
||||
try {
|
||||
val startedAt = System.currentTimeMillis()
|
||||
val startupLog = ArrayDeque<String>()
|
||||
process.inputStream.bufferedReader().use { reader ->
|
||||
reader.forEachLine { line ->
|
||||
if (System.currentTimeMillis() - startedAt <= STARTUP_LOG_WINDOW_MS) {
|
||||
if (startupLog.size >= STARTUP_LOG_MAX_LINES) startupLog.removeFirst()
|
||||
startupLog.addLast(line)
|
||||
} else if (startupLog.isNotEmpty()) {
|
||||
startupLog.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val exitCode = process.waitFor()
|
||||
if (exitCode != 0 && startupLog.isNotEmpty()) {
|
||||
Log.e(
|
||||
TAG,
|
||||
"Xray process exited with code $exitCode; startup output:\n" +
|
||||
startupLog.joinToString("\n")
|
||||
)
|
||||
} else {
|
||||
Log.e(TAG, "Xray process exited with code $exitCode")
|
||||
}
|
||||
if (xrayProcess === process &&
|
||||
AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN == config.RUNTIME_TOKEN &&
|
||||
(AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTED ||
|
||||
AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTING)
|
||||
) {
|
||||
// Hold the Android TUN while the core is recovered. Killing
|
||||
// the VPN service here would allow direct-network fallback.
|
||||
xrayProcess = null
|
||||
AppConfigs.RUNTIME_READY = false
|
||||
AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTING
|
||||
sendStatusBroadcast(context, config)
|
||||
if (context is XrayVPNService) {
|
||||
context.handleXrayCoreExit(config)
|
||||
} else {
|
||||
stopCore(context)
|
||||
}
|
||||
}
|
||||
} catch (e: java.io.InterruptedIOException) {
|
||||
// Expected when stopping
|
||||
} catch (e: InterruptedException) {
|
||||
// Expected when stopping
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading xray output", e)
|
||||
}
|
||||
}.start()
|
||||
|
||||
return true
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start Xray process", e)
|
||||
// The process may already be running: pb.start() succeeds before
|
||||
// anything below it can throw. Leaving it alive leaked one live
|
||||
// core per recovery attempt. Clear the field BEFORE destroying so
|
||||
// the monitor thread's `xrayProcess === process` guard fails and
|
||||
// recovery is not re-entered for a core we are killing here.
|
||||
val orphan = xrayProcess
|
||||
xrayProcess = null
|
||||
try {
|
||||
orphan?.destroy()
|
||||
} catch (destroyError: Exception) {
|
||||
Log.w(TAG, "Failed to destroy partially started Xray process", destroyError)
|
||||
}
|
||||
deleteEphemeralConfigOnFailure(configFilesDir)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the Xray Core process and cleans up notifications.
|
||||
*/
|
||||
fun stopCore(context: Service, confirmationToken: String? = AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN) {
|
||||
try {
|
||||
xrayProcess?.destroy()
|
||||
xrayProcess = null
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to destroy Xray process", e)
|
||||
}
|
||||
|
||||
AppConfigs.RUNTIME_READY = false
|
||||
AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_DISCONNECTED
|
||||
stopTimer()
|
||||
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.cancel(NOTIFICATION_ID)
|
||||
sendDisconnectedBroadcast(context, confirmationToken.orEmpty())
|
||||
AppConfigs.V2RAY_CONFIG = null
|
||||
}
|
||||
|
||||
fun markRuntimeConnecting(context: Context, config: XrayConfig) {
|
||||
if (AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN != config.RUNTIME_TOKEN) return
|
||||
AppConfigs.RUNTIME_READY = false
|
||||
AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTING
|
||||
sendStatusBroadcast(context, config)
|
||||
}
|
||||
|
||||
fun markRuntimeReady(context: Service, config: XrayConfig) {
|
||||
if (AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN != config.RUNTIME_TOKEN) return
|
||||
AppConfigs.RUNTIME_READY = true
|
||||
AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTED
|
||||
showNotification(context, config)
|
||||
sendStatusBroadcast(context, config)
|
||||
}
|
||||
|
||||
fun isXrayRunning(): Boolean {
|
||||
// Check state instead of process because VPN runs in separate service process
|
||||
return AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTED ||
|
||||
AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTING
|
||||
}
|
||||
|
||||
// CountDownTimer builds a Handler, so it only constructs on a looper thread.
|
||||
// Core recovery runs on a bare Thread (XrayVPNService.handleXrayCoreExit),
|
||||
// where this threw "Can't create handler inside thread ... that has not
|
||||
// called Looper.prepare()" and took the whole restart down with it.
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
private fun runOnMain(block: () -> Unit) {
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) block() else mainHandler.post(block)
|
||||
}
|
||||
|
||||
private fun startTimer(context: Context) {
|
||||
// Captured now: a stop that lands while the post is queued must not
|
||||
// leave a timer ticking for a generation that is already gone.
|
||||
val token = AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN
|
||||
runOnMain { startTimerOnMain(context, token) }
|
||||
}
|
||||
|
||||
private fun startTimerOnMain(context: Context, token: String?) {
|
||||
if (AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN != token) return
|
||||
countDownTimer?.cancel()
|
||||
seconds = 0
|
||||
countDownTimer = object : CountDownTimer(Long.MAX_VALUE, 1000) {
|
||||
override fun onTick(millisUntilFinished: Long) {
|
||||
seconds++
|
||||
val intent = Intent(AppConfigs.V2RAY_CONNECTION_INFO).setPackage(context.packageName)
|
||||
intent.putExtra("STATE", AppConfigs.V2RAY_STATE)
|
||||
intent.putExtra("RUNTIME_TOKEN", AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN.orEmpty())
|
||||
intent.putExtra("RUNTIME_READY", AppConfigs.RUNTIME_READY)
|
||||
intent.putExtra("DURATION", seconds.toString())
|
||||
|
||||
intent.putExtra("UPLOAD_SPEED", 0L)
|
||||
intent.putExtra("DOWNLOAD_SPEED", 0L)
|
||||
intent.putExtra("UPLOAD_TRAFFIC", 0L)
|
||||
intent.putExtra("DOWNLOAD_TRAFFIC", 0L)
|
||||
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
|
||||
override fun onFinish() {}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun stopTimer() {
|
||||
runOnMain {
|
||||
countDownTimer?.cancel()
|
||||
countDownTimer = null
|
||||
seconds = 0
|
||||
}
|
||||
}
|
||||
|
||||
fun sendStatusBroadcast(context: Context, config: XrayConfig) {
|
||||
val intent = Intent(AppConfigs.V2RAY_CONNECTION_INFO).setPackage(context.packageName)
|
||||
intent.putExtra("STATE", AppConfigs.V2RAY_STATE)
|
||||
intent.putExtra("RUNTIME_TOKEN", config.RUNTIME_TOKEN)
|
||||
intent.putExtra("RUNTIME_READY", AppConfigs.RUNTIME_READY)
|
||||
intent.putExtra("DURATION", seconds.toString())
|
||||
intent.putExtra("UPLOAD_SPEED", 0L)
|
||||
intent.putExtra("DOWNLOAD_SPEED", 0L)
|
||||
intent.putExtra("UPLOAD_TRAFFIC", 0L)
|
||||
intent.putExtra("DOWNLOAD_TRAFFIC", 0L)
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
|
||||
private fun sendDisconnectedBroadcast(context: Context, runtimeToken: String) {
|
||||
val intent = Intent(AppConfigs.V2RAY_CONNECTION_INFO).setPackage(context.packageName)
|
||||
intent.putExtra("STATE", AppConfigs.V2RAY_STATES.V2RAY_DISCONNECTED)
|
||||
intent.putExtra("RUNTIME_TOKEN", runtimeToken)
|
||||
intent.putExtra("RUNTIME_READY", false)
|
||||
intent.putExtra("DURATION", "0")
|
||||
intent.putExtra("UPLOAD_SPEED", 0L)
|
||||
intent.putExtra("DOWNLOAD_SPEED", 0L)
|
||||
intent.putExtra("UPLOAD_TRAFFIC", 0L)
|
||||
intent.putExtra("DOWNLOAD_TRAFFIC", 0L)
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
|
||||
private fun showNotification(context: Service, config: XrayConfig) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (ActivityCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val channelId = createNotificationChannel(context, config.APPLICATION_NAME)
|
||||
|
||||
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
||||
launchIntent?.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
|
||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_UPDATE_CURRENT
|
||||
val contentPendingIntent = PendingIntent.getActivity(context, 0, launchIntent, flags)
|
||||
|
||||
val stopIntent = Intent(context, XrayVPNService::class.java)
|
||||
stopIntent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE)
|
||||
stopIntent.putExtra("RUNTIME_TOKEN", config.RUNTIME_TOKEN)
|
||||
val stopPendingIntent = PendingIntent.getService(context, 0, stopIntent, flags)
|
||||
val smallIcon = if (config.APPLICATION_ICON != 0) config.APPLICATION_ICON else android.R.drawable.ic_dialog_info
|
||||
|
||||
val builder = NotificationCompat.Builder(context, channelId)
|
||||
.setSmallIcon(smallIcon)
|
||||
.setContentTitle(config.REMARK)
|
||||
.setContentText(if (AppConfigs.RUNTIME_READY) "Connected" else "Connecting")
|
||||
.addAction(0, config.NOTIFICATION_DISCONNECT_BUTTON_NAME, stopPendingIntent)
|
||||
.setContentIntent(contentPendingIntent)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setOngoing(true)
|
||||
.setShowWhen(true)
|
||||
|
||||
context.startForeground(NOTIFICATION_ID, builder.build())
|
||||
}
|
||||
|
||||
fun getConnectedV2rayServerDelay(context: Context, url: String): Long {
|
||||
// Use the configured SOCKS port (default 10807)
|
||||
val port = AppConfigs.V2RAY_CONFIG?.LOCAL_SOCKS5_PORT ?: 10807
|
||||
Log.d(TAG, "getConnectedV2rayServerDelay: Testing delay to $url via SOCKS port $port")
|
||||
|
||||
return try {
|
||||
val start = System.currentTimeMillis()
|
||||
val proxy = java.net.Proxy(java.net.Proxy.Type.SOCKS, java.net.InetSocketAddress("127.0.0.1", port))
|
||||
val connection = java.net.URL(url).openConnection(proxy) as java.net.HttpURLConnection
|
||||
connection.connectTimeout = 5000
|
||||
connection.readTimeout = 5000
|
||||
connection.requestMethod = "HEAD"
|
||||
val responseCode = connection.responseCode
|
||||
val end = System.currentTimeMillis()
|
||||
val delay = end - start
|
||||
connection.disconnect()
|
||||
Log.d(TAG, "getConnectedV2rayServerDelay: Success! Response code: $responseCode, Delay: ${delay}ms")
|
||||
delay
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "getConnectedV2rayServerDelay: Failed to measure delay (VPN may not be running)", e)
|
||||
-1L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure delay for a server configuration (when not connected).
|
||||
* Temporarily starts Xray with the provided config, measures delay, then stops it.
|
||||
*/
|
||||
fun getServerDelay(context: Context, configJson: String, url: String): Long {
|
||||
Log.d(TAG, "getServerDelay: Starting temporary Xray instance")
|
||||
|
||||
var tempProcess: Process? = null
|
||||
try {
|
||||
// Find a random free port to avoid conflict with running VPN
|
||||
val freePort = try {
|
||||
val socket = java.net.ServerSocket(0)
|
||||
val port = socket.localPort
|
||||
socket.close()
|
||||
port
|
||||
} catch (e: Exception) {
|
||||
10806 // Fallback
|
||||
}
|
||||
|
||||
val (json, socksPort) = buildDelayConfigJson(configJson, freePort, context.filesDir)
|
||||
Log.d(TAG, "getServerDelay: Using SOCKS port $socksPort")
|
||||
|
||||
// Write temp config file
|
||||
val tempConfigFile = File(context.filesDir, "temp_delay_config.json")
|
||||
tempConfigFile.writeText(json.toString())
|
||||
|
||||
// Copy assets
|
||||
Utilities.copyAssets(context)
|
||||
|
||||
// Start Xray process
|
||||
val xrayExecutable = File(context.applicationInfo.nativeLibraryDir, "libxray.so")
|
||||
if (!xrayExecutable.exists()) {
|
||||
Log.e(TAG, "getServerDelay: Xray executable not found")
|
||||
return -1L
|
||||
}
|
||||
|
||||
val cmd = listOf(
|
||||
xrayExecutable.absolutePath,
|
||||
"-config", tempConfigFile.absolutePath
|
||||
)
|
||||
|
||||
val pb = ProcessBuilder(cmd)
|
||||
pb.directory(context.filesDir)
|
||||
val env = pb.environment()
|
||||
env["XRAY_LOCATION_ASSET"] = Utilities.getUserAssetsPath(context)
|
||||
|
||||
tempProcess = pb.start()
|
||||
|
||||
// Wait a bit for Xray to start
|
||||
Thread.sleep(1000)
|
||||
|
||||
// Measure delay
|
||||
val delay = try {
|
||||
val start = System.currentTimeMillis()
|
||||
val proxy = java.net.Proxy(java.net.Proxy.Type.SOCKS, java.net.InetSocketAddress("127.0.0.1", socksPort))
|
||||
val connection = java.net.URL(url).openConnection(proxy) as java.net.HttpURLConnection
|
||||
connection.connectTimeout = 5000
|
||||
connection.readTimeout = 5000
|
||||
connection.requestMethod = "HEAD"
|
||||
val responseCode = connection.responseCode
|
||||
val end = System.currentTimeMillis()
|
||||
connection.disconnect()
|
||||
val result = end - start
|
||||
Log.d(TAG, "getServerDelay: Success! Response code: $responseCode, Delay: ${result}ms")
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "getServerDelay: Failed to measure delay", e)
|
||||
-1L
|
||||
}
|
||||
|
||||
// Stop temp process
|
||||
tempProcess?.destroy()
|
||||
tempConfigFile.delete()
|
||||
|
||||
return delay
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "getServerDelay: Error starting temp Xray", e)
|
||||
tempProcess?.destroy()
|
||||
return -1L
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannel(context: Context, appName: String): String {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channelId = "REVOLT_VPN_SERVICE"
|
||||
val channelName = "$appName Background Service"
|
||||
val channel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW)
|
||||
channel.lightColor = Color.BLUE
|
||||
channel.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.createNotificationChannel(channel)
|
||||
return channelId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package com.github.tfox.flutter_vless.xray.dto
|
||||
|
||||
import java.io.Serializable
|
||||
import java.util.ArrayList
|
||||
|
||||
/**
|
||||
* Data class representing the Xray configuration and app settings.
|
||||
*
|
||||
* This class is passed from Flutter to Android and contains:
|
||||
* 1. The full Xray JSON configuration (V2RAY_FULL_JSON_CONFIG).
|
||||
* 2. App-specific settings like blocked apps, notification text, etc.
|
||||
* 3. Connection parameters (server address, ports).
|
||||
*/
|
||||
data class XrayConfig(
|
||||
/** The IP address of the connected server (used to exclude it from VPN routing). */
|
||||
var CONNECTED_V2RAY_SERVER_ADDRESS: String = "",
|
||||
|
||||
/** The port of the connected server. */
|
||||
var CONNECTED_V2RAY_SERVER_PORT: String = "",
|
||||
|
||||
/** Local port for SOCKS5 proxy (default: 10807). used by tun2socks. */
|
||||
var LOCAL_SOCKS5_PORT: Int = 10807,
|
||||
|
||||
/** Local port for HTTP proxy (default: 10808). */
|
||||
var LOCAL_HTTP_PORT: Int = 10808,
|
||||
|
||||
/** Local port for Xray API (stats, etc.) (default: 10809). */
|
||||
var LOCAL_API_PORT: Int = 10809,
|
||||
|
||||
/** Timeout for connections in milliseconds. */
|
||||
var ALLOWED_TIMEOUT_MS: Int = 0,
|
||||
|
||||
/** List of subnets to bypass (not routed through VPN). */
|
||||
var BYPASS_SUBNETS: ArrayList<String> = ArrayList(),
|
||||
|
||||
/** List of app package names to exclude from VPN. */
|
||||
var BLOCKED_APPS: ArrayList<String> = ArrayList(),
|
||||
|
||||
/** The raw Xray configuration JSON string. */
|
||||
var V2RAY_FULL_JSON_CONFIG: String = "",
|
||||
|
||||
/** Whether to enable traffic statistics collection. */
|
||||
var ENABLE_TRAFFIC_STATICS: Boolean = false,
|
||||
|
||||
/** Remark/Name of the server configuration. */
|
||||
var REMARK: String = "",
|
||||
|
||||
/** Name of the application (displayed in notification). */
|
||||
var APPLICATION_NAME: String = "Flutter Vless",
|
||||
|
||||
/** Resource ID of the notification icon. */
|
||||
var APPLICATION_ICON: Int = 0,
|
||||
|
||||
/** Resource name of the notification icon (e.g. "ic_notification"). */
|
||||
var NOTIFICATION_ICON_RESOURCE_NAME: String = "",
|
||||
|
||||
/** Resource type of the notification icon (e.g. "drawable"). */
|
||||
var NOTIFICATION_ICON_RESOURCE_TYPE: String = "",
|
||||
|
||||
/** Text for the disconnect button in the notification. */
|
||||
var NOTIFICATION_DISCONNECT_BUTTON_NAME: String = "Disconnect",
|
||||
|
||||
/** Domain resolution strategy (e.g. "IPIfNonMatch"). */
|
||||
var DOMAIN_STRATEGY: String = "",
|
||||
|
||||
/** Routing domain strategy. */
|
||||
var ROUTING_DOMAIN_STRATEGY: String = "",
|
||||
|
||||
/** Unique generation token used to reject stale native runtime events. */
|
||||
var RUNTIME_TOKEN: String = ""
|
||||
) : Serializable
|
||||
+827
@@ -0,0 +1,827 @@
|
||||
package com.github.tfox.flutter_vless.xray.service
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.LocalSocket
|
||||
import android.net.LocalSocketAddress
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import com.github.tfox.flutter_vless.xray.core.XrayCoreManager
|
||||
import com.github.tfox.flutter_vless.xray.dto.XrayConfig
|
||||
import com.github.tfox.flutter_vless.xray.utils.AppConfigs
|
||||
import com.github.tfox.flutter_vless.xray.utils.RevoltAlerts
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Android VPN Service implementation.
|
||||
*
|
||||
* This service owns the TUN interface and keeps runtime state generation-scoped.
|
||||
* Session deadlines are enforced both by an in-process monotonic timer and by
|
||||
* AlarmManager, while only the non-secret token/deadline pair is persisted.
|
||||
* Per-session VLESS/SOCKS credentials remain memory-only.
|
||||
*/
|
||||
class XrayVPNService : VpnService() {
|
||||
|
||||
private var mInterface: ParcelFileDescriptor? = null
|
||||
private var tun2socksProcess: Process? = null
|
||||
@Volatile private var isRunning = false
|
||||
@Volatile private var recoveringXray = false
|
||||
@Volatile private var recoveringTun2socks = false
|
||||
private var tun2socksRecoveryAttempt = 0
|
||||
private var currentConfig: XrayConfig? = null
|
||||
private var currentProxyOnly = false
|
||||
private var shuttingDownIntentionally = false
|
||||
|
||||
private val deadlineHandler = Handler(Looper.getMainLooper())
|
||||
private var sessionDeadlineElapsed: Long? = null
|
||||
private var sessionDeadlineToken: String? = null
|
||||
private val sessionDeadlineRunnable = Runnable { enforceSessionDeadline() }
|
||||
|
||||
// The warning's own backstop, mirroring the deadline's. The alarm alone is
|
||||
// not enough: it is inexact on purpose (no SCHEDULE_EXACT_ALARM), and with
|
||||
// the app closed on a dozing device it was measured never arriving at all,
|
||||
// while the deadline's Handler fired within a second of its target.
|
||||
private val sessionWarningRunnable = Runnable {
|
||||
handleSessionWarningAlarm(sessionDeadlineToken.orEmpty())
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent == null) {
|
||||
if (isRunning && currentConfig != null) return START_REDELIVER_INTENT
|
||||
|
||||
// Session credentials intentionally stay memory-only. If Android
|
||||
// cannot redeliver the original START intent, fail closed instead
|
||||
// of rebuilding a tunnel from sensitive material stored on disk.
|
||||
clearPersistedDeadline()
|
||||
cancelSessionExpiryAlarm()
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
if (intent.getBooleanExtra(EXTRA_SESSION_EXPIRED, false)) {
|
||||
enforcePersistedDeadline(intent.getStringExtra("RUNTIME_TOKEN").orEmpty())
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
// Must stay above the command dispatch: an intent with no COMMAND
|
||||
// falls through to the final else and stops the tunnel.
|
||||
if (intent.getBooleanExtra(EXTRA_SESSION_WARNING, false)) {
|
||||
handleSessionWarningAlarm(intent.getStringExtra("RUNTIME_TOKEN").orEmpty())
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
val command = if (Build.VERSION.SDK_INT >= 33) {
|
||||
intent.getSerializableExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent.getSerializableExtra("COMMAND") as? AppConfigs.V2RAY_SERVICE_COMMANDS
|
||||
}
|
||||
|
||||
if (command == AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE) {
|
||||
val requestedToken = intent.getStringExtra("RUNTIME_TOKEN").orEmpty()
|
||||
val activeToken = currentConfig?.RUNTIME_TOKEN.orEmpty()
|
||||
if (requestedToken.isEmpty() ||
|
||||
(activeToken.isNotEmpty() && requestedToken != activeToken)
|
||||
) {
|
||||
Log.w(TAG, "Ignoring STOP_SERVICE without matching runtime generation")
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
stopAll(requestedToken, userRequested = true)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
if (command == AppConfigs.V2RAY_SERVICE_COMMANDS.UPDATE_SESSION_DEADLINE) {
|
||||
updateSessionDeadline(intent)
|
||||
return if (isRunning) START_REDELIVER_INTENT else START_NOT_STICKY
|
||||
}
|
||||
|
||||
createNotificationChannel()
|
||||
val notification = createNotification("VPN Service Running")
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
startForeground(1, notification, FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
|
||||
} else {
|
||||
startForeground(1, notification)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start foreground", e)
|
||||
clearPersistedDeadline()
|
||||
cancelSessionExpiryAlarm()
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
if (command == AppConfigs.V2RAY_SERVICE_COMMANDS.START_SERVICE) {
|
||||
val config = if (Build.VERSION.SDK_INT >= 33) {
|
||||
intent.getSerializableExtra("V2RAY_CONFIG", XrayConfig::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent.getSerializableExtra("V2RAY_CONFIG") as? XrayConfig
|
||||
}
|
||||
|
||||
if (config != null) {
|
||||
if (XrayCoreManager.isXrayRunning()) {
|
||||
Log.w(TAG, "Stopping stale Xray core before restart")
|
||||
XrayCoreManager.stopCore(this)
|
||||
}
|
||||
cleanup(clearDeadline = true)
|
||||
RevoltAlerts.cancelAll(this)
|
||||
shuttingDownIntentionally = false
|
||||
currentConfig = config
|
||||
|
||||
val proxyOnly = intent.getBooleanExtra("PROXY_ONLY", false)
|
||||
currentProxyOnly = proxyOnly
|
||||
armSessionDeadline(config, BOOTSTRAP_SESSION_SECONDS)
|
||||
if (shuttingDownIntentionally) return START_NOT_STICKY
|
||||
|
||||
if (XrayCoreManager.startCore(this, config)) {
|
||||
if (!proxyOnly) {
|
||||
setupVpn(config)
|
||||
} else {
|
||||
isRunning = true
|
||||
Log.d(TAG, "Starting in PROXY_ONLY mode")
|
||||
XrayCoreManager.markRuntimeReady(this, config)
|
||||
}
|
||||
} else {
|
||||
stopAll(config.RUNTIME_TOKEN)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "START_SERVICE missing V2RAY_CONFIG")
|
||||
stopAll()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
} else {
|
||||
stopAll()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
return START_REDELIVER_INTENT
|
||||
}
|
||||
|
||||
private fun setupVpn(config: XrayConfig) {
|
||||
try {
|
||||
if (mInterface != null) {
|
||||
mInterface?.close()
|
||||
mInterface = null
|
||||
}
|
||||
|
||||
val builder = Builder()
|
||||
builder.setSession(config.REMARK)
|
||||
builder.setMtu(1500)
|
||||
builder.addAddress("26.26.26.1", 30)
|
||||
builder.addAddress("fd00:26:26::1", 126)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
builder.setMetered(false)
|
||||
}
|
||||
|
||||
if (config.BLOCKED_APPS.isNotEmpty()) {
|
||||
throw IllegalStateException("Per-app VPN bypass is disabled in ReVolt")
|
||||
}
|
||||
try {
|
||||
builder.addDisallowedApplication(packageName)
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to exclude ReVolt from its own VPN", e)
|
||||
}
|
||||
|
||||
builder.addRoute("0.0.0.0", 0)
|
||||
builder.addRoute("::", 0)
|
||||
|
||||
try {
|
||||
builder.addDnsServer("8.8.8.8")
|
||||
builder.addDnsServer("1.1.1.1")
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to configure VPN DNS", e)
|
||||
}
|
||||
|
||||
mInterface = builder.establish()
|
||||
?: throw IllegalStateException("Android refused to establish VPN interface")
|
||||
isRunning = true
|
||||
runTun2socks(config)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to setup VPN", e)
|
||||
stopAll()
|
||||
}
|
||||
}
|
||||
|
||||
private data class SecureSocksCredentials(
|
||||
val port: Int,
|
||||
val username: String,
|
||||
val password: String,
|
||||
)
|
||||
|
||||
private fun secureSocksCredentials(config: XrayConfig): SecureSocksCredentials? {
|
||||
val inbounds = try {
|
||||
JSONObject(config.V2RAY_FULL_JSON_CONFIG).optJSONArray("inbounds")
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: return null
|
||||
|
||||
for (i in 0 until inbounds.length()) {
|
||||
val inbound = inbounds.optJSONObject(i) ?: continue
|
||||
if (inbound.optString("tag") != "revolt-secure-socks") continue
|
||||
if (inbound.optString("protocol") != "socks" ||
|
||||
inbound.optString("listen") != "127.0.0.1"
|
||||
) return null
|
||||
|
||||
val settings = inbound.optJSONObject("settings") ?: return null
|
||||
if (settings.optString("auth") != "password") return null
|
||||
val user = settings.optJSONArray("users")?.optJSONObject(0) ?: return null
|
||||
val port = inbound.optInt("port", -1)
|
||||
val username = user.optString("user")
|
||||
val password = user.optString("pass")
|
||||
if (port <= 1024 || port > 65535 || username.isEmpty() || password.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
return SecureSocksCredentials(port, username, password)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun scheduleTun2socksRecovery(config: XrayConfig, reason: String) {
|
||||
if (!isRunning || currentConfig !== config || recoveringTun2socks) return
|
||||
XrayCoreManager.markRuntimeConnecting(this, config)
|
||||
recoveringTun2socks = true
|
||||
val attempt = ++tun2socksRecoveryAttempt
|
||||
val shift = (attempt - 1).coerceIn(0, 6)
|
||||
val delayMs = (500L * (1L shl shift)).coerceAtMost(30_000L)
|
||||
Log.w(TAG, "Scheduling tun2socks recovery attempt $attempt in ${delayMs}ms: $reason")
|
||||
|
||||
Thread {
|
||||
try {
|
||||
Thread.sleep(delayMs)
|
||||
} catch (_: InterruptedException) {
|
||||
recoveringTun2socks = false
|
||||
return@Thread
|
||||
}
|
||||
|
||||
recoveringTun2socks = false
|
||||
if (isRunning && currentConfig === config) {
|
||||
runTun2socks(config)
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun runTun2socks(config: XrayConfig) {
|
||||
val tun2socksPath = File(applicationInfo.nativeLibraryDir, "libtun2socks.so").absolutePath
|
||||
val socketFile = File(filesDir, "sock_path")
|
||||
if (socketFile.exists() && !socketFile.delete()) {
|
||||
Log.w(TAG, "Could not delete stale tun2socks socket")
|
||||
}
|
||||
val sockPath = socketFile.absolutePath
|
||||
|
||||
val secure = secureSocksCredentials(config)
|
||||
?: throw IllegalStateException("Authenticated ReVolt SOCKS5 inbound missing")
|
||||
config.LOCAL_SOCKS5_PORT = secure.port
|
||||
|
||||
val cmd = arrayListOf(
|
||||
tun2socksPath,
|
||||
"-sock-path", sockPath,
|
||||
"-proxy", "socks5://${secure.username}:${secure.password}@127.0.0.1:${secure.port}",
|
||||
"-mtu", "1500",
|
||||
"-loglevel", "debug"
|
||||
)
|
||||
|
||||
Log.d(TAG, "Starting tun2socks with authenticated loopback SOCKS5")
|
||||
|
||||
try {
|
||||
val pb = ProcessBuilder(cmd)
|
||||
pb.redirectErrorStream(true)
|
||||
pb.directory(filesDir)
|
||||
val process = pb.start()
|
||||
tun2socksProcess = process
|
||||
|
||||
Thread {
|
||||
try {
|
||||
process.inputStream.bufferedReader().use { reader ->
|
||||
reader.forEachLine { _ -> }
|
||||
}
|
||||
|
||||
process.waitFor()
|
||||
if (isRunning && tun2socksProcess === process && currentConfig === config) {
|
||||
Log.e(TAG, "tun2socks exited unexpectedly; keeping TUN fail-closed")
|
||||
tun2socksProcess = null
|
||||
scheduleTun2socksRecovery(config, "process exited")
|
||||
}
|
||||
} catch (_: java.io.InterruptedIOException) {
|
||||
// Expected when stopping
|
||||
} catch (_: InterruptedException) {
|
||||
// Expected when stopping
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading tun2socks output", e)
|
||||
}
|
||||
}.start()
|
||||
|
||||
sendFd(process, config)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start tun2socks; keeping TUN fail-closed", e)
|
||||
scheduleTun2socksRecovery(config, "start failure")
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendFd(process: Process, config: XrayConfig) {
|
||||
val fd = mInterface?.fileDescriptor ?: return
|
||||
val sockFile = File(filesDir, "sock_path").absolutePath
|
||||
|
||||
Thread {
|
||||
var tries = 0
|
||||
while (tries < 10 && isRunning && tun2socksProcess === process && process.isAlive) {
|
||||
var localSocket: LocalSocket? = null
|
||||
try {
|
||||
Thread.sleep(500)
|
||||
localSocket = LocalSocket()
|
||||
localSocket.connect(LocalSocketAddress(sockFile, LocalSocketAddress.Namespace.FILESYSTEM))
|
||||
val out = localSocket.outputStream
|
||||
localSocket.setFileDescriptorsForSend(arrayOf(fd))
|
||||
out.write(32)
|
||||
out.flush()
|
||||
localSocket.setFileDescriptorsForSend(null)
|
||||
localSocket.shutdownOutput()
|
||||
localSocket.close()
|
||||
if (isRunning && currentConfig === config && tun2socksProcess === process) {
|
||||
tun2socksRecoveryAttempt = 0
|
||||
XrayCoreManager.markRuntimeReady(this, config)
|
||||
}
|
||||
return@Thread
|
||||
} catch (_: Exception) {
|
||||
tries++
|
||||
try {
|
||||
localSocket?.close()
|
||||
} catch (closeError: Exception) {
|
||||
Log.w(TAG, "Failed to close tun2socks handoff socket", closeError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isRunning && tun2socksProcess === process) {
|
||||
Log.e(TAG, "FD handoff failed; recycling tun2socks without dropping TUN")
|
||||
try {
|
||||
process.destroy()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to recycle tun2socks after FD handoff failure", e)
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
fun handleXrayCoreExit(config: XrayConfig) {
|
||||
if (!isRunning || currentConfig !== config || recoveringXray) return
|
||||
XrayCoreManager.markRuntimeConnecting(this, config)
|
||||
recoveringXray = true
|
||||
|
||||
Thread {
|
||||
var attempt = 0
|
||||
try {
|
||||
while (isRunning && currentConfig === config) {
|
||||
attempt++
|
||||
val shift = (attempt - 1).coerceIn(0, 6)
|
||||
val delayMs = (500L * (1L shl shift)).coerceAtMost(30_000L)
|
||||
Log.w(TAG, "Recovering Xray core attempt $attempt in ${delayMs}ms")
|
||||
Thread.sleep(delayMs)
|
||||
if (!isRunning || currentConfig !== config) break
|
||||
|
||||
if (XrayCoreManager.startCore(this, config)) {
|
||||
Log.w(TAG, "Xray core recovered without dropping TUN")
|
||||
if (currentProxyOnly || (mInterface != null && tun2socksProcess?.isAlive == true)) {
|
||||
XrayCoreManager.markRuntimeReady(this, config)
|
||||
}
|
||||
return@Thread
|
||||
}
|
||||
}
|
||||
} catch (_: InterruptedException) {
|
||||
// Expected when stopping
|
||||
} finally {
|
||||
recoveringXray = false
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun cleanup(clearDeadline: Boolean = true) {
|
||||
isRunning = false
|
||||
if (clearDeadline) {
|
||||
clearSessionDeadline()
|
||||
} else {
|
||||
deadlineHandler.removeCallbacks(sessionDeadlineRunnable)
|
||||
sessionDeadlineElapsed = null
|
||||
sessionDeadlineToken = null
|
||||
}
|
||||
recoveringXray = false
|
||||
recoveringTun2socks = false
|
||||
tun2socksRecoveryAttempt = 0
|
||||
currentProxyOnly = false
|
||||
AppConfigs.RUNTIME_READY = false
|
||||
tun2socksProcess?.destroy()
|
||||
tun2socksProcess = null
|
||||
try {
|
||||
mInterface?.close()
|
||||
mInterface = null
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to close VPN interface during cleanup", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Anything that stops a live tunnel unasked must say so. isRunning is read
|
||||
// before cleanup() clears it, which keeps a failed connect silent.
|
||||
private fun stopAll(
|
||||
confirmationToken: String = currentConfig?.RUNTIME_TOKEN.orEmpty(),
|
||||
userRequested: Boolean = false,
|
||||
) {
|
||||
if (shuttingDownIntentionally) return
|
||||
val alertUser = isRunning && !userRequested
|
||||
shuttingDownIntentionally = true
|
||||
cleanup(clearDeadline = true)
|
||||
currentConfig = null
|
||||
XrayCoreManager.stopCore(this, confirmationToken)
|
||||
stopForeground(true)
|
||||
stopSelf()
|
||||
if (alertUser) RevoltAlerts.postDisconnected(this)
|
||||
}
|
||||
|
||||
private fun armSessionDeadline(config: XrayConfig, remainingSeconds: Long) {
|
||||
val boundedSeconds = remainingSeconds
|
||||
.coerceAtLeast(0L)
|
||||
.coerceAtMost(Long.MAX_VALUE / 1000L)
|
||||
val delayMs = boundedSeconds * 1000L
|
||||
val nowEpochMs = System.currentTimeMillis()
|
||||
val epochDeadline = if (Long.MAX_VALUE - nowEpochMs < delayMs) {
|
||||
Long.MAX_VALUE
|
||||
} else {
|
||||
nowEpochMs + delayMs
|
||||
}
|
||||
|
||||
sessionDeadlineToken = config.RUNTIME_TOKEN
|
||||
sessionDeadlineElapsed = SystemClock.elapsedRealtime() + delayMs
|
||||
|
||||
if (!persistDeadline(config.RUNTIME_TOKEN, epochDeadline) ||
|
||||
!scheduleSessionExpiryAlarm(config.RUNTIME_TOKEN, epochDeadline)
|
||||
) {
|
||||
Log.e(TAG, "Could not arm OS-level session expiry; stopping fail-closed")
|
||||
stopAll(config.RUNTIME_TOKEN)
|
||||
return
|
||||
}
|
||||
|
||||
armExpiryWarning(config.RUNTIME_TOKEN, epochDeadline)
|
||||
|
||||
deadlineHandler.removeCallbacks(sessionDeadlineRunnable)
|
||||
if (delayMs == 0L) deadlineHandler.post(sessionDeadlineRunnable)
|
||||
else deadlineHandler.postDelayed(sessionDeadlineRunnable, delayMs)
|
||||
}
|
||||
|
||||
// Advisory, unlike the deadline itself: nothing here fails closed. Dart
|
||||
// re-pushes the deadline every sync, so this runs about once a minute.
|
||||
private fun armExpiryWarning(runtimeToken: String, expiresAtEpochMs: Long) {
|
||||
if (runtimeToken.isEmpty()) return
|
||||
|
||||
val warnAt = expiresAtEpochMs - RevoltAlerts.WARNING_LEAD_MS
|
||||
// Inside the window already (or the short bootstrap deadline). Cancel
|
||||
// nothing here: with no dedupe on the re-push, cancelling on this
|
||||
// branch deletes the armed warning and never re-arms it.
|
||||
if (warnAt <= System.currentTimeMillis() + WARNING_REARM_GRACE_MS) return
|
||||
|
||||
cancelSessionWarningAlarm()
|
||||
// A warning on screen now belongs to an older deadline (top-up).
|
||||
RevoltAlerts.cancelExpiryWarning(this)
|
||||
|
||||
try {
|
||||
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
val pendingIntent = sessionWarningPendingIntent(runtimeToken)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
// Inexact on purpose - keeps this off SCHEDULE_EXACT_ALARM.
|
||||
alarmManager.setAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP,
|
||||
warnAt,
|
||||
pendingIntent,
|
||||
)
|
||||
} else {
|
||||
alarmManager.set(AlarmManager.RTC_WAKEUP, warnAt, pendingIntent)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to schedule session-expiry warning", e)
|
||||
}
|
||||
|
||||
// postDelayed runs on uptimeMillis, so a truly suspended device still
|
||||
// fires this late - handleSessionWarningAlarm then rejects it on the
|
||||
// window check rather than claiming "5 minutes" with seconds left.
|
||||
// Whenever the process is merely backgrounded, which is the common
|
||||
// case, this is what actually delivers the warning.
|
||||
val warnDelay = warnAt - System.currentTimeMillis()
|
||||
deadlineHandler.removeCallbacks(sessionWarningRunnable)
|
||||
if (warnDelay > 0L) {
|
||||
deadlineHandler.postDelayed(sessionWarningRunnable, warnDelay)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSessionWarningAlarm(alarmToken: String) {
|
||||
val config = currentConfig
|
||||
if (config == null) {
|
||||
// Woke a process with no live runtime; nothing to warn about.
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
if (alarmToken.isEmpty() || alarmToken != config.RUNTIME_TOKEN) {
|
||||
Log.w(TAG, "Ignoring session warning for stale runtime generation")
|
||||
return
|
||||
}
|
||||
|
||||
val deadline = sessionDeadlineElapsed ?: return
|
||||
val remaining = deadline - SystemClock.elapsedRealtime()
|
||||
if (remaining < WARNING_FLOOR_MS ||
|
||||
remaining > RevoltAlerts.WARNING_LEAD_MS + WARNING_SLACK_MS
|
||||
) {
|
||||
// Too late, or the deadline moved. Saying "5 minutes" with
|
||||
// seconds left is worse than silence.
|
||||
Log.w(TAG, "Ignoring session warning outside the warning window")
|
||||
return
|
||||
}
|
||||
|
||||
RevoltAlerts.postExpiryWarning(this)
|
||||
}
|
||||
|
||||
private fun clearSessionDeadline() {
|
||||
deadlineHandler.removeCallbacks(sessionDeadlineRunnable)
|
||||
sessionDeadlineElapsed = null
|
||||
sessionDeadlineToken = null
|
||||
clearPersistedDeadline()
|
||||
cancelSessionExpiryAlarm()
|
||||
cancelSessionWarningAlarm()
|
||||
RevoltAlerts.cancelExpiryWarning(this)
|
||||
}
|
||||
|
||||
private fun enforceSessionDeadline() {
|
||||
val token = sessionDeadlineToken ?: return
|
||||
val deadline = sessionDeadlineElapsed ?: return
|
||||
val config = currentConfig ?: return
|
||||
if (config.RUNTIME_TOKEN != token) return
|
||||
val remaining = deadline - SystemClock.elapsedRealtime()
|
||||
if (remaining > 0L) {
|
||||
deadlineHandler.postDelayed(sessionDeadlineRunnable, remaining)
|
||||
return
|
||||
}
|
||||
Log.w(TAG, "Native session deadline expired; stopping runtime fail-closed")
|
||||
stopAll(token)
|
||||
}
|
||||
|
||||
private fun updateSessionDeadline(intent: Intent) {
|
||||
val config = currentConfig ?: return
|
||||
val token = intent.getStringExtra("RUNTIME_TOKEN").orEmpty()
|
||||
if (token.isEmpty() || token != config.RUNTIME_TOKEN) {
|
||||
Log.w(TAG, "Ignoring session deadline update for stale runtime generation")
|
||||
return
|
||||
}
|
||||
val remainingSeconds = intent.getLongExtra("REMAINING_SECONDS", -1L)
|
||||
if (remainingSeconds < 0L) {
|
||||
Log.w(TAG, "Ignoring invalid negative session deadline")
|
||||
return
|
||||
}
|
||||
armSessionDeadline(config, remainingSeconds)
|
||||
}
|
||||
|
||||
private fun persistDeadline(runtimeToken: String, expiresAtEpochMs: Long): Boolean {
|
||||
return try {
|
||||
getSharedPreferences(DEADLINE_PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(PREF_RUNTIME_TOKEN, runtimeToken)
|
||||
.putLong(PREF_EXPIRES_AT_MS, expiresAtEpochMs)
|
||||
.commit()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to persist non-secret session deadline", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearPersistedDeadline() {
|
||||
try {
|
||||
getSharedPreferences(DEADLINE_PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.remove(PREF_RUNTIME_TOKEN)
|
||||
.remove(PREF_EXPIRES_AT_MS)
|
||||
.apply()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to clear persisted session deadline", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun enforcePersistedDeadline(alarmToken: String) {
|
||||
val stored = try {
|
||||
val prefs = getSharedPreferences(DEADLINE_PREFS, Context.MODE_PRIVATE)
|
||||
prefs.getString(PREF_RUNTIME_TOKEN, null).orEmpty() to
|
||||
prefs.getLong(PREF_EXPIRES_AT_MS, 0L)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to read persisted session deadline", e)
|
||||
clearPersistedDeadline()
|
||||
cancelSessionExpiryAlarm()
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
|
||||
val storedToken = stored.first
|
||||
val storedDeadline = stored.second
|
||||
if (alarmToken.isEmpty() || storedToken.isEmpty() || alarmToken != storedToken) {
|
||||
Log.w(TAG, "Ignoring stale session-expiry alarm")
|
||||
if (currentConfig == null) stopSelf()
|
||||
return
|
||||
}
|
||||
|
||||
val remaining = storedDeadline - System.currentTimeMillis()
|
||||
if (storedDeadline > 0L && remaining > 0L) {
|
||||
if (!scheduleSessionExpiryAlarm(storedToken, storedDeadline)) {
|
||||
Log.e(TAG, "Failed to re-arm early session-expiry alarm")
|
||||
val activeToken = currentConfig?.RUNTIME_TOKEN.orEmpty()
|
||||
if (activeToken == storedToken) stopAll(storedToken) else stopSelf()
|
||||
return
|
||||
}
|
||||
// An alarm can wake a fresh service process after clock changes.
|
||||
// If there is no live runtime in this process, do not leave a
|
||||
// background service instance hanging around just to hold a timer.
|
||||
if (currentConfig == null) stopSelf()
|
||||
return
|
||||
}
|
||||
|
||||
Log.w(TAG, "OS session-expiry alarm fired; stopping runtime fail-closed")
|
||||
val activeToken = currentConfig?.RUNTIME_TOKEN.orEmpty()
|
||||
if (activeToken.isNotEmpty() && activeToken == storedToken) {
|
||||
stopAll(storedToken)
|
||||
} else {
|
||||
clearPersistedDeadline()
|
||||
cancelSessionExpiryAlarm()
|
||||
cancelSessionWarningAlarm()
|
||||
RevoltAlerts.cancelExpiryWarning(this)
|
||||
stopForeground(true)
|
||||
stopSelf()
|
||||
// This arm does not go through stopAll, so it posts for itself.
|
||||
RevoltAlerts.postDisconnected(this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleSessionExpiryAlarm(runtimeToken: String, expiresAtEpochMs: Long): Boolean {
|
||||
cancelSessionExpiryAlarm()
|
||||
if (runtimeToken.isEmpty() || expiresAtEpochMs <= 0L) return false
|
||||
|
||||
return try {
|
||||
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
val pendingIntent = sessionExpiryPendingIntent(runtimeToken)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
try {
|
||||
alarmManager.setExactAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP,
|
||||
expiresAtEpochMs,
|
||||
pendingIntent,
|
||||
)
|
||||
} catch (_: SecurityException) {
|
||||
Log.w(TAG, "Exact alarm unavailable; using inexact idle-capable expiry alarm")
|
||||
alarmManager.setAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP,
|
||||
expiresAtEpochMs,
|
||||
pendingIntent,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
alarmManager.setExact(AlarmManager.RTC_WAKEUP, expiresAtEpochMs, pendingIntent)
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to schedule OS session-expiry alarm", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelSessionExpiryAlarm() {
|
||||
try {
|
||||
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
alarmManager.cancel(sessionExpiryPendingIntent(""))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to cancel session-expiry alarm", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelSessionWarningAlarm() {
|
||||
deadlineHandler.removeCallbacks(sessionWarningRunnable)
|
||||
try {
|
||||
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
// Look it up rather than build it: FLAG_UPDATE_CURRENT would
|
||||
// rewrite the live alarm's RUNTIME_TOKEN to "" before cancelling.
|
||||
existingSessionWarningPendingIntent()?.let { alarmManager.cancel(it) }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to cancel session-warning alarm", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun existingSessionWarningPendingIntent(): PendingIntent? {
|
||||
val intent = Intent(this, XrayVPNService::class.java)
|
||||
.putExtra(EXTRA_SESSION_WARNING, true)
|
||||
val flags = PendingIntent.FLAG_NO_CREATE or
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
|
||||
return PendingIntent.getService(this, SESSION_WARNING_REQUEST_CODE, intent, flags)
|
||||
}
|
||||
|
||||
// Identity is component + request code, not extras, so the warning needs
|
||||
// its own code or it clobbers the expiry alarm.
|
||||
private fun sessionWarningPendingIntent(runtimeToken: String): PendingIntent {
|
||||
val intent = Intent(this, XrayVPNService::class.java)
|
||||
.putExtra(EXTRA_SESSION_WARNING, true)
|
||||
.putExtra("RUNTIME_TOKEN", runtimeToken)
|
||||
val flags = PendingIntent.FLAG_UPDATE_CURRENT or
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
|
||||
return PendingIntent.getService(this, SESSION_WARNING_REQUEST_CODE, intent, flags)
|
||||
}
|
||||
|
||||
private fun sessionExpiryPendingIntent(runtimeToken: String): PendingIntent {
|
||||
val intent = Intent(this, XrayVPNService::class.java)
|
||||
.putExtra(EXTRA_SESSION_EXPIRED, true)
|
||||
.putExtra("RUNTIME_TOKEN", runtimeToken)
|
||||
val flags = PendingIntent.FLAG_UPDATE_CURRENT or
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
|
||||
return PendingIntent.getService(this, SESSION_EXPIRY_REQUEST_CODE, intent, flags)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (!shuttingDownIntentionally) {
|
||||
val confirmationToken = currentConfig?.RUNTIME_TOKEN.orEmpty()
|
||||
cleanup(clearDeadline = false)
|
||||
currentConfig = null
|
||||
XrayCoreManager.stopCore(this, confirmationToken)
|
||||
stopForeground(true)
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onRevoke() {
|
||||
stopAll(userRequested = true)
|
||||
super.onRevoke()
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channelId = "REVOLT_VPN_SERVICE"
|
||||
val channelName = "VPN Service"
|
||||
val channel = android.app.NotificationChannel(
|
||||
channelId,
|
||||
channelName,
|
||||
android.app.NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
val manager = getSystemService(android.app.NotificationManager::class.java)
|
||||
manager?.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotification(content: String): android.app.Notification {
|
||||
val channelId = "REVOLT_VPN_SERVICE"
|
||||
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
android.app.Notification.Builder(this, channelId)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
android.app.Notification.Builder(this)
|
||||
}
|
||||
|
||||
val icon = resources.getIdentifier("notification_icon", "drawable", packageName)
|
||||
.takeIf { it != 0 } ?: android.R.drawable.ic_dialog_info
|
||||
|
||||
val notification = builder
|
||||
.setContentTitle("VPN Service")
|
||||
.setContentText(content)
|
||||
.setSmallIcon(icon)
|
||||
.setOngoing(true)
|
||||
.setAutoCancel(false)
|
||||
.setOnlyAlertOnce(true)
|
||||
.build()
|
||||
|
||||
notification.flags = notification.flags or
|
||||
android.app.Notification.FLAG_ONGOING_EVENT or
|
||||
android.app.Notification.FLAG_NO_CLEAR
|
||||
return notification
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "XrayVPNService"
|
||||
private const val FOREGROUND_SERVICE_TYPE_SPECIAL_USE = 0x40000000
|
||||
private const val BOOTSTRAP_SESSION_SECONDS = 120L
|
||||
private const val DEADLINE_PREFS = "revolt_session_deadline"
|
||||
private const val PREF_RUNTIME_TOKEN = "runtime_token"
|
||||
private const val PREF_EXPIRES_AT_MS = "expires_at_ms"
|
||||
private const val EXTRA_SESSION_EXPIRED = "SESSION_EXPIRED"
|
||||
private const val EXTRA_SESSION_WARNING = "SESSION_WARNING"
|
||||
private const val SESSION_EXPIRY_REQUEST_CODE = 1001
|
||||
private const val SESSION_WARNING_REQUEST_CODE = 1002
|
||||
// Drift allowed before a warning is treated as belonging elsewhere.
|
||||
private const val WARNING_SLACK_MS = 60_000L
|
||||
// Don't re-arm for a deadline that moved by round-trip jitter alone.
|
||||
private const val WARNING_REARM_GRACE_MS = 15_000L
|
||||
// Below this the warning has lost its point.
|
||||
private const val WARNING_FLOOR_MS = 60_000L
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.github.tfox.flutter_vless.xray.utils
|
||||
|
||||
import com.github.tfox.flutter_vless.xray.dto.XrayConfig
|
||||
import java.io.Serializable
|
||||
|
||||
/**
|
||||
* Global application configuration and state.
|
||||
*
|
||||
* This object holds:
|
||||
* 1. Constants for Intent actions and Service commands.
|
||||
* 2. Enums for connection states and modes.
|
||||
* 3. Global state variables (current connection state, active config).
|
||||
* 4. Static storage for notification icon resources (passed from Flutter).
|
||||
*/
|
||||
object AppConfigs {
|
||||
const val V2RAY_CONNECTION_INFO = "com.github.tfox.flutter_vless.xray.V2RAY_CONNECTION_INFO"
|
||||
|
||||
/**
|
||||
* Commands sent to the XrayVPNService via Intent.
|
||||
*/
|
||||
enum class V2RAY_SERVICE_COMMANDS : Serializable {
|
||||
START_SERVICE, STOP_SERVICE, RESTART_SERVICE, UPDATE_SESSION_DEADLINE
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection states for the Xray Core.
|
||||
*/
|
||||
enum class V2RAY_STATES : Serializable {
|
||||
V2RAY_CONNECTED, V2RAY_DISCONNECTED, V2RAY_CONNECTING
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection modes:
|
||||
* - VPN_TUN: Standard VPN mode using Android VpnService (TUN device).
|
||||
* - PROXY_ONLY: Runs Xray as a local SOCKS/HTTP proxy without establishing a VPN tunnel.
|
||||
*/
|
||||
enum class V2RAY_CONNECTION_MODES : Serializable {
|
||||
VPN_TUN, PROXY_ONLY
|
||||
}
|
||||
|
||||
var V2RAY_STATE: V2RAY_STATES = V2RAY_STATES.V2RAY_DISCONNECTED
|
||||
@Volatile var RUNTIME_READY: Boolean = false
|
||||
var V2RAY_CONFIG: XrayConfig? = null
|
||||
var V2RAY_CONNECTION_MODE: V2RAY_CONNECTION_MODES = V2RAY_CONNECTION_MODES.VPN_TUN
|
||||
|
||||
// Stores the resource name and type for the custom notification icon
|
||||
var NOTIFICATION_ICON_RESOURCE_NAME: String = ""
|
||||
var NOTIFICATION_ICON_RESOURCE_TYPE: String = ""
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package com.github.tfox.flutter_vless.xray.utils
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
|
||||
// local_packages/.../xray/utils/RevoltAlerts.kt
|
||||
//
|
||||
// The two session alerts. Both XrayVPNService (:RunSoLibXrayDaemon) and
|
||||
// MainActivity post them, sharing ids so a second post replaces. Separate from
|
||||
// the foreground-service notification (id 1, IMPORTANCE_LOW): these must be
|
||||
// noticed, and must be dismissible.
|
||||
object RevoltAlerts {
|
||||
|
||||
const val CHANNEL_ID = "REVOLT_VPN_ALERTS"
|
||||
const val WARNING_NOTIFICATION_ID = 2
|
||||
const val DISCONNECTED_NOTIFICATION_ID = 3
|
||||
|
||||
const val WARNING_LEAD_MS = 5 * 60 * 1000L
|
||||
|
||||
private const val TAG = "RevoltAlerts"
|
||||
private const val LAUNCH_REQUEST_CODE = 2001
|
||||
|
||||
// Neither line names a number or a mechanism, because this notification
|
||||
// cannot honour either. The caller accepts it anywhere from WARNING_FLOOR_MS
|
||||
// to WARNING_LEAD_MS + WARNING_SLACK_MS remaining, so "5 minutes" can be
|
||||
// off by four of them; and "watch an ad" is a dead end in a release build,
|
||||
// where the support bypass is still kDebugMode-gated (rule 87). Put the
|
||||
// number back once the warning is punctual, and the ad back once the
|
||||
// support button works without a debug build.
|
||||
fun postExpiryWarning(context: Context) {
|
||||
post(
|
||||
context,
|
||||
WARNING_NOTIFICATION_ID,
|
||||
"Revolt VPN disconnects soon",
|
||||
"Open the app to extend your session.",
|
||||
)
|
||||
}
|
||||
|
||||
fun postDisconnected(context: Context) {
|
||||
post(
|
||||
context,
|
||||
DISCONNECTED_NOTIFICATION_ID,
|
||||
"Revolt VPN disconnected",
|
||||
"Your traffic is no longer protected. Tap to reconnect.",
|
||||
)
|
||||
}
|
||||
|
||||
fun cancelExpiryWarning(context: Context) = cancel(context, WARNING_NOTIFICATION_ID)
|
||||
|
||||
fun cancelDisconnected(context: Context) = cancel(context, DISCONNECTED_NOTIFICATION_ID)
|
||||
|
||||
fun cancelAll(context: Context) {
|
||||
cancelExpiryWarning(context)
|
||||
cancelDisconnected(context)
|
||||
}
|
||||
|
||||
private fun cancel(context: Context, id: Int) {
|
||||
try {
|
||||
manager(context)?.cancel(id)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to cancel alert $id", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun post(context: Context, id: Int, title: String, text: String) {
|
||||
if (!canPost(context)) {
|
||||
Log.w(TAG, "POST_NOTIFICATIONS not granted; dropping alert $id")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val manager = manager(context) ?: return
|
||||
ensureChannel(manager)
|
||||
|
||||
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Notification.Builder(context, CHANNEL_ID)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
Notification.Builder(context).setPriority(Notification.PRIORITY_HIGH)
|
||||
}
|
||||
|
||||
val icon = context.resources
|
||||
.getIdentifier("notification_icon", "drawable", context.packageName)
|
||||
.takeIf { it != 0 } ?: android.R.drawable.ic_dialog_info
|
||||
|
||||
builder
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setStyle(Notification.BigTextStyle().bigText(text))
|
||||
.setSmallIcon(icon)
|
||||
.setAutoCancel(true)
|
||||
.setOngoing(false)
|
||||
// A replacing post must not sound again.
|
||||
.setOnlyAlertOnce(true)
|
||||
|
||||
launchIntent(context)?.let { builder.setContentIntent(it) }
|
||||
|
||||
manager.notify(id, builder.build())
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to post alert $id", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun canPost(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true
|
||||
return context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun manager(context: Context): NotificationManager? =
|
||||
context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
|
||||
|
||||
// Importance is fixed at creation, so ask for HIGH up front.
|
||||
private fun ensureChannel(manager: NotificationManager) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Session alerts",
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
)
|
||||
channel.description = "Warns before a session ends and when protection stops."
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun launchIntent(context: Context): PendingIntent? {
|
||||
val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
||||
?: return null
|
||||
intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or
|
||||
Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
val flags = PendingIntent.FLAG_UPDATE_CURRENT or
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
|
||||
return PendingIntent.getActivity(context, LAUNCH_REQUEST_CODE, intent, flags)
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.github.tfox.flutter_vless.xray.utils
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
object Utilities {
|
||||
|
||||
fun getUserAssetsPath(context: Context): String {
|
||||
val dir = context.filesDir
|
||||
if (!dir.exists()) {
|
||||
dir.mkdirs()
|
||||
}
|
||||
return dir.absolutePath
|
||||
}
|
||||
|
||||
fun copyAssets(context: Context) {
|
||||
val assets = context.assets
|
||||
val files = assets.list("") ?: return
|
||||
for (filename in files) {
|
||||
if (filename == "geoip.dat" || filename == "geosite.dat") {
|
||||
var `in`: InputStream? = null
|
||||
var out: OutputStream? = null
|
||||
try {
|
||||
`in` = assets.open(filename)
|
||||
val outFile = File(getUserAssetsPath(context), filename)
|
||||
out = FileOutputStream(outFile)
|
||||
copyFile(`in`, out)
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
if (`in` != null) {
|
||||
try {
|
||||
`in`.close()
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
if (out != null) {
|
||||
try {
|
||||
out.close()
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun copyFile(`in`: InputStream, out: OutputStream) {
|
||||
val buffer = ByteArray(1024)
|
||||
var read: Int
|
||||
while (`in`.read(buffer).also { read = it } != -1) {
|
||||
out.write(buffer, 0, read)
|
||||
}
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
package com.github.tfox.flutter_vless.xray.core
|
||||
|
||||
import com.github.tfox.flutter_vless.xray.dto.XrayConfig
|
||||
import java.io.File
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
class XrayCoreManagerTest {
|
||||
@Test
|
||||
fun buildRuntimeConfigJson_keepsSecureSocksAndSanitizesLogs() {
|
||||
val filesDir = File("build/test-files/runtime-secure")
|
||||
val config = XrayConfig(
|
||||
V2RAY_FULL_JSON_CONFIG = runtimeConfig(
|
||||
extraLog = """
|
||||
"log": {
|
||||
"access": "/tmp/access.log",
|
||||
"error": "/tmp/error.log"
|
||||
},
|
||||
""".trimIndent(),
|
||||
)
|
||||
)
|
||||
|
||||
val output = XrayCoreManager.buildRuntimeConfigJson(config, filesDir)
|
||||
val log = output.getJSONObject("log")
|
||||
val inbound = output.getJSONArray("inbounds").getJSONObject(0)
|
||||
val user = output
|
||||
.getJSONArray("outbounds")
|
||||
.getJSONObject(0)
|
||||
.getJSONObject("settings")
|
||||
.getJSONArray("vnext")
|
||||
.getJSONObject(0)
|
||||
.getJSONArray("users")
|
||||
.getJSONObject(0)
|
||||
|
||||
assertFalse(log.has("access"))
|
||||
assertEquals(File(filesDir, "error.log").absolutePath, log.getString("error"))
|
||||
assertEquals("revolt-secure-socks", inbound.getString("tag"))
|
||||
assertEquals("password", inbound.getJSONObject("settings").getString("auth"))
|
||||
assertEquals(19080, config.LOCAL_SOCKS5_PORT)
|
||||
assertEquals(0, config.LOCAL_HTTP_PORT)
|
||||
assertEquals(vlessEncryption, user.getString("encryption"))
|
||||
assertEquals("xtls-rprx-vision", user.getString("flow"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildRuntimeConfigJson_rejectsInvalidSecureSocks() {
|
||||
val config = XrayConfig(
|
||||
V2RAY_FULL_JSON_CONFIG = """
|
||||
{
|
||||
"inbounds": [
|
||||
{
|
||||
"tag": "revolt-secure-socks",
|
||||
"listen": "127.0.0.1",
|
||||
"port": 19080,
|
||||
"protocol": "socks",
|
||||
"settings": { "auth": "noauth", "users": [] }
|
||||
}
|
||||
],
|
||||
"outbounds": []
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
try {
|
||||
XrayCoreManager.buildRuntimeConfigJson(
|
||||
config,
|
||||
File("build/test-files/runtime-invalid-socks"),
|
||||
)
|
||||
fail("Invalid secure SOCKS5 configuration must fail closed")
|
||||
} catch (expected: IllegalStateException) {
|
||||
assertTrue(expected.message.orEmpty().contains("Secure SOCKS5"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildRuntimeConfigJson_normalizesXrayRuntimeAliases() {
|
||||
val config = XrayConfig(
|
||||
V2RAY_FULL_JSON_CONFIG = """
|
||||
{
|
||||
"inbounds": [
|
||||
{
|
||||
"tag": "revolt-secure-socks",
|
||||
"listen": "127.0.0.1",
|
||||
"port": 19080,
|
||||
"protocol": "socks",
|
||||
"settings": {
|
||||
"auth": "password",
|
||||
"users": [{"user": "runtime-user", "pass": "runtime-pass"}]
|
||||
}
|
||||
}
|
||||
],
|
||||
"outbounds": [
|
||||
{
|
||||
"protocol": "vless",
|
||||
"settings": {
|
||||
"vnext": [
|
||||
{
|
||||
"address": "example.com",
|
||||
"port": 443,
|
||||
"users": [{"id": "11111111-1111-4111-8111-111111111111", "encryption": "none"}]
|
||||
}
|
||||
]
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "XHTTP",
|
||||
"security": "tls",
|
||||
"tlsSettings": {"allowInsecure": false, "serverName": "example.com"},
|
||||
"xHTTPSettings": {"path": "/", "mode": "auto"},
|
||||
"httpUpgradeSettings": {"path": "/upgrade"},
|
||||
"splitHTTPSettings": {"path": "/split"}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
val output = XrayCoreManager.buildRuntimeConfigJson(
|
||||
config,
|
||||
File("build/test-files/runtime-aliases"),
|
||||
)
|
||||
val stream = output
|
||||
.getJSONArray("outbounds")
|
||||
.getJSONObject(0)
|
||||
.getJSONObject("streamSettings")
|
||||
|
||||
assertEquals("xhttp", stream.getString("network"))
|
||||
assertTrue(stream.has("xhttpSettings"))
|
||||
assertTrue(stream.has("httpupgradeSettings"))
|
||||
assertTrue(stream.has("splithttpSettings"))
|
||||
assertFalse(stream.has("xHTTPSettings"))
|
||||
assertFalse(stream.has("httpUpgradeSettings"))
|
||||
assertFalse(stream.has("splitHTTPSettings"))
|
||||
assertFalse(stream.getJSONObject("tlsSettings").has("allowInsecure"))
|
||||
}
|
||||
|
||||
private fun runtimeConfig(extraLog: String = ""): String = """
|
||||
{
|
||||
$extraLog
|
||||
"inbounds": [
|
||||
{
|
||||
"tag": "revolt-secure-socks",
|
||||
"listen": "127.0.0.1",
|
||||
"port": 19080,
|
||||
"protocol": "socks",
|
||||
"settings": {
|
||||
"auth": "password",
|
||||
"users": [{"user": "runtime-user", "pass": "runtime-pass"}]
|
||||
}
|
||||
}
|
||||
],
|
||||
"outbounds": [
|
||||
{
|
||||
"tag": "proxy",
|
||||
"protocol": "vless",
|
||||
"settings": {
|
||||
"address": "xhttp.example.com",
|
||||
"port": 443,
|
||||
"id": "b94da146-a56e-49d7-af4c-a68c9065cbfd",
|
||||
"encryption": "$vlessEncryption",
|
||||
"flow": "xtls-rprx-vision",
|
||||
"level": 8
|
||||
},
|
||||
"streamSettings": {"network": "xhttp", "security": "none"}
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private companion object {
|
||||
const val vlessEncryption =
|
||||
"mlkem768x25519plus.native.1rtt.100-500-2000.75-0-100.80-0-5000.gtmOXB2AN_r905czmOIr6dKq_YDdEJB8RWGqfsXurns"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) 2024-2026 13FOX Studio / tfox.dev.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import 'package:flutter_vless_platform_interface/flutter_vless_platform_interface.dart';
|
||||
|
||||
/// Android implementation of [VlessPlatform] using MethodChannel.
|
||||
class FlutterVlessAndroid extends VlessMethodChannelAdapter {
|
||||
/// Registers this class as the platform implementation.
|
||||
/// This is called automatically by Flutter's plugin registry.
|
||||
static void registerWith() {
|
||||
VlessPlatform.instance = FlutterVlessAndroid();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
name: flutter_vless_android
|
||||
description: Android implementation of the flutter_vless plugin.
|
||||
version: 1.1.5
|
||||
homepage: https://tfox.dev
|
||||
repository: https://github.com/XIIIFOX/flutter_vless
|
||||
issue_tracker: https://github.com/XIIIFOX/flutter_vless/issues
|
||||
documentation: https://github.com/XIIIFOX/flutter_vless/blob/main/doc/platform/android.md
|
||||
funding:
|
||||
- https://tfox.dev
|
||||
|
||||
environment:
|
||||
sdk: '>=3.4.0 <4.0.0'
|
||||
flutter: '>=3.3.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_vless_platform_interface: ^1.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
flutter:
|
||||
plugin:
|
||||
implements: flutter_vless_platform_interface
|
||||
platforms:
|
||||
android:
|
||||
package: com.github.tfox.flutter_vless
|
||||
pluginClass: FlutterVlessPlugin
|
||||
+14
-87
@@ -65,22 +65,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cupertino_icons
|
||||
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.9"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -126,30 +110,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
flutter_local_notifications:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_local_notifications
|
||||
sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "17.2.4"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_linux
|
||||
sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.1"
|
||||
flutter_local_notifications_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_platform_interface
|
||||
sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.2.0"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -212,12 +172,11 @@ packages:
|
||||
source: hosted
|
||||
version: "1.1.5"
|
||||
flutter_vless_android:
|
||||
dependency: transitive
|
||||
dependency: "direct overridden"
|
||||
description:
|
||||
name: flutter_vless_android
|
||||
sha256: "5e6fe73d3606ed8c73e87fac15efa7631e12ce18d39a4bd4e13b6c097c6d52b9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
path: "local_packages/flutter_vless_android-1.1.5"
|
||||
relative: true
|
||||
source: path
|
||||
version: "1.1.5"
|
||||
flutter_vless_macos:
|
||||
dependency: transitive
|
||||
@@ -248,14 +207,6 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
google_fonts:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_fonts
|
||||
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.3"
|
||||
google_mobile_ads:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -348,10 +299,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.19"
|
||||
version: "0.12.20"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -364,10 +315,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
version: "1.19.0"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -417,7 +368,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: "direct main"
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
@@ -464,14 +415,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -617,18 +560,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timezone
|
||||
sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.4"
|
||||
version: "0.7.12"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -713,10 +648,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
version: "2.4.2"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -781,14 +716,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -798,5 +725,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.10.3 <4.0.0"
|
||||
dart: ">=3.11.0-0 <4.0.0"
|
||||
flutter: ">=3.38.4"
|
||||
+5
-7
@@ -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.
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
version: 3.3.0+26
|
||||
version: 3.3.7+33
|
||||
|
||||
environment:
|
||||
sdk: ">=3.0.0 <4.0.0"
|
||||
@@ -24,10 +24,6 @@ dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.5
|
||||
google_fonts: ^6.1.0
|
||||
provider: ^6.1.1
|
||||
google_mobile_ads: ^4.0.0
|
||||
flutter_vless: 1.1.5
|
||||
@@ -36,8 +32,6 @@ dependencies:
|
||||
url_launcher: ^6.3.2
|
||||
flutter_secure_storage: ^10.3.1
|
||||
uuid: ^4.5.3
|
||||
flutter_local_notifications: ^17.0.0
|
||||
path_provider: ^2.1.5
|
||||
package_info_plus: ^6.0.0
|
||||
|
||||
dev_dependencies:
|
||||
@@ -84,3 +78,7 @@ flutter:
|
||||
#
|
||||
# For details regarding fonts from package dependencies,
|
||||
# see https://flutter.dev/custom-fonts/#from-packages
|
||||
|
||||
dependency_overrides:
|
||||
flutter_vless_android:
|
||||
path: local_packages/flutter_vless_android-1.1.5
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:revoltvpn/logic/secure_socks_session.dart';
|
||||
|
||||
void main() {
|
||||
test('release verification stays fail-closed and reproducible', () {
|
||||
final gradle = File('android/app/build.gradle.kts').readAsStringSync();
|
||||
final workflow = File('.github/workflows/android-ci.yml').readAsStringSync();
|
||||
final lockfile = File('pubspec.lock').readAsStringSync();
|
||||
final wrapper =
|
||||
File('android/gradle/wrapper/gradle-wrapper.properties').readAsStringSync();
|
||||
final verification =
|
||||
File('android/gradle/verification-metadata.xml').readAsStringSync();
|
||||
final proguard = File('android/app/proguard-rules.pro').readAsStringSync();
|
||||
|
||||
expect(
|
||||
wrapper,
|
||||
contains(
|
||||
'distributionSha256Sum='
|
||||
'efe9a3d147d948d7528a9887fa35abcf24ca1a43ad06439996490f77569b02d1',
|
||||
),
|
||||
);
|
||||
expect(wrapper, contains('validateDistributionUrl=true'));
|
||||
|
||||
expect(verification, contains('<verify-metadata>true</verify-metadata>'));
|
||||
expect(verification, contains('<verify-signatures>false</verify-signatures>'));
|
||||
expect(verification, contains('<sha256 value='));
|
||||
expect(
|
||||
verification,
|
||||
contains('a804b261645ef8c13eb3d5c44a5c2fb0340c5539'),
|
||||
);
|
||||
|
||||
expect(gradle, contains('REVOLT_APP_CONFIG_SHA256'));
|
||||
expect(gradle, contains('verifyReleaseAppConfig'));
|
||||
expect(gradle, contains('preReleaseBuild'));
|
||||
expect(gradle, contains('isMinifyEnabled = true'));
|
||||
expect(gradle, contains('isShrinkResources = true'));
|
||||
expect(gradle, contains('REVOLT_CI_RELEASE_SMOKE'));
|
||||
expect(gradle, contains('signingConfigs.getByName("release")'));
|
||||
|
||||
expect(workflow, contains("flutter-version: '3.47.2'"));
|
||||
expect(workflow, contains('Verify Gradle supply chain'));
|
||||
expect(workflow, contains('Verify dependency lock is committed'));
|
||||
expect(workflow, contains('Verify production release config fails closed'));
|
||||
expect(workflow, contains('Cold APK build'));
|
||||
expect(workflow, contains('Warm APK build'));
|
||||
expect(workflow, contains('Release R8 smoke build'));
|
||||
expect(workflow, contains('Verify tracked build inputs stayed immutable'));
|
||||
expect(workflow, contains('REVOLT_CI_RELEASE_SMOKE'));
|
||||
expect(
|
||||
workflow,
|
||||
contains('actions/checkout@11d5960a326750d5838078e36cf38b85af677262'),
|
||||
);
|
||||
expect(
|
||||
workflow,
|
||||
contains('actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961'),
|
||||
);
|
||||
expect(lockfile, contains('dart: ">=3.11.0-0 <4.0.0"'));
|
||||
|
||||
expect(proguard, contains('-keep class io.flutter.** { *; }'));
|
||||
expect(proguard, contains('-keep class xray.** { *; }'));
|
||||
});
|
||||
|
||||
test('runtime keeps authenticated SOCKS on the IPv4 loopback', () async {
|
||||
final session = await SecureSocksSession.create(jsonEncode({
|
||||
'inbounds': <Object?>[],
|
||||
'outbounds': <Object?>[],
|
||||
}));
|
||||
final config = jsonDecode(session.configJson) as Map<String, dynamic>;
|
||||
final inbounds = config['inbounds'] as List<dynamic>;
|
||||
final inbound = inbounds.single as Map<String, dynamic>;
|
||||
final settings = inbound['settings'] as Map<String, dynamic>;
|
||||
final users = settings['users'] as List<dynamic>;
|
||||
final account = users.single as Map<String, dynamic>;
|
||||
|
||||
expect(inbound['tag'], SecureSocksSession.inboundTag);
|
||||
expect(inbound['listen'], '127.0.0.1');
|
||||
expect(inbound['port'], session.port);
|
||||
expect(settings['auth'], 'password');
|
||||
expect(account['user'], session.username);
|
||||
expect(account['pass'], session.password);
|
||||
expect(session.username, isNotEmpty);
|
||||
expect(session.password, isNotEmpty);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
// Source assertions, in the style of release_verification_regression_test.dart.
|
||||
// The behaviours below were all found broken on a device and fixed on
|
||||
// 2026-10-17 (internal/DEVICE_TEST_2026-10-17.md). None of them can be covered
|
||||
// by a Dart unit test - they live in the vendored Android runtime, and the one
|
||||
// that does not needs a live tunnel - but every one of them regresses silently
|
||||
// and looks like tidying when it does. Pin the shape instead of the behaviour.
|
||||
|
||||
const _runtime = 'local_packages/flutter_vless_android-1.1.5/android/src/main/'
|
||||
'kotlin/com/github/tfox/flutter_vless/xray';
|
||||
|
||||
void main() {
|
||||
test('Xray core recovery survives running off a bare thread', () {
|
||||
final core = File('$_runtime/core/XrayCoreManager.kt').readAsStringSync();
|
||||
|
||||
// startTimer builds a CountDownTimer, which builds a Handler. Recovery runs
|
||||
// on a plain Thread, so constructing it inline threw Looper.prepare() and
|
||||
// killed every restart attempt.
|
||||
expect(core, contains('Handler(Looper.getMainLooper())'));
|
||||
expect(core, contains('runOnMain'));
|
||||
expect(
|
||||
core,
|
||||
contains('runOnMain { startTimerOnMain(context, token) }'),
|
||||
reason: 'startTimer must not construct the CountDownTimer inline',
|
||||
);
|
||||
|
||||
// The post must not outlive the generation that scheduled it.
|
||||
expect(core, contains('if (AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN != token) return'));
|
||||
|
||||
// pb.start() succeeds before the throw, so the catch has to kill the
|
||||
// process or each retry strands another live core.
|
||||
expect(core, contains('val orphan = xrayProcess'));
|
||||
expect(
|
||||
core.indexOf('xrayProcess = null'),
|
||||
lessThan(core.indexOf('orphan?.destroy()')),
|
||||
reason: 'clear the field before destroying, so the monitor thread\'s '
|
||||
'identity guard fails and recovery is not re-entered',
|
||||
);
|
||||
});
|
||||
|
||||
test('a failed Xray core still reports why, without logging destinations', () {
|
||||
final core = File('$_runtime/core/XrayCoreManager.kt').readAsStringSync();
|
||||
|
||||
expect(core, contains('STARTUP_LOG_WINDOW_MS'));
|
||||
expect(core, contains('STARTUP_LOG_MAX_LINES'));
|
||||
expect(
|
||||
core,
|
||||
isNot(contains('reader.forEachLine { _ -> }')),
|
||||
reason: 'discarding the core output leaves exit 23 undiagnosable',
|
||||
);
|
||||
// Rule 95: a core that has been up long enough to carry traffic must not
|
||||
// have its log retained.
|
||||
expect(core, contains('startupLog.clear()'));
|
||||
});
|
||||
|
||||
test('the expiry warning has the same backstop as the deadline', () {
|
||||
final service = File('$_runtime/service/XrayVPNService.kt').readAsStringSync();
|
||||
|
||||
// The alarm alone is inexact on purpose (no SCHEDULE_EXACT_ALARM) and Doze
|
||||
// deferred it past the end of the session it was warning about.
|
||||
expect(service, contains('sessionWarningRunnable'));
|
||||
expect(
|
||||
service,
|
||||
contains('deadlineHandler.postDelayed(sessionWarningRunnable, warnDelay)'),
|
||||
reason: 'without this the warning never arrives when the app is closed',
|
||||
);
|
||||
expect(
|
||||
service,
|
||||
contains('deadlineHandler.removeCallbacks(sessionWarningRunnable)'),
|
||||
reason: 'a cancelled warning must drop its Handler callback too',
|
||||
);
|
||||
expect(service, isNot(contains('SCHEDULE_EXACT_ALARM"')));
|
||||
});
|
||||
|
||||
test('the warning promises neither a number of minutes nor an ad', () {
|
||||
final alerts = File('$_runtime/utils/RevoltAlerts.kt').readAsStringSync();
|
||||
|
||||
// It is accepted anywhere from WARNING_FLOOR_MS to LEAD + SLACK remaining,
|
||||
// so a figure can be off by minutes; and the support bypass is still
|
||||
// kDebugMode-gated (rule 87), so an ad is a dead end in release.
|
||||
expect(alerts, isNot(contains('disconnects in 5 minutes')));
|
||||
expect(alerts, isNot(contains('Watch an ad')));
|
||||
expect(alerts, contains('Revolt VPN disconnects soon'));
|
||||
});
|
||||
|
||||
test('the countdown notification is taken down when the tunnel stops', () {
|
||||
final service = File('lib/logic/notification_service.dart').readAsStringSync();
|
||||
final timer = File('lib/logic/session_timer.dart').readAsStringSync();
|
||||
final activity =
|
||||
File('android/app/src/main/kotlin/com/paladinvpn/app/MainActivity.kt')
|
||||
.readAsStringSync();
|
||||
|
||||
// updateTimer re-posts an ongoing, non-dismissible notification every tick,
|
||||
// so a tick after the service stops strands a frozen countdown.
|
||||
expect(service, contains('cancelTimer'));
|
||||
expect(activity, contains('"cancelNotification"'));
|
||||
expect(timer, contains('await NotificationService.cancelTimer()'));
|
||||
|
||||
// Must run after the service is down, or the platform swallows the cancel.
|
||||
expect(
|
||||
timer.indexOf('await vpnConnection.disconnect();'),
|
||||
lessThan(timer.indexOf('await NotificationService.cancelTimer()')),
|
||||
);
|
||||
});
|
||||
|
||||
test('only a user-requested disconnect hands the session back', () {
|
||||
final timer = File('lib/logic/session_timer.dart').readAsStringSync();
|
||||
final hivemind = File('lib/logic/hivemind_service.dart').readAsStringSync();
|
||||
|
||||
expect(hivemind, contains('releaseSession'));
|
||||
expect(hivemind, contains("_publicUrl('/session/stop')"));
|
||||
|
||||
// /session/stop takes a device_id as its only proof of ownership, so it
|
||||
// must never be reachable from an error path. disconnect() is called only
|
||||
// by the connect button; _doDisconnect is every other ending.
|
||||
final disconnectEntry = timer.indexOf('Future<void> disconnect(');
|
||||
final doDisconnect = timer.indexOf('Future<void> _doDisconnect(');
|
||||
final release = timer.indexOf('HivemindService.releaseSession()');
|
||||
expect(release, greaterThan(disconnectEntry));
|
||||
expect(
|
||||
release,
|
||||
lessThan(doDisconnect),
|
||||
reason: 'releaseSession belongs in disconnect(), not _doDisconnect',
|
||||
);
|
||||
expect(
|
||||
'HivemindService.releaseSession()'.allMatches(timer).length,
|
||||
1,
|
||||
reason: 'exactly one call site',
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user