fix(client): 连接时长按墙上时钟算,切后台/锁屏不漏计
iOS 锁屏/切后台后连接页时长少算:elapsed 原来靠每秒 +1 的 Timer 累加,后台 isolate 被挂起、Timer 停跳 → 后台那段时长漏掉。改为记 _connectedAt 起点, elapsed = now - _connectedAt(墙上时钟),与 timer 是否跳无关;回前台(resumed) 立即 _refreshElapsed 补上。_connectedAt 用 ??= 保留同一会话起点,断开才清。 测试:新增「切后台 300s 时长不漏计」回归(前台 10s + 后台 300s = 310s);全 8 例过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -101,7 +101,10 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
_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<ConnectionState> {
|
||||
StreamSubscription<VpnStatsEvent>? _statsSub;
|
||||
ProviderSubscription<AuthState>? _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<ConnectionState> {
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user