Second review pass found that VpnStatus.error is unreachable mid-session: AppConfigs.V2RAY_STATES has no error state and the plugin's stateName when() ends in else -> "DISCONNECTED", so Dart's unknown -> error mapping is dead code on Android. Every native stop therefore arrived as a plain disconnect, and anything other than the expiry deadline - an Xray or TUN setup failure, a mid-session fail-closed deadline arm - left the user on their real IP with no notification and no message. That is the exact hole this feature exists to close. stopAll() is the single funnel for every native stop, so the alert moves there: it fires when the stop interrupted a live tunnel and the user did not ask for it. isRunning is captured before cleanup() clears it, which keeps a failed connect silent - it never had a tunnel to interrupt. STOP_SERVICE (the notification's Disconnect action) and onRevoke pass userRequested: in the revoke case the user switched VPNs or withdrew consent themselves, and may well be protected by the app that took over. Also from the pass: - re-arming now needs the deadline to move by more than 15 s, so round-trip jitter cannot cancel and re-post a warning that is already on screen - a warning delivered with under a minute left is dropped rather than announcing "5 minutes"; the disconnected alert follows anyway - the _doDisconnect comment no longer claims endedMessage posts the alert - CHANGELOG: the alert does now cover the tunnel stopping unexpectedly flutter analyze clean; debug APK builds with the Kotlin recompiled. Co-Authored-By: Claude Opus 5 <[email protected]>
493 lines
16 KiB
Dart
493 lines
16 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 _pollIntervalSeconds = 60;
|
|
static const int _backgroundPollIntervalSeconds = 60;
|
|
|
|
int _lastUsedBytes = 0;
|
|
Stopwatch? _sinceLastSuccessfulSync;
|
|
// Wall-clock deadline, kept alongside the Stopwatch because they answer
|
|
// different questions. The Stopwatch is CLOCK_MONOTONIC: it does not advance
|
|
// while the device is suspended, which is exactly when an RTC_WAKEUP expiry
|
|
// alarm fires, so it cannot be used to recognise an expiry after sleep.
|
|
DateTime? _deadlineAt;
|
|
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;
|
|
|
|
// True once the control plane has missed enough polls in a row to be worth
|
|
// telling the user about. It is a display state only: a tunnel that still
|
|
// carries traffic is never torn down because the API is unreachable (the
|
|
// native deadline and the server both still enforce expiry).
|
|
bool get serverUnreachable => _consecutiveFailures >= _maxConsecutiveFailures;
|
|
|
|
// Why the last session ended, when the user did not end it themselves.
|
|
// Cleared on the next connect. Rendered by status_bar.dart.
|
|
String? _lastEndMessage;
|
|
String? get lastEndMessage => _lastEndMessage;
|
|
|
|
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',
|
|
endedMessage: 'The tunnel stopped. You are not protected.',
|
|
));
|
|
return;
|
|
}
|
|
|
|
if (vpnConnection.status == VpnStatus.disconnected) {
|
|
// The native deadline fires on the exact second while this 1 s tick is
|
|
// in arbitrary phase, so expiry almost always arrives here rather than
|
|
// at the countdown check in _tick - without this, the commonest session
|
|
// end left the UI with nothing to say.
|
|
final deadline = _deadlineAt;
|
|
final expired = _hasSyncedOnce &&
|
|
deadline != null &&
|
|
DateTime.now().isAfter(
|
|
deadline.subtract(const Duration(seconds: 5)),
|
|
);
|
|
// postAlert is false either way: the native side already posted for its
|
|
// own deadline, and the notification's Disconnect action is the user
|
|
// asking, which must never raise a "not protected" alert.
|
|
unawaited(_doDisconnect(
|
|
'VPN tunnel dropped',
|
|
endedMessage:
|
|
expired ? 'Your session ended. You are not protected.' : null,
|
|
postAlert: false,
|
|
));
|
|
}
|
|
}
|
|
|
|
// resetSupportClaim is false when re-attaching to a session the device
|
|
// already holds: the support top-up is once per server session, and the
|
|
// claim flag is the only thing enforcing that.
|
|
Future<void> start({bool resetSupportClaim = true}) async {
|
|
_sessionEpoch++;
|
|
_remainingSeconds = 0;
|
|
_remainingAtLastSync = 0;
|
|
_usedBytes = 0;
|
|
_lastUsedBytes = 0;
|
|
_sinceLastSuccessfulSync?.stop();
|
|
_sinceLastSuccessfulSync = null;
|
|
_deadlineAt = null;
|
|
_currentSpeedKBps = 0.0;
|
|
_tickCount = 0;
|
|
_hasSyncedOnce = false;
|
|
_consecutiveFailures = 0;
|
|
_isDisconnecting = false;
|
|
_appBackgrounded = false;
|
|
_lastEndMessage = null;
|
|
NotificationService.reset();
|
|
|
|
if (resetSupportClaim) {
|
|
_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 (_hasSyncedOnce && _remainingSeconds <= 0) {
|
|
unawaited(_doDisconnect(
|
|
'Session expired',
|
|
endedMessage: 'Your session ended. You are not protected.',
|
|
));
|
|
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);
|
|
}
|
|
|
|
// endedMessage is the one sentence the UI shows after an end the user did
|
|
// not ask for; postAlert independently decides whether to raise the "not
|
|
// protected" notification, which the native service already posts for every
|
|
// stop it sees. Both exist so nobody is left on their real IP without being
|
|
// told. A user-requested disconnect passes neither.
|
|
Future<void> _doDisconnect(
|
|
String reason, {
|
|
String? endedMessage,
|
|
bool postAlert = true,
|
|
}) async {
|
|
if (_isDisconnecting) return;
|
|
_isDisconnecting = true;
|
|
_sessionEpoch++;
|
|
debugPrint('[Timer] Disconnecting: $reason');
|
|
|
|
_lastEndMessage = endedMessage;
|
|
_deadlineAt = null;
|
|
_timer?.cancel();
|
|
_timer = null;
|
|
_currentSpeedKBps = 0.0;
|
|
_remainingSeconds = 0;
|
|
_remainingAtLastSync = 0;
|
|
_hasSyncedOnce = false;
|
|
_sinceLastSuccessfulSync?.stop();
|
|
_sinceLastSuccessfulSync = null;
|
|
NotificationService.reset();
|
|
notifyListeners();
|
|
|
|
await vpnConnection.disconnect();
|
|
if (endedMessage != null && postAlert) {
|
|
unawaited(NotificationService.showDisconnectedAlert());
|
|
}
|
|
|
|
_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.directGet(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',
|
|
endedMessage:
|
|
'The server ended your session. You are not protected.',
|
|
);
|
|
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;
|
|
_deadlineAt = DateTime.now().add(
|
|
Duration(seconds: _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',
|
|
endedMessage: 'Data limit reached. You are not protected.',
|
|
);
|
|
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',
|
|
endedMessage:
|
|
'Could not arm the session timer. You are not protected.',
|
|
);
|
|
return;
|
|
}
|
|
if (epoch != _sessionEpoch || _isDisconnecting) return;
|
|
_consecutiveFailures = 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();
|
|
|
|
// The Stopwatch is CLOCK_MONOTONIC and stops while the device is
|
|
// suspended, so after a long sleep it over-reports - and with the server
|
|
// unreachable no sync arrives to correct it, which is exactly the state
|
|
// the app now stays in rather than disconnecting. Take whichever is
|
|
// smaller, never the wall clock alone: a clock set backwards must not be
|
|
// able to extend a session.
|
|
final deadline = _deadlineAt;
|
|
if (deadline != null) {
|
|
final byWallClock = deadline.difference(DateTime.now()).inSeconds;
|
|
if (byWallClock < _remainingSeconds) {
|
|
_remainingSeconds = byWallClock.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();
|
|
}
|
|
}
|