From b32e414538b4feb3cdbb9666732ea6b60ad2bdb7 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Wed, 21 Oct 2026 13:54:23 +0200 Subject: [PATCH 1/2] fix(messaging,android): deliver every foreground message to onMessage exactly once Foreground messages went from the broadcast receiver to the plugin through a `LiveData`, which caused two bugs: - Messages were lost: `postValue` keeps only the last of the values posted before the main thread runs, so a burst of pushes reached `onMessage` as a single message (5 back to back: only the last one arrived). - Old messages were delivered again: `LiveData` hands its last value to every new observer, so each newly attached engine (app reopened after Back while the process lived, or another engine) got the previous foreground message as a new `onMessage` (#12411). `FlutterFirebaseRemoteMessageDispatcher` replaces it. It delivers each message once, in order, to the engines whose Dart listens to `FirebaseMessaging.onMessage`, which now tells the plugin when it starts and stops listening (`Messaging#startForegroundMessages` / `Messaging#stopForegroundMessages`). The signal is sent from this package, which ships with the native code, so the two cannot get out of step. Messages received while no engine listens are kept for up to a minute and handed to the first one that does. Engines that never listen, like the background engine that runs `onBackgroundMessage`, get none. `FlutterFirebaseRemoteMessageLiveData` is deprecated: `postRemoteMessage` forwards to the dispatcher, and its observers are still told about every message. The main channel buffer is also enlarged, with every message sent to Dart: engines created with `new FlutterEngine(...)` register plugins before running Dart, and Flutter drops messages until then. Tests: - JUnit tests for the dispatcher, added to `android-unit-tests`. - Dart tests for the start/stop signal. - e2e tests that hand messages to the real receiver through a test-only channel in the example app: a burst reaches `onMessage` once and in order, a second engine started later gets only the new messages, and messages sent before Dart handles the channel are all kept. Each fails on the previous code. --- .github/workflows/android_unit_tests.yaml | 2 +- .../firebase_messaging/android/build.gradle | 5 + .../FlutterFirebaseMessagingPlugin.java | 51 +++-- .../FlutterFirebaseMessagingReceiver.java | 7 +- ...lutterFirebaseRemoteMessageDispatcher.java | 141 ++++++++++++++ .../FlutterFirebaseRemoteMessageLiveData.java | 14 +- ...erFirebaseRemoteMessageDispatcherTest.java | 178 ++++++++++++++++++ .../messaging/example/MainActivity.kt | 120 ++++++++++++ .../remote_notification_delivery.dart | 136 ++++++++++++- .../lib/firebase_messaging.dart | 3 + .../firebase_messaging/lib/src/messaging.dart | 62 +++++- .../test/on_message_listening_test.dart | 79 ++++++++ 12 files changed, 778 insertions(+), 20 deletions(-) create mode 100644 packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseRemoteMessageDispatcher.java create mode 100644 packages/firebase_messaging/firebase_messaging/android/src/test/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseRemoteMessageDispatcherTest.java create mode 100644 packages/firebase_messaging/firebase_messaging/test/on_message_listening_test.dart diff --git a/.github/workflows/android_unit_tests.yaml b/.github/workflows/android_unit_tests.yaml index 458257cfa3f0..2ec8d17726a1 100644 --- a/.github/workflows/android_unit_tests.yaml +++ b/.github/workflows/android_unit_tests.yaml @@ -43,7 +43,7 @@ jobs: # Tasks are listed per package rather than using the root testDebugUnitTest # task, which would also build the Dart sources of the aggregate test app. # Add a task here when a package gains an android/src/test directory. - run: cd tests/android && ./gradlew :firebase_crashlytics:testDebugUnitTest :cloud_functions:testDebugUnitTest + run: cd tests/android && ./gradlew :firebase_crashlytics:testDebugUnitTest :cloud_functions:testDebugUnitTest :firebase_messaging:testDebugUnitTest - name: 'Upload test reports' if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a diff --git a/packages/firebase_messaging/firebase_messaging/android/build.gradle b/packages/firebase_messaging/firebase_messaging/android/build.gradle index 8a7291976765..ae05b563e1d7 100644 --- a/packages/firebase_messaging/firebase_messaging/android/build.gradle +++ b/packages/firebase_messaging/firebase_messaging/android/build.gradle @@ -59,12 +59,17 @@ android { disable 'InvalidPackage' } + testOptions { + unitTests.returnDefaultValues = true + } + dependencies { api firebaseCoreProject implementation platform("com.google.firebase:firebase-bom:${getRootProjectExtOrCoreProperty("FirebaseSDKVersion", firebaseCoreProject)}") implementation 'com.google.firebase:firebase-messaging' implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0' implementation 'androidx.annotation:annotation:1.7.1' + testImplementation 'junit:junit:4.13.2' } } diff --git a/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseMessagingPlugin.java b/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseMessagingPlugin.java index d2ad1e7c8235..12021ee2ed6c 100644 --- a/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseMessagingPlugin.java +++ b/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseMessagingPlugin.java @@ -57,6 +57,10 @@ public class FlutterFirebaseMessagingPlugin private static final String TAG = "FLTFireMsgPlugin"; + // Messages sent to Dart before it has set up its handler on the channel, e.g. the token or a + // message received while the engine starts. Flutter only keeps one by default. + private static final int CHANNEL_BUFFER_SIZE = 64; + /** Mirrors the de-duplication window the Messaging SDK keeps in FcmLifecycleCallbacks. */ private static final int RECENTLY_LOGGED_MESSAGE_IDS_MAX_SIZE = 10; @@ -66,9 +70,10 @@ public class FlutterFirebaseMessagingPlugin private MethodChannel channel; private Activity mainActivity; - private final LiveData liveDataRemoteMessage = - FlutterFirebaseRemoteMessageLiveData.getInstance(); - private Observer remoteMessageObserver; + private final FlutterFirebaseRemoteMessageDispatcher remoteMessageDispatcher = + FlutterFirebaseRemoteMessageDispatcher.getInstance(); + private FlutterFirebaseRemoteMessageDispatcher.Listener remoteMessageListener; + private boolean listeningToForegroundMessages = false; private final LiveData liveDataToken = FlutterFirebaseTokenLiveData.getInstance(); private Observer tokenObserver; private final LiveData liveDataRegistered = @@ -84,24 +89,32 @@ public class FlutterFirebaseMessagingPlugin FlutterFirebasePermissionManager permissionManager; + // Sends a message to Dart. Messages sent before Dart has set up its handler on the channel wait + // in a buffer that only keeps one by default, so it is enlarged with every message rather than + // once at registration: engines created with `new FlutterEngine(...)` register plugins before + // they run Dart, and Flutter drops messages until then, and a hot restart resets the buffers. + private void invokeDartMethod(String method, Object arguments) { + channel.resizeChannelBuffer(CHANNEL_BUFFER_SIZE); + channel.invokeMethod(method, arguments); + } + private void initInstance(BinaryMessenger messenger) { String channelName = "plugins.flutter.io/firebase_messaging"; channel = new MethodChannel(messenger, channelName); channel.setMethodCallHandler(this); permissionManager = new FlutterFirebasePermissionManager(); - remoteMessageObserver = + remoteMessageListener = remoteMessage -> { Map content = FlutterFirebaseMessagingUtils.remoteMessageToMap(remoteMessage); - channel.invokeMethod("Messaging#onMessage", content); + invokeDartMethod("Messaging#onMessage", content); }; - tokenObserver = token -> channel.invokeMethod("Messaging#onTokenRefresh", token); - registeredObserver = fid -> channel.invokeMethod("Messaging#onRegistered", fid); - unregisteredObserver = fid -> channel.invokeMethod("Messaging#onUnregistered", fid); + tokenObserver = token -> invokeDartMethod("Messaging#onTokenRefresh", token); + registeredObserver = fid -> invokeDartMethod("Messaging#onRegistered", fid); + unregisteredObserver = fid -> invokeDartMethod("Messaging#onUnregistered", fid); // We remove these observers in the onDetachedFromEngine method. Using "observeForever()" // allows us to use without a LifecycleOwner. - liveDataRemoteMessage.observeForever(remoteMessageObserver); liveDataToken.observeForever(tokenObserver); liveDataRegistered.observeForever(registeredObserver); liveDataUnregistered.observeForever(unregisteredObserver); @@ -118,7 +131,8 @@ public void onAttachedToEngine(FlutterPluginBinding binding) { @Override public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { liveDataToken.removeObserver(tokenObserver); - liveDataRemoteMessage.removeObserver(remoteMessageObserver); + remoteMessageDispatcher.removeListener(remoteMessageListener); + listeningToForegroundMessages = false; liveDataRegistered.removeObserver(registeredObserver); liveDataUnregistered.removeObserver(unregisteredObserver); } @@ -577,6 +591,21 @@ public void onMethodCall(final MethodCall call, @NonNull final Result result) { FlutterFirebaseMessagingBackgroundService.setUserCallbackHandle(userCallbackHandle); methodCallTask = Tasks.forResult(null); break; + // Sent when Dart starts and stops listening to onMessage. Foreground messages are only sent + // to the engines that listen: the messages received while none does are kept until one + // does, and engines that never listen (the background engine) get none. + case "Messaging#startForegroundMessages": + if (!listeningToForegroundMessages) { + listeningToForegroundMessages = true; + remoteMessageDispatcher.addListener(remoteMessageListener); + } + methodCallTask = Tasks.forResult(null); + break; + case "Messaging#stopForegroundMessages": + listeningToForegroundMessages = false; + remoteMessageDispatcher.removeListener(remoteMessageListener); + methodCallTask = Tasks.forResult(null); + break; case "Messaging#getInitialMessage": methodCallTask = getInitialMessage(); break; @@ -770,7 +799,7 @@ private boolean handleNotificationIntent(@NonNull Intent intent, boolean shouldN // On a terminated launch, getInitialMessage() owns this message. if (shouldNotifyStream) { - channel.invokeMethod("Messaging#onMessageOpenedApp", message); + invokeDartMethod("Messaging#onMessageOpenedApp", message); } mainActivity.setIntent(intent); return true; 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 ef5686fbf32f..67bd113e9325 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 @@ -67,7 +67,7 @@ public void onReceive(Context context, Intent intent) { // App in Foreground // ------------------------ if (FlutterFirebaseMessagingUtils.isApplicationForeground(context)) { - FlutterFirebaseRemoteMessageLiveData.getInstance().postRemoteMessage(remoteMessage); + FlutterFirebaseRemoteMessageDispatcher.getInstance().dispatch(remoteMessage); return; } @@ -91,7 +91,10 @@ public void onReceive(Context context, Intent intent) { onBackgroundMessageIntent, remoteMessage.getOriginalPriority() == RemoteMessage.PRIORITY_HIGH); } finally { - pendingResult.finish(); + // Null when onReceive is called outside of a broadcast, as the e2e tests do. + if (pendingResult != null) { + pendingResult.finish(); + } } }); } diff --git a/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseRemoteMessageDispatcher.java b/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseRemoteMessageDispatcher.java new file mode 100644 index 000000000000..72113ab40bc9 --- /dev/null +++ b/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseRemoteMessageDispatcher.java @@ -0,0 +1,141 @@ +// 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 android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import com.google.firebase.messaging.RemoteMessage; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executor; + +/** + * Delivers the messages received while the app is in the foreground to the plugin on every engine + * whose Dart listens to {@code onMessage}, each message exactly once. + * + *

This replaces a {@code LiveData}, which keeps only the last of the values posted before the + * main thread runs, and hands its last value again to every new observer (each new engine). + * + *

Messages received while no engine is listening, for example while the app engine is starting, + * are kept for {@link #PENDING_MESSAGE_LIFETIME_MS} and delivered to the first engine that starts + * listening. + */ +class FlutterFirebaseRemoteMessageDispatcher { + interface Listener { + void onRemoteMessage(@NonNull RemoteMessage message); + } + + interface Clock { + long elapsedRealtime(); + } + + private static final String TAG = "FLTFireMsgDispatcher"; + + @VisibleForTesting static final int MAX_PENDING_MESSAGES = 50; + @VisibleForTesting static final long PENDING_MESSAGE_LIFETIME_MS = 60_000; + + private static FlutterFirebaseRemoteMessageDispatcher instance; + + @SuppressWarnings("deprecation") + static synchronized FlutterFirebaseRemoteMessageDispatcher getInstance() { + if (instance == null) { + Handler mainHandler = new Handler(Looper.getMainLooper()); + instance = + new FlutterFirebaseRemoteMessageDispatcher( + mainHandler::post, + SystemClock::elapsedRealtime, + // Keeps observers of the deprecated LiveData working. + message -> + FlutterFirebaseRemoteMessageLiveData.getInstance().notifyObservers(message)); + } + return instance; + } + + private static final class PendingMessage { + final RemoteMessage message; + final long receivedAt; + + PendingMessage(RemoteMessage message, long receivedAt) { + this.message = message; + this.receivedAt = receivedAt; + } + } + + private final Executor mainThread; + private final Clock clock; + @Nullable private final Listener everyMessageListener; + // Only accessed on the main thread. + private final List listeners = new ArrayList<>(); + private final ArrayDeque pendingMessages = new ArrayDeque<>(); + // Logged once until messages are delivered again, apps may never listen. + private boolean loggedDroppedMessages = false; + + /** + * @param everyMessageListener is told about every message as it arrives, whether or not an engine + * listens. + */ + @VisibleForTesting + FlutterFirebaseRemoteMessageDispatcher( + Executor mainThread, Clock clock, @Nullable Listener everyMessageListener) { + this.mainThread = mainThread; + this.clock = clock; + this.everyMessageListener = everyMessageListener; + } + + /** Delivers {@code message} on the main thread. Can be called from any thread. */ + void dispatch(@NonNull RemoteMessage message) { + mainThread.execute(() -> deliver(message)); + } + + /** Must be called on the main thread. */ + void addListener(@NonNull Listener listener) { + listeners.add(listener); + loggedDroppedMessages = false; + long now = clock.elapsedRealtime(); + while (!pendingMessages.isEmpty()) { + PendingMessage pending = pendingMessages.poll(); + if (now - pending.receivedAt <= PENDING_MESSAGE_LIFETIME_MS) { + listener.onRemoteMessage(pending.message); + } else { + logDroppedMessage(pending.message, "no engine listened to onMessage in time"); + } + } + } + + /** Must be called on the main thread. */ + void removeListener(@NonNull Listener listener) { + listeners.remove(listener); + } + + private void logDroppedMessage(RemoteMessage message, String reason) { + if (!loggedDroppedMessages) { + loggedDroppedMessages = true; + Log.w(TAG, "Dropped foreground message " + message.getMessageId() + ": " + reason + "."); + } + } + + private void deliver(RemoteMessage message) { + if (everyMessageListener != null) { + everyMessageListener.onRemoteMessage(message); + } + if (listeners.isEmpty()) { + pendingMessages.add(new PendingMessage(message, clock.elapsedRealtime())); + if (pendingMessages.size() > MAX_PENDING_MESSAGES) { + logDroppedMessage(pendingMessages.poll().message, "too many waiting for onMessage"); + } + return; + } + // Copy: a listener may remove itself while handling the message. + for (Listener listener : new ArrayList<>(listeners)) { + listener.onRemoteMessage(message); + } + } +} diff --git a/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseRemoteMessageLiveData.java b/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseRemoteMessageLiveData.java index faf9e0ab490d..a117fc80ac1e 100644 --- a/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseRemoteMessageLiveData.java +++ b/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseRemoteMessageLiveData.java @@ -7,6 +7,13 @@ import androidx.lifecycle.LiveData; import com.google.firebase.messaging.RemoteMessage; +/** + * @deprecated Foreground messages are delivered by {@code FlutterFirebaseRemoteMessageDispatcher}, + * which, unlike a {@link LiveData}, does not drop messages posted in quick succession or replay + * the last one to new observers. {@link #postRemoteMessage} forwards to it, and observers of + * this LiveData are still told about every message. + */ +@Deprecated public class FlutterFirebaseRemoteMessageLiveData extends LiveData { private static FlutterFirebaseRemoteMessageLiveData instance; @@ -18,6 +25,11 @@ public static FlutterFirebaseRemoteMessageLiveData getInstance() { } public void postRemoteMessage(RemoteMessage remoteMessage) { - postValue(remoteMessage); + FlutterFirebaseRemoteMessageDispatcher.getInstance().dispatch(remoteMessage); + } + + /** Called by the dispatcher on the main thread for every message. */ + void notifyObservers(RemoteMessage remoteMessage) { + setValue(remoteMessage); } } diff --git a/packages/firebase_messaging/firebase_messaging/android/src/test/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseRemoteMessageDispatcherTest.java b/packages/firebase_messaging/firebase_messaging/android/src/test/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseRemoteMessageDispatcherTest.java new file mode 100644 index 000000000000..09f7bca8ad80 --- /dev/null +++ b/packages/firebase_messaging/firebase_messaging/android/src/test/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseRemoteMessageDispatcherTest.java @@ -0,0 +1,178 @@ +// 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.assertEquals; +import static org.junit.Assert.assertSame; + +import android.os.Bundle; +import com.google.firebase.messaging.RemoteMessage; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.junit.Before; +import org.junit.Test; + +public class FlutterFirebaseRemoteMessageDispatcherTest { + // Runs posted tasks only when asked, like a main thread busy with something else. + private final ArrayDeque mainThreadTasks = new ArrayDeque<>(); + private long now = 1_000; + private final RecordingListener everyMessage = new RecordingListener(); + private FlutterFirebaseRemoteMessageDispatcher dispatcher; + + private static final class RecordingListener + implements FlutterFirebaseRemoteMessageDispatcher.Listener { + final List messages = new ArrayList<>(); + + @Override + public void onRemoteMessage(RemoteMessage message) { + messages.add(message); + } + } + + @Before + public void setUp() { + dispatcher = + new FlutterFirebaseRemoteMessageDispatcher(mainThreadTasks::add, () -> now, everyMessage); + } + + private void runMainThread() { + while (!mainThreadTasks.isEmpty()) { + mainThreadTasks.poll().run(); + } + } + + private static RemoteMessage message() { + return new RemoteMessage(new Bundle()); + } + + @Test + public void deliversEveryMessagePostedBeforeTheMainThreadRuns() { + RecordingListener listener = new RecordingListener(); + dispatcher.addListener(listener); + RemoteMessage first = message(); + RemoteMessage second = message(); + RemoteMessage third = message(); + + dispatcher.dispatch(first); + dispatcher.dispatch(second); + dispatcher.dispatch(third); + runMainThread(); + + assertEquals(Arrays.asList(first, second, third), listener.messages); + } + + @Test + public void deliversToEveryListener() { + RecordingListener first = new RecordingListener(); + RecordingListener second = new RecordingListener(); + dispatcher.addListener(first); + dispatcher.addListener(second); + RemoteMessage message = message(); + + dispatcher.dispatch(message); + runMainThread(); + + assertEquals(Arrays.asList(message), first.messages); + assertEquals(Arrays.asList(message), second.messages); + } + + @Test + public void doesNotReplayDeliveredMessagesToALaterListener() { + RecordingListener first = new RecordingListener(); + dispatcher.addListener(first); + dispatcher.dispatch(message()); + runMainThread(); + + RecordingListener later = new RecordingListener(); + dispatcher.addListener(later); + RemoteMessage next = message(); + dispatcher.dispatch(next); + runMainThread(); + + assertEquals(Arrays.asList(next), later.messages); + assertEquals(2, first.messages.size()); + } + + @Test + public void keepsMessagesUntilTheFirstListenerThenOnlyDeliversThemOnce() { + RemoteMessage first = message(); + RemoteMessage second = message(); + dispatcher.dispatch(first); + dispatcher.dispatch(second); + runMainThread(); + + RecordingListener listener = new RecordingListener(); + dispatcher.addListener(listener); + RecordingListener later = new RecordingListener(); + dispatcher.addListener(later); + + assertEquals(Arrays.asList(first, second), listener.messages); + assertEquals(0, later.messages.size()); + } + + @Test + public void dropsKeptMessagesOnceTheyAreTooOld() { + RemoteMessage old = message(); + dispatcher.dispatch(old); + runMainThread(); + now += FlutterFirebaseRemoteMessageDispatcher.PENDING_MESSAGE_LIFETIME_MS + 1; + RemoteMessage recent = message(); + dispatcher.dispatch(recent); + runMainThread(); + + RecordingListener listener = new RecordingListener(); + dispatcher.addListener(listener); + + assertEquals(Arrays.asList(recent), listener.messages); + } + + @Test + public void keepsOnlyTheMostRecentMessagesWhenTooManyArrive() { + List sent = new ArrayList<>(); + for (int i = 0; i < FlutterFirebaseRemoteMessageDispatcher.MAX_PENDING_MESSAGES + 1; i++) { + RemoteMessage message = message(); + sent.add(message); + dispatcher.dispatch(message); + } + runMainThread(); + + RecordingListener listener = new RecordingListener(); + dispatcher.addListener(listener); + + assertEquals(sent.subList(1, sent.size()), listener.messages); + } + + @Test + public void tellsTheEveryMessageListenerAboutEveryMessageWhenItArrives() { + RemoteMessage kept = message(); + dispatcher.dispatch(kept); + runMainThread(); + RecordingListener listener = new RecordingListener(); + dispatcher.addListener(listener); + RemoteMessage delivered = message(); + dispatcher.dispatch(delivered); + runMainThread(); + + assertEquals(Arrays.asList(kept, delivered), everyMessage.messages); + } + + @Test + public void stopsDeliveringToARemovedListener() { + RecordingListener removed = new RecordingListener(); + RecordingListener remaining = new RecordingListener(); + dispatcher.addListener(removed); + dispatcher.addListener(remaining); + + dispatcher.removeListener(removed); + RemoteMessage message = message(); + dispatcher.dispatch(message); + runMainThread(); + + assertEquals(0, removed.messages.size()); + assertSame(message, remaining.messages.get(0)); + } +} 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 73bd99fca26e..7c9a6e4249ba 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 @@ -1,11 +1,19 @@ package io.flutter.plugins.firebase.messaging.example +import android.app.ActivityManager +import android.app.KeyguardManager import android.content.Context +import android.content.Intent import android.os.Build import android.os.ParcelFileDescriptor +import io.flutter.FlutterInjector import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.dart.DartExecutor import io.flutter.plugin.common.MethodChannel +import io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingPlugin +import io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingReceiver +import io.flutter.view.FlutterCallbackInformation import java.io.FileInputStream import java.io.InputStream import java.io.OutputStream @@ -14,9 +22,75 @@ import java.net.Socket import java.nio.charset.StandardCharsets class MainActivity : FlutterActivity() { + private var secondEngine: FlutterEngine? = null + private val secondEngineMessages = mutableListOf() + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) + // Test-only channel that hands FCM messages to the plugin's receiver like + // Google Play services does when pushes arrive, without needing FCM. + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "tests/remote_notifications") + .setMethodCallHandler { call, result -> + when (call.method) { + "deliverToReceiver" -> { + val messageIds = call.argument>("messageIds") + if (messageIds == null) { + 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 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") + } + receiver.onReceive(applicationContext, intent) + } + // Keep the main thread busy while the receiver hands the messages over from its + // own thread, as when pushes arrive faster than the main thread runs. + Thread.sleep(500) + result.success(mapOf("foreground" to foreground)) + } + "startSecondEngine" -> { + val callbackHandle = call.argument("callbackHandle") + val callback = + callbackHandle?.let { + FlutterCallbackInformation.lookupCallbackInformation(it.toLong()) + } + if (callback == null) { + result.error("INVALID_ARG", "a valid callbackHandle is required", null) + return@setMethodCallHandler + } + startSecondEngine(callback, result) + } + "secondEngineMessages" -> result.success(secondEngineMessages.toList()) + "stopSecondEngine" -> { + secondEngine?.destroy() + secondEngine = null + result.success(null) + } + else -> result.notImplemented() + } + } + // Test-only channel for manipulating runtime permissions during // integration tests. `flutter test` launches the app without Android // instrumentation, so UiAutomation via InstrumentationRegistry is often @@ -83,6 +157,52 @@ class MainActivity : FlutterActivity() { } } + private fun isAppInForeground(): Boolean { + val keyguard = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + if (keyguard.isKeyguardLocked) return false + val state = ActivityManager.RunningAppProcessInfo() + ActivityManager.getMyMemoryState(state) + return state.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND + } + + /// 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 + /// were received. + private fun startSecondEngine(callback: FlutterCallbackInformation, result: MethodChannel.Result) { + secondEngine?.destroy() + secondEngineMessages.clear() + val engine = FlutterEngine(applicationContext, null, /* automaticallyRegisterPlugins= */ false) + secondEngine = engine + MethodChannel(engine.dartExecutor.binaryMessenger, "tests/second_engine") + .setMethodCallHandler { call, reply -> + when (call.method) { + "ready" -> { + engine.plugins.add(FlutterFirebaseMessagingPlugin()) + reply.success(null) + } + "listening" -> { + reply.success(null) + result.success(null) + } + "onMessage" -> { + secondEngineMessages.add(call.arguments as String) + reply.success(null) + } + else -> reply.notImplemented() + } + } + // Looked up by callback handle: in test builds the root library is a + // generated wrapper that only has `main`. + engine.dartExecutor.executeDartCallback( + DartExecutor.DartCallback( + assets, + FlutterInjector.instance().flutterLoader().findAppBundlePath(), + callback, + ), + ) + } + private fun mutatePermission(permission: String, grant: Boolean) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return val action = if (grant) "grant" else "revoke" 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 ec7b9a1cd6b7..c99f11e9de0b 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 @@ -2,15 +2,19 @@ // 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. +import 'dart:ui' show PluginUtilities; + import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging_platform_interface/firebase_messaging_platform_interface.dart'; // ignore: implementation_imports import 'package:firebase_messaging_platform_interface/src/method_channel/method_channel_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -/// iOS test helper implemented in `ios/Runner/RemoteNotificationTestChannel.swift`. +/// Test helpers implemented in `ios/Runner/RemoteNotificationTestChannel.swift` +/// and `android/app/src/main/kotlin/.../MainActivity.kt`. const _remoteNotificationsChannel = MethodChannel('tests/remote_notifications'); /// Delivers a data message through the app delegate the same way iOS does when @@ -35,6 +39,44 @@ Future> deliverRemoteNotification( return delivery!; } +/// 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 +/// app did not count as in the foreground, where messages go to `onMessage`. +Future deliverToAndroidReceiver(List messageIds) async { + final delivery = await _remoteNotificationsChannel + .invokeMapMethod('deliverToReceiver', { + 'messageIds': messageIds, + }); + expect( + delivery!['foreground'], + isTrue, + reason: 'The app must be in the foreground with the screen unlocked.', + ); +} + +/// Entry point of the second engine started by the Android tests: listens to +/// `onMessage` and reports what it receives to `MainActivity.kt`. +@pragma('vm:entry-point') +Future secondEngineMain() async { + WidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('tests/second_engine'); + await channel.invokeMethod('ready'); + FirebaseMessaging.onMessage.listen( + (message) => channel.invokeMethod('onMessage', message.messageId), + ); + await channel.invokeMethod('listening'); +} + +Future _waitUntil( + bool Function() condition, { + Duration timeout = const Duration(seconds: 10), +}) async { + final deadline = DateTime.now().add(timeout); + while (!condition() && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 50)); + } +} + const _uiBackgroundFetchResultNewData = 0; final List _backgroundMessages = []; @@ -47,8 +89,8 @@ Future _backgroundMessageHandler(RemoteMessage message) async { String _uniqueMessageId(String prefix) => '$prefix-${DateTime.now().microsecondsSinceEpoch}'; -/// Pushes delivered the way iOS delivers them, run by `e2e_test.dart` and on -/// their own by `remote_notification_delivery_test.dart`. +/// Pushes delivered the way iOS and Android deliver them, run by +/// `e2e_test.dart` and on their own by `remote_notification_delivery_test.dart`. void remoteNotificationDeliveryTests() { group( 'remote notification delivery', @@ -185,4 +227,92 @@ void remoteNotificationDeliveryTests() { // Pushes are delivered through the iOS app delegate. skip: kIsWeb || defaultTargetPlatform != TargetPlatform.iOS, ); + + group( + 'remote notification delivery on Android', + () { + test( + 'every message received in the foreground reaches onMessage once ' + 'and in order', () async { + final messageIds = [ + for (var i = 0; i < 5; i++) _uniqueMessageId('foreground-$i'), + ]; + final received = []; + final subscription = FirebaseMessaging.onMessage + .where((message) => messageIds.contains(message.messageId)) + .listen(received.add); + addTearDown(subscription.cancel); + + await deliverToAndroidReceiver(messageIds); + await _waitUntil(() => received.length >= messageIds.length); + // Leave time for an unexpected duplicate to show up. + await Future.delayed(const Duration(milliseconds: 500)); + + expect(received.map((message) => message.messageId), messageIds); + expect( + received.first.data, + containsPair('test', 'remote-notification-delivery'), + ); + }); + + test( + 'an engine that starts listening later only gets the messages ' + 'received after', () async { + final before = _uniqueMessageId('before-second-engine'); + final after = _uniqueMessageId('after-second-engine'); + final received = []; + final subscription = FirebaseMessaging.onMessage + .where((m) => m.messageId == before || m.messageId == after) + .listen((message) => received.add(message.messageId)); + addTearDown(subscription.cancel); + + await deliverToAndroidReceiver([before]); + await _waitUntil(() => received.isNotEmpty); + addTearDown( + () => _remoteNotificationsChannel + .invokeMethod('stopSecondEngine'), + ); + await _remoteNotificationsChannel + .invokeMethod('startSecondEngine', { + 'callbackHandle': PluginUtilities.getCallbackHandle(secondEngineMain)! + .toRawHandle(), + }).timeout(const Duration(seconds: 20)); + await deliverToAndroidReceiver([after]); + await _waitUntil(() => received.length >= 2); + // Leave time for an unexpected delivery to the second engine. + await Future.delayed(const Duration(seconds: 1)); + final secondEngine = await _remoteNotificationsChannel + .invokeListMethod('secondEngineMessages'); + + expect(received, [before, after]); + expect(secondEngine, [after]); + }); + + test( + 'messages sent before Dart handles the channel are all kept ' + 'until it does', () async { + final messageIds = [ + for (var i = 0; i < 3; i++) _uniqueMessageId('buffered-$i'), + ]; + final received = []; + final subscription = FirebaseMessaging.onMessage + .where((message) => messageIds.contains(message.messageId)) + .listen((message) => received.add(message.messageId)); + addTearDown(subscription.cancel); + + MethodChannelFirebaseMessaging.channel.setMethodCallHandler(null); + addTearDown(MethodChannelFirebaseMessaging.setMethodCallHandlers); + await deliverToAndroidReceiver(messageIds); + await Future.delayed(const Duration(milliseconds: 500)); + expect(received, isEmpty); + + MethodChannelFirebaseMessaging.setMethodCallHandlers(); + await _waitUntil(() => received.length >= messageIds.length); + + expect(received, messageIds); + }); + }, + // Pushes are delivered through the plugin's broadcast receiver. + skip: kIsWeb || defaultTargetPlatform != TargetPlatform.android, + ); } diff --git a/packages/firebase_messaging/firebase_messaging/lib/firebase_messaging.dart b/packages/firebase_messaging/firebase_messaging/lib/firebase_messaging.dart index 1783d4654c91..05765b9f84c6 100644 --- a/packages/firebase_messaging/firebase_messaging/lib/firebase_messaging.dart +++ b/packages/firebase_messaging/firebase_messaging/lib/firebase_messaging.dart @@ -5,6 +5,9 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core_platform_interface/firebase_core_platform_interface.dart' show FirebasePlugin; diff --git a/packages/firebase_messaging/firebase_messaging/lib/src/messaging.dart b/packages/firebase_messaging/firebase_messaging/lib/src/messaging.dart index 5446c0629519..5058934a112f 100644 --- a/packages/firebase_messaging/firebase_messaging/lib/src/messaging.dart +++ b/packages/firebase_messaging/firebase_messaging/lib/src/messaging.dart @@ -30,6 +30,9 @@ class FirebaseMessaging extends FirebasePlugin { /// Returns an instance using the default [FirebaseApp]. static FirebaseMessaging get instance { FirebaseApp defaultAppInstance = Firebase.app(); + if (!_foregroundMessagesStateSent) { + _sendForegroundMessagesState(); + } return FirebaseMessaging._instanceFor(app: defaultAppInstance); } @@ -48,8 +51,63 @@ class FirebaseMessaging extends FirebasePlugin { /// /// To handle messages whilst the app is in the background or terminated, /// see [onBackgroundMessage]. - static Stream get onMessage => - FirebaseMessagingPlatform.onMessage.stream; + static Stream get onMessage => _onMessage.stream; + + // On Android the plugin only sends foreground messages to the engines whose + // Dart listens to `onMessage`, and keeps them while none does, so listening + // and cancelling tell it. Done here rather than in the platform interface, + // because this package ships with the native code. + // ignore: close_sinks, never closed + static final StreamController _onMessage = + StreamController.broadcast( + onListen: () { + _onMessageSubscription = FirebaseMessagingPlatform.onMessage.stream + .listen(_onMessage.add, onError: _onMessage.addError); + _setListeningToForegroundMessages(true); + }, + onCancel: () { + _onMessageSubscription?.cancel(); + _onMessageSubscription = null; + _setListeningToForegroundMessages(false); + }, + ); + static StreamSubscription? _onMessageSubscription; + + static const MethodChannel _channel = + MethodChannel('plugins.flutter.io/firebase_messaging'); + static bool _listeningToForegroundMessages = false; + // False when the last state could not be sent, e.g. before the binding was + // initialized; retried when the app uses `FirebaseMessaging.instance`. + static bool _foregroundMessagesStateSent = true; + + static void _setListeningToForegroundMessages(bool listening) { + if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) { + return; + } + _listeningToForegroundMessages = listening; + _sendForegroundMessagesState(); + } + + static void _sendForegroundMessagesState() { + final listening = _listeningToForegroundMessages; + _foregroundMessagesStateSent = false; + unawaited( + _channel + .invokeMethod( + listening + ? 'Messaging#startForegroundMessages' + : 'Messaging#stopForegroundMessages', + ) + .then( + (_) { + if (_listeningToForegroundMessages == listening) { + _foregroundMessagesStateSent = true; + } + }, + onError: (Object _) {}, + ), + ); + } /// Returns a [Stream] that is called when a user presses a notification message displayed /// via FCM. diff --git a/packages/firebase_messaging/firebase_messaging/test/on_message_listening_test.dart b/packages/firebase_messaging/firebase_messaging/test/on_message_listening_test.dart new file mode 100644 index 000000000000..5433193d501c --- /dev/null +++ b/packages/firebase_messaging/firebase_messaging/test/on_message_listening_test.dart @@ -0,0 +1,79 @@ +// 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. + +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:firebase_messaging_platform_interface/firebase_messaging_platform_interface.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// The Android plugin only sends foreground messages to the engines whose Dart +// listens to `onMessage`, and keeps them while none does. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('plugins.flutter.io/firebase_messaging'); + final calls = []; + + setUp(() { + calls.clear(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call.method); + return null; + }); + }); + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('tells the Android plugin when the app starts and stops listening', + () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + + final first = FirebaseMessaging.onMessage.listen((_) {}); + final second = FirebaseMessaging.onMessage.listen((_) {}); + await pumpEventQueue(); + expect(calls, ['Messaging#startForegroundMessages']); + + await first.cancel(); + await pumpEventQueue(); + expect(calls, ['Messaging#startForegroundMessages']); + + await second.cancel(); + await pumpEventQueue(); + expect(calls, [ + 'Messaging#startForegroundMessages', + 'Messaging#stopForegroundMessages', + ]); + }); + + test('delivers the messages of the platform stream', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + final received = []; + final subscription = FirebaseMessaging.onMessage + .listen((message) => received.add(message.messageId)); + addTearDown(subscription.cancel); + + FirebaseMessagingPlatform.onMessage.add( + const RemoteMessage(messageId: 'from-platform'), + ); + await pumpEventQueue(); + + expect(received, ['from-platform']); + }); + + test('sends nothing on iOS', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + + final subscription = FirebaseMessaging.onMessage.listen((_) {}); + await pumpEventQueue(); + await subscription.cancel(); + await pumpEventQueue(); + + expect(calls, isEmpty); + }); +} From 40c14141d0d0059896fa66936c950ea4f168d6a7 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Wed, 21 Oct 2026 14:08:19 +0200 Subject: [PATCH 2/2] style(messaging): format the example's MainActivity.kt with ktfmt --- .../plugins/firebase/messaging/example/MainActivity.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 7c9a6e4249ba..933b0e44dfed 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 @@ -169,7 +169,10 @@ class MainActivity : FlutterActivity() { /// listens to onMessage and reports what it receives. The messaging plugin /// is attached once its Dart runs, like an engine created after messages /// were received. - private fun startSecondEngine(callback: FlutterCallbackInformation, result: MethodChannel.Result) { + private fun startSecondEngine( + callback: FlutterCallbackInformation, + result: MethodChannel.Result + ) { secondEngine?.destroy() secondEngineMessages.clear() val engine = FlutterEngine(applicationContext, null, /* automaticallyRegisterPlugins= */ false)