nexo/packages/messaging_flutter/lib/src/nexo_messaging_plugin.dart
toki ea77708c4f feat(messaging): Web 알림 전경 계약을 추가한다
Flutter Web foreground 알림 흐름을 이어가기 위해 permission API와 payload mapper를 패키지 내부 계약으로 고정한다.
2026-06-07 15:08:40 +09:00

275 lines
8.5 KiB
Dart

import 'dart:async';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'nexo_messaging_options.dart';
import 'notification_opened_event.dart';
import 'push_notification_type.dart';
import 'web_notification_permission.dart';
import 'web_notification_permission_client.dart';
import 'web_notification_permission_client_factory.dart';
const String _kNotificationChannelName =
'com.tokilabs.nexo.messaging/notifications';
const String _kActionChannelName =
'com.tokilabs.nexo.messaging/notification_actions';
class NexoMessagingPlugin {
NexoMessagingPlugin._();
static final NexoMessagingPlugin instance = NexoMessagingPlugin._();
@visibleForTesting
static const String notificationChannelName = _kNotificationChannelName;
@visibleForTesting
static const String actionChannelName = _kActionChannelName;
static const EventChannel _notificationChannel = EventChannel(
_kNotificationChannelName,
);
static const MethodChannel _actionChannel = MethodChannel(
_kActionChannelName,
);
StreamSubscription? _channelSubscription;
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;
final WebNotificationPermissionClient _webPermissionClient =
createWebNotificationPermissionClient();
void Function(String serverUrl, String channelId)? onNavigateToChannel;
void Function(String serverUrl, String rootId)? onNavigateToThread;
void Function(String deviceToken)? onDeviceTokenReady;
/// Initialize the messaging plugin.
///
/// By default ([options] unchanged) the plugin subscribes to native events,
/// listens for FCM token refreshes, requests notification permission, and
/// registers the device token — matching the pre-Version-0.0.2 behaviour.
///
/// Pass [NexoMessagingInitializeOptions] to disable any of those steps.
Future<void> initialize({
NexoMessagingInitializeOptions options =
const NexoMessagingInitializeOptions(),
}) async {
_listenNativeChannel();
if (options.listenForTokenRefresh) {
_listenFcmTokenRefresh();
}
if (options.requestNotificationPermission) {
await _requestPermission();
}
if (options.registerDeviceToken) {
await _saveCurrentDeviceToken();
}
}
void dispose() {
_channelSubscription?.cancel();
_notificationController.close();
_openedController.close();
}
void _listenNativeChannel() {
if (_channelSubscription != null) return;
_channelSubscription = _notificationChannel.receiveBroadcastStream().listen(
(dynamic event) {
if (event is Map) {
handleNativeEvent(Map<String, dynamic>.from(event));
}
},
onError: (error) =>
debugPrint('[PushNotification] EventChannel error: $error'),
);
}
@visibleForTesting
void listenNativeChannelForTesting() {
_listenNativeChannel();
}
@visibleForTesting
Future<void> debugSendNativeEventForTesting(Map<String, dynamic> data) async {
await _actionChannel.invokeMethod('debugSendNativeEvent', data);
}
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,
);
debugPrint(
'[PushNotification] Permission: ${settings.authorizationStatus}',
);
}
/// Returns whether browser notifications are supported and the current
/// browser permission state.
///
/// On non-web platforms this returns an unsupported status without touching
/// native notification permission APIs.
Future<NexoMessagingWebNotificationStatus>
getWebNotificationPermissionStatus() {
return _webPermissionClient.currentStatus();
}
/// Requests browser notification permission when running on Flutter Web.
///
/// On non-web platforms this returns an unsupported status without prompting
/// the user or touching native notification permission APIs.
Future<NexoMessagingWebNotificationStatus>
requestWebNotificationPermission() {
return _webPermissionClient.requestPermission();
}
/// Reads the current FCM/APNs token and saves it via [_saveDeviceToken].
/// Called during [initialize] when [NexoMessagingInitializeOptions.registerDeviceToken] is `true`.
Future<void> _saveCurrentDeviceToken() async {
final token = await FirebaseMessaging.instance.getToken();
if (token != null) await _saveDeviceToken(token);
}
@visibleForTesting
void handleNativeEvent(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);
}
}
void _handleClearNotification(Map<String, dynamic> data) {
debugPrint('[PushNotification] Clear: channelId=${data['channel_id']}');
}
void _handleSessionNotification(Map<String, dynamic> data) {
debugPrint(
'[PushNotification] Session expired: serverUrl=${data['server_url']}',
);
}
void _handleNotificationOpened(Map<String, dynamic> data) {
final event = NotificationOpenedEvent.fromMap(data);
debugPrint(
'[PushNotification] Opened: channelId=${event.channelId} rootId=${event.rootId}',
);
if (!_openedController.isClosed) {
_openedController.add(event);
}
final serverUrl = event.serverUrl;
if (serverUrl == null) return;
if (event.isCRTEnabled &&
event.rootId != null &&
event.rootId!.isNotEmpty) {
onNavigateToThread?.call(serverUrl, event.rootId!);
} else if (event.channelId != null) {
onNavigateToChannel?.call(serverUrl, event.channelId!);
}
}
Future<void> setAuthToken(
String serverUrl,
String token, {
String? identifier,
}) async {
try {
await _actionChannel.invokeMethod('setAuthToken', {
'serverUrl': serverUrl,
'token': token,
'identifier': identifier,
});
debugPrint('[PushNotification] Auth token saved for $serverUrl');
} catch (e) {
debugPrint('[PushNotification] Failed to save auth token: $e');
}
}
Future<void> clearAuthToken(String serverUrl) async {
try {
await _actionChannel.invokeMethod('clearAuthToken', {
'serverUrl': serverUrl,
});
} catch (e) {
debugPrint('[PushNotification] Failed to clear auth token: $e');
}
}
Future<void> setSigningKey(String serverUrl, String signingKey) async {
try {
await _actionChannel.invokeMethod('setSigningKey', {
'serverUrl': serverUrl,
'signingKey': signingKey,
});
} catch (e) {
debugPrint('[PushNotification] Failed to save signing key: $e');
}
}
Future<void> _saveDeviceToken(String token) async {
try {
final formattedToken = '${NexoMessagingTokenPrefix.androidV2}:$token';
await _actionChannel.invokeMethod('saveDeviceToken', {
'token': formattedToken,
});
debugPrint('[PushNotification] Device token saved');
onDeviceTokenReady?.call(formattedToken);
} catch (e) {
debugPrint('[PushNotification] Failed to save device token: $e');
}
}
Future<String?> getDeviceToken() async {
try {
return await _actionChannel.invokeMethod<String>('getDeviceToken');
} catch (e) {
return null;
}
}
}