Files
ReVoltVPN/lib/logic/session_timer.dart
T
esefxdz 9ac236a981 feat(app): OS session-expiry enforcement, session persistence, support sync
Enforce session expiry at the OS level (AlarmManager + handler) so the tunnel dies even if the UI process is killed; persist the session so a START_STICKY restart re-establishes it; sync immediately after a support top-up; slow the session beacon 5s/30s -> 60s/60s; drop the Always-on-VPN deep-link and the legacy 'ass' migration. Docs: CHANGELOG 3.3.4, privacy-policy log disclosure, README.
2026-09-02 20:33:33 +03:00

405 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/power_settings.dart';
import 'package:revoltvpn/logic/vpn_connection.dart';
class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
Timer? _timer;
int _tickCount = 0;
int _lastPushedExpiryAtMs = 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;
bool _syncInProgress = false;
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;
// The countdown ticks locally every second; these only drive the slow
// server heartbeat that catches server-side changes (support-ad extension,
// 10 GB cap, session-ended). Keeping it slow avoids a chatty /session/status
// beacon (see internal/LAUNCH_READINESS.md "cut the beacon rate").
static const int _pollIntervalSeconds = 60;
static const int _backgroundPollIntervalSeconds = 60;
int _lastUsedBytes = 0;
DateTime? _lastSuccessfulSyncAt;
double _currentSpeedKBps = 0.0;
SessionTimer({required this.vpnConnection}) {
WidgetsBinding.instance.addObserver(this);
vpnConnection.addListener(_onVpnConnectionChanged);
unawaited(_loadSupportRewardState());
// This provider is built lazily, so the tunnel may already be up and
// VpnConnection may already have fired its change before we attached.
// A listener does not replay history -- evaluate the current state once.
// Deferred so we never notify during the build that created us.
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, however we got here:
// adopting a tunnel that outlived the UI process, recovering from a blip, or
// attaching late to one that was already up. Previously this required either
// adoptedRunningRuntime or _hasSyncedOnce, both false on a fresh process, so
// the clock stayed at 00:00:00 and the notification countdown froze with it.
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) {
_stopForVpnFailure('VPN entered error state');
return;
}
if (vpnConnection.status == VpnStatus.disconnected) {
unawaited(_doDisconnect('VPN tunnel dropped'));
}
}
void _stopForVpnFailure(String reason) {
if (_isDisconnecting) return;
_isDisconnecting = true;
debugPrint('[Timer] Stopping after VPN failure: $reason');
_timer?.cancel();
_timer = null;
_currentSpeedKBps = 0.0;
_remainingSeconds = 0;
_remainingAtLastSync = 0;
_hasSyncedOnce = false;
_lastSuccessfulSyncAt = null;
_consecutiveFailures = 0;
_offlineSeconds = 0;
NotificationService.reset();
notifyListeners();
}
Future<void> start() async {
_remainingSeconds = 0;
_remainingAtLastSync = 0;
_usedBytes = 0;
_lastUsedBytes = 0;
_lastSuccessfulSyncAt = null;
_currentSpeedKBps = 0.0;
_tickCount = 0;
_lastPushedExpiryAtMs = 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 && _consecutiveFailures < _maxConsecutiveFailures) {
_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 an immediate server sync (e.g. after a support-ad extension so the
/// countdown reflects the new expiry without waiting for the next heartbeat).
Future<void> syncNow() => _syncWithHivemind();
Future<void> disconnect({String reason = 'User requested'}) async {
await _doDisconnect(reason);
}
Future<void> _doDisconnect(String reason) async {
if (_isDisconnecting) return;
_isDisconnecting = true;
debugPrint('[Timer] Disconnecting: $reason');
_timer?.cancel();
_timer = null;
_currentSpeedKBps = 0.0;
_remainingSeconds = 0;
_remainingAtLastSync = 0;
_hasSyncedOnce = false;
_lastSuccessfulSyncAt = null;
NotificationService.reset();
notifyListeners();
await vpnConnection.disconnect();
if (!_isDisconnecting) return;
_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 || _syncInProgress) return;
_syncInProgress = true;
try {
final deviceId = await CryptoService.getDeviceId();
final base = Uri.parse('${AppConfig.hivemindApiPublic}/session/status');
final url = base.replace(queryParameters: {'device_id': deviceId});
final response = await HivemindService.directGet(url);
if (_isDisconnecting) return;
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
debugPrint('[Timer] Invalid session payload type.');
_markSyncFailure();
return;
}
final data = decoded;
final activeValue = data['active'];
if (activeValue is! bool) {
debugPrint('[Timer] Session payload missing boolean active state.');
_markSyncFailure();
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();
return;
}
_remainingAtLastSync = expiresValue.toInt();
_remainingSeconds = _remainingAtLastSync;
// Push the absolute deadline to the VPN service so it can tear the
// tunnel down on its own clock, even if this UI process is gone.
final expiresAtMs =
DateTime.now().millisecondsSinceEpoch + _remainingSeconds * 1000;
if ((expiresAtMs - _lastPushedExpiryAtMs).abs() > 5000) {
_lastPushedExpiryAtMs = expiresAtMs;
unawaited(PowerSettings.setSessionExpiry(expiresAtMs));
}
_usedBytes = _readNonNegativeInt(data['used_bytes'], _usedBytes);
final now = DateTime.now();
final previousSyncAt = _lastSuccessfulSyncAt;
final deltaBytes = _usedBytes - _lastUsedBytes;
if (_hasSyncedOnce && previousSyncAt != null) {
final elapsedMs = now.difference(previousSyncAt).inMilliseconds;
if (deltaBytes > 0 && elapsedMs > 0) {
_currentSpeedKBps = (deltaBytes / (elapsedMs / 1000.0)) / 1000.0;
} else {
_currentSpeedKBps = 0.0;
}
} else {
_currentSpeedKBps = 0.0;
}
_lastUsedBytes = _usedBytes;
_lastSuccessfulSyncAt = now;
if (data['cap_exhausted'] == true) {
await _doDisconnect('Data cap reached');
return;
}
_consecutiveFailures = 0;
_offlineSeconds = 0;
_hasSyncedOnce = true;
notifyListeners();
} else {
_markSyncFailure();
}
} catch (e) {
if (!_isDisconnecting) {
debugPrint('Hivemind sync error: $e');
_markSyncFailure();
}
} finally {
_syncInProgress = false;
}
}
void _markSyncFailure() {
_consecutiveFailures++;
if (_consecutiveFailures >= _maxConsecutiveFailures) {
notifyListeners();
}
}
void _reconcileElapsedTime() {
final syncedAt = _lastSuccessfulSyncAt;
if (!_hasSyncedOnce || syncedAt == null) return;
final elapsed = DateTime.now().difference(syncedAt).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() {
WidgetsBinding.instance.removeObserver(this);
vpnConnection.removeListener(_onVpnConnectionChanged);
_timer?.cancel();
super.dispose();
}
}