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 { 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 { 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)` 로 막으므로 인증이 끊긴 뒤에는 토큰을 지울 수 없다. 그대로 두면 /// 다음 사용자가 그 기기를 쓸 때 이전 계정의 알림이 계속 내려간다. /// /// 토픽 해지를 먼저 하는 이유는 실패 순서다. 토큰 삭제가 실패해도 구독은 끊겨 /// 알림이 멈춘다. 반대로 두면 토큰만 지워진 채 토픽 알림이 계속 온다. Future clearOnSignOut() async { // 해제는 한꺼번에 보낸다. 가입 행성 × 구분 수만큼 차례로 기다리면 그것만으로 // 로그아웃이 수 초 걸린다. await Future.wait([ for (final fandomId in await _resolveFandomIds()) for (final topic in PushTopic.values) _transport.unsubscribeFromTopic(topic.topicFor(fandomId)), ]); 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; } 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, }); factory PushNotice.of(RemoteMessage message) => PushNotice( title: message.notification?.title ?? '', body: message.notification?.body ?? '', kind: message.data['kind']?.toString() ?? '', fandomId: message.data['fandomId']?.toString(), ); final String title; final String body; /// 서버가 실은 분기 키 — `briefing` · `eclipse`. final String kind; final String? fandomId; /// 탭했을 때 열 경로. /// /// 브리핑 상세 화면은 아직 라우트가 없어 홈의 브리핑 블록으로 보낸다. 이클립스는 /// 갤럭시에서 행성으로 들어가는 동선이 이미 있다. String get route => switch (kind) { 'eclipse' => '/galaxy', _ => '/home', }; /// 표시할 내용이 있는가. 데이터 전용 메시지는 화면에 띄우지 않는다. bool get hasText => title.isNotEmpty || body.isNotEmpty; }