382 lines
17 KiB
Dart
382 lines
17 KiB
Dart
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<RemoteMessage>? _foreground;
|
|
StreamSubscription<RemoteMessage>? _opened;
|
|
StreamSubscription<String>? _tokenRefresh;
|
|
|
|
/// 가입한 팬덤 전부. 토픽 이름의 일부라 매번 읽지 않도록 기억한다.
|
|
Set<String>? _fandomIds;
|
|
|
|
/// 알림 권한 상태. 화면이 「거부됨」 안내와 재요청 버튼을 그릴 근거다.
|
|
AuthorizationStatus _status = AuthorizationStatus.notDetermined;
|
|
AuthorizationStatus get status => _status;
|
|
|
|
bool get isAuthorized => _status.isAllowed;
|
|
|
|
/// 알림 권한 요청.
|
|
///
|
|
/// Android 13+ 의 `POST_NOTIFICATIONS` 런타임 권한도 이 호출이 띄운다 —
|
|
/// 플러그인이 매니페스트에 권한을 선언하고 `requestPermission` 이 시스템
|
|
/// 다이얼로그로 이어진다. 별도 권한 플러그인을 넣지 않은 이유가 이것이다.
|
|
Future<AuthorizationStatus> requestPermission() async {
|
|
_status = await _transport.requestPermission();
|
|
return _status;
|
|
}
|
|
|
|
/// 현재 권한 상태만 읽는다. 다이얼로그를 띄우지 않으므로 화면 진입 시 쓴다.
|
|
Future<AuthorizationStatus> refreshStatus() async {
|
|
_status = await _transport.currentStatus();
|
|
return _status;
|
|
}
|
|
|
|
/// 단말 토큰을 `users/{uid}.fcmTokens` 에 더한다.
|
|
///
|
|
/// 배열로 두는 이유는 한 계정이 여러 단말을 쓰기 때문이다. 단일 필드면 새 단말
|
|
/// 로그인이 이전 단말의 토큰을 지우고, 그 단말은 조용히 알림을 못 받는다.
|
|
/// 토큰 재발급도 같은 경로로 흘러 들어온다([listen] 의 `onTokenRefresh`).
|
|
///
|
|
/// iOS 는 APNs 키 전까지 토큰이 null 이다 — 예외가 아니라 정상이다.
|
|
Future<String?> syncToken() async {
|
|
await _awaitPendingClear();
|
|
final token = await _transport.getToken();
|
|
if (token == null || token.isEmpty) return null;
|
|
await _saveToken(token);
|
|
return token;
|
|
}
|
|
|
|
Future<void> _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<Set<String>> _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 <String, dynamic>{}).strOr('fandomId'),
|
|
for (final d in memberships.docs) d.id,
|
|
}..remove('');
|
|
if (ids.isNotEmpty) _fandomIds = ids;
|
|
return ids;
|
|
}
|
|
|
|
/// 설정대로 토픽을 맞춘다.
|
|
///
|
|
/// **구독 상태를 앱이 기억하지 않는다.** 켜진 구분은 구독을, 꺼진 구분은 해지를
|
|
/// 조건 없이 부른다. FCM 의 두 호출은 멱등이라 이미 그 상태여도 문제가 없고,
|
|
/// 반대로 앱이 기억한 상태를 믿으면 재설치·토큰 재발급·다른 기기 로그인처럼
|
|
/// 앱이 모르는 사이에 실제 구독이 달라진 경우를 되돌릴 방법이 없다.
|
|
///
|
|
/// 팬덤 ID 는 사용자 문서에서 스스로 읽는다. 호출자가 넘기게 하면 설정 화면이
|
|
/// 그 값을 알아야 하고, 화면이 알 이유가 없다.
|
|
Future<void> syncTopics(Map<PushTopic, bool> 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<void> 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<void> clearOnSignOut() async {
|
|
late final Future<void> 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<void>? _pendingClear;
|
|
|
|
/// 새 구독 전에 남은 정리를 기다린다. 멈춘 정리가 로그인을 막지 않도록 상한을 둔다.
|
|
Future<void> _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<void> _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<void> _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<void> 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<String> subscribe;
|
|
final Set<String> unsubscribe;
|
|
}
|
|
|
|
/// 설정에서 계획을 낸다. 모든 구분이 둘 중 한쪽에 들어간다.
|
|
///
|
|
/// 플러그인을 부르지 않는 순수 함수로 떼어 둔 이유는 검증이다. 「Marketing 을 끈
|
|
/// 상태로는 그 토픽을 구독하지 않는다」가 정책 검증 기준이고, 그 판정을 실제 FCM
|
|
/// 없이 확인할 수 있어야 한다.
|
|
TopicPlan topicPlan(String fandomId, Map<PushTopic, bool> prefs) {
|
|
final subscribe = <String>{};
|
|
final unsubscribe = <String>{};
|
|
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));
|