import 'dart:async'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_messaging/firebase_messaging.dart' show FirebaseMessaging; import 'package:flutter/foundation.dart'; import '../data/backend.dart'; import '../../features/my/my_data.dart'; import 'push_transport.dart'; /// FCM 수신 — 정책 원장 B-77 「Push 알림」 5 개 구분의 앱 쪽 절반. /// /// **구분마다 토픽을 나눈 이유가 여기 있다.** 서버는 토픽으로만 보내므로 수신자를 /// 고를 수 없다. 사용자가 「AI Briefing 만 끄기」를 고르면 앱이 그 토픽을 해지하고, /// 그래서 **OFF 구분의 수신이 0 건**이 된다(B-77 검증 기준). 앱이 받아서 버리는 /// 방식이면 기기에 알림이 먼저 떠 버려 정책을 지킬 수 없다. /// /// Marketing 은 기본값이 OFF 라 명시 동의 전에는 구독하지 않는다. /// /// **iOS 는 코드 경로만이다.** APNs 키가 Apple Developer 계정 확정 대기(별첨 A-1)라 /// 토큰이 발급되지 않는다. 키가 들어오는 날 이 파일을 고치지 않아도 되도록 /// 지금 경로를 맞춰 둔다. class PushMessaging { PushMessaging({required Backend backend, PushTransport? transport}) : _backend = backend, _transport = transport ?? FirebaseMessagingTransport(); final Backend _backend; final PushTransport _transport; StreamSubscription? _foreground; StreamSubscription? _opened; StreamSubscription? _tokenRefresh; /// 가입한 팬덤 전부. 토픽 이름의 일부라 매번 읽지 않도록 기억한다. Set? _fandomIds; /// 알림 권한 상태. 화면이 「거부됨」 안내와 재요청 버튼을 그릴 근거다. AuthorizationStatus _status = AuthorizationStatus.notDetermined; AuthorizationStatus get status => _status; bool get isAuthorized => _status.isAllowed; /// 알림 권한 요청. /// /// Android 13+ 의 `POST_NOTIFICATIONS` 런타임 권한도 이 호출이 띄운다 — /// 플러그인이 매니페스트에 권한을 선언하고 `requestPermission` 이 시스템 /// 다이얼로그로 이어진다. 별도 권한 플러그인을 넣지 않은 이유가 이것이다. Future requestPermission() async { _status = await _transport.requestPermission(); return _status; } /// 현재 권한 상태만 읽는다. 다이얼로그를 띄우지 않으므로 화면 진입 시 쓴다. Future refreshStatus() async { _status = await _transport.currentStatus(); return _status; } /// 단말 토큰을 `users/{uid}.fcmTokens` 에 더한다. /// /// 배열로 두는 이유는 한 계정이 여러 단말을 쓰기 때문이다. 단일 필드면 새 단말 /// 로그인이 이전 단말의 토큰을 지우고, 그 단말은 조용히 알림을 못 받는다. /// 토큰 재발급도 같은 경로로 흘러 들어온다([listen] 의 `onTokenRefresh`). /// /// iOS 는 APNs 키 전까지 토큰이 null 이다 — 예외가 아니라 정상이다. Future syncToken() async { await _awaitPendingClear(); final token = await _transport.getToken(); if (token == null || token.isEmpty) return null; await _saveToken(token); return token; } Future _saveToken(String token) async { final uid = _backend.uid; if (uid == null) return; await _backend.db.doc('users/$uid').set({ 'fcmTokens': FieldValue.arrayUnion([token]), 'updatedAt': FieldValue.serverTimestamp(), }, SetOptions(merge: true)); } /// 가입한 팬덤 ID 전부 — 대표 팬덤(`users.fandomId`) + 가입 목록(`users/{uid}/memberships`). /// /// PRD-06 「관심 아티스트를 등록한 사용자」 · 별첨 1 ③-5 「FCM 푸시 수신」 — 대표 팬덤 /// 하나만 구독하면 다른 행성의 브리핑 · 이클립스 예고를 받지 못한다. Future> _resolveFandomIds() async { final cached = _fandomIds; if (cached != null) return cached; final uid = _backend.uid; if (uid == null) return const {}; final user = await _backend.db.doc('users/$uid').get(); final memberships = await _backend.db .collection('users/$uid/memberships') .get(); final ids = { (user.data() ?? const {}).strOr('fandomId'), for (final d in memberships.docs) d.id, }..remove(''); if (ids.isNotEmpty) _fandomIds = ids; return ids; } /// 설정대로 토픽을 맞춘다. /// /// **구독 상태를 앱이 기억하지 않는다.** 켜진 구분은 구독을, 꺼진 구분은 해지를 /// 조건 없이 부른다. FCM 의 두 호출은 멱등이라 이미 그 상태여도 문제가 없고, /// 반대로 앱이 기억한 상태를 믿으면 재설치·토큰 재발급·다른 기기 로그인처럼 /// 앱이 모르는 사이에 실제 구독이 달라진 경우를 되돌릴 방법이 없다. /// /// 팬덤 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) { await _transport.subscribeToTopic(topic); } for (final topic in plan.unsubscribe) { await _transport.unsubscribeFromTopic(topic); } } } /// 수신 구독을 연다. /// /// 포그라운드에서는 시스템 알림이 뜨지 않으므로(Android 기본 동작) 앱이 직접 /// 표시해야 한다. 탭은 두 경로로 들어온다 — 앱이 살아 있을 때의 /// `onMessageOpenedApp` 과 종료 상태에서 알림으로 켠 `getInitialMessage`. /// 후자를 빼면 「알림을 눌러 앱이 열렸는데 홈에 그냥 서 있다」가 된다. Future listen({ required void Function(PushNotice notice) onForeground, required void Function(PushNotice notice) onOpened, }) async { _foreground = FirebaseMessaging.onMessage.listen( (m) => onForeground(PushNotice.of(m)), ); _opened = FirebaseMessaging.onMessageOpenedApp.listen( (m) => onOpened(PushNotice.of(m)), ); _tokenRefresh = _transport.onTokenRefresh.listen(_saveToken); final initial = await _transport.getInitialMessage(); if (initial != null) onOpened(PushNotice.of(initial)); } /// 로그아웃 정리 — 팬덤 토픽을 전부 해지하고 이 기기 토큰을 지운다. /// /// **로그아웃보다 먼저 불러야 한다.** 보안 규칙이 `users/{uid}` 쓰기를 /// `isSelf(uid)` 로 막으므로 인증이 끊긴 뒤에는 토큰을 지울 수 없다. 그대로 두면 /// 다음 사용자가 그 기기를 쓸 때 이전 계정의 알림이 계속 내려간다. /// /// 토픽 해지를 먼저 하는 이유는 실패 순서다. 토큰 삭제가 실패해도 구독은 끊겨 /// 알림이 멈춘다. 반대로 두면 토큰만 지워진 채 토픽 알림이 계속 온다. /// /// **두 단계다.** 토큰 제거는 반환 전에 끝낸다(상한 [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 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.isEmpty) return; await _backend.db.doc('users/$uid').set({ 'fcmTokens': FieldValue.arrayRemove([token]), 'updatedAt': FieldValue.serverTimestamp(), }, SetOptions(merge: true)); } Future dispose() async { await _foreground?.cancel(); await _opened?.cancel(); await _tokenRefresh?.cancel(); } } /// 알림을 실제로 받을 수 있는 상태인가. /// /// `provisional` 을 포함하는 이유는 iOS 임시 승인이다. 사용자가 아직 명시적으로 /// 허용하지 않았어도 조용한 알림은 전달되므로, 「꺼져 있습니다」 안내를 띄우면 /// 사실과 다르다. extension PushAuthorization on AuthorizationStatus { bool get isAllowed => this == AuthorizationStatus.authorized || this == AuthorizationStatus.provisional; } /// 토픽 계획 — 무엇을 구독하고 무엇을 해지할지. @immutable class TopicPlan { const TopicPlan({required this.subscribe, required this.unsubscribe}); final Set subscribe; final Set unsubscribe; } /// 설정에서 계획을 낸다. 모든 구분이 둘 중 한쪽에 들어간다. /// /// 플러그인을 부르지 않는 순수 함수로 떼어 둔 이유는 검증이다. 「Marketing 을 끈 /// 상태로는 그 토픽을 구독하지 않는다」가 정책 검증 기준이고, 그 판정을 실제 FCM /// 없이 확인할 수 있어야 한다. TopicPlan topicPlan(String fandomId, Map prefs) { final subscribe = {}; final unsubscribe = {}; for (final topic in PushTopic.values) { // 서비스 필수는 끌 수 없으므로 설정값을 보지 않는다. final on = topic.locked || (prefs[topic] ?? topic.defaultOn); (on ? subscribe : unsubscribe).add(topic.topicFor(fandomId)); } return TopicPlan(subscribe: subscribe, unsubscribe: unsubscribe); } /// 받은 알림 한 건. 화면이 쓰는 형태로만 남긴다. @immutable class PushNotice { const PushNotice({ required this.title, required this.body, required this.kind, this.fandomId, this.phase = '', this.targetPath, this.questId, this.campaignId, }); factory PushNotice.of(RemoteMessage message) { String? str(String key) => switch (message.data[key]?.toString()) { null || '' => null, final v => v, }; return PushNotice( title: message.notification?.title ?? '', body: message.notification?.body ?? '', kind: str('kind') ?? '', fandomId: str('fandomId'), phase: str('phase') ?? '', targetPath: str('targetPath'), questId: str('questId'), campaignId: str('campaignId'), ); } final String title; final String body; /// 서버가 실은 분기 키 — 팬덤 토픽 `briefing` · `eclipse` · `timeattack` · `evolution`, /// 알림함 개인 Push 는 그 항목의 `eventType`(`eclipse` · `item_used` · `quest` · `campaign` · `evolution`). final String kind; final String? fandomId; /// 이클립스 알림함 항목의 단계 — `start` · `defended`. 방어 성공은 위기가 아니라 결과다(N-9). final String phase; /// 알림함 항목이 실은 이동 경로(`quests/{id}`) · 타임어택 퀘스트 · 캠페인 정산 알림의 캠페인. final String? targetPath; final String? questId; final String? campaignId; /// 탭했을 때 먼저 갈 탭. 그 위에 [target] 화면을 연다. String get route => switch (kind) { 'eclipse' => '/galaxy', _ => '/home', }; /// 탭했을 때 열 화면 — UX·FX v1.5 8장 「Deep Link — 공유/Push 에서 진입한 Planet/Campaign/Briefing 의 /// 관련 화면으로」 · 9장 Eclipse 「탭 시 해당 Planet/Eclipse 로 Deep Link」 · HERO TIME 「해당 Planet 즉시 /// 참여 CTA」. 경로 표기는 알림함과 같다(`quests/{id}` · `campaigns/{id}`), 행성은 `planets/{id}`, /// 진행 중 이클립스는 `eclipse/{id}`. 모르면 null — 탭만 연다(브리핑은 HOME 블록이 그 화면이다). String? get target { if (targetPath case final p? when kind == 'quest' || kind == 'campaign') { return p; } return switch ((kind, fandomId)) { ('campaign', _) when campaignId != null => 'campaigns/$campaignId', ('timeattack', _) when questId != null => 'quests/$questId', ('eclipse', final f?) when phase != 'defended' => 'eclipse/$f', ('eclipse' || 'evolution' || 'item_used', final f?) => 'planets/$f', _ => null, }; } /// 표시할 내용이 있는가. 데이터 전용 메시지는 화면에 띄우지 않는다. bool get hasText => title.isNotEmpty || body.isNotEmpty; PushTier get tier => pushTierOf(kind, phase: phase); /// 같은 사건인가 — 문구가 아니라 사건으로 본다. 진화는 팬덤 토픽 Push 와 참여자 알림함 Push 가 /// 문구만 다르게 함께 온다(v1.5 §1.1 「동일 이벤트가 과도하게 중첩되지 않도록」). bool sameAs(PushNotice other) => kind == other.kind && fandomId == other.fandomId && phase == other.phase && target == other.target; /// HOME 라이브 띠가 같은 사건을 띄우고 있을 때의 키([liveTickerKey]). 방어 성공은 띠에 없다. String? get tickerKey => fandomId == null || phase == 'defended' ? null : liveTickerKey(kind, fandomId!); } /// 라이브 띠 · Push 가 같은 사건을 가리키는 키 — Push `kind` 표기 + 팬덤. String liveTickerKey(String kind, String fandomId) => '$kind:$fandomId'; /// 정책 원장 C-78 「Push 우선순위」 — ① 위기 ② 공동행동 ③ 성취 ④ 결과, 그 밖(단순 보상 유도형 포함)은 맨 뒤. enum PushTier { crisis, collective, achievement, result, other } /// 정책 원장 C-78 단계 배정 — Push 와 알림함이 같은 표를 쓴다. /// /// - 위기: 이클립스 사전 경고 · 발생 /// - 공동행동: TIME ATTACK 시작 — **임시값(해석 확인 Q-C-10 A안)** /// - 성취: Evolution · HERO TIME 발동(영웅의 선물, 알림함 `item_used`) /// - 결과: Quest 완료 · Campaign 정산 · 이클립스 **방어 성공**(통보 N-9) /// - 그 밖: AI 브리핑(**임시값 Q-C-10 A안** — 네 단계 예시에 없음) · 모르는 종류 PushTier pushTierOf(String kind, {String phase = ''}) => switch (kind) { 'eclipse' when phase == 'defended' => PushTier.result, 'eclipse' => PushTier.crisis, 'timeattack' => PushTier.collective, 'evolution' || 'item_used' => PushTier.achievement, 'quest' || 'campaign' => PushTier.result, _ => PushTier.other, }; /// 앱 사용 중 수신한 알림을 띄울지 — UX·FX v1.5 §1.1 「Push 와 인앱 티커는 동일 이벤트가 과도하게 /// 중첩되지 않도록 노출 빈도를 제어하고 핵심 이벤트는 우선순위를 높게」 · 9장 Eclipse 「앱 사용 중 동일 /// 내용의 과도한 중복 Push」 금지. /// /// [showing] 은 지금 떠 있는 알림. 같은 사건은 다시 띄우지 않고, 더 낮은 순위가 높은 순위를 덮지 않는다. /// [ticker] 는 지금 보이는 HOME 라이브 띠의 사건([liveTickerKey]) — 띠가 이미 알리는 사건은 띄우지 않는다. bool shouldShowInApp(PushNotice next, PushNotice? showing, {String? ticker}) => (ticker == null || next.tickerKey != ticker) && (showing == null || (!next.sameAs(showing) && next.tier.index <= showing.tier.index));