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
+670 -215

No files matched your search

+3 -18
View File
@@ -8,26 +8,11 @@ abstract final class AppConfig {
// ── Server ───────────────────────────────────────────────────────────
/// Your server's public IP. The VPN tunnel is pinned to this.
static const String serverIp = '0.0.0.0'; // ◄── 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';
static const String hivemindApiPublic = 'https://YOUR_DOMAIN/api'; // ◄── REPLACE
// ── 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 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 name/flag) and SelectionScreen (lets user pick a server).
//
// Each server is defined by its tunnel IP/port. Reality keys (public key,
// shortId, fingerprint) come from AppConfig — all servers share the same
// Reality keypair and bootstrap UUID (same operator, different locations).
// Each server is defined by its tunnel IP/port. Reality credentials (public
// key, shortId) come from /session/status per session; only the transport
// parameters live in AppConfig.
//
// When multi-server ships:
// - _all becomes a fetched list (from /api/servers or bundled config)
+55 -11
View File
@@ -1,14 +1,16 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:revoltvpn/logic/hivemind_service.dart';
import 'package:revoltvpn/logic/app_config.dart';
import 'package:revoltvpn/logic/vpn_connection.dart';
import 'package:revoltvpn/logic/crypto_service.dart';
import 'package:revoltvpn/logic/notification_service.dart';
class SessionTimer extends ChangeNotifier {
class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
Timer? _timer;
int _tickCount = 0;
bool _appBackgrounded = false;
final VpnConnection vpnConnection;
@@ -25,11 +27,14 @@ class SessionTimer extends ChangeNotifier {
int _offlineSeconds = 0;
static const int _pollIntervalSeconds = 5;
static const int _backgroundPollIntervalSeconds = 30;
int _lastUsedBytes = 0;
double _currentSpeedKBps = 0.0;
int _lastUsedBytes = 0;
double _currentSpeedKBps = 0.0;
DateTime? _lastSyncAt;
SessionTimer({required this.vpnConnection}) {
WidgetsBinding.instance.addObserver(this);
vpnConnection.addListener(_onVpnConnectionChanged);
}
@@ -77,11 +82,14 @@ class SessionTimer extends ChangeNotifier {
_usedBytes = 0;
_lastUsedBytes = 0;
_currentSpeedKBps = 0.0;
_lastSyncAt = null;
NotificationService.reset();
_tickCount = 0;
_hasSyncedOnce = false;
_consecutiveFailures = 0;
_offlineSeconds = 0;
_isDisconnecting = false;
_appBackgrounded = false;
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
@@ -92,10 +100,9 @@ class SessionTimer extends ChangeNotifier {
void _tick(Timer t) {
if (_isDisconnecting) return;
if (_hasSyncedOnce && _consecutiveFailures < _maxConsecutiveFailures) {
if (_remainingSeconds > 0) {
_remainingSeconds--;
}
// A local clock: poll failures are about the network, not the clock.
if (_hasSyncedOnce && _remainingSeconds > 0) {
_remainingSeconds--;
}
if (_consecutiveFailures >= _maxConsecutiveFailures) {
@@ -112,11 +119,22 @@ class SessionTimer extends ChangeNotifier {
}
_tickCount++;
if (_tickCount % _pollIntervalSeconds == 0) {
final pollInterval = _appBackgrounded
? _backgroundPollIntervalSeconds
: _pollIntervalSeconds;
if (_tickCount % pollInterval == 0) {
_syncWithHivemind();
}
notifyListeners();
// Gated on a live tunnel. The plugin cancels the notification when the
// service stops, and re-posting after that leaves an ongoing notification
// the user cannot swipe away below Android 14.
if (_hasSyncedOnce &&
vpnConnection.status == VpnStatus.connected) {
NotificationService.updateTimer(formatted);
}
}
Future<void> disconnect({String reason = 'User requested'}) async {
@@ -133,6 +151,8 @@ class SessionTimer extends ChangeNotifier {
_currentSpeedKBps = 0.0;
_remainingSeconds = 0;
_hasSyncedOnce = false;
_lastSyncAt = null;
NotificationService.reset();
notifyListeners();
await vpnConnection.disconnect();
@@ -163,9 +183,17 @@ class SessionTimer extends ChangeNotifier {
_remainingSeconds = data['expires_in_seconds'] ?? _remainingSeconds;
_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;
if (_hasSyncedOnce && deltaBytes > 0) {
_currentSpeedKBps = (deltaBytes / _pollIntervalSeconds) / 1000;
if (_hasSyncedOnce && deltaBytes > 0 && lastSync != null) {
final elapsed = now.difference(lastSync).inMilliseconds / 1000.0;
_currentSpeedKBps =
elapsed > 0 ? (deltaBytes / elapsed) / 1000 : _currentSpeedKBps;
} else if (!_hasSyncedOnce) {
_currentSpeedKBps = 0.0;
}
@@ -200,6 +228,7 @@ class SessionTimer extends ChangeNotifier {
}
void _resumeTicking() {
_lastSyncAt = null;
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
_isDisconnecting = false;
@@ -207,8 +236,23 @@ class SessionTimer extends ChangeNotifier {
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
void dispose() {
WidgetsBinding.instance.removeObserver(this);
vpnConnection.removeListener(_onVpnConnectionChanged);
_timer?.cancel();
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 {
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 {
return await launchUrl(Uri.parse(url),
mode: LaunchMode.externalApplication);
@@ -115,32 +131,35 @@ class Updater {
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 localVersion = info.version;
if (localVersion.isEmpty) {
_showSnackBar(context, 'Could not determine app version',
_showSnackBar(messenger, 'Could not determine app version',
isError: true);
return;
}
final latestVersion = await GitHubUpdater.fetchLatestVersion();
if (latestVersion == null) {
_showSnackBar(context, 'Could not reach GitHub', isError: true);
_showSnackBar(messenger, 'Could not reach GitHub', isError: true);
return;
}
if (_compareVersions(localVersion, latestVersion) >= 0) {
_showSnackBar(context, 'Up to date');
_showSnackBar(messenger, 'Up to date');
return;
}
final source = await installSource;
final storeName = source == InstallSource.playStore ? 'Play Store' : 'GitHub';
if (!context.mounted) return;
if (!navigator.mounted) return;
showDialog(
context: context,
context: navigator.context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.bgCard,
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}) {
ScaffoldMessenger.of(context).showSnackBar(
if (!messenger.mounted) return;
messenger.showSnackBar(
SnackBar(
content: Row(
children: [
+28 -4
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_vless/flutter_vless.dart';
import 'package:revoltvpn/logic/hivemind_service.dart';
@@ -11,7 +12,7 @@ enum VpnStatus {
error,
}
class VpnConnection extends ChangeNotifier {
class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
bool _cancelled = false;
VpnStatus _status = VpnStatus.disconnected;
VpnStatus get status => _status;
@@ -36,6 +37,7 @@ class VpnConnection extends ChangeNotifier {
Future<void> get ready => _readyCompleter.future;
VpnConnection() {
WidgetsBinding.instance.addObserver(this);
_init();
}
@@ -71,9 +73,7 @@ class VpnConnection extends ChangeNotifier {
debugPrint('[VPN] VLESS init error (expected on emulator): $e');
}
_checkHealth();
_healthTimer =
Timer.periodic(const Duration(seconds: 30), (_) => _checkHealth());
_startHealthPolling();
try {
final coreVersion = await _vless.getCoreVersion();
@@ -251,13 +251,37 @@ class VpnConnection extends ChangeNotifier {
notifyListeners();
}
void _startHealthPolling() {
_healthTimer?.cancel();
_checkHealthIfIdle();
_healthTimer = Timer.periodic(
const Duration(seconds: 30), (_) => _checkHealthIfIdle());
}
Future<void> _checkHealthIfIdle() async {
// A live tunnel already proves reachability.
if (_status == VpnStatus.connected) return;
await _checkHealth();
}
Future<void> _checkHealth() async {
_serverReachable = await HivemindService.checkHealth();
notifyListeners();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.detached) {
_healthTimer?.cancel();
} else if (state == AppLifecycleState.resumed) {
_startHealthPolling();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_healthTimer?.cancel();
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
try {