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:
esefxdz committed 2026-07-16 13:40:09 +03:00
1 parent 86baf38149
commit a84474a586
15 files changed
+543 -70

No files matched your search

+1
View File
@@ -64,6 +64,7 @@ local_packages/*/.dart_tool/
# Secrets # Secrets
android/key.properties android/key.properties
SERVER_NOTES.md
lib/logic/backup/ lib/logic/backup/
00-FULL-REPORT.md 00-FULL-REPORT.md
server full picture.txt server full picture.txt
+3 -3
View File
@@ -1,5 +1,5 @@
Privacy Policy Privacy Policy
Last updated: May 2026 Last updated: July 2025
PaladinVPN is a free VPN service built and operated by a solo developer. PaladinVPN is a free VPN service built and operated by a solo developer.
What we collect What we collect
We collect the minimum required to operate the service: We collect the minimum required to operate the service:
@@ -13,8 +13,8 @@ We do not collect your name, email, or any personal information.
We do not log your internet traffic or browsing history. We do not log your internet traffic or browsing history.
We do not sell or share any data with third parties. We do not sell or share any data with third parties.
Ads Ads & Consent
PaladinVPN uses Google AdMob to display rewarded video ads. Google may collect data as part of ad delivery. Please refer to Google's Privacy Policy for details. PaladinVPN uses Google AdMob to display rewarded video ads. On first launch, users in the European Economic Area (EEA) and United Kingdom are shown a consent dialog (provided by Google's User Messaging Platform) that lets them choose whether to allow personalized ads. This consent choice is stored on your device and can be changed at any time by reinstalling the app. Google may collect data as part of ad delivery. Please refer to Google's Privacy Policy for details.
Your traffic Your traffic
Your internet traffic is routed through our server located in Finland. We do not inspect, log, or store your traffic. Your internet traffic is routed through our server located in Finland. We do not inspect, log, or store your traffic.
Contact Contact
+16 -5
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:paladinvpn/logic/vpn_connection.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/ad_manager.dart';
import 'package:paladinvpn/components/online_dot.dart'; import 'package:paladinvpn/components/online_dot.dart';
import 'package:paladinvpn/components/watch_ad_button.dart'; import 'package:paladinvpn/components/watch_ad_button.dart';
@@ -14,9 +15,12 @@ class BottomBar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<VpnConnection>( return Consumer2<VpnConnection, SessionTimer>(
builder: (context, vpn, _) { builder: (context, vpn, timer, _) {
final isOnline = vpn.status == VpnStatus.connected; 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( return Container(
margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
@@ -31,10 +35,17 @@ class BottomBar extends StatelessWidget {
child: Row( child: Row(
children: [ children: [
// Server online/offline chip // Server online/offline chip
OnlineDot(isOnline: isOnline), OnlineDot(
isOnline: serverHealthy,
label: isConnected && !timer.hasSyncedOnce
? 'Syncing…'
: serverHealthy
? 'Server Online'
: 'Disconnected',
),
const Spacer(), const Spacer(),
// Support button — only visible while connected AND ads are enabled // Support button — only visible while connected AND ads are enabled
if (isOnline && AdManager.adsEnabled) const WatchAdButton(), if (isConnected && AdManager.adsEnabled) const WatchAdButton(),
], ],
), ),
); );
+2 -4
View File
@@ -1,4 +1,3 @@
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:paladinvpn/logic/session_timer.dart'; import 'package:paladinvpn/logic/session_timer.dart';
@@ -13,7 +12,6 @@ import 'package:paladinvpn/logic/session_timer.dart';
class ClockDisplay extends StatelessWidget { class ClockDisplay extends StatelessWidget {
const ClockDisplay({super.key}); const ClockDisplay({super.key});
static const Color _cyanGlow = Color(0xFF00E5FF);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -77,8 +75,8 @@ class ClockDisplay extends StatelessWidget {
const Icon(Icons.speed, color: Colors.white54, size: 14), const Icon(Icons.speed, color: Colors.white54, size: 14),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
timer.currentSpeedKbps > 0.5 timer.currentSpeedKBps > 0.5
? '${timer.currentSpeedKbps.toStringAsFixed(1)} KB/s' ? '${timer.currentSpeedKBps.toStringAsFixed(1)} KB/s'
: 'Idle', : 'Idle',
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
+45 -4
View File
@@ -65,10 +65,12 @@ class _ConnectButtonState extends State<ConnectButton>
final timer = context.read<SessionTimer>(); final timer = context.read<SessionTimer>();
if (vpn.status == VpnStatus.connected) { 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(); HapticFeedback.lightImpact();
vpn.disconnect();
timer.stop(); timer.stop();
await vpn.disconnect();
// Short cooldown to prevent accidental immediate reconnect
setState(() => _inCooldown = true); setState(() => _inCooldown = true);
Future.delayed(const Duration(seconds: 2), () { Future.delayed(const Duration(seconds: 2), () {
if (mounted) setState(() => _inCooldown = false); if (mounted) setState(() => _inCooldown = false);
@@ -85,10 +87,49 @@ class _ConnectButtonState extends State<ConnectButton>
if (mounted) setState(() => _inCooldown = false); if (mounted) setState(() => _inCooldown = false);
}); });
} }
// On failure the button stays active — the error message in // On failure, check if it's a permission issue and offer recovery.
// StatusText tells the user what went wrong. 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
+92
View File
@@ -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,
);
}
}
+26 -2
View File
@@ -41,10 +41,14 @@ class VpnNotificationManager {
if (!_initialized) await init(); 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( const androidDetails = AndroidNotificationDetails(
'paladin_vpn_status', 'paladin_vpn_status_v2',
'VPN Status', 'VPN Status',
channelDescription: 'Persistent notification shown while VPN is active', channelDescription: 'Shown while VPN is active — non-dismissable',
importance: Importance.low, importance: Importance.low,
priority: Priority.low, priority: Priority.low,
// Non-dismissable while VPN is running // Non-dismissable while VPN is running
@@ -58,16 +62,36 @@ class VpnNotificationManager {
showWhen: false, showWhen: false,
// Show on lock screen // Show on lock screen
visibility: NotificationVisibility.public, visibility: NotificationVisibility.public,
// Extra insurance against swipe-to-dismiss on aggressive Android skins
usesChronometer: false,
category: AndroidNotificationCategory.service,
); );
const notificationDetails = NotificationDetails(android: androidDetails); const notificationDetails = NotificationDetails(android: androidDetails);
try {
await _plugin.show( await _plugin.show(
888, // Fixed ID — always updates the same notification 888, // Fixed ID — always updates the same notification
'Paladin VPN Active', 'Paladin VPN Active',
'Time: $timeLeft • $speedKbps', 'Time: $timeLeft • $speedKbps',
notificationDetails, 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 { static Future<void> cancel() async {
+12 -7
View File
@@ -1,22 +1,29 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// OnlineDot displays a glowing green dot when the server is online/connected, /// OnlineDot displays a glowing green dot when the server is healthy,
/// and a muted gray dot when disconnected. /// and a muted gray dot when disconnected or still syncing.
class OnlineDot extends StatelessWidget { class OnlineDot extends StatelessWidget {
/// Represents if the VPN tunnel session is active (online). /// Whether the server has been confirmed reachable.
final bool isOnline; final bool isOnline;
/// Optional custom label. Falls back to "Server Online" / "Disconnected".
final String? label;
const OnlineDot({ const OnlineDot({
super.key, super.key,
required this.isOnline, required this.isOnline,
this.label,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final displayLabel = label ??
(isOnline ? 'Server Online' : 'Disconnected');
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// Glowing status dot: Green when active, muted gray when inactive. // Glowing status dot: Green when healthy, muted gray when not.
AnimatedContainer( AnimatedContainer(
duration: const Duration(milliseconds: 400), duration: const Duration(milliseconds: 400),
width: 8, width: 8,
@@ -35,10 +42,8 @@ class OnlineDot extends StatelessWidget {
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
// Dynamic status text label
Text( Text(
isOnline ? 'Server Online' : 'Disconnected', displayLabel,
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
+117
View File
@@ -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;
}
}
+32 -6
View File
@@ -8,17 +8,23 @@ import 'package:paladinvpn/logic/crypto_service.dart';
class HivemindService { class HivemindService {
/// Polls the Hivemind server until the session is ready or timeout occurs. /// Polls the Hivemind server until the session is ready or timeout occurs.
/// Returns the WireGuard config string. /// 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 deviceId = await CryptoService.getDeviceId();
final keys = await CryptoService.getOrCreateKeys(); final keys = await CryptoService.getOrCreateKeys();
final privKey = keys['privateKey']!; final privKey = keys['privateKey']!;
final url = Uri.parse('${AppConfig.hivemindApiBase}/session/status?device_id=$deviceId'); final url = Uri.parse('${AppConfig.hivemindApiBase}/session/status?device_id=$deviceId');
// ── TEMPORARY AD BYPASS HACK FOR TESTING ── // ── AD BYPASS (debug only) ────────────────────────────────────────
// Since the crypto signature check is disabled on the Python server, // In debug mode, send a fake AdMob callback so the server creates
// we send a fake "Ad Watched" ping from the app directly. // a session without a real ad. In release mode this code is
// TODO: Remove this when real AdMob SSV is enabled on the server. // stripped — sessions are only created by real AdMob SSV callbacks.
if (kDebugMode) {
try { try {
final customData = jsonEncode({ final customData = jsonEncode({
'device_id': deviceId, 'device_id': deviceId,
@@ -29,10 +35,13 @@ class HivemindService {
'${AppConfig.hivemindApiBase}/admob/callback?signature=test&key_id=test&custom_data=${Uri.encodeComponent(customData)}'); '${AppConfig.hivemindApiBase}/admob/callback?signature=test&key_id=test&custom_data=${Uri.encodeComponent(customData)}');
await http.get(fakeAdmobPingUrl).timeout(const Duration(seconds: 8)); await http.get(fakeAdmobPingUrl).timeout(const Duration(seconds: 8));
} catch (_) {} } catch (_) {}
// ────────────────────────────────────────── }
// ───────────────────────────────────────────────────────────────────
int attempts = 0; int attempts = 0;
while (attempts < 15) { while (attempts < 15) {
debugPrint('[HivemindService] Polling attempt ${attempts + 1}/15…');
onAttempt?.call(attempts + 1, 15);
try { try {
final response = await http.get(url).timeout(const Duration(seconds: 10)); final response = await http.get(url).timeout(const Duration(seconds: 10));
if (response.statusCode == 200) { if (response.statusCode == 200) {
@@ -85,6 +94,23 @@ PersistentKeepalive = ${AppConfig.persistentKeepalive}
throw Exception("Session not activated. S2S callback failed or timed out."); 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) /// Disconnects the session (cleans up on server)
static Future<void> disconnectAndCleanup() async { static Future<void> disconnectAndCleanup() async {
final deviceId = await CryptoService.getDeviceId(); final deviceId = await CryptoService.getDeviceId();
+107 -19
View File
@@ -28,17 +28,24 @@ class SessionTimer extends ChangeNotifier {
bool _hasSyncedOnce = false; bool _hasSyncedOnce = false;
int _consecutiveFailures = 0; int _consecutiveFailures = 0;
bool _isDisconnecting = false; bool _isDisconnecting = false;
bool _userInitiatedStop = false; // true when user tapped disconnect
/// Maximum consecutive sync failures before the local countdown is paused /// Maximum consecutive sync failures before the local countdown is paused
/// to avoid showing a false "expired" state while the network is down. /// to avoid showing a false "expired" state while the network is down.
static const int _maxConsecutiveFailures = 3; 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). /// Seconds between server polls (reduced from 3 to ease server load).
static const int _pollIntervalSeconds = 5; static const int _pollIntervalSeconds = 5;
// ── Speed State ──────────────────────────────────────────────────────────── // ── Speed State ────────────────────────────────────────────────────────────
int _lastUsedBytes = 0; int _lastUsedBytes = 0;
double _currentSpeedKbps = 0.0; double _currentSpeedKBps = 0.0; // kilobytes per second
// ── Notification dedup ───────────────────────────────────────────────────── // ── Notification dedup ─────────────────────────────────────────────────────
String _lastNotifTime = ''; String _lastNotifTime = '';
@@ -59,7 +66,7 @@ class SessionTimer extends ChangeNotifier {
int get usedBytes => _usedBytes; int get usedBytes => _usedBytes;
int get remainingBytes => _quotaBytes > _usedBytes ? _quotaBytes - _usedBytes : 0; int get remainingBytes => _quotaBytes > _usedBytes ? _quotaBytes - _usedBytes : 0;
bool get isThrottled => _isThrottled; bool get isThrottled => _isThrottled;
double get currentSpeedKbps => _currentSpeedKbps; double get currentSpeedKBps => _currentSpeedKBps;
String get formatted { String get formatted {
final h = (_remainingSeconds ~/ 3600).toString().padLeft(2, '0'); final h = (_remainingSeconds ~/ 3600).toString().padLeft(2, '0');
@@ -92,13 +99,30 @@ class SessionTimer extends ChangeNotifier {
// ── Lifecycle ────────────────────────────────────────────────────────────── // ── Lifecycle ──────────────────────────────────────────────────────────────
/// Fires whenever VpnConnection state changes. /// Fires whenever VpnConnection state changes.
/// Only auto-restarts the timer during startup restoration —
/// normal connects are driven by [ConnectButton].
void _onVpnConnectionChanged() { void _onVpnConnectionChanged() {
// ── Startup restoration: app killed while VPN was running ──────────
if (vpnConnection.status == VpnStatus.connected && if (vpnConnection.status == VpnStatus.connected &&
!isRunning && !isRunning &&
vpnConnection.isStartupRestoration) { vpnConnection.isStartupRestoration) {
start('resume'); 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; _quotaBytes = 0;
_usedBytes = 0; _usedBytes = 0;
_lastUsedBytes = 0; _lastUsedBytes = 0;
_currentSpeedKbps = 0.0; _currentSpeedKBps = 0.0;
_isThrottled = false; _isThrottled = false;
_tickCount = 0; _tickCount = 0;
_hasSyncedOnce = false; _hasSyncedOnce = false;
_consecutiveFailures = 0; _consecutiveFailures = 0;
_offlineSeconds = 0;
_isDisconnecting = false; _isDisconnecting = false;
_userInitiatedStop = false;
_timer?.cancel(); _timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), _tick); _timer = Timer.periodic(const Duration(seconds: 1), _tick);
@@ -128,14 +154,36 @@ class SessionTimer extends ChangeNotifier {
} }
void _tick(Timer t) { void _tick(Timer t) {
// Only decrement the local clock after the first successful sync // ── Local countdown ──────────────────────────────────────────────────
// and while we are not in a network-blackout window. // Decrement the local clock as long as we're not in a network blackout.
if (_hasSyncedOnce && _consecutiveFailures < _maxConsecutiveFailures) { if (_hasSyncedOnce && _consecutiveFailures < _maxConsecutiveFailures) {
if (_remainingSeconds > 0) { if (_remainingSeconds > 0) {
_remainingSeconds--; _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++; _tickCount++;
if (_tickCount % _pollIntervalSeconds == 0) { if (_tickCount % _pollIntervalSeconds == 0) {
_syncWithHivemind(); _syncWithHivemind();
@@ -144,6 +192,22 @@ class SessionTimer extends ChangeNotifier {
notifyListeners(); 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 ──────────────────────────────────────────────────────────── // ── Server Sync ────────────────────────────────────────────────────────────
Future<void> _syncWithHivemind() async { Future<void> _syncWithHivemind() async {
@@ -161,9 +225,9 @@ class SessionTimer extends ChangeNotifier {
final bool active = data['active'] ?? false; final bool active = data['active'] ?? false;
if (!active) { if (!active) {
// Server killed the session! // Server killed the session — tear down and wait for it.
stop(); stop();
vpnConnection.disconnect(); await vpnConnection.disconnect();
return; return;
} }
@@ -175,19 +239,20 @@ class SessionTimer extends ChangeNotifier {
// ── Speed calculation over the polling interval ──────────────────── // ── Speed calculation over the polling interval ────────────────────
final int deltaBytes = _usedBytes - _lastUsedBytes; final int deltaBytes = _usedBytes - _lastUsedBytes;
if (_hasSyncedOnce && deltaBytes > 0) { if (_hasSyncedOnce && deltaBytes > 0) {
_currentSpeedKbps = (deltaBytes / _pollIntervalSeconds) / 1024; _currentSpeedKBps = (deltaBytes / _pollIntervalSeconds) / 1000;
} else if (!_hasSyncedOnce) { } else if (!_hasSyncedOnce) {
_currentSpeedKbps = 0.0; _currentSpeedKBps = 0.0;
} }
_lastUsedBytes = _usedBytes; _lastUsedBytes = _usedBytes;
// ── Network is healthy — reset failure counter ───────────────────── // ── Network is healthy — reset failure counters ────────────────────
_consecutiveFailures = 0; _consecutiveFailures = 0;
_offlineSeconds = 0;
_hasSyncedOnce = true; _hasSyncedOnce = true;
// ── Update notification (only when values actually change) ───────── // ── Update notification (only when values actually change) ─────────
final speedText = _currentSpeedKbps > 0.5 final speedText = _currentSpeedKBps > 0.5
? '${_currentSpeedKbps.toStringAsFixed(1)} KB/s' ? '${_currentSpeedKBps.toStringAsFixed(1)} KB/s'
: 'Idle'; : 'Idle';
if (formatted != _lastNotifTime || speedText != _lastNotifSpeed) { if (formatted != _lastNotifTime || speedText != _lastNotifSpeed) {
_lastNotifTime = formatted; _lastNotifTime = formatted;
@@ -202,7 +267,7 @@ class SessionTimer extends ChangeNotifier {
} else if (response.statusCode == 404) { } else if (response.statusCode == 404) {
// Peer not found on server (expired / deleted natively) // Peer not found on server (expired / deleted natively)
stop(); stop();
vpnConnection.disconnect(); await vpnConnection.disconnect();
} else { } else {
_markSyncFailure(); _markSyncFailure();
} }
@@ -243,14 +308,37 @@ class SessionTimer extends ChangeNotifier {
// ── Stop ─────────────────────────────────────────────────────────────────── // ── 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() { void stop() {
_userInitiatedStop = true;
_isDisconnecting = true; _isDisconnecting = true;
_timer?.cancel(); _timer?.cancel();
_timer = null; _timer = null;
// Keep _remainingSeconds visible so the UI doesn't flash 00:00:00 _currentSpeedKBps = 0.0;
// while the tunnel is tearing down. The VpnConnection will drive the notifyListeners();
// UI transition, and dispose() will do final cleanup. }
_currentSpeedKbps = 0.0;
/// 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(); VpnNotificationManager.cancel();
notifyListeners(); notifyListeners();
} }
+44 -5
View File
@@ -115,19 +115,24 @@ class VpnConnection extends ChangeNotifier {
switch (stage) { switch (stage) {
case VpnStage.connected: case VpnStage.connected:
_setStatus(VpnStatus.connected, 'Secured'); _setStatus(VpnStatus.connected, 'Secured');
break;
case VpnStage.disconnected: case VpnStage.disconnected:
_setStatus(VpnStatus.disconnected, 'Tap to connect'); _setStatus(VpnStatus.disconnected, 'Tap to connect');
break;
case VpnStage.connecting: case VpnStage.connecting:
case VpnStage.waitingConnection: case VpnStage.waitingConnection:
case VpnStage.authenticating: case VpnStage.authenticating:
case VpnStage.preparing: case VpnStage.preparing:
case VpnStage.reconnect: case VpnStage.reconnect:
_setStatus(VpnStatus.connecting, 'Establishing tunnel…'); _setStatus(VpnStatus.connecting, 'Establishing tunnel…');
break;
case VpnStage.disconnecting: case VpnStage.disconnecting:
case VpnStage.exiting: case VpnStage.exiting:
_setStatus(VpnStatus.disconnecting, 'Tearing down…'); _setStatus(VpnStatus.disconnecting, 'Tearing down…');
break;
default: default:
_setStatus(VpnStatus.disconnected, 'Tap to connect'); _setStatus(VpnStatus.disconnected, 'Tap to connect');
break;
} }
} }
@@ -166,7 +171,11 @@ class VpnConnection extends ChangeNotifier {
// ── Step 1: Fetch WireGuard config from Hivemind ──────────────────── // ── Step 1: Fetch WireGuard config from Hivemind ────────────────────
String configText; String configText;
try { 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") // Parse the internal IP (e.g. "Address = 10.8.0.2/16" or "/32")
final regex = RegExp(r'Address\s*=\s*([0-9\.]+)/'); final regex = RegExp(r'Address\s*=\s*([0-9\.]+)/');
@@ -204,6 +213,24 @@ class VpnConnection extends ChangeNotifier {
wgQuickConfig: configText, wgQuickConfig: configText,
providerBundleIdentifier: 'com.paladinvpn.app.WireGuardProvider', 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'); _setStatus(VpnStatus.connected, 'Secured');
return true; return true;
} catch (e) { } catch (e) {
@@ -248,7 +275,9 @@ class VpnConnection extends ChangeNotifier {
// ── Disconnect ──────────────────────────────────────────────────────────── // ── Disconnect ────────────────────────────────────────────────────────────
// Tears down the active tunnel and returns Android to normal networking. // Tears down the active tunnel and returns Android to normal networking.
Future<void> disconnect() async { 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…'); _setStatus(VpnStatus.disconnecting, 'Tearing down…');
@@ -258,14 +287,24 @@ class VpnConnection extends ChangeNotifier {
return; 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 { try {
await _wireguard.stopVpn(); await _wireguard.stopVpn().timeout(
const Duration(seconds: 5),
onTimeout: () {
debugPrint('[VPN] stopVpn() timed out — forcing disconnect state.');
},
);
} catch (e) { } catch (e) {
debugPrint('WireGuard stop error: $e'); debugPrint('WireGuard stop error: $e');
} }
// Clean up the peer from the server so we don't leave dead connections lying around // Clean up the peer from the server so we don't leave dead connections lying around.
await HivemindService.disconnectAndCleanup(); // 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'); _setStatus(VpnStatus.disconnected, 'Tap to connect');
} }
+12 -1
View File
@@ -1,15 +1,17 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.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/vpn_connection.dart';
import 'package:paladinvpn/logic/session_timer.dart'; import 'package:paladinvpn/logic/session_timer.dart';
import 'package:paladinvpn/logic/ad_manager.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/main_screen.dart';
import 'package:paladinvpn/screens/intro.dart'; import 'package:paladinvpn/screens/intro.dart';
/// The entry point for the Paladin VPN application. /// 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 // Ensure that the Flutter engine is fully initialized before using platform channels
// (e.g., for SystemChrome or MobileAds). // (e.g., for SystemChrome or MobileAds).
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -28,6 +30,15 @@ void main() {
systemNavigationBarIconBrightness: Brightness.light, 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 // Inflate the root widget
runApp(const PaladinApp()); runApp(const PaladinApp());
} }
+32 -14
View File
@@ -9,15 +9,41 @@ class IntroScreen extends StatefulWidget {
State<IntroScreen> createState() => _IntroScreenState(); State<IntroScreen> createState() => _IntroScreenState();
} }
class _IntroScreenState extends State<IntroScreen> { class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Transition to the main screen after a brief natural pause. WidgetsBinding.instance.addObserver(this);
// The delay is just long enough for the logo to be perceived (≈1.2 s) _scheduleNavigation();
// but not so long that it feels like the app is frozen. }
@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), () { Timer(const Duration(milliseconds: 1200), () {
if (mounted) { 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( Navigator.of(context).pushReplacement(
PageRouteBuilder( PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 600), transitionDuration: const Duration(milliseconds: 600),
@@ -28,24 +54,20 @@ class _IntroScreenState extends State<IntroScreen> {
), ),
); );
} }
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFF0D1117), // Deep space black background backgroundColor: const Color(0xFF0D1117),
body: Center( body: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// App Logo
Image.asset( Image.asset(
'assets/logo.png', 'assets/logo.png',
width: 140, width: 140,
height: 140, height: 140,
errorBuilder: (context, error, stackTrace) { errorBuilder: (context, error, stackTrace) {
// Fallback icon if logo.png is missing or misconfigured
return const Icon( return const Icon(
Icons.shield, Icons.shield,
size: 140, size: 140,
@@ -54,8 +76,6 @@ class _IntroScreenState extends State<IntroScreen> {
}, },
), ),
const SizedBox(height: 30), const SizedBox(height: 30),
// PaladinVPN Text
const Text( const Text(
'PALADIN VPN', 'PALADIN VPN',
style: TextStyle( style: TextStyle(
@@ -66,8 +86,6 @@ class _IntroScreenState extends State<IntroScreen> {
), ),
), ),
const SizedBox(height: 60), const SizedBox(height: 60),
// Loading Circle
const SizedBox( const SizedBox(
width: 40, width: 40,
height: 40, height: 40,
+2
View File
@@ -5,6 +5,7 @@ import 'package:paladinvpn/components/connect_button.dart';
import 'package:paladinvpn/components/bottom_bar.dart'; import 'package:paladinvpn/components/bottom_bar.dart';
import 'package:paladinvpn/components/info_button.dart'; import 'package:paladinvpn/components/info_button.dart';
import 'package:paladinvpn/components/privacy_policy_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. /// MainScreen serves as the primary home screen for Paladin VPN.
/// ///
@@ -74,6 +75,7 @@ class MainScreen extends StatelessWidget {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
GdprButton(),
PrivacyPolicyButton(), PrivacyPolicyButton(),
InfoButton(), InfoButton(),
], ],