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 @@ -7,15 +7,22 @@
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
import android.util.Log;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import com.google.firebase.messaging.RemoteMessage;
import java.util.HashMap;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;

public class FlutterFirebaseMessagingReceiver extends BroadcastReceiver {
private static final String TAG = "FLTFireMsgReceiver";
private static final String EXTRA_WRAPPED_INTENT = "wrapped_intent";
private static final String EXTRA_MESSAGE_TYPE = "message_type";
private static final String MESSAGE_TYPE_MESSAGE = "gcm";
static HashMap<String, RemoteMessage> notifications = new HashMap<>();

// SharedPreferences writes and process-state lookups must not run on the main
Expand All @@ -26,7 +33,6 @@ public class FlutterFirebaseMessagingReceiver extends BroadcastReceiver {

@Override
public void onReceive(Context context, Intent intent) {
Log.d(TAG, "broadcast received for message");
if (ContextHolder.getApplicationContext() == null) {
Context aContext = context;
if (context.getApplicationContext() != null) {
Expand All @@ -36,15 +42,11 @@ public void onReceive(Context context, Intent intent) {
ContextHolder.setApplicationContext(aContext);
}

if (intent.getExtras() == null) {
Log.d(
TAG,
"broadcast received but intent contained no extras to process RemoteMessage. Operation"
+ " cancelled.");
RemoteMessage remoteMessage = remoteMessageFrom(intent);
if (remoteMessage == null) {
return;
}

RemoteMessage remoteMessage = new RemoteMessage(intent.getExtras());
Log.d(TAG, "broadcast received for message");

// Keep the in-memory message available to the plugin on the main thread; it is
// read from the main thread when a notification is tapped.
Expand Down Expand Up @@ -98,4 +100,55 @@ public void onReceive(Context context, Intent intent) {
}
});
}

/**
* Returns the message to deliver for a broadcast, or null for a broadcast without extras, for a
* notification event, or for one whose {@code message_type} is set and is not {@code gcm}.
*
* <p>When firebase-messaging displays a notification for a message that has analytics enabled, it
* gives the notification a delete intent. If the user dismisses the notification, Android sends
* the app that intent: a {@code com.google.android.c2dm.intent.RECEIVE} broadcast, the action
* messages use, with the {@code NOTIFICATION_DISMISS} intent in its {@code wrapped_intent} extra.
* Google's CloudMessagingReceiver handles a broadcast with an intent there as a notification
* event, never as a message, and this receiver ignores it. Read as a message, it has no ID and
* nothing in it.
*/
@VisibleForTesting
@Nullable
static RemoteMessage remoteMessageFrom(Intent intent) {
Bundle extras = intent.getExtras();
if (extras == null) {
Log.d(
TAG,
"broadcast received but intent contained no extras to process RemoteMessage. Operation"
+ " cancelled.");
return null;
}

// The same check as CloudMessagingReceiver. The typed getter needs API 33, and AndroidX's
// IntentCompat only relies on it from API 34.
@SuppressWarnings("deprecation")
Parcelable wrappedIntent = intent.getParcelableExtra(EXTRA_WRAPPED_INTENT);
if (wrappedIntent instanceof Intent) {
Log.d(
TAG,
"broadcast received for notification event "
+ ((Intent) wrappedIntent).getAction()
+ ", not a message. Ignored.");
return null;
}

// FirebaseMessagingService passes a broadcast to onMessageReceived only if it has no message
// type or gcm. Any other type is not a message: deleted_messages, which FCM sends when it has
// deleted messages pending for the app, send_event and send_error, about upstream messages, or
// a type the SDK does not know and drops.
String messageType = intent.getStringExtra(EXTRA_MESSAGE_TYPE);
if (messageType != null && !MESSAGE_TYPE_MESSAGE.equals(messageType)) {
Log.d(
TAG, "broadcast received for message type " + messageType + ", not a message. Ignored.");
return null;
}

return new RemoteMessage(extras);
}
Comment thread
Lyokone marked this conversation as resolved.
Resolved
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// 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.assertNotNull;
import static org.junit.Assert.assertNull;

import android.content.Intent;
import android.os.Bundle;
import android.os.Parcelable;
import org.junit.Test;

public class FlutterFirebaseMessagingReceiverTest {
// The android.jar of unit tests keeps no extras, so this broadcast answers the reads the receiver
// makes itself: all its extras, the "wrapped_intent" one and the "message_type" one.
private static Intent broadcast(
final Bundle extras, final Parcelable wrappedIntent, final String messageType) {
return new Intent() {
@Override
public Bundle getExtras() {
return extras;
}

@Override
@SuppressWarnings({"deprecation", "unchecked"})
public <T extends Parcelable> T getParcelableExtra(String name) {
return "wrapped_intent".equals(name) ? (T) wrappedIntent : null;
}

@Override
public String getStringExtra(String name) {
return "message_type".equals(name) ? messageType : null;
}
};
}

private static Intent broadcast(Bundle extras, Parcelable wrappedIntent) {
return broadcast(extras, wrappedIntent, null);
}

private static Intent broadcastOfType(String messageType) {
return broadcast(new Bundle(), null, messageType);
}

@Test
public void readsTheMessageOfABroadcastWithExtras() {
assertNotNull(
FlutterFirebaseMessagingReceiver.remoteMessageFrom(broadcast(new Bundle(), null)));
}

@Test
public void ignoresABroadcastWrappingAnIntent() {
// What firebase-messaging's delete intent wraps when the user dismisses a notification. The
// android.jar of unit tests drops the action: any wrapped Intent is a notification event.
Intent dismissal =
broadcast(new Bundle(), new Intent("com.google.firebase.messaging.NOTIFICATION_DISMISS"));

assertNull(FlutterFirebaseMessagingReceiver.remoteMessageFrom(dismissal));
}

@Test
public void ignoresABroadcastWithoutExtras() {
assertNull(FlutterFirebaseMessagingReceiver.remoteMessageFrom(broadcast(null, null)));
}

@Test
public void readsTheMessageWhenTheWrappedValueIsNotAnIntent() {
// CloudMessagingReceiver also reads only an Intent there as a notification event.
assertNotNull(
FlutterFirebaseMessagingReceiver.remoteMessageFrom(broadcast(new Bundle(), new Bundle())));
}

@Test
public void readsTheMessageOfABroadcastOfTypeGcm() {
assertNotNull(FlutterFirebaseMessagingReceiver.remoteMessageFrom(broadcastOfType("gcm")));
}

@Test
public void ignoresADeletedMessagesBroadcast() {
// Like the broadcast FCM sends when it has deleted messages pending for the app.
assertNull(
FlutterFirebaseMessagingReceiver.remoteMessageFrom(broadcastOfType("deleted_messages")));
}

@Test
public void ignoresMessageTypesOtherThanGcm() {
// FirebaseMessagingService passes upstream message events to their own callbacks, and drops
// a type it does not know.
for (String messageType : new String[] {"send_event", "send_error", "unknown"}) {
assertNull(
messageType,
FlutterFirebaseMessagingReceiver.remoteMessageFrom(broadcastOfType(messageType)));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@ package io.flutter.plugins.firebase.messaging.example

import android.app.ActivityManager
import android.app.KeyguardManager
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.ParcelFileDescriptor
import io.flutter.FlutterInjector
import io.flutter.embedding.android.FlutterActivity
Expand Down Expand Up @@ -39,28 +44,16 @@ class MainActivity : FlutterActivity() {
result.error("INVALID_ARG", "messageIds is required", null)
return@setMethodCallHandler
}
// The receiver only sends messages to onMessage while the app is in the
// foreground, which the lock screen prevents.
if (!isAppInForeground()) {
try {
executeShell("wm dismiss-keyguard")
} catch (_: Exception) {
// Reported below.
}
// The keyguard goes away asynchronously.
for (attempt in 0 until 20) {
if (isAppInForeground()) break
Thread.sleep(100)
}
}
val foreground = isAppInForeground()
val messageType = call.argument<String>("messageType")
val foreground = unlockAndCheckForeground()
val receiver = FlutterFirebaseMessagingReceiver()
for (messageId in messageIds) {
val intent =
Intent("com.google.android.c2dm.intent.RECEIVE").apply {
putExtra("google.message_id", messageId)
putExtra("from", "e2e")
putExtra("test", "remote-notification-delivery")
messageType?.let { putExtra("message_type", it) }
}
receiver.onReceive(applicationContext, intent)
}
Expand All @@ -69,6 +62,36 @@ class MainActivity : FlutterActivity() {
Thread.sleep(500)
result.success(mapOf("foreground" to foreground))
}
"dismissFirebaseNotification" -> {
val foreground = unlockAndCheckForeground()
val deleteIntent =
try {
firebaseNotificationDeleteIntent()
} catch (e: Exception) {
result.error("SDK_CHANGED", errorMessage(e), null)
return@setMethodCallHandler
}
if (deleteIntent == null) {
result.error("SDK_CHANGED", "firebase-messaging set no delete intent", null)
return@setMethodCallHandler
}
// Android sends the delete intent when the user dismisses the notification. With
// a callback, the broadcast is ordered: the callback runs once every receiver has
// finished with it, and gets the result code Firebase's own receiver set.
try {
deleteIntent.send(
applicationContext,
0,
null,
{ _, _, resultCode, _, _ ->
result.success(mapOf("foreground" to foreground, "resultCode" to resultCode))
},
Handler(Looper.getMainLooper()),
)
} catch (e: PendingIntent.CanceledException) {
result.error("SEND_FAILED", errorMessage(e), null)
}
}
"startSecondEngine" -> {
val callbackHandle = call.argument<Number>("callbackHandle")
val callback =
Expand Down Expand Up @@ -157,6 +180,56 @@ class MainActivity : FlutterActivity() {
}
}

/// Dismisses the lock screen if needed: the receiver only sends messages to
/// onMessage while the app is in the foreground. Returns whether it is.
private fun unlockAndCheckForeground(): Boolean {
if (!isAppInForeground()) {
try {
executeShell("wm dismiss-keyguard")
} catch (_: Exception) {
// Reported by the return value.
}
// The keyguard goes away asynchronously.
for (attempt in 0 until 20) {
if (isAppInForeground()) break
Thread.sleep(100)
}
}
return isAppInForeground()
}

/// The delete intent firebase-messaging sets on a notification it displays,
/// which Android sends when the user dismisses the notification. Built by
/// the SDK rather than by hand, so the test follows any change to that
/// broadcast. The builder is hidden SDK API: reflection keeps a change to it
/// from breaking the build, and fails only this test.
private fun firebaseNotificationDeleteIntent(): PendingIntent? {
// A notification message as FCM delivers it. The SDK only sets a delete
// intent when analytics is enabled for the message (google.c.a.e).
val data =
Bundle().apply {
putString("gcm.n.e", "1")
putString("gcm.notification.title", "e2e")
putString("google.c.a.e", "1")
}
val paramsClass = Class.forName("com.google.firebase.messaging.NotificationParams")
val params = paramsClass.getConstructor(Bundle::class.java).newInstance(data)
val info =
Class.forName("com.google.firebase.messaging.CommonNotificationBuilder")
.getMethod(
"createNotificationInfo",
Context::class.java,
Context::class.java,
paramsClass,
String::class.java,
Bundle::class.java,
)
.invoke(null, applicationContext, applicationContext, params, "e2e", Bundle())!!
val builder = info.javaClass.getField("notificationBuilder").get(info)!!
val notification = builder.javaClass.getMethod("build").invoke(builder) as Notification
return notification.deleteIntent
}

private fun isAppInForeground(): Boolean {
val keyguard = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
if (keyguard.isKeyguardLocked) return false
Expand Down
Loading
Loading