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
android/key.properties
SERVER_NOTES.md
lib/logic/backup/
00-FULL-REPORT.md
server full picture.txt
+3 -3
View File
@@ -1,5 +1,5 @@
Privacy Policy
Last updated: May 2026
Last updated: July 2025
PaladinVPN is a free VPN service built and operated by a solo developer.
What we collect
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 sell or share any data with third parties.
Ads
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.
Ads & Consent
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 internet traffic is routed through our server located in Finland. We do not inspect, log, or store your traffic.
Contact
+16 -5
View File
@@ -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(),
],
),
);
+2 -4
View File
@@ -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,
+45 -4
View File
@@ -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,10 +87,49 @@ 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) {
+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();
// 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);
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 {
+12 -7
View File
@@ -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,
+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 {
/// 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.
// ── 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,
@@ -29,10 +35,13 @@ class HivemindService {
'${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();
+107 -19
View File
@@ -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;
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();
}
+44 -5
View File
@@ -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
View File
@@ -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());
}
+32 -14
View File
@@ -9,15 +9,41 @@ 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) {
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),
@@ -28,24 +54,20 @@ class _IntroScreenState extends State<IntroScreen> {
),
);
}
});
}
@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,
+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/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(),
],