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> _notificationController = StreamController>.broadcast(); Stream> get onNotification => _notificationController.stream; /// 알림 탭 스트림 (채널/스레드 네비게이션용) final StreamController _openedController = StreamController.broadcast(); Stream 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.from(event)); } }, onError: (error) => print('[PushNotification] EventChannel error: $error'), ); } void _listenFcmTokenRefresh() { FirebaseMessaging.instance.onTokenRefresh.listen((token) async { await _saveDeviceToken(token); }); } Future _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 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 data) { final isUserInteraction = data['userInteraction'] == true; if (isUserInteraction) { _handleNotificationOpened(data); } // 포그라운드 메시지는 onNotification 스트림으로 UI에서 처리 } void _handleClearNotification(Map data) { print('[PushNotification] Clear: channelId=${data['channel_id']}'); // TODO: 뱃지 카운트 초기화, 로컬 알림 상태 초기화 } void _handleSessionNotification(Map data) { print('[PushNotification] Session expired: serverUrl=${data['server_url']}'); // TODO: 해당 서버 로그아웃 처리 // clearAuthToken(data['server_url']) } /// 알림 탭 처리 - 채널 또는 스레드로 이동. /// mattermost-mobile의 openNotification() + pushNotificationEntry() 대응. void _handleNotificationOpened(Map 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 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 clearAuthToken(String serverUrl) async { try { await _actionChannel.invokeMethod('clearAuthToken', {'serverUrl': serverUrl}); } catch (e) { print('[PushNotification] Failed to clear auth token: $e'); } } /// 서버의 JWT 서명 검증용 공개키를 네이티브에 저장. Future 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 _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 getDeviceToken() async { try { return await _actionChannel.invokeMethod('getDeviceToken'); } catch (e) { return null; } } }