diff --git a/docs/in-app-messaging/_customize-messages.md b/docs/in-app-messaging/_customize-messages.md index 63f04f7c6d68..5ddebcaed38d 100644 --- a/docs/in-app-messaging/_customize-messages.md +++ b/docs/in-app-messaging/_customize-messages.md @@ -31,3 +31,28 @@ The action's format depends on which message layout you choose. Modals get action buttons with customizable button text content, text color, and background color. Images and top banners, on the other hand, become interactive and invoke the specified action when tapped. + +## Render campaigns with Flutter widgets + +By default, In-App Messaging draws native templates (banner, modal, card, +image-only). To render campaigns with your own Flutter UI instead, opt in to +custom display, listen for the campaign payload, and report impression, click, +and dismiss back to the SDK: + +```dart +FirebaseInAppMessaging.instance.onMessageDisplay.listen((message) async { + await message.impress(); + // Draw your own widgets using message.title, body, imageUrl, actions, data. + // The plugin does not open action URLs — your app should. + await message.click(message.primaryAction ?? message.action!); + await message.dismiss(); +}); + +await FirebaseInAppMessaging.instance.setCustomDisplayEnabled(true); +``` + +Until `setCustomDisplayEnabled(true)` is called, native templates keep working. +Listening to `onMessageDisplay` alone does not replace native UI. + +If you also listen to `onMessageClicked` / `onMessageImpression`, those +lifecycle streams still fire when your Flutter UI reports click and impress. diff --git a/docs/in-app-messaging/_modify-message-behavior.md b/docs/in-app-messaging/_modify-message-behavior.md index 1e5d2ef32997..3ab2d6c9450f 100644 --- a/docs/in-app-messaging/_modify-message-behavior.md +++ b/docs/in-app-messaging/_modify-message-behavior.md @@ -48,9 +48,8 @@ In your campaigns, you can specify custom data in a series of key/value pairs. When users interact with messages, this data is available for you to, for example, display a promo code. -To do so, you will have to use the platform-native APIs. -See the documentation for [iOS](/docs/in-app-messaging/modify-message-behavior?platform=ios#use_campaign_custom_metadata) -and [Android](/docs/in-app-messaging/modify-message-behavior?platform=android#use_campaign_custom_metadata). +When you opt in to [custom Flutter display](/docs/in-app-messaging/customize-messages?platform=flutter), +those key/value pairs are available on `InAppMessage.data`. ## Temporarily disable in-app messages 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 45dded20e3d2..39c8a504921c 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 @@ -28,6 +28,24 @@ FirebaseInAppMessaging.instance.onMessageClicked.listen((event) { `onMessageImpression`, `onMessageDismissed` and `onMessageDisplayError` report the rest of the message lifecycle. +### Custom Flutter display + +To render campaigns with your own widgets instead of the native templates: + +```dart +FirebaseInAppMessaging.instance.onMessageDisplay.listen((message) async { + await message.impress(); + // Draw your own UI, then: + // await message.click(message.action!); + // await message.dismiss(); +}); + +await FirebaseInAppMessaging.instance.setCustomDisplayEnabled(true); +``` + +The plugin does not open action URLs. You must report impress / click / dismiss +so campaign analytics keep working. + ## Issues and feedback Please file FlutterFire specific issues, bugs, or feature requests in our [issue tracker](https://github.com/firebase/flutterfire/issues/new). 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 978da39292b8..acd5370ef651 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 @@ -79,7 +79,10 @@ android { dependencies { api firebaseCoreProject implementation platform("com.google.firebase:firebase-bom:${getRootProjectExtOrCoreProperty("FirebaseSDKVersion", firebaseCoreProject)}") - implementation 'com.google.firebase:firebase-inappmessaging-display' + // `api` so app Java compilation can resolve FirebaseInAppMessagingDisplay, + // which the plugin class implements. + api 'com.google.firebase:firebase-inappmessaging' + api 'com.google.firebase:firebase-inappmessaging-display' implementation 'androidx.annotation:annotation:1.7.0' } } 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 4db99d586126..e77fcdca27d3 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 @@ -3,6 +3,9 @@ // found in the LICENSE file. package io.flutter.plugins.firebase.inappmessaging +import android.app.Activity +import android.app.Application +import android.os.Bundle import android.os.Handler import android.os.Looper import com.google.android.gms.tasks.Task @@ -11,21 +14,43 @@ import com.google.firebase.FirebaseApp 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.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 +import com.google.firebase.inappmessaging.model.MessageType import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.FlutterPlugin.FlutterPluginBinding import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugins.firebase.core.FlutterFirebasePlugin import io.flutter.plugins.firebase.core.FlutterFirebasePluginRegistry +import java.lang.ref.WeakReference +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executor /** FirebaseInAppMessagingPlugin */ class FirebaseInAppMessagingPlugin : - FlutterFirebasePlugin, FlutterPlugin, FirebaseInAppMessagingHostApi { + FlutterFirebasePlugin, + FlutterPlugin, + FirebaseInAppMessagingHostApi, + FirebaseInAppMessagingDisplay { 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? = 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. @@ -35,7 +60,9 @@ class FirebaseInAppMessagingPlugin : private val clickListener = FirebaseInAppMessagingClickListener { inAppMessage, action -> flutterApi?.onMessageClicked( - campaignMetadata(inAppMessage), FiamAction(action.actionUrl, action.button?.text?.text)) {} + campaignMetadata(inAppMessage), + FiamAction(action.actionUrl, action.button?.text?.text), + ) {} } private val impressionListener = FirebaseInAppMessagingImpressionListener { inAppMessage -> @@ -54,6 +81,35 @@ class FirebaseInAppMessagingPlugin : private var eventListenersAdded = false + private val lifecycleCallbacks = + object : Application.ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} + + override fun onActivityStarted(activity: Activity) {} + + override fun onActivityResumed(activity: Activity) { + lastActivity = WeakReference(activity) + if (customDisplayEnabled) { + FirebaseInAppMessaging.getInstance() + .setMessageDisplayComponent( + this@FirebaseInAppMessagingPlugin, + ) + } + } + + override fun onActivityPaused(activity: Activity) { + if (lastActivity?.get() === activity) { + lastActivity = null + } + } + + override fun onActivityStopped(activity: Activity) {} + + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + + override fun onActivityDestroyed(activity: Activity) {} + } + private fun initInstance(messenger: BinaryMessenger) { FlutterFirebasePluginRegistry.registerPlugin(METHOD_CHANNEL_NAME, this) binaryMessenger = messenger @@ -62,20 +118,40 @@ class FirebaseInAppMessagingPlugin : } override fun onAttachedToEngine(binding: FlutterPluginBinding) { + application = binding.applicationContext as? Application initInstance(binding.binaryMessenger) } override fun onDetachedFromEngine(binding: FlutterPluginBinding) { + setCustomDisplayEnabledInternal(false) removeEventListeners() binaryMessenger = null flutterApi = null FirebaseInAppMessagingHostApi.setUp(binding.binaryMessenger, null) } + override fun displayMessage( + inAppMessage: InAppMessage, + callbacks: FirebaseInAppMessagingDisplayCallbacks, + ) { + if (!customDisplayEnabled) { + return + } + + val message = toDisplayMessage(inAppMessage) + pendingDisplays[message.campaignMetadata.campaignId] = + PendingDisplay(callbacks, collectActions(inAppMessage)) + + mainHandler.post { flutterApi?.onMessageDisplay(message) {} } + } + private fun campaignMetadata(inAppMessage: InAppMessage): FiamCampaignMetadata { val metadata = inAppMessage.campaignMetadata return FiamCampaignMetadata( - metadata?.campaignId ?: "", metadata?.campaignName ?: "", metadata?.isTestMessage ?: false) + metadata?.campaignId ?: "", + metadata?.campaignName ?: "", + metadata?.isTestMessage ?: false, + ) } private fun removeEventListeners() { @@ -93,15 +169,7 @@ class FirebaseInAppMessagingPlugin : override fun addEventListeners(appName: String, callback: (Result) -> Unit) { FlutterFirebasePlugin.cachedThreadPool.execute { try { - if (!eventListenersAdded) { - FirebaseInAppMessaging.getInstance().apply { - addClickListener(clickListener, mainThreadExecutor) - addImpressionListener(impressionListener, mainThreadExecutor) - addDismissListener(dismissListener, mainThreadExecutor) - addDisplayErrorListener(displayErrorListener, mainThreadExecutor) - } - eventListenersAdded = true - } + addEventListenersInternal() callback(Result.success(Unit)) } catch (exception: Exception) { handleFailure(callback, exception) @@ -123,7 +191,7 @@ class FirebaseInAppMessagingPlugin : override fun setMessagesSuppressed( appName: String, suppress: Boolean, - callback: (Result) -> Unit + callback: (Result) -> Unit, ) { FlutterFirebasePlugin.cachedThreadPool.execute { try { @@ -138,7 +206,7 @@ class FirebaseInAppMessagingPlugin : override fun setAutomaticDataCollectionEnabled( appName: String, enabled: Boolean, - callback: (Result) -> Unit + callback: (Result) -> Unit, ) { FlutterFirebasePlugin.cachedThreadPool.execute { try { @@ -150,6 +218,253 @@ class FirebaseInAppMessagingPlugin : } } + override fun setCustomDisplayEnabled( + appName: String, + enabled: Boolean, + callback: (Result) -> Unit, + ) { + mainHandler.post { + try { + setCustomDisplayEnabledInternal(enabled) + callback(Result.success(Unit)) + } catch (exception: Exception) { + handleFailure(callback, exception) + } + } + } + + override fun reportImpression(campaignId: String, callback: (Result) -> Unit) { + FlutterFirebasePlugin.cachedThreadPool.execute { + try { + pendingDisplays[campaignId]?.callbacks?.impressionDetected() + callback(Result.success(Unit)) + } catch (exception: Exception) { + handleFailure(callback, exception) + } + } + } + + override fun reportClick( + campaignId: String, + actionId: String, + callback: (Result) -> Unit, + ) { + FlutterFirebasePlugin.cachedThreadPool.execute { + try { + val pending = pendingDisplays.remove(campaignId) + val action = pending?.actions?.get(actionId) + if (pending != null && action != null) { + pending.callbacks.messageClicked(action) + } else { + pending + ?.callbacks + ?.messageDismissed( + FirebaseInAppMessagingDisplayCallbacks.InAppMessagingDismissType.CLICK, + ) + } + callback(Result.success(Unit)) + } catch (exception: Exception) { + handleFailure(callback, exception) + } + } + } + + override fun reportDismiss( + campaignId: String, + dismissType: String, + callback: (Result) -> Unit, + ) { + FlutterFirebasePlugin.cachedThreadPool.execute { + try { + val pending = pendingDisplays.remove(campaignId) + pending?.callbacks?.messageDismissed(toDismissType(dismissType)) + callback(Result.success(Unit)) + } catch (exception: Exception) { + handleFailure(callback, exception) + } + } + } + + override fun reportDisplayError( + campaignId: String, + reason: String, + callback: (Result) -> Unit, + ) { + FlutterFirebasePlugin.cachedThreadPool.execute { + try { + val pending = pendingDisplays.remove(campaignId) + pending?.callbacks?.displayErrorEncountered(toErrorReason(reason)) + callback(Result.success(Unit)) + } catch (exception: Exception) { + handleFailure(callback, exception) + } + } + } + + private fun setCustomDisplayEnabledInternal(enabled: Boolean) { + customDisplayEnabled = enabled + val fiam = FirebaseInAppMessaging.getInstance() + if (enabled) { + fiam.setMessageDisplayComponent(this) + if (!lifecycleRegistered) { + application?.registerActivityLifecycleCallbacks(lifecycleCallbacks) + lifecycleRegistered = true + } + } else { + if (lifecycleRegistered) { + application?.unregisterActivityLifecycleCallbacks(lifecycleCallbacks) + lifecycleRegistered = false + } + dismissAllPending() + restoreDefaultDisplay() + } + } + + /// 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. + 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 + } + + private fun dismissAllPending() { + val pending = pendingDisplays.values.toList() + pendingDisplays.clear() + for (entry in pending) { + try { + entry.callbacks.messageDismissed( + FirebaseInAppMessagingDisplayCallbacks.InAppMessagingDismissType.AUTO, + ) + } catch (_: Exception) {} + } + } + + private fun toDisplayMessage(message: InAppMessage): FiamDisplayMessage { + val metadata = campaignMetadata(message) + val campaignId = metadata.campaignId + val card = message as? CardMessage + return FiamDisplayMessage( + campaignMetadata = metadata, + messageType = toMessageType(message.messageType), + title = toFiamText(message.title), + body = toFiamText(message.body), + imageUrl = + card?.portraitImageData?.imageUrl ?: message.imageUrl ?: message.imageData?.imageUrl, + landscapeImageUrl = card?.landscapeImageData?.imageUrl, + backgroundHexColor = message.backgroundHexColor, + action = + if (card == null) toDisplayAction("${campaignId}_action", message.action) else null, + primaryAction = toDisplayAction("${campaignId}_primary", card?.primaryAction), + secondaryAction = toDisplayAction("${campaignId}_secondary", card?.secondaryAction), + data = toData(message.data), + ) + } + + private fun collectActions(message: InAppMessage): Map { + val campaignId = campaignMetadata(message).campaignId + val actions = mutableMapOf() + val card = message as? CardMessage + if (card != null) { + actions["${campaignId}_primary"] = card.primaryAction + card.secondaryAction?.let { actions["${campaignId}_secondary"] = it } + } else { + message.action?.let { actions["${campaignId}_action"] = it } + } + return actions + } + + private fun toMessageType(type: MessageType?): String { + return when (type) { + MessageType.BANNER -> "BANNER" + MessageType.MODAL -> "MODAL" + MessageType.CARD -> "CARD" + MessageType.IMAGE_ONLY -> "IMAGE_ONLY" + else -> "UNKNOWN" + } + } + + private fun toData(data: Map?): Map? { + if (data.isNullOrEmpty()) { + return null + } + return HashMap(data) + } + + private fun toFiamText(text: com.google.firebase.inappmessaging.model.Text?): FiamText? { + val value = text?.text ?: return null + return FiamText(text = value, hexColor = text.hexColor) + } + + private fun toDisplayAction(id: String, action: Action?): FiamDisplayAction? { + if (action == null) { + return null + } + val button = action.button + val url = action.actionUrl + if (button == null && url.isNullOrEmpty()) { + return null + } + return FiamDisplayAction( + id = id, + actionUrl = url, + buttonText = button?.text?.text, + buttonTextHexColor = button?.text?.hexColor, + buttonBackgroundHexColor = button?.buttonHexColor, + ) + } + + private fun toDismissType( + dismissType: String, + ): FirebaseInAppMessagingDisplayCallbacks.InAppMessagingDismissType { + return when (dismissType) { + "auto" -> FirebaseInAppMessagingDisplayCallbacks.InAppMessagingDismissType.AUTO + "swipe" -> FirebaseInAppMessagingDisplayCallbacks.InAppMessagingDismissType.SWIPE + "unknown" -> + FirebaseInAppMessagingDisplayCallbacks.InAppMessagingDismissType.UNKNOWN_DISMISS_TYPE + else -> FirebaseInAppMessagingDisplayCallbacks.InAppMessagingDismissType.CLICK + } + } + + private fun toErrorReason( + reason: String, + ): FirebaseInAppMessagingDisplayCallbacks.InAppMessagingErrorReason { + return when (reason) { + "IMAGE_FETCH_ERROR", + "imageFetchError" -> + FirebaseInAppMessagingDisplayCallbacks.InAppMessagingErrorReason.IMAGE_FETCH_ERROR + "IMAGE_DISPLAY_ERROR", + "imageDisplayError" -> + FirebaseInAppMessagingDisplayCallbacks.InAppMessagingErrorReason.IMAGE_DISPLAY_ERROR + "IMAGE_UNSUPPORTED_FORMAT", + "imageUnsupportedFormat" -> + FirebaseInAppMessagingDisplayCallbacks.InAppMessagingErrorReason.IMAGE_UNSUPPORTED_FORMAT + else -> + FirebaseInAppMessagingDisplayCallbacks.InAppMessagingErrorReason.UNSPECIFIED_RENDER_ERROR + } + } + private fun handleFailure(callback: (Result) -> Unit, exception: Exception?) { val message = exception?.message ?: "An unknown error occurred" callback(Result.failure(FlutterError("firebase_in_app_messaging", message, null))) diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/src/main/kotlin/io/flutter/plugins/firebase/inappmessaging/GeneratedAndroidFirebaseInAppMessaging.g.kt b/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/src/main/kotlin/io/flutter/plugins/firebase/inappmessaging/GeneratedAndroidFirebaseInAppMessaging.g.kt index 67d5b639a569..e97c5d6ca18e 100644 --- a/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/src/main/kotlin/io/flutter/plugins/firebase/inappmessaging/GeneratedAndroidFirebaseInAppMessaging.g.kt +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging/android/src/main/kotlin/io/flutter/plugins/firebase/inappmessaging/GeneratedAndroidFirebaseInAppMessaging.g.kt @@ -316,6 +316,236 @@ data class FiamAction(val actionUrl: String? = null, val buttonText: String? = n } } +/** + * Styled text from a campaign, used by custom Flutter display. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class FiamText(val text: String, val hexColor: String? = null) { + companion object { + fun fromList(pigeonVar_list: List): FiamText { + val text = pigeonVar_list[0] as String + val hexColor = pigeonVar_list[1] as String? + return FiamText(text, hexColor) + } + } + + fun toList(): List { + return listOf( + text, + hexColor, + ) + } + + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as FiamText + return GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepEquals(this.text, other.text) && + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepEquals(this.hexColor, other.hexColor) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepHash(this.text) + result = 31 * result + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepHash(this.hexColor) + return result + } +} + +/** + * A campaign action forwarded for custom Flutter display. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class FiamDisplayAction( + val id: String, + val actionUrl: String? = null, + val buttonText: String? = null, + val buttonTextHexColor: String? = null, + val buttonBackgroundHexColor: String? = null +) { + companion object { + fun fromList(pigeonVar_list: List): FiamDisplayAction { + val id = pigeonVar_list[0] as String + val actionUrl = pigeonVar_list[1] as String? + val buttonText = pigeonVar_list[2] as String? + val buttonTextHexColor = pigeonVar_list[3] as String? + val buttonBackgroundHexColor = pigeonVar_list[4] as String? + return FiamDisplayAction( + id, actionUrl, buttonText, buttonTextHexColor, buttonBackgroundHexColor) + } + } + + fun toList(): List { + return listOf( + id, + actionUrl, + buttonText, + buttonTextHexColor, + buttonBackgroundHexColor, + ) + } + + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as FiamDisplayAction + return GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepEquals(this.id, other.id) && + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepEquals( + this.actionUrl, other.actionUrl) && + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepEquals( + this.buttonText, other.buttonText) && + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepEquals( + this.buttonTextHexColor, other.buttonTextHexColor) && + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepEquals( + this.buttonBackgroundHexColor, other.buttonBackgroundHexColor) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepHash(this.id) + result = + 31 * result + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepHash(this.actionUrl) + result = + 31 * result + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepHash(this.buttonText) + result = + 31 * result + + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepHash(this.buttonTextHexColor) + result = + 31 * result + + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepHash( + this.buttonBackgroundHexColor) + return result + } +} + +/** + * Full campaign payload forwarded instead of native templates. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class FiamDisplayMessage( + val campaignMetadata: FiamCampaignMetadata, + /** One of BANNER, MODAL, CARD, IMAGE_ONLY, UNKNOWN. */ + val messageType: String, + val title: FiamText? = null, + val body: FiamText? = null, + val imageUrl: String? = null, + val landscapeImageUrl: String? = null, + val backgroundHexColor: String? = null, + val action: FiamDisplayAction? = null, + val primaryAction: FiamDisplayAction? = null, + val secondaryAction: FiamDisplayAction? = null, + val data: Map? = null +) { + companion object { + fun fromList(pigeonVar_list: List): FiamDisplayMessage { + val campaignMetadata = pigeonVar_list[0] as FiamCampaignMetadata + val messageType = pigeonVar_list[1] as String + val title = pigeonVar_list[2] as FiamText? + val body = pigeonVar_list[3] as FiamText? + val imageUrl = pigeonVar_list[4] as String? + val landscapeImageUrl = pigeonVar_list[5] as String? + val backgroundHexColor = pigeonVar_list[6] as String? + val action = pigeonVar_list[7] as FiamDisplayAction? + val primaryAction = pigeonVar_list[8] as FiamDisplayAction? + val secondaryAction = pigeonVar_list[9] as FiamDisplayAction? + val data = pigeonVar_list[10] as Map? + return FiamDisplayMessage( + campaignMetadata, + messageType, + title, + body, + imageUrl, + landscapeImageUrl, + backgroundHexColor, + action, + primaryAction, + secondaryAction, + data) + } + } + + fun toList(): List { + return listOf( + campaignMetadata, + messageType, + title, + body, + imageUrl, + landscapeImageUrl, + backgroundHexColor, + action, + primaryAction, + secondaryAction, + data, + ) + } + + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as FiamDisplayMessage + return GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepEquals( + this.campaignMetadata, other.campaignMetadata) && + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepEquals( + this.messageType, other.messageType) && + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepEquals(this.title, other.title) && + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepEquals(this.body, other.body) && + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepEquals( + this.imageUrl, other.imageUrl) && + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepEquals( + this.landscapeImageUrl, other.landscapeImageUrl) && + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepEquals( + this.backgroundHexColor, other.backgroundHexColor) && + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepEquals(this.action, other.action) && + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepEquals( + this.primaryAction, other.primaryAction) && + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepEquals( + this.secondaryAction, other.secondaryAction) && + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepEquals(this.data, other.data) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = + 31 * result + + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepHash(this.campaignMetadata) + result = + 31 * result + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepHash(this.messageType) + result = 31 * result + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepHash(this.title) + result = 31 * result + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepHash(this.body) + result = 31 * result + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepHash(this.imageUrl) + result = + 31 * result + + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepHash(this.landscapeImageUrl) + result = + 31 * result + + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepHash(this.backgroundHexColor) + result = 31 * result + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepHash(this.action) + result = + 31 * result + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepHash(this.primaryAction) + result = + 31 * result + + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepHash(this.secondaryAction) + result = 31 * result + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.deepHash(this.data) + return result + } +} + private open class GeneratedAndroidFirebaseInAppMessagingPigeonCodec : StandardMessageCodec() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { return when (type) { @@ -328,6 +558,15 @@ private open class GeneratedAndroidFirebaseInAppMessagingPigeonCodec : StandardM 131.toByte() -> { return (readValue(buffer) as? List)?.let { FiamAction.fromList(it) } } + 132.toByte() -> { + return (readValue(buffer) as? List)?.let { FiamText.fromList(it) } + } + 133.toByte() -> { + return (readValue(buffer) as? List)?.let { FiamDisplayAction.fromList(it) } + } + 134.toByte() -> { + return (readValue(buffer) as? List)?.let { FiamDisplayMessage.fromList(it) } + } else -> super.readValueOfType(type, buffer) } } @@ -346,6 +585,18 @@ private open class GeneratedAndroidFirebaseInAppMessagingPigeonCodec : StandardM stream.write(131) writeValue(stream, value.toList()) } + is FiamText -> { + stream.write(132) + writeValue(stream, value.toList()) + } + is FiamDisplayAction -> { + stream.write(133) + writeValue(stream, value.toList()) + } + is FiamDisplayMessage -> { + stream.write(134) + writeValue(stream, value.toList()) + } else -> super.writeValue(stream, value) } } @@ -368,6 +619,16 @@ interface FirebaseInAppMessagingHostApi { */ fun addEventListeners(appName: String, callback: (Result) -> Unit) + fun setCustomDisplayEnabled(appName: String, enabled: Boolean, callback: (Result) -> Unit) + + fun reportImpression(campaignId: String, callback: (Result) -> Unit) + + fun reportClick(campaignId: String, actionId: String, callback: (Result) -> Unit) + + fun reportDismiss(campaignId: String, dismissType: String, callback: (Result) -> Unit) + + fun reportDisplayError(campaignId: String, reason: String, callback: (Result) -> Unit) + companion object { /** The codec used by FirebaseInAppMessagingHostApi. */ val codec: MessageCodec by lazy { GeneratedAndroidFirebaseInAppMessagingPigeonCodec() } @@ -478,6 +739,125 @@ interface FirebaseInAppMessagingHostApi { channel.setMessageHandler(null) } } + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingHostApi.setCustomDisplayEnabled$separatedMessageChannelSuffix", + codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val appNameArg = args[0] as String + val enabledArg = args[1] as Boolean + api.setCustomDisplayEnabled(appNameArg, enabledArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeneratedAndroidFirebaseInAppMessagingPigeonUtils.wrapError(error)) + } else { + reply.reply(GeneratedAndroidFirebaseInAppMessagingPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingHostApi.reportImpression$separatedMessageChannelSuffix", + codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val campaignIdArg = args[0] as String + api.reportImpression(campaignIdArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeneratedAndroidFirebaseInAppMessagingPigeonUtils.wrapError(error)) + } else { + reply.reply(GeneratedAndroidFirebaseInAppMessagingPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingHostApi.reportClick$separatedMessageChannelSuffix", + codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val campaignIdArg = args[0] as String + val actionIdArg = args[1] as String + api.reportClick(campaignIdArg, actionIdArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeneratedAndroidFirebaseInAppMessagingPigeonUtils.wrapError(error)) + } else { + reply.reply(GeneratedAndroidFirebaseInAppMessagingPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingHostApi.reportDismiss$separatedMessageChannelSuffix", + codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val campaignIdArg = args[0] as String + val dismissTypeArg = args[1] as String + api.reportDismiss(campaignIdArg, dismissTypeArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeneratedAndroidFirebaseInAppMessagingPigeonUtils.wrapError(error)) + } else { + reply.reply(GeneratedAndroidFirebaseInAppMessagingPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingHostApi.reportDisplayError$separatedMessageChannelSuffix", + codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val campaignIdArg = args[0] as String + val reasonArg = args[1] as String + api.reportDisplayError(campaignIdArg, reasonArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeneratedAndroidFirebaseInAppMessagingPigeonUtils.wrapError(error)) + } else { + reply.reply(GeneratedAndroidFirebaseInAppMessagingPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } } } } @@ -593,4 +973,26 @@ class FirebaseInAppMessagingFlutterApi( } } } + + fun onMessageDisplay(messageArg: FiamDisplayMessage, callback: (Result) -> Unit) { + val separatedMessageChannelSuffix = + if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = + "dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingFlutterApi.onMessageDisplay$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(messageArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback( + Result.failure( + GeneratedAndroidFirebaseInAppMessagingPigeonUtils.createConnectionError( + channelName))) + } + } + } } diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging/example/android/app/build.gradle b/packages/firebase_in_app_messaging/firebase_in_app_messaging/example/android/app/build.gradle index 9708744c711e..2357e2f1e8ae 100644 --- a/packages/firebase_in_app_messaging/firebase_in_app_messaging/example/android/app/build.gradle +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging/example/android/app/build.gradle @@ -41,7 +41,7 @@ android { applicationId = "io.flutter.plugins.firebase.tests" // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. - minSdk = 23 + minSdkVersion = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutterVersionCode.toInteger() versionName = flutterVersionName diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging/example/android/gradle.properties b/packages/firebase_in_app_messaging/firebase_in_app_messaging/example/android/gradle.properties index 3c0f502f334a..d142b890ccee 100644 --- a/packages/firebase_in_app_messaging/firebase_in_app_messaging/example/android/gradle.properties +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging/example/android/gradle.properties @@ -1,4 +1,8 @@ org.gradle.jvmargs=-Xmx4G -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true -androidGradlePluginVersion=8.3.0 \ No newline at end of file +androidGradlePluginVersion=8.11.1 +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/firebase_in_app_messaging/firebase_in_app_messaging/example/android/gradle/wrapper/gradle-wrapper.properties index e411586a54a8..c6406bc3cce0 100644 --- a/packages/firebase_in_app_messaging/firebase_in_app_messaging/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging/example/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging/example/android/settings.gradle b/packages/firebase_in_app_messaging/firebase_in_app_messaging/example/android/settings.gradle index a12a424c31ed..bc335bdf627c 100644 --- a/packages/firebase_in_app_messaging/firebase_in_app_messaging/example/android/settings.gradle +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging/example/android/settings.gradle @@ -18,7 +18,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "${androidGradlePluginVersion}" apply false + id "com.android.application" version "8.11.1" apply false // START: FlutterFire Configuration id "com.google.gms.google-services" version "4.3.15" apply false // END: FlutterFire Configuration diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging/example/lib/main.dart b/packages/firebase_in_app_messaging/firebase_in_app_messaging/example/lib/main.dart index b12001fa64f4..4f074fdc0045 100644 --- a/packages/firebase_in_app_messaging/firebase_in_app_messaging/example/lib/main.dart +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging/example/lib/main.dart @@ -39,6 +39,7 @@ class MyApp extends StatelessWidget { AnalyticsEventExample(), ProgrammaticTriggersExample(), MessageEventsExample(), + CustomDisplayExample(), ], ), ); @@ -164,6 +165,112 @@ class _MessageEventsExampleState extends State { } } +class CustomDisplayExample extends StatefulWidget { + @override + State createState() => _CustomDisplayExampleState(); +} + +class _CustomDisplayExampleState extends State { + StreamSubscription? _subscription; + bool _customDisplayEnabled = false; + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } + + Future _toggle(bool enabled) async { + await _subscription?.cancel(); + _subscription = null; + if (enabled) { + _subscription = MyApp.fiam.onMessageDisplay.listen(_show); + } + await MyApp.fiam.setCustomDisplayEnabled(enabled); + if (mounted) { + setState(() { + _customDisplayEnabled = enabled; + }); + } + } + + Future _show(InAppMessage message) async { + if (!mounted) return; + await message.impress(); + if (!mounted) return; + await showDialog( + context: context, + builder: (BuildContext context) { + final InAppMessageAction? primary = + message.primaryAction ?? message.action; + return AlertDialog( + title: Text( + message.title?.text ?? message.campaignMetadata.campaignName), + content: Text(message.body?.text ?? 'Custom Flutter in-app message'), + actions: [ + TextButton( + onPressed: () async { + await message.dismiss(); + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + child: const Text('Dismiss'), + ), + if (primary != null) + FilledButton( + onPressed: () async { + await message.click(primary); + if (context.mounted) { + Navigator.of(context).pop(); + ScaffoldMessenger.of(this.context).showSnackBar( + SnackBar( + content: Text( + 'Action URL: ${primary.actionUrl ?? '(none)'}', + ), + ), + ); + } + }, + child: Text(primary.buttonText ?? 'Continue'), + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + const Text( + 'Custom Flutter display', + style: TextStyle( + fontStyle: FontStyle.italic, + fontSize: 18, + ), + ), + const SizedBox(height: 8), + const Text( + 'When enabled, campaigns are rendered with Flutter widgets instead of native templates.', + textAlign: TextAlign.center, + ), + SwitchListTile( + title: const Text('Use custom display'), + value: _customDisplayEnabled, + onChanged: _toggle, + ), + ], + ), + ), + ); + } +} + class AnalyticsEventExample extends StatelessWidget { Future _sendAnalyticsEvent() async { await MyApp.analytics.logEvent( diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging/ios/firebase_in_app_messaging/Sources/firebase_in_app_messaging/FirebaseInAppMessagingMessages.g.swift b/packages/firebase_in_app_messaging/firebase_in_app_messaging/ios/firebase_in_app_messaging/Sources/firebase_in_app_messaging/FirebaseInAppMessagingMessages.g.swift index d5a7cf2b8c52..643dc52c55f7 100644 --- a/packages/firebase_in_app_messaging/firebase_in_app_messaging/ios/firebase_in_app_messaging/Sources/firebase_in_app_messaging/FirebaseInAppMessagingMessages.g.swift +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging/ios/firebase_in_app_messaging/Sources/firebase_in_app_messaging/FirebaseInAppMessagingMessages.g.swift @@ -59,7 +59,8 @@ private func wrapError(_ error: Any) -> [Any?] { private func createConnectionError(withChannelName channelName: String) -> PigeonError { PigeonError( - code: "channel-error", message: "Unable to establish connection on channel: '\(channelName)'.", + code: "channel-error", + message: "Unable to establish connection on channel: '\(channelName)'.", details: "" ) } @@ -230,8 +231,10 @@ struct FiamCampaignMetadata: Hashable { return false } return deepEqualsFirebaseInAppMessagingMessages(lhs.campaignId, rhs.campaignId) - && deepEqualsFirebaseInAppMessagingMessages(lhs.campaignName, rhs.campaignName) - && deepEqualsFirebaseInAppMessagingMessages(lhs.isTestMessage, rhs.isTestMessage) + && deepEqualsFirebaseInAppMessagingMessages( + lhs.campaignName, + rhs.campaignName + ) && deepEqualsFirebaseInAppMessagingMessages(lhs.isTestMessage, rhs.isTestMessage) } func hash(into hasher: inout Hasher) { @@ -272,7 +275,10 @@ struct FiamAction: Hashable { return false } return deepEqualsFirebaseInAppMessagingMessages(lhs.actionUrl, rhs.actionUrl) - && deepEqualsFirebaseInAppMessagingMessages(lhs.buttonText, rhs.buttonText) + && deepEqualsFirebaseInAppMessagingMessages( + lhs.buttonText, + rhs.buttonText + ) } func hash(into hasher: inout Hasher) { @@ -282,6 +288,224 @@ struct FiamAction: Hashable { } } +/// Styled text from a campaign, used by custom Flutter display. +/// +/// Generated class from Pigeon that represents data sent in messages. +struct FiamText: Hashable { + var text: String + var hexColor: String? + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> FiamText? { + let text = pigeonVar_list[0] as! String + let hexColor: String? = nilOrValue(pigeonVar_list[1]) + + return FiamText( + text: text, + hexColor: hexColor + ) + } + + func toList() -> [Any?] { + [ + text, + hexColor, + ] + } + + static func == (lhs: FiamText, rhs: FiamText) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return deepEqualsFirebaseInAppMessagingMessages(lhs.text, rhs.text) + && deepEqualsFirebaseInAppMessagingMessages( + lhs.hexColor, + rhs.hexColor + ) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("FiamText") + deepHashFirebaseInAppMessagingMessages(value: text, hasher: &hasher) + deepHashFirebaseInAppMessagingMessages(value: hexColor, hasher: &hasher) + } +} + +/// A campaign action forwarded for custom Flutter display. +/// +/// Generated class from Pigeon that represents data sent in messages. +struct FiamDisplayAction: Hashable { + var id: String + var actionUrl: String? + var buttonText: String? + var buttonTextHexColor: String? + var buttonBackgroundHexColor: String? + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> FiamDisplayAction? { + let id = pigeonVar_list[0] as! String + let actionUrl: String? = nilOrValue(pigeonVar_list[1]) + let buttonText: String? = nilOrValue(pigeonVar_list[2]) + let buttonTextHexColor: String? = nilOrValue(pigeonVar_list[3]) + let buttonBackgroundHexColor: String? = nilOrValue(pigeonVar_list[4]) + + return FiamDisplayAction( + id: id, + actionUrl: actionUrl, + buttonText: buttonText, + buttonTextHexColor: buttonTextHexColor, + buttonBackgroundHexColor: buttonBackgroundHexColor + ) + } + + func toList() -> [Any?] { + [ + id, + actionUrl, + buttonText, + buttonTextHexColor, + buttonBackgroundHexColor, + ] + } + + static func == (lhs: FiamDisplayAction, rhs: FiamDisplayAction) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return deepEqualsFirebaseInAppMessagingMessages(lhs.id, rhs.id) + && deepEqualsFirebaseInAppMessagingMessages( + lhs.actionUrl, + rhs.actionUrl + ) && deepEqualsFirebaseInAppMessagingMessages(lhs.buttonText, rhs.buttonText) + && deepEqualsFirebaseInAppMessagingMessages( + lhs.buttonTextHexColor, + rhs.buttonTextHexColor + ) + && deepEqualsFirebaseInAppMessagingMessages( + lhs.buttonBackgroundHexColor, + rhs.buttonBackgroundHexColor + ) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("FiamDisplayAction") + deepHashFirebaseInAppMessagingMessages(value: id, hasher: &hasher) + deepHashFirebaseInAppMessagingMessages(value: actionUrl, hasher: &hasher) + deepHashFirebaseInAppMessagingMessages(value: buttonText, hasher: &hasher) + deepHashFirebaseInAppMessagingMessages(value: buttonTextHexColor, hasher: &hasher) + deepHashFirebaseInAppMessagingMessages(value: buttonBackgroundHexColor, hasher: &hasher) + } +} + +/// Full campaign payload forwarded instead of native templates. +/// +/// Generated class from Pigeon that represents data sent in messages. +struct FiamDisplayMessage: Hashable { + var campaignMetadata: FiamCampaignMetadata + /// One of BANNER, MODAL, CARD, IMAGE_ONLY, UNKNOWN. + var messageType: String + var title: FiamText? + var body: FiamText? + var imageUrl: String? + var landscapeImageUrl: String? + var backgroundHexColor: String? + var action: FiamDisplayAction? + var primaryAction: FiamDisplayAction? + var secondaryAction: FiamDisplayAction? + var data: [String?: String?]? + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> FiamDisplayMessage? { + let campaignMetadata = pigeonVar_list[0] as! FiamCampaignMetadata + let messageType = pigeonVar_list[1] as! String + let title: FiamText? = nilOrValue(pigeonVar_list[2]) + let body: FiamText? = nilOrValue(pigeonVar_list[3]) + let imageUrl: String? = nilOrValue(pigeonVar_list[4]) + let landscapeImageUrl: String? = nilOrValue(pigeonVar_list[5]) + let backgroundHexColor: String? = nilOrValue(pigeonVar_list[6]) + let action: FiamDisplayAction? = nilOrValue(pigeonVar_list[7]) + let primaryAction: FiamDisplayAction? = nilOrValue(pigeonVar_list[8]) + let secondaryAction: FiamDisplayAction? = nilOrValue(pigeonVar_list[9]) + let data: [String?: String?]? = nilOrValue(pigeonVar_list[10]) + + return FiamDisplayMessage( + campaignMetadata: campaignMetadata, + messageType: messageType, + title: title, + body: body, + imageUrl: imageUrl, + landscapeImageUrl: landscapeImageUrl, + backgroundHexColor: backgroundHexColor, + action: action, + primaryAction: primaryAction, + secondaryAction: secondaryAction, + data: data + ) + } + + func toList() -> [Any?] { + [ + campaignMetadata, + messageType, + title, + body, + imageUrl, + landscapeImageUrl, + backgroundHexColor, + action, + primaryAction, + secondaryAction, + data, + ] + } + + static func == (lhs: FiamDisplayMessage, rhs: FiamDisplayMessage) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return deepEqualsFirebaseInAppMessagingMessages(lhs.campaignMetadata, rhs.campaignMetadata) + && deepEqualsFirebaseInAppMessagingMessages( + lhs.messageType, + rhs.messageType + ) && deepEqualsFirebaseInAppMessagingMessages(lhs.title, rhs.title) + && deepEqualsFirebaseInAppMessagingMessages( + lhs.body, + rhs.body + ) && deepEqualsFirebaseInAppMessagingMessages(lhs.imageUrl, rhs.imageUrl) + && deepEqualsFirebaseInAppMessagingMessages( + lhs.landscapeImageUrl, + rhs.landscapeImageUrl + ) + && deepEqualsFirebaseInAppMessagingMessages( + lhs.backgroundHexColor, + rhs.backgroundHexColor + ) + && deepEqualsFirebaseInAppMessagingMessages( + lhs.action, + rhs.action + ) && deepEqualsFirebaseInAppMessagingMessages(lhs.primaryAction, rhs.primaryAction) + && deepEqualsFirebaseInAppMessagingMessages( + lhs.secondaryAction, + rhs.secondaryAction + ) && deepEqualsFirebaseInAppMessagingMessages(lhs.data, rhs.data) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("FiamDisplayMessage") + deepHashFirebaseInAppMessagingMessages(value: campaignMetadata, hasher: &hasher) + deepHashFirebaseInAppMessagingMessages(value: messageType, hasher: &hasher) + deepHashFirebaseInAppMessagingMessages(value: title, hasher: &hasher) + deepHashFirebaseInAppMessagingMessages(value: body, hasher: &hasher) + deepHashFirebaseInAppMessagingMessages(value: imageUrl, hasher: &hasher) + deepHashFirebaseInAppMessagingMessages(value: landscapeImageUrl, hasher: &hasher) + deepHashFirebaseInAppMessagingMessages(value: backgroundHexColor, hasher: &hasher) + deepHashFirebaseInAppMessagingMessages(value: action, hasher: &hasher) + deepHashFirebaseInAppMessagingMessages(value: primaryAction, hasher: &hasher) + deepHashFirebaseInAppMessagingMessages(value: secondaryAction, hasher: &hasher) + deepHashFirebaseInAppMessagingMessages(value: data, hasher: &hasher) + } +} + private class FirebaseInAppMessagingMessagesPigeonCodecReader: FlutterStandardReader { override func readValue(ofType type: UInt8) -> Any? { switch type { @@ -295,6 +519,12 @@ private class FirebaseInAppMessagingMessagesPigeonCodecReader: FlutterStandardRe return FiamCampaignMetadata.fromList(readValue() as! [Any?]) case 131: return FiamAction.fromList(readValue() as! [Any?]) + case 132: + return FiamText.fromList(readValue() as! [Any?]) + case 133: + return FiamDisplayAction.fromList(readValue() as! [Any?]) + case 134: + return FiamDisplayMessage.fromList(readValue() as! [Any?]) default: return super.readValue(ofType: type) } @@ -312,6 +542,15 @@ private class FirebaseInAppMessagingMessagesPigeonCodecWriter: FlutterStandardWr } else if let value = value as? FiamAction { super.writeByte(131) super.writeValue(value.toList()) + } else if let value = value as? FiamText { + super.writeByte(132) + super.writeValue(value.toList()) + } else if let value = value as? FiamDisplayAction { + super.writeByte(133) + super.writeValue(value.toList()) + } else if let value = value as? FiamDisplayMessage { + super.writeByte(134) + super.writeValue(value.toList()) } else { super.writeValue(value) } @@ -329,9 +568,10 @@ private class FirebaseInAppMessagingMessagesPigeonCodecReaderWriter: FlutterStan } class FirebaseInAppMessagingMessagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { - static let shared = FirebaseInAppMessagingMessagesPigeonCodec( - readerWriter: FirebaseInAppMessagingMessagesPigeonCodecReaderWriter() - ) + static let shared = + FirebaseInAppMessagingMessagesPigeonCodec( + readerWriter: FirebaseInAppMessagingMessagesPigeonCodecReaderWriter() + ) } /// Generated protocol from Pigeon that represents a handler of messages from Flutter. @@ -348,6 +588,19 @@ protocol FirebaseInAppMessagingHostApi { /// Attaches the native message lifecycle listeners that forward events to /// [FirebaseInAppMessagingFlutterApi]. Calling this more than once is a no-op. func addEventListeners(appName: String, completion: @escaping (Result) -> Void) + func setCustomDisplayEnabled( + appName: String, enabled: Bool, + completion: @escaping (Result) -> Void) + func reportImpression(campaignId: String, completion: @escaping (Result) -> Void) + func reportClick( + campaignId: String, actionId: String, + completion: @escaping (Result) -> Void) + func reportDismiss( + campaignId: String, dismissType: String, + completion: @escaping (Result) -> Void) + func reportDisplayError( + campaignId: String, reason: String, + completion: @escaping (Result) -> Void) } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -366,7 +619,8 @@ class FirebaseInAppMessagingHostApiSetup { let triggerEventChannel = FlutterBasicMessageChannel( name: "dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingHostApi.triggerEvent\(channelSuffix)", - binaryMessenger: binaryMessenger, codec: codec + binaryMessenger: binaryMessenger, + codec: codec ) if let api { triggerEventChannel.setMessageHandler { message, reply in @@ -388,7 +642,8 @@ class FirebaseInAppMessagingHostApiSetup { let setMessagesSuppressedChannel = FlutterBasicMessageChannel( name: "dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingHostApi.setMessagesSuppressed\(channelSuffix)", - binaryMessenger: binaryMessenger, codec: codec + binaryMessenger: binaryMessenger, + codec: codec ) if let api { setMessagesSuppressedChannel.setMessageHandler { message, reply in @@ -410,7 +665,8 @@ class FirebaseInAppMessagingHostApiSetup { let setAutomaticDataCollectionEnabledChannel = FlutterBasicMessageChannel( name: "dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingHostApi.setAutomaticDataCollectionEnabled\(channelSuffix)", - binaryMessenger: binaryMessenger, codec: codec + binaryMessenger: binaryMessenger, + codec: codec ) if let api { setAutomaticDataCollectionEnabledChannel.setMessageHandler { message, reply in @@ -434,7 +690,8 @@ class FirebaseInAppMessagingHostApiSetup { let addEventListenersChannel = FlutterBasicMessageChannel( name: "dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingHostApi.addEventListeners\(channelSuffix)", - binaryMessenger: binaryMessenger, codec: codec + binaryMessenger: binaryMessenger, + codec: codec ) if let api { addEventListenersChannel.setMessageHandler { message, reply in @@ -452,6 +709,120 @@ class FirebaseInAppMessagingHostApiSetup { } else { addEventListenersChannel.setMessageHandler(nil) } + let setCustomDisplayEnabledChannel = FlutterBasicMessageChannel( + name: + "dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingHostApi.setCustomDisplayEnabled\(channelSuffix)", + binaryMessenger: binaryMessenger, + codec: codec + ) + if let api { + setCustomDisplayEnabledChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let appNameArg = args[0] as! String + let enabledArg = args[1] as! Bool + api.setCustomDisplayEnabled(appName: appNameArg, enabled: enabledArg) { result in + switch result { + case .success: + reply(wrapResult(nil)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + setCustomDisplayEnabledChannel.setMessageHandler(nil) + } + let reportImpressionChannel = FlutterBasicMessageChannel( + name: + "dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingHostApi.reportImpression\(channelSuffix)", + binaryMessenger: binaryMessenger, + codec: codec + ) + if let api { + reportImpressionChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let campaignIdArg = args[0] as! String + api.reportImpression(campaignId: campaignIdArg) { result in + switch result { + case .success: + reply(wrapResult(nil)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + reportImpressionChannel.setMessageHandler(nil) + } + let reportClickChannel = FlutterBasicMessageChannel( + name: + "dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingHostApi.reportClick\(channelSuffix)", + binaryMessenger: binaryMessenger, + codec: codec + ) + if let api { + reportClickChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let campaignIdArg = args[0] as! String + let actionIdArg = args[1] as! String + api.reportClick(campaignId: campaignIdArg, actionId: actionIdArg) { result in + switch result { + case .success: + reply(wrapResult(nil)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + reportClickChannel.setMessageHandler(nil) + } + let reportDismissChannel = FlutterBasicMessageChannel( + name: + "dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingHostApi.reportDismiss\(channelSuffix)", + binaryMessenger: binaryMessenger, + codec: codec + ) + if let api { + reportDismissChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let campaignIdArg = args[0] as! String + let dismissTypeArg = args[1] as! String + api.reportDismiss(campaignId: campaignIdArg, dismissType: dismissTypeArg) { result in + switch result { + case .success: + reply(wrapResult(nil)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + reportDismissChannel.setMessageHandler(nil) + } + let reportDisplayErrorChannel = FlutterBasicMessageChannel( + name: + "dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingHostApi.reportDisplayError\(channelSuffix)", + binaryMessenger: binaryMessenger, + codec: codec + ) + if let api { + reportDisplayErrorChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let campaignIdArg = args[0] as! String + let reasonArg = args[1] as! String + api.reportDisplayError(campaignId: campaignIdArg, reason: reasonArg) { result in + switch result { + case .success: + reply(wrapResult(nil)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + reportDisplayErrorChannel.setMessageHandler(nil) + } } } @@ -472,6 +843,9 @@ protocol FirebaseInAppMessagingFlutterApiProtocol { campaignMetadata campaignMetadataArg: FiamCampaignMetadata, errorMessage errorMessageArg: String?, completion: @escaping (Result) -> Void) + func onMessageDisplay( + message messageArg: FiamDisplayMessage, + completion: @escaping (Result) -> Void) } class FirebaseInAppMessagingFlutterApi: FirebaseInAppMessagingFlutterApiProtocol { @@ -494,7 +868,9 @@ class FirebaseInAppMessagingFlutterApi: FirebaseInAppMessagingFlutterApiProtocol let channelName = "dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingFlutterApi.onMessageClicked\(messageChannelSuffix)" let channel = FlutterBasicMessageChannel( - name: channelName, binaryMessenger: binaryMessenger, codec: codec + name: channelName, + binaryMessenger: binaryMessenger, + codec: codec ) channel.sendMessage([campaignMetadataArg, actionArg] as [Any?]) { response in guard let listResponse = response as? [Any?] else { @@ -519,7 +895,9 @@ class FirebaseInAppMessagingFlutterApi: FirebaseInAppMessagingFlutterApiProtocol let channelName = "dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingFlutterApi.onMessageImpression\(messageChannelSuffix)" let channel = FlutterBasicMessageChannel( - name: channelName, binaryMessenger: binaryMessenger, codec: codec + name: channelName, + binaryMessenger: binaryMessenger, + codec: codec ) channel.sendMessage([campaignMetadataArg] as [Any?]) { response in guard let listResponse = response as? [Any?] else { @@ -545,7 +923,9 @@ class FirebaseInAppMessagingFlutterApi: FirebaseInAppMessagingFlutterApiProtocol let channelName = "dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingFlutterApi.onMessageDismissed\(messageChannelSuffix)" let channel = FlutterBasicMessageChannel( - name: channelName, binaryMessenger: binaryMessenger, codec: codec + name: channelName, + binaryMessenger: binaryMessenger, + codec: codec ) channel.sendMessage([campaignMetadataArg, dismissTypeArg] as [Any?]) { response in guard let listResponse = response as? [Any?] else { @@ -571,7 +951,9 @@ class FirebaseInAppMessagingFlutterApi: FirebaseInAppMessagingFlutterApiProtocol let channelName = "dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingFlutterApi.onMessageDisplayError\(messageChannelSuffix)" let channel = FlutterBasicMessageChannel( - name: channelName, binaryMessenger: binaryMessenger, codec: codec + name: channelName, + binaryMessenger: binaryMessenger, + codec: codec ) channel.sendMessage([campaignMetadataArg, errorMessageArg] as [Any?]) { response in guard let listResponse = response as? [Any?] else { @@ -588,4 +970,31 @@ class FirebaseInAppMessagingFlutterApi: FirebaseInAppMessagingFlutterApiProtocol } } } + + func onMessageDisplay( + message messageArg: FiamDisplayMessage, + completion: @escaping (Result) -> Void + ) { + let channelName = + "dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingFlutterApi.onMessageDisplay\(messageChannelSuffix)" + let channel = FlutterBasicMessageChannel( + name: channelName, + binaryMessenger: binaryMessenger, + codec: codec + ) + channel.sendMessage([messageArg] as [Any?]) { response in + guard let listResponse = response as? [Any?] else { + completion(.failure(createConnectionError(withChannelName: channelName))) + return + } + if listResponse.count > 1 { + let code: String = listResponse[0] as! String + let message: String? = nilOrValue(listResponse[1]) + let details: String? = nilOrValue(listResponse[2]) + completion(.failure(PigeonError(code: code, message: message, details: details))) + } else { + completion(.success(())) + } + } + } } diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging/ios/firebase_in_app_messaging/Sources/firebase_in_app_messaging/FirebaseInAppMessagingPlugin.swift b/packages/firebase_in_app_messaging/firebase_in_app_messaging/ios/firebase_in_app_messaging/Sources/firebase_in_app_messaging/FirebaseInAppMessagingPlugin.swift index 7fd574fd3830..a442dd123cbd 100644 --- a/packages/firebase_in_app_messaging/firebase_in_app_messaging/ios/firebase_in_app_messaging/Sources/firebase_in_app_messaging/FirebaseInAppMessagingPlugin.swift +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging/ios/firebase_in_app_messaging/Sources/firebase_in_app_messaging/FirebaseInAppMessagingPlugin.swift @@ -3,6 +3,7 @@ // found in the LICENSE file. import FirebaseInAppMessaging +import UIKit #if canImport(FlutterMacOS) import FlutterMacOS @@ -22,6 +23,16 @@ public class FirebaseInAppMessagingPlugin: NSObject, FLTFirebasePluginProtocol, FirebaseInAppMessagingHostApi { private var flutterApi: FirebaseInAppMessagingFlutterApi? + private var customDisplayEnabled = false + private var pendingMessage: InAppMessagingDisplayMessage? + private var pendingDelegate: InAppMessagingDisplayDelegate? + private var pendingActions: [String: InAppMessagingAction] = [:] + /// The SDK's original display component. `messageDisplayComponent` is non-null + /// in Swift, so disable restores this instead of assigning `nil`. + private var defaultDisplayComponent: (any InAppMessagingDisplay)? + + /// Retained so `messageDisplayComponent` is not deallocated. + private static var sharedInstance: FirebaseInAppMessagingPlugin? public static func register(with registrar: FlutterPluginRegistrar) { let binaryMessenger: FlutterBinaryMessenger @@ -34,6 +45,7 @@ public class FirebaseInAppMessagingPlugin: NSObject, FLTFirebasePluginProtocol, let instance = FirebaseInAppMessagingPlugin() instance.flutterApi = FirebaseInAppMessagingFlutterApi(binaryMessenger: binaryMessenger) + sharedInstance = instance FLTFirebasePluginRegistry.sharedInstance().register(instance) FirebaseInAppMessagingHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: instance) } @@ -99,6 +111,105 @@ public class FirebaseInAppMessagingPlugin: NSObject, FLTFirebasePluginProtocol, InAppMessaging.inAppMessaging().delegate = self completion(.success(())) } + + public func setCustomDisplayEnabled( + appName: String, + enabled: Bool, + completion: @escaping (Result) -> Void + ) { + customDisplayEnabled = enabled + let inAppMessaging = InAppMessaging.inAppMessaging() + if enabled { + if defaultDisplayComponent == nil, + !(inAppMessaging.messageDisplayComponent is FirebaseInAppMessagingPlugin) + { + defaultDisplayComponent = inAppMessaging.messageDisplayComponent + } + inAppMessaging.messageDisplayComponent = self + } else if let original = defaultDisplayComponent { + inAppMessaging.messageDisplayComponent = original + dismissPending(type: .typeAuto) + } else { + dismissPending(type: .typeAuto) + } + completion(.success(())) + } + + public func reportImpression( + campaignId: String, + completion: @escaping (Result) -> Void + ) { + if let message = pendingMessage, let delegate = pendingDelegate { + delegate.impressionDetected?(for: message) + } + completion(.success(())) + } + + public func reportClick( + campaignId: String, + actionId: String, + completion: @escaping (Result) -> Void + ) { + if let message = pendingMessage, let delegate = pendingDelegate { + if let action = pendingActions[actionId] { + delegate.messageClicked?(message, with: action) + } else { + delegate.messageDismissed?(message, dismissType: .typeUserTapClose) + } + } + clearPending() + completion(.success(())) + } + + public func reportDismiss( + campaignId: String, + dismissType: String, + completion: @escaping (Result) -> Void + ) { + dismissPending(type: nativeDismissType(dismissType)) + completion(.success(())) + } + + public func reportDisplayError( + campaignId: String, + reason: String, + completion: @escaping (Result) -> Void + ) { + if let message = pendingMessage, let delegate = pendingDelegate { + let error = NSError( + domain: "firebase_in_app_messaging", + code: 0, + userInfo: [NSLocalizedDescriptionKey: reason] + ) + delegate.displayError?(for: message, error: error) + } + clearPending() + completion(.success(())) + } + + private func dismissPending(type: InAppMessagingDismissType) { + if let message = pendingMessage, let delegate = pendingDelegate { + delegate.messageDismissed?(message, dismissType: type) + } + clearPending() + } + + private func clearPending() { + pendingMessage = nil + pendingDelegate = nil + pendingActions = [:] + } + + private func nativeDismissType(_ dismissType: String) -> InAppMessagingDismissType { + switch dismissType { + case "auto": + return .typeAuto + case "swipe": + return .typeUserSwipe + default: + return .typeUserTapClose + } + } } /// The delegate callbacks are documented as being called on the main thread, @@ -143,7 +254,7 @@ extension FirebaseInAppMessagingPlugin: InAppMessagingDisplayDelegate { ) { _ in } } - private static func campaignMetadata(_ inAppMessage: InAppMessagingDisplayMessage) + fileprivate static func campaignMetadata(_ inAppMessage: InAppMessagingDisplayMessage) -> FiamCampaignMetadata { FiamCampaignMetadata( @@ -153,7 +264,7 @@ extension FirebaseInAppMessagingPlugin: InAppMessagingDisplayDelegate { ) } - private static func dismissType(_ dismissType: InAppMessagingDismissType) -> FiamDismissType { + fileprivate static func dismissType(_ dismissType: InAppMessagingDismissType) -> FiamDismissType { switch dismissType { case .typeUserSwipe: return .swipe @@ -166,3 +277,235 @@ extension FirebaseInAppMessagingPlugin: InAppMessagingDisplayDelegate { } } } + +extension FirebaseInAppMessagingPlugin: InAppMessagingDisplay { + public func displayMessage( + _ messageForDisplay: InAppMessagingDisplayMessage, + displayDelegate: InAppMessagingDisplayDelegate + ) { + DispatchQueue.main.async { [weak self] in + self?.handleDisplay(messageForDisplay, displayDelegate: displayDelegate) + } + } + + private func handleDisplay( + _ messageForDisplay: InAppMessagingDisplayMessage, + displayDelegate: InAppMessagingDisplayDelegate + ) { + guard customDisplayEnabled else { + displayDelegate.messageDismissed?(messageForDisplay, dismissType: .typeAuto) + return + } + + let payload = Self.toDisplayMessage(messageForDisplay) + pendingMessage = messageForDisplay + pendingDelegate = displayDelegate + pendingActions = Self.collectActions(messageForDisplay) + flutterApi?.onMessageDisplay(message: payload) { _ in } + } + + private static func toDisplayMessage(_ message: InAppMessagingDisplayMessage) + -> FiamDisplayMessage + { + let metadata = campaignMetadata(message) + let campaignId = metadata.campaignId + + if let modal = message as? InAppMessagingModalDisplay { + return FiamDisplayMessage( + campaignMetadata: metadata, + messageType: "MODAL", + title: FiamText(text: modal.title, hexColor: hexString(from: modal.textColor)), + body: modal.bodyText.map { FiamText(text: $0, hexColor: hexString(from: modal.textColor)) }, + imageUrl: modal.imageData?.imageURL, + landscapeImageUrl: nil, + backgroundHexColor: hexString(from: modal.displayBackgroundColor), + action: displayAction( + id: "\(campaignId)_action", + text: modal.actionButton?.buttonText, + textColor: modal.actionButton?.buttonTextColor, + backgroundColor: modal.actionButton?.buttonBackgroundColor, + url: modal.actionURL + ), + primaryAction: nil, + secondaryAction: nil, + data: appData(message) + ) + } + + if let banner = message as? InAppMessagingBannerDisplay { + return FiamDisplayMessage( + campaignMetadata: metadata, + messageType: "BANNER", + title: FiamText(text: banner.title, hexColor: hexString(from: banner.textColor)), + body: banner.bodyText.map { + FiamText(text: $0, hexColor: hexString(from: banner.textColor)) + }, + imageUrl: banner.imageData?.imageURL, + landscapeImageUrl: nil, + backgroundHexColor: hexString(from: banner.displayBackgroundColor), + action: displayAction( + id: "\(campaignId)_action", + text: nil, + textColor: nil, + backgroundColor: nil, + url: banner.actionURL + ), + primaryAction: nil, + secondaryAction: nil, + data: appData(message) + ) + } + + if let card = message as? InAppMessagingCardDisplay { + return FiamDisplayMessage( + campaignMetadata: metadata, + messageType: "CARD", + title: FiamText(text: card.title, hexColor: hexString(from: card.textColor)), + body: card.body.map { FiamText(text: $0, hexColor: hexString(from: card.textColor)) }, + imageUrl: card.portraitImageData.imageURL, + landscapeImageUrl: card.landscapeImageData?.imageURL, + backgroundHexColor: hexString(from: card.displayBackgroundColor), + action: nil, + primaryAction: displayAction( + id: "\(campaignId)_primary", + text: card.primaryActionButton.buttonText, + textColor: card.primaryActionButton.buttonTextColor, + backgroundColor: card.primaryActionButton.buttonBackgroundColor, + url: card.primaryActionURL + ), + secondaryAction: displayAction( + id: "\(campaignId)_secondary", + text: card.secondaryActionButton?.buttonText, + textColor: card.secondaryActionButton?.buttonTextColor, + backgroundColor: card.secondaryActionButton?.buttonBackgroundColor, + url: card.secondaryActionURL + ), + data: appData(message) + ) + } + + if let imageOnly = message as? InAppMessagingImageOnlyDisplay { + return FiamDisplayMessage( + campaignMetadata: metadata, + messageType: "IMAGE_ONLY", + title: nil, + body: nil, + imageUrl: imageOnly.imageData.imageURL, + landscapeImageUrl: nil, + backgroundHexColor: nil, + action: displayAction( + id: "\(campaignId)_action", + text: nil, + textColor: nil, + backgroundColor: nil, + url: imageOnly.actionURL + ), + primaryAction: nil, + secondaryAction: nil, + data: appData(message) + ) + } + + return FiamDisplayMessage( + campaignMetadata: metadata, + messageType: "UNKNOWN", + title: nil, + body: nil, + imageUrl: nil, + landscapeImageUrl: nil, + backgroundHexColor: nil, + action: nil, + primaryAction: nil, + secondaryAction: nil, + data: appData(message) + ) + } + + private static func collectActions(_ message: InAppMessagingDisplayMessage) + -> [String: InAppMessagingAction] + { + let campaignId = campaignMetadata(message).campaignId + var actions: [String: InAppMessagingAction] = [:] + + if let modal = message as? InAppMessagingModalDisplay { + actions["\(campaignId)_action"] = InAppMessagingAction( + actionText: modal.actionButton?.buttonText, + actionURL: modal.actionURL + ) + } else if let banner = message as? InAppMessagingBannerDisplay { + actions["\(campaignId)_action"] = InAppMessagingAction( + actionText: nil, + actionURL: banner.actionURL + ) + } else if let card = message as? InAppMessagingCardDisplay { + actions["\(campaignId)_primary"] = InAppMessagingAction( + actionText: card.primaryActionButton.buttonText, + actionURL: card.primaryActionURL + ) + if let secondary = card.secondaryActionButton { + actions["\(campaignId)_secondary"] = InAppMessagingAction( + actionText: secondary.buttonText, + actionURL: card.secondaryActionURL + ) + } + } else if let imageOnly = message as? InAppMessagingImageOnlyDisplay { + actions["\(campaignId)_action"] = InAppMessagingAction( + actionText: nil, + actionURL: imageOnly.actionURL + ) + } + + return actions + } + + private static func displayAction( + id: String, + text: String?, + textColor: UIColor?, + backgroundColor: UIColor?, + url: URL? + ) -> FiamDisplayAction? { + if text == nil, url == nil { + return nil + } + return FiamDisplayAction( + id: id, + actionUrl: url?.absoluteString, + buttonText: text, + buttonTextHexColor: textColor.map { hexString(from: $0) }, + buttonBackgroundHexColor: backgroundColor.map { hexString(from: $0) } + ) + } + + private static func appData(_ message: InAppMessagingDisplayMessage) -> [String?: String?]? { + guard let appData = message.appData, !appData.isEmpty else { return nil } + var mapped: [String?: String?] = [:] + for (key, value) in appData { + mapped[String(describing: key)] = String(describing: value) + } + return mapped + } + + private static func hexString(from color: UIColor) -> String { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + if alpha < 1 { + return String( + format: "#%02X%02X%02X%02X", + Int(alpha * 255), + Int(red * 255), + Int(green * 255), + Int(blue * 255) + ) + } + return String( + format: "#%02X%02X%02X", + Int(red * 255), + Int(green * 255), + Int(blue * 255) + ) + } +} 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 c464d989f93e..6d8887f408a6 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 @@ -15,7 +15,11 @@ export 'package:firebase_in_app_messaging_platform_interface/firebase_in_app_mes InAppMessagingDismissEvent, InAppMessagingDismissType, InAppMessagingDisplayErrorEvent, - InAppMessagingImpressionEvent; + InAppMessagingImpressionEvent, + InAppMessage, + InAppMessageAction, + InAppMessageText, + InAppMessageType; class FirebaseInAppMessaging extends FirebasePlugin { FirebaseInAppMessaging._({required this.app}) @@ -103,4 +107,29 @@ class FirebaseInAppMessaging extends FirebasePlugin { /// because its image failed to download. Stream get onMessageDisplayError => _delegate.onMessageDisplayError; + + /// Opt in to custom Flutter rendering for In-App Messaging campaigns. + /// + /// 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. + /// + /// Apps must report [InAppMessage.impress], [InAppMessage.click], or + /// [InAppMessage.dismiss] so analytics and frequency capping keep working. + /// The plugin does not open [InAppMessageAction.actionUrl]. + /// + /// Reporting those callbacks still notifies the lifecycle streams + /// ([onMessageClicked], [onMessageImpression], and so on) if you listen + /// to them. + Future setCustomDisplayEnabled(bool enabled) { + return _delegate.setCustomDisplayEnabled(enabled); + } + + /// Campaigns the native SDK wants shown while custom display is enabled. + /// + /// 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. + Stream get onMessageDisplay => _delegate.onMessageDisplay; } diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging/test/firebase_in_app_messaging_test.dart b/packages/firebase_in_app_messaging/firebase_in_app_messaging/test/firebase_in_app_messaging_test.dart index 3a0744700f66..a8874935a454 100644 --- a/packages/firebase_in_app_messaging/firebase_in_app_messaging/test/firebase_in_app_messaging_test.dart +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging/test/firebase_in_app_messaging_test.dart @@ -43,6 +43,9 @@ void main() { when(mockFiam.setAutomaticDataCollectionEnabled(any)).thenAnswer( (_) => Future.value(), ); + when(mockFiam.setCustomDisplayEnabled(any)).thenAnswer( + (_) => Future.value(), + ); }); test('triggerEvent', () async { @@ -144,6 +147,35 @@ void main() { expect(await emitted, event); }); + + test('setCustomDisplayEnabled', () async { + await fiam.setCustomDisplayEnabled(true); + verify(mockFiam.setCustomDisplayEnabled(true)); + }); + + test('onMessageDisplay', () async { + final controller = StreamController(); + addTearDown(controller.close); + when(mockFiam.onMessageDisplay).thenAnswer((_) => controller.stream); + + final event = InAppMessage( + campaignMetadata: const InAppMessagingCampaignMetadata( + campaignId: 'campaign-id', + campaignName: 'campaign-name', + isTestMessage: false, + ), + messageType: InAppMessageType.modal, + onImpress: () async {}, + onClick: (_) async {}, + onDismiss: (_) async {}, + onError: (_) async {}, + ); + + final emitted = fiam.onMessageDisplay.first; + controller.add(event); + + expect(await emitted, event); + }); }); } @@ -233,6 +265,24 @@ class MockFirebaseInAppMessaging extends Mock const Stream.empty(), ); } + + @override + Future setCustomDisplayEnabled(bool? enabled) { + return super.noSuchMethod( + Invocation.method(#setCustomDisplayEnabled, [enabled]), + returnValue: Future.value(), + returnValueForMissingStub: Future.value(), + ); + } + + @override + Stream get onMessageDisplay { + return super.noSuchMethod( + Invocation.getter(#onMessageDisplay), + returnValue: const Stream.empty(), + returnValueForMissingStub: const Stream.empty(), + ); + } } class TestFirebaseInAppMessagingPlatform diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/lib/firebase_in_app_messaging_platform_interface.dart b/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/lib/firebase_in_app_messaging_platform_interface.dart index 00d1fa43ead8..0ef787188a33 100644 --- a/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/lib/firebase_in_app_messaging_platform_interface.dart +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/lib/firebase_in_app_messaging_platform_interface.dart @@ -3,4 +3,5 @@ // found in the LICENSE file. export 'src/events.dart'; +export 'src/in_app_message.dart'; export 'src/platform_interface/platform_interface_firebase_in_app_messaging.dart'; diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/lib/src/in_app_message.dart b/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/lib/src/in_app_message.dart new file mode 100644 index 000000000000..e23b4e828529 --- /dev/null +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/lib/src/in_app_message.dart @@ -0,0 +1,165 @@ +// 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. + +import 'events.dart'; + +/// Layout chosen for the campaign in the Firebase Console. +enum InAppMessageType { + /// Top or bottom banner. + banner, + + /// Centered modal. + modal, + + /// Card with optional portrait and landscape images. + card, + + /// Image with an optional tap action. + imageOnly, + + /// Unrecognized native message type. + unknown, +} + +/// Styled text from a campaign. +class InAppMessageText { + /// Creates an [InAppMessageText]. + const InAppMessageText({ + required this.text, + this.hexColor, + }); + + /// Visible copy. + final String text; + + /// Optional `#RRGGBB` or `#AARRGGBB` color from the console. + final String? hexColor; +} + +/// A campaign action used when rendering with a custom Flutter UI. +class InAppMessageAction { + /// Creates an [InAppMessageAction]. + const InAppMessageAction({ + required this.id, + this.actionUrl, + this.buttonText, + this.buttonTextHexColor, + this.buttonBackgroundHexColor, + }); + + /// Plugin-generated id used when reporting a click to native. + final String id; + + /// URL or deep link from the console. The plugin does not open this. + final String? actionUrl; + + /// Button label, if the layout has a button. + final String? buttonText; + + /// Optional `#RRGGBB` or `#AARRGGBB` button text color. + final String? buttonTextHexColor; + + /// Optional `#RRGGBB` or `#AARRGGBB` button background color. + final String? buttonBackgroundHexColor; +} + +/// A campaign the native SDK decided to show, forwarded for Flutter rendering. +/// +/// Only delivered after `setCustomDisplayEnabled(true)`. Call [impress], +/// [click], [dismiss], or [reportError] so analytics and frequency capping +/// keep working. +class InAppMessage { + /// Creates an [InAppMessage]. + InAppMessage({ + required this.campaignMetadata, + required this.messageType, + this.title, + this.body, + this.imageUrl, + this.landscapeImageUrl, + this.backgroundHexColor, + this.action, + this.primaryAction, + this.secondaryAction, + this.data = const {}, + required Future Function() onImpress, + required Future Function(InAppMessageAction action) onClick, + required Future Function(InAppMessagingDismissType type) onDismiss, + required Future Function(String reason) onError, + }) : _onImpress = onImpress, + _onClick = onClick, + _onDismiss = onDismiss, + _onError = onError; + + final Future Function() _onImpress; + final Future Function(InAppMessageAction action) _onClick; + final Future Function(InAppMessagingDismissType type) _onDismiss; + final Future Function(String reason) _onError; + bool _terminal = false; + + /// Campaign id, name, and test-message flag. + final InAppMessagingCampaignMetadata campaignMetadata; + + /// Console layout type. + final InAppMessageType messageType; + + /// Title copy and color. + final InAppMessageText? title; + + /// Body copy and color. + final InAppMessageText? body; + + /// Image URL for banner, modal, image-only, and card portrait. + final String? imageUrl; + + /// Landscape image URL for card campaigns. + final String? landscapeImageUrl; + + /// Optional `#RRGGBB` or `#AARRGGBB` background color. + final String? backgroundHexColor; + + /// Single action for banner, modal, and image-only layouts. + final InAppMessageAction? action; + + /// Primary action for card layouts. + final InAppMessageAction? primaryAction; + + /// Secondary action for card layouts. + final InAppMessageAction? secondaryAction; + + /// Custom key/value metadata from the console campaign. + final Map data; + + /// Reports that the user saw the message (frequency capping). + Future impress() => _onImpress(); + + /// Reports that the user followed [action]. Does not open [actionUrl]. + Future click(InAppMessageAction action) async { + if (_terminal) { + return; + } + _terminal = true; + await _onClick(action); + } + + /// Reports that the message was dismissed without following an action. + Future dismiss([ + InAppMessagingDismissType type = InAppMessagingDismissType.clickedCancel, + ]) async { + if (_terminal) { + return; + } + _terminal = true; + await _onDismiss(type); + } + + /// Reports that Flutter failed to display the message (for example image load). + Future reportError(String reason) async { + if (_terminal) { + return; + } + _terminal = true; + await _onError(reason); + } +} 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 7bd5590f8420..78a55a19f9fe 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 @@ -68,6 +68,13 @@ class _FirebaseInAppMessagingFlutterApi ); } + @override + void onMessageDisplay(pigeon.FiamDisplayMessage message) { + MethodChannelFirebaseInAppMessaging.displayController.add( + inAppMessageFromPigeon(message), + ); + } + static InAppMessagingCampaignMetadata _campaignMetadata( pigeon.FiamCampaignMetadata metadata, ) { @@ -133,7 +140,12 @@ class MethodChannelFirebaseInAppMessaging static final displayErrorController = StreamController.broadcast(); + @visibleForTesting + // ignore: close_sinks + static final displayController = StreamController.broadcast(); + static bool _eventListenersAdded = false; + static bool _flutterApiSetUp = false; /// Returns a stub instance to allow the platform interface to access /// the class instance statically. @@ -197,18 +209,77 @@ class MethodChannelFirebaseInAppMessaging return displayErrorController.stream; } + @override + Stream get onMessageDisplay { + _ensureFlutterApi(); + return displayController.stream; + } + + @override + Future setCustomDisplayEnabled(bool enabled) async { + _ensureFlutterApi(); + try { + await pigeonChannel.setCustomDisplayEnabled(app!.name, enabled); + } catch (e, s) { + convertPlatformException(e, s); + } + } + + @override + Future reportImpression(String campaignId) async { + try { + await pigeonChannel.reportImpression(campaignId); + } catch (e, s) { + convertPlatformException(e, s); + } + } + + @override + Future reportClick(String campaignId, String actionId) async { + try { + await pigeonChannel.reportClick(campaignId, actionId); + } catch (e, s) { + convertPlatformException(e, s); + } + } + + @override + Future reportDismiss(String campaignId, String dismissType) async { + try { + await pigeonChannel.reportDismiss(campaignId, dismissType); + } catch (e, s) { + convertPlatformException(e, s); + } + } + + @override + Future reportDisplayError(String campaignId, String reason) async { + try { + await pigeonChannel.reportDisplayError(campaignId, reason); + } catch (e, s) { + convertPlatformException(e, s); + } + } + + void _ensureFlutterApi() { + if (_flutterApiSetUp) { + return; + } + _flutterApiSetUp = true; + pigeon.FirebaseInAppMessagingFlutterApi.setUp( + _FirebaseInAppMessagingFlutterApi(), + ); + } + /// The message lifecycle listeners are attached natively the first time one /// of the event streams is used, so that apps which never listen keep the /// previous behavior - most notably they keep ownership of the iOS /// `InAppMessaging` display delegate. void _ensureEventListeners() { + _ensureFlutterApi(); if (_eventListenersAdded) return; _eventListenersAdded = true; - pigeon.FirebaseInAppMessagingFlutterApi.setUp( - _FirebaseInAppMessagingFlutterApi(), - ); - pigeonChannel.addEventListeners(app!.name).catchError(( Object error, StackTrace stackTrace, @@ -227,3 +298,83 @@ class MethodChannelFirebaseInAppMessaging }); } } + +InAppMessage inAppMessageFromPigeon(pigeon.FiamDisplayMessage message) { + InAppMessageText? mapText(pigeon.FiamText? text) { + if (text == null) { + return null; + } + return InAppMessageText(text: text.text, hexColor: text.hexColor); + } + + InAppMessageAction? mapAction(pigeon.FiamDisplayAction? action) { + if (action == null) { + return null; + } + return InAppMessageAction( + id: action.id, + actionUrl: action.actionUrl, + buttonText: action.buttonText, + buttonTextHexColor: action.buttonTextHexColor, + buttonBackgroundHexColor: action.buttonBackgroundHexColor, + ); + } + + final data = {}; + message.data?.forEach((key, value) { + if (key != null && value != null) { + data[key] = value; + } + }); + + final campaignId = message.campaignMetadata.campaignId; + + return InAppMessage( + campaignMetadata: InAppMessagingCampaignMetadata( + campaignId: campaignId, + campaignName: message.campaignMetadata.campaignName, + isTestMessage: message.campaignMetadata.isTestMessage, + ), + messageType: parseInAppMessageType(message.messageType), + title: mapText(message.title), + body: mapText(message.body), + imageUrl: message.imageUrl, + landscapeImageUrl: message.landscapeImageUrl, + backgroundHexColor: message.backgroundHexColor, + action: mapAction(message.action), + primaryAction: mapAction(message.primaryAction), + secondaryAction: mapAction(message.secondaryAction), + data: data, + onImpress: () { + return MethodChannelFirebaseInAppMessaging.instance + .reportImpression(campaignId); + }, + onClick: (action) { + return MethodChannelFirebaseInAppMessaging.instance + .reportClick(campaignId, action.id); + }, + onDismiss: (type) { + return MethodChannelFirebaseInAppMessaging.instance + .reportDismiss(campaignId, type.name); + }, + onError: (reason) { + return MethodChannelFirebaseInAppMessaging.instance + .reportDisplayError(campaignId, reason); + }, + ); +} + +InAppMessageType parseInAppMessageType(String raw) { + switch (raw) { + case 'BANNER': + return InAppMessageType.banner; + case 'MODAL': + return InAppMessageType.modal; + case 'CARD': + return InAppMessageType.card; + case 'IMAGE_ONLY': + return InAppMessageType.imageOnly; + default: + return InAppMessageType.unknown; + } +} diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/lib/src/pigeon/messages.pigeon.dart b/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/lib/src/pigeon/messages.pigeon.dart index 3b7f5242b485..23ea47e17f91 100644 --- a/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/lib/src/pigeon/messages.pigeon.dart +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/lib/src/pigeon/messages.pigeon.dart @@ -229,6 +229,223 @@ class FiamAction { int get hashCode => _deepHash([runtimeType, ..._toList()]); } +/// Styled text from a campaign, used by custom Flutter display. +class FiamText { + FiamText({ + required this.text, + this.hexColor, + }); + + String text; + + String? hexColor; + + List _toList() { + return [ + text, + hexColor, + ]; + } + + Object encode() { + return _toList(); + } + + static FiamText decode(Object result) { + result as List; + return FiamText( + text: result[0]! as String, + hexColor: result[1] as String?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! FiamText || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(text, other.text) && + _deepEquals(hexColor, other.hexColor); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); +} + +/// A campaign action forwarded for custom Flutter display. +class FiamDisplayAction { + FiamDisplayAction({ + required this.id, + this.actionUrl, + this.buttonText, + this.buttonTextHexColor, + this.buttonBackgroundHexColor, + }); + + String id; + + String? actionUrl; + + String? buttonText; + + String? buttonTextHexColor; + + String? buttonBackgroundHexColor; + + List _toList() { + return [ + id, + actionUrl, + buttonText, + buttonTextHexColor, + buttonBackgroundHexColor, + ]; + } + + Object encode() { + return _toList(); + } + + static FiamDisplayAction decode(Object result) { + result as List; + return FiamDisplayAction( + id: result[0]! as String, + actionUrl: result[1] as String?, + buttonText: result[2] as String?, + buttonTextHexColor: result[3] as String?, + buttonBackgroundHexColor: result[4] as String?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! FiamDisplayAction || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(id, other.id) && + _deepEquals(actionUrl, other.actionUrl) && + _deepEquals(buttonText, other.buttonText) && + _deepEquals(buttonTextHexColor, other.buttonTextHexColor) && + _deepEquals(buttonBackgroundHexColor, other.buttonBackgroundHexColor); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); +} + +/// Full campaign payload forwarded instead of native templates. +class FiamDisplayMessage { + FiamDisplayMessage({ + required this.campaignMetadata, + required this.messageType, + this.title, + this.body, + this.imageUrl, + this.landscapeImageUrl, + this.backgroundHexColor, + this.action, + this.primaryAction, + this.secondaryAction, + this.data, + }); + + FiamCampaignMetadata campaignMetadata; + + /// One of BANNER, MODAL, CARD, IMAGE_ONLY, UNKNOWN. + String messageType; + + FiamText? title; + + FiamText? body; + + String? imageUrl; + + String? landscapeImageUrl; + + String? backgroundHexColor; + + FiamDisplayAction? action; + + FiamDisplayAction? primaryAction; + + FiamDisplayAction? secondaryAction; + + Map? data; + + List _toList() { + return [ + campaignMetadata, + messageType, + title, + body, + imageUrl, + landscapeImageUrl, + backgroundHexColor, + action, + primaryAction, + secondaryAction, + data, + ]; + } + + Object encode() { + return _toList(); + } + + static FiamDisplayMessage decode(Object result) { + result as List; + return FiamDisplayMessage( + campaignMetadata: result[0]! as FiamCampaignMetadata, + messageType: result[1]! as String, + title: result[2] as FiamText?, + body: result[3] as FiamText?, + imageUrl: result[4] as String?, + landscapeImageUrl: result[5] as String?, + backgroundHexColor: result[6] as String?, + action: result[7] as FiamDisplayAction?, + primaryAction: result[8] as FiamDisplayAction?, + secondaryAction: result[9] as FiamDisplayAction?, + data: (result[10] as Map?)?.cast(), + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! FiamDisplayMessage || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(campaignMetadata, other.campaignMetadata) && + _deepEquals(messageType, other.messageType) && + _deepEquals(title, other.title) && + _deepEquals(body, other.body) && + _deepEquals(imageUrl, other.imageUrl) && + _deepEquals(landscapeImageUrl, other.landscapeImageUrl) && + _deepEquals(backgroundHexColor, other.backgroundHexColor) && + _deepEquals(action, other.action) && + _deepEquals(primaryAction, other.primaryAction) && + _deepEquals(secondaryAction, other.secondaryAction) && + _deepEquals(data, other.data); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); +} + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -245,6 +462,15 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is FiamAction) { buffer.putUint8(131); writeValue(buffer, value.encode()); + } else if (value is FiamText) { + buffer.putUint8(132); + writeValue(buffer, value.encode()); + } else if (value is FiamDisplayAction) { + buffer.putUint8(133); + writeValue(buffer, value.encode()); + } else if (value is FiamDisplayMessage) { + buffer.putUint8(134); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -260,6 +486,12 @@ class _PigeonCodec extends StandardMessageCodec { return FiamCampaignMetadata.decode(readValue(buffer)!); case 131: return FiamAction.decode(readValue(buffer)!); + case 132: + return FiamText.decode(readValue(buffer)!); + case 133: + return FiamDisplayAction.decode(readValue(buffer)!); + case 134: + return FiamDisplayMessage.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } @@ -359,6 +591,101 @@ class FirebaseInAppMessagingHostApi { isNullValid: true, ); } + + Future setCustomDisplayEnabled(String appName, bool enabled) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingHostApi.setCustomDisplayEnabled$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = + pigeonVar_channel.send([appName, enabled]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); + } + + Future reportImpression(String campaignId) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingHostApi.reportImpression$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = + pigeonVar_channel.send([campaignId]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); + } + + Future reportClick(String campaignId, String actionId) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingHostApi.reportClick$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = + pigeonVar_channel.send([campaignId, actionId]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); + } + + Future reportDismiss(String campaignId, String dismissType) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingHostApi.reportDismiss$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = + pigeonVar_channel.send([campaignId, dismissType]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); + } + + Future reportDisplayError(String campaignId, String reason) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingHostApi.reportDisplayError$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = + pigeonVar_channel.send([campaignId, reason]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); + } } abstract class FirebaseInAppMessagingFlutterApi { @@ -375,6 +702,8 @@ abstract class FirebaseInAppMessagingFlutterApi { void onMessageDisplayError( FiamCampaignMetadata campaignMetadata, String? errorMessage); + void onMessageDisplay(FiamDisplayMessage message); + static void setUp( FirebaseInAppMessagingFlutterApi? api, { BinaryMessenger? binaryMessenger, @@ -481,5 +810,28 @@ abstract class FirebaseInAppMessagingFlutterApi { }); } } + { + final pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingFlutterApi.onMessageDisplay$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + final List args = message! as List; + final FiamDisplayMessage arg_message = args[0]! as FiamDisplayMessage; + try { + api.onMessageDisplay(arg_message); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } } } diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/lib/src/platform_interface/platform_interface_firebase_in_app_messaging.dart b/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/lib/src/platform_interface/platform_interface_firebase_in_app_messaging.dart index 1d529eaebaf6..5d76bde8c54e 100644 --- a/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/lib/src/platform_interface/platform_interface_firebase_in_app_messaging.dart +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/lib/src/platform_interface/platform_interface_firebase_in_app_messaging.dart @@ -7,6 +7,7 @@ import 'package:meta/meta.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import '../events.dart'; +import '../in_app_message.dart'; import '../method_channel/method_channel_firebase_in_app_messaging.dart'; abstract class FirebaseInAppMessagingPlatform extends PlatformInterface { @@ -89,4 +90,37 @@ abstract class FirebaseInAppMessagingPlatform extends PlatformInterface { Stream get onMessageDisplayError { throw UnimplementedError('onMessageDisplayError is not implemented'); } + + /// Opt in or out of custom Flutter rendering. + /// + /// When enabled, the native SDK does not draw its default templates. Eligible + /// campaigns are forwarded on [onMessageDisplay] instead. + Future setCustomDisplayEnabled(bool enabled) { + throw UnimplementedError('setCustomDisplayEnabled() is not implemented'); + } + + /// Campaigns the native SDK wants shown, after [setCustomDisplayEnabled] is true. + Stream get onMessageDisplay { + throw UnimplementedError('onMessageDisplay is not implemented'); + } + + /// Reports that [campaignId] was displayed. + Future reportImpression(String campaignId) { + throw UnimplementedError('reportImpression() is not implemented'); + } + + /// Reports that the user followed [actionId] on [campaignId]. + Future reportClick(String campaignId, String actionId) { + throw UnimplementedError('reportClick() is not implemented'); + } + + /// Reports that [campaignId] was dismissed. + Future reportDismiss(String campaignId, String dismissType) { + throw UnimplementedError('reportDismiss() is not implemented'); + } + + /// Reports that Flutter failed to display [campaignId]. + Future reportDisplayError(String campaignId, String reason) { + throw UnimplementedError('reportDisplayError() is not implemented'); + } } diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/pigeons/messages.dart b/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/pigeons/messages.dart index e5ad4b6e64f4..6dfcb7699652 100644 --- a/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/pigeons/messages.dart +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/pigeons/messages.dart @@ -58,6 +58,62 @@ class FiamAction { final String? buttonText; } +/// Styled text from a campaign, used by custom Flutter display. +class FiamText { + const FiamText({required this.text, this.hexColor}); + + final String text; + final String? hexColor; +} + +/// A campaign action forwarded for custom Flutter display. +class FiamDisplayAction { + const FiamDisplayAction({ + required this.id, + this.actionUrl, + this.buttonText, + this.buttonTextHexColor, + this.buttonBackgroundHexColor, + }); + + final String id; + final String? actionUrl; + final String? buttonText; + final String? buttonTextHexColor; + final String? buttonBackgroundHexColor; +} + +/// Full campaign payload forwarded instead of native templates. +class FiamDisplayMessage { + const FiamDisplayMessage({ + required this.campaignMetadata, + required this.messageType, + this.title, + this.body, + this.imageUrl, + this.landscapeImageUrl, + this.backgroundHexColor, + this.action, + this.primaryAction, + this.secondaryAction, + this.data, + }); + + final FiamCampaignMetadata campaignMetadata; + + /// One of BANNER, MODAL, CARD, IMAGE_ONLY, UNKNOWN. + final String messageType; + final FiamText? title; + final FiamText? body; + final String? imageUrl; + final String? landscapeImageUrl; + final String? backgroundHexColor; + final FiamDisplayAction? action; + final FiamDisplayAction? primaryAction; + final FiamDisplayAction? secondaryAction; + final Map? data; +} + @HostApi(dartHostTestHandler: 'TestFirebaseInAppMessagingHostApi') abstract class FirebaseInAppMessagingHostApi { @async @@ -73,6 +129,21 @@ abstract class FirebaseInAppMessagingHostApi { /// [FirebaseInAppMessagingFlutterApi]. Calling this more than once is a no-op. @async void addEventListeners(String appName); + + @async + void setCustomDisplayEnabled(String appName, bool enabled); + + @async + void reportImpression(String campaignId); + + @async + void reportClick(String campaignId, String actionId); + + @async + void reportDismiss(String campaignId, String dismissType); + + @async + void reportDisplayError(String campaignId, String reason); } @FlutterApi() @@ -91,4 +162,6 @@ abstract class FirebaseInAppMessagingFlutterApi { FiamCampaignMetadata campaignMetadata, String? errorMessage, ); + + void onMessageDisplay(FiamDisplayMessage message); } 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 1256deee908d..63076e12ed02 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 @@ -143,6 +143,49 @@ void main() { expect(errorEvent.campaignMetadata.campaignId, 'campaign-id'); expect(errorEvent.errorMessage, 'IMAGE_FETCH_ERROR'); }); + + test('setCustomDisplayEnabled forwards the app name and value', () async { + await inAppMessaging.setCustomDisplayEnabled(true); + + expect(hostApi.appName, app.name); + expect(hostApi.customDisplayEnabled, isTrue); + }); + + test('onMessageDisplay emits the campaign payload', () async { + await inAppMessaging.setCustomDisplayEnabled(true); + final event = inAppMessaging.onMessageDisplay.first; + + await _sendFlutterApiMessage('onMessageDisplay', [ + pigeon.FiamDisplayMessage( + campaignMetadata: pigeon.FiamCampaignMetadata( + campaignId: 'campaign-id', + campaignName: 'campaign-name', + isTestMessage: false, + ), + messageType: 'MODAL', + title: pigeon.FiamText(text: 'Hello', hexColor: '#FFFFFF'), + body: pigeon.FiamText(text: 'World'), + imageUrl: 'https://example.com/image.png', + action: pigeon.FiamDisplayAction( + id: 'campaign-id_action', + actionUrl: 'https://example.com', + buttonText: 'Open', + ), + data: {'promo': 'SAVE10'}, + ), + ]); + + final message = await event; + expect(message.campaignMetadata.campaignId, 'campaign-id'); + expect(message.messageType, InAppMessageType.modal); + expect(message.title?.text, 'Hello'); + expect(message.body?.text, 'World'); + expect(message.action?.actionUrl, 'https://example.com'); + expect(message.data['promo'], 'SAVE10'); + + await message.impress(); + expect(hostApi.campaignId, 'campaign-id'); + }); } /// Simulates the native side calling [pigeon.FirebaseInAppMessagingFlutterApi]. @@ -168,6 +211,11 @@ class _TestFirebaseInAppMessagingHostApi bool? messagesSuppressed; bool? automaticDataCollectionEnabled; int addEventListenersCount = 0; + bool? customDisplayEnabled; + String? campaignId; + String? actionId; + String? dismissType; + String? errorReason; void reset() { appName = null; @@ -175,6 +223,11 @@ class _TestFirebaseInAppMessagingHostApi messagesSuppressed = null; automaticDataCollectionEnabled = null; addEventListenersCount = 0; + customDisplayEnabled = null; + campaignId = null; + actionId = null; + dismissType = null; + errorReason = null; } @override @@ -203,4 +256,33 @@ class _TestFirebaseInAppMessagingHostApi this.appName = appName; addEventListenersCount++; } + + @override + Future setCustomDisplayEnabled(String appName, bool enabled) async { + this.appName = appName; + customDisplayEnabled = enabled; + } + + @override + Future reportImpression(String campaignId) async { + this.campaignId = campaignId; + } + + @override + Future reportClick(String campaignId, String actionId) async { + this.campaignId = campaignId; + this.actionId = actionId; + } + + @override + Future reportDismiss(String campaignId, String dismissType) async { + this.campaignId = campaignId; + this.dismissType = dismissType; + } + + @override + Future reportDisplayError(String campaignId, String reason) async { + this.campaignId = campaignId; + errorReason = reason; + } } diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/test/pigeon/test_api.dart b/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/test/pigeon/test_api.dart index 52cabef1123f..447b5948632b 100644 --- a/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/test/pigeon/test_api.dart +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/test/pigeon/test_api.dart @@ -29,6 +29,15 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is FiamAction) { buffer.putUint8(131); writeValue(buffer, value.encode()); + } else if (value is FiamText) { + buffer.putUint8(132); + writeValue(buffer, value.encode()); + } else if (value is FiamDisplayAction) { + buffer.putUint8(133); + writeValue(buffer, value.encode()); + } else if (value is FiamDisplayMessage) { + buffer.putUint8(134); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -44,6 +53,12 @@ class _PigeonCodec extends StandardMessageCodec { return FiamCampaignMetadata.decode(readValue(buffer)!); case 131: return FiamAction.decode(readValue(buffer)!); + case 132: + return FiamText.decode(readValue(buffer)!); + case 133: + return FiamDisplayAction.decode(readValue(buffer)!); + case 134: + return FiamDisplayMessage.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } @@ -65,6 +80,16 @@ abstract class TestFirebaseInAppMessagingHostApi { /// [FirebaseInAppMessagingFlutterApi]. Calling this more than once is a no-op. Future addEventListeners(String appName); + Future setCustomDisplayEnabled(String appName, bool enabled); + + Future reportImpression(String campaignId); + + Future reportClick(String campaignId, String actionId); + + Future reportDismiss(String campaignId, String dismissType); + + Future reportDisplayError(String campaignId, String reason); + static void setUp( TestFirebaseInAppMessagingHostApi? api, { BinaryMessenger? binaryMessenger, @@ -180,5 +205,139 @@ abstract class TestFirebaseInAppMessagingHostApi { }); } } + { + final pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingHostApi.setCustomDisplayEnabled$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + _testBinaryMessengerBinding!.defaultBinaryMessenger + .setMockDecodedMessageHandler(pigeonVar_channel, null); + } else { + _testBinaryMessengerBinding!.defaultBinaryMessenger + .setMockDecodedMessageHandler(pigeonVar_channel, + (Object? message) async { + final List args = message! as List; + final String arg_appName = args[0]! as String; + final bool arg_enabled = args[1]! as bool; + try { + await api.setCustomDisplayEnabled(arg_appName, arg_enabled); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingHostApi.reportImpression$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + _testBinaryMessengerBinding!.defaultBinaryMessenger + .setMockDecodedMessageHandler(pigeonVar_channel, null); + } else { + _testBinaryMessengerBinding!.defaultBinaryMessenger + .setMockDecodedMessageHandler(pigeonVar_channel, + (Object? message) async { + final List args = message! as List; + final String arg_campaignId = args[0]! as String; + try { + await api.reportImpression(arg_campaignId); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingHostApi.reportClick$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + _testBinaryMessengerBinding!.defaultBinaryMessenger + .setMockDecodedMessageHandler(pigeonVar_channel, null); + } else { + _testBinaryMessengerBinding!.defaultBinaryMessenger + .setMockDecodedMessageHandler(pigeonVar_channel, + (Object? message) async { + final List args = message! as List; + final String arg_campaignId = args[0]! as String; + final String arg_actionId = args[1]! as String; + try { + await api.reportClick(arg_campaignId, arg_actionId); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingHostApi.reportDismiss$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + _testBinaryMessengerBinding!.defaultBinaryMessenger + .setMockDecodedMessageHandler(pigeonVar_channel, null); + } else { + _testBinaryMessengerBinding!.defaultBinaryMessenger + .setMockDecodedMessageHandler(pigeonVar_channel, + (Object? message) async { + final List args = message! as List; + final String arg_campaignId = args[0]! as String; + final String arg_dismissType = args[1]! as String; + try { + await api.reportDismiss(arg_campaignId, arg_dismissType); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.firebase_in_app_messaging_platform_interface.FirebaseInAppMessagingHostApi.reportDisplayError$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + _testBinaryMessengerBinding!.defaultBinaryMessenger + .setMockDecodedMessageHandler(pigeonVar_channel, null); + } else { + _testBinaryMessengerBinding!.defaultBinaryMessenger + .setMockDecodedMessageHandler(pigeonVar_channel, + (Object? message) async { + final List args = message! as List; + final String arg_campaignId = args[0]! as String; + final String arg_reason = args[1]! as String; + try { + await api.reportDisplayError(arg_campaignId, arg_reason); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } } } diff --git a/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/test/platform_interface/platform_interface_firebase_in_app_messaging_test.dart b/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/test/platform_interface/platform_interface_firebase_in_app_messaging_test.dart index 33cbd47e114b..44149567bc4d 100644 --- a/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/test/platform_interface/platform_interface_firebase_in_app_messaging_test.dart +++ b/packages/firebase_in_app_messaging/firebase_in_app_messaging_platform_interface/test/platform_interface/platform_interface_firebase_in_app_messaging_test.dart @@ -71,6 +71,20 @@ void main() { throwsA(isA()), ); }); + + test('setCustomDisplayEnabled throws if not implemented', () async { + await expectLater( + () => platform!.setCustomDisplayEnabled(true), + throwsA(isA()), + ); + }); + + test('onMessageDisplay throws if not implemented', () { + expect( + () => platform!.onMessageDisplay, + throwsA(isA()), + ); + }); }); }