chore: release 3.3.0 (sidebar nav fix, notification countdown, hardening)
Co-authored-by: Naveax <[email protected]>
This commit is contained in:
1 parent
4eb1fca666
commit
62104d5730
28 files changed
+646
-191
No files matched your search
@@ -0,0 +1,10 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.ps1 text eol=crlf
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.webp binary
|
||||||
|
*.jks binary
|
||||||
|
*.keystore binary
|
||||||
|
*.jar binary
|
||||||
+60
-51
@@ -5,63 +5,24 @@
|
|||||||
# everything else is excluded by extension or directory rather than by being
|
# everything else is excluded by extension or directory rather than by being
|
||||||
# listed individually. A new file is therefore ignored by default instead of
|
# listed individually. A new file is therefore ignored by default instead of
|
||||||
# needing to be named here, and this file does not enumerate what it protects.
|
# needing to be named here, and this file does not enumerate what it protects.
|
||||||
#
|
|
||||||
# Machine-local exclusions live in .git/info/exclude, which is never committed.
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# ── OS & editor ────────────────────────────────────────────────────────────
|
# ── Secrets ────────────────────────────────────────────────────────────────
|
||||||
*.swp
|
|
||||||
.DS_Store
|
|
||||||
.atom/
|
|
||||||
.history
|
|
||||||
.svn/
|
|
||||||
.java-version
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
|
|
||||||
# ── Flutter / Dart build output ────────────────────────────────────────────
|
|
||||||
.dart_tool/
|
|
||||||
.flutter-plugins
|
|
||||||
.flutter-plugins-dependencies
|
|
||||||
.pub-cache/
|
|
||||||
.pub/
|
|
||||||
build/
|
|
||||||
**/build/
|
|
||||||
local_packages/*/build/
|
|
||||||
local_packages/*/.dart_tool/
|
|
||||||
*.dart_tool/
|
|
||||||
**/doc/api/
|
|
||||||
**/ios/Flutter/.last_build_id
|
|
||||||
|
|
||||||
# ── Android build output ───────────────────────────────────────────────────
|
|
||||||
**/android/**/gradle-wrapper.jar
|
|
||||||
**/android/.gradle
|
|
||||||
**/android/captures/
|
|
||||||
**/android/gradlew
|
|
||||||
**/android/gradlew.bat
|
|
||||||
**/android/local.properties
|
|
||||||
**/android/**/GeneratedPluginRegistrant.java
|
|
||||||
**/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java
|
|
||||||
**/android/.cxx/
|
|
||||||
**/android/app/build/
|
|
||||||
**/android/app/crashlytics-build.properties
|
|
||||||
|
|
||||||
# ── Python bytecode ────────────────────────────────────────────────────────
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
|
|
||||||
# ── Credentials & machine-local config ─────────────────────────────────────
|
|
||||||
*.keystore
|
*.keystore
|
||||||
*.jks
|
*.jks
|
||||||
android/key.properties
|
android/key.properties
|
||||||
android/admob.properties
|
android/admob.properties
|
||||||
|
|
||||||
|
# Real IPs and IDs. app_config.example.dart is the committed template.
|
||||||
lib/logic/app_config.dart
|
lib/logic/app_config.dart
|
||||||
devtools_options.yaml
|
|
||||||
|
|
||||||
# ── File types the shipped app does not contain ────────────────────────────
|
# ── File types the shipped app does not contain ────────────────────────────
|
||||||
# Server code, infrastructure configs, captures, archives and logs. None of
|
# Server code, infrastructure configs, captures, archives, dumps and logs.
|
||||||
# these extensions are tracked, so a blanket rule is safe and stays safe.
|
# None of these extensions are tracked, so a blanket rule is safe and stays
|
||||||
|
# safe — it covers files that do not exist yet.
|
||||||
*.py
|
*.py
|
||||||
|
*.pyc
|
||||||
*.json
|
*.json
|
||||||
*.conf
|
*.conf
|
||||||
*.sh
|
*.sh
|
||||||
@@ -81,10 +42,58 @@ devtools_options.yaml
|
|||||||
!/CONTRIBUTING.md
|
!/CONTRIBUTING.md
|
||||||
!/PRIVACY_POLICY.md
|
!/PRIVACY_POLICY.md
|
||||||
|
|
||||||
# ── Local working directories ──────────────────────────────────────────────
|
# ── Working directories ────────────────────────────────────────────────────
|
||||||
# Working material that is not part of the app. Needs a committed rule rather
|
# Material that is not part of the app. These need directory rules rather
|
||||||
# than a local-only one, because it can hold source-file types the extension
|
# than type rules, because they can hold source-file types the rules above
|
||||||
# rules above deliberately do not cover.
|
# deliberately do not cover.
|
||||||
.local/
|
.local/
|
||||||
|
internal/
|
||||||
|
hivemind/
|
||||||
|
scraped/
|
||||||
backup/
|
backup/
|
||||||
|
backups/
|
||||||
lib/logic/backup/
|
lib/logic/backup/
|
||||||
|
|
||||||
|
# ── Flutter / Dart build output ────────────────────────────────────────────
|
||||||
|
.dart_tool/
|
||||||
|
*.dart_tool/
|
||||||
|
.flutter-plugins
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
.pub-cache/
|
||||||
|
.pub/
|
||||||
|
build/
|
||||||
|
**/build/
|
||||||
|
local_packages/*/build/
|
||||||
|
local_packages/*/.dart_tool/
|
||||||
|
**/doc/api/
|
||||||
|
**/ios/Flutter/.last_build_id
|
||||||
|
|
||||||
|
# ── Android build output ───────────────────────────────────────────────────
|
||||||
|
**/android/.gradle
|
||||||
|
**/android/.cxx/
|
||||||
|
**/android/app/build/
|
||||||
|
**/android/captures/
|
||||||
|
**/android/gradlew
|
||||||
|
**/android/gradlew.bat
|
||||||
|
**/android/local.properties
|
||||||
|
**/android/**/gradle-wrapper.jar
|
||||||
|
**/android/**/GeneratedPluginRegistrant.java
|
||||||
|
**/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java
|
||||||
|
**/android/app/crashlytics-build.properties
|
||||||
|
|
||||||
|
# ── Editors, tooling & OS ──────────────────────────────────────────────────
|
||||||
|
.claude/
|
||||||
|
.codewhale/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.atom/
|
||||||
|
.history
|
||||||
|
.svn/
|
||||||
|
.java-version
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
__pycache__/
|
||||||
|
devtools_options.yaml
|
||||||
|
|
||||||
|
#why are you even looking at this file? it's a secret. go away.
|
||||||
|
whatsthis
|
||||||
@@ -5,6 +5,77 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [3.3.0] — Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Session countdown in the VPN notification, updated each second while connected.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Session-expiry desync** — the app could show "Online" with no internet after
|
||||||
|
the server ended the session. The countdown no longer freezes when a poll fails.
|
||||||
|
- **Notification Disconnect button** disappeared a second after connecting.
|
||||||
|
Re-posting the foreground notification replaces it wholesale, so the action,
|
||||||
|
colour and tap-intent flutter_vless sets are now rebuilt rather than dropped.
|
||||||
|
- **Speed readout** divided the byte delta by the nominal poll interval instead of
|
||||||
|
the real time between polls — wrong by up to 6x around every background switch.
|
||||||
|
- **Settings toggles** now apply at launch. Rain, lightning and haptics were only
|
||||||
|
read from disk when Settings mounted, so an effect turned off came back — ticker
|
||||||
|
and all — on every relaunch, and haptic feedback did nothing until Settings had
|
||||||
|
been opened at least once.
|
||||||
|
- **Haptic feedback** now fires `mediumImpact()` instead of `lightImpact()` (which
|
||||||
|
is imperceptible on many Android devices), wrapped best-effort so a platform
|
||||||
|
vibration failure cannot fail a connect/disconnect, with a preview tap when the
|
||||||
|
toggle is switched on.
|
||||||
|
- First-launch disclosure dialog said data was linked to "your account". It is
|
||||||
|
linked to a randomly generated device ID, and the policy is in the sidebar, not
|
||||||
|
on a website.
|
||||||
|
- Sidebar "Settings" and "Check for updates" used the drawer's context after
|
||||||
|
`Navigator.pop()` — a defunct context. They now capture the root navigator (and
|
||||||
|
messenger) before closing the drawer. *(Fix ported from Naveax's review.)*
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Rain overlay ticks only while enabled, repaints through a scoped `Listenable`
|
||||||
|
instead of rebuilding every frame, and precomputes drop colours.
|
||||||
|
- Lightning no longer fires strikes while disabled.
|
||||||
|
- Connect button pulse pauses when backgrounded; brand image hoisted out of the
|
||||||
|
per-frame rebuild.
|
||||||
|
- Server poll drops to 30 s while backgrounded (5 s foreground), with an immediate
|
||||||
|
re-sync on resume. The 1 s countdown keeps running.
|
||||||
|
- Health probe runs only while disconnected, and pauses in the background.
|
||||||
|
- Speed and support widgets rebuild on value change instead of every second.
|
||||||
|
- Notification channel is created once per process, not once per second.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- VLESS engine initializes under the splash screen; the intro waits on
|
||||||
|
`VpnConnection.ready` (8 s cap) before showing the main screen.
|
||||||
|
- AdMob SDK init moved off the blocking pre-`runApp()` path so startup cannot hang
|
||||||
|
on an unreachable Google endpoint.
|
||||||
|
- `app_config.example.dart` drops the dead bootstrap fields and documents
|
||||||
|
`hivemindApiPublic` in their place.
|
||||||
|
- `.gitignore` reorganized around type and directory rules; the enumerated
|
||||||
|
filename list is gone (redundant with the type rules) and `.claude/` is now
|
||||||
|
ignored.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Update checker pins the release URL to this repo's path on `github.com`, not just
|
||||||
|
the host. Anything else falls back to the releases page.
|
||||||
|
- Notification timer updates are gated on a live tunnel, so a late tick cannot leave
|
||||||
|
an ongoing notification with no foreground service behind it.
|
||||||
|
- Dropped `READ_EXTERNAL_STORAGE`, which `flutter_vless` pulls in but the app never uses
|
||||||
|
(least privilege).
|
||||||
|
- Pinned the Gradle 8.14 distribution SHA-256; `validateDistributionUrl` enabled
|
||||||
|
(supply-chain integrity).
|
||||||
|
|
||||||
|
### Build & tooling (ported from Naveax)
|
||||||
|
- Removed the deprecated `android.enableR8` flag.
|
||||||
|
- Added `analysis_options.yaml` (flutter_lints, `local_packages/**` excluded, `avoid_print`).
|
||||||
|
- Added `.gitattributes` to pin LF line endings repo-wide.
|
||||||
|
- Credit: reviewed and ported from **Naveax**.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
- Privacy policy: disclosed that the server sees the connecting IP, corrected the
|
||||||
|
"stored in memory" claim, and documented consent being changeable from the sidebar.
|
||||||
|
|
||||||
## [3.2.2] — 2026-08-08
|
## [3.2.2] — 2026-08-08
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+6
-2
@@ -15,9 +15,13 @@ We collect the minimum required to operate the service:
|
|||||||
- A randomly generated device ID created on your phone. This is not linked to your identity in any way.
|
- A randomly generated device ID created on your phone. This is not linked to your identity in any way.
|
||||||
- Your connection duration and data usage for the current session, linked to your device ID. This is used solely to enforce the per-session quota (see below).
|
- Your connection duration and data usage for the current session, linked to your device ID. This is used solely to enforce the per-session quota (see below).
|
||||||
|
|
||||||
|
## What our server sees
|
||||||
|
|
||||||
|
To start, check, or refresh your session, the app contacts our API directly rather than through the VPN tunnel. Our server therefore sees the IP address you are connecting from, alongside your device ID, for as long as the app is running. This is a necessary consequence of how sessions are issued and kept alive — it is separate from your browsing traffic, which is carried inside the tunnel and is described below.
|
||||||
|
|
||||||
## Session quotas
|
## Session quotas
|
||||||
|
|
||||||
Each session lasts up to **2 hours** or **10 GB** of data — whichever comes first. When your session expires or hits the data cap, the tunnel disconnects. You can start a new session by watching another ad. Session data (duration and bytes transferred) is stored in memory and deleted when your session ends.
|
Each session lasts up to **2 hours** or **10 GB** of data — whichever comes first. When your session expires or hits the data cap, the tunnel disconnects. You can start a new session by watching another ad. Session records (duration and bytes transferred) are held on our server for the life of your session and removed when it ends.
|
||||||
|
|
||||||
Support ads (the "Support us" button) extend an active session by 30 minutes and add extra data allowance.
|
Support ads (the "Support us" button) extend an active session by 30 minutes and add extra data allowance.
|
||||||
|
|
||||||
@@ -34,7 +38,7 @@ We keep a short in-memory log buffer (last 200 lines) for debugging and server h
|
|||||||
|
|
||||||
## Ads & Consent
|
## Ads & Consent
|
||||||
|
|
||||||
ReVoltVPN uses Google AdMob to display rewarded video ads. On first launch, users in the European Economic Area (EEA) and United Kingdom are shown a consent dialog (provided by Google's User Messaging Platform) that lets them choose whether to allow personalized ads. This consent choice is stored on your device and can be changed at any time by reinstalling the app. Google may collect data as part of ad delivery. Please refer to Google's Privacy Policy for details.
|
ReVoltVPN uses Google AdMob to display rewarded video ads. Where required — for users in the European Economic Area (EEA) and United Kingdom — a consent dialog provided by Google's User Messaging Platform is shown before the first ad is requested, letting you choose whether to allow personalized ads. This choice is stored on your device and can be reviewed at any time from the "Ad Consent" entry in the sidebar menu. Google may collect data as part of ad delivery. Please refer to Google's Privacy Policy for details.
|
||||||
|
|
||||||
## Your traffic
|
## Your traffic
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
analyzer:
|
||||||
|
exclude:
|
||||||
|
- local_packages/**
|
||||||
|
- whatsthis/**
|
||||||
|
|
||||||
|
linter:
|
||||||
|
rules:
|
||||||
|
avoid_print: true
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||||
<!-- VpnService provides its own system notification; SPECIAL_USE is not needed -->
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
<!-- Required by url_launcher on Android 11+ to open external browser -->
|
|
||||||
|
<!-- flutter_vless 1.1.5 declares this legacy permission. ReVolt never needs it. -->
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
|
tools:node="remove"/>
|
||||||
|
|
||||||
<queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.intent.action.VIEW"/>
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
@@ -21,7 +27,6 @@
|
|||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:extractNativeLibs="true">
|
android:extractNativeLibs="true">
|
||||||
|
|
||||||
<!-- AdMob App ID — injected at build time from android/admob.properties -->
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.google.android.gms.ads.APPLICATION_ID"
|
android:name="com.google.android.gms.ads.APPLICATION_ID"
|
||||||
android:value="${ADMOB_APP_ID}"/>
|
android:value="${ADMOB_APP_ID}"/>
|
||||||
@@ -36,16 +41,15 @@
|
|||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="io.flutter.embedding.android.NormalTheme"
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
android:resource="@style/NormalTheme"
|
android:resource="@style/NormalTheme"/>
|
||||||
/>
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2"/>
|
android:value="2"/>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
package com.paladinvpn.app
|
package com.paladinvpn.app
|
||||||
|
|
||||||
|
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.os.Build
|
||||||
|
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 io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
@@ -11,6 +21,11 @@ class MainActivity : FlutterActivity() {
|
|||||||
// Must match the string in lib/logic/updater.dart.
|
// Must match the string in lib/logic/updater.dart.
|
||||||
private val installerChannel = "com.revoltvpn.app/installer"
|
private val installerChannel = "com.revoltvpn.app/installer"
|
||||||
|
|
||||||
|
// Must match lib/logic/notification_service.dart.
|
||||||
|
private val notificationChannel = "com.revoltvpn.app/notification"
|
||||||
|
|
||||||
|
private var notificationChannelReady = false
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
@@ -22,6 +37,102 @@ class MainActivity : FlutterActivity() {
|
|||||||
result.notImplemented()
|
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)
|
||||||
|
} else {
|
||||||
|
result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) !=
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureNotificationChannel()
|
||||||
|
|
||||||
|
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
} else {
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.setShowWhen(false)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
|
||||||
|
if (launchIntent != null) {
|
||||||
|
builder.setContentIntent(
|
||||||
|
PendingIntent.getActivity(this, 0, launchIntent, flags)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
NotificationManagerCompat.from(this).notify(NOTIFICATION_ID, builder.build())
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// The foreground notification may not be up yet — the update is cosmetic.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 channel = NotificationChannel(
|
||||||
|
NOTIFICATION_CHANNEL_ID,
|
||||||
|
"Revolt VPN Background Service",
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
)
|
||||||
|
channel.setShowBadge(false)
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
notificationChannelReady = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,4 +150,10 @@ class MainActivity : FlutterActivity() {
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// Must match flutter_vless's XrayCoreManager.
|
||||||
|
private const val NOTIFICATION_ID = 1
|
||||||
|
private const val NOTIFICATION_CHANNEL_ID = "XRAY_SERVICE_CHANNEL"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
org.gradle.jvmargs=-Xmx1536M
|
org.gradle.jvmargs=-Xmx1536M
|
||||||
android.enableR8=true
|
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
|
||||||
android.builtInKotlin=false
|
android.builtInKotlin=false
|
||||||
# This newDsl flag was added automatically by Flutter migrator
|
|
||||||
android.newDsl=false
|
android.newDsl=false
|
||||||
@@ -3,3 +3,6 @@ distributionPath=wrapper/dists
|
|||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||||
|
distributionSha256Sum=efe9a3d147d948d7528a9887fa35abcf24ca1a43ad06439996490f77569b02d1
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:revoltvpn/logic/app_colors.dart';
|
import 'package:revoltvpn/logic/app_colors.dart';
|
||||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||||
@@ -15,7 +15,7 @@ class ConnectButton extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ConnectButtonState extends State<ConnectButton>
|
class _ConnectButtonState extends State<ConnectButton>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
|
||||||
bool _busy = false;
|
bool _busy = false;
|
||||||
DateTime _lastTap = DateTime.now();
|
DateTime _lastTap = DateTime.now();
|
||||||
AnimationController? _pulse;
|
AnimationController? _pulse;
|
||||||
@@ -29,6 +29,7 @@ class _ConnectButtonState extends State<ConnectButton>
|
|||||||
final pulse = AnimationController(vsync: this, duration: const Duration(milliseconds: 2000));
|
final pulse = AnimationController(vsync: this, duration: const Duration(milliseconds: 2000));
|
||||||
_pulse = pulse;
|
_pulse = pulse;
|
||||||
_pulseAnim = Tween(begin: 0.0, end: 1.0).animate(CurvedAnimation(parent: pulse, curve: Curves.easeInOut));
|
_pulseAnim = Tween(begin: 0.0, end: 1.0).animate(CurvedAnimation(parent: pulse, curve: Curves.easeInOut));
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
context.read<VpnConnection>().addListener(_onVpnChanged);
|
context.read<VpnConnection>().addListener(_onVpnChanged);
|
||||||
});
|
});
|
||||||
@@ -36,6 +37,7 @@ class _ConnectButtonState extends State<ConnectButton>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
context.read<VpnConnection>().removeListener(_onVpnChanged);
|
context.read<VpnConnection>().removeListener(_onVpnChanged);
|
||||||
_pulse?.dispose();
|
_pulse?.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -51,6 +53,20 @@ class _ConnectButtonState extends State<ConnectButton>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (state == AppLifecycleState.paused ||
|
||||||
|
state == AppLifecycleState.detached) {
|
||||||
|
_pulse?.stop();
|
||||||
|
} else if (state == AppLifecycleState.resumed) {
|
||||||
|
final vpn = context.read<VpnConnection>();
|
||||||
|
if (vpn.status == VpnStatus.connected) {
|
||||||
|
_pulse?.repeat(reverse: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _handleTap() async {
|
Future<void> _handleTap() async {
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
@@ -65,7 +81,7 @@ class _ConnectButtonState extends State<ConnectButton>
|
|||||||
if (vpn.status == VpnStatus.connected || vpn.status == VpnStatus.connecting) {
|
if (vpn.status == VpnStatus.connected || vpn.status == VpnStatus.connecting) {
|
||||||
_busy = false;
|
_busy = false;
|
||||||
await timer.disconnect();
|
await timer.disconnect();
|
||||||
if (hapticEnabled) HapticFeedback.lightImpact();
|
unawaited(Haptics.connectionChanged());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +101,7 @@ class _ConnectButtonState extends State<ConnectButton>
|
|||||||
final ok = await vpn.connect();
|
final ok = await vpn.connect();
|
||||||
if (ok) {
|
if (ok) {
|
||||||
timer.start();
|
timer.start();
|
||||||
if (hapticEnabled) HapticFeedback.lightImpact();
|
unawaited(Haptics.connectionChanged());
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _busy = false);
|
if (mounted) setState(() => _busy = false);
|
||||||
@@ -105,15 +121,36 @@ class _ConnectButtonState extends State<ConnectButton>
|
|||||||
child: _pulseAnim != null
|
child: _pulseAnim != null
|
||||||
? AnimatedBuilder(
|
? AnimatedBuilder(
|
||||||
animation: _pulseAnim!,
|
animation: _pulseAnim!,
|
||||||
builder: (_, __) => _buildCircle(isConnected, spinning, _pulseValue),
|
child: _buildInner(spinning),
|
||||||
|
builder: (_, child) =>
|
||||||
|
_buildCircle(isConnected, _pulseValue, child),
|
||||||
)
|
)
|
||||||
: _buildCircle(isConnected, spinning, 0),
|
: _buildCircle(isConnected, 0, _buildInner(spinning)),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCircle(bool isConnected, bool spinning, double pulse) {
|
Widget _buildInner(bool spinning) {
|
||||||
|
if (spinning) {
|
||||||
|
return const Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
color: AppColors.accent70,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Image.asset('assets/brand.png', fit: BoxFit.contain),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCircle(bool isConnected, double pulse, Widget? child) {
|
||||||
final glowAlpha = isConnected ? ((0.3 + pulse * 0.25) * 255).round() : 0;
|
final glowAlpha = isConnected ? ((0.3 + pulse * 0.25) * 255).round() : 0;
|
||||||
final glowRadius = isConnected ? 18.0 + (pulse * 14) : 0.0;
|
final glowRadius = isConnected ? 18.0 + (pulse * 14) : 0.0;
|
||||||
|
|
||||||
@@ -128,21 +165,17 @@ class _ConnectButtonState extends State<ConnectButton>
|
|||||||
width: isConnected ? 2.5 : 1.5,
|
width: isConnected ? 2.5 : 1.5,
|
||||||
),
|
),
|
||||||
boxShadow: isConnected
|
boxShadow: isConnected
|
||||||
? [BoxShadow(color: Color.fromARGB(glowAlpha, 255, 214, 0), blurRadius: glowRadius, spreadRadius: glowRadius * 0.3)]
|
? [
|
||||||
|
BoxShadow(
|
||||||
|
color: Color.fromARGB(glowAlpha, 255, 214, 0),
|
||||||
|
blurRadius: glowRadius,
|
||||||
|
spreadRadius: glowRadius * 0.3,
|
||||||
|
)
|
||||||
|
]
|
||||||
: [],
|
: [],
|
||||||
),
|
),
|
||||||
clipBehavior: Clip.hardEdge,
|
clipBehavior: Clip.hardEdge,
|
||||||
child: spinning
|
child: child,
|
||||||
? const Center(
|
|
||||||
child: SizedBox(
|
|
||||||
width: 36, height: 36,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2.5, color: AppColors.accent70),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Padding(
|
|
||||||
padding: const EdgeInsets.all(14),
|
|
||||||
child: Image.asset('assets/brand.png', fit: BoxFit.contain),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,10 +64,11 @@ class _DisclosureDialog extends StatelessWidget {
|
|||||||
const Text(
|
const Text(
|
||||||
'Revolt VPN creates a secure VPN tunnel to protect your '
|
'Revolt VPN creates a secure VPN tunnel to protect your '
|
||||||
'traffic. To enforce your data/time quota, we log your '
|
'traffic. To enforce your data/time quota, we log your '
|
||||||
'connection duration and data usage, linked to your '
|
'connection duration and data usage, linked to a randomly '
|
||||||
'account. We do not log or monitor the content of your '
|
'generated device ID — not to your identity. We do not log '
|
||||||
'traffic. We do not share your data with third parties.'
|
'or monitor the content of your traffic. We do not share '
|
||||||
'If you have more questions, visit our website.',
|
'your data with third parties. The full policy is in the '
|
||||||
|
'sidebar menu under "Privacy Policy".',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: AppColors.textMuted,
|
color: AppColors.textMuted,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
|
|||||||
@@ -37,11 +37,24 @@ class _LightningEffectState extends State<LightningEffect>
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 400));
|
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 400));
|
||||||
|
lightningEnabled.addListener(_onEnabledChanged);
|
||||||
|
if (lightningEnabled.value) _scheduleNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onEnabledChanged() {
|
||||||
|
if (lightningEnabled.value) {
|
||||||
_scheduleNext();
|
_scheduleNext();
|
||||||
|
} else {
|
||||||
|
_timer?.cancel();
|
||||||
|
_ctrl.stop();
|
||||||
|
_bolt = null;
|
||||||
|
}
|
||||||
|
if (mounted) setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
lightningEnabled.removeListener(_onEnabledChanged);
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_ctrl.dispose();
|
_ctrl.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -54,13 +67,13 @@ class _LightningEffectState extends State<LightningEffect>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _strike() {
|
void _strike() {
|
||||||
if (!mounted) return;
|
if (!mounted || !lightningEnabled.value) return;
|
||||||
_bolt = LightningBolt.random(_random);
|
_bolt = LightningBolt.random(_random);
|
||||||
_ctrl.forward(from: 0.0);
|
_ctrl.forward(from: 0.0);
|
||||||
|
|
||||||
if (_random.nextDouble() < widget.doubleChance) {
|
if (_random.nextDouble() < widget.doubleChance) {
|
||||||
Future.delayed(const Duration(milliseconds: 120), () {
|
Future.delayed(const Duration(milliseconds: 120), () {
|
||||||
if (mounted) {
|
if (mounted && lightningEnabled.value) {
|
||||||
_bolt = LightningBolt.random(_random);
|
_bolt = LightningBolt.random(_random);
|
||||||
_ctrl.forward(from: 0.0);
|
_ctrl.forward(from: 0.0);
|
||||||
}
|
}
|
||||||
@@ -78,7 +91,7 @@ class _LightningEffectState extends State<LightningEffect>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (!lightningEnabled) return const SizedBox.shrink();
|
if (!lightningEnabled.value) return const SizedBox.shrink();
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: _ctrl,
|
animation: _ctrl,
|
||||||
builder: (_, child) {
|
builder: (_, child) {
|
||||||
|
|||||||
+33
-17
@@ -36,6 +36,7 @@ class _RainEffectState extends State<RainEffect>
|
|||||||
late final Ticker _ticker;
|
late final Ticker _ticker;
|
||||||
final _drops = <_Drop>[];
|
final _drops = <_Drop>[];
|
||||||
final _random = Random();
|
final _random = Random();
|
||||||
|
final _repaint = ValueNotifier<int>(0);
|
||||||
Duration _last = Duration.zero;
|
Duration _last = Duration.zero;
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
@@ -44,17 +45,33 @@ class _RainEffectState extends State<RainEffect>
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
for (int i = 0; i < widget.dropCount; i++) {
|
for (int i = 0; i < widget.dropCount; i++) {
|
||||||
_drops.add(_Drop.spawn(_random, widget.minLength, widget.maxLength));
|
_drops.add(
|
||||||
|
_Drop.spawn(_random, widget.minLength, widget.maxLength, widget.color));
|
||||||
}
|
}
|
||||||
_ticker = createTicker(_onTick)..start();
|
_ticker = createTicker(_onTick);
|
||||||
|
rainEnabled.addListener(_onEnabledChanged);
|
||||||
|
if (rainEnabled.value) _ticker.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
rainEnabled.removeListener(_onEnabledChanged);
|
||||||
_ticker.dispose();
|
_ticker.dispose();
|
||||||
|
_repaint.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onEnabledChanged() {
|
||||||
|
if (rainEnabled.value) {
|
||||||
|
// Ticker elapsed restarts at zero; a stale _last makes the first dt negative.
|
||||||
|
_last = Duration.zero;
|
||||||
|
_ticker.start();
|
||||||
|
} else {
|
||||||
|
_ticker.stop();
|
||||||
|
}
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
// Tick
|
// Tick
|
||||||
|
|
||||||
void _onTick(Duration now) {
|
void _onTick(Duration now) {
|
||||||
@@ -68,23 +85,23 @@ class _RainEffectState extends State<RainEffect>
|
|||||||
for (final d in _drops) {
|
for (final d in _drops) {
|
||||||
d.y += d.speed * dt;
|
d.y += d.speed * dt;
|
||||||
if (d.y > 1.08) {
|
if (d.y > 1.08) {
|
||||||
d.spawn(_random, widget.minLength, widget.maxLength);
|
d.spawn(_random, widget.minLength, widget.maxLength, widget.color);
|
||||||
d.y = -0.06 - _random.nextDouble() * 0.08;
|
d.y = -0.06 - _random.nextDouble() * 0.08;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setState(() {});
|
_repaint.value++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build
|
// Build
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (!rainEnabled) return const SizedBox.shrink();
|
if (!rainEnabled.value) return const SizedBox.shrink();
|
||||||
return CustomPaint(
|
return CustomPaint(
|
||||||
painter: _RainPainter(
|
painter: _RainPainter(
|
||||||
drops: _drops,
|
drops: _drops,
|
||||||
color: widget.color,
|
|
||||||
windDrift: widget.windDrift,
|
windDrift: widget.windDrift,
|
||||||
|
repaint: _repaint,
|
||||||
),
|
),
|
||||||
size: Size.infinite,
|
size: Size.infinite,
|
||||||
);
|
);
|
||||||
@@ -100,33 +117,33 @@ class _Drop {
|
|||||||
double y;
|
double y;
|
||||||
double speed;
|
double speed;
|
||||||
double length;
|
double length;
|
||||||
double opacity;
|
Color color;
|
||||||
|
|
||||||
_Drop({
|
_Drop({
|
||||||
required this.x,
|
required this.x,
|
||||||
required this.y,
|
required this.y,
|
||||||
required this.speed,
|
required this.speed,
|
||||||
required this.length,
|
required this.length,
|
||||||
required this.opacity,
|
required this.color,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Create a drop at a random position anywhere on screen.
|
/// Create a drop at a random position anywhere on screen.
|
||||||
factory _Drop.spawn(Random r, double minLen, double maxLen) {
|
factory _Drop.spawn(Random r, double minLen, double maxLen, Color base) {
|
||||||
return _Drop(
|
return _Drop(
|
||||||
x: r.nextDouble(),
|
x: r.nextDouble(),
|
||||||
y: r.nextDouble(),
|
y: r.nextDouble(),
|
||||||
speed: 0.30 + r.nextDouble() * 0.55,
|
speed: 0.30 + r.nextDouble() * 0.55,
|
||||||
length: minLen + r.nextDouble() * (maxLen - minLen),
|
length: minLen + r.nextDouble() * (maxLen - minLen),
|
||||||
opacity: 0.05 + r.nextDouble() * 0.12,
|
color: base.withAlpha(((0.05 + r.nextDouble() * 0.12) * 255).round()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-randomise all properties (call when recycling a drop to the top).
|
/// Re-randomise all properties (call when recycling a drop to the top).
|
||||||
void spawn(Random r, double minLen, double maxLen) {
|
void spawn(Random r, double minLen, double maxLen, Color base) {
|
||||||
x = r.nextDouble();
|
x = r.nextDouble();
|
||||||
speed = 0.30 + r.nextDouble() * 0.55;
|
speed = 0.30 + r.nextDouble() * 0.55;
|
||||||
length = minLen + r.nextDouble() * (maxLen - minLen);
|
length = minLen + r.nextDouble() * (maxLen - minLen);
|
||||||
opacity = 0.05 + r.nextDouble() * 0.12;
|
color = base.withAlpha(((0.05 + r.nextDouble() * 0.12) * 255).round());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,14 +153,13 @@ class _Drop {
|
|||||||
|
|
||||||
class _RainPainter extends CustomPainter {
|
class _RainPainter extends CustomPainter {
|
||||||
final List<_Drop> drops;
|
final List<_Drop> drops;
|
||||||
final Color color;
|
|
||||||
final double windDrift;
|
final double windDrift;
|
||||||
|
|
||||||
_RainPainter({
|
_RainPainter({
|
||||||
required this.drops,
|
required this.drops,
|
||||||
required this.color,
|
|
||||||
required this.windDrift,
|
required this.windDrift,
|
||||||
});
|
required Listenable repaint,
|
||||||
|
}) : super(repaint: repaint);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
@@ -157,11 +173,11 @@ class _RainPainter extends CustomPainter {
|
|||||||
final ex = sx - windDrift; // slight leftward slant
|
final ex = sx - windDrift; // slight leftward slant
|
||||||
final ey = sy + d.length;
|
final ey = sy + d.length;
|
||||||
|
|
||||||
paint.color = color.withAlpha((d.opacity * 255).round());
|
paint.color = d.color;
|
||||||
canvas.drawLine(Offset(sx, sy), Offset(ex, ey), paint);
|
canvas.drawLine(Offset(sx, sy), Offset(ex, ey), paint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRepaint(_RainPainter old) => true; // always animating
|
bool shouldRepaint(_RainPainter old) => false; // repaint listenable drives frames
|
||||||
}
|
}
|
||||||
@@ -12,9 +12,7 @@ class SettingsButton extends StatelessWidget {
|
|||||||
|
|
||||||
// Opens the settings page sliding in from the right — matches the
|
// Opens the settings page sliding in from the right — matches the
|
||||||
// sidebar end-drawer animation so it feels like one continuous motion.
|
// sidebar end-drawer animation so it feels like one continuous motion.
|
||||||
static void open(BuildContext context) {
|
static PageRoute<void> _route() => PageRouteBuilder<void>(
|
||||||
Navigator.of(context).push(
|
|
||||||
PageRouteBuilder(
|
|
||||||
transitionDuration: const Duration(milliseconds: 300),
|
transitionDuration: const Duration(milliseconds: 300),
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||||
pageBuilder: (_, __, ___) => const SettingsScreen(),
|
pageBuilder: (_, __, ___) => const SettingsScreen(),
|
||||||
@@ -23,15 +21,21 @@ class SettingsButton extends StatelessWidget {
|
|||||||
position: Tween<Offset>(
|
position: Tween<Offset>(
|
||||||
begin: const Offset(1.0, 0.0),
|
begin: const Offset(1.0, 0.0),
|
||||||
end: Offset.zero,
|
end: Offset.zero,
|
||||||
).animate(CurvedAnimation(
|
).animate(
|
||||||
parent: animation,
|
CurvedAnimation(parent: animation, curve: Curves.easeOut),
|
||||||
curve: Curves.easeOut,
|
),
|
||||||
)),
|
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
static void openWithNavigator(NavigatorState navigator) {
|
||||||
|
if (!navigator.mounted) return;
|
||||||
|
navigator.push(_route());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void open(BuildContext context) {
|
||||||
|
openWithNavigator(Navigator.of(context, rootNavigator: true));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:revoltvpn/logic/app_colors.dart';
|
import 'package:revoltvpn/logic/app_colors.dart';
|
||||||
@@ -126,8 +127,9 @@ class _SidebarDrawerState extends State<SidebarDrawer> {
|
|||||||
icon: Icons.settings,
|
icon: Icons.settings,
|
||||||
label: 'Settings',
|
label: 'Settings',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
final navigator = Navigator.of(context, rootNavigator: true);
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
SettingsButton.open(context);
|
SettingsButton.openWithNavigator(navigator);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -135,8 +137,12 @@ class _SidebarDrawerState extends State<SidebarDrawer> {
|
|||||||
icon: Icons.system_update_alt,
|
icon: Icons.system_update_alt,
|
||||||
label: 'Check for updates',
|
label: 'Check for updates',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
final navigator = Navigator.of(context, rootNavigator: true);
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
Updater.check(context);
|
unawaited(
|
||||||
|
Updater.check(navigator: navigator, messenger: messenger),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ class SpeedBox extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Consumer<SessionTimer>(
|
return Selector<SessionTimer, double>(
|
||||||
builder: (context, timer, _) {
|
selector: (_, timer) => timer.currentSpeedKBps,
|
||||||
final speed = timer.currentSpeedKBps > 0.5
|
builder: (context, speedKBps, _) {
|
||||||
? '${timer.currentSpeedKBps.toStringAsFixed(1)} KB/s'
|
final speed = speedKBps > 0.5
|
||||||
|
? '${speedKBps.toStringAsFixed(1)} KB/s'
|
||||||
: 'Idle';
|
: 'Idle';
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
|
|||||||
@@ -34,9 +34,10 @@ class _SupportButtonState extends State<SupportButton> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Consumer<SessionTimer>(
|
return Selector<SessionTimer, bool>(
|
||||||
builder: (context, timer, _) {
|
selector: (_, timer) => timer.isRunning || timer.hasSyncedOnce,
|
||||||
if (!timer.isRunning && !timer.hasSyncedOnce) {
|
builder: (context, visible, _) {
|
||||||
|
if (!visible) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,26 +8,11 @@ abstract final class AppConfig {
|
|||||||
// ── Server ───────────────────────────────────────────────────────────
|
// ── Server ───────────────────────────────────────────────────────────
|
||||||
/// Your server's public IP. The VPN tunnel is pinned to this.
|
/// Your server's public IP. The VPN tunnel is pinned to this.
|
||||||
static const String serverIp = '0.0.0.0'; // ◄── REPLACE
|
static const String serverIp = '0.0.0.0'; // ◄── REPLACE
|
||||||
|
static const String hivemindApiPublic = 'https://YOUR_DOMAIN/api'; // ◄── REPLACE
|
||||||
// ── Bootstrap (first-connect config fetch) ────────────────────────────
|
|
||||||
/// Hardcoded Reality config. App connects with this, fetches the real
|
|
||||||
/// per-session VLESS URL through the tunnel, then reconnects.
|
|
||||||
static String get bootstrapVlessUrl =>
|
|
||||||
'vless://$bootstrapUuid@$serverIp:8443'
|
|
||||||
'?security=reality&type=xhttp&path=/revolt'
|
|
||||||
'&pbk=$realityPbk&sni=www.github.com&sid=$realitySid&fp=chrome'
|
|
||||||
'#ReVoltVPN';
|
|
||||||
|
|
||||||
/// Generate with `xray uuid` on the server. Must match xray_config_reality.json.
|
|
||||||
static const String bootstrapUuid = '00000000-0000-0000-0000-000000000000'; // ◄── REPLACE
|
|
||||||
|
|
||||||
/// Server's Reality public key (from `xray x25519`).
|
|
||||||
static const String realityPbk = 'REPLACE_WITH_YOUR_PUBLIC_KEY';
|
|
||||||
|
|
||||||
/// Must be in xray_config_reality.json shortIds[].
|
|
||||||
static const String realitySid = 'abc123';
|
|
||||||
|
|
||||||
// ── Reality (VPN tunnel) ──────────────────────────────────────────────
|
// ── Reality (VPN tunnel) ──────────────────────────────────────────────
|
||||||
|
// Credentials (public key, shortId) come from /session/status, not from
|
||||||
|
// here. Only the transport parameters below are compiled in.
|
||||||
/// The server picks a random SNI per session from REALITY_SNI_POOL.
|
/// The server picks a random SNI per session from REALITY_SNI_POOL.
|
||||||
/// The client does NOT hardcode one — server is always the source of truth.
|
/// The client does NOT hardcode one — server is always the source of truth.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
// ── lib/logic/notification_service.dart ─────────────────────────────────────
|
||||||
|
// Bridge to the native VPN notification, which flutter_vless owns. This only
|
||||||
|
// refreshes its content text with the session countdown, so a failure here is
|
||||||
|
// never worth propagating to the session timer.
|
||||||
|
|
||||||
|
class NotificationService {
|
||||||
|
NotificationService._();
|
||||||
|
|
||||||
|
static const MethodChannel _channel =
|
||||||
|
MethodChannel('com.revoltvpn.app/notification');
|
||||||
|
|
||||||
|
static String? _lastText;
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// Cosmetic.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forces the next connect to re-post even if the countdown resumes on the
|
||||||
|
/// same value.
|
||||||
|
static void reset() => _lastText = null;
|
||||||
|
}
|
||||||
@@ -5,9 +5,9 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
// Server data model and ChangeNotifier — shared by StatusBar (shows current
|
// Server data model and ChangeNotifier — shared by StatusBar (shows current
|
||||||
// server name/flag) and SelectionScreen (lets user pick a server).
|
// server name/flag) and SelectionScreen (lets user pick a server).
|
||||||
//
|
//
|
||||||
// Each server is defined by its tunnel IP/port. Reality keys (public key,
|
// Each server is defined by its tunnel IP/port. Reality credentials (public
|
||||||
// shortId, fingerprint) come from AppConfig — all servers share the same
|
// key, shortId) come from /session/status per session; only the transport
|
||||||
// Reality keypair and bootstrap UUID (same operator, different locations).
|
// parameters live in AppConfig.
|
||||||
//
|
//
|
||||||
// When multi-server ships:
|
// When multi-server ships:
|
||||||
// - _all becomes a fetched list (from /api/servers or bundled config)
|
// - _all becomes a fetched list (from /api/servers or bundled config)
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:revoltvpn/logic/hivemind_service.dart';
|
import 'package:revoltvpn/logic/hivemind_service.dart';
|
||||||
import 'package:revoltvpn/logic/app_config.dart';
|
import 'package:revoltvpn/logic/app_config.dart';
|
||||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||||
import 'package:revoltvpn/logic/crypto_service.dart';
|
import 'package:revoltvpn/logic/crypto_service.dart';
|
||||||
|
import 'package:revoltvpn/logic/notification_service.dart';
|
||||||
|
|
||||||
class SessionTimer extends ChangeNotifier {
|
class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
int _tickCount = 0;
|
int _tickCount = 0;
|
||||||
|
bool _appBackgrounded = false;
|
||||||
|
|
||||||
final VpnConnection vpnConnection;
|
final VpnConnection vpnConnection;
|
||||||
|
|
||||||
@@ -25,11 +27,14 @@ class SessionTimer extends ChangeNotifier {
|
|||||||
int _offlineSeconds = 0;
|
int _offlineSeconds = 0;
|
||||||
|
|
||||||
static const int _pollIntervalSeconds = 5;
|
static const int _pollIntervalSeconds = 5;
|
||||||
|
static const int _backgroundPollIntervalSeconds = 30;
|
||||||
|
|
||||||
int _lastUsedBytes = 0;
|
int _lastUsedBytes = 0;
|
||||||
double _currentSpeedKBps = 0.0;
|
double _currentSpeedKBps = 0.0;
|
||||||
|
DateTime? _lastSyncAt;
|
||||||
|
|
||||||
SessionTimer({required this.vpnConnection}) {
|
SessionTimer({required this.vpnConnection}) {
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
vpnConnection.addListener(_onVpnConnectionChanged);
|
vpnConnection.addListener(_onVpnConnectionChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,11 +82,14 @@ class SessionTimer extends ChangeNotifier {
|
|||||||
_usedBytes = 0;
|
_usedBytes = 0;
|
||||||
_lastUsedBytes = 0;
|
_lastUsedBytes = 0;
|
||||||
_currentSpeedKBps = 0.0;
|
_currentSpeedKBps = 0.0;
|
||||||
|
_lastSyncAt = null;
|
||||||
|
NotificationService.reset();
|
||||||
_tickCount = 0;
|
_tickCount = 0;
|
||||||
_hasSyncedOnce = false;
|
_hasSyncedOnce = false;
|
||||||
_consecutiveFailures = 0;
|
_consecutiveFailures = 0;
|
||||||
_offlineSeconds = 0;
|
_offlineSeconds = 0;
|
||||||
_isDisconnecting = false;
|
_isDisconnecting = false;
|
||||||
|
_appBackgrounded = false;
|
||||||
|
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
||||||
@@ -92,11 +100,10 @@ class SessionTimer extends ChangeNotifier {
|
|||||||
|
|
||||||
void _tick(Timer t) {
|
void _tick(Timer t) {
|
||||||
if (_isDisconnecting) return;
|
if (_isDisconnecting) return;
|
||||||
if (_hasSyncedOnce && _consecutiveFailures < _maxConsecutiveFailures) {
|
// A local clock: poll failures are about the network, not the clock.
|
||||||
if (_remainingSeconds > 0) {
|
if (_hasSyncedOnce && _remainingSeconds > 0) {
|
||||||
_remainingSeconds--;
|
_remainingSeconds--;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (_consecutiveFailures >= _maxConsecutiveFailures) {
|
if (_consecutiveFailures >= _maxConsecutiveFailures) {
|
||||||
_offlineSeconds++;
|
_offlineSeconds++;
|
||||||
@@ -112,11 +119,22 @@ class SessionTimer extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_tickCount++;
|
_tickCount++;
|
||||||
if (_tickCount % _pollIntervalSeconds == 0) {
|
final pollInterval = _appBackgrounded
|
||||||
|
? _backgroundPollIntervalSeconds
|
||||||
|
: _pollIntervalSeconds;
|
||||||
|
if (_tickCount % pollInterval == 0) {
|
||||||
_syncWithHivemind();
|
_syncWithHivemind();
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyListeners();
|
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) {
|
||||||
|
NotificationService.updateTimer(formatted);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> disconnect({String reason = 'User requested'}) async {
|
Future<void> disconnect({String reason = 'User requested'}) async {
|
||||||
@@ -133,6 +151,8 @@ class SessionTimer extends ChangeNotifier {
|
|||||||
_currentSpeedKBps = 0.0;
|
_currentSpeedKBps = 0.0;
|
||||||
_remainingSeconds = 0;
|
_remainingSeconds = 0;
|
||||||
_hasSyncedOnce = false;
|
_hasSyncedOnce = false;
|
||||||
|
_lastSyncAt = null;
|
||||||
|
NotificationService.reset();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
await vpnConnection.disconnect();
|
await vpnConnection.disconnect();
|
||||||
@@ -163,9 +183,17 @@ class SessionTimer extends ChangeNotifier {
|
|||||||
_remainingSeconds = data['expires_in_seconds'] ?? _remainingSeconds;
|
_remainingSeconds = data['expires_in_seconds'] ?? _remainingSeconds;
|
||||||
_usedBytes = data['used_bytes'] ?? _usedBytes;
|
_usedBytes = data['used_bytes'] ?? _usedBytes;
|
||||||
|
|
||||||
|
// 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 int deltaBytes = _usedBytes - _lastUsedBytes;
|
final int deltaBytes = _usedBytes - _lastUsedBytes;
|
||||||
if (_hasSyncedOnce && deltaBytes > 0) {
|
if (_hasSyncedOnce && deltaBytes > 0 && lastSync != null) {
|
||||||
_currentSpeedKBps = (deltaBytes / _pollIntervalSeconds) / 1000;
|
final elapsed = now.difference(lastSync).inMilliseconds / 1000.0;
|
||||||
|
_currentSpeedKBps =
|
||||||
|
elapsed > 0 ? (deltaBytes / elapsed) / 1000 : _currentSpeedKBps;
|
||||||
} else if (!_hasSyncedOnce) {
|
} else if (!_hasSyncedOnce) {
|
||||||
_currentSpeedKBps = 0.0;
|
_currentSpeedKBps = 0.0;
|
||||||
}
|
}
|
||||||
@@ -200,6 +228,7 @@ class SessionTimer extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _resumeTicking() {
|
void _resumeTicking() {
|
||||||
|
_lastSyncAt = null;
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
||||||
_isDisconnecting = false;
|
_isDisconnecting = false;
|
||||||
@@ -207,8 +236,23 @@ class SessionTimer extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
vpnConnection.removeListener(_onVpnConnectionChanged);
|
vpnConnection.removeListener(_onVpnConnectionChanged);
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
|
|||||||
+29
-9
@@ -61,8 +61,24 @@ class GitHubUpdater {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool _isTrustedReleaseUrl(String url) {
|
||||||
|
final uri = Uri.tryParse(url);
|
||||||
|
if (uri == null || uri.scheme != 'https' || uri.host != 'github.com') {
|
||||||
|
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 {
|
static Future<bool> open() async {
|
||||||
final url = _cachedDownloadUrl ?? AppConfig.githubReleasesUrl;
|
final cached = _cachedDownloadUrl;
|
||||||
|
final url = (cached != null && _isTrustedReleaseUrl(cached))
|
||||||
|
? cached
|
||||||
|
: AppConfig.githubReleasesUrl;
|
||||||
|
if (cached != null && url != cached) {
|
||||||
|
debugPrint('[Updater] Rejected untrusted release URL: $cached');
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return await launchUrl(Uri.parse(url),
|
return await launchUrl(Uri.parse(url),
|
||||||
mode: LaunchMode.externalApplication);
|
mode: LaunchMode.externalApplication);
|
||||||
@@ -115,32 +131,35 @@ class Updater {
|
|||||||
return '$prefix$v${isLocal ? ' (installed)' : ''}';
|
return '$prefix$v${isLocal ? ' (installed)' : ''}';
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> check(BuildContext context) async {
|
static Future<void> check({
|
||||||
|
required NavigatorState navigator,
|
||||||
|
required ScaffoldMessengerState messenger,
|
||||||
|
}) async {
|
||||||
final info = await PackageInfo.fromPlatform();
|
final info = await PackageInfo.fromPlatform();
|
||||||
final localVersion = info.version;
|
final localVersion = info.version;
|
||||||
if (localVersion.isEmpty) {
|
if (localVersion.isEmpty) {
|
||||||
_showSnackBar(context, 'Could not determine app version',
|
_showSnackBar(messenger, 'Could not determine app version',
|
||||||
isError: true);
|
isError: true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final latestVersion = await GitHubUpdater.fetchLatestVersion();
|
final latestVersion = await GitHubUpdater.fetchLatestVersion();
|
||||||
if (latestVersion == null) {
|
if (latestVersion == null) {
|
||||||
_showSnackBar(context, 'Could not reach GitHub', isError: true);
|
_showSnackBar(messenger, 'Could not reach GitHub', isError: true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_compareVersions(localVersion, latestVersion) >= 0) {
|
if (_compareVersions(localVersion, latestVersion) >= 0) {
|
||||||
_showSnackBar(context, 'Up to date');
|
_showSnackBar(messenger, 'Up to date');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final source = await installSource;
|
final source = await installSource;
|
||||||
final storeName = source == InstallSource.playStore ? 'Play Store' : 'GitHub';
|
final storeName = source == InstallSource.playStore ? 'Play Store' : 'GitHub';
|
||||||
if (!context.mounted) return;
|
if (!navigator.mounted) return;
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: navigator.context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
backgroundColor: AppColors.bgCard,
|
backgroundColor: AppColors.bgCard,
|
||||||
title: const Text('Update available',
|
title: const Text('Update available',
|
||||||
@@ -171,9 +190,10 @@ class Updater {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void _showSnackBar(BuildContext context, String message,
|
static void _showSnackBar(ScaffoldMessengerState messenger, String message,
|
||||||
{bool isError = false}) {
|
{bool isError = false}) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
if (!messenger.mounted) return;
|
||||||
|
messenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Row(
|
content: Row(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_vless/flutter_vless.dart';
|
import 'package:flutter_vless/flutter_vless.dart';
|
||||||
import 'package:revoltvpn/logic/hivemind_service.dart';
|
import 'package:revoltvpn/logic/hivemind_service.dart';
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ enum VpnStatus {
|
|||||||
error,
|
error,
|
||||||
}
|
}
|
||||||
|
|
||||||
class VpnConnection extends ChangeNotifier {
|
class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
||||||
bool _cancelled = false;
|
bool _cancelled = false;
|
||||||
VpnStatus _status = VpnStatus.disconnected;
|
VpnStatus _status = VpnStatus.disconnected;
|
||||||
VpnStatus get status => _status;
|
VpnStatus get status => _status;
|
||||||
@@ -36,6 +37,7 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
Future<void> get ready => _readyCompleter.future;
|
Future<void> get ready => _readyCompleter.future;
|
||||||
|
|
||||||
VpnConnection() {
|
VpnConnection() {
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_init();
|
_init();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,9 +73,7 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
debugPrint('[VPN] VLESS init error (expected on emulator): $e');
|
debugPrint('[VPN] VLESS init error (expected on emulator): $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
_checkHealth();
|
_startHealthPolling();
|
||||||
_healthTimer =
|
|
||||||
Timer.periodic(const Duration(seconds: 30), (_) => _checkHealth());
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final coreVersion = await _vless.getCoreVersion();
|
final coreVersion = await _vless.getCoreVersion();
|
||||||
@@ -251,13 +251,37 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _startHealthPolling() {
|
||||||
|
_healthTimer?.cancel();
|
||||||
|
_checkHealthIfIdle();
|
||||||
|
_healthTimer = Timer.periodic(
|
||||||
|
const Duration(seconds: 30), (_) => _checkHealthIfIdle());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkHealthIfIdle() async {
|
||||||
|
// A live tunnel already proves reachability.
|
||||||
|
if (_status == VpnStatus.connected) return;
|
||||||
|
await _checkHealth();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _checkHealth() async {
|
Future<void> _checkHealth() async {
|
||||||
_serverReachable = await HivemindService.checkHealth();
|
_serverReachable = await HivemindService.checkHealth();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (state == AppLifecycleState.paused ||
|
||||||
|
state == AppLifecycleState.detached) {
|
||||||
|
_healthTimer?.cancel();
|
||||||
|
} else if (state == AppLifecycleState.resumed) {
|
||||||
|
_startHealthPolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_healthTimer?.cancel();
|
_healthTimer?.cancel();
|
||||||
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
|
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import 'package:provider/provider.dart';
|
|||||||
import 'package:revoltvpn/logic/app_colors.dart';
|
import 'package:revoltvpn/logic/app_colors.dart';
|
||||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||||
import 'package:revoltvpn/screens/main_screen.dart';
|
import 'package:revoltvpn/screens/main_screen.dart';
|
||||||
|
import 'package:revoltvpn/screens/settings/in_settings/rain.dart';
|
||||||
|
import 'package:revoltvpn/screens/settings/in_settings/lightning.dart';
|
||||||
|
import 'package:revoltvpn/screens/settings/in_settings/haptic.dart';
|
||||||
|
|
||||||
class IntroScreen extends StatefulWidget {
|
class IntroScreen extends StatefulWidget {
|
||||||
const IntroScreen({super.key});
|
const IntroScreen({super.key});
|
||||||
@@ -42,6 +45,11 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
|
|||||||
final elapsed = Stopwatch()..start();
|
final elapsed = Stopwatch()..start();
|
||||||
final vpn = context.read<VpnConnection>();
|
final vpn = context.read<VpnConnection>();
|
||||||
|
|
||||||
|
// The overlays read these in initState, so they must land before
|
||||||
|
// MainScreen mounts.
|
||||||
|
final settingsPrefs = Future.wait(
|
||||||
|
[loadRainPref(), loadLightningPref(), loadHapticPref()]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await vpn.ready.timeout(_bootTimeout);
|
await vpn.ready.timeout(_bootTimeout);
|
||||||
} on TimeoutException {
|
} on TimeoutException {
|
||||||
@@ -51,6 +59,12 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
|
|||||||
debugPrint('[Boot] Engine init error: $e');
|
debugPrint('[Boot] Engine init error: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await settingsPrefs;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[Boot] Settings prefs load failed, using defaults: $e');
|
||||||
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _status = 'Ready');
|
setState(() => _status = 'Ready');
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,44 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:revoltvpn/logic/app_colors.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;
|
bool hapticEnabled = false;
|
||||||
|
|
||||||
|
/// Hydrates [hapticEnabled] from disk. Called at boot — the toggle tile below
|
||||||
|
/// only mounts once Settings is opened.
|
||||||
|
Future<void> loadHapticPref() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
hapticEnabled = prefs.getBool('haptic_feedback_enabled') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Haptic calls are deliberately best-effort: a vibration failure must never
|
||||||
|
/// block or fail a VPN connect/disconnect. `mediumImpact` is used instead of
|
||||||
|
/// `lightImpact` — the latter is imperceptible on many Android devices.
|
||||||
|
class Haptics {
|
||||||
|
Haptics._();
|
||||||
|
|
||||||
|
static Future<void> connectionChanged() async {
|
||||||
|
if (!hapticEnabled) return;
|
||||||
|
try {
|
||||||
|
await HapticFeedback.mediumImpact();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[Haptics] Feedback unavailable: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> previewEnabled() async {
|
||||||
|
try {
|
||||||
|
await HapticFeedback.selectionClick();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[Haptics] Preview unavailable: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class HapticFeedbackTile extends StatefulWidget {
|
class HapticFeedbackTile extends StatefulWidget {
|
||||||
const HapticFeedbackTile({super.key});
|
const HapticFeedbackTile({super.key});
|
||||||
|
|
||||||
@@ -20,12 +55,9 @@ class _HapticFeedbackTileState extends State<HapticFeedbackTile> {
|
|||||||
_load();
|
_load();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loads persisted state and syncs the top-level bool.
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
await loadHapticPref();
|
||||||
final value = prefs.getBool('haptic_feedback_enabled') ?? false;
|
if (mounted) setState(() => _on = hapticEnabled);
|
||||||
hapticEnabled = value;
|
|
||||||
if (mounted) setState(() => _on = value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Writes to SharedPreferences AND updates the live bool so the connect
|
// Writes to SharedPreferences AND updates the live bool so the connect
|
||||||
@@ -34,6 +66,7 @@ class _HapticFeedbackTileState extends State<HapticFeedbackTile> {
|
|||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setBool('haptic_feedback_enabled', value);
|
await prefs.setBool('haptic_feedback_enabled', value);
|
||||||
hapticEnabled = value;
|
hapticEnabled = value;
|
||||||
|
if (value) unawaited(Haptics.previewEnabled());
|
||||||
setState(() => _on = value);
|
setState(() => _on = value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import 'package:revoltvpn/logic/app_colors.dart';
|
|||||||
|
|
||||||
// ── lib/screens/settings/in_settings/lightning.dart ─────────────────────────
|
// ── lib/screens/settings/in_settings/lightning.dart ─────────────────────────
|
||||||
// Toggle for the lightning strike overlay on the main screen.
|
// Toggle for the lightning strike overlay on the main screen.
|
||||||
//
|
|
||||||
// Same architecture as haptic.dart: exports a top-level bool read synchronously
|
|
||||||
// by lib/components/lightning.dart. Default: true (lightning is on — opt-out).
|
|
||||||
|
|
||||||
// lib/components/lightning.dart reads this in its build method.
|
final ValueNotifier<bool> lightningEnabled = ValueNotifier(true);
|
||||||
bool lightningEnabled = true;
|
|
||||||
|
Future<void> loadLightningPref() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
lightningEnabled.value = prefs.getBool('lightning_effect_enabled') ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
class LightningToggleTile extends StatefulWidget {
|
class LightningToggleTile extends StatefulWidget {
|
||||||
const LightningToggleTile({super.key});
|
const LightningToggleTile({super.key});
|
||||||
@@ -28,16 +29,14 @@ class _LightningToggleTileState extends State<LightningToggleTile> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
await loadLightningPref();
|
||||||
final value = prefs.getBool('lightning_effect_enabled') ?? true;
|
if (mounted) setState(() => _on = lightningEnabled.value);
|
||||||
lightningEnabled = value;
|
|
||||||
if (mounted) setState(() => _on = value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggle(bool value) async {
|
Future<void> _toggle(bool value) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setBool('lightning_effect_enabled', value);
|
await prefs.setBool('lightning_effect_enabled', value);
|
||||||
lightningEnabled = value;
|
lightningEnabled.value = value;
|
||||||
setState(() => _on = value);
|
setState(() => _on = value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import 'package:revoltvpn/logic/app_colors.dart';
|
|||||||
|
|
||||||
// ── lib/screens/settings/in_settings/rain.dart ──────────────────────────────
|
// ── lib/screens/settings/in_settings/rain.dart ──────────────────────────────
|
||||||
// Toggle for the animated rain overlay on the main screen.
|
// Toggle for the animated rain overlay on the main screen.
|
||||||
//
|
|
||||||
// Same architecture as haptic.dart: exports a top-level bool read synchronously
|
|
||||||
// by lib/components/rain.dart. Default: true (rain is on — opt-out).
|
|
||||||
|
|
||||||
// lib/components/rain.dart reads this in its build method.
|
final ValueNotifier<bool> rainEnabled = ValueNotifier(true);
|
||||||
bool rainEnabled = true;
|
|
||||||
|
Future<void> loadRainPref() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
rainEnabled.value = prefs.getBool('rain_effect_enabled') ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
class RainToggleTile extends StatefulWidget {
|
class RainToggleTile extends StatefulWidget {
|
||||||
const RainToggleTile({super.key});
|
const RainToggleTile({super.key});
|
||||||
@@ -28,16 +29,14 @@ class _RainToggleTileState extends State<RainToggleTile> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
await loadRainPref();
|
||||||
final value = prefs.getBool('rain_effect_enabled') ?? true;
|
if (mounted) setState(() => _on = rainEnabled.value);
|
||||||
rainEnabled = value;
|
|
||||||
if (mounted) setState(() => _on = value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggle(bool value) async {
|
Future<void> _toggle(bool value) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setBool('rain_effect_enabled', value);
|
await prefs.setBool('rain_effect_enabled', value);
|
||||||
rainEnabled = value;
|
rainEnabled.value = value;
|
||||||
setState(() => _on = value);
|
setState(() => _on = value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
|||||||
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
|
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
|
||||||
# Read more about iOS versioning at
|
# Read more about iOS versioning at
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
version: 3.2.2+25
|
version: 3.3.0+26
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.0.0 <4.0.0"
|
sdk: ">=3.0.0 <4.0.0"
|
||||||
|
|||||||
Reference in new issue
Block a user