Repository: customerio/customerio-android
Pull request #553: chore: fix display event handling and error recovery for in-app messages
Author: login: mrehan27
Created: 2025-05-20T11:51:09Z | Merged: 2025-05-21T11:54:23Z

closes: [MBL-1155](https://linear.app/customerio/issue/MBL-1155/fix-inline-message-view-not-dismissed-on-error)

### Changes

- Updated `InAppMessageViewController` to dispatch the display event only once per message using flag
- In `InlineInAppMessageViewController`:
  - Reset display event flag on dismiss to allow proper handling for the next message
  - Called `onLoadingStarted` at start to improve loading UI experience
- In `ModalInAppMessageViewController`, ensured `EngineWebView` is updated only on first `routeLoaded`
- Updated `InAppMessageReducer` to dismiss inline messages on loading failure
- Moved inline message dismissal logic to an extension and extracted `InAppMessagingState` extensions to a dedicated file
- Moved min height setting from Compose view to base view for a consistent experience across Compose and XML
- Added and updated tests to validate these changes

### How to Reproduce

**Issue 1: Display Event Dispatched Multiple Times**

- Open Inline Examples
- Observe logs and display message event should never be dispatched multiple times even if `routeLoaded` is called multiple times for same message

**Issue 2: Inline View Not Dismissed on Message Loading Failure**

- Open Inline Examples
- Simulate message load failure
- Notice that the view remains partially visible and state isn't properly cleared before the fix

**Issue 3: Extra Space in Compose Inline View**

- Open Inline Compose Examples
- Before this fix, the first message loads, an empty space is shown and is fixed after the first message appears

**Issue 4: Progress Bar Not Visible in Inline XML View**

- Open Inline XML Examples
- Set `InlineInAppMessageView` height to `0dp` instead of `wrap_content`
- Before this fix, the progress bar might not show up properly due to missing `minHeight`


Diff:
diff --git a/messaginginapp-compose/src/main/java/io/customer/messaginginapp/compose/InlineInAppMessageView.kt b/messaginginapp-compose/src/main/java/io/customer/messaginginapp/compose/InlineInAppMessageView.kt
index 3f42a77e1..6b83a29ca 100644
--- a/messaginginapp-compose/src/main/java/io/customer/messaginginapp/compose/InlineInAppMessageView.kt
+++ b/messaginginapp-compose/src/main/java/io/customer/messaginginapp/compose/InlineInAppMessageView.kt
@@ -46,9 +46,6 @@ fun InlineInAppMessage(
                 ViewGroup.LayoutParams.WRAP_CONTENT
             )
 
-            // Set a minimum height to ensure visibility
-            minimumHeight = (context.resources.displayMetrics.density * 48).toInt()
-
             // Set custom progress tint if provided
             progressTint?.let { color ->
                 setProgressTint(color.toArgb())
diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/ModuleMessagingInApp.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/ModuleMessagingInApp.kt
index c2bb464f7..d1129b93f 100644
--- a/messaginginapp/src/main/java/io/customer/messaginginapp/ModuleMessagingInApp.kt
+++ b/messaginginapp/src/main/java/io/customer/messaginginapp/ModuleMessagingInApp.kt
@@ -46,7 +46,6 @@ class ModuleMessagingInApp(
     }
 
     override fun embedMessage(message: Message, elementId: String) {
-        moduleConfig.eventListener?.messageShown(InAppMessage.getFromGistMessage(message))
     }
 
     override fun onMessageShown(message: Message) {
diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessageReducer.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessageReducer.kt
index 51c1494e9..fa7cf6596 100644
--- a/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessageReducer.kt
+++ b/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessageReducer.kt
@@ -23,8 +23,10 @@ internal val inAppMessagingReducer: Reducer<InAppMessagingState> = { state, acti
         is InAppMessagingAction.SetPollingInterval ->
             state.copy(pollInterval = action.interval)
 
-        is InAppMessagingAction.EngineAction.MessageLoadingFailed ->
-            state.copy(modalMessageState = ModalMessageState.Dismissed(action.message))
+        is InAppMessagingAction.EngineAction.MessageLoadingFailed -> state.withMessageDismissed(
+            message = action.message,
+            shouldMarkAsShown = false
+        )
 
         is InAppMessagingAction.LoadMessage ->
             state.copy(modalMessageState = ModalMessageState.Loading(action.message))
@@ -80,35 +82,10 @@ internal val inAppMessagingReducer: Reducer<InAppMessagingState> = { state, acti
             } ?: state
         }
 
-        is InAppMessagingAction.DismissMessage -> {
-            var shownMessageQueueIds = state.shownMessageQueueIds
-            // If the message should be tracked shown when it is dismissed, add the queueId to shownMessageQueueIds.
-            if (action.shouldMarkMessageAsShown() && action.message.queueId != null) {
-                shownMessageQueueIds = shownMessageQueueIds + action.message.queueId
-            }
-
-            if (action.message.isEmbedded) {
-                // For embedded messages
-                if (action.message.queueId != null) {
-                    // Update embedded message state if it has a queueId
-                    state.withUpdatedEmbeddedMessage(
-                        queueId = action.message.queueId,
-                        newState = InlineMessageState.Dismissed(action.message),
-                        shownMessageQueueIds = shownMessageQueueIds
-                    )
-                } else {
-                    // For embedded messages without queueId
-                    // Just return the state unchanged
-                    state
-                }
-            } else {
-                // Handle modal message
-                state.copy(
-                    modalMessageState = ModalMessageState.Dismissed(action.message),
-                    shownMessageQueueIds = shownMessageQueueIds
-                )
-            }
-        }
+        is InAppMessagingAction.DismissMessage -> state.withMessageDismissed(
+            message = action.message,
+            shouldMarkAsShown = action.shouldMarkMessageAsShown()
+        )
 
         is InAppMessagingAction.EngineAction.Tap ->
             state // No state changes for tap action
diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingState.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingState.kt
index c54a61d85..bace8b23b 100644
--- a/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingState.kt
+++ b/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingState.kt
@@ -45,20 +45,6 @@ internal data class InAppMessagingState(
     }
 }
 
-internal fun InAppMessagingState.withUpdatedEmbeddedMessage(
-    queueId: String,
-    newState: InlineMessageState,
-    shownMessageQueueIds: Set<String> = this.shownMessageQueueIds,
-    messagesInQueue: Set<Message> = this.messagesInQueue
-): InAppMessagingState {
-    val updatedEmbeddedMessagesState = queuedInlineMessagesState.updateMessageState(queueId, newState)
-    return copy(
-        queuedInlineMessagesState = updatedEmbeddedMessagesState,
-        shownMessageQueueIds = shownMessageQueueIds,
-        messagesInQueue = messagesInQueue
-    )
-}
-
 internal sealed class InlineMessageState {
     abstract val message: Message
 
diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingStateExtensions.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingStateExtensions.kt
new file mode 100644
index 000000000..b5b90afd2
--- /dev/null
+++ b/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingStateExtensions.kt
@@ -0,0 +1,76 @@
+package io.customer.messaginginapp.state
+
+import io.customer.messaginginapp.gist.data.model.Message
+
+/**
+ * Updates [InAppMessagingState] to reflect the new state of an embedded messages.
+ *
+ * @param queueId The queueId of the embedded message to update.
+ * @param newState The new state of the embedded message.
+ * @param shownMessageQueueIds The set of queueIds that have been shown.
+ * @param messagesInQueue The set of messages in the queue.
+ * @return Updated [InAppMessagingState] with new state of the embedded message.
+ */
+internal fun InAppMessagingState.withUpdatedEmbeddedMessage(
+    queueId: String,
+    newState: InlineMessageState,
+    shownMessageQueueIds: Set<String> = this.shownMessageQueueIds,
+    messagesInQueue: Set<Message> = this.messagesInQueue
+): InAppMessagingState {
+    val updatedEmbeddedMessagesState = queuedInlineMessagesState.updateMessageState(queueId, newState)
+    return copy(
+        queuedInlineMessagesState = updatedEmbeddedMessagesState,
+        shownMessageQueueIds = shownMessageQueueIds,
+        messagesInQueue = messagesInQueue
+    )
+}
+
+/**
+ * Updates [InAppMessagingState] to reflect the dismissal of current in-app message.
+ * If the message is embedded, it updates the state of the embedded message.
+ * If the message is modal, it updates the modal message state.
+ *
+ * @param message The message from current action.
+ * @param shouldMarkAsShown Indicates whether the message should be marked as shown when dismissed.
+ * @return Updated [InAppMessagingState] with the dismissed message.
+ */
+internal fun InAppMessagingState.withMessageDismissed(
+    message: Message,
+    shouldMarkAsShown: Boolean
+): InAppMessagingState {
+    var shownMessageQueueIds = this.shownMessageQueueIds
+    // If the message should be tracked shown when it is dismissed, add the queueId to shownMessageQueueIds.
+    if (shouldMarkAsShown && message.queueId != null) {
+        shownMessageQueueIds = shownMessageQueueIds + message.queueId
+    }
+
+    when {
+        message.isEmbedded -> {
+            // For embedded messages
+            return when {
+                message.queueId != null -> {
+                    // Update embedded message state if it has a queueId
+                    this.withUpdatedEmbeddedMessage(
+                        queueId = message.queueId,
+                        newState = InlineMessageState.Dismissed(message),
+                        shownMessageQueueIds = shownMessageQueueIds
+                    )
+                }
+
+                else -> {
+                    // For embedded messages without queueId
+                    // Just return the state unchanged
+                    this
+                }
+            }
+        }
+
+        else -> {
+            // Handle modal message
+            return this.copy(
+                modalMessageState = ModalMessageState.Dismissed(message),
+                shownMessageQueueIds = shownMessageQueueIds
+            )
+        }
+    }
+}
diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/ui/InlineInAppMessageView.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/ui/InlineInAppMessageView.kt
index 6346086f5..bf3429633 100644
--- a/messaginginapp/src/main/java/io/customer/messaginginapp/ui/InlineInAppMessageView.kt
+++ b/messaginginapp/src/main/java/io/customer/messaginginapp/ui/InlineInAppMessageView.kt
@@ -39,9 +39,10 @@ class InlineInAppMessageView @JvmOverloads constructor(
     @StyleRes defStyleRes: Int = 0
 ) : FrameLayout(context, attrs, defStyleAttr, defStyleRes),
     InlineInAppMessageViewCallback {
+    private val platformDelegate = AndroidInAppPlatformDelegate(view = this)
     private val controller = InlineInAppMessageViewController(
         viewDelegate = InAppHostViewDelegateImpl(view = this),
-        platformDelegate = AndroidInAppPlatformDelegate(view = this)
+        platformDelegate = platformDelegate
     )
     private val progressIndicator: ProgressBar = ProgressBar(context)
 
@@ -118,8 +119,17 @@ class InlineInAppMessageView @JvmOverloads constructor(
     }
 
     override fun onLoadingStarted() {
-        progressIndicator.visibility = VISIBLE
-        progressIndicator.bringToFront()
+        // Set a minimum height to ensure visibility
+        // Keep the animation duration shorter so it can be completed before we
+        // start receiving size update callbacks from WebView to avoid flickering
+        platformDelegate.animateViewSize(
+            heightInPx = platformDelegate.convertDpToPixels(48.0),
+            duration = 100,
+            onStart = {
+                progressIndicator.visibility = VISIBLE
+                progressIndicator.bringToFront()
+            }
+        )
     }
 
     override fun onLoadingFinished() {
diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/ui/controller/InAppMessageViewController.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/ui/controller/InAppMessageViewController.kt
index 4c1fbd537..c7cd7f3f9 100644
--- a/messaginginapp/src/main/java/io/customer/messaginginapp/ui/controller/InAppMessageViewController.kt
+++ b/messaginginapp/src/main/java/io/customer/messaginginapp/ui/controller/InAppMessageViewController.kt
@@ -1,6 +1,7 @@
 package io.customer.messaginginapp.ui.controller
 
 import android.content.ActivityNotFoundException
+import androidx.annotation.CallSuper
 import androidx.annotation.UiThread
 import androidx.annotation.VisibleForTesting
 import io.customer.messaginginapp.di.inAppMessagingManager
@@ -27,18 +28,20 @@ internal abstract class InAppMessageViewController<ViewCallback : InAppMessageVi
     val viewDelegate: InAppHostViewDelegate
 ) : EngineWebViewListener {
     private val logger = SDKComponent.logger
-    internal val inAppMessagingManager = SDKComponent.inAppMessagingManager
+    protected val inAppMessagingManager = SDKComponent.inAppMessagingManager
+    protected var shouldDispatchDisplayEvent: Boolean = true
 
     @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
-    internal var engineWebViewDelegate: EngineWebViewDelegate? = null
+    var engineWebViewDelegate: EngineWebViewDelegate? = null
 
     @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
-    internal var currentMessage: Message? = null
+    var currentMessage: Message? = null
 
     @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
-    internal var currentRoute: String? = null
+    var currentRoute: String? = null
 
-    internal var viewCallback: ViewCallback? = null
+    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+    var viewCallback: ViewCallback? = null
 
     /**
      * Listener to handle action clicks from inline in-app messages.
@@ -242,9 +245,21 @@ internal abstract class InAppMessageViewController<ViewCallback : InAppMessageVi
         }
     }
 
-    override fun routeLoaded(route: String) {
+    final override fun routeLoaded(route: String) {
         logViewEvent("Route loaded: $route")
         currentRoute = route
+        val message = currentMessage ?: return
+        if (!shouldDispatchDisplayEvent) return
+
+        // Dispatch the display event to track metrics
+        // The display event should be dispatched only once for each message
+        shouldDispatchDisplayEvent = false
+        onRouteLoaded(message = message, route = route)
+    }
+
+    @CallSuper
+    protected open fun onRouteLoaded(message: Message, route: String) {
+        inAppMessagingManager.dispatch(InAppMessagingAction.DisplayMessage(message))
     }
 
     override fun error() {
diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/ui/controller/InlineInAppMessageViewController.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/ui/controller/InlineInAppMessageViewController.kt
index 544f46250..3fca69ffb 100644
--- a/messaginginapp/src/main/java/io/customer/messaginginapp/ui/controller/InlineInAppMessageViewController.kt
+++ b/messaginginapp/src/main/java/io/customer/messaginginapp/ui/controller/InlineInAppMessageViewController.kt
@@ -74,14 +74,6 @@ internal constructor(
         viewDelegate.post { refreshViewState(state = state) }
     }
 
-    override fun routeLoaded(route: String) {
-        super.routeLoaded(route)
-
-        currentMessage?.let { message ->
-            inAppMessagingManager.dispatch(InAppMessagingAction.DisplayMessage(message))
-        }
-    }
-
     internal fun onDetachedFromWindow() {
         val message = currentMessage ?: return
 
@@ -151,6 +143,7 @@ internal constructor(
                     viewDelegate.isVisible = false
                     contentWidthInDp = null
                     contentHeightInDp = null
+                    shouldDispatchDisplayEvent = true
                     detachAndCleanupEngineWebView()
                     viewCallback?.onNoMessageToDisplay()
                     onComplete()
@@ -162,8 +155,8 @@ internal constructor(
     @UiThread
     private fun displayMessage(message: Message) {
         elapsedTimer.start("Displaying inline message: ${message.messageId}")
-        attachEngineWebView()
         viewCallback?.onLoadingStarted()
+        attachEngineWebView()
         viewDelegate.isVisible = true
         loadMessage(message)
     }
diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/ui/controller/ModalInAppMessageViewController.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/ui/controller/ModalInAppMessageViewController.kt
index 5f0315f06..6019836c6 100644
--- a/messaginginapp/src/main/java/io/customer/messaginginapp/ui/controller/ModalInAppMessageViewController.kt
+++ b/messaginginapp/src/main/java/io/customer/messaginginapp/ui/controller/ModalInAppMessageViewController.kt
@@ -1,6 +1,6 @@
 package io.customer.messaginginapp.ui.controller
 
-import io.customer.messaginginapp.state.InAppMessagingAction
+import io.customer.messaginginapp.gist.data.model.Message
 import io.customer.messaginginapp.ui.bridge.InAppHostViewDelegate
 import io.customer.messaginginapp.ui.bridge.InAppPlatformDelegate
 import io.customer.messaginginapp.ui.bridge.ModalInAppMessageViewCallback
@@ -13,8 +13,6 @@ internal class ModalInAppMessageViewController(
     viewDelegate = viewDelegate,
     platformDelegate = platformDelegate
 ) {
-    private var shouldDispatchDisplayEvent: Boolean = true
-
     init {
         attachEngineWebView()
     }
@@ -27,15 +25,9 @@ internal class ModalInAppMessageViewController(
         return result
     }
 
-    override fun routeLoaded(route: String) {
-        super.routeLoaded(route)
-        if (!shouldDispatchDisplayEvent) return
-
-        shouldDispatchDisplayEvent = false
+    override fun onRouteLoaded(message: Message, route: String) {
         engineWebViewDelegate?.setAlpha(1.0F)
-        currentMessage?.let { message ->
-            inAppMessagingManager.dispatch(InAppMessagingAction.DisplayMessage(message))
-        }
+        super.onRouteLoaded(message, route)
     }
 
     override fun bootstrapped() {
diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/InAppMessagingStoreTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/InAppMessagingStoreTest.kt
index 93e42719f..32dbebf6d 100644
--- a/messaginginapp/src/test/java/io/customer/messaginginapp/InAppMessagingStoreTest.kt
+++ b/messaginginapp/src/test/java/io/customer/messaginginapp/InAppMessagingStoreTest.kt
@@ -2,6 +2,7 @@ package io.customer.messaginginapp
 
 import io.customer.commontest.config.TestConfig
 import io.customer.commontest.core.TestConstants
+import io.customer.commontest.extensions.assertNoInteractions
 import io.customer.commontest.extensions.attachToSDKComponent
 import io.customer.commontest.extensions.random
 import io.customer.messaginginapp.di.inAppMessagingManager
@@ -354,7 +355,7 @@ class InAppMessagingStoreTest : IntegrationTest() {
     }
 
     @Test
-    fun givenMessage_whenEmbedded_thenEmbedMessageCallbackIsCalled() = runTest {
+    fun givenMessage_whenEmbedded_thenNoCallbackIsCalled() = runTest {
         initializeAndSetUser()
         // Create a message with custom properties to include an elementId for embedding
         // We need to manually construct the Message here since our helper doesn't support elementId
@@ -366,7 +367,7 @@ class InAppMessagingStoreTest : IntegrationTest() {
 
         manager.dispatch(InAppMessagingAction.EmbedMessages(listOf(message)))
 
-        verify { inAppEventListener.messageShown(InAppMessage.getFromGistMessage(message)) }
+        assertNoInteractions(inAppEventListener)
     }
 
     @Test
diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/state/InAppMessageReducerTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/state/InAppMessageReducerTest.kt
index c2e107685..e07269bfd 100644
--- a/messaginginapp/src/test/java/io/customer/messaginginapp/state/InAppMessageReducerTest.kt
+++ b/messaginginapp/src/test/java/io/customer/messaginginapp/state/InAppMessageReducerTest.kt
@@ -229,6 +229,77 @@ class InAppMessageReducerTest : JUnitTest() {
         assertFalse(shouldMarkAsShown)
     }
 
+    @Test
+    fun messageDismissed_whenInlineMessageWithoutQueueId_expectStateUnchanged() {
+        // Given a non-persistent message that is displayed but not yet marked as shown
+        val elementId = String.random
+        val testMessage = createTestMessage(persistent = false, queueId = null, elementId = elementId)
+
+        // The message is displayed but not in shownMessageQueueIds
+        val startingState = initialState.copy(
+            queuedInlineMessagesState = QueuedInlineMessagesState()
+                .addMessage(message = testMessage, elementId = elementId),
+            shownMessageQueueIds = emptySet()
+        )
+
+        // When the message is dismissed
+        val dismissAction = InAppMessagingAction.DismissMessage(message = testMessage)
+        val resultState = inAppMessagingReducer(startingState, dismissAction)
+
+        // Starting state should be unchanged
+        assertEquals(startingState, resultState)
+    }
+
+    @Test
+    fun messageLoadingFailed_whenModalMessage_expectModalMessageToBeDismissed() {
+        // Given a non-persistent message that is displayed but not yet marked as shown
+        val testMessage = createTestMessage(persistent = false)
+
+        // The message is displayed but not in shownMessageQueueIds
+        val startingState = initialState.copy(
+            modalMessageState = ModalMessageState.Displayed(testMessage),
+            shownMessageQueueIds = emptySet()
+        )
+
+        // When the message fails to load
+        val dismissAction = InAppMessagingAction.EngineAction.MessageLoadingFailed(message = testMessage)
+        val resultState = inAppMessagingReducer(startingState, dismissAction)
+
+        // Then the message should NOT be added to shownMessageQueueIds
+        assertTrue(resultState.shownMessageQueueIds.isEmpty())
+
+        // And the message state should be Dismissed
+        val modalState = resultState.modalMessageState as ModalMessageState.Dismissed
+        assertEquals(testMessage, modalState.message)
+    }
+
+    @Test
+    fun messageLoadingFailed_whenInlineMessage_expectInlineMessageToBeDismissed() {
+        // Given a non-persistent message that is displayed but not yet marked as shown
+        val elementId = String.random
+        val testMessage = createTestMessage(persistent = false, elementId = elementId)
+
+        // The message is displayed but not in shownMessageQueueIds
+        val startingState = initialState.copy(
+            queuedInlineMessagesState = QueuedInlineMessagesState()
+                .addMessage(message = testMessage, elementId = elementId),
+            shownMessageQueueIds = emptySet()
+        )
+
+        // When the message fails to load
+        val dismissAction = InAppMessagingAction.EngineAction.MessageLoadingFailed(message = testMessage)
+        val resultState = inAppMessagingReducer(startingState, dismissAction)
+
+        // Then the message should NOT be added to shownMessageQueueIds
+        assertTrue(resultState.shownMessageQueueIds.isEmpty())
+
+        // And the message state should be Dismissed
+        val inlineMessageStateMap = resultState.queuedInlineMessagesState.messagesByElementId
+        assertEquals(1, inlineMessageStateMap.count())
+        val inlineMessageState = inlineMessageStateMap[elementId] as InlineMessageState.Dismissed
+        assertEquals(testMessage, inlineMessageState.message)
+    }
+
     @Test
     fun reset_shouldClearAllStates() {
         // Given a state with some messages
@@ -252,22 +323,25 @@ class InAppMessageReducerTest : JUnitTest() {
     /**
      * Helper method to create a test message with customizable persistence
      */
-    private fun createTestMessage(persistent: Boolean): Message {
+    private fun createTestMessage(
+        persistent: Boolean,
+        queueId: String? = String.random,
+        elementId: String? = null
+    ): Message {
         val messageId = String.random
-        val queueId = String.random
 
         return mockk<Message>(relaxed = true) {
             every { this@mockk.messageId } returns messageId
             every { this@mockk.queueId } returns queueId
             every { this@mockk.gistProperties } returns GistProperties(
                 routeRule = null,
-                elementId = null,
+                elementId = elementId,
                 campaignId = null,
                 position = MessagePosition.CENTER,
                 persistent = persistent,
                 overlayColor = null
             )
-            every { this@mockk.isEmbedded } returns false
+            every { this@mockk.isEmbedded } returns (elementId != null)
         }
     }
 }
diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/ui/controller/InAppMessageViewControllerTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/ui/controller/InAppMessageViewControllerTest.kt
index de14306e9..2719f56a2 100644
--- a/messaginginapp/src/test/java/io/customer/messaginginapp/ui/controller/InAppMessageViewControllerTest.kt
+++ b/messaginginapp/src/test/java/io/customer/messaginginapp/ui/controller/InAppMessageViewControllerTest.kt
@@ -24,6 +24,7 @@ import io.customer.messaginginapp.ui.bridge.EngineWebViewDelegate
 import io.customer.messaginginapp.ui.bridge.InAppHostViewDelegate
 import io.customer.messaginginapp.ui.bridge.InAppMessageViewCallback
 import io.customer.messaginginapp.ui.bridge.InAppPlatformDelegate
+import io.mockk.clearMocks
 import io.mockk.every
 import io.mockk.mockk
 import io.mockk.spyk
@@ -313,12 +314,33 @@ class InAppMessageViewControllerTest : JUnitTest() {
     //region Route tests
 
     @Test
-    fun routeListener_givenRouteLoaded_expectCurrentRouteIsUpdated() {
+    fun routeLoaded_givenPendingEvent_expectRouteUpdatedAndEventDispatched() {
+        val givenMessage = createInAppMessage()
+        val givenRoute = String.random
+        controller.currentMessage = givenMessage
+
+        controller.routeLoaded(givenRoute)
+
+        controller.currentRoute shouldBeEqualTo givenRoute
+        verifyOrder {
+            inAppMessagingManager.dispatch(
+                InAppMessagingAction.DisplayMessage(givenMessage)
+            )
+        }
+    }
+
+    @Test
+    fun routeLoaded_givenEventAlreadyDispatched_expectRouteUpdatedAndNoDispatch() {
+        val givenMessage = createInAppMessage()
         val givenRoute = String.random
+        controller.currentMessage = givenMessage
+        controller.routeLoaded(givenRoute)
+        clearMocks(inAppMessagingManager)
 
         controller.routeLoaded(givenRoute)
 
         controller.currentRoute shouldBeEqualTo givenRoute
+        assertNoInteractions(inAppMessagingManager)
     }
 
     @Test
diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/ui/controller/InlineMessageViewControllerBehaviorTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/ui/controller/InlineMessageViewControllerBehaviorTest.kt
index fe3f0d74e..38a51c9b5 100644
--- a/messaginginapp/src/test/java/io/customer/messaginginapp/ui/controller/InlineMessageViewControllerBehaviorTest.kt
+++ b/messaginginapp/src/test/java/io/customer/messaginginapp/ui/controller/InlineMessageViewControllerBehaviorTest.kt
@@ -3,6 +3,7 @@ package io.customer.messaginginapp.ui.controller
 import io.customer.commontest.config.TestConfig
 import io.customer.commontest.config.testConfigurationDefault
 import io.customer.commontest.core.TestConstants
+import io.customer.commontest.extensions.assertCalledOnce
 import io.customer.commontest.extensions.assertNoInteractions
 import io.customer.commontest.extensions.attachToSDKComponent
 import io.customer.commontest.extensions.flushCoroutines
@@ -77,7 +78,8 @@ class InlineMessageViewControllerBehaviorTest : JUnitTest() {
             dataCenter = gistDataCenter,
             environment = gistEnvironment
         ).also { SDKComponent.overrideDependency<GistProvider>(it) }
-        messagingManager = SDKComponent.inAppMessagingManager
+        messagingManager = spyk(SDKComponent.inAppMessagingManager)
+            .also { SDKComponent.overrideDependency<InAppMessagingManager>(it) }
         elapsedTimer = spyk(ElapsedTimer())
     }
 
@@ -247,6 +249,47 @@ class InlineMessageViewControllerBehaviorTest : JUnitTest() {
         controller.currentMessage shouldBeEqualTo givenInAppMessage
     }
 
+    @Test
+    fun handleMessageState_givenMultipleMessageDisplayed_expectCorrectEventsDispatched() {
+        val controller = setupGistAndCreateViewController()
+        val viewCallback = controller.initMockViewCallback()
+        val givenElementId = "test-element-id"
+        controller.elementId = givenElementId
+        val givenInAppMessageOne = createInAppMessage(queueId = "1", elementId = givenElementId)
+        val givenInAppMessageTwo = createInAppMessage(queueId = "2", elementId = givenElementId)
+        messagingManager.dispatch(
+            InAppMessagingAction.EmbedMessages(listOf(givenInAppMessageOne))
+        ).flushCoroutines(scopeProviderStub.inAppLifecycleScope)
+        controller.routeLoaded(String.random)
+        messagingManager.dispatch(
+            InAppMessagingAction.DismissMessage(givenInAppMessageOne)
+        ).flushCoroutines(scopeProviderStub.inAppLifecycleScope)
+
+        messagingManager.dispatch(
+            InAppMessagingAction.EmbedMessages(listOf(givenInAppMessageTwo))
+        ).flushCoroutines(scopeProviderStub.inAppLifecycleScope)
+        controller.routeLoaded(String.random)
+
+        assertCalledOnce {
+            messagingManager.dispatch(InAppMessagingAction.DisplayMessage(givenInAppMessageOne))
+            messagingManager.dispatch(InAppMessagingAction.DisplayMessage(givenInAppMessageTwo))
+        }
+        assertMessageLoadingCalls(
+            controller = controller,
+            viewCallback = viewCallback,
+            expectedMessage = givenInAppMessageOne
+        )
+        assertMessageDismissedCalls(
+            viewCallback = viewCallback
+        )
+        assertMessageLoadingCalls(
+            controller = controller,
+            viewCallback = viewCallback,
+            expectedMessage = givenInAppMessageTwo
+        )
+        controller.currentMessage shouldBeEqualTo givenInAppMessageTwo
+    }
+
     @Test
     fun messageLoaded_givenSizeUpdate_expectViewMadeVisible() {
         val controller = setupGistAndCreateViewController()
@@ -367,6 +410,46 @@ class InlineMessageViewControllerBehaviorTest : JUnitTest() {
         controller.contentHeightInDp.shouldBeNull()
     }
 
+    @Test
+    fun messageLoadingFailed_givenRouteError_expectViewDismissed() {
+        val controller = setupGistAndCreateViewController()
+        val viewCallback = controller.initMockViewCallback()
+        val givenElementId = "test-element-id"
+        controller.elementId = givenElementId
+        val givenInAppMessage = createInAppMessage(queueId = "1", elementId = givenElementId)
+        controller.currentMessage = givenInAppMessage
+        messagingManager.dispatch(
+            InAppMessagingAction.EmbedMessages(listOf(givenInAppMessage))
+        ).flushCoroutines(scopeProviderStub.inAppLifecycleScope)
+
+        controller.routeError(String.random)
+            .flushCoroutines(scopeProviderStub.inAppLifecycleScope)
+
+        assertMessageDismissedCalls(
+            viewCallback = viewCallback
+        )
+    }
+
+    @Test
+    fun messageLoadingFailed_givenLoadError_expectViewDismissed() {
+        val controller = setupGistAndCreateViewController()
+        val viewCallback = controller.initMockViewCallback()
+        val givenElementId = "test-element-id"
+        controller.elementId = givenElementId
+        val givenInAppMessage = createInAppMessage(queueId = "1", elementId = givenElementId)
+        controller.currentMessage = givenInAppMessage
+        messagingManager.dispatch(
+            InAppMessagingAction.EmbedMessages(listOf(givenInAppMessage))
+        ).flushCoroutines(scopeProviderStub.inAppLifecycleScope)
+
+        controller.error()
+            .flushCoroutines(scopeProviderStub.inAppLifecycleScope)
+
+        assertMessageDismissedCalls(
+            viewCallback = viewCallback
+        )
+    }
+
     private fun assertMessageLoadingCalls(
         controller: InlineInAppMessageViewController,
         viewCallback: InlineInAppMessageViewCallback,
@@ -382,11 +465,11 @@ class InlineMessageViewControllerBehaviorTest : JUnitTest() {
         )
 
         elapsedTimer.start(any())
+        viewCallback.onLoadingStarted()
         viewDelegate.createEngineWebViewInstance()
         engineWebViewDelegate.setAlpha(0.0F)
         engineWebViewDelegate.listener = controller
         viewDelegate.addView(engineWebViewDelegate)
-        viewCallback.onLoadingStarted()
         viewDelegate.isVisible = true
         engineWebViewDelegate.setup(expectedConfig)
     }


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR ensures display events fire only once per message, improves inline/modal error-dismiss logic, and refactors state handling with dedicated extensions.

- Centralize and guard display event dispatch in the base `InAppMessageViewController`
- Reset dispatch flags and add loading-started callbacks in inline and modal controllers; expand tests for error recovery
- Extract dismissal logic into `InAppMessagingStateExtensions`, update reducer, and move UI sizing from Compose to base view

### Reviewed Changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated no comments.

<details>
<summary>Show a summary per file</summary>

| File                                                               | Description                                                                                          |
| ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- |
| `InlineMessageViewControllerBehaviorTest.kt`                       | Added tests for multi-message display and loading-failure dismissal                                  |
| `InAppMessageViewControllerTest.kt`                                | Renamed and expanded tests for single dispatch and no-op on repeated `routeLoaded`                   |
| `InAppMessageReducerTest.kt`                                       | Added tests ensuring inline and modal messages are dismissed on loading errors                        |
| `InAppMessagingStoreTest.kt`                                       | Updated store test to expect no callback when embedding a message                                     |
| `ModalInAppMessageViewController.kt`                               | Refactored to use `onRouteLoaded` hook and centralized dispatch logic                                |
| `InlineInAppMessageViewController.kt`                              | Reset dispatch flag on detach and call `onLoadingStarted` earlier                                    |
| `InAppMessageViewController.kt`                                    | Moved dispatch guard into final `routeLoaded`, made hooks `protected`, and added `@CallSuper` hook   |
| `InlineInAppMessageView.kt`                                        | Added `animateViewSize` for loading start and moved min-height logic out of Compose                  |
| `InlineInAppMessageView.kt` *(Compose)*                            | Removed `minimumHeight` in Compose version                                                           |
| `InAppMessagingStateExtensions.kt`                                 | Introduced extensions for updating and dismissing messages                                           |
| `InAppMessagingState.kt`                                           | Removed inline dismissal extensions and moved them to a dedicated file                               |
| `InAppMessageReducer.kt`                                           | Replaced manual dismissal cases with `withMessageDismissed`                                           |
| `ModuleMessagingInApp.kt`                                          | Removed embedded message callback invocation                                                         |
</details>



<details>
<summary>Comments suppressed due to low confidence (3)</summary>

**messaginginapp-compose/src/main/java/io/customer/messaginginapp/compose/InlineInAppMessageView.kt:46**
* The Compose InlineInAppMessageView no longer sets a minimum height or animates its size on loading, which may reintroduce layout inconsistencies. Consider applying similar animateViewSize or a minHeight modifier in the composable to match the base view behavior.
```
// Set a minimum height to ensure visibility
```
**messaginginapp/src/main/java/io/customer/messaginginapp/ModuleMessagingInApp.kt:48**
* Removing the `messageShown` callback from `embedMessage` changes the public API contract for embedding messages. This breaking change should be documented or accompanied by a deprecation note for users relying on that event.
```
override fun embedMessage(message: Message, elementId: String) {
```
**messaginginapp/src/main/java/io/customer/messaginginapp/ui/controller/InAppMessageViewController.kt:44**
* [nitpick] Exposing internal controller state (e.g., viewCallback, currentMessage, engineWebViewDelegate) as public vars can break encapsulation and make future refactoring harder; consider reducing visibility or using dedicated testing hooks only where needed.
```
var viewCallback: ViewCallback? = null
```
</details>


state: COMMENTED
author_association: NONE
submitted_at: 2025-05-21T09:49:05Z
user: login: Shahroz16
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T11:25:50Z
user: login: mrehan27
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T11:46:05Z
user: login: mrehan27
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T11:46:17Z

Review comments:
diff_hunk: @@ -0,0 +1,76 @@
+package io.customer.messaginginapp.state
+
+import io.customer.messaginginapp.gist.data.model.Message
+
+/**
+ * Updates [InAppMessagingState] to reflect the new state of an embedded messages.
+ *
+ * @param queueId The queueId of the embedded message to update.
+ * @param newState The new state of the embedded message.
+ * @param shownMessageQueueIds The set of queueIds that have been shown.
+ * @param messagesInQueue The set of messages in the queue.
+ * @return Updated [InAppMessagingState] with new state of the embedded message.
+ */
+internal fun InAppMessagingState.withUpdatedEmbeddedMessage(
+    queueId: String,
+    newState: InlineMessageState,
+    shownMessageQueueIds: Set<String> = this.shownMessageQueueIds,
+    messagesInQueue: Set<Message> = this.messagesInQueue
+): InAppMessagingState {
+    val updatedEmbeddedMessagesState = queuedInlineMessagesState.updateMessageState(queueId, newState)
+    return copy(
+        queuedInlineMessagesState = updatedEmbeddedMessagesState,
+        shownMessageQueueIds = shownMessageQueueIds,
+        messagesInQueue = messagesInQueue
+    )
+}
+
+/**
+ * Updates [InAppMessagingState] to reflect the dismissal of current in-app message.
+ * If the message is embedded, it updates the state of the embedded message.
+ * If the message is modal, it updates the modal message state.
+ *
+ * @param message The message from current action.
+ * @param shouldMarkAsShown Indicates whether the message should be marked as shown when dismissed.
+ * @return Updated [InAppMessagingState] with the dismissed message.
+ */
+internal fun InAppMessagingState.withMessageDismissed(
+    message: Message,
+    shouldMarkAsShown: Boolean
+): InAppMessagingState {
+    var shownMessageQueueIds = this.shownMessageQueueIds
+    // If the message should be tracked shown when it is dismissed, add the queueId to shownMessageQueueIds.
+    if (shouldMarkAsShown && message.queueId != null) {
+        shownMessageQueueIds = shownMessageQueueIds + message.queueId
+    }
+
+    when {
+        message.isEmbedded -> {
+            // For embedded messages
+            return when {
+                message.queueId != null -> {
+                    // Update embedded message state if it has a queueId
+                    this.withUpdatedEmbeddedMessage(
+                        queueId = message.queueId,
+                        newState = InlineMessageState.Dismissed(message),
+                        shownMessageQueueIds = shownMessageQueueIds
+                    )
+                }
+
+                else -> {
+                    // For embedded messages without queueId
+                    // Just return the state unchanged
+                    this
path: messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingStateExtensions.kt
user: login: Shahroz16
body: test for this seems missing? 
created_at: 2025-05-21T11:25:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -46,9 +46,6 @@ fun InlineInAppMessage(
                 ViewGroup.LayoutParams.WRAP_CONTENT
             )
 
-            // Set a minimum height to ensure visibility
-            minimumHeight = (context.resources.displayMetrics.density * 48).toInt()
path: messaginginapp-compose/src/main/java/io/customer/messaginginapp/compose/InlineInAppMessageView.kt
user: login: Shahroz16
body: this is now at xml view level? 
created_at: 2025-05-21T11:25:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,76 @@
+package io.customer.messaginginapp.state
+
+import io.customer.messaginginapp.gist.data.model.Message
+
+/**
+ * Updates [InAppMessagingState] to reflect the new state of an embedded messages.
+ *
+ * @param queueId The queueId of the embedded message to update.
+ * @param newState The new state of the embedded message.
+ * @param shownMessageQueueIds The set of queueIds that have been shown.
+ * @param messagesInQueue The set of messages in the queue.
+ * @return Updated [InAppMessagingState] with new state of the embedded message.
+ */
+internal fun InAppMessagingState.withUpdatedEmbeddedMessage(
+    queueId: String,
+    newState: InlineMessageState,
+    shownMessageQueueIds: Set<String> = this.shownMessageQueueIds,
+    messagesInQueue: Set<Message> = this.messagesInQueue
+): InAppMessagingState {
+    val updatedEmbeddedMessagesState = queuedInlineMessagesState.updateMessageState(queueId, newState)
+    return copy(
+        queuedInlineMessagesState = updatedEmbeddedMessagesState,
+        shownMessageQueueIds = shownMessageQueueIds,
+        messagesInQueue = messagesInQueue
+    )
+}
+
+/**
+ * Updates [InAppMessagingState] to reflect the dismissal of current in-app message.
+ * If the message is embedded, it updates the state of the embedded message.
+ * If the message is modal, it updates the modal message state.
+ *
+ * @param message The message from current action.
+ * @param shouldMarkAsShown Indicates whether the message should be marked as shown when dismissed.
+ * @return Updated [InAppMessagingState] with the dismissed message.
+ */
+internal fun InAppMessagingState.withMessageDismissed(
+    message: Message,
+    shouldMarkAsShown: Boolean
+): InAppMessagingState {
+    var shownMessageQueueIds = this.shownMessageQueueIds
+    // If the message should be tracked shown when it is dismissed, add the queueId to shownMessageQueueIds.
+    if (shouldMarkAsShown && message.queueId != null) {
+        shownMessageQueueIds = shownMessageQueueIds + message.queueId
+    }
+
+    when {
+        message.isEmbedded -> {
+            // For embedded messages
+            return when {
+                message.queueId != null -> {
+                    // Update embedded message state if it has a queueId
+                    this.withUpdatedEmbeddedMessage(
+                        queueId = message.queueId,
+                        newState = InlineMessageState.Dismissed(message),
+                        shownMessageQueueIds = shownMessageQueueIds
+                    )
+                }
+
+                else -> {
+                    // For embedded messages without queueId
+                    // Just return the state unchanged
+                    this
path: messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingStateExtensions.kt
user: login: mrehan27
body: Added
created_at: 2025-05-21T11:46:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -46,9 +46,6 @@ fun InlineInAppMessage(
                 ViewGroup.LayoutParams.WRAP_CONTENT
             )
 
-            // Set a minimum height to ensure visibility
-            minimumHeight = (context.resources.displayMetrics.density * 48).toInt()
path: messaginginapp-compose/src/main/java/io/customer/messaginginapp/compose/InlineInAppMessageView.kt
user: login: mrehan27
body: Yes, correct.
created_at: 2025-05-21T11:46:17Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-20T11:51:23Z
body: <!-- sample app builds --> 
# Sample app builds 📱

Below you will find the list of the latest versions of the sample apps. It's recommended to always download the latest builds of the sample apps to accurately test the pull request. 

---
author_association: NONE
user: login: codecov[bot]
created_at: 2025-05-20T11:53:41Z
body: ## [Codecov](https://app.codecov.io/gh/customerio/customerio-android/pull/553?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=customerio) Report
Attention: Patch coverage is `78.84615%` with `11 lines` in your changes missing coverage. Please review.
> Please [upload](https://docs.codecov.com/docs/codecov-uploader) report for BASE (`feature/inline-in-app-messages@ce94759`). [Learn more](https://docs.codecov.io/docs/error-reference?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=customerio#section-missing-base-commit) about missing BASE report.

| [Files with missing lines](https://app.codecov.io/gh/customerio/customerio-android/pull/553?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=customerio) | Patch % | Lines |
|---|---|---|
| [...stomer/messaginginapp/ui/InlineInAppMessageView.kt](https://app.codecov.io/gh/customerio/customerio-android/pull/553?src=pr&el=tree&filepath=messaginginapp%2Fsrc%2Fmain%2Fjava%2Fio%2Fcustomer%2Fmessaginginapp%2Fui%2FInlineInAppMessageView.kt&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=customerio#diff-bWVzc2FnaW5naW5hcHAvc3JjL21haW4vamF2YS9pby9jdXN0b21lci9tZXNzYWdpbmdpbmFwcC91aS9JbmxpbmVJbkFwcE1lc3NhZ2VWaWV3Lmt0) | 0.00% | [8 Missing :warning: ](https://app.codecov.io/gh/customerio/customerio-android/pull/553?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=customerio) |
| [...saginginapp/state/InAppMessagingStateExtensions.kt](https://app.codecov.io/gh/customerio/customerio-android/pull/553?src=pr&el=tree&filepath=messaginginapp%2Fsrc%2Fmain%2Fjava%2Fio%2Fcustomer%2Fmessaginginapp%2Fstate%2FInAppMessagingStateExtensions.kt&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=customerio#diff-bWVzc2FnaW5naW5hcHAvc3JjL21haW4vamF2YS9pby9jdXN0b21lci9tZXNzYWdpbmdpbmFwcC9zdGF0ZS9JbkFwcE1lc3NhZ2luZ1N0YXRlRXh0ZW5zaW9ucy5rdA==) | 91.30% | [1 Missing and 1 partial :warning: ](https://app.codecov.io/gh/customerio/customerio-android/pull/553?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=customerio) |
| [...ginapp/ui/controller/InAppMessageViewController.kt](https://app.codecov.io/gh/customerio/customerio-android/pull/553?src=pr&el=tree&filepath=messaginginapp%2Fsrc%2Fmain%2Fjava%2Fio%2Fcustomer%2Fmessaginginapp%2Fui%2Fcontroller%2FInAppMessageViewController.kt&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=customerio#diff-bWVzc2FnaW5naW5hcHAvc3JjL21haW4vamF2YS9pby9jdXN0b21lci9tZXNzYWdpbmdpbmFwcC91aS9jb250cm9sbGVyL0luQXBwTWVzc2FnZVZpZXdDb250cm9sbGVyLmt0) | 91.66% | [0 Missing and 1 partial :warning: ](https://app.codecov.io/gh/customerio/customerio-android/pull/553?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=customerio) |

<details><summary>Additional details and impacted files</summary>


```diff
@@                        Coverage Diff                        @@
##             feature/inline-in-app-messages     #553   +/-   ##
=================================================================
  Coverage                                  ?   59.36%           
  Complexity                                ?      477           
=================================================================
  Files                                     ?      118           
  Lines                                     ?     3480           
  Branches                                  ?      442           
=================================================================
  Hits                                      ?     2066           
  Misses                                    ?     1281           
  Partials                                  ?      133           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/customerio/customerio-android/pull/553?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=customerio).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=customerio).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-20T11:56:04Z
body: Build available to test
Version: `mbl-1155-dismiss-fix-SNAPSHOT`
Repository: `https://s01.oss.sonatype.org/content/repositories/snapshots/`
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-20T11:56:50Z
body: ## 📏 SDK Binary Size Comparison Report

No changes detected in SDK binary size ✅

author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-20T11:59:19Z
body: * java_layout: `mbl-1155-dismiss-fix (1747741936)`
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-20T12:00:09Z
body: * kotlin_compose: `mbl-1155-dismiss-fix (1747741934)`
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-21T11:52:42Z
body: * java_layout: `mbl-1155-dismiss-fix (1747828015)`
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-21T11:54:58Z
body: * kotlin_compose: `mbl-1155-dismiss-fix (1747828021)`
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-21T12:00:46Z
body: * java_layout: `mbl-1155-dismiss-fix (1747828558)`
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-21T12:01:51Z
body: * kotlin_compose: `mbl-1155-dismiss-fix (1747828562)`
author_association: NONE