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
+670
-215
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:revoltvpn/logic/app_colors.dart';
|
||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||
@@ -15,7 +15,7 @@ class ConnectButton extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ConnectButtonState extends State<ConnectButton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
|
||||
bool _busy = false;
|
||||
DateTime _lastTap = DateTime.now();
|
||||
AnimationController? _pulse;
|
||||
@@ -29,6 +29,7 @@ class _ConnectButtonState extends State<ConnectButton>
|
||||
final pulse = AnimationController(vsync: this, duration: const Duration(milliseconds: 2000));
|
||||
_pulse = pulse;
|
||||
_pulseAnim = Tween(begin: 0.0, end: 1.0).animate(CurvedAnimation(parent: pulse, curve: Curves.easeInOut));
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<VpnConnection>().addListener(_onVpnChanged);
|
||||
});
|
||||
@@ -36,6 +37,7 @@ class _ConnectButtonState extends State<ConnectButton>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
context.read<VpnConnection>().removeListener(_onVpnChanged);
|
||||
_pulse?.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 {
|
||||
|
||||
final now = DateTime.now();
|
||||
@@ -65,7 +81,7 @@ class _ConnectButtonState extends State<ConnectButton>
|
||||
if (vpn.status == VpnStatus.connected || vpn.status == VpnStatus.connecting) {
|
||||
_busy = false;
|
||||
await timer.disconnect();
|
||||
if (hapticEnabled) HapticFeedback.lightImpact();
|
||||
unawaited(Haptics.connectionChanged());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -85,7 +101,7 @@ class _ConnectButtonState extends State<ConnectButton>
|
||||
final ok = await vpn.connect();
|
||||
if (ok) {
|
||||
timer.start();
|
||||
if (hapticEnabled) HapticFeedback.lightImpact();
|
||||
unawaited(Haptics.connectionChanged());
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
@@ -105,15 +121,36 @@ class _ConnectButtonState extends State<ConnectButton>
|
||||
child: _pulseAnim != null
|
||||
? AnimatedBuilder(
|
||||
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 glowRadius = isConnected ? 18.0 + (pulse * 14) : 0.0;
|
||||
|
||||
@@ -128,21 +165,17 @@ class _ConnectButtonState extends State<ConnectButton>
|
||||
width: isConnected ? 2.5 : 1.5,
|
||||
),
|
||||
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,
|
||||
child: spinning
|
||||
? 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),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -64,10 +64,11 @@ class _DisclosureDialog extends StatelessWidget {
|
||||
const Text(
|
||||
'Revolt VPN creates a secure VPN tunnel to protect your '
|
||||
'traffic. To enforce your data/time quota, we log your '
|
||||
'connection duration and data usage, linked to your '
|
||||
'account. We do not log or monitor the content of your '
|
||||
'traffic. We do not share your data with third parties.'
|
||||
'If you have more questions, visit our website.',
|
||||
'connection duration and data usage, linked to a randomly '
|
||||
'generated device ID — not to your identity. We do not log '
|
||||
'or monitor the content of your traffic. We do not share '
|
||||
'your data with third parties. The full policy is in the '
|
||||
'sidebar menu under "Privacy Policy".',
|
||||
style: TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 14,
|
||||
|
||||
@@ -37,11 +37,24 @@ class _LightningEffectState extends State<LightningEffect>
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 400));
|
||||
_scheduleNext();
|
||||
lightningEnabled.addListener(_onEnabledChanged);
|
||||
if (lightningEnabled.value) _scheduleNext();
|
||||
}
|
||||
|
||||
void _onEnabledChanged() {
|
||||
if (lightningEnabled.value) {
|
||||
_scheduleNext();
|
||||
} else {
|
||||
_timer?.cancel();
|
||||
_ctrl.stop();
|
||||
_bolt = null;
|
||||
}
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
lightningEnabled.removeListener(_onEnabledChanged);
|
||||
_timer?.cancel();
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
@@ -54,13 +67,13 @@ class _LightningEffectState extends State<LightningEffect>
|
||||
}
|
||||
|
||||
void _strike() {
|
||||
if (!mounted) return;
|
||||
if (!mounted || !lightningEnabled.value) return;
|
||||
_bolt = LightningBolt.random(_random);
|
||||
_ctrl.forward(from: 0.0);
|
||||
|
||||
if (_random.nextDouble() < widget.doubleChance) {
|
||||
Future.delayed(const Duration(milliseconds: 120), () {
|
||||
if (mounted) {
|
||||
if (mounted && lightningEnabled.value) {
|
||||
_bolt = LightningBolt.random(_random);
|
||||
_ctrl.forward(from: 0.0);
|
||||
}
|
||||
@@ -78,7 +91,7 @@ class _LightningEffectState extends State<LightningEffect>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!lightningEnabled) return const SizedBox.shrink();
|
||||
if (!lightningEnabled.value) return const SizedBox.shrink();
|
||||
return AnimatedBuilder(
|
||||
animation: _ctrl,
|
||||
builder: (_, child) {
|
||||
|
||||
+39
-23
@@ -36,6 +36,7 @@ class _RainEffectState extends State<RainEffect>
|
||||
late final Ticker _ticker;
|
||||
final _drops = <_Drop>[];
|
||||
final _random = Random();
|
||||
final _repaint = ValueNotifier<int>(0);
|
||||
Duration _last = Duration.zero;
|
||||
|
||||
// Lifecycle
|
||||
@@ -44,47 +45,63 @@ class _RainEffectState extends State<RainEffect>
|
||||
void initState() {
|
||||
super.initState();
|
||||
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
|
||||
void dispose() {
|
||||
rainEnabled.removeListener(_onEnabledChanged);
|
||||
_ticker.dispose();
|
||||
_repaint.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
|
||||
|
||||
void _onTick(Duration now) {
|
||||
double dt = 0.016; // ~60 fps fallback on first tick
|
||||
if (_last != Duration.zero) {
|
||||
dt = (now - _last).inMicroseconds / 1000000.0;
|
||||
dt = dt.clamp(0.0, 0.1);
|
||||
dt = dt.clamp(0.0, 0.1);
|
||||
}
|
||||
_last = now;
|
||||
|
||||
for (final d in _drops) {
|
||||
d.y += d.speed * dt;
|
||||
if (d.y > 1.08) {
|
||||
d.spawn(_random, widget.minLength, widget.maxLength);
|
||||
d.y = -0.06 - _random.nextDouble() * 0.08;
|
||||
d.spawn(_random, widget.minLength, widget.maxLength, widget.color);
|
||||
d.y = -0.06 - _random.nextDouble() * 0.08;
|
||||
}
|
||||
}
|
||||
setState(() {});
|
||||
_repaint.value++;
|
||||
}
|
||||
|
||||
// Build
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!rainEnabled) return const SizedBox.shrink();
|
||||
if (!rainEnabled.value) return const SizedBox.shrink();
|
||||
return CustomPaint(
|
||||
painter: _RainPainter(
|
||||
drops: _drops,
|
||||
color: widget.color,
|
||||
windDrift: widget.windDrift,
|
||||
repaint: _repaint,
|
||||
),
|
||||
size: Size.infinite,
|
||||
);
|
||||
@@ -96,37 +113,37 @@ class _RainEffectState extends State<RainEffect>
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class _Drop {
|
||||
double x;
|
||||
double y;
|
||||
double speed;
|
||||
double length;
|
||||
double opacity;
|
||||
double x;
|
||||
double y;
|
||||
double speed;
|
||||
double length;
|
||||
Color color;
|
||||
|
||||
_Drop({
|
||||
required this.x,
|
||||
required this.y,
|
||||
required this.speed,
|
||||
required this.length,
|
||||
required this.opacity,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
/// 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(
|
||||
x: r.nextDouble(),
|
||||
y: r.nextDouble(),
|
||||
speed: 0.30 + r.nextDouble() * 0.55,
|
||||
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).
|
||||
void spawn(Random r, double minLen, double maxLen) {
|
||||
void spawn(Random r, double minLen, double maxLen, Color base) {
|
||||
x = r.nextDouble();
|
||||
speed = 0.30 + r.nextDouble() * 0.55;
|
||||
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 {
|
||||
final List<_Drop> drops;
|
||||
final Color color;
|
||||
final double windDrift;
|
||||
|
||||
_RainPainter({
|
||||
required this.drops,
|
||||
required this.color,
|
||||
required this.windDrift,
|
||||
});
|
||||
required Listenable repaint,
|
||||
}) : super(repaint: repaint);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
@@ -157,11 +173,11 @@ class _RainPainter extends CustomPainter {
|
||||
final ex = sx - windDrift; // slight leftward slant
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
// sidebar end-drawer animation so it feels like one continuous motion.
|
||||
static void open(BuildContext context) {
|
||||
Navigator.of(context).push(
|
||||
PageRouteBuilder(
|
||||
static PageRoute<void> _route() => PageRouteBuilder<void>(
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||
pageBuilder: (_, __, ___) => const SettingsScreen(),
|
||||
@@ -23,15 +21,21 @@ class SettingsButton extends StatelessWidget {
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(1.0, 0.0),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeOut,
|
||||
)),
|
||||
).animate(
|
||||
CurvedAnimation(parent: animation, curve: Curves.easeOut),
|
||||
),
|
||||
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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:revoltvpn/logic/app_colors.dart';
|
||||
@@ -126,8 +127,9 @@ class _SidebarDrawerState extends State<SidebarDrawer> {
|
||||
icon: Icons.settings,
|
||||
label: 'Settings',
|
||||
onTap: () {
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
Navigator.of(context).pop();
|
||||
SettingsButton.open(context);
|
||||
SettingsButton.openWithNavigator(navigator);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -135,8 +137,12 @@ class _SidebarDrawerState extends State<SidebarDrawer> {
|
||||
icon: Icons.system_update_alt,
|
||||
label: 'Check for updates',
|
||||
onTap: () {
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
Navigator.of(context).pop();
|
||||
Updater.check(context);
|
||||
unawaited(
|
||||
Updater.check(navigator: navigator, messenger: messenger),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
|
||||
@@ -8,10 +8,11 @@ class SpeedBox extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<SessionTimer>(
|
||||
builder: (context, timer, _) {
|
||||
final speed = timer.currentSpeedKBps > 0.5
|
||||
? '${timer.currentSpeedKBps.toStringAsFixed(1)} KB/s'
|
||||
return Selector<SessionTimer, double>(
|
||||
selector: (_, timer) => timer.currentSpeedKBps,
|
||||
builder: (context, speedKBps, _) {
|
||||
final speed = speedKBps > 0.5
|
||||
? '${speedKBps.toStringAsFixed(1)} KB/s'
|
||||
: 'Idle';
|
||||
|
||||
return Container(
|
||||
|
||||
@@ -34,9 +34,10 @@ class _SupportButtonState extends State<SupportButton> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<SessionTimer>(
|
||||
builder: (context, timer, _) {
|
||||
if (!timer.isRunning && !timer.hasSyncedOnce) {
|
||||
return Selector<SessionTimer, bool>(
|
||||
selector: (_, timer) => timer.isRunning || timer.hasSyncedOnce,
|
||||
builder: (context, visible, _) {
|
||||
if (!visible) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 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)
|
||||
|
||||
@@ -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
@@ -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: [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -4,6 +4,9 @@ import 'package:provider/provider.dart';
|
||||
import 'package:revoltvpn/logic/app_colors.dart';
|
||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||
import 'package:revoltvpn/screens/main_screen.dart';
|
||||
import 'package:revoltvpn/screens/settings/in_settings/rain.dart';
|
||||
import 'package:revoltvpn/screens/settings/in_settings/lightning.dart';
|
||||
import 'package:revoltvpn/screens/settings/in_settings/haptic.dart';
|
||||
|
||||
class IntroScreen extends StatefulWidget {
|
||||
const IntroScreen({super.key});
|
||||
@@ -42,6 +45,11 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
|
||||
final elapsed = Stopwatch()..start();
|
||||
final vpn = context.read<VpnConnection>();
|
||||
|
||||
// The overlays read these in initState, so they must land before
|
||||
// MainScreen mounts.
|
||||
final settingsPrefs = Future.wait(
|
||||
[loadRainPref(), loadLightningPref(), loadHapticPref()]);
|
||||
|
||||
try {
|
||||
await vpn.ready.timeout(_bootTimeout);
|
||||
} on TimeoutException {
|
||||
@@ -51,6 +59,12 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
|
||||
debugPrint('[Boot] Engine init error: $e');
|
||||
}
|
||||
|
||||
try {
|
||||
await settingsPrefs;
|
||||
} catch (e) {
|
||||
debugPrint('[Boot] Settings prefs load failed, using defaults: $e');
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _status = 'Ready');
|
||||
|
||||
|
||||
@@ -1,9 +1,44 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:revoltvpn/logic/app_colors.dart';
|
||||
|
||||
// A plain bool, not a ValueNotifier: this is only read at the moment of a tap,
|
||||
// so nothing has to react to it changing. Default: false.
|
||||
bool hapticEnabled = false;
|
||||
|
||||
/// Hydrates [hapticEnabled] from disk. Called at boot — the toggle tile below
|
||||
/// only mounts once Settings is opened.
|
||||
Future<void> loadHapticPref() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
hapticEnabled = prefs.getBool('haptic_feedback_enabled') ?? false;
|
||||
}
|
||||
|
||||
/// Haptic calls are deliberately best-effort: a vibration failure must never
|
||||
/// block or fail a VPN connect/disconnect. `mediumImpact` is used instead of
|
||||
/// `lightImpact` — the latter is imperceptible on many Android devices.
|
||||
class Haptics {
|
||||
Haptics._();
|
||||
|
||||
static Future<void> connectionChanged() async {
|
||||
if (!hapticEnabled) return;
|
||||
try {
|
||||
await HapticFeedback.mediumImpact();
|
||||
} catch (e) {
|
||||
debugPrint('[Haptics] Feedback unavailable: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> previewEnabled() async {
|
||||
try {
|
||||
await HapticFeedback.selectionClick();
|
||||
} catch (e) {
|
||||
debugPrint('[Haptics] Preview unavailable: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class HapticFeedbackTile extends StatefulWidget {
|
||||
const HapticFeedbackTile({super.key});
|
||||
|
||||
@@ -20,12 +55,9 @@ class _HapticFeedbackTileState extends State<HapticFeedbackTile> {
|
||||
_load();
|
||||
}
|
||||
|
||||
// Loads persisted state and syncs the top-level bool.
|
||||
Future<void> _load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final value = prefs.getBool('haptic_feedback_enabled') ?? false;
|
||||
hapticEnabled = value;
|
||||
if (mounted) setState(() => _on = value);
|
||||
await loadHapticPref();
|
||||
if (mounted) setState(() => _on = hapticEnabled);
|
||||
}
|
||||
|
||||
// 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();
|
||||
await prefs.setBool('haptic_feedback_enabled', value);
|
||||
hapticEnabled = value;
|
||||
if (value) unawaited(Haptics.previewEnabled());
|
||||
setState(() => _on = value);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,13 @@ import 'package:revoltvpn/logic/app_colors.dart';
|
||||
|
||||
// ── lib/screens/settings/in_settings/lightning.dart ─────────────────────────
|
||||
// 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.
|
||||
bool lightningEnabled = true;
|
||||
final ValueNotifier<bool> lightningEnabled = ValueNotifier(true);
|
||||
|
||||
Future<void> loadLightningPref() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
lightningEnabled.value = prefs.getBool('lightning_effect_enabled') ?? true;
|
||||
}
|
||||
|
||||
class LightningToggleTile extends StatefulWidget {
|
||||
const LightningToggleTile({super.key});
|
||||
@@ -28,16 +29,14 @@ class _LightningToggleTileState extends State<LightningToggleTile> {
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final value = prefs.getBool('lightning_effect_enabled') ?? true;
|
||||
lightningEnabled = value;
|
||||
if (mounted) setState(() => _on = value);
|
||||
await loadLightningPref();
|
||||
if (mounted) setState(() => _on = lightningEnabled.value);
|
||||
}
|
||||
|
||||
Future<void> _toggle(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('lightning_effect_enabled', value);
|
||||
lightningEnabled = value;
|
||||
lightningEnabled.value = value;
|
||||
setState(() => _on = value);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,13 @@ import 'package:revoltvpn/logic/app_colors.dart';
|
||||
|
||||
// ── lib/screens/settings/in_settings/rain.dart ──────────────────────────────
|
||||
// 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.
|
||||
bool rainEnabled = true;
|
||||
final ValueNotifier<bool> rainEnabled = ValueNotifier(true);
|
||||
|
||||
Future<void> loadRainPref() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
rainEnabled.value = prefs.getBool('rain_effect_enabled') ?? true;
|
||||
}
|
||||
|
||||
class RainToggleTile extends StatefulWidget {
|
||||
const RainToggleTile({super.key});
|
||||
@@ -28,16 +29,14 @@ class _RainToggleTileState extends State<RainToggleTile> {
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final value = prefs.getBool('rain_effect_enabled') ?? true;
|
||||
rainEnabled = value;
|
||||
if (mounted) setState(() => _on = value);
|
||||
await loadRainPref();
|
||||
if (mounted) setState(() => _on = rainEnabled.value);
|
||||
}
|
||||
|
||||
Future<void> _toggle(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('rain_effect_enabled', value);
|
||||
rainEnabled = value;
|
||||
rainEnabled.value = value;
|
||||
setState(() => _on = value);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user