diff --git a/.github/workflows/android_unit_tests.yaml b/.github/workflows/android_unit_tests.yaml index 458257cfa3f0..a1243f316b5d 100644 --- a/.github/workflows/android_unit_tests.yaml +++ b/.github/workflows/android_unit_tests.yaml @@ -44,10 +44,19 @@ jobs: # 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 + - name: 'Run In-App Messaging Android unit tests' + # firebase_in_app_messaging is not a dependency of the aggregate test + # app, so its tests run from the plugin's example app. + working-directory: packages/firebase_in_app_messaging/firebase_in_app_messaging/example + run: | + flutter build apk --debug --config-only + cd android && ./gradlew :firebase_in_app_messaging:testDebugUnitTest - name: 'Upload test reports' if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: android-unit-test-reports - path: tests/build/*/reports/tests/ + path: | + tests/build/*/reports/tests/ + packages/firebase_in_app_messaging/firebase_in_app_messaging/example/build/*/reports/tests/ retention-days: 5 diff --git a/docs/in-app-messaging/_customize-messages.md b/docs/in-app-messaging/_customize-messages.md index 5ddebcaed38d..0382e2a1b4b7 100644 --- a/docs/in-app-messaging/_customize-messages.md +++ b/docs/in-app-messaging/_customize-messages.md @@ -54,5 +54,12 @@ 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)`. 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 39c8a504921c..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 @@ -43,6 +43,12 @@ 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 +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 acd5370ef651..53642011dfc1 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,6 +84,7 @@ android { api 'com.google.firebase:firebase-inappmessaging' api 'com.google.firebase:firebase-inappmessaging-display' implementation 'androidx.annotation:annotation:1.7.0' + testImplementation 'junit:junit:4.13.2' } } 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 e77fcdca27d3..6863fb82dffb 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 @@ -33,11 +32,19 @@ import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executor /** FirebaseInAppMessagingPlugin */ -class FirebaseInAppMessagingPlugin : +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, FirebaseInAppMessagingDisplay { + constructor() : this(MainThreadExecutor(), FirebaseInAppMessagingSdk) + private var binaryMessenger: BinaryMessenger? = null private var flutterApi: FirebaseInAppMessagingFlutterApi? = null private var application: Application? = null @@ -45,19 +52,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), @@ -79,7 +79,26 @@ class FirebaseInAppMessagingPlugin : flutterApi?.onMessageDisplayError(campaignMetadata(inAppMessage), errorReason.name) {} } - private var eventListenersAdded = false + private val eventListeners = + EventListenerRegistration( + addListeners = { + sdk.addListeners( + clickListener, + impressionListener, + dismissListener, + displayErrorListener, + mainExecutor, + ) + }, + removeListeners = { + sdk.removeListeners( + clickListener, + impressionListener, + dismissListener, + displayErrorListener, + ) + }, + ) private val lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks { @@ -90,17 +109,17 @@ class FirebaseInAppMessagingPlugin : override fun onActivityResumed(activity: Activity) { lastActivity = WeakReference(activity) if (customDisplayEnabled) { - FirebaseInAppMessaging.getInstance() - .setMessageDisplayComponent( - this@FirebaseInAppMessagingPlugin, - ) + sdk.setMessageDisplayComponent(this@FirebaseInAppMessagingPlugin) } + 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) {} @@ -118,16 +137,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) - removeEventListeners() + eventListeners.remove() + updateLifecycleCallbacks() binaryMessenger = null flutterApi = null - FirebaseInAppMessagingHostApi.setUp(binding.binaryMessenger, null) + FirebaseInAppMessagingHostApi.setUp(messenger, null) } override fun displayMessage( @@ -142,7 +170,7 @@ class FirebaseInAppMessagingPlugin : pendingDisplays[message.campaignMetadata.campaignId] = PendingDisplay(callbacks, collectActions(inAppMessage)) - mainHandler.post { flutterApi?.onMessageDisplay(message) {} } + mainExecutor.execute { flutterApi?.onMessageDisplay(message) {} } } private fun campaignMetadata(inAppMessage: InAppMessage): FiamCampaignMetadata { @@ -154,22 +182,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. + mainExecutor.execute { try { - addEventListenersInternal() + eventListeners.add() + updateLifecycleCallbacks() callback(Result.success(Unit)) } catch (exception: Exception) { handleFailure(callback, exception) @@ -223,7 +241,7 @@ class FirebaseInAppMessagingPlugin : enabled: Boolean, callback: (Result) -> Unit, ) { - mainHandler.post { + mainExecutor.execute { try { setCustomDisplayEnabledInternal(enabled) callback(Result.success(Unit)) @@ -302,51 +320,41 @@ 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 - val fiam = FirebaseInAppMessaging.getInstance() + updateLifecycleCallbacks() if (enabled) { - fiam.setMessageDisplayComponent(this) - if (!lifecycleRegistered) { - application?.registerActivityLifecycleCallbacks(lifecycleCallbacks) - lifecycleRegistered = true - } + sdk.setMessageDisplayComponent(this) } 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) - 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 + sdk.rebindDefaultDisplay(activity) + eventListeners.onRemovedBySdk() + eventListeners.restore() } private fun dismissAllPending() { @@ -498,6 +506,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/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/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..4aacb853b312 --- /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,225 @@ +// 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.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) + assertSame(plugin, 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 6d8887f408a6..8b0f270f5772 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 @@ -105,6 +105,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; @@ -113,7 +116,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. @@ -131,5 +135,11 @@ 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 + /// 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 78a55a19f9fe..ca15a9276364 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,60 @@ class MethodChannelFirebaseInAppMessaging } } + static void _addDisplay(InAppMessage message) { + if (displayController.hasListener) { + displayController.add(message); + return; + } + // 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) { + _releaseUndelivered(replaced); + } + _pendingDisplay = message; + _pendingDisplayTimer = Timer(pendingDisplayTimeout, () { + final undelivered = _takePendingDisplay(); + if (undelivered != null) { + _releaseUndelivered(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); + } + } + + /// 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 _) {}); + } + 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 63076e12ed02..9d384b0f834b 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'; @@ -186,6 +187,175 @@ 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; + debugDefaultTargetPlatformOverride = null; + }); + + 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'); + }); + }); + + // 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, + ); + + 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'); + }); + }); + }); +} + +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].