nomadcode/apps/mobile/lib/services/push_notification_service.dart
toki 9212ed02ac chore: import nomadcode-app into apps/mobile
git-subtree-dir: apps/mobile
git-subtree-mainline: 11490df648
git-subtree-split: c7bc4fea56
2026-05-21 13:35:24 +09:00

250 lines
8.5 KiB
Dart

import 'dart:async';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/services.dart';
/// 알림 타입 상수.
class PushNotificationType {
static const String message = 'message';
static const String clear = 'clear';
static const String session = 'session';
static const String tokenRefresh = 'token_refresh';
static const String opened = 'opened';
}
/// 알림 탭 이벤트 데이터.
class NotificationOpenedEvent {
final String? serverUrl;
final String? channelId;
final String? rootId;
final bool isCRTEnabled;
const NotificationOpenedEvent({
this.serverUrl,
this.channelId,
this.rootId,
this.isCRTEnabled = false,
});
}
/// 푸시 알림 싱글톤 서비스.
/// mattermost-mobile의 push_notifications.ts 싱글톤 대응.
class PushNotificationService {
static final PushNotificationService _instance =
PushNotificationService._internal();
factory PushNotificationService() => _instance;
PushNotificationService._internal();
static const EventChannel _notificationChannel =
EventChannel('com.tokilabs.mattermost/notifications');
static const MethodChannel _actionChannel =
MethodChannel('com.tokilabs.mattermost/notification_actions');
StreamSubscription? _channelSubscription;
/// 알림 수신 스트림 (포그라운드 메시지 등 UI에서 구독)
final StreamController<Map<String, dynamic>> _notificationController =
StreamController<Map<String, dynamic>>.broadcast();
Stream<Map<String, dynamic>> get onNotification =>
_notificationController.stream;
/// 알림 탭 스트림 (채널/스레드 네비게이션용)
final StreamController<NotificationOpenedEvent> _openedController =
StreamController<NotificationOpenedEvent>.broadcast();
Stream<NotificationOpenedEvent> get onNotificationOpened =>
_openedController.stream;
// 네비게이션 콜백 (앱 라우터에서 등록)
void Function(String serverUrl, String channelId)? onNavigateToChannel;
void Function(String serverUrl, String rootId)? onNavigateToThread;
// FCM 토큰 발급/갱신 시 서버 등록 콜백
void Function(String deviceToken)? onDeviceTokenReady;
// ─── 초기화 / 해제 ────────────────────────────────────────────────────
void init() {
_listenNativeChannel();
_listenFcmTokenRefresh();
_requestPermission();
}
void dispose() {
_channelSubscription?.cancel();
_notificationController.close();
_openedController.close();
}
// ─── 내부 초기화 ─────────────────────────────────────────────────────
void _listenNativeChannel() {
_channelSubscription = _notificationChannel
.receiveBroadcastStream()
.listen(
(dynamic event) {
if (event is Map) {
_processNotification(Map<String, dynamic>.from(event));
}
},
onError: (error) => print('[PushNotification] EventChannel error: $error'),
);
}
void _listenFcmTokenRefresh() {
FirebaseMessaging.instance.onTokenRefresh.listen((token) async {
await _saveDeviceToken(token);
});
}
Future<void> _requestPermission() async {
final settings = await FirebaseMessaging.instance.requestPermission(
alert: true,
badge: true,
sound: true,
);
print('[PushNotification] Permission: ${settings.authorizationStatus}');
final token = await FirebaseMessaging.instance.getToken();
if (token != null) await _saveDeviceToken(token);
}
// ─── 알림 처리 ───────────────────────────────────────────────────────
void _processNotification(Map<String, dynamic> data) {
final type = data['type'] as String?;
switch (type) {
case PushNotificationType.message:
_handleMessageNotification(data);
break;
case PushNotificationType.clear:
_handleClearNotification(data);
break;
case PushNotificationType.session:
_handleSessionNotification(data);
break;
case PushNotificationType.tokenRefresh:
final token = data['token'] as String?;
if (token != null) _saveDeviceToken(token);
break;
case PushNotificationType.opened:
_handleNotificationOpened(data);
break;
}
if (!_notificationController.isClosed) {
_notificationController.add(data);
}
}
void _handleMessageNotification(Map<String, dynamic> data) {
final isUserInteraction = data['userInteraction'] == true;
if (isUserInteraction) {
_handleNotificationOpened(data);
}
// 포그라운드 메시지는 onNotification 스트림으로 UI에서 처리
}
void _handleClearNotification(Map<String, dynamic> data) {
print('[PushNotification] Clear: channelId=${data['channel_id']}');
// TODO: 뱃지 카운트 초기화, 로컬 알림 상태 초기화
}
void _handleSessionNotification(Map<String, dynamic> data) {
print('[PushNotification] Session expired: serverUrl=${data['server_url']}');
// TODO: 해당 서버 로그아웃 처리
// clearAuthToken(data['server_url'])
}
/// 알림 탭 처리 - 채널 또는 스레드로 이동.
/// mattermost-mobile의 openNotification() + pushNotificationEntry() 대응.
void _handleNotificationOpened(Map<String, dynamic> data) {
final serverUrl = data['server_url'] as String?;
final channelId = data['channel_id'] as String?;
final rootId = data['root_id'] as String?;
final isCRTEnabled = data['is_crt_enabled'] == 'true';
print('[PushNotification] Opened: channelId=$channelId rootId=$rootId');
final event = NotificationOpenedEvent(
serverUrl: serverUrl,
channelId: channelId,
rootId: rootId,
isCRTEnabled: isCRTEnabled,
);
if (!_openedController.isClosed) {
_openedController.add(event);
}
// 등록된 네비게이션 콜백 호출
if (serverUrl == null) return;
if (isCRTEnabled && rootId != null && rootId.isNotEmpty) {
onNavigateToThread?.call(serverUrl, rootId);
} else if (channelId != null) {
onNavigateToChannel?.call(serverUrl, channelId);
}
}
// ─── 인증 토큰 관리 ──────────────────────────────────────────────────
/// 로그인 성공 시 호출. 서버 URL + Bearer 토큰을 네이티브에 저장.
/// 이후 ReceiptDelivery(ACK), NotificationReplyBroadcastReceiver(답장) API 호출에 사용.
Future<void> setAuthToken(String serverUrl, String token,
{String? identifier}) async {
try {
await _actionChannel.invokeMethod('setAuthToken', {
'serverUrl': serverUrl,
'token': token,
'identifier': identifier,
});
print('[PushNotification] Auth token saved for $serverUrl');
} catch (e) {
print('[PushNotification] Failed to save auth token: $e');
}
}
/// 로그아웃 시 호출.
Future<void> clearAuthToken(String serverUrl) async {
try {
await _actionChannel.invokeMethod('clearAuthToken', {'serverUrl': serverUrl});
} catch (e) {
print('[PushNotification] Failed to clear auth token: $e');
}
}
/// 서버의 JWT 서명 검증용 공개키를 네이티브에 저장.
Future<void> setSigningKey(String serverUrl, String signingKey) async {
try {
await _actionChannel.invokeMethod('setSigningKey', {
'serverUrl': serverUrl,
'signingKey': signingKey,
});
} catch (e) {
print('[PushNotification] Failed to save signing key: $e');
}
}
/// FCM 디바이스 토큰 저장 (네이티브 DB에 android_rn-v2:{token} 형식으로 저장).
Future<void> _saveDeviceToken(String token) async {
try {
const prefix = 'android_rn';
final formattedToken = '$prefix-v2:$token';
await _actionChannel
.invokeMethod('saveDeviceToken', {'token': formattedToken});
print('[PushNotification] Device token saved');
// 토큰 준비 완료 → 서버 등록 콜백 호출
onDeviceTokenReady?.call(formattedToken);
} catch (e) {
print('[PushNotification] Failed to save device token: $e');
}
}
Future<String?> getDeviceToken() async {
try {
return await _actionChannel.invokeMethod<String>('getDeviceToken');
} catch (e) {
return null;
}
}
}