Flutter Web foreground 알림 흐름을 이어가기 위해 permission API와 payload mapper를 패키지 내부 계약으로 고정한다.
275 lines
8.5 KiB
Dart
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;
|
|
}
|
|
}
|
|
}
|