bugfixes: switch fallthrough, SSV debug-gate, session verify, speed math, auto-reconnect, permission recovery, health indicator, lifecycle-safety, GDPR consent, progress feedback
This commit is contained in:
1 parent
86baf38149
commit
a84474a586
15 files changed
+567
-94
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:paladinvpn/logic/vpn_connection.dart';
|
||||
import 'package:paladinvpn/logic/session_timer.dart';
|
||||
import 'package:paladinvpn/logic/ad_manager.dart';
|
||||
import 'package:paladinvpn/components/online_dot.dart';
|
||||
import 'package:paladinvpn/components/watch_ad_button.dart';
|
||||
@@ -14,9 +15,12 @@ class BottomBar extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<VpnConnection>(
|
||||
builder: (context, vpn, _) {
|
||||
final isOnline = vpn.status == VpnStatus.connected;
|
||||
return Consumer2<VpnConnection, SessionTimer>(
|
||||
builder: (context, vpn, timer, _) {
|
||||
final isConnected = vpn.status == VpnStatus.connected;
|
||||
// Health is based on whether we've successfully talked to the
|
||||
// server recently — not just whether the VPN tunnel is up.
|
||||
final serverHealthy = isConnected && timer.hasSyncedOnce;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||
@@ -31,10 +35,17 @@ class BottomBar extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
// Server online/offline chip
|
||||
OnlineDot(isOnline: isOnline),
|
||||
OnlineDot(
|
||||
isOnline: serverHealthy,
|
||||
label: isConnected && !timer.hasSyncedOnce
|
||||
? 'Syncing…'
|
||||
: serverHealthy
|
||||
? 'Server Online'
|
||||
: 'Disconnected',
|
||||
),
|
||||
const Spacer(),
|
||||
// Support button — only visible while connected AND ads are enabled
|
||||
if (isOnline && AdManager.adsEnabled) const WatchAdButton(),
|
||||
if (isConnected && AdManager.adsEnabled) const WatchAdButton(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:paladinvpn/logic/session_timer.dart';
|
||||
@@ -13,7 +12,6 @@ import 'package:paladinvpn/logic/session_timer.dart';
|
||||
class ClockDisplay extends StatelessWidget {
|
||||
const ClockDisplay({super.key});
|
||||
|
||||
static const Color _cyanGlow = Color(0xFF00E5FF);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -77,8 +75,8 @@ class ClockDisplay extends StatelessWidget {
|
||||
const Icon(Icons.speed, color: Colors.white54, size: 14),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
timer.currentSpeedKbps > 0.5
|
||||
? '${timer.currentSpeedKbps.toStringAsFixed(1)} KB/s'
|
||||
timer.currentSpeedKBps > 0.5
|
||||
? '${timer.currentSpeedKBps.toStringAsFixed(1)} KB/s'
|
||||
: 'Idle',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
|
||||
@@ -65,10 +65,12 @@ class _ConnectButtonState extends State<ConnectButton>
|
||||
final timer = context.read<SessionTimer>();
|
||||
|
||||
if (vpn.status == VpnStatus.connected) {
|
||||
// Disconnect — apply a short cooldown to prevent rapid toggle spam
|
||||
// Disconnect — await the full teardown so the notification and UI
|
||||
// stay in sync with the actual tunnel state.
|
||||
HapticFeedback.lightImpact();
|
||||
vpn.disconnect();
|
||||
timer.stop();
|
||||
await vpn.disconnect();
|
||||
// Short cooldown to prevent accidental immediate reconnect
|
||||
setState(() => _inCooldown = true);
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
if (mounted) setState(() => _inCooldown = false);
|
||||
@@ -85,11 +87,50 @@ class _ConnectButtonState extends State<ConnectButton>
|
||||
if (mounted) setState(() => _inCooldown = false);
|
||||
});
|
||||
}
|
||||
// On failure the button stays active — the error message in
|
||||
// StatusText tells the user what went wrong.
|
||||
// On failure, check if it's a permission issue and offer recovery.
|
||||
if (!success && mounted) {
|
||||
final msg = vpn.errorMessage ?? '';
|
||||
if (msg.contains('permission') || msg.contains('Permission')) {
|
||||
_showPermissionDialog();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows a dialog guiding the user to grant the VPN permission in
|
||||
/// Android system settings, since there's no way to re-prompt the
|
||||
/// native VpnService dialog after the first denial.
|
||||
void _showPermissionDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: const Color(0xFF1A202C),
|
||||
title: const Text('VPN Permission Required',
|
||||
style: TextStyle(color: Colors.white)),
|
||||
content: const Text(
|
||||
'PaladinVPN needs permission to set up a VPN connection.\n\n'
|
||||
'Since the permission was denied once, Android won\'t ask again. '
|
||||
'To grant it manually:\n\n'
|
||||
'1. Open your device Settings\n'
|
||||
'2. Go to Apps → PaladinVPN\n'
|
||||
'3. Tap "VPN" and enable it\n'
|
||||
'4. Return to PaladinVPN and tap connect',
|
||||
style: TextStyle(color: Color(0xFFA0AEC0)),
|
||||
),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF00E5FF),
|
||||
),
|
||||
child: const Text('Got it',
|
||||
style: TextStyle(color: Color(0xFF0D1117))),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer2<VpnConnection, SessionTimer>(
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
/// GdprButton lets the user review or change their ad consent choices.
|
||||
///
|
||||
/// When tapped, it tries to show Google's UMP consent form so the user
|
||||
/// can adjust their preferences (personalized vs non-personalized ads).
|
||||
/// If the form isn't available (e.g. user is outside the EEA), it falls
|
||||
/// back to opening the privacy policy instead.
|
||||
class GdprButton extends StatelessWidget {
|
||||
const GdprButton({super.key});
|
||||
|
||||
Future<void> _showConsentForm() async {
|
||||
try {
|
||||
// If the form is already available, show it directly — no need to
|
||||
// re-request consent info from Google's servers on every tap.
|
||||
if (await ConsentInformation.instance.isConsentFormAvailable()) {
|
||||
await _loadAndShowForm();
|
||||
return;
|
||||
}
|
||||
|
||||
// Form not available — refresh consent info first, then try again.
|
||||
final completer = Completer<void>();
|
||||
ConsentInformation.instance.requestConsentInfoUpdate(
|
||||
ConsentRequestParameters(),
|
||||
() async {
|
||||
if (await ConsentInformation.instance.isConsentFormAvailable()) {
|
||||
await _loadAndShowForm();
|
||||
}
|
||||
completer.complete();
|
||||
},
|
||||
(FormError error) {
|
||||
debugPrint('[GDPR] Consent update error: ${error.message}');
|
||||
_fallbackToPrivacyPolicy();
|
||||
completer.complete();
|
||||
},
|
||||
);
|
||||
await completer.future.timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: _fallbackToPrivacyPolicy,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[GDPR] Error: $e');
|
||||
_fallbackToPrivacyPolicy();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadAndShowForm() async {
|
||||
final completer = Completer<void>();
|
||||
ConsentForm.loadConsentForm(
|
||||
(ConsentForm form) {
|
||||
form.show((FormError? dismissError) {
|
||||
if (dismissError != null) {
|
||||
debugPrint('[GDPR] Form dismissed with error: ${dismissError.message}');
|
||||
}
|
||||
completer.complete();
|
||||
});
|
||||
},
|
||||
(FormError loadError) {
|
||||
debugPrint('[GDPR] Form load error: ${loadError.message}');
|
||||
_fallbackToPrivacyPolicy();
|
||||
completer.complete();
|
||||
},
|
||||
);
|
||||
await completer.future.timeout(
|
||||
const Duration(seconds: 120),
|
||||
onTimeout: _fallbackToPrivacyPolicy,
|
||||
);
|
||||
}
|
||||
|
||||
void _fallbackToPrivacyPolicy() async {
|
||||
final Uri url = Uri.parse(
|
||||
'https://github.com/esefxdz/PaladinVPN/blob/main/PRIVACY_POLICY.md',
|
||||
);
|
||||
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
|
||||
debugPrint('Could not launch $url');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.admin_panel_settings_outlined,
|
||||
color: Colors.white, size: 28),
|
||||
onPressed: _showConsentForm,
|
||||
tooltip: 'Ad consent & privacy choices',
|
||||
splashRadius: 24,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -41,10 +41,14 @@ class VpnNotificationManager {
|
||||
|
||||
if (!_initialized) await init();
|
||||
|
||||
// NOTE: The channel ID includes 'v2' to force Android to create a fresh
|
||||
// notification channel. Android channels are immutable once created — if
|
||||
// the old channel was created without 'ongoing: true', users would be
|
||||
// stuck with a swipeable notification forever.
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'paladin_vpn_status',
|
||||
'paladin_vpn_status_v2',
|
||||
'VPN Status',
|
||||
channelDescription: 'Persistent notification shown while VPN is active',
|
||||
channelDescription: 'Shown while VPN is active — non-dismissable',
|
||||
importance: Importance.low,
|
||||
priority: Priority.low,
|
||||
// Non-dismissable while VPN is running
|
||||
@@ -58,16 +62,36 @@ class VpnNotificationManager {
|
||||
showWhen: false,
|
||||
// Show on lock screen
|
||||
visibility: NotificationVisibility.public,
|
||||
// Extra insurance against swipe-to-dismiss on aggressive Android skins
|
||||
usesChronometer: false,
|
||||
category: AndroidNotificationCategory.service,
|
||||
);
|
||||
|
||||
const notificationDetails = NotificationDetails(android: androidDetails);
|
||||
|
||||
await _plugin.show(
|
||||
888, // Fixed ID — always updates the same notification
|
||||
'Paladin VPN Active',
|
||||
'Time: $timeLeft • $speedKbps',
|
||||
notificationDetails,
|
||||
);
|
||||
try {
|
||||
await _plugin.show(
|
||||
888, // Fixed ID — always updates the same notification
|
||||
'Paladin VPN Active',
|
||||
'Time: $timeLeft • $speedKbps',
|
||||
notificationDetails,
|
||||
);
|
||||
} catch (e) {
|
||||
// If showing fails (e.g. permission revoked), try to recover by
|
||||
// re-initializing and retrying once.
|
||||
_initialized = false;
|
||||
try {
|
||||
await init();
|
||||
await _plugin.show(
|
||||
888,
|
||||
'Paladin VPN Active',
|
||||
'Time: $timeLeft • $speedKbps',
|
||||
notificationDetails,
|
||||
);
|
||||
} catch (_) {
|
||||
// Give up — the VPN still works, just without the notification.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> cancel() async {
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// OnlineDot displays a glowing green dot when the server is online/connected,
|
||||
/// and a muted gray dot when disconnected.
|
||||
/// OnlineDot displays a glowing green dot when the server is healthy,
|
||||
/// and a muted gray dot when disconnected or still syncing.
|
||||
class OnlineDot extends StatelessWidget {
|
||||
/// Represents if the VPN tunnel session is active (online).
|
||||
/// Whether the server has been confirmed reachable.
|
||||
final bool isOnline;
|
||||
|
||||
/// Optional custom label. Falls back to "Server Online" / "Disconnected".
|
||||
final String? label;
|
||||
|
||||
const OnlineDot({
|
||||
super.key,
|
||||
required this.isOnline,
|
||||
this.label,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final displayLabel = label ??
|
||||
(isOnline ? 'Server Online' : 'Disconnected');
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Glowing status dot: Green when active, muted gray when inactive.
|
||||
// Glowing status dot: Green when healthy, muted gray when not.
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
width: 8,
|
||||
@@ -35,10 +42,8 @@ class OnlineDot extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
|
||||
// Dynamic status text label
|
||||
Text(
|
||||
isOnline ? 'Server Online' : 'Disconnected',
|
||||
displayLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
|
||||
/// ConsentManager wraps Google's UMP (User Messaging Platform) SDK.
|
||||
///
|
||||
/// What it does:
|
||||
/// - Checks whether the user is in the EEA, UK, or another region requiring
|
||||
/// GDPR-style consent.
|
||||
/// - If consent is required and hasn't been collected yet, shows Google's
|
||||
/// standard consent form (handles all the legal wording automatically).
|
||||
/// - Communicates the user's choices to AdMob so ads respect their preferences.
|
||||
///
|
||||
/// This satisfies both Google Play's ad policy requirement AND the EU GDPR
|
||||
/// requirement for informed consent before personalized ads.
|
||||
///
|
||||
/// Usage — call once at app startup before anything else:
|
||||
/// ```dart
|
||||
/// await ConsentManager.requestConsentIfNeeded();
|
||||
/// ```
|
||||
class ConsentManager {
|
||||
ConsentManager._();
|
||||
|
||||
/// Whether the consent flow has completed (consent gathered or not needed).
|
||||
static bool _isComplete = false;
|
||||
static bool get isComplete => _isComplete;
|
||||
|
||||
/// Run the full consent flow.
|
||||
///
|
||||
/// Returns `true` when consent is resolved (or wasn't needed), so the app
|
||||
/// can safely proceed to initialize ads and the VPN.
|
||||
static Future<bool> requestConsentIfNeeded() async {
|
||||
if (_isComplete) return true;
|
||||
|
||||
try {
|
||||
final params = ConsentRequestParameters(
|
||||
consentDebugSettings: kDebugMode
|
||||
? ConsentDebugSettings(
|
||||
debugGeography: DebugGeography.debugGeographyEea,
|
||||
testIdentifiers: [],
|
||||
)
|
||||
: null,
|
||||
);
|
||||
|
||||
// Request consent info update from Google's servers.
|
||||
// In google_mobile_ads 4.0.0 this uses a callback pattern.
|
||||
await _requestConsentInfo(params);
|
||||
|
||||
// Check if a consent form is available and needed.
|
||||
if (await ConsentInformation.instance.isConsentFormAvailable()) {
|
||||
await _loadAndShowForm();
|
||||
debugPrint('[Consent] Form shown and dismissed by user.');
|
||||
} else {
|
||||
debugPrint('[Consent] No consent form needed for this user.');
|
||||
}
|
||||
} catch (e) {
|
||||
// If anything goes wrong, fail open — the app still works, ads just
|
||||
// default to non-personalized.
|
||||
debugPrint('[Consent] Unexpected error in consent flow: $e');
|
||||
}
|
||||
|
||||
_isComplete = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Wraps the callback-based requestConsentInfoUpdate in a Future.
|
||||
static Future<void> _requestConsentInfo(ConsentRequestParameters params) {
|
||||
final completer = Completer<void>();
|
||||
ConsentInformation.instance.requestConsentInfoUpdate(
|
||||
params,
|
||||
() {
|
||||
debugPrint('[Consent] Consent info updated successfully.');
|
||||
completer.complete();
|
||||
},
|
||||
(FormError error) {
|
||||
debugPrint('[Consent] Failed to update consent info: ${error.message}');
|
||||
completer.complete(); // Proceed anyway
|
||||
},
|
||||
);
|
||||
return completer.future.timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () => debugPrint('[Consent] Consent info request timed out.'),
|
||||
);
|
||||
}
|
||||
|
||||
/// Loads and shows the consent form with a timeout.
|
||||
static Future<void> _loadAndShowForm() {
|
||||
final completer = Completer<void>();
|
||||
|
||||
ConsentForm.loadConsentForm(
|
||||
(ConsentForm form) {
|
||||
debugPrint('[Consent] Form loaded — showing.');
|
||||
form.show((FormError? dismissError) {
|
||||
if (dismissError != null) {
|
||||
debugPrint('[Consent] Form dismissed with error: ${dismissError.message}');
|
||||
}
|
||||
completer.complete();
|
||||
});
|
||||
},
|
||||
(FormError loadError) {
|
||||
debugPrint('[Consent] Form load error: ${loadError.message}');
|
||||
completer.complete(); // Proceed anyway
|
||||
},
|
||||
);
|
||||
|
||||
return completer.future.timeout(
|
||||
const Duration(seconds: 120),
|
||||
onTimeout: () => debugPrint('[Consent] Form show timed out.'),
|
||||
);
|
||||
}
|
||||
|
||||
/// Resets consent state. Useful for testing; NOT for production.
|
||||
static Future<void> resetForTesting() async {
|
||||
await ConsentInformation.instance.reset();
|
||||
_isComplete = false;
|
||||
}
|
||||
}
|
||||
@@ -8,31 +8,40 @@ import 'package:paladinvpn/logic/crypto_service.dart';
|
||||
class HivemindService {
|
||||
/// Polls the Hivemind server until the session is ready or timeout occurs.
|
||||
/// Returns the WireGuard config string.
|
||||
static Future<String> fetchConfigWithPolling() async {
|
||||
///
|
||||
/// [onAttempt] is called after each poll with (current, total) so the UI
|
||||
/// can show progress (e.g. "Contacting server (3/15)…").
|
||||
static Future<String> fetchConfigWithPolling({
|
||||
void Function(int attempt, int total)? onAttempt,
|
||||
}) async {
|
||||
final deviceId = await CryptoService.getDeviceId();
|
||||
final keys = await CryptoService.getOrCreateKeys();
|
||||
final privKey = keys['privateKey']!;
|
||||
|
||||
final url = Uri.parse('${AppConfig.hivemindApiBase}/session/status?device_id=$deviceId');
|
||||
|
||||
// ── TEMPORARY AD BYPASS HACK FOR TESTING ──
|
||||
// Since the crypto signature check is disabled on the Python server,
|
||||
// we send a fake "Ad Watched" ping from the app directly.
|
||||
// TODO: Remove this when real AdMob SSV is enabled on the server.
|
||||
try {
|
||||
final customData = jsonEncode({
|
||||
'device_id': deviceId,
|
||||
'public_key': keys['publicKey']!,
|
||||
'ad_type': 'main_ad',
|
||||
});
|
||||
final fakeAdmobPingUrl = Uri.parse(
|
||||
'${AppConfig.hivemindApiBase}/admob/callback?signature=test&key_id=test&custom_data=${Uri.encodeComponent(customData)}');
|
||||
await http.get(fakeAdmobPingUrl).timeout(const Duration(seconds: 8));
|
||||
} catch (_) {}
|
||||
// ──────────────────────────────────────────
|
||||
// ── AD BYPASS (debug only) ────────────────────────────────────────
|
||||
// In debug mode, send a fake AdMob callback so the server creates
|
||||
// a session without a real ad. In release mode this code is
|
||||
// stripped — sessions are only created by real AdMob SSV callbacks.
|
||||
if (kDebugMode) {
|
||||
try {
|
||||
final customData = jsonEncode({
|
||||
'device_id': deviceId,
|
||||
'public_key': keys['publicKey']!,
|
||||
'ad_type': 'main_ad',
|
||||
});
|
||||
final fakeAdmobPingUrl = Uri.parse(
|
||||
'${AppConfig.hivemindApiBase}/admob/callback?signature=test&key_id=test&custom_data=${Uri.encodeComponent(customData)}');
|
||||
await http.get(fakeAdmobPingUrl).timeout(const Duration(seconds: 8));
|
||||
} catch (_) {}
|
||||
}
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
|
||||
int attempts = 0;
|
||||
while (attempts < 15) {
|
||||
debugPrint('[HivemindService] Polling attempt ${attempts + 1}/15…');
|
||||
onAttempt?.call(attempts + 1, 15);
|
||||
try {
|
||||
final response = await http.get(url).timeout(const Duration(seconds: 10));
|
||||
if (response.statusCode == 200) {
|
||||
@@ -85,6 +94,23 @@ PersistentKeepalive = ${AppConfig.persistentKeepalive}
|
||||
throw Exception("Session not activated. S2S callback failed or timed out.");
|
||||
}
|
||||
|
||||
/// Quick one-shot check: does the server have a live session for this device?
|
||||
/// Used by VpnConnection after the tunnel starts to confirm the server-side
|
||||
/// session exists before reporting "connected" to the UI.
|
||||
static Future<bool> verifySession() async {
|
||||
try {
|
||||
final deviceId = await CryptoService.getDeviceId();
|
||||
final url = Uri.parse(
|
||||
'${AppConfig.hivemindApiBase}/session/status?device_id=$deviceId');
|
||||
final response = await http.get(url).timeout(const Duration(seconds: 5));
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
return data['active'] == true;
|
||||
}
|
||||
} catch (_) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Disconnects the session (cleans up on server)
|
||||
static Future<void> disconnectAndCleanup() async {
|
||||
final deviceId = await CryptoService.getDeviceId();
|
||||
|
||||
+108
-20
@@ -28,17 +28,24 @@ class SessionTimer extends ChangeNotifier {
|
||||
bool _hasSyncedOnce = false;
|
||||
int _consecutiveFailures = 0;
|
||||
bool _isDisconnecting = false;
|
||||
bool _userInitiatedStop = false; // true when user tapped disconnect
|
||||
|
||||
/// Maximum consecutive sync failures before the local countdown is paused
|
||||
/// to avoid showing a false "expired" state while the network is down.
|
||||
static const int _maxConsecutiveFailures = 3;
|
||||
|
||||
/// How long we tolerate total silence from the server before forcibly
|
||||
/// killing the VPN. Prevents the "orphaned tunnel" scenario where the
|
||||
/// server is gone but the VPN stays up forever.
|
||||
static const int _maxOfflineSeconds = 120;
|
||||
int _offlineSeconds = 0;
|
||||
|
||||
/// Seconds between server polls (reduced from 3 to ease server load).
|
||||
static const int _pollIntervalSeconds = 5;
|
||||
|
||||
// ── Speed State ────────────────────────────────────────────────────────────
|
||||
int _lastUsedBytes = 0;
|
||||
double _currentSpeedKbps = 0.0;
|
||||
int _lastUsedBytes = 0;
|
||||
double _currentSpeedKBps = 0.0; // kilobytes per second
|
||||
|
||||
// ── Notification dedup ─────────────────────────────────────────────────────
|
||||
String _lastNotifTime = '';
|
||||
@@ -59,7 +66,7 @@ class SessionTimer extends ChangeNotifier {
|
||||
int get usedBytes => _usedBytes;
|
||||
int get remainingBytes => _quotaBytes > _usedBytes ? _quotaBytes - _usedBytes : 0;
|
||||
bool get isThrottled => _isThrottled;
|
||||
double get currentSpeedKbps => _currentSpeedKbps;
|
||||
double get currentSpeedKBps => _currentSpeedKBps;
|
||||
|
||||
String get formatted {
|
||||
final h = (_remainingSeconds ~/ 3600).toString().padLeft(2, '0');
|
||||
@@ -92,13 +99,30 @@ class SessionTimer extends ChangeNotifier {
|
||||
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Fires whenever VpnConnection state changes.
|
||||
/// Only auto-restarts the timer during startup restoration —
|
||||
/// normal connects are driven by [ConnectButton].
|
||||
void _onVpnConnectionChanged() {
|
||||
// ── Startup restoration: app killed while VPN was running ──────────
|
||||
if (vpnConnection.status == VpnStatus.connected &&
|
||||
!isRunning &&
|
||||
vpnConnection.isStartupRestoration) {
|
||||
start('resume');
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Network-blip reconnect: VPN dropped and came back on its own ──
|
||||
// The underlying AmneziaWG tunnel may briefly flap. If we weren't
|
||||
// explicitly stopped by the user, resume where we left off.
|
||||
if (vpnConnection.status == VpnStatus.connected &&
|
||||
!isRunning &&
|
||||
!_userInitiatedStop &&
|
||||
_hasSyncedOnce) {
|
||||
debugPrint('[Timer] VPN reconnected after blip — resuming.');
|
||||
_resumeTicking();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Auto-cleanup when the VPN actually goes down ──────────────────
|
||||
if (vpnConnection.status == VpnStatus.disconnected && isRunning) {
|
||||
_stopInternal();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,12 +135,14 @@ class SessionTimer extends ChangeNotifier {
|
||||
_quotaBytes = 0;
|
||||
_usedBytes = 0;
|
||||
_lastUsedBytes = 0;
|
||||
_currentSpeedKbps = 0.0;
|
||||
_currentSpeedKBps = 0.0;
|
||||
_isThrottled = false;
|
||||
_tickCount = 0;
|
||||
_hasSyncedOnce = false;
|
||||
_consecutiveFailures = 0;
|
||||
_offlineSeconds = 0;
|
||||
_isDisconnecting = false;
|
||||
_userInitiatedStop = false;
|
||||
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
||||
@@ -128,14 +154,36 @@ class SessionTimer extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void _tick(Timer t) {
|
||||
// Only decrement the local clock after the first successful sync
|
||||
// and while we are not in a network-blackout window.
|
||||
// ── Local countdown ──────────────────────────────────────────────────
|
||||
// Decrement the local clock as long as we're not in a network blackout.
|
||||
if (_hasSyncedOnce && _consecutiveFailures < _maxConsecutiveFailures) {
|
||||
if (_remainingSeconds > 0) {
|
||||
_remainingSeconds--;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Offline watchdog ─────────────────────────────────────────────────
|
||||
// If the server has been unreachable for too long, kill the VPN.
|
||||
// Don't let an orphaned tunnel keep running forever.
|
||||
if (_consecutiveFailures >= _maxConsecutiveFailures) {
|
||||
_offlineSeconds++;
|
||||
if (_offlineSeconds >= _maxOfflineSeconds) {
|
||||
debugPrint('[Timer] Offline for $_maxOfflineSeconds s — forcing disconnect.');
|
||||
_forceDisconnect('Session lost — server unreachable');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Local expiry ─────────────────────────────────────────────────────
|
||||
// When our clock hits zero, disconnect immediately. Don't wait for the
|
||||
// next server poll — the session is over and the user expects the VPN
|
||||
// to drop right now.
|
||||
if (_hasSyncedOnce && _remainingSeconds <= 0) {
|
||||
debugPrint('[Timer] Local countdown expired — disconnecting.');
|
||||
_forceDisconnect('Session expired');
|
||||
return;
|
||||
}
|
||||
|
||||
_tickCount++;
|
||||
if (_tickCount % _pollIntervalSeconds == 0) {
|
||||
_syncWithHivemind();
|
||||
@@ -144,6 +192,22 @@ class SessionTimer extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Force-disconnects the VPN and stops the timer with a status message.
|
||||
/// Used as a local fallback when the server can't be reached.
|
||||
void _forceDisconnect(String reason) {
|
||||
if (_isDisconnecting) return; // Already tearing down
|
||||
debugPrint('[Timer] $reason — forcing disconnect.');
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_currentSpeedKBps = 0.0;
|
||||
_isDisconnecting = true;
|
||||
VpnNotificationManager.cancel();
|
||||
notifyListeners();
|
||||
// Await the disconnect so the tunnel is confirmed down before we
|
||||
// consider the session fully cleaned up.
|
||||
vpnConnection.disconnect();
|
||||
}
|
||||
|
||||
// ── Server Sync ────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _syncWithHivemind() async {
|
||||
@@ -161,9 +225,9 @@ class SessionTimer extends ChangeNotifier {
|
||||
|
||||
final bool active = data['active'] ?? false;
|
||||
if (!active) {
|
||||
// Server killed the session!
|
||||
// Server killed the session — tear down and wait for it.
|
||||
stop();
|
||||
vpnConnection.disconnect();
|
||||
await vpnConnection.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -175,19 +239,20 @@ class SessionTimer extends ChangeNotifier {
|
||||
// ── Speed calculation over the polling interval ────────────────────
|
||||
final int deltaBytes = _usedBytes - _lastUsedBytes;
|
||||
if (_hasSyncedOnce && deltaBytes > 0) {
|
||||
_currentSpeedKbps = (deltaBytes / _pollIntervalSeconds) / 1024;
|
||||
_currentSpeedKBps = (deltaBytes / _pollIntervalSeconds) / 1000;
|
||||
} else if (!_hasSyncedOnce) {
|
||||
_currentSpeedKbps = 0.0;
|
||||
_currentSpeedKBps = 0.0;
|
||||
}
|
||||
_lastUsedBytes = _usedBytes;
|
||||
|
||||
// ── Network is healthy — reset failure counter ─────────────────────
|
||||
// ── Network is healthy — reset failure counters ────────────────────
|
||||
_consecutiveFailures = 0;
|
||||
_offlineSeconds = 0;
|
||||
_hasSyncedOnce = true;
|
||||
|
||||
// ── Update notification (only when values actually change) ─────────
|
||||
final speedText = _currentSpeedKbps > 0.5
|
||||
? '${_currentSpeedKbps.toStringAsFixed(1)} KB/s'
|
||||
final speedText = _currentSpeedKBps > 0.5
|
||||
? '${_currentSpeedKBps.toStringAsFixed(1)} KB/s'
|
||||
: 'Idle';
|
||||
if (formatted != _lastNotifTime || speedText != _lastNotifSpeed) {
|
||||
_lastNotifTime = formatted;
|
||||
@@ -202,7 +267,7 @@ class SessionTimer extends ChangeNotifier {
|
||||
} else if (response.statusCode == 404) {
|
||||
// Peer not found on server (expired / deleted natively)
|
||||
stop();
|
||||
vpnConnection.disconnect();
|
||||
await vpnConnection.disconnect();
|
||||
} else {
|
||||
_markSyncFailure();
|
||||
}
|
||||
@@ -243,14 +308,37 @@ class SessionTimer extends ChangeNotifier {
|
||||
|
||||
// ── Stop ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Called externally (e.g. from ConnectButton) to begin tearing down.
|
||||
/// Cancels the timer but keeps the notification alive —
|
||||
/// [_onVpnConnectionChanged] will do final cleanup when the VPN actually
|
||||
/// confirms it's disconnected.
|
||||
void stop() {
|
||||
_userInitiatedStop = true;
|
||||
_isDisconnecting = true;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
// Keep _remainingSeconds visible so the UI doesn't flash 00:00:00
|
||||
// while the tunnel is tearing down. The VpnConnection will drive the
|
||||
// UI transition, and dispose() will do final cleanup.
|
||||
_currentSpeedKbps = 0.0;
|
||||
_currentSpeedKBps = 0.0;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Restarts the periodic timer without resetting session state.
|
||||
/// Used when the VPN reconnects after a brief network blip so we don't
|
||||
/// lose track of remaining time / data.
|
||||
void _resumeTicking() {
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
||||
_isDisconnecting = false;
|
||||
_syncWithHivemind();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Internal cleanup — called when VpnConnection confirms disconnected.
|
||||
/// Cancels the persistent notification since the tunnel is truly down.
|
||||
void _stopInternal() {
|
||||
_isDisconnecting = true;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_currentSpeedKBps = 0.0;
|
||||
VpnNotificationManager.cancel();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -115,19 +115,24 @@ class VpnConnection extends ChangeNotifier {
|
||||
switch (stage) {
|
||||
case VpnStage.connected:
|
||||
_setStatus(VpnStatus.connected, 'Secured');
|
||||
break;
|
||||
case VpnStage.disconnected:
|
||||
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
||||
break;
|
||||
case VpnStage.connecting:
|
||||
case VpnStage.waitingConnection:
|
||||
case VpnStage.authenticating:
|
||||
case VpnStage.preparing:
|
||||
case VpnStage.reconnect:
|
||||
_setStatus(VpnStatus.connecting, 'Establishing tunnel…');
|
||||
break;
|
||||
case VpnStage.disconnecting:
|
||||
case VpnStage.exiting:
|
||||
_setStatus(VpnStatus.disconnecting, 'Tearing down…');
|
||||
break;
|
||||
default:
|
||||
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +171,11 @@ class VpnConnection extends ChangeNotifier {
|
||||
// ── Step 1: Fetch WireGuard config from Hivemind ────────────────────
|
||||
String configText;
|
||||
try {
|
||||
configText = await HivemindService.fetchConfigWithPolling();
|
||||
configText = await HivemindService.fetchConfigWithPolling(
|
||||
onAttempt: (attempt, total) {
|
||||
_setStatus(VpnStatus.connecting, 'Contacting server ($attempt/$total)…');
|
||||
},
|
||||
);
|
||||
|
||||
// Parse the internal IP (e.g. "Address = 10.8.0.2/16" or "/32")
|
||||
final regex = RegExp(r'Address\s*=\s*([0-9\.]+)/');
|
||||
@@ -204,6 +213,24 @@ class VpnConnection extends ChangeNotifier {
|
||||
wgQuickConfig: configText,
|
||||
providerBundleIdentifier: 'com.paladinvpn.app.WireGuardProvider',
|
||||
);
|
||||
|
||||
// ── Step 3: Verify the server session is alive ──────────────────
|
||||
// The tunnel is up but the server might not have a session yet.
|
||||
// Do a quick one-shot status check before telling the UI we're
|
||||
// connected — if the session doesn't exist, tear down and fail.
|
||||
_setStatus(VpnStatus.connecting, 'Verifying session…');
|
||||
final sessionOk = await HivemindService.verifySession();
|
||||
if (!sessionOk) {
|
||||
debugPrint('[VPN] Tunnel started but no server session — tearing down.');
|
||||
try {
|
||||
await _wireguard.stopVpn().timeout(const Duration(seconds: 3));
|
||||
} catch (_) {}
|
||||
_errorMessage = 'Session not confirmed by server.\n'
|
||||
'Please try again.';
|
||||
_setStatus(VpnStatus.error, 'Session rejected');
|
||||
return false;
|
||||
}
|
||||
|
||||
_setStatus(VpnStatus.connected, 'Secured');
|
||||
return true;
|
||||
} catch (e) {
|
||||
@@ -248,7 +275,9 @@ class VpnConnection extends ChangeNotifier {
|
||||
// ── Disconnect ────────────────────────────────────────────────────────────
|
||||
// Tears down the active tunnel and returns Android to normal networking.
|
||||
Future<void> disconnect() async {
|
||||
if (_status == VpnStatus.disconnected) return;
|
||||
if (_status == VpnStatus.disconnected || _status == VpnStatus.disconnecting) {
|
||||
return; // Already disconnecting or disconnected — don't double-fire
|
||||
}
|
||||
|
||||
_setStatus(VpnStatus.disconnecting, 'Tearing down…');
|
||||
|
||||
@@ -258,14 +287,24 @@ class VpnConnection extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop the VPN tunnel with a hard timeout.
|
||||
// stopVpn() can hang indefinitely if the native Go backend is stuck —
|
||||
// we give it 5 seconds max, then force through.
|
||||
try {
|
||||
await _wireguard.stopVpn();
|
||||
await _wireguard.stopVpn().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () {
|
||||
debugPrint('[VPN] stopVpn() timed out — forcing disconnect state.');
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('WireGuard stop error: $e');
|
||||
}
|
||||
|
||||
// Clean up the peer from the server so we don't leave dead connections lying around
|
||||
await HivemindService.disconnectAndCleanup();
|
||||
// Clean up the peer from the server so we don't leave dead connections lying around.
|
||||
// Fire-and-forget with a short timeout — server cleanup failing should never
|
||||
// block the local disconnect from completing.
|
||||
HivemindService.disconnectAndCleanup();
|
||||
|
||||
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
||||
}
|
||||
|
||||
+12
-1
@@ -1,15 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
|
||||
import 'package:paladinvpn/logic/vpn_connection.dart';
|
||||
import 'package:paladinvpn/logic/session_timer.dart';
|
||||
import 'package:paladinvpn/logic/ad_manager.dart';
|
||||
import 'package:paladinvpn/logic/consent_manager.dart';
|
||||
import 'package:paladinvpn/screens/main_screen.dart';
|
||||
import 'package:paladinvpn/screens/intro.dart';
|
||||
|
||||
/// The entry point for the Paladin VPN application.
|
||||
void main() {
|
||||
void main() async {
|
||||
// Ensure that the Flutter engine is fully initialized before using platform channels
|
||||
// (e.g., for SystemChrome or MobileAds).
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -28,6 +30,15 @@ void main() {
|
||||
systemNavigationBarIconBrightness: Brightness.light,
|
||||
));
|
||||
|
||||
// ── GDPR Consent (Google UMP) ──────────────────────────────────────────
|
||||
// Must run BEFORE MobileAds initialization. Shows consent dialog to
|
||||
// EEA/UK users; no-op for everyone else.
|
||||
await ConsentManager.requestConsentIfNeeded();
|
||||
|
||||
// ── Initialize AdMob ───────────────────────────────────────────────────
|
||||
// Only after consent is resolved so AdMob respects user preferences.
|
||||
await MobileAds.instance.initialize();
|
||||
|
||||
// Inflate the root widget
|
||||
runApp(const PaladinApp());
|
||||
}
|
||||
|
||||
+39
-21
@@ -9,43 +9,65 @@ class IntroScreen extends StatefulWidget {
|
||||
State<IntroScreen> createState() => _IntroScreenState();
|
||||
}
|
||||
|
||||
class _IntroScreenState extends State<IntroScreen> {
|
||||
class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Transition to the main screen after a brief natural pause.
|
||||
// The delay is just long enough for the logo to be perceived (≈1.2 s)
|
||||
// but not so long that it feels like the app is frozen.
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_scheduleNavigation();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_navigateToMain();
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleNavigation() {
|
||||
// Brief natural pause — just long enough to perceive the logo.
|
||||
Timer(const Duration(milliseconds: 1200), () {
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushReplacement(
|
||||
PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 600),
|
||||
pageBuilder: (_, __, ___) => const MainScreen(),
|
||||
transitionsBuilder: (_, animation, __, child) {
|
||||
return FadeTransition(opacity: animation, child: child);
|
||||
},
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.paused) {
|
||||
// App is backgrounded — defer navigation until resumed.
|
||||
return;
|
||||
}
|
||||
_navigateToMain();
|
||||
});
|
||||
}
|
||||
|
||||
void _navigateToMain() {
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pushReplacement(
|
||||
PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 600),
|
||||
pageBuilder: (_, __, ___) => const MainScreen(),
|
||||
transitionsBuilder: (_, animation, __, child) {
|
||||
return FadeTransition(opacity: animation, child: child);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF0D1117), // Deep space black background
|
||||
backgroundColor: const Color(0xFF0D1117),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// App Logo
|
||||
Image.asset(
|
||||
'assets/logo.png',
|
||||
width: 140,
|
||||
height: 140,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
// Fallback icon if logo.png is missing or misconfigured
|
||||
return const Icon(
|
||||
Icons.shield,
|
||||
size: 140,
|
||||
@@ -54,8 +76,6 @@ class _IntroScreenState extends State<IntroScreen> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
|
||||
// PaladinVPN Text
|
||||
const Text(
|
||||
'PALADIN VPN',
|
||||
style: TextStyle(
|
||||
@@ -66,8 +86,6 @@ class _IntroScreenState extends State<IntroScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 60),
|
||||
|
||||
// Loading Circle
|
||||
const SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:paladinvpn/components/connect_button.dart';
|
||||
import 'package:paladinvpn/components/bottom_bar.dart';
|
||||
import 'package:paladinvpn/components/info_button.dart';
|
||||
import 'package:paladinvpn/components/privacy_policy_button.dart';
|
||||
import 'package:paladinvpn/components/gdpr_button.dart';
|
||||
|
||||
/// MainScreen serves as the primary home screen for Paladin VPN.
|
||||
///
|
||||
@@ -74,6 +75,7 @@ class MainScreen extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
GdprButton(),
|
||||
PrivacyPolicyButton(),
|
||||
InfoButton(),
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user