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