chore: release 3.3.0 (sidebar nav fix, notification countdown, hardening)

Co-authored-by: Naveax <[email protected]>
This commit is contained in:
esefxdzandNaveax committed 2026-08-30 14:01:52 +03:00
1 parent 4eb1fca666
commit 62104d5730
28 files changed
+646 -191

No files matched your search

+10
View File
@@ -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
View File
@@ -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
+71
View File
@@ -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
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- local_packages/**
- whatsthis/**
linter:
rules:
avoid_print: true
+11 -7
View File
@@ -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"
}
} }
-3
View File
@@ -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
View File
@@ -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
+52 -19
View File
@@ -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),
),
); );
} }
} }
+5 -4
View File
@@ -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,
+16 -3
View File
@@ -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
View File
@@ -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 -8
View File
@@ -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
+8 -2
View File
@@ -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),
);
}, },
), ),
+5 -4
View File
@@ -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(
+4 -3
View File
@@ -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();
} }
+3 -18
View File
@@ -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.
+37
View File
@@ -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;
}
+3 -3
View File
@@ -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)
+52 -8
View File
@@ -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
View File
@@ -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: [
+28 -4
View File
@@ -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 {
+14
View File
@@ -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');
+38 -5
View File
@@ -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);
} }
+9 -10
View File
@@ -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
View File
@@ -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"