feat(app): add secure routing and runtime resilience

Add explicit TUN and SOCKS5 connection modes, app routing controls, authenticated per-session local SOCKS5, Android network monitoring, persistent haptics, resilient session recovery, notification hardening and client-side Hivemind cleanup.

Keep the hosted flutter_vless_android 1.1.5 runtime as the base and apply the Android compatibility/security patches at build time instead of restoring the unstable vendored runtime.
This commit is contained in:
Naveax committed 2026-08-31 19:31:12 +03:00
1 parent 371db8da9f
commit 8ead3ff249
30 files changed
+3959 -484

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:revoltvpn/logic/haptic_settings.dart';
/// Gives short touch taps the same haptic feedback across the app.
/// Scroll gestures are ignored by checking travel distance and touch duration.
class AppHapticSurface extends StatefulWidget {
final Widget child;
const AppHapticSurface({super.key, required this.child});
@override
State<AppHapticSurface> createState() => _AppHapticSurfaceState();
}
class _PointerStart {
final Offset position;
final DateTime time;
const _PointerStart(this.position, this.time);
}
class _AppHapticSurfaceState extends State<AppHapticSurface> {
static const double _maxTapTravel = 18;
static const Duration _maxTapDuration = Duration(milliseconds: 700);
final Map<int, _PointerStart> _starts = <int, _PointerStart>{};
bool _supportsHaptics(PointerDeviceKind kind) {
return kind == PointerDeviceKind.touch ||
kind == PointerDeviceKind.stylus ||
kind == PointerDeviceKind.invertedStylus;
}
void _onDown(PointerDownEvent event) {
if (!_supportsHaptics(event.kind)) return;
_starts[event.pointer] = _PointerStart(event.position, DateTime.now());
}
void _onCancel(PointerCancelEvent event) {
_starts.remove(event.pointer);
}
void _onUp(PointerUpEvent event) {
final start = _starts.remove(event.pointer);
if (start == null || !_supportsHaptics(event.kind)) return;
final travelled = (event.position - start.position).distance;
final elapsed = DateTime.now().difference(start.time);
if (travelled <= _maxTapTravel && elapsed <= _maxTapDuration) {
HapticSettings.tap();
}
}
@override
Widget build(BuildContext context) {
return Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: _onDown,
onPointerUp: _onUp,
onPointerCancel: _onCancel,
child: widget.child,
);
}
}
+14 -23
View File
@@ -1,11 +1,9 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/logic/vpn_connection.dart';
import 'package:revoltvpn/logic/session_timer.dart';
import 'package:revoltvpn/logic/ad_manager.dart';
import 'package:revoltvpn/screens/settings/in_settings/haptic.dart';
class ConnectButton extends StatefulWidget {
const ConnectButton({super.key});
@@ -17,7 +15,7 @@ class ConnectButton extends StatefulWidget {
class _ConnectButtonState extends State<ConnectButton>
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
bool _busy = false;
DateTime _lastTap = DateTime.now();
DateTime _lastTap = DateTime.fromMillisecondsSinceEpoch(0);
AnimationController? _pulse;
Animation<double>? _pulseAnim;
@@ -26,12 +24,17 @@ class _ConnectButtonState extends State<ConnectButton>
@override
void initState() {
super.initState();
final pulse = AnimationController(vsync: this, duration: const Duration(milliseconds: 2000));
final pulse = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2000),
);
_pulse = pulse;
_pulseAnim = Tween(begin: 0.0, end: 1.0).animate(CurvedAnimation(parent: pulse, curve: Curves.easeInOut));
_pulseAnim = Tween(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: pulse, curve: Curves.easeInOut),
);
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<VpnConnection>().addListener(_onVpnChanged);
if (mounted) context.read<VpnConnection>().addListener(_onVpnChanged);
});
}
@@ -68,7 +71,6 @@ class _ConnectButtonState extends State<ConnectButton>
}
Future<void> _handleTap() async {
final now = DateTime.now();
if (now.difference(_lastTap).inMilliseconds < 1000) return;
_lastTap = now;
@@ -77,32 +79,21 @@ class _ConnectButtonState extends State<ConnectButton>
final timer = context.read<SessionTimer>();
final ad = context.read<AdManager>();
if (vpn.status == VpnStatus.connected || vpn.status == VpnStatus.connecting) {
_busy = false;
await timer.disconnect();
unawaited(Haptics.connectionChanged());
return;
}
if (vpn.status == VpnStatus.disconnecting) return;
if (_busy) return;
if (vpn.status == VpnStatus.disconnecting || _busy) return;
setState(() => _busy = true);
try {
final adWatched = await ad.showAd('main');
if (!adWatched && AdManager.adsEnabled) {
setState(() => _busy = false);
return;
}
if (!adWatched && AdManager.adsEnabled) return;
final ok = await vpn.connect();
if (ok) {
timer.start();
unawaited(Haptics.connectionChanged());
}
if (ok) await timer.start();
} finally {
if (mounted) setState(() => _busy = false);
}
@@ -170,7 +161,7 @@ class _ConnectButtonState extends State<ConnectButton>
color: Color.fromARGB(glowAlpha, 255, 214, 0),
blurRadius: glowRadius,
spreadRadius: glowRadius * 0.3,
)
),
]
: [],
),
+4 -1
View File
@@ -141,7 +141,10 @@ class _SidebarDrawerState extends State<SidebarDrawer> {
final messenger = ScaffoldMessenger.of(context);
Navigator.of(context).pop();
unawaited(
Updater.check(navigator: navigator, messenger: messenger),
Updater.checkWithHandles(
navigator: navigator,
messenger: messenger,
),
);
},
),
+27 -32
View File
@@ -7,16 +7,6 @@ import 'package:revoltvpn/logic/server_list.dart';
import 'package:revoltvpn/components/online_dot.dart';
import 'package:revoltvpn/screens/server_selector/selection_screen.dart';
// ── lib/components/status_bar.dart ──────────────────────────────────────────
// Two-card layout at the bottom of the main screen.
//
// Top card: server location from ServerList provider (flag + city).
// Tappable → opens server selector. Always visible.
// Bottom card: connection status (health dot + label + lock icon).
//
// Separated into two rows — no more RenderFlex overflow and ready for
// multi-server picker.
class StatusBar extends StatelessWidget {
const StatusBar({super.key});
@@ -28,15 +18,14 @@ class StatusBar extends StatelessWidget {
final isConnecting = vpn.status == VpnStatus.connecting;
final serverHealthy =
vpn.serverReachable || (isConnected && timer.hasSyncedOnce);
final sessionOrTunnelHealthy = timer.hasSyncedOnce || vpn.serverReachable;
final statusLabel = isConnected
? (timer.hasSyncedOnce ? 'Online' : 'Syncing…')
? (sessionOrTunnelHealthy ? 'Online' : 'Syncing…')
: (isConnecting
? 'Connecting…'
: (vpn.serverReachable ? 'Ready' : 'Offline'));
// Read from the ServerList provider — the source of truth for
// which server is selected and what it looks like.
final server = context.watch<ServerList>().selected;
return Padding(
@@ -44,7 +33,6 @@ class StatusBar extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ── Server location ──────────────────────────────────────────
GestureDetector(
onTap: () {
Navigator.of(context).push(
@@ -56,10 +44,12 @@ class StatusBar 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,
);
},
@@ -79,24 +69,26 @@ class StatusBar extends StatelessWidget {
Image.asset(server.flagAsset, width: 20, height: 20),
const SizedBox(width: 8),
Expanded(
child: Text(server.name,
style: const TextStyle(
color: AppColors.accent,
fontWeight: FontWeight.w600,
fontSize: 13,
letterSpacing: 0.5,
)),
child: Text(
server.name,
style: const TextStyle(
color: AppColors.accent,
fontWeight: FontWeight.w600,
fontSize: 13,
letterSpacing: 0.5,
),
),
),
const Icon(
Icons.chevron_right,
color: AppColors.textDim,
size: 20,
),
const Icon(Icons.chevron_right,
color: AppColors.textDim, size: 20),
],
),
),
),
const SizedBox(height: 8),
// ── Connection status ────────────────────────────────────────
Container(
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
@@ -113,8 +105,11 @@ class StatusBar extends StatelessWidget {
),
const Spacer(),
if (isConnected)
const Icon(Icons.lock,
color: AppColors.green50, size: 14),
const Icon(
Icons.lock,
color: AppColors.green50,
size: 14,
),
],
),
),
+61 -39
View File
@@ -20,13 +20,21 @@ class _SupportButtonState extends State<SupportButton> {
final ad = context.read<AdManager>();
final timer = context.read<SessionTimer>();
// Only available when there's an active session.
if (!timer.isRunning && !timer.hasSyncedOnce) return;
// Only available when there's an active session, once the persisted
// support state is known, and only once per client-side VPN session.
if ((!timer.isRunning && !timer.hasSyncedOnce) ||
!timer.supportRewardStateLoaded ||
timer.supportRewardClaimed) {
return;
}
setState(() => _busy = true);
try {
await ad.showAd('support');
final rewarded = await ad.showAd('support');
if (rewarded) {
await timer.markSupportRewardClaimed();
}
} finally {
if (mounted) setState(() => _busy = false);
}
@@ -34,48 +42,62 @@ class _SupportButtonState extends State<SupportButton> {
@override
Widget build(BuildContext context) {
return Selector<SessionTimer, bool>(
selector: (_, timer) => timer.isRunning || timer.hasSyncedOnce,
builder: (context, visible, _) {
if (!visible) {
return Consumer<SessionTimer>(
builder: (context, timer, _) {
if (!timer.isRunning && !timer.hasSyncedOnce) {
return const SizedBox.shrink();
}
final stateLoaded = timer.supportRewardStateLoaded;
final claimed = timer.supportRewardClaimed;
final disabled = !stateLoaded || claimed;
return GestureDetector(
onTap: _handleTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: AppColors.glassBg,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: AppColors.accent.withAlpha(100),
width: 1,
onTap: disabled ? null : _handleTap,
child: Opacity(
opacity: disabled ? 0.55 : 1.0,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: AppColors.glassBg,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: AppColors.accent.withAlpha(100),
width: 1,
),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.favorite_border, color: AppColors.accent, size: 20),
const SizedBox(width: 8),
_busy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.accent,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
claimed ? Icons.check_circle_outline : Icons.favorite_border,
color: AppColors.accent,
size: 20,
),
const SizedBox(width: 8),
_busy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.accent,
),
)
: Text(
!stateLoaded
? 'Checking support bonus…'
: claimed
? 'Support bonus used'
: 'Support us — 30 min!',
style: const TextStyle(
color: AppColors.textWhite,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
)
: const Text(
'Support us \u2014 30 min!',
style: TextStyle(
color: AppColors.textWhite,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
],
],
),
),
),
);