From 69db84f170b895ace122b68e1b54aff962237a2f Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Tue, 9 Jun 2026 09:48:23 +0200 Subject: [PATCH] feat(messaging,ios): add support for actionIdentifier on iOS devices --- .../FLTFirebaseMessagingPlugin.m | 38 ++++++++++++++++--- .../lib/src/remote_message.dart | 6 +++ .../test/remote_message_test.dart | 12 ++++++ 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/packages/firebase_messaging/firebase_messaging/ios/firebase_messaging/Sources/firebase_messaging/FLTFirebaseMessagingPlugin.m b/packages/firebase_messaging/firebase_messaging/ios/firebase_messaging/Sources/firebase_messaging/FLTFirebaseMessagingPlugin.m index a0eec7d0419f..77f2078991c6 100644 --- a/packages/firebase_messaging/firebase_messaging/ios/firebase_messaging/Sources/firebase_messaging/FLTFirebaseMessagingPlugin.m +++ b/packages/firebase_messaging/firebase_messaging/ios/firebase_messaging/Sources/firebase_messaging/FLTFirebaseMessagingPlugin.m @@ -226,6 +226,11 @@ - (void)messaging:(nonnull FIRMessaging *)messaging - (void)setupNotificationHandlingWithRemoteNotification: (nullable NSDictionary *)remoteNotification { + [self setupNotificationHandlingWithRemoteNotification:remoteNotification actionIdentifier:nil]; +} + +- (void)setupNotificationHandlingWithRemoteNotification:(nullable NSDictionary *)remoteNotification + actionIdentifier:(nullable NSString *)actionIdentifier { // If notification handling was already set up (e.g. from // application_onDidFinishLaunchingNotification) and we're called again (e.g. from // scene:willConnectToSession:), only process the notification but skip delegate/swizzler @@ -234,7 +239,8 @@ - (void)setupNotificationHandlingWithRemoteNotification: if (_notificationHandlingSetup) { if (remoteNotification != nil) { _initialNotification = - [FLTFirebaseMessagingPlugin remoteMessageUserInfoToDict:remoteNotification]; + [FLTFirebaseMessagingPlugin remoteMessageUserInfoToDict:remoteNotification + withActionIdentifier:actionIdentifier]; _initialNotificationID = remoteNotification[@"gcm.message_id"]; _initialNotificationGathered = YES; [self initialNotificationCallback]; @@ -255,7 +261,8 @@ - (void)setupNotificationHandlingWithRemoteNotification: if (remoteNotification != nil) { // If remoteNotification exists, it is the notification that opened the app. _initialNotification = - [FLTFirebaseMessagingPlugin remoteMessageUserInfoToDict:remoteNotification]; + [FLTFirebaseMessagingPlugin remoteMessageUserInfoToDict:remoteNotification + withActionIdentifier:actionIdentifier]; _initialNotificationID = remoteNotification[@"gcm.message_id"]; _initialNotificationGathered = YES; [self initialNotificationCallback]; @@ -458,12 +465,20 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center API_AVAILABLE(macos(10.14), ios(10.0)) { NSDictionary *remoteNotification = response.notification.request.content.userInfo; _notificationOpenedAppID = remoteNotification[@"gcm.message_id"]; + NSDictionary *notificationDict = + [FLTFirebaseMessagingPlugin remoteMessageUserInfoToDict:remoteNotification + withActionIdentifier:response.actionIdentifier]; + + if (_initialNotification != nil && + [_initialNotificationID isEqualToString:_notificationOpenedAppID]) { + _initialNotification = notificationDict; + [self initialNotificationCallback]; + } + // We only want to handle FCM notifications and stop firing `onMessageOpenedApp()` when app is // coming from a terminated state. if (_notificationOpenedAppID != nil && ![_initialNotificationID isEqualToString:_notificationOpenedAppID]) { - NSDictionary *notificationDict = - [FLTFirebaseMessagingPlugin remoteMessageUserInfoToDict:remoteNotification]; [_channel invokeMethod:@"Messaging#onMessageOpenedApp" arguments:notificationDict]; } @@ -642,14 +657,17 @@ - (void)scene:(UIScene *)scene _sceneDidConnect = YES; NSDictionary *remoteNotification = nil; + NSString *actionIdentifier = nil; if (connectionOptions.notificationResponse != nil) { // User tapped the notification. remoteNotification = connectionOptions.notificationResponse.notification.request.content.userInfo; + actionIdentifier = connectionOptions.notificationResponse.actionIdentifier; _notificationOpenedAppID = remoteNotification[@"gcm.message_id"]; } - [self setupNotificationHandlingWithRemoteNotification:remoteNotification]; + [self setupNotificationHandlingWithRemoteNotification:remoteNotification + actionIdentifier:actionIdentifier]; } #endif @@ -960,11 +978,21 @@ + (NSDictionary *)NSDictionaryFromUNNotification:(UNNotification *)notification } + (NSDictionary *)remoteMessageUserInfoToDict:(NSDictionary *)userInfo { + return [self remoteMessageUserInfoToDict:userInfo withActionIdentifier:nil]; +} + ++ (NSDictionary *)remoteMessageUserInfoToDict:(NSDictionary *)userInfo + withActionIdentifier:(nullable NSString *)actionIdentifier { NSMutableDictionary *message = [[NSMutableDictionary alloc] init]; NSMutableDictionary *data = [[NSMutableDictionary alloc] init]; NSMutableDictionary *notification = [[NSMutableDictionary alloc] init]; NSMutableDictionary *notificationIOS = [[NSMutableDictionary alloc] init]; + // message.actionIdentifier + if (actionIdentifier != nil) { + message[@"actionIdentifier"] = actionIdentifier; + } + // message.data for (id key in userInfo) { // message.messageId diff --git a/packages/firebase_messaging/firebase_messaging_platform_interface/lib/src/remote_message.dart b/packages/firebase_messaging/firebase_messaging_platform_interface/lib/src/remote_message.dart index cc596a496ba0..1b39fad8d387 100644 --- a/packages/firebase_messaging/firebase_messaging_platform_interface/lib/src/remote_message.dart +++ b/packages/firebase_messaging/firebase_messaging_platform_interface/lib/src/remote_message.dart @@ -11,6 +11,7 @@ class RemoteMessage { const RemoteMessage( {this.senderId, this.category, + this.actionIdentifier, this.collapseKey, this.contentAvailable = false, this.data = const {}, @@ -28,6 +29,7 @@ class RemoteMessage { return RemoteMessage( senderId: map['senderId'], category: map['category'], + actionIdentifier: map['actionIdentifier'], collapseKey: map['collapseKey'], contentAvailable: map['contentAvailable'] ?? false, data: map['data'] == null @@ -57,6 +59,7 @@ class RemoteMessage { return { 'senderId': senderId, 'category': category, + 'actionIdentifier': actionIdentifier, 'collapseKey': collapseKey, 'contentAvailable': contentAvailable, 'data': data, @@ -77,6 +80,9 @@ class RemoteMessage { /// The iOS category this notification is assigned to. final String? category; + /// The Apple notification action identifier used to open the app. + final String? actionIdentifier; + /// The collapse key a message was sent with. Used to override existing messages with the same key. final String? collapseKey; diff --git a/packages/firebase_messaging/firebase_messaging_platform_interface/test/remote_message_test.dart b/packages/firebase_messaging/firebase_messaging_platform_interface/test/remote_message_test.dart index 0868d2eab3dd..b676e3c1ea0a 100644 --- a/packages/firebase_messaging/firebase_messaging_platform_interface/test/remote_message_test.dart +++ b/packages/firebase_messaging/firebase_messaging_platform_interface/test/remote_message_test.dart @@ -16,6 +16,7 @@ void main() { mockMessageMap = { 'senderId': 'senderId', 'category': 'category', + 'actionIdentifier': 'actionIdentifier', 'collapseKey': 'collapseKey', 'contentAvailable': true, 'data': { @@ -38,6 +39,7 @@ void main() { mockNullableMessageMap = { 'senderId': null, 'category': null, + 'actionIdentifier': null, 'collapseKey': null, 'data': null, 'from': null, @@ -55,6 +57,7 @@ void main() { expect(message.senderId, mockMessageMap!['senderId']); expect(message.category, mockMessageMap!['category']); + expect(message.actionIdentifier, mockMessageMap!['actionIdentifier']); expect(message.collapseKey, mockMessageMap!['collapseKey']); expect(message.contentAvailable, mockMessageMap!['contentAvailable']); expect(message.data, mockMessageMap!['data']); @@ -85,6 +88,8 @@ void main() { expect(message.senderId, mockNullableMessageMap['senderId']); expect(message.category, mockNullableMessageMap['category']); + expect( + message.actionIdentifier, mockNullableMessageMap['actionIdentifier']); expect(message.collapseKey, mockNullableMessageMap['collapseKey']); expect(message.contentAvailable, false); expect(message.data, {}); @@ -105,6 +110,7 @@ void main() { final message = RemoteMessage( senderId: mockMessageMap!['senderId'], category: mockMessageMap!['category'], + actionIdentifier: mockMessageMap!['actionIdentifier'], collapseKey: mockMessageMap!['collapseKey'], contentAvailable: mockMessageMap!['contentAvailable'], data: mockMessageMap!['data'], @@ -120,6 +126,7 @@ void main() { expect(message.senderId, mockMessageMap!['senderId']); expect(message.category, mockMessageMap!['category']); + expect(message.actionIdentifier, mockMessageMap!['actionIdentifier']); expect(message.collapseKey, mockMessageMap!['collapseKey']); expect(message.contentAvailable, mockMessageMap!['contentAvailable']); expect(message.data, mockMessageMap!['data']); @@ -141,6 +148,7 @@ void main() { mockNullableMessageMap = { 'senderId': null, 'category': null, + 'actionIdentifier': null, 'collapseKey': null, 'data': null, 'from': null, @@ -156,6 +164,8 @@ void main() { expect(message.senderId, mockNullableMessageMap['senderId']); expect(message.category, mockNullableMessageMap['category']); + expect( + message.actionIdentifier, mockNullableMessageMap['actionIdentifier']); expect(message.collapseKey, mockNullableMessageMap['collapseKey']); expect(message.contentAvailable, false); expect(message.data, {}); @@ -173,6 +183,7 @@ void main() { final RemoteMessage remoteMessage = RemoteMessage( senderId: 'senderId', category: 'category', + actionIdentifier: 'actionIdentifier', collapseKey: 'collapseKey', contentAvailable: true, data: {}, @@ -193,6 +204,7 @@ void main() { expect(map['senderId'], remoteMessage.senderId); expect(map['category'], remoteMessage.category); + expect(map['actionIdentifier'], remoteMessage.actionIdentifier); expect(map['collapseKey'], remoteMessage.collapseKey); expect(map['contentAvailable'], remoteMessage.contentAvailable); expect(map['data'], remoteMessage.data);