From aaad40b56d0422a4b8f4c07533ac88ebecc1592c Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Wed, 21 Oct 2026 14:23:26 +0200 Subject: [PATCH 1/3] fix(in_app_messaging): re-add Android event listeners after pause and report unheard display messages firebase-inappmessaging-display removes all listeners on every activity pause, so the lifecycle streams went silent on Android after the app was backgrounded or a campaign opened another activity. The listeners are now added again when an activity resumes. A campaign sent to onMessageDisplay while nothing listened was dropped and never reported, which on iOS blocks every later campaign. It is now held for the first listener and reported as a display error if none subscribes within 5 seconds. --- .github/workflows/android_unit_tests.yaml | 2 +- docs/in-app-messaging/_customize-messages.md | 6 + .../firebase_in_app_messaging/README.md | 4 + .../android/build.gradle | 5 + .../EventListenerRegistration.kt | 49 ++++++++ .../FirebaseInAppMessagingPlugin.kt | 93 +++++++-------- .../EventListenerRegistrationTest.kt | 74 ++++++++++++ .../lib/firebase_in_app_messaging.dart | 8 +- ...hod_channel_firebase_in_app_messaging.dart | 66 ++++++++++- ...hannel_firebase_in_app_messaging_test.dart | 109 ++++++++++++++++++ 10 files changed, 366 insertions(+), 50 deletions(-) create mode 100644 packages/firebase_in_app_messaging/firebase_in_app_messaging/android/src/main/kotlin/io/flutter/plugins/firebase/inappmessaging/EventListenerRegistration.kt create mode 100644 packages/firebase_in_app_messaging/firebase_in_app_messaging/android/src/test/kotlin/io/flutter/plugins/firebase/inappmessaging/EventListenerRegistrationTest.kt diff --git a/.github/workflows/android_unit_tests.yaml b/.github/workflows/android_unit_tests.yaml index 2ec8d17726a1..0bbac0dbd693 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 :firebase_messaging:testDebugUnitTest + run: cd tests/android && ./gradlew :firebase_crashlytics:testDebugUnitTest :cloud_functions:testDebugUnitTest :firebase_messaging:testDebugUnitTest :firebase_in_app_messaging:testDebugUnitTest - name: 'Upload test reports' if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a diff --git a/docs/in-app-messaging/_customize-messages.md b/docs/in-app-messaging/_customize-messages.md index 5ddebcaed38d..78d0c0b3e38b 100644 --- a/docs/in-app-messaging/_customize-messages.md +++ b/docs/in-app-messaging/_customize-messages.md @@ -54,5 +54,11 @@ await FirebaseInAppMessaging.instance.setCustomDisplayEnabled(true); Until `setCustomDisplayEnabled(true)` is called, native templates keep working. Listening to `onMessageDisplay` alone does not replace native UI. +Start listening to `onMessageDisplay` before you call +`setCustomDisplayEnabled(true)`. The SDK shows no other campaign until the +current one is reported, so a message that arrives while nothing listens is +held for the first listener for a few seconds, then reported as a display +error. + If you also listen to `onMessageClicked` / `onMessageImpression`, those lifecycle streams still fire when your Flutter UI reports click and impress. diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging/README.md b/packages/firebase_in_app_messaging/firebase_in_app_messaging/README.md index 39c8a504921c..9f7c10cec366 100644 --- a/packages/firebase_in_app_messaging/firebase_in_app_messaging/README.md +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging/README.md @@ -43,6 +43,10 @@ FirebaseInAppMessaging.instance.onMessageDisplay.listen((message) async { await FirebaseInAppMessaging.instance.setCustomDisplayEnabled(true); ``` +Listen to `onMessageDisplay` before calling `setCustomDisplayEnabled(true)`. A +message that arrives while nothing listens is held for a few seconds, then +reported as a display error so the SDK can show the next one. + The plugin does not open action URLs. You must report impress / click / dismiss so campaign analytics keep working. diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/build.gradle b/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/build.gradle index 45b3935a96d9..f52faa7e4702 100644 --- a/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/build.gradle +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/build.gradle @@ -82,6 +82,11 @@ android { implementation 'com.google.firebase:firebase-inappmessaging' implementation 'com.google.firebase:firebase-inappmessaging-display' implementation 'androidx.annotation:annotation:1.7.0' + testImplementation 'junit:junit:4.13.2' + } + + testOptions { + unitTests.returnDefaultValues = true } } diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/src/main/kotlin/io/flutter/plugins/firebase/inappmessaging/EventListenerRegistration.kt b/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/src/main/kotlin/io/flutter/plugins/firebase/inappmessaging/EventListenerRegistration.kt new file mode 100644 index 000000000000..758002793a40 --- /dev/null +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/src/main/kotlin/io/flutter/plugins/firebase/inappmessaging/EventListenerRegistration.kt @@ -0,0 +1,49 @@ +// Copyright 2026 The Chromium Authors. 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.inappmessaging + +/** + * Tracks whether the message lifecycle listeners should be registered with + * `FirebaseInAppMessaging`, separately from whether they currently are. + * + * `firebase-inappmessaging-display` calls `removeAllListeners()` every time an activity pauses, so + * the listeners have to be added again when an activity resumes, for as long as Dart wants the + * events. + */ +internal class EventListenerRegistration( + private val addListeners: () -> Unit, + private val removeListeners: () -> Unit, +) { + var isWanted = false + private set + + private var isAdded = false + + /** Starts forwarding the events, and keeps doing so until [remove]. */ + fun add() { + isWanted = true + restore() + } + + fun remove() { + isWanted = false + if (isAdded) { + isAdded = false + removeListeners() + } + } + + /** The SDK dropped every listener, as it does in `onActivityPaused`. */ + fun onRemovedBySdk() { + isAdded = false + } + + /** Adds the listeners again if they are wanted and were dropped. */ + fun restore() { + if (isWanted && !isAdded) { + addListeners() + isAdded = true + } + } +} diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/src/main/kotlin/io/flutter/plugins/firebase/inappmessaging/FirebaseInAppMessagingPlugin.kt b/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/src/main/kotlin/io/flutter/plugins/firebase/inappmessaging/FirebaseInAppMessagingPlugin.kt index 7c6a1c54acfb..3c95f34d4530 100644 --- a/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/src/main/kotlin/io/flutter/plugins/firebase/inappmessaging/FirebaseInAppMessagingPlugin.kt +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/src/main/kotlin/io/flutter/plugins/firebase/inappmessaging/FirebaseInAppMessagingPlugin.kt @@ -77,7 +77,25 @@ class FirebaseInAppMessagingPlugin : flutterApi?.onMessageDisplayError(campaignMetadata(inAppMessage), errorReason.name) {} } - private var eventListenersAdded = false + private val eventListeners = + EventListenerRegistration( + addListeners = { + FirebaseInAppMessaging.getInstance().apply { + addClickListener(clickListener, mainThreadExecutor) + addImpressionListener(impressionListener, mainThreadExecutor) + addDismissListener(dismissListener, mainThreadExecutor) + addDisplayErrorListener(displayErrorListener, mainThreadExecutor) + } + }, + removeListeners = { + FirebaseInAppMessaging.getInstance().apply { + removeClickListener(clickListener) + removeImpressionListener(impressionListener) + removeDismissListener(dismissListener) + removeDisplayErrorListener(displayErrorListener) + } + }, + ) private val messageDisplay = object : FirebaseInAppMessagingDisplay { @@ -120,12 +138,15 @@ class FirebaseInAppMessagingPlugin : if (customDisplayEnabled) { FirebaseInAppMessaging.getInstance().setMessageDisplayComponent(messageDisplay) } + eventListeners.restore() } override fun onActivityPaused(activity: Activity) { if (lastActivity?.get() === activity) { lastActivity = null } + // `firebase-inappmessaging-display` removes all listeners here. + eventListeners.onRemovedBySdk() } override fun onActivityStopped(activity: Activity) {} @@ -149,7 +170,8 @@ class FirebaseInAppMessagingPlugin : override fun onDetachedFromEngine(binding: FlutterPluginBinding) { setCustomDisplayEnabledInternal(false) - removeEventListeners() + eventListeners.remove() + updateLifecycleCallbacks() binaryMessenger = null flutterApi = null FirebaseInAppMessagingHostApi.setUp(binding.binaryMessenger, null) @@ -164,22 +186,12 @@ class FirebaseInAppMessagingPlugin : ) } - private fun removeEventListeners() { - if (!eventListenersAdded) return - eventListenersAdded = false - - FirebaseInAppMessaging.getInstance().apply { - removeClickListener(clickListener) - removeImpressionListener(impressionListener) - removeDismissListener(dismissListener) - removeDisplayErrorListener(displayErrorListener) - } - } - override fun addEventListeners(appName: String, callback: (Result) -> Unit) { - FlutterFirebasePlugin.cachedThreadPool.execute { + // On the main thread, where the SDK removes the listeners on pause. + mainHandler.post { try { - addEventListenersInternal() + eventListeners.add() + updateLifecycleCallbacks() callback(Result.success(Unit)) } catch (exception: Exception) { handleFailure(callback, exception) @@ -313,50 +325,39 @@ class FirebaseInAppMessagingPlugin : private fun setCustomDisplayEnabledInternal(enabled: Boolean) { customDisplayEnabled = enabled - val fiam = FirebaseInAppMessaging.getInstance() + updateLifecycleCallbacks() if (enabled) { - fiam.setMessageDisplayComponent(messageDisplay) - if (!lifecycleRegistered) { - application?.registerActivityLifecycleCallbacks(lifecycleCallbacks) - lifecycleRegistered = true - } + FirebaseInAppMessaging.getInstance().setMessageDisplayComponent(messageDisplay) } else { - if (lifecycleRegistered) { - application?.unregisterActivityLifecycleCallbacks(lifecycleCallbacks) - lifecycleRegistered = false - } dismissAllPending() restoreDefaultDisplay() } } + /// Registered only once Dart uses the plugin, so they run after the ones + /// `firebase-inappmessaging-display` registers when Firebase initializes. + private fun updateLifecycleCallbacks() { + val needed = customDisplayEnabled || eventListeners.isWanted + if (needed && !lifecycleRegistered) { + application?.registerActivityLifecycleCallbacks(lifecycleCallbacks) + lifecycleRegistered = true + } else if (!needed && lifecycleRegistered) { + application?.unregisterActivityLifecycleCallbacks(lifecycleCallbacks) + lifecycleRegistered = false + } + } + /// Hands rendering back to `firebase-inappmessaging-display`. That SDK /// overwrites the display component on activity resume, so we force a - /// rebind; `onActivityPaused` also calls `removeAllListeners()`, so any - /// lifecycle listeners attached by this plugin are registered again. + /// rebind; `onActivityPaused` also calls `removeAllListeners()`, so the + /// lifecycle listeners are registered again. private fun restoreDefaultDisplay() { val activity = lastActivity?.get() ?: return val defaultDisplay = FirebaseInAppMessagingDefaultDisplay.getInstance() - val shouldRestoreListeners = eventListenersAdded defaultDisplay.onActivityPaused(activity) + eventListeners.onRemovedBySdk() defaultDisplay.onActivityResumed(activity) - if (shouldRestoreListeners) { - eventListenersAdded = false - addEventListenersInternal() - } - } - - private fun addEventListenersInternal() { - if (eventListenersAdded) { - return - } - FirebaseInAppMessaging.getInstance().apply { - addClickListener(clickListener, mainThreadExecutor) - addImpressionListener(impressionListener, mainThreadExecutor) - addDismissListener(dismissListener, mainThreadExecutor) - addDisplayErrorListener(displayErrorListener, mainThreadExecutor) - } - eventListenersAdded = true + eventListeners.restore() } private fun dismissAllPending() { diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/src/test/kotlin/io/flutter/plugins/firebase/inappmessaging/EventListenerRegistrationTest.kt b/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/src/test/kotlin/io/flutter/plugins/firebase/inappmessaging/EventListenerRegistrationTest.kt new file mode 100644 index 000000000000..14234fcfa410 --- /dev/null +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/src/test/kotlin/io/flutter/plugins/firebase/inappmessaging/EventListenerRegistrationTest.kt @@ -0,0 +1,74 @@ +// Copyright 2026 The Chromium Authors. 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.inappmessaging + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class EventListenerRegistrationTest { + // Stands in for the listeners registered with `FirebaseInAppMessaging`. + private var sdkHasListeners = false + private var addCount = 0 + + private val registration = + EventListenerRegistration( + addListeners = { + sdkHasListeners = true + addCount++ + }, + removeListeners = { sdkHasListeners = false }, + ) + + /** What `FirebaseInAppMessagingDisplay` and the plugin do on pause and resume. */ + private fun pauseAndResumeActivity() { + sdkHasListeners = false // `removeAllListeners()` + registration.onRemovedBySdk() + registration.restore() + } + + @Test + fun add_registersTheListenersOnce() { + registration.add() + registration.add() + + assertTrue(sdkHasListeners) + assertEquals(1, addCount) + } + + @Test + fun listenersAreAddedAgainAfterAnActivityPause() { + registration.add() + + pauseAndResumeActivity() + assertTrue(sdkHasListeners) + + pauseAndResumeActivity() + assertTrue(sdkHasListeners) + assertEquals(3, addCount) + } + + @Test + fun resumeDoesNotAddListenersThatAreNotWanted() { + pauseAndResumeActivity() + + assertFalse(sdkHasListeners) + assertEquals(0, addCount) + } + + @Test + fun remove_stopsAddingTheListenersAgain() { + registration.add() + registration.remove() + assertFalse(sdkHasListeners) + + pauseAndResumeActivity() + assertFalse(sdkHasListeners) + assertFalse(registration.isWanted) + } +} diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging/lib/firebase_in_app_messaging.dart b/packages/firebase_in_app_messaging/firebase_in_app_messaging/lib/firebase_in_app_messaging.dart index 018d24c5cd6c..d50e2201d548 100644 --- a/packages/firebase_in_app_messaging/firebase_in_app_messaging/lib/firebase_in_app_messaging.dart +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging/lib/firebase_in_app_messaging.dart @@ -111,7 +111,8 @@ class FirebaseInAppMessaging extends FirebasePlugin { /// When [enabled] is `true`, native modal / card / banner / image-only /// templates are not shown. Eligible campaigns are delivered on /// [onMessageDisplay] instead. Call this after [Firebase.initializeApp] - /// and before campaigns may trigger. + /// and before campaigns may trigger, and listen to [onMessageDisplay] + /// first. /// /// Apps must report [InAppMessage.impress], [InAppMessage.click], or /// [InAppMessage.dismiss] so analytics and frequency capping keep working. @@ -129,5 +130,10 @@ class FirebaseInAppMessaging extends FirebasePlugin { /// This is not Firebase Cloud Messaging's `onMessage`, and it is not /// [onMessageClicked]. It fires only after [setCustomDisplayEnabled] is /// `true`, at the moment the SDK would have drawn a native template. + /// + /// Listen before enabling custom display. A message that arrives while + /// nothing listens is held for the first listener for a few seconds, then + /// reported as a display error, because the SDK shows no other campaign + /// until the current one is reported. Stream get onMessageDisplay => _delegate.onMessageDisplay; } diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/lib/src/method_channel/method_channel_firebase_in_app_messaging.dart b/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/lib/src/method_channel/method_channel_firebase_in_app_messaging.dart index a33115d1804a..aff68eb8c495 100644 --- a/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/lib/src/method_channel/method_channel_firebase_in_app_messaging.dart +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/lib/src/method_channel/method_channel_firebase_in_app_messaging.dart @@ -70,7 +70,7 @@ class _FirebaseInAppMessagingFlutterApi @override void onMessageDisplay(pigeon.FiamDisplayMessage message) { - MethodChannelFirebaseInAppMessaging.displayController.add( + MethodChannelFirebaseInAppMessaging._addDisplay( inAppMessageFromPigeon(message), ); } @@ -142,7 +142,17 @@ class MethodChannelFirebaseInAppMessaging @visibleForTesting // ignore: close_sinks - static final displayController = StreamController.broadcast(); + static final displayController = StreamController.broadcast( + onListen: _deliverPendingDisplay, + ); + + /// How long a message that arrives while nothing listens to + /// [onMessageDisplay] is held for the first listener. + @visibleForTesting + static Duration pendingDisplayTimeout = const Duration(seconds: 5); + + static InAppMessage? _pendingDisplay; + static Timer? _pendingDisplayTimer; static bool _eventListenersAdded = false; static bool _flutterApiSetUp = false; @@ -218,6 +228,10 @@ class MethodChannelFirebaseInAppMessaging @override Future setCustomDisplayEnabled(bool enabled) async { _ensureFlutterApi(); + if (!enabled) { + // Native dismisses the message itself. + _takePendingDisplay(); + } try { await pigeonChannel.setCustomDisplayEnabled(app!.name, enabled); } catch (e, s) { @@ -261,6 +275,54 @@ class MethodChannelFirebaseInAppMessaging } } + static void _addDisplay(InAppMessage message) { + if (displayController.hasListener) { + displayController.add(message); + return; + } + // A broadcast stream drops events nobody listens to, but the native SDKs + // wait for every message to be reported: iOS shows no other campaign + // until then. Hold the message for a listener, and report it if none + // arrives. + final replaced = _takePendingDisplay(); + // Native keeps one message per campaign, which is now [message]. + if (replaced != null && + replaced.campaignMetadata.campaignId != + message.campaignMetadata.campaignId) { + _reportUndelivered(replaced); + } + _pendingDisplay = message; + _pendingDisplayTimer = Timer(pendingDisplayTimeout, () { + final undelivered = _takePendingDisplay(); + if (undelivered != null) { + _reportUndelivered(undelivered); + } + }); + } + + static InAppMessage? _takePendingDisplay() { + final message = _pendingDisplay; + _pendingDisplay = null; + _pendingDisplayTimer?.cancel(); + _pendingDisplayTimer = null; + return message; + } + + static void _deliverPendingDisplay() { + final message = _takePendingDisplay(); + if (message != null) { + displayController.add(message); + } + } + + /// Reported as a display error rather than a dismissal, which both SDKs + /// would count as an impression of a message nobody saw. + static void _reportUndelivered(InAppMessage message) { + message + .reportError('No listener on onMessageDisplay') + .catchError((Object _) {}); + } + void _ensureFlutterApi() { if (_flutterApiSetUp) { return; diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/test/method_channel/method_channel_firebase_in_app_messaging_test.dart b/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/test/method_channel/method_channel_firebase_in_app_messaging_test.dart index 7a1f88043b31..30ff5c8e736b 100644 --- a/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/test/method_channel/method_channel_firebase_in_app_messaging_test.dart +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/test/method_channel/method_channel_firebase_in_app_messaging_test.dart @@ -183,6 +183,115 @@ void main() { await message.impress(); expect(hostApi.campaignId, 'campaign-id'); }); + + group('onMessageDisplay without a listener', () { + final defaultTimeout = + MethodChannelFirebaseInAppMessaging.pendingDisplayTimeout; + + setUp(() async { + MethodChannelFirebaseInAppMessaging.pendingDisplayTimeout = + const Duration(milliseconds: 50); + await inAppMessaging.setCustomDisplayEnabled(true); + hostApi.reset(); + }); + + tearDown(() { + MethodChannelFirebaseInAppMessaging.pendingDisplayTimeout = + defaultTimeout; + }); + + test('holds the message for the first listener', () async { + await _sendFlutterApiMessage('onMessageDisplay', [ + _displayMessage('held-campaign'), + ]); + + final messages = []; + final subscription = inAppMessaging.onMessageDisplay.listen( + messages.add, + ); + addTearDown(subscription.cancel); + await pumpEventQueue(); + + expect(messages, hasLength(1)); + expect(messages.single.campaignMetadata.campaignId, 'held-campaign'); + + // The held message was delivered, so it is not reported. + await Future.delayed(_pastPendingDisplayTimeout); + expect(hostApi.campaignId, isNull); + }); + + test('reports a display error when nobody listens', () async { + await _sendFlutterApiMessage('onMessageDisplay', [ + _displayMessage('unheard-campaign'), + ]); + + await Future.delayed(_pastPendingDisplayTimeout); + + expect(hostApi.campaignId, 'unheard-campaign'); + expect(hostApi.errorReason, isNotNull); + expect(hostApi.dismissType, isNull); + }); + + test('reports a held message that a newer one replaces', () async { + await _sendFlutterApiMessage('onMessageDisplay', [ + _displayMessage('older-campaign'), + ]); + await _sendFlutterApiMessage('onMessageDisplay', [ + _displayMessage('newer-campaign'), + ]); + await pumpEventQueue(); + + expect(hostApi.campaignId, 'older-campaign'); + expect(hostApi.errorReason, isNotNull); + + final message = await inAppMessaging.onMessageDisplay.first; + expect(message.campaignMetadata.campaignId, 'newer-campaign'); + }); + + test('does not report a held message that its campaign replaces', () async { + await _sendFlutterApiMessage('onMessageDisplay', [ + _displayMessage('same-campaign'), + ]); + await _sendFlutterApiMessage('onMessageDisplay', [ + _displayMessage('same-campaign'), + ]); + + final message = await inAppMessaging.onMessageDisplay.first; + expect(message.campaignMetadata.campaignId, 'same-campaign'); + await Future.delayed(_pastPendingDisplayTimeout); + expect(hostApi.campaignId, isNull); + }); + + test('drops the held message when custom display is disabled', () async { + await _sendFlutterApiMessage('onMessageDisplay', [ + _displayMessage('disabled-campaign'), + ]); + await inAppMessaging.setCustomDisplayEnabled(false); + + final messages = []; + final subscription = inAppMessaging.onMessageDisplay.listen( + messages.add, + ); + addTearDown(subscription.cancel); + await Future.delayed(_pastPendingDisplayTimeout); + + expect(messages, isEmpty); + expect(hostApi.campaignId, isNull); + }); + }); +} + +const _pastPendingDisplayTimeout = Duration(milliseconds: 100); + +pigeon.FiamDisplayMessage _displayMessage(String campaignId) { + return pigeon.FiamDisplayMessage( + campaignMetadata: pigeon.FiamCampaignMetadata( + campaignId: campaignId, + campaignName: 'campaign-name', + isTestMessage: false, + ), + messageType: 'BANNER', + ); } /// Simulates the native side calling [pigeon.FirebaseInAppMessagingFlutterApi]. From ac1abb81c4e7aed2367fe4597086c180c190b2c5 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Wed, 21 Oct 2026 15:37:08 +0200 Subject: [PATCH 2/3] fix(in_app_messaging): report unheard display messages only on iOS A message that arrives on `onMessageDisplay` while nothing listens is still held for the first listener for a few seconds. When none arrives: - iOS reports it as a display error, as before, so the SDK releases its display lock and can show the next campaign. `onMessageDisplayError` fires for it. - Android now only drops it. `displayErrorEncountered` stores a campaign impression there, and for app open campaigns uses up the one a day app open limit, for a message nobody saw. Android has no display lock, so the campaign stays eligible and triggers again. On Android, disabling custom display while it is already off, which `onDetachedFromEngine` also does, no longer pauses and resumes the default display on the current activity. That restarted a default message on screen and dropped the listeners of other engines. The plugin's SDK calls now go through an internal seam, so JUnit tests drive its activity lifecycle wiring against a fake `Application`. --- docs/in-app-messaging/_customize-messages.md | 9 +- .../firebase_in_app_messaging/README.md | 4 +- .../android/build.gradle | 4 - .../FirebaseInAppMessagingPlugin.kt | 83 ++++--- .../inappmessaging/InAppMessagingSdk.kt | 83 +++++++ .../FirebaseInAppMessagingPluginTest.kt | 228 ++++++++++++++++++ .../lib/firebase_in_app_messaging.dart | 8 +- ...hod_channel_firebase_in_app_messaging.dart | 24 +- ...hannel_firebase_in_app_messaging_test.dart | 207 ++++++++++------ 9 files changed, 524 insertions(+), 126 deletions(-) create mode 100644 packages/firebase_in_app_messaging/firebase_in_app_messaging/android/src/main/kotlin/io/flutter/plugins/firebase/inappmessaging/InAppMessagingSdk.kt create mode 100644 packages/firebase_in_app_messaging/firebase_in_app_messaging/android/src/test/kotlin/io/flutter/plugins/firebase/inappmessaging/FirebaseInAppMessagingPluginTest.kt diff --git a/docs/in-app-messaging/_customize-messages.md b/docs/in-app-messaging/_customize-messages.md index 78d0c0b3e38b..0382e2a1b4b7 100644 --- a/docs/in-app-messaging/_customize-messages.md +++ b/docs/in-app-messaging/_customize-messages.md @@ -55,10 +55,11 @@ Until `setCustomDisplayEnabled(true)` is called, native templates keep working. Listening to `onMessageDisplay` alone does not replace native UI. Start listening to `onMessageDisplay` before you call -`setCustomDisplayEnabled(true)`. The SDK shows no other campaign until the -current one is reported, so a message that arrives while nothing listens is -held for the first listener for a few seconds, then reported as a display -error. +`setCustomDisplayEnabled(true)`. A message that arrives while nothing listens +is held for the first listener for a few seconds, then dropped. On iOS, the SDK +shows no other campaign until the current one is reported, so the plugin also +reports the message as a display error, and `onMessageDisplayError` fires for +it. If you also listen to `onMessageClicked` / `onMessageImpression`, those lifecycle streams still fire when your Flutter UI reports click and impress. diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging/README.md b/packages/firebase_in_app_messaging/firebase_in_app_messaging/README.md index 9f7c10cec366..e4119f48454f 100644 --- a/packages/firebase_in_app_messaging/firebase_in_app_messaging/README.md +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging/README.md @@ -45,7 +45,9 @@ await FirebaseInAppMessaging.instance.setCustomDisplayEnabled(true); Listen to `onMessageDisplay` before calling `setCustomDisplayEnabled(true)`. A message that arrives while nothing listens is held for a few seconds, then -reported as a display error so the SDK can show the next one. +dropped. On iOS, where the SDK shows no other campaign until the current one is +reported, the plugin also reports it as a display error, so +`onMessageDisplayError` fires for it. The plugin does not open action URLs. You must report impress / click / dismiss so campaign analytics keep working. diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/build.gradle b/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/build.gradle index f52faa7e4702..24705c39e4e8 100644 --- a/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/build.gradle +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/build.gradle @@ -84,10 +84,6 @@ android { implementation 'androidx.annotation:annotation:1.7.0' testImplementation 'junit:junit:4.13.2' } - - testOptions { - unitTests.returnDefaultValues = true - } } plugins.withId("org.jetbrains.kotlin.android") { diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/src/main/kotlin/io/flutter/plugins/firebase/inappmessaging/FirebaseInAppMessagingPlugin.kt b/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/src/main/kotlin/io/flutter/plugins/firebase/inappmessaging/FirebaseInAppMessagingPlugin.kt index 3c95f34d4530..2a561aea4ac2 100644 --- a/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/src/main/kotlin/io/flutter/plugins/firebase/inappmessaging/FirebaseInAppMessagingPlugin.kt +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/src/main/kotlin/io/flutter/plugins/firebase/inappmessaging/FirebaseInAppMessagingPlugin.kt @@ -18,7 +18,6 @@ import com.google.firebase.inappmessaging.FirebaseInAppMessagingDisplay import com.google.firebase.inappmessaging.FirebaseInAppMessagingDisplayCallbacks import com.google.firebase.inappmessaging.FirebaseInAppMessagingDisplayErrorListener import com.google.firebase.inappmessaging.FirebaseInAppMessagingImpressionListener -import com.google.firebase.inappmessaging.display.FirebaseInAppMessagingDisplay as FirebaseInAppMessagingDefaultDisplay import com.google.firebase.inappmessaging.model.Action import com.google.firebase.inappmessaging.model.CardMessage import com.google.firebase.inappmessaging.model.InAppMessage @@ -34,8 +33,15 @@ import java.util.concurrent.Executor import kotlin.jvm.Volatile /** FirebaseInAppMessagingPlugin */ -class FirebaseInAppMessagingPlugin : - FlutterFirebasePlugin, FlutterPlugin, FirebaseInAppMessagingHostApi { +class FirebaseInAppMessagingPlugin +internal constructor( + // Runs on the main thread, where events are sent to Dart and the SDK + // removes the listeners on activity pause. + private val mainExecutor: Executor, + private val sdk: InAppMessagingSdk, +) : FlutterFirebasePlugin, FlutterPlugin, FirebaseInAppMessagingHostApi { + constructor() : this(MainThreadExecutor(), FirebaseInAppMessagingSdk) + private var binaryMessenger: BinaryMessenger? = null private var flutterApi: FirebaseInAppMessagingFlutterApi? = null private var application: Application? = null @@ -43,19 +49,12 @@ class FirebaseInAppMessagingPlugin : private var lifecycleRegistered = false private var lastActivity: WeakReference? = null private val pendingDisplays = ConcurrentHashMap() - private val mainHandler = Handler(Looper.getMainLooper()) private data class PendingDisplay( val callbacks: FirebaseInAppMessagingDisplayCallbacks, val actions: Map, ) - // The listeners below forward events to Dart, which has to happen on the main - // thread, so they are registered with a main thread executor. - private val mainThreadExecutor = Executor { command -> - Handler(Looper.getMainLooper()).post(command) - } - private val clickListener = FirebaseInAppMessagingClickListener { inAppMessage, action -> flutterApi?.onMessageClicked( campaignMetadata(inAppMessage), @@ -80,20 +79,21 @@ class FirebaseInAppMessagingPlugin : private val eventListeners = EventListenerRegistration( addListeners = { - FirebaseInAppMessaging.getInstance().apply { - addClickListener(clickListener, mainThreadExecutor) - addImpressionListener(impressionListener, mainThreadExecutor) - addDismissListener(dismissListener, mainThreadExecutor) - addDisplayErrorListener(displayErrorListener, mainThreadExecutor) - } + sdk.addListeners( + clickListener, + impressionListener, + dismissListener, + displayErrorListener, + mainExecutor, + ) }, removeListeners = { - FirebaseInAppMessaging.getInstance().apply { - removeClickListener(clickListener) - removeImpressionListener(impressionListener) - removeDismissListener(dismissListener) - removeDisplayErrorListener(displayErrorListener) - } + sdk.removeListeners( + clickListener, + impressionListener, + dismissListener, + displayErrorListener, + ) }, ) @@ -110,7 +110,7 @@ class FirebaseInAppMessagingPlugin : val message = toDisplayMessage(inAppMessage) val actions = collectActions(inAppMessage) - mainHandler.post { + mainExecutor.execute { val api = flutterApi if (api != null && customDisplayEnabled) { pendingDisplays[message.campaignMetadata.campaignId] = @@ -136,7 +136,7 @@ class FirebaseInAppMessagingPlugin : override fun onActivityResumed(activity: Activity) { lastActivity = WeakReference(activity) if (customDisplayEnabled) { - FirebaseInAppMessaging.getInstance().setMessageDisplayComponent(messageDisplay) + sdk.setMessageDisplayComponent(messageDisplay) } eventListeners.restore() } @@ -164,17 +164,25 @@ class FirebaseInAppMessagingPlugin : } override fun onAttachedToEngine(binding: FlutterPluginBinding) { - application = binding.applicationContext as? Application - initInstance(binding.binaryMessenger) + onAttachedToEngine(binding.applicationContext as? Application, binding.binaryMessenger) + } + + internal fun onAttachedToEngine(application: Application?, messenger: BinaryMessenger) { + this.application = application + initInstance(messenger) } override fun onDetachedFromEngine(binding: FlutterPluginBinding) { + onDetachedFromEngine(binding.binaryMessenger) + } + + internal fun onDetachedFromEngine(messenger: BinaryMessenger) { setCustomDisplayEnabledInternal(false) eventListeners.remove() updateLifecycleCallbacks() binaryMessenger = null flutterApi = null - FirebaseInAppMessagingHostApi.setUp(binding.binaryMessenger, null) + FirebaseInAppMessagingHostApi.setUp(messenger, null) } private fun campaignMetadata(inAppMessage: InAppMessage): FiamCampaignMetadata { @@ -188,7 +196,7 @@ class FirebaseInAppMessagingPlugin : override fun addEventListeners(appName: String, callback: (Result) -> Unit) { // On the main thread, where the SDK removes the listeners on pause. - mainHandler.post { + mainExecutor.execute { try { eventListeners.add() updateLifecycleCallbacks() @@ -245,7 +253,7 @@ class FirebaseInAppMessagingPlugin : enabled: Boolean, callback: (Result) -> Unit, ) { - mainHandler.post { + mainExecutor.execute { try { setCustomDisplayEnabledInternal(enabled) callback(Result.success(Unit)) @@ -324,10 +332,13 @@ class FirebaseInAppMessagingPlugin : } private fun setCustomDisplayEnabledInternal(enabled: Boolean) { + // The default display is already in place. Rebinding it would restart a + // message it shows, and drop every engine's listeners. + if (!enabled && !customDisplayEnabled) return customDisplayEnabled = enabled updateLifecycleCallbacks() if (enabled) { - FirebaseInAppMessaging.getInstance().setMessageDisplayComponent(messageDisplay) + sdk.setMessageDisplayComponent(messageDisplay) } else { dismissAllPending() restoreDefaultDisplay() @@ -353,10 +364,8 @@ class FirebaseInAppMessagingPlugin : /// lifecycle listeners are registered again. private fun restoreDefaultDisplay() { val activity = lastActivity?.get() ?: return - val defaultDisplay = FirebaseInAppMessagingDefaultDisplay.getInstance() - defaultDisplay.onActivityPaused(activity) + sdk.rebindDefaultDisplay(activity) eventListeners.onRemovedBySdk() - defaultDisplay.onActivityResumed(activity) eventListeners.restore() } @@ -509,6 +518,14 @@ class FirebaseInAppMessagingPlugin : return taskCompletionSource.task } + private class MainThreadExecutor : Executor { + private val handler = Handler(Looper.getMainLooper()) + + override fun execute(command: Runnable) { + handler.post(command) + } + } + companion object { private const val METHOD_CHANNEL_NAME = "plugins.flutter.io/firebase_in_app_messaging" } diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/src/main/kotlin/io/flutter/plugins/firebase/inappmessaging/InAppMessagingSdk.kt b/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/src/main/kotlin/io/flutter/plugins/firebase/inappmessaging/InAppMessagingSdk.kt new file mode 100644 index 000000000000..a4d733b24876 --- /dev/null +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/src/main/kotlin/io/flutter/plugins/firebase/inappmessaging/InAppMessagingSdk.kt @@ -0,0 +1,83 @@ +// Copyright 2026 The Chromium Authors. 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.inappmessaging + +import android.app.Activity +import com.google.firebase.inappmessaging.FirebaseInAppMessaging +import com.google.firebase.inappmessaging.FirebaseInAppMessagingClickListener +import com.google.firebase.inappmessaging.FirebaseInAppMessagingDismissListener +import com.google.firebase.inappmessaging.FirebaseInAppMessagingDisplay +import com.google.firebase.inappmessaging.FirebaseInAppMessagingDisplayErrorListener +import com.google.firebase.inappmessaging.FirebaseInAppMessagingImpressionListener +import com.google.firebase.inappmessaging.display.FirebaseInAppMessagingDisplay as FirebaseInAppMessagingDefaultDisplay +import java.util.concurrent.Executor + +/** + * The calls the plugin makes to the Firebase SDKs around activity lifecycle events, faked in tests. + */ +internal interface InAppMessagingSdk { + fun addListeners( + click: FirebaseInAppMessagingClickListener, + impression: FirebaseInAppMessagingImpressionListener, + dismiss: FirebaseInAppMessagingDismissListener, + displayError: FirebaseInAppMessagingDisplayErrorListener, + executor: Executor, + ) + + fun removeListeners( + click: FirebaseInAppMessagingClickListener, + impression: FirebaseInAppMessagingImpressionListener, + dismiss: FirebaseInAppMessagingDismissListener, + displayError: FirebaseInAppMessagingDisplayErrorListener, + ) + + fun setMessageDisplayComponent(display: FirebaseInAppMessagingDisplay) + + /** + * Makes `firebase-inappmessaging-display` the display component again, by running its activity + * pause and resume. The pause removes every listener. + */ + fun rebindDefaultDisplay(activity: Activity) +} + +internal object FirebaseInAppMessagingSdk : InAppMessagingSdk { + override fun addListeners( + click: FirebaseInAppMessagingClickListener, + impression: FirebaseInAppMessagingImpressionListener, + dismiss: FirebaseInAppMessagingDismissListener, + displayError: FirebaseInAppMessagingDisplayErrorListener, + executor: Executor, + ) { + FirebaseInAppMessaging.getInstance().apply { + addClickListener(click, executor) + addImpressionListener(impression, executor) + addDismissListener(dismiss, executor) + addDisplayErrorListener(displayError, executor) + } + } + + override fun removeListeners( + click: FirebaseInAppMessagingClickListener, + impression: FirebaseInAppMessagingImpressionListener, + dismiss: FirebaseInAppMessagingDismissListener, + displayError: FirebaseInAppMessagingDisplayErrorListener, + ) { + FirebaseInAppMessaging.getInstance().apply { + removeClickListener(click) + removeImpressionListener(impression) + removeDismissListener(dismiss) + removeDisplayErrorListener(displayError) + } + } + + override fun setMessageDisplayComponent(display: FirebaseInAppMessagingDisplay) { + FirebaseInAppMessaging.getInstance().setMessageDisplayComponent(display) + } + + override fun rebindDefaultDisplay(activity: Activity) { + val defaultDisplay = FirebaseInAppMessagingDefaultDisplay.getInstance() + defaultDisplay.onActivityPaused(activity) + defaultDisplay.onActivityResumed(activity) + } +} diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/src/test/kotlin/io/flutter/plugins/firebase/inappmessaging/FirebaseInAppMessagingPluginTest.kt b/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/src/test/kotlin/io/flutter/plugins/firebase/inappmessaging/FirebaseInAppMessagingPluginTest.kt new file mode 100644 index 000000000000..3f2063865f32 --- /dev/null +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/src/test/kotlin/io/flutter/plugins/firebase/inappmessaging/FirebaseInAppMessagingPluginTest.kt @@ -0,0 +1,228 @@ +// Copyright 2026 The Chromium Authors. 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.inappmessaging + +import android.app.Activity +import android.app.Application +import android.os.Bundle +import com.google.firebase.inappmessaging.FirebaseInAppMessagingClickListener +import com.google.firebase.inappmessaging.FirebaseInAppMessagingDismissListener +import com.google.firebase.inappmessaging.FirebaseInAppMessagingDisplay +import com.google.firebase.inappmessaging.FirebaseInAppMessagingDisplayErrorListener +import com.google.firebase.inappmessaging.FirebaseInAppMessagingImpressionListener +import io.flutter.plugin.common.BinaryMessenger +import java.nio.ByteBuffer +import java.util.concurrent.Executor +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class FirebaseInAppMessagingPluginTest { + private val sdk = FakeSdk() + private val application = FakeApplication() + private val activity = Activity() + private val messenger = FakeBinaryMessenger() + + @Before + fun setUp() { + // `firebase-inappmessaging-display` registers its callbacks when Firebase + // initializes, before any plugin does. + application.registerActivityLifecycleCallbacks(sdk.defaultDisplayLifecycle) + } + + private fun attachPlugin(): FirebaseInAppMessagingPlugin = + FirebaseInAppMessagingPlugin(Executor { it.run() }, sdk).apply { + onAttachedToEngine(application, messenger) + } + + private fun FirebaseInAppMessagingPlugin.addEventListeners() { + var result: Result? = null + addEventListeners("[DEFAULT]") { result = it } + assertTrue(result!!.isSuccess) + } + + private fun FirebaseInAppMessagingPlugin.setCustomDisplayEnabled(enabled: Boolean) { + var result: Result? = null + setCustomDisplayEnabled("[DEFAULT]", enabled) { result = it } + assertTrue(result!!.isSuccess) + } + + @Test + fun addEventListeners_addsTheListeners() { + attachPlugin().addEventListeners() + + assertEquals(4, sdk.listeners.size) + } + + @Test + fun listenersAreAddedAgainWhenAnActivityResumes() { + attachPlugin().addEventListeners() + application.resume(activity) + + application.pause(activity) + assertEquals(0, sdk.listeners.size) + application.resume(activity) + assertEquals(4, sdk.listeners.size) + + application.pause(activity) + application.resume(activity) + assertEquals(4, sdk.listeners.size) + } + + @Test + fun onDetachedFromEngine_stopsAddingTheListeners() { + val plugin = attachPlugin() + plugin.addEventListeners() + + plugin.onDetachedFromEngine(messenger) + assertEquals(0, sdk.listeners.size) + + application.pause(activity) + application.resume(activity) + assertEquals(0, sdk.listeners.size) + assertEquals(listOf(sdk.defaultDisplayLifecycle), application.callbacks) + } + + @Test + fun disablingCustomDisplay_rebindsTheDefaultDisplayAndKeepsTheListeners() { + val plugin = attachPlugin() + plugin.addEventListeners() + plugin.setCustomDisplayEnabled(true) + application.resume(activity) + assertNotNull(sdk.displayComponent) + assertNotSame(sdk.defaultDisplay, sdk.displayComponent) + + plugin.setCustomDisplayEnabled(false) + + assertEquals(1, sdk.defaultDisplayRebinds) + assertSame(sdk.defaultDisplay, sdk.displayComponent) + assertEquals(4, sdk.listeners.size) + } + + @Test + fun disablingCustomDisplayThatIsOff_leavesTheDefaultDisplayAlone() { + val plugin = attachPlugin() + plugin.addEventListeners() + // Another Flutter engine's plugin. + attachPlugin().addEventListeners() + application.resume(activity) + + plugin.setCustomDisplayEnabled(false) + + assertEquals(0, sdk.defaultDisplayRebinds) + assertEquals(8, sdk.listeners.size) + } + + @Test + fun onDetachedFromEngine_withCustomDisplayOff_leavesTheDefaultDisplayAlone() { + val plugin = attachPlugin() + plugin.addEventListeners() + application.resume(activity) + + plugin.onDetachedFromEngine(messenger) + + assertEquals(0, sdk.defaultDisplayRebinds) + } +} + +/** Stands in for `FirebaseInAppMessaging` and `firebase-inappmessaging-display`. */ +private class FakeSdk : InAppMessagingSdk { + val listeners = mutableListOf() + var displayComponent: FirebaseInAppMessagingDisplay? = null + var defaultDisplayRebinds = 0 + val defaultDisplay = FirebaseInAppMessagingDisplay { _, _ -> } + + /** What `firebase-inappmessaging-display` does on activity pause and resume. */ + val defaultDisplayLifecycle = + object : TestActivityLifecycleCallbacks() { + override fun onActivityResumed(activity: Activity) { + displayComponent = defaultDisplay + } + + override fun onActivityPaused(activity: Activity) { + // `removeAllListeners()` + listeners.clear() + } + } + + override fun addListeners( + click: FirebaseInAppMessagingClickListener, + impression: FirebaseInAppMessagingImpressionListener, + dismiss: FirebaseInAppMessagingDismissListener, + displayError: FirebaseInAppMessagingDisplayErrorListener, + executor: Executor, + ) { + listeners.addAll(listOf(click, impression, dismiss, displayError)) + } + + override fun removeListeners( + click: FirebaseInAppMessagingClickListener, + impression: FirebaseInAppMessagingImpressionListener, + dismiss: FirebaseInAppMessagingDismissListener, + displayError: FirebaseInAppMessagingDisplayErrorListener, + ) { + listeners.removeAll(listOf(click, impression, dismiss, displayError)) + } + + override fun setMessageDisplayComponent(display: FirebaseInAppMessagingDisplay) { + displayComponent = display + } + + override fun rebindDefaultDisplay(activity: Activity) { + defaultDisplayRebinds++ + defaultDisplayLifecycle.onActivityPaused(activity) + defaultDisplayLifecycle.onActivityResumed(activity) + } +} + +private class FakeApplication : Application() { + val callbacks = mutableListOf() + + override fun registerActivityLifecycleCallbacks(callback: ActivityLifecycleCallbacks) { + callbacks += callback + } + + override fun unregisterActivityLifecycleCallbacks(callback: ActivityLifecycleCallbacks) { + callbacks -= callback + } + + fun resume(activity: Activity) = callbacks.toList().forEach { it.onActivityResumed(activity) } + + fun pause(activity: Activity) = callbacks.toList().forEach { it.onActivityPaused(activity) } +} + +private open class TestActivityLifecycleCallbacks : Application.ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} + + override fun onActivityStarted(activity: Activity) {} + + override fun onActivityResumed(activity: Activity) {} + + override fun onActivityPaused(activity: Activity) {} + + override fun onActivityStopped(activity: Activity) {} + + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + + override fun onActivityDestroyed(activity: Activity) {} +} + +private class FakeBinaryMessenger : BinaryMessenger { + override fun send(channel: String, message: ByteBuffer?) {} + + override fun send( + channel: String, + message: ByteBuffer?, + callback: BinaryMessenger.BinaryReply? + ) {} + + override fun setMessageHandler(channel: String, handler: BinaryMessenger.BinaryMessageHandler?) {} +} diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging/lib/firebase_in_app_messaging.dart b/packages/firebase_in_app_messaging/firebase_in_app_messaging/lib/firebase_in_app_messaging.dart index d50e2201d548..92ca34561922 100644 --- a/packages/firebase_in_app_messaging/firebase_in_app_messaging/lib/firebase_in_app_messaging.dart +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging/lib/firebase_in_app_messaging.dart @@ -103,6 +103,9 @@ class FirebaseInAppMessaging extends FirebasePlugin { /// Notifies when an in-app message could not be rendered, for example /// because its image failed to download. + /// + /// On iOS, it also fires for a message that nothing listened to on + /// [onMessageDisplay], which the plugin reports as a display error. Stream get onMessageDisplayError => _delegate.onMessageDisplayError; @@ -133,7 +136,8 @@ class FirebaseInAppMessaging extends FirebasePlugin { /// /// Listen before enabling custom display. A message that arrives while /// nothing listens is held for the first listener for a few seconds, then - /// reported as a display error, because the SDK shows no other campaign - /// until the current one is reported. + /// dropped. On iOS, where the SDK shows no other campaign until the current + /// one is reported, the message is also reported as a display error, which + /// [onMessageDisplayError] notifies. Stream get onMessageDisplay => _delegate.onMessageDisplay; } diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/lib/src/method_channel/method_channel_firebase_in_app_messaging.dart b/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/lib/src/method_channel/method_channel_firebase_in_app_messaging.dart index aff68eb8c495..ed42ad3e7dea 100644 --- a/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/lib/src/method_channel/method_channel_firebase_in_app_messaging.dart +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/lib/src/method_channel/method_channel_firebase_in_app_messaging.dart @@ -280,22 +280,20 @@ class MethodChannelFirebaseInAppMessaging displayController.add(message); return; } - // A broadcast stream drops events nobody listens to, but the native SDKs - // wait for every message to be reported: iOS shows no other campaign - // until then. Hold the message for a listener, and report it if none - // arrives. + // A broadcast stream drops events nobody listens to. Hold the message for + // a listener, and release it if none arrives. final replaced = _takePendingDisplay(); // Native keeps one message per campaign, which is now [message]. if (replaced != null && replaced.campaignMetadata.campaignId != message.campaignMetadata.campaignId) { - _reportUndelivered(replaced); + _releaseUndelivered(replaced); } _pendingDisplay = message; _pendingDisplayTimer = Timer(pendingDisplayTimeout, () { final undelivered = _takePendingDisplay(); if (undelivered != null) { - _reportUndelivered(undelivered); + _releaseUndelivered(undelivered); } }); } @@ -315,9 +313,17 @@ class MethodChannelFirebaseInAppMessaging } } - /// Reported as a display error rather than a dismissal, which both SDKs - /// would count as an impression of a message nobody saw. - static void _reportUndelivered(InAppMessage message) { + /// Lets the SDK move on from a message no listener received. + /// + /// iOS shows no other campaign until the message is reported, so it is + /// reported as a display error, which iOS does not count as an impression. + /// Android has no such lock, and records a display error as an impression, + /// which for an app open campaign also uses up the one a day app open + /// limit, so there the message is only dropped. + static void _releaseUndelivered(InAppMessage message) { + if (defaultTargetPlatform != TargetPlatform.iOS) { + return; + } message .reportError('No listener on onMessageDisplay') .catchError((Object _) {}); diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/test/method_channel/method_channel_firebase_in_app_messaging_test.dart b/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/test/method_channel/method_channel_firebase_in_app_messaging_test.dart index 30ff5c8e736b..e981dbabd916 100644 --- a/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/test/method_channel/method_channel_firebase_in_app_messaging_test.dart +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/test/method_channel/method_channel_firebase_in_app_messaging_test.dart @@ -7,6 +7,7 @@ import 'package:firebase_in_app_messaging_platform_interface/firebase_in_app_mes import 'package:firebase_in_app_messaging_platform_interface/src/method_channel/method_channel_firebase_in_app_messaging.dart'; import 'package:firebase_in_app_messaging_platform_interface/src/pigeon/messages.pigeon.dart' as pigeon; +import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import '../mock.dart'; @@ -198,85 +199,145 @@ void main() { tearDown(() { MethodChannelFirebaseInAppMessaging.pendingDisplayTimeout = defaultTimeout; + debugDefaultTargetPlatformOverride = null; }); - test('holds the message for the first listener', () async { - await _sendFlutterApiMessage('onMessageDisplay', [ - _displayMessage('held-campaign'), - ]); - - final messages = []; - final subscription = inAppMessaging.onMessageDisplay.listen( - messages.add, - ); - addTearDown(subscription.cancel); - await pumpEventQueue(); - - expect(messages, hasLength(1)); - expect(messages.single.campaignMetadata.campaignId, 'held-campaign'); - - // The held message was delivered, so it is not reported. - await Future.delayed(_pastPendingDisplayTimeout); - expect(hostApi.campaignId, isNull); - }); - - test('reports a display error when nobody listens', () async { - await _sendFlutterApiMessage('onMessageDisplay', [ - _displayMessage('unheard-campaign'), - ]); - - await Future.delayed(_pastPendingDisplayTimeout); - - expect(hostApi.campaignId, 'unheard-campaign'); - expect(hostApi.errorReason, isNotNull); - expect(hostApi.dismissType, isNull); + for (final platform in [TargetPlatform.android, TargetPlatform.iOS]) { + group(platform.name, () { + setUp(() => debugDefaultTargetPlatformOverride = platform); + + test('holds the message for the first listener', () async { + await _sendFlutterApiMessage('onMessageDisplay', [ + _displayMessage('held-campaign'), + ]); + + final messages = []; + final subscription = inAppMessaging.onMessageDisplay.listen( + messages.add, + ); + addTearDown(subscription.cancel); + await pumpEventQueue(); + + expect(messages, hasLength(1)); + expect(messages.single.campaignMetadata.campaignId, 'held-campaign'); + + // The held message was delivered, so it is not reported. + await Future.delayed(_pastPendingDisplayTimeout); + expect(hostApi.campaignId, isNull); + }); + + test( + 'does not report a held message that its campaign replaces', + () async { + await _sendFlutterApiMessage('onMessageDisplay', [ + _displayMessage('same-campaign'), + ]); + await _sendFlutterApiMessage('onMessageDisplay', [ + _displayMessage('same-campaign'), + ]); + + final message = await inAppMessaging.onMessageDisplay.first; + expect(message.campaignMetadata.campaignId, 'same-campaign'); + await Future.delayed(_pastPendingDisplayTimeout); + expect(hostApi.campaignId, isNull); + }, + ); + + test( + 'drops the held message when custom display is disabled', + () async { + await _sendFlutterApiMessage('onMessageDisplay', [ + _displayMessage('disabled-campaign'), + ]); + await inAppMessaging.setCustomDisplayEnabled(false); + + final messages = []; + final subscription = inAppMessaging.onMessageDisplay.listen( + messages.add, + ); + addTearDown(subscription.cancel); + await Future.delayed(_pastPendingDisplayTimeout); + + expect(messages, isEmpty); + expect(hostApi.campaignId, isNull); + }, + ); + }); + } + + // iOS shows no other campaign until the message is reported, and a display + // error records no impression there. + group('iOS', () { + setUp(() => debugDefaultTargetPlatformOverride = TargetPlatform.iOS); + + test('reports a display error when nobody listens', () async { + await _sendFlutterApiMessage('onMessageDisplay', [ + _displayMessage('unheard-campaign'), + ]); + + await Future.delayed(_pastPendingDisplayTimeout); + + expect(hostApi.campaignId, 'unheard-campaign'); + expect(hostApi.errorReason, isNotNull); + expect(hostApi.dismissType, isNull); + }); + + test('reports a held message that a newer one replaces', () async { + await _sendFlutterApiMessage('onMessageDisplay', [ + _displayMessage('older-campaign'), + ]); + await _sendFlutterApiMessage('onMessageDisplay', [ + _displayMessage('newer-campaign'), + ]); + await pumpEventQueue(); + + expect(hostApi.campaignId, 'older-campaign'); + expect(hostApi.errorReason, isNotNull); + + final message = await inAppMessaging.onMessageDisplay.first; + expect(message.campaignMetadata.campaignId, 'newer-campaign'); + }); }); - test('reports a held message that a newer one replaces', () async { - await _sendFlutterApiMessage('onMessageDisplay', [ - _displayMessage('older-campaign'), - ]); - await _sendFlutterApiMessage('onMessageDisplay', [ - _displayMessage('newer-campaign'), - ]); - await pumpEventQueue(); - - expect(hostApi.campaignId, 'older-campaign'); - expect(hostApi.errorReason, isNotNull); - - final message = await inAppMessaging.onMessageDisplay.first; - expect(message.campaignMetadata.campaignId, 'newer-campaign'); - }); - - test('does not report a held message that its campaign replaces', () async { - await _sendFlutterApiMessage('onMessageDisplay', [ - _displayMessage('same-campaign'), - ]); - await _sendFlutterApiMessage('onMessageDisplay', [ - _displayMessage('same-campaign'), - ]); - - final message = await inAppMessaging.onMessageDisplay.first; - expect(message.campaignMetadata.campaignId, 'same-campaign'); - await Future.delayed(_pastPendingDisplayTimeout); - expect(hostApi.campaignId, isNull); - }); - - test('drops the held message when custom display is disabled', () async { - await _sendFlutterApiMessage('onMessageDisplay', [ - _displayMessage('disabled-campaign'), - ]); - await inAppMessaging.setCustomDisplayEnabled(false); - - final messages = []; - final subscription = inAppMessaging.onMessageDisplay.listen( - messages.add, + // Android counts a display error as an impression, and caps app open + // campaigns at one a day, so a message nobody saw is not reported. + group('Android', () { + setUp( + () => debugDefaultTargetPlatformOverride = TargetPlatform.android, ); - addTearDown(subscription.cancel); - await Future.delayed(_pastPendingDisplayTimeout); - expect(messages, isEmpty); - expect(hostApi.campaignId, isNull); + test('drops the message without reporting it', () async { + await _sendFlutterApiMessage('onMessageDisplay', [ + _displayMessage('unheard-campaign'), + ]); + + await Future.delayed(_pastPendingDisplayTimeout); + expect(hostApi.campaignId, isNull); + + // The message is no longer held either. + final messages = []; + final subscription = inAppMessaging.onMessageDisplay.listen( + messages.add, + ); + addTearDown(subscription.cancel); + await pumpEventQueue(); + expect(messages, isEmpty); + }); + + test('drops a held message that a newer one replaces', () async { + await _sendFlutterApiMessage('onMessageDisplay', [ + _displayMessage('older-campaign'), + ]); + await _sendFlutterApiMessage('onMessageDisplay', [ + _displayMessage('newer-campaign'), + ]); + await pumpEventQueue(); + + expect(hostApi.campaignId, isNull); + + final message = await inAppMessaging.onMessageDisplay.first; + expect(message.campaignMetadata.campaignId, 'newer-campaign'); + }); }); }); } From 6aeb37bce036f9892485a4de35b024749342f48b Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Thu, 29 Oct 2026 14:07:34 +0100 Subject: [PATCH 3/3] style(in_app_messaging): format the new tests for the Dart 3.10 formatter --- .../method_channel_firebase_in_app_messaging_test.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/test/method_channel/method_channel_firebase_in_app_messaging_test.dart b/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/test/method_channel/method_channel_firebase_in_app_messaging_test.dart index e981dbabd916..c09e2969ae78 100644 --- a/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/test/method_channel/method_channel_firebase_in_app_messaging_test.dart +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/test/method_channel/method_channel_firebase_in_app_messaging_test.dart @@ -302,9 +302,7 @@ void main() { // Android counts a display error as an impression, and caps app open // campaigns at one a day, so a message nobody saw is not reported. group('Android', () { - setUp( - () => debugDefaultTargetPlatformOverride = TargetPlatform.android, - ); + setUp(() => debugDefaultTargetPlatformOverride = TargetPlatform.android); test('drops the message without reporting it', () async { await _sendFlutterApiMessage('onMessageDisplay', [