Scope asynchronous connect, disconnect, sync, and reattachment work to the active session so stale work cannot mutate newer VPN state.
416 lines
12 KiB
Dart
416 lines
12 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
import 'package:revoltvpn/logic/app_config.dart';
|
|
import 'package:revoltvpn/logic/crypto_service.dart';
|
|
import 'package:revoltvpn/logic/hivemind_service.dart';
|
|
import 'package:revoltvpn/logic/notification_service.dart';
|
|
import 'package:revoltvpn/logic/vpn_connection.dart';
|
|
|
|
class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|
Timer? _timer;
|
|
int _tickCount = 0;
|
|
bool _appBackgrounded = false;
|
|
|
|
final VpnConnection vpnConnection;
|
|
|
|
int _remainingSeconds = 0;
|
|
int _remainingAtLastSync = 0;
|
|
int _usedBytes = 0;
|
|
|
|
bool _hasSyncedOnce = false;
|
|
int _consecutiveFailures = 0;
|
|
bool _isDisconnecting = false;
|
|
int? _syncEpochInProgress;
|
|
Completer<void>? _syncCompletion;
|
|
int _sessionEpoch = 0;
|
|
|
|
bool _supportRewardClaimed = false;
|
|
bool _supportRewardStateLoaded = false;
|
|
int _supportStateEpoch = 0;
|
|
|
|
static const String _supportRewardClaimKey =
|
|
'support_reward_claimed_active_session';
|
|
static const FlutterSecureStorage _supportStorage = FlutterSecureStorage();
|
|
|
|
static const int _maxConsecutiveFailures = 3;
|
|
static const int _maxOfflineSeconds = 120;
|
|
int _offlineSeconds = 0;
|
|
|
|
static const int _pollIntervalSeconds = 5;
|
|
static const int _backgroundPollIntervalSeconds = 30;
|
|
|
|
int _lastUsedBytes = 0;
|
|
Stopwatch? _sinceLastSuccessfulSync;
|
|
double _currentSpeedKBps = 0.0;
|
|
|
|
SessionTimer({required this.vpnConnection}) {
|
|
WidgetsBinding.instance.addObserver(this);
|
|
vpnConnection.addListener(_onVpnConnectionChanged);
|
|
unawaited(_loadSupportRewardState());
|
|
|
|
// VpnConnection is eager and can report an already-running native tunnel
|
|
// before this provider is created. ChangeNotifier listeners do not replay
|
|
// old events, so always evaluate the current state once after construction.
|
|
unawaited(Future.microtask(_onVpnConnectionChanged));
|
|
}
|
|
|
|
int get remaining => _remainingSeconds;
|
|
bool get isRunning => _timer != null && _timer!.isActive;
|
|
bool get isExpired => _remainingSeconds <= 0 && !isRunning;
|
|
bool get hasSyncedOnce => _hasSyncedOnce;
|
|
bool get supportRewardClaimed => _supportRewardClaimed;
|
|
bool get supportRewardStateLoaded => _supportRewardStateLoaded;
|
|
|
|
int get usedBytes => _usedBytes;
|
|
double get currentSpeedKBps => _currentSpeedKBps;
|
|
|
|
String get formatted {
|
|
final h = (_remainingSeconds ~/ 3600).toString().padLeft(2, '0');
|
|
final m = ((_remainingSeconds % 3600) ~/ 60).toString().padLeft(2, '0');
|
|
final s = (_remainingSeconds % 60).toString().padLeft(2, '0');
|
|
return '$h:$m:$s';
|
|
}
|
|
|
|
Future<void> _loadSupportRewardState() async {
|
|
final epoch = _supportStateEpoch;
|
|
var claimed = false;
|
|
try {
|
|
claimed =
|
|
(await _supportStorage.read(key: _supportRewardClaimKey)) == '1';
|
|
} catch (e) {
|
|
debugPrint('[Timer] Failed to load support reward state: $e');
|
|
}
|
|
|
|
if (epoch != _supportStateEpoch) return;
|
|
_supportRewardClaimed = claimed;
|
|
_supportRewardStateLoaded = true;
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> _persistSupportRewardState(bool value) async {
|
|
try {
|
|
await _supportStorage.write(
|
|
key: _supportRewardClaimKey,
|
|
value: value ? '1' : '0',
|
|
);
|
|
} catch (e) {
|
|
debugPrint('[Timer] Failed to persist support reward state: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> markSupportRewardClaimed() async {
|
|
if (_supportRewardClaimed) return;
|
|
_supportRewardClaimed = true;
|
|
_supportRewardStateLoaded = true;
|
|
notifyListeners();
|
|
await _persistSupportRewardState(true);
|
|
}
|
|
|
|
void _onVpnConnectionChanged() {
|
|
// A live tunnel with a stopped clock is always wrong, whether this process
|
|
// adopted an existing runtime, attached late, or recovered from a blip.
|
|
if (vpnConnection.status == VpnStatus.connected &&
|
|
!isRunning &&
|
|
!_isDisconnecting) {
|
|
debugPrint('[Timer] Connected with a stopped clock - resuming.');
|
|
_resumeTicking();
|
|
return;
|
|
}
|
|
|
|
final hadActiveSession = isRunning || _hasSyncedOnce;
|
|
if (!hadActiveSession || _isDisconnecting) return;
|
|
|
|
if (vpnConnection.status == VpnStatus.error) {
|
|
unawaited(_doDisconnect('VPN entered error state'));
|
|
return;
|
|
}
|
|
|
|
if (vpnConnection.status == VpnStatus.disconnected) {
|
|
unawaited(_doDisconnect('VPN tunnel dropped'));
|
|
}
|
|
}
|
|
|
|
Future<void> start() async {
|
|
_sessionEpoch++;
|
|
_remainingSeconds = 0;
|
|
_remainingAtLastSync = 0;
|
|
_usedBytes = 0;
|
|
_lastUsedBytes = 0;
|
|
_sinceLastSuccessfulSync?.stop();
|
|
_sinceLastSuccessfulSync = null;
|
|
_currentSpeedKBps = 0.0;
|
|
_tickCount = 0;
|
|
_hasSyncedOnce = false;
|
|
_consecutiveFailures = 0;
|
|
_offlineSeconds = 0;
|
|
_isDisconnecting = false;
|
|
_appBackgrounded = false;
|
|
NotificationService.reset();
|
|
|
|
_supportStateEpoch++;
|
|
_supportRewardClaimed = false;
|
|
_supportRewardStateLoaded = true;
|
|
unawaited(_persistSupportRewardState(false));
|
|
|
|
_timer?.cancel();
|
|
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
|
|
|
notifyListeners();
|
|
unawaited(_syncWithHivemind());
|
|
}
|
|
|
|
void _tick(Timer t) {
|
|
if (_isDisconnecting) return;
|
|
|
|
if (_hasSyncedOnce) {
|
|
_reconcileElapsedTime();
|
|
}
|
|
|
|
if (_consecutiveFailures >= _maxConsecutiveFailures) {
|
|
_offlineSeconds++;
|
|
if (_offlineSeconds >= _maxOfflineSeconds) {
|
|
unawaited(_doDisconnect('Server unreachable'));
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (_hasSyncedOnce && _remainingSeconds <= 0) {
|
|
unawaited(_doDisconnect('Session expired'));
|
|
return;
|
|
}
|
|
|
|
_tickCount++;
|
|
final pollInterval = _appBackgrounded
|
|
? _backgroundPollIntervalSeconds
|
|
: _pollIntervalSeconds;
|
|
if (_tickCount % pollInterval == 0) {
|
|
unawaited(_syncWithHivemind());
|
|
}
|
|
|
|
notifyListeners();
|
|
|
|
if (_hasSyncedOnce && vpnConnection.status == VpnStatus.connected) {
|
|
NotificationService.updateTimer(formatted);
|
|
}
|
|
}
|
|
|
|
/// Force a control-plane refresh after an event that changes the session
|
|
/// server-side. If a periodic sync is already in flight, wait for it and
|
|
/// issue a fresh request so the event cannot be hidden behind the busy guard.
|
|
Future<void> syncNow() async {
|
|
final requestedEpoch = _sessionEpoch;
|
|
while (requestedEpoch == _sessionEpoch && !_isDisconnecting) {
|
|
final active = _syncEpochInProgress == requestedEpoch
|
|
? _syncCompletion
|
|
: null;
|
|
if (active != null) {
|
|
await active.future;
|
|
continue;
|
|
}
|
|
await _syncWithHivemind();
|
|
return;
|
|
}
|
|
}
|
|
|
|
Future<void> disconnect({String reason = 'User requested'}) async {
|
|
await _doDisconnect(reason);
|
|
}
|
|
|
|
Future<void> _doDisconnect(String reason) async {
|
|
if (_isDisconnecting) return;
|
|
_isDisconnecting = true;
|
|
_sessionEpoch++;
|
|
debugPrint('[Timer] Disconnecting: $reason');
|
|
|
|
_timer?.cancel();
|
|
_timer = null;
|
|
_currentSpeedKBps = 0.0;
|
|
_remainingSeconds = 0;
|
|
_remainingAtLastSync = 0;
|
|
_hasSyncedOnce = false;
|
|
_sinceLastSuccessfulSync?.stop();
|
|
_sinceLastSuccessfulSync = null;
|
|
NotificationService.reset();
|
|
notifyListeners();
|
|
|
|
await vpnConnection.disconnect();
|
|
|
|
_isDisconnecting = false;
|
|
notifyListeners();
|
|
}
|
|
|
|
int _readNonNegativeInt(dynamic value, int fallback) {
|
|
if (value is int) return value >= 0 ? value : fallback;
|
|
if (value is num && value >= 0) return value.toInt();
|
|
return fallback;
|
|
}
|
|
|
|
Future<void> _syncWithHivemind() async {
|
|
if (_isDisconnecting) return;
|
|
final epoch = _sessionEpoch;
|
|
if (_syncEpochInProgress == epoch) return;
|
|
|
|
final completion = Completer<void>();
|
|
_syncCompletion = completion;
|
|
_syncEpochInProgress = epoch;
|
|
|
|
try {
|
|
final deviceId = await CryptoService.getDeviceId();
|
|
if (epoch != _sessionEpoch || _isDisconnecting) return;
|
|
|
|
final base = Uri.parse('${AppConfig.hivemindApiPublic}/session/status');
|
|
final url = base.replace(queryParameters: {'device_id': deviceId});
|
|
final response = await HivemindService.controlGet(url);
|
|
if (epoch != _sessionEpoch || _isDisconnecting) return;
|
|
|
|
if (response.statusCode == 200) {
|
|
final decoded = jsonDecode(response.body);
|
|
if (decoded is! Map<String, dynamic>) {
|
|
debugPrint('[Timer] Invalid session payload type.');
|
|
_markSyncFailure(epoch);
|
|
return;
|
|
}
|
|
final data = decoded;
|
|
|
|
final activeValue = data['active'];
|
|
if (activeValue is! bool) {
|
|
debugPrint('[Timer] Session payload missing boolean active state.');
|
|
_markSyncFailure(epoch);
|
|
return;
|
|
}
|
|
if (!activeValue) {
|
|
await _doDisconnect('Server ended session');
|
|
return;
|
|
}
|
|
|
|
final expiresValue = data['expires_in_seconds'];
|
|
if (expiresValue is! num ||
|
|
!expiresValue.isFinite ||
|
|
expiresValue < 0) {
|
|
debugPrint('[Timer] Session payload has invalid expiry.');
|
|
_markSyncFailure(epoch);
|
|
return;
|
|
}
|
|
|
|
final elapsedMs = _sinceLastSuccessfulSync?.elapsedMilliseconds;
|
|
_remainingAtLastSync = expiresValue.toInt();
|
|
_remainingSeconds = _remainingAtLastSync;
|
|
_usedBytes = _readNonNegativeInt(data['used_bytes'], _usedBytes);
|
|
|
|
final deltaBytes = _usedBytes - _lastUsedBytes;
|
|
if (_hasSyncedOnce && elapsedMs != null && elapsedMs > 0) {
|
|
_currentSpeedKBps = deltaBytes > 0
|
|
? (deltaBytes / (elapsedMs / 1000.0)) / 1000.0
|
|
: 0.0;
|
|
} else {
|
|
_currentSpeedKBps = 0.0;
|
|
}
|
|
_lastUsedBytes = _usedBytes;
|
|
_sinceLastSuccessfulSync?.stop();
|
|
_sinceLastSuccessfulSync = Stopwatch()..start();
|
|
|
|
if (data['cap_exhausted'] == true) {
|
|
await _doDisconnect('Data cap reached');
|
|
return;
|
|
}
|
|
|
|
if (epoch != _sessionEpoch || _isDisconnecting) return;
|
|
try {
|
|
await vpnConnection.setNativeSessionDeadline(_remainingAtLastSync);
|
|
} catch (e) {
|
|
debugPrint('[Timer] Failed to arm native session deadline: $e');
|
|
await _doDisconnect('Native session deadline unavailable');
|
|
return;
|
|
}
|
|
if (epoch != _sessionEpoch || _isDisconnecting) return;
|
|
_consecutiveFailures = 0;
|
|
_offlineSeconds = 0;
|
|
_hasSyncedOnce = true;
|
|
notifyListeners();
|
|
} else {
|
|
_markSyncFailure(epoch);
|
|
}
|
|
} catch (e) {
|
|
if (epoch == _sessionEpoch && !_isDisconnecting) {
|
|
debugPrint('Hivemind sync error: $e');
|
|
_markSyncFailure(epoch);
|
|
}
|
|
} finally {
|
|
if (_syncEpochInProgress == epoch) {
|
|
_syncEpochInProgress = null;
|
|
}
|
|
if (identical(_syncCompletion, completion)) {
|
|
_syncCompletion = null;
|
|
}
|
|
if (!completion.isCompleted) completion.complete();
|
|
}
|
|
}
|
|
|
|
void _markSyncFailure(int epoch) {
|
|
if (epoch != _sessionEpoch || _isDisconnecting) return;
|
|
_consecutiveFailures++;
|
|
if (_consecutiveFailures >= _maxConsecutiveFailures) {
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
void _reconcileElapsedTime() {
|
|
final clock = _sinceLastSuccessfulSync;
|
|
if (!_hasSyncedOnce || clock == null) return;
|
|
|
|
final elapsed = clock.elapsed.inSeconds;
|
|
_remainingSeconds =
|
|
(_remainingAtLastSync - elapsed).clamp(0, 1 << 31).toInt();
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
if (state == AppLifecycleState.paused ||
|
|
state == AppLifecycleState.detached ||
|
|
state == AppLifecycleState.inactive ||
|
|
state == AppLifecycleState.hidden) {
|
|
_appBackgrounded = true;
|
|
return;
|
|
}
|
|
|
|
if (state != AppLifecycleState.resumed || _isDisconnecting) return;
|
|
_appBackgrounded = false;
|
|
if (vpnConnection.status != VpnStatus.connected) return;
|
|
|
|
_reconcileElapsedTime();
|
|
if (!isRunning) {
|
|
_resumeTicking();
|
|
return;
|
|
}
|
|
|
|
unawaited(_syncWithHivemind());
|
|
notifyListeners();
|
|
}
|
|
|
|
void _resumeTicking() {
|
|
_reconcileElapsedTime();
|
|
_timer?.cancel();
|
|
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
|
_isDisconnecting = false;
|
|
unawaited(_syncWithHivemind());
|
|
notifyListeners();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
// Invalidate every asynchronous generation before removing listeners so
|
|
// late storage/network completions cannot notify a disposed notifier.
|
|
_sessionEpoch++;
|
|
_supportStateEpoch++;
|
|
_isDisconnecting = true;
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
vpnConnection.removeListener(_onVpnConnectionChanged);
|
|
_timer?.cancel();
|
|
_sinceLastSuccessfulSync?.stop();
|
|
super.dispose();
|
|
}
|
|
}
|