diff --git a/client/lib/state/connection_provider.dart b/client/lib/state/connection_provider.dart index 63118e2..83b04df 100644 --- a/client/lib/state/connection_provider.dart +++ b/client/lib/state/connection_provider.dart @@ -101,7 +101,10 @@ class ConnectionController extends StateNotifier { _lifecycle = AppLifecycleListener( onStateChange: (s) { if (s == AppLifecycleState.resumed) { - if (state.phase == VpnPhase.on && _watchdog == null) _startWatchdog(); + if (state.phase == VpnPhase.on) { + if (_watchdog == null) _startWatchdog(); + _refreshElapsed(); // 回前台按墙上时钟补上后台漏计的时长 + } } else if (s == AppLifecycleState.paused || s == AppLifecycleState.hidden) { _stopWatchdog(); } @@ -115,6 +118,9 @@ class ConnectionController extends StateNotifier { StreamSubscription? _statsSub; ProviderSubscription? _authSub; Timer? _elapsed; + // 本次连接的起始时刻;连接时长按「now - _connectedAt」墙上时钟算,而非每秒 +1 累加 —— + // 否则 app 切后台/锁屏 isolate 被挂起、Timer 停跳,后台那段时长会漏计(iOS 尤甚)。 + DateTime? _connectedAt; Timer? _watchdog; AppLifecycleListener? _lifecycle; bool _probing = false; @@ -377,16 +383,23 @@ class ConnectionController extends StateNotifier { void _startElapsed() { _elapsed?.cancel(); - _elapsed = Timer.periodic(const Duration(seconds: 1), (_) { - if (mounted && state.phase == VpnPhase.on) { - state = state.copyWith(elapsed: state.elapsed + const Duration(seconds: 1)); - } - }); + _connectedAt ??= _now(); // 同一会话保留起点(on 被重复上报也不重置);断开时清空 + _refreshElapsed(); // 立即刷新,避免等 1s + _elapsed = Timer.periodic(const Duration(seconds: 1), (_) => _refreshElapsed()); + } + + /// 按墙上时钟把 elapsed 刷成 now - _connectedAt(切后台回来也准)。 + void _refreshElapsed() { + final at = _connectedAt; + if (mounted && state.phase == VpnPhase.on && at != null) { + state = state.copyWith(elapsed: _now().difference(at)); + } } void _stopElapsed() { _elapsed?.cancel(); _elapsed = null; + _connectedAt = null; } @override diff --git a/client/test/unit/connection_watchdog_test.dart b/client/test/unit/connection_watchdog_test.dart index 6157795..7485082 100644 --- a/client/test/unit/connection_watchdog_test.dart +++ b/client/test/unit/connection_watchdog_test.dart @@ -233,6 +233,35 @@ void main() { await tester.pump(); }); + testWidgets('连接时长按墙上时钟:切后台的时长不漏计', (tester) async { + final bridge = _FakeBridge(); + var fake = DateTime(2026, 1, 1); + final c = makeContainer(bridge, now: () => fake); + addTearDown(bridge.dispose); + addTearDown(c.dispose); + await tester.pumpWidget(UncontrolledProviderScope(container: c, child: const SizedBox())); + c.read(selectedNodeCodeProvider.notifier).select('HK'); + await driveOn(tester, c, bridge); // on:_connectedAt=t0 + // 前台 10s:墙上时钟 +10、timer 跳一次。 + fake = fake.add(const Duration(seconds: 10)); + await tester.pump(const Duration(seconds: 1)); + expect(c.read(connectionProvider).elapsed, const Duration(seconds: 10)); + // 切后台(timer 冻结:只推墙上时钟、不 pump timer 间隔),过 300s。 + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + fake = fake.add(const Duration(seconds: 300)); + // 回前台:应按墙上时钟补上后台 300s → 共 310s,而非只有前台的 10s。 + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + expect(c.read(connectionProvider).elapsed, const Duration(seconds: 310), + reason: '后台 300s 应计入连接时长'); + bridge.emit(VpnStatus.off); // 收尾 + await tester.pump(); + }); + testWidgets('节点健康 → 保持连接,不触发任何动作', (tester) async { final bridge = _FakeBridge(); final c = makeContainer(bridge); // 都 up