Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .github/workflows/android_unit_tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions docs/in-app-messaging/_customize-messages.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,31 +32,32 @@ 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
private var customDisplayEnabled = false
private var lifecycleRegistered = false
private var lastActivity: WeakReference<Activity>? = null
private val pendingDisplays = ConcurrentHashMap<String, PendingDisplay>()
private val mainHandler = Handler(Looper.getMainLooper())

private data class PendingDisplay(
val callbacks: FirebaseInAppMessagingDisplayCallbacks,
val actions: Map<String, Action>,
)

// 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),
Expand All @@ -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 {
Expand All @@ -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) {}
Expand All @@ -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(
Expand All @@ -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 {
Expand All @@ -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>) -> 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)
Expand Down Expand Up @@ -223,7 +241,7 @@ class FirebaseInAppMessagingPlugin :
enabled: Boolean,
callback: (Result<Unit>) -> Unit,
) {
mainHandler.post {
mainExecutor.execute {
try {
setCustomDisplayEnabledInternal(enabled)
callback(Result.success(Unit))
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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"
}
Expand Down
Loading
Loading