250 lines
8.5 KiB
Dart
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;
|
|
}
|
|
}
|
|
}
|