Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

public class FlutterFirebaseMessagingReceiver extends BroadcastReceiver {
private static final String TAG = "FLTFireMsgReceiver";
private static final String EXTRA_WRAPPED_INTENT = "wrapped_intent";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low

The constant EXTRA_WRAPPED_INTENT can be made package-private (by removing the private modifier) so that it can be reused in the corresponding unit test FlutterFirebaseMessagingReceiverTest instead of hardcoding the string literal "wrapped_intent" there.

Suggested change
private static final String EXTRA_WRAPPED_INTENT = "wrapped_intent";
static final String EXTRA_WRAPPED_INTENT = "wrapped_intent";

static HashMap<String, RemoteMessage> notifications = new HashMap<>();

// SharedPreferences writes and process-state lookups must not run on the main
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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 extends Parcelable> T getParcelableExtra(String name) {
return "wrapped_intent".equals(name) ? (T) wrapped : null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low

Reuse the package-private constant EXTRA_WRAPPED_INTENT from FlutterFirebaseMessagingReceiver instead of hardcoding the string literal "wrapped_intent" to improve maintainability.

Suggested change
return "wrapped_intent".equals(name) ? (T) wrapped : null;
return FlutterFirebaseMessagingReceiver.EXTRA_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())));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ class MainActivity : FlutterActivity() {
}
val foreground = isAppInForeground()
val receiver = FlutterFirebaseMessagingReceiver()
if (call.argument<Boolean>("dismissalFirst") == true) {
receiver.onReceive(applicationContext, notificationDismissal())
}
for (messageId in messageIds) {
val intent =
Intent("com.google.android.c2dm.intent.RECEIVE").apply {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,18 @@ Future<Map<String, Object?>> 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<void> deliverToAndroidReceiver(List<String> messageIds) async {
Future<void> deliverToAndroidReceiver(
List<String> messageIds, {
bool dismissalFirst = false,
}) async {
final delivery = await _remoteNotificationsChannel
.invokeMapMethod<String, Object?>('deliverToReceiver', {
'messageIds': messageIds,
'dismissalFirst': dismissalFirst,
});
expect(
delivery!['foreground'],
Expand Down Expand Up @@ -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 = <String?>[];
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<void>.delayed(const Duration(milliseconds: 500));

expect(received, <String?>[messageId]);
});

test('an engine that starts listening later only gets the messages '
'received after', () async {
final before = _uniqueMessageId('before-second-engine');
Expand Down
Loading