diff --git a/app/lib/core/push/push_messaging.dart b/app/lib/core/push/push_messaging.dart index 1f0d41e..8f99411 100644 --- a/app/lib/core/push/push_messaging.dart +++ b/app/lib/core/push/push_messaging.dart @@ -65,6 +65,7 @@ class PushMessaging { /// /// iOS 는 APNs 키 전까지 토큰이 null 이다 — 예외가 아니라 정상이다. Future syncToken() async { + await _awaitPendingClear(); final token = await _transport.getToken(); if (token == null || token.isEmpty) return null; await _saveToken(token); @@ -111,6 +112,7 @@ class PushMessaging { /// 팬덤 ID 는 사용자 문서에서 스스로 읽는다. 호출자가 넘기게 하면 설정 화면이 /// 그 값을 알아야 하고, 화면이 알 이유가 없다. Future syncTopics(Map prefs) async { + await _awaitPendingClear(); for (final fandomId in await _resolveFandomIds()) { final plan = topicPlan(fandomId, prefs); for (final topic in plan.subscribe) { @@ -150,25 +152,67 @@ class PushMessaging { /// /// 토픽 해지를 먼저 하는 이유는 실패 순서다. 토큰 삭제가 실패해도 구독은 끊겨 /// 알림이 멈춘다. 반대로 두면 토큰만 지워진 채 토픽 알림이 계속 온다. + /// + /// **두 단계다.** 토큰 제거는 반환 전에 끝낸다(상한 [tokenRemoveTimeout]) — 이 기기 토큰이 + /// 이전 계정 문서에 남으면 다음 사용자가 그 계정의 개인 알림을 받는다. 호출자는 이 Future 가 + /// 끝난 뒤 로그아웃한다. 토픽 해지는 기다리지 않고 [_pendingClear] 에 남겨, 다음 로그인의 + /// [syncToken] · [syncTopics] 가 끝나기를 기다린 뒤에야 새 구독을 넣는다. 실패는 로그만 남긴다. Future clearOnSignOut() async { - // 해제는 한꺼번에 보낸다. 가입 행성 × 구분 수만큼 차례로 기다리면 그것만으로 - // 로그아웃이 수 초 걸린다. + late final Future run; + run = _unsubscribeAll() + .catchError( + (Object e) => debugPrint('[Push] topic cleanup failed: $e'), + ) + .whenComplete(() { + if (identical(_pendingClear, run)) _pendingClear = null; + }); + _pendingClear = run; + try { + await _removeToken().timeout(tokenRemoveTimeout); + } on Object catch (e) { + debugPrint('[Push] token removal skipped: $e'); + } + } + + @visibleForTesting + static Duration tokenRemoveTimeout = const Duration(seconds: 2); + + /// 진행 중인 로그아웃 정리. 끝나면 비운다. + Future? _pendingClear; + + /// 새 구독 전에 남은 정리를 기다린다. 멈춘 정리가 로그인을 막지 않도록 상한을 둔다. + Future _awaitPendingClear() async { + final pending = _pendingClear; + if (pending == null) return; + await pending.timeout( + pendingClearTimeout, + onTimeout: () => debugPrint('[Push] pending cleanup timed out'), + ); + } + + @visibleForTesting + static Duration pendingClearTimeout = const Duration(seconds: 10); + + Future _unsubscribeAll() async { + // 해제는 한꺼번에 보낸다. 가입 행성 × 구분 수만큼 차례로 기다리면 그것만으로 수 초 걸린다. + final ids = await _resolveFandomIds(); + // 다음 로그인은 다른 계정일 수 있다. 팬덤을 들고 있으면 그 계정의 토픽에 구독해 버린다. + _fandomIds = null; await Future.wait([ - for (final fandomId in await _resolveFandomIds()) + for (final fandomId in ids) for (final topic in PushTopic.values) _transport.unsubscribeFromTopic(topic.topicFor(fandomId)), ]); + } + + Future _removeToken() async { final uid = _backend.uid; final token = await _transport.getToken(); - if (uid != null && token != null && token.isNotEmpty) { - await _backend.db.doc('users/$uid').set({ - 'fcmTokens': FieldValue.arrayRemove([token]), - 'updatedAt': FieldValue.serverTimestamp(), - }, SetOptions(merge: true)); - } - // 다음 로그인은 다른 계정일 수 있다. 팬덤을 들고 있으면 그 계정의 토픽에 - // 구독해 버린다. - _fandomIds = null; + if (uid == null || token == null || token.isEmpty) return; + await _backend.db.doc('users/$uid').set({ + 'fcmTokens': FieldValue.arrayRemove([token]), + 'updatedAt': FieldValue.serverTimestamp(), + }, SetOptions(merge: true)); } Future dispose() async { diff --git a/app/lib/features/my/settings_screen.dart b/app/lib/features/my/settings_screen.dart index b746a95..dd08303 100644 --- a/app/lib/features/my/settings_screen.dart +++ b/app/lib/features/my/settings_screen.dart @@ -369,31 +369,20 @@ class _SettingsScreenState extends State { Future _notice(String message) => showAlertDialog(context, message); /// 확인 팝업(취소 · 실행) — 시안 `554:24669` · `374:14364` · `554:23257`. - /// 푸시 정리를 **먼저** 한다. 보안 규칙이 `users/{uid}` 쓰기를 `isSelf(uid)` 로 막아, - /// 로그아웃 뒤에는 이 기기 토큰을 지울 수 없다. 정리는 확인 버튼이 진행 중으로 바뀐 - /// 채 돌고, 실패하든 [_cleanupTimeout] 안에 끝나지 않든 로그아웃은 한다 — 토픽 해제 · - /// 토큰 쓰기가 응답 없이 멈추면 signOut 에 닿지 못해 첫 확인이 무시된 것처럼 - /// 보였다(10/6 실기기 2회). + /// 토큰 제거는 signOut 전에 기다린다(내부 상한 2초) — 남으면 다음 사용자가 개인 알림을 받는다. + /// 토픽 해지는 [PushMessaging] 이 들고 있다가 다음 로그인의 구독 전에 기다린다(10/6 실기기). Future _logout() async { final l10n = context.l10n; final ok = await showConfirmDialog( context, l10n.settingsLogoutConfirm, confirmLabel: l10n.settingsLogout, - onConfirm: () async { - try { - await widget.push?.clearOnSignOut().timeout(_cleanupTimeout); - } on Object catch (error) { - debugPrint('[Settings] push cleanup skipped: ${error.runtimeType}'); - } - }, ); - if (ok) await widget.auth?.signOut(); + if (!ok) return; + await widget.push?.clearOnSignOut(); + await widget.auth?.signOut(); } - /// 로그아웃 전 정리의 상한. 넘기면 정리를 두고 로그아웃한다. - static const _cleanupTimeout = Duration(seconds: 3); - Future _confirm({ required String title, String? body, diff --git a/app/test/backend_repository_test.dart b/app/test/backend_repository_test.dart index 1e3a651..d151425 100644 --- a/app/test/backend_repository_test.dart +++ b/app/test/backend_repository_test.dart @@ -1570,6 +1570,58 @@ void main() { ); }); + test('정리가 끝나기 전 로그인하면 구독은 정리 완료 뒤에 호출된다', () async { + final db = await _seed(); + final transport = _FakeTransport()..gate = Completer(); + final push = PushMessaging(backend: _backend(db), transport: transport); + final cleanup = push.clearOnSignOut(); // 기다리지 않는다 + final sync = push.syncTopics(PushTopic.defaults); + await Future.delayed(const Duration(milliseconds: 20)); + expect(transport.subscribed, isEmpty); + transport.gate!.complete(); + await Future.wait([cleanup, sync]); + expect(transport.subscribed, isNotEmpty); + // 정리의 해지(구분 수만큼)가 모두 끝난 뒤에야 첫 구독이 나간다. + expect( + transport.log.indexOf('sub'), + greaterThanOrEqualTo(PushTopic.values.length), + ); + }); + + test('정리가 실패해도 로그인 구독은 진행된다', () async { + final db = await _seed(); + final transport = _FakeTransport()..failUnsubscribe = true; + final push = PushMessaging(backend: _backend(db), transport: transport); + await push.clearOnSignOut(); // 던지지 않는다 + transport.failUnsubscribe = false; + await push.syncTopics(PushTopic.defaults); + expect(transport.subscribed, isNotEmpty); + }); + + test('정리가 멈추면 상한 뒤 구독을 진행한다', () async { + final db = await _seed(); + final transport = _FakeTransport()..gate = Completer(); + final push = PushMessaging(backend: _backend(db), transport: transport); + final old = PushMessaging.pendingClearTimeout; + PushMessaging.pendingClearTimeout = const Duration(milliseconds: 50); + addTearDown(() => PushMessaging.pendingClearTimeout = old); + unawaited(push.clearOnSignOut()); + await Future.delayed(const Duration(milliseconds: 10)); + transport.gate = null; // 이후 호출은 막지 않는다 + await push.syncTopics(PushTopic.defaults); + expect(transport.subscribed, isNotEmpty); + }); + + test('토큰 제거가 멈춰도 상한 뒤 clearOnSignOut 이 끝난다', () async { + final db = await _seed(); + final transport = _FakeTransport()..tokenGate = Completer(); + final push = PushMessaging(backend: _backend(db), transport: transport); + final old = PushMessaging.tokenRemoveTimeout; + PushMessaging.tokenRemoveTimeout = const Duration(milliseconds: 50); + addTearDown(() => PushMessaging.tokenRemoveTimeout = old); + await push.clearOnSignOut().timeout(const Duration(seconds: 2)); + }); + test('팬덤 소속이 없으면 아무 토픽도 건드리지 않는다', () async { final db = await _seed(); await db.doc('users/$_uid').set({'uid': _uid, 'fandomId': ''}); @@ -1605,6 +1657,10 @@ void main() { final class _FakeTransport implements PushTransport { final List subscribed = []; final List unsubscribed = []; + final List log = []; + Completer? gate; + bool failUnsubscribe = false; + Completer? tokenGate; @override Future requestPermission() async => @@ -1615,17 +1671,24 @@ final class _FakeTransport implements PushTransport { AuthorizationStatus.authorized; @override - Future getToken() async => 'fake-token'; + Future getToken() async => tokenGate?.future ?? 'fake-token'; @override Stream get onTokenRefresh => const Stream.empty(); @override - Future subscribeToTopic(String topic) async => subscribed.add(topic); + Future subscribeToTopic(String topic) async { + log.add('sub'); + subscribed.add(topic); + } @override - Future unsubscribeFromTopic(String topic) async => - unsubscribed.add(topic); + Future unsubscribeFromTopic(String topic) async { + await gate?.future; + if (failUnsubscribe) throw Exception('offline'); + log.add('unsub'); + unsubscribed.add(topic); + } @override Future getInitialMessage() async => null; diff --git a/app/test/f6_screens_test.dart b/app/test/f6_screens_test.dart index 3b0696e..39d37ec 100644 --- a/app/test/f6_screens_test.dart +++ b/app/test/f6_screens_test.dart @@ -1010,14 +1010,15 @@ void main() { /// /// 이 배선이 없던 동안 모달은 「09-10 회차에 연결합니다」 안내만 띄우고 세션을 /// 그대로 두었다. 화면만 보면 로그아웃된 것처럼 보이지 않아 결함이 드러나지 않는다. - testWidgets('푸시 정리가 실패해도 첫 확인에 로그아웃된다 (10/6 실기기)', (tester) async { + testWidgets('로그아웃은 푸시 토큰 제거 뒤에 세션을 끊는다 (10/6 r4)', (tester) async { final session = _LogoutSession( const AuthAccount(uid: 'u1', provider: SocialProvider.google), ); final auth = AuthRepository(providers: const [], session: session, releaseMode: true); + final push = _OrderPush(session); await pumpWide( tester, - SettingsScreen(repository: SampleMyRepository(), auth: auth, push: _FailingPush()), + SettingsScreen(repository: SampleMyRepository(), auth: auth, push: push), ); await tester.pumpAndSettle(); await tester.scrollUntilVisible(find.text('로그아웃'), 220, scrollable: find.byType(Scrollable).first); @@ -1025,31 +1026,7 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, '로그아웃')); await tester.pumpAndSettle(); - expect(session.signOutCalls, 1); - auth.dispose(); - await session.close(); - }); - - testWidgets('푸시 정리가 멈춰도 상한 안에 첫 확인으로 로그아웃된다 — 확인 버튼은 진행 중 (10/6 r3)', (tester) async { - final session = _LogoutSession( - const AuthAccount(uid: 'u1', provider: SocialProvider.google), - ); - final auth = AuthRepository(providers: const [], session: session, releaseMode: true); - await pumpWide( - tester, - SettingsScreen(repository: SampleMyRepository(), auth: auth, push: _HangingPush()), - ); - await tester.pumpAndSettle(); - await tester.scrollUntilVisible(find.text('로그아웃'), 220, scrollable: find.byType(Scrollable).first); - await tester.tap(find.text('로그아웃')); - await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(FilledButton, '로그아웃')); - await tester.pump(); - // 정리가 도는 동안 확인 버튼은 진행 중 표시다. - expect(find.byType(CircularProgressIndicator), findsOneWidget); - expect(session.signOutCalls, 0); - await tester.pump(const Duration(seconds: 4)); - await tester.pumpAndSettle(); + expect(push.signOutsAtClear, 0); expect(session.signOutCalls, 1); auth.dispose(); await session.close(); @@ -1090,19 +1067,14 @@ void main() { }); } -/// 푸시 정리가 실패하는 경계 — 토픽 해제 · 토큰 쓰기가 네트워크 오류로 던진다. -class _FailingPush implements PushMessaging { - @override - Future clearOnSignOut() async => throw Exception('offline'); +/// 토큰 제거가 불린 시점의 signOut 횟수를 남긴다 — 0 이어야 제거가 먼저다. +class _OrderPush implements PushMessaging { + _OrderPush(this._session); + final _LogoutSession _session; + int? signOutsAtClear; @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - -/// 푸시 정리가 응답 없이 멈추는 경계 — 토픽 해제가 끝나지 않는다. -class _HangingPush implements PushMessaging { - @override - Future clearOnSignOut() => Completer().future; + Future clearOnSignOut() async => signOutsAtClear = _session.signOutCalls; @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);