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

+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();