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..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 @@ -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,55 @@ 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); + }); +}