This commit is contained in:
esefxdz committed 2026-06-13 13:37:57 +03:00
1 parent 208da38586
commit 41a2648c6e
17 files changed
+1171 -216

No files matched your search

+61 -32
View File
@@ -7,6 +7,9 @@ import 'package:paladinvpn/logic/session_timer.dart';
///
/// Shows a circular quota-progress arc, a glowing countdown timer, and the
/// current transfer speed — all sized to fit within the 260px button.
///
/// Before the first server sync arrives, a subtle "Syncing…" placeholder
/// is shown instead of a misleading 00:00:00.
class ClockDisplay extends StatelessWidget {
const ClockDisplay({super.key});
@@ -16,50 +19,76 @@ class ClockDisplay extends StatelessWidget {
Widget build(BuildContext context) {
return Consumer<SessionTimer>(
builder: (context, timer, _) {
final bool showSyncing = !timer.hasSyncedOnce;
return SizedBox(
width: 200,
height: 200,
child: Stack(
alignment: Alignment.center,
children: [
// Center content: countdown + speed
// Center content: countdown + speed (or syncing placeholder)
Column(
mainAxisSize: MainAxisSize.min,
children: [
// Glowing countdown timer
Text(
timer.formatted,
style: const TextStyle(
fontSize: 40,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: 2,
shadows: [
Shadow(
color: Color.fromRGBO(0, 229, 255, 0.5),
blurRadius: 16,
if (showSyncing) ...[
// Syncing placeholder — spinner + label
const SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Color.fromRGBO(0, 229, 255, 0.5),
),
),
const SizedBox(height: 10),
const Text(
'Syncing…',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color.fromRGBO(0, 229, 255, 0.6),
letterSpacing: 1.5,
),
),
] else ...[
// Glowing countdown timer
Text(
timer.formatted,
style: const TextStyle(
fontSize: 40,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: 2,
shadows: [
Shadow(
color: Color.fromRGBO(0, 229, 255, 0.5),
blurRadius: 16,
),
],
),
),
const SizedBox(height: 6),
// Speed indicator
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.speed, color: Colors.white54, size: 14),
const SizedBox(width: 4),
Text(
timer.currentSpeedKbps > 0.5
? '${timer.currentSpeedKbps.toStringAsFixed(1)} KB/s'
: 'Idle',
style: const TextStyle(
fontSize: 12,
color: Colors.white54,
fontWeight: FontWeight.w500,
),
),
],
),
),
const SizedBox(height: 6),
// Speed indicator
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.speed, color: Colors.white54, size: 14),
const SizedBox(width: 4),
Text(
'${timer.currentSpeedKbps.toStringAsFixed(1)} KB/s',
style: const TextStyle(
fontSize: 12,
color: Colors.white54,
fontWeight: FontWeight.w500,
),
),
],
),
],
],
),
],
+58 -16
View File
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.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/clock_display.dart';
class ConnectButton extends StatefulWidget {
@@ -60,30 +62,51 @@ class _ConnectButtonState extends State<ConnectButton>
void _handleTap() async {
if (_inCooldown) return;
setState(() => _inCooldown = true);
Future.delayed(const Duration(seconds: 5), () {
if (mounted) setState(() => _inCooldown = false);
});
final vpn = context.read<VpnConnection>();
final timer = context.read<SessionTimer>();
final ad = context.read<AdManager>();
if (vpn.status == VpnStatus.connected) {
// Disconnect — apply a short cooldown to prevent rapid toggle spam
HapticFeedback.lightImpact();
vpn.disconnect();
timer.stop();
setState(() => _inCooldown = true);
Future.delayed(const Duration(seconds: 2), () {
if (mounted) setState(() => _inCooldown = false);
});
} else if (vpn.status == VpnStatus.disconnected ||
vpn.status == VpnStatus.error) {
// Only attempt connect if an ad is loaded (or we're in dev/web mode)
if (!ad.isAdLoaded && !ad.isAdLoading) {
ad.preloadAd(); // trigger a reload in background
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Ad is not ready yet. Please wait a moment.'),
duration: Duration(seconds: 2),
),
);
return;
}
HapticFeedback.lightImpact();
final success = await vpn.connect();
if (success) {
timer.start('main_ad');
// Only apply cooldown on success — failures clear immediately
setState(() => _inCooldown = true);
Future.delayed(const Duration(seconds: 5), () {
if (mounted) setState(() => _inCooldown = false);
});
}
// On failure the button stays active — the error message in
// StatusText tells the user what went wrong.
}
}
@override
Widget build(BuildContext context) {
return Consumer2<VpnConnection, SessionTimer>(
builder: (context, vpn, timer, _) {
return Consumer3<VpnConnection, SessionTimer, AdManager>(
builder: (context, vpn, timer, ad, _) {
final isConnected = vpn.status == VpnStatus.connected;
final isConnecting = vpn.status == VpnStatus.connecting;
@@ -126,7 +149,7 @@ class _ConnectButtonState extends State<ConnectButton>
child: Center(
child: isConnected
? const ClockDisplay()
: _buildDisconnectedIcon(vpn.status, _inCooldown),
: _buildDisconnectedIcon(vpn.status, _inCooldown, ad),
),
);
},
@@ -136,7 +159,7 @@ class _ConnectButtonState extends State<ConnectButton>
);
}
Widget _buildDisconnectedIcon(VpnStatus status, bool inCooldown) {
Widget _buildDisconnectedIcon(VpnStatus status, bool inCooldown, AdManager ad) {
if (status == VpnStatus.connecting || status == VpnStatus.disconnecting) {
return const SizedBox(
width: 48,
@@ -147,19 +170,38 @@ class _ConnectButtonState extends State<ConnectButton>
),
);
}
// Determine the right label for the disconnected state
String label;
IconData icon;
Color tint;
if (inCooldown) {
label = 'PLEASE WAIT';
icon = Icons.hourglass_bottom;
tint = Colors.grey;
} else if (status == VpnStatus.error) {
label = 'TRY AGAIN';
icon = Icons.refresh;
tint = const Color(0xFFEF5350); // soft red — something went wrong
} else if (!ad.isAdLoaded && !ad.isAdLoading) {
label = 'LOADING AD…';
icon = Icons.hourglass_empty;
tint = const Color.fromRGBO(74, 85, 104, 0.7);
} else {
label = 'TAP TO CONNECT';
icon = Icons.power_settings_new;
tint = const Color.fromRGBO(74, 85, 104, 0.7);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
inCooldown ? Icons.hourglass_bottom : Icons.power_settings_new,
size: 64,
color: inCooldown ? Colors.grey : const Color.fromRGBO(74, 85, 104, 0.7),
),
Icon(icon, size: 64, color: tint),
const SizedBox(height: 12),
Text(
inCooldown ? 'PLEASE WAIT' : 'TAP TO CONNECT',
label,
style: TextStyle(
color: inCooldown ? Colors.grey : const Color.fromRGBO(74, 85, 104, 0.9),
color: tint,
fontWeight: FontWeight.bold,
letterSpacing: 1.5,
),
+9
View File
@@ -26,10 +26,19 @@ class VpnNotificationManager {
_initialized = true;
}
static String? _lastTimeLeft;
static String? _lastSpeedKbps;
static Future<void> showOrUpdateStatus({
required String timeLeft,
required String speedKbps,
}) async {
// Skip update if nothing changed — prevents notification flicker
// on Android skins that animate every notification update.
if (timeLeft == _lastTimeLeft && speedKbps == _lastSpeedKbps) return;
_lastTimeLeft = timeLeft;
_lastSpeedKbps = speedKbps;
if (!_initialized) await init();
const androidDetails = AndroidNotificationDetails(
+21 -3
View File
@@ -15,9 +15,15 @@ class WatchAdButton extends StatelessWidget {
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () async {
final success = await context.read<AdManager>().showAd('bonus_ad');
if (success && context.mounted) {
context.read<SessionTimer>().addBonusTime(); // Calls Hivemind API for 30m + 1GB
final adWatched = await context.read<AdManager>().showAd('bonus_ad');
if (!adWatched || !context.mounted) return;
// The actual session extension happens server-side via AdMob SSV.
// addBonusTime() syncs the new limits from the server.
final synced = await context.read<SessionTimer>().addBonusTime();
if (!context.mounted) return;
if (synced) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text(
@@ -32,6 +38,18 @@ class WatchAdButton extends StatelessWidget {
duration: const Duration(seconds: 2),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Could not confirm bonus. Please check your connection.',
style: TextStyle(fontWeight: FontWeight.w600),
),
backgroundColor: Color(0xFF5D4037),
behavior: SnackBarBehavior.floating,
duration: Duration(seconds: 3),
),
);
}
},
child: Container(
+2 -1
View File
@@ -29,7 +29,8 @@ class AdManager extends ChangeNotifier {
_preloadAd();
}
/// Internal helper to load an ad and return a Future when it's ready
/// Loads a rewarded ad in the background and returns a Future when ready.
/// Safe to call multiple times — re-entrant calls wait on the same load.
Future<bool> _preloadAd() async {
// If already loaded, return instantly
if (_isAdLoaded) return true;
+6 -18
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:paladinvpn/logic/app_config.dart';
import 'package:paladinvpn/logic/crypto_service.dart';
@@ -14,20 +15,8 @@ class HivemindService {
final url = Uri.parse('${AppConfig.hivemindApiBase}/session/status?device_id=$deviceId');
// ── TEMPORARY AD BYPASS HACK FOR TESTING ──
// Since you disabled the crypto signature check on the Python server,
// we can just send a fake "Ad Watched" ping from the app directly.
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 (_) {}
// ──────────────────────────────────────────
// AdMob SSV is handled server-to-server by Google.
// The app waits for the server to activate the session via the real callback.
int attempts = 0;
while (attempts < 15) {
@@ -36,8 +25,7 @@ class HivemindService {
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
// Debugging log to see exactly what the server returned
print('[HivemindService] Polling response: ${response.body}');
debugPrint('[HivemindService] Session active, constructing config…');
if (data['active'] == true && data['client_ip'] != null && data['server_pubkey'] != null) {
final clientIp = data['client_ip'];
@@ -70,10 +58,10 @@ PersistentKeepalive = ${AppConfig.persistentKeepalive}
return configText;
}
} else {
print('[HivemindService] Non-200 status: ${response.statusCode}');
debugPrint('[HivemindService] Non-200 status: ${response.statusCode}');
}
} catch (e) {
print('[HivemindService] Polling exception: $e');
debugPrint('[HivemindService] Polling exception: $e');
// Ignore network errors and keep polling
}
+148 -84
View File
@@ -7,48 +7,58 @@ import 'package:paladinvpn/logic/vpn_connection.dart';
import 'package:paladinvpn/logic/crypto_service.dart';
import 'package:paladinvpn/components/notification.dart';
/// SessionTimer acts as the client-side session authority for the VPN tunnel.
///
/// It connects to the Hivemind API on the server to sync the true remaining
/// time, data quotas, speed, and throttling status.
/// SessionTimer is the client-side session authority for the VPN tunnel.
///
/// Polls the Hivemind API to sync remaining time, data quotas, speed, and
/// throttling status. Rather than guessing the session length optimistically,
/// it waits for the first successful sync before the local countdown begins.
class SessionTimer extends ChangeNotifier {
Timer? _timer;
int _tickCount = 0;
final VpnConnection vpnConnection;
// ── Session State ──
// ── Session State ──────────────────────────────────────────────────────────
int _remainingSeconds = 0;
int _quotaBytes = 0;
int _usedBytes = 0;
bool _isThrottled = false;
// ── Speed State ──
int _lastUsedBytes = 0;
double _currentSpeedKbps = 0.0; // Total speed (RX+TX)
int _quotaBytes = 0;
int _usedBytes = 0;
bool _isThrottled = false;
// ── Sync / Network Health ──────────────────────────────────────────────────
bool _hasSyncedOnce = false;
int _consecutiveFailures = 0;
bool _isDisconnecting = false;
/// 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;
/// 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;
// ── Notification dedup ─────────────────────────────────────────────────────
String _lastNotifTime = '';
String _lastNotifSpeed = '';
SessionTimer({required this.vpnConnection}) {
vpnConnection.addListener(_onVpnConnectionChanged);
}
/// Fires whenever VpnConnection state changes.
/// Only acts during startup restoration — normal connects go through ConnectButton.
void _onVpnConnectionChanged() {
if (vpnConnection.status == VpnStatus.connected &&
!isRunning &&
vpnConnection.isStartupRestoration) {
start('resume');
}
}
// ── Public Getters ─────────────────────────────────────────────────────────
int get remaining => _remainingSeconds;
bool get isRunning => _timer != null && _timer!.isActive;
bool get isExpired => _remainingSeconds <= 0 && !isRunning;
int get quotaBytes => _quotaBytes;
int get usedBytes => _usedBytes;
int get remainingBytes => _quotaBytes > _usedBytes ? _quotaBytes - _usedBytes : 0;
bool get isThrottled => _isThrottled;
int get remaining => _remainingSeconds;
bool get isRunning => _timer != null && _timer!.isActive;
bool get isExpired => _remainingSeconds <= 0 && !isRunning;
bool get hasSyncedOnce => _hasSyncedOnce;
int get quotaBytes => _quotaBytes;
int get usedBytes => _usedBytes;
int get remainingBytes => _quotaBytes > _usedBytes ? _quotaBytes - _usedBytes : 0;
bool get isThrottled => _isThrottled;
double get currentSpeedKbps => _currentSpeedKbps;
String get formatted {
@@ -75,51 +85,80 @@ class SessionTimer extends ChangeNotifier {
}
double get progress {
// Max duration is generally 60 or 90 minutes. We'll base the ring purely on data usage or just standard time
// For UI simplicity, we'll return data progress:
if (_quotaBytes == 0) return 0.0;
return (_quotaBytes - _usedBytes) / _quotaBytes;
}
// ── Lifecycle ──────────────────────────────────────────────────────────────
/// Fires whenever VpnConnection state changes.
/// Only auto-restarts the timer during startup restoration —
/// normal connects are driven by [ConnectButton].
void _onVpnConnectionChanged() {
if (vpnConnection.status == VpnStatus.connected &&
!isRunning &&
vpnConnection.isStartupRestoration) {
start('resume');
}
}
/// Starts the local countdown and begins server polling.
///
/// Values are initialised at 0 — the UI shows "Syncing…" until the first
/// successful [_syncWithHivemind] returns real data.
Future<void> start(String adType) async {
_remainingSeconds = 3600; // optimistic start
_quotaBytes = 2 * 1024 * 1024 * 1024;
_usedBytes = 0;
_lastUsedBytes = 0;
_currentSpeedKbps = 0.0;
_isThrottled = false;
_tickCount = 0;
_remainingSeconds = 0;
_quotaBytes = 0;
_usedBytes = 0;
_lastUsedBytes = 0;
_currentSpeedKbps = 0.0;
_isThrottled = false;
_tickCount = 0;
_hasSyncedOnce = false;
_consecutiveFailures = 0;
_isDisconnecting = false;
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
// Kick off an immediate sync so the UI shows real data right away
notifyListeners();
// Kick off the first sync — the UI will show real data as soon as it lands.
_syncWithHivemind();
}
void _tick(Timer t) {
if (_remainingSeconds > 0) {
_remainingSeconds--;
// Only decrement the local clock after the first successful sync
// and while we are not in a network-blackout window.
if (_hasSyncedOnce && _consecutiveFailures < _maxConsecutiveFailures) {
if (_remainingSeconds > 0) {
_remainingSeconds--;
}
}
_tickCount++;
// Poll the server every 3 seconds to get live stats
if (_tickCount % 3 == 0) {
if (_tickCount % _pollIntervalSeconds == 0) {
_syncWithHivemind();
}
notifyListeners();
}
// ── Server Sync ────────────────────────────────────────────────────────────
Future<void> _syncWithHivemind() async {
// Never sync while the user is explicitly disconnecting.
if (_isDisconnecting) return;
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: 3));
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);
final bool active = data['active'] ?? false;
if (!active) {
// Server killed the session!
@@ -129,63 +168,88 @@ class SessionTimer extends ChangeNotifier {
}
_remainingSeconds = data['expires_in_seconds'] ?? _remainingSeconds;
_quotaBytes = data['quota_bytes'] ?? _quotaBytes;
_usedBytes = data['used_bytes'] ?? _usedBytes;
_isThrottled = data['is_throttled'] ?? false;
_quotaBytes = data['quota_bytes'] ?? _quotaBytes;
_usedBytes = data['used_bytes'] ?? _usedBytes;
_isThrottled = data['is_throttled'] ?? false;
// Calculate speed over the polling interval
// ── Speed calculation over the polling interval ────────────────────
final int deltaBytes = _usedBytes - _lastUsedBytes;
if (_lastUsedBytes > 0 && deltaBytes > 0) {
_currentSpeedKbps = (deltaBytes / 3) / 1024;
} else {
if (_hasSyncedOnce && deltaBytes > 0) {
_currentSpeedKbps = (deltaBytes / _pollIntervalSeconds) / 1024;
} else if (!_hasSyncedOnce) {
_currentSpeedKbps = 0.0;
}
_lastUsedBytes = _usedBytes;
VpnNotificationManager.showOrUpdateStatus(
timeLeft: formatted,
speedKbps: '${_currentSpeedKbps.toStringAsFixed(2)} KB/s',
);
// ── Network is healthy — reset failure counter ─────────────────────
_consecutiveFailures = 0;
_hasSyncedOnce = true;
// ── Update notification (only when values actually change) ─────────
final speedText = _currentSpeedKbps > 0.5
? '${_currentSpeedKbps.toStringAsFixed(1)} KB/s'
: 'Idle';
if (formatted != _lastNotifTime || speedText != _lastNotifSpeed) {
_lastNotifTime = formatted;
_lastNotifSpeed = speedText;
VpnNotificationManager.showOrUpdateStatus(
timeLeft: formatted,
speedKbps: speedText,
);
}
notifyListeners();
} else if (response.statusCode == 404) {
// Peer not found on server (expired/deleted natively)
// Peer not found on server (expired / deleted natively)
stop();
vpnConnection.disconnect();
} else {
_markSyncFailure();
}
} catch (e) {
debugPrint('Hivemind sync error: $e');
// If we can't reach the server, just let the local timer run
_markSyncFailure();
}
}
Future<void> addBonusTime() async {
final ip = vpnConnection.internalIp;
if (ip != null) {
try {
final deviceId = await CryptoService.getDeviceId();
await http.post(
Uri.parse('${AppConfig.hivemindApiBase}/session/start'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'ip': ip,
'device_id': deviceId,
'ad_type': 'bonus_ad'
}),
).timeout(const Duration(seconds: 5));
// Immediately sync to fetch the updated limits and un-throttled status
_syncWithHivemind();
} catch (e) {
debugPrint('Failed to add bonus time with Hivemind: $e');
}
/// Increments the failure counter; pauses the local countdown after
/// [_maxConsecutiveFailures] to avoid displaying a false "expired" clock.
void _markSyncFailure() {
_consecutiveFailures++;
if (_consecutiveFailures >= _maxConsecutiveFailures) {
notifyListeners(); // let the UI show "Reconnecting…"
}
}
// ── Bonus Time ─────────────────────────────────────────────────────────────
/// Called after the user watches a bonus rewarded ad.
///
/// The actual session extension is handled server-side via the AdMob SSV
/// callback. This method simply re-syncs so the UI picks up the new
/// limits and un-throttled status.
///
/// Returns `true` if the sync succeeded, so the UI can decide whether to
/// show a success snackbar.
Future<bool> addBonusTime() async {
try {
await _syncWithHivemind();
return _hasSyncedOnce;
} catch (e) {
debugPrint('Bonus-time sync error: $e');
return false;
}
}
// ── Stop ───────────────────────────────────────────────────────────────────
void stop() {
_isDisconnecting = true;
_timer?.cancel();
_timer = null;
_remainingSeconds = 0;
// 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;
VpnNotificationManager.cancel();
notifyListeners();
+64 -16
View File
@@ -138,7 +138,17 @@ class VpnConnection extends ChangeNotifier {
// The connect button uses this to decide whether to start the timer.
Future<bool> connect() async {
// Don't do anything if we're already connected or currently connecting
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) return false;
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
return false;
}
// Guard: native plugin never loaded (wrong arch, missing .so)
if (!kIsWeb && !_pluginAvailable) {
_errorMessage = 'VPN service unavailable. The native tunnel library '
'could not be loaded on this device.';
_setStatus(VpnStatus.error, 'Service unavailable');
return false;
}
_setStatus(VpnStatus.connecting, 'Fetching config…');
_errorMessage = null;
@@ -150,32 +160,39 @@ class VpnConnection extends ChangeNotifier {
return true;
}
// ── Step 2: Fetch config from Hivemind ──
// ── Step 1: Fetch WireGuard config from Hivemind ────────────────────
String configText;
try {
configText = await HivemindService.fetchConfigWithPolling();
// Parse the internal IP (e.g. "Address = 10.8.0.2/24" 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 match = regex.firstMatch(configText);
if (match != null) {
internalIp = match.group(1);
}
} catch (e) {
debugPrint('Hivemind API error: $e');
// Set the exact error as the status message so the user can see it on screen
String errorMsg = e.toString().replaceAll('Exception: ', '');
if (errorMsg.length > 30) {
errorMsg = '${errorMsg.substring(0, 30)}...'; // keep it somewhat short for the UI
debugPrint('Hivemind config error: $e');
final raw = e.toString().replaceAll('Exception: ', '');
// Map the known failures to something the user can act on
if (raw.contains('timed out') || raw.contains('Session not activated')) {
_errorMessage = 'The server did not respond in time.\n'
'Check your internet connection and try again.';
_setStatus(VpnStatus.error, 'Server unreachable');
} else if (raw.contains('SocketException') ||
raw.contains('Failed host lookup')) {
_errorMessage = 'Cannot reach the VPN server.\n'
'Make sure you have an active internet connection.';
_setStatus(VpnStatus.error, 'No internet');
} else {
_errorMessage = raw;
final short = raw.length > 35 ? '${raw.substring(0, 35)}…' : raw;
_setStatus(VpnStatus.error, short);
}
_errorMessage = e.toString();
_setStatus(VpnStatus.error, errorMsg);
return false;
}
// ── Step 3: Start the WireGuard tunnel with the fetched config ─────────
// ── Step 2: Start the WireGuard tunnel with the fetched config ──────
try {
// Re-initialize to ensure VPN permission is granted before starting
await _wireguard.initialize(interfaceName: 'Paladin0');
@@ -188,8 +205,39 @@ class VpnConnection extends ChangeNotifier {
return true;
} catch (e) {
debugPrint('WireGuard tunnel error: $e');
_errorMessage = 'Tunnel failed to start.\nTry reconnecting.';
_setStatus(VpnStatus.error, 'Tunnel error');
final raw = e.toString();
if (raw.contains('VPN_PERMISSION_REQUESTED')) {
// Native plugin showed the VPN permission dialog — wait and retry
_setStatus(VpnStatus.connecting, 'Waiting for permission…');
await Future.delayed(const Duration(seconds: 2));
// Retry the tunnel start once the user has had time to grant permission
try {
await _wireguard.startVpn(
serverAddress: AppConfig.serverEndpoint,
wgQuickConfig: configText,
providerBundleIdentifier: 'com.paladinvpn.app.WireGuardProvider',
);
_setStatus(VpnStatus.connected, 'Secured');
return true;
} catch (retryError) {
debugPrint('WireGuard retry error: $retryError');
_errorMessage = 'VPN permission was denied.\n'
'Please grant the VPN permission and try again.';
_setStatus(VpnStatus.error, 'Permission denied');
return false;
}
} else if (raw.contains('Permission') || raw.contains('permissions')) {
_errorMessage = 'VPN permission was denied.\n'
'Please grant the VPN permission and try again.';
_setStatus(VpnStatus.error, 'Permission denied');
} else if (raw.contains('NameInvalid') || raw.contains('invalid')) {
_errorMessage = 'The WireGuard configuration is invalid.\n'
'Please contact support.';
_setStatus(VpnStatus.error, 'Bad config');
} else {
_errorMessage = 'Tunnel failed to start.\nTry reconnecting.';
_setStatus(VpnStatus.error, 'Tunnel error');
}
return false;
}
}
+5 -3
View File
@@ -13,12 +13,14 @@ class _IntroScreenState extends State<IntroScreen> {
@override
void initState() {
super.initState();
// Wait for 2.5 seconds then smoothly transition to the main screen
Timer(const Duration(milliseconds: 2500), () {
// 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.
Timer(const Duration(milliseconds: 1200), () {
if (mounted) {
Navigator.of(context).pushReplacement(
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 800),
transitionDuration: const Duration(milliseconds: 600),
pageBuilder: (_, __, ___) => const MainScreen(),
transitionsBuilder: (_, animation, __, child) {
return FadeTransition(opacity: animation, child: child);