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:
1 parent
371db8da9f
commit
8ead3ff249
30 files changed
+3959
-484
No files matched your search
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
),
|
||||
]
|
||||
: [],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user