diff --git a/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseMessagingReceiver.java b/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseMessagingReceiver.java index 67bd113e9325..610c12ba30f0 100644 --- a/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseMessagingReceiver.java +++ b/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseMessagingReceiver.java @@ -16,6 +16,7 @@ public class FlutterFirebaseMessagingReceiver extends BroadcastReceiver { private static final String TAG = "FLTFireMsgReceiver"; + private static final String EXTRA_WRAPPED_INTENT = "wrapped_intent"; static HashMap notifications = new HashMap<>(); // SharedPreferences writes and process-state lookups must not run on the main @@ -44,6 +45,11 @@ public void onReceive(Context context, Intent intent) { return; } + if (isNotificationEvent(intent)) { + Log.d(TAG, "broadcast received for a notification event, not a message. Ignored."); + return; + } + RemoteMessage remoteMessage = new RemoteMessage(intent.getExtras()); // Keep the in-memory message available to the plugin on the main thread; it is @@ -98,4 +104,14 @@ public void onReceive(Context context, Intent intent) { } }); } + + // Whether the broadcast reports an event of a notification the Firebase SDK displayed itself, + // such as the user dismissing it, rather than a message. firebase-messaging sends those events + // with the action messages arrive with, the event's own intent in the "wrapped_intent" extra. + // Its own receiver, CloudMessagingReceiver, checks for that extra first and never handles such a + // broadcast as a message; read as one, it has no ID and nothing in it. + @SuppressWarnings("deprecation") // getParcelableExtra(String, Class) needs API 33. + static boolean isNotificationEvent(Intent intent) { + return intent.getParcelableExtra(EXTRA_WRAPPED_INTENT) instanceof Intent; + } } diff --git a/packages/firebase_messaging/firebase_messaging/android/src/test/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseMessagingReceiverTest.java b/packages/firebase_messaging/firebase_messaging/android/src/test/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseMessagingReceiverTest.java new file mode 100644 index 000000000000..d47789dc5b74 --- /dev/null +++ b/packages/firebase_messaging/firebase_messaging/android/src/test/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseMessagingReceiverTest.java @@ -0,0 +1,47 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +package io.flutter.plugins.firebase.messaging; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.content.Intent; +import android.os.Bundle; +import android.os.Parcelable; +import org.junit.Test; + +public class FlutterFirebaseMessagingReceiverTest { + // A broadcast whose "wrapped_intent" extra is `wrapped`. The android.jar of unit tests keeps no + // extras, so the broadcast answers the one the receiver reads itself. + private static Intent broadcastWrapping(final Parcelable wrapped) { + return new Intent() { + @Override + @SuppressWarnings({"deprecation", "unchecked"}) + public T getParcelableExtra(String name) { + return "wrapped_intent".equals(name) ? (T) wrapped : null; + } + }; + } + + @Test + public void aDismissedNotificationIsANotificationEvent() { + // What firebase-messaging broadcasts when the user dismisses a notification it displayed. + Intent dismissal = + broadcastWrapping(new Intent("com.google.firebase.messaging.NOTIFICATION_DISMISS")); + + assertTrue(FlutterFirebaseMessagingReceiver.isNotificationEvent(dismissal)); + } + + @Test + public void aMessageIsNotANotificationEvent() { + assertFalse(FlutterFirebaseMessagingReceiver.isNotificationEvent(broadcastWrapping(null))); + } + + @Test + public void aWrappedExtraThatIsNotAnIntentIsNotANotificationEvent() { + assertFalse( + FlutterFirebaseMessagingReceiver.isNotificationEvent(broadcastWrapping(new Bundle()))); + } +} diff --git a/packages/firebase_messaging/firebase_messaging/example/android/app/src/main/kotlin/io/flutter/plugins/firebase/messaging/example/MainActivity.kt b/packages/firebase_messaging/firebase_messaging/example/android/app/src/main/kotlin/io/flutter/plugins/firebase/messaging/example/MainActivity.kt index 933b0e44dfed..33e47f71a7b0 100644 --- a/packages/firebase_messaging/firebase_messaging/example/android/app/src/main/kotlin/io/flutter/plugins/firebase/messaging/example/MainActivity.kt +++ b/packages/firebase_messaging/firebase_messaging/example/android/app/src/main/kotlin/io/flutter/plugins/firebase/messaging/example/MainActivity.kt @@ -55,6 +55,9 @@ class MainActivity : FlutterActivity() { } val foreground = isAppInForeground() val receiver = FlutterFirebaseMessagingReceiver() + if (call.argument("dismissalFirst") == true) { + receiver.onReceive(applicationContext, notificationDismissal()) + } for (messageId in messageIds) { val intent = Intent("com.google.android.c2dm.intent.RECEIVE").apply { @@ -165,6 +168,15 @@ class MainActivity : FlutterActivity() { return state.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND } + /// What firebase-messaging broadcasts when the user dismisses a notification + /// it displayed (the notification's delete intent): the NOTIFICATION_DISMISS + /// intent, wrapped in a broadcast with the action messages arrive with. + private fun notificationDismissal(): Intent = + Intent("com.google.android.c2dm.intent.RECEIVE").apply { + setPackage(packageName) + putExtra("wrapped_intent", Intent("com.google.firebase.messaging.NOTIFICATION_DISMISS")) + } + /// Starts another engine running `secondEngineMain` from the test, which /// listens to onMessage and reports what it receives. The messaging plugin /// is attached once its Dart runs, like an engine created after messages diff --git a/packages/firebase_messaging/firebase_messaging/example/integration_test/remote_notification_delivery.dart b/packages/firebase_messaging/firebase_messaging/example/integration_test/remote_notification_delivery.dart index 6c0b9afe3a15..c37108f48e39 100644 --- a/packages/firebase_messaging/firebase_messaging/example/integration_test/remote_notification_delivery.dart +++ b/packages/firebase_messaging/firebase_messaging/example/integration_test/remote_notification_delivery.dart @@ -40,12 +40,18 @@ Future> deliverRemoteNotification( } /// Hands data messages to the plugin's broadcast receiver back to back, like -/// Google Play services does when several pushes arrive at once. Fails if the +/// Google Play services does when several pushes arrive at once. With +/// [dismissalFirst], the receiver first gets the broadcast firebase-messaging +/// sends when the user dismisses a notification it displayed. Fails if the /// app did not count as in the foreground, where messages go to `onMessage`. -Future deliverToAndroidReceiver(List messageIds) async { +Future deliverToAndroidReceiver( + List messageIds, { + bool dismissalFirst = false, +}) async { final delivery = await _remoteNotificationsChannel .invokeMapMethod('deliverToReceiver', { 'messageIds': messageIds, + 'dismissalFirst': dismissalFirst, }); expect( delivery!['foreground'], @@ -251,6 +257,25 @@ void remoteNotificationDeliveryTests() { ); }); + test('a dismissed notification does not reach onMessage', () async { + // When the user dismisses a notification it displayed, firebase-messaging + // sends a broadcast with the action messages arrive with, the event + // wrapped inside. Read as a message, it has no ID and nothing in it. + final messageId = _uniqueMessageId('after-dismissal'); + final received = []; + final subscription = FirebaseMessaging.onMessage + .where((m) => m.messageId == null || m.messageId == messageId) + .listen((message) => received.add(message.messageId)); + addTearDown(subscription.cancel); + + await deliverToAndroidReceiver([messageId], dismissalFirst: true); + await _waitUntil(() => received.contains(messageId)); + // Leave time for an unexpected message to show up. + await Future.delayed(const Duration(milliseconds: 500)); + + expect(received, [messageId]); + }); + test('an engine that starts listening later only gets the messages ' 'received after', () async { final before = _uniqueMessageId('before-second-engine');