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
+567 -94

No files matched your search

+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;
}
}
+42 -16
View File
@@ -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
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;
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();
}
+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');
}