ser
This commit is contained in:
1 parent
208da38586
commit
41a2648c6e
17 files changed
+1171
-216
No files matched your search
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user