diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/presentation/GistModalActivity.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/presentation/GistModalActivity.kt index 0060a398..1d47b332 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/presentation/GistModalActivity.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/presentation/GistModalActivity.kt @@ -35,6 +35,10 @@ class GistModalActivity : AppCompatActivity(), ModalInAppMessageViewCallback, Tr private lateinit var binding: ActivityGistBinding private var messagePosition: MessagePosition = MessagePosition.CENTER + // Store the message that this activity is displaying to avoid dismissing wrong messages + // when multiple modal activities exist during transitions (race condition fix) + private var activityMessage: Message? = null + private val attributesListenerJob: MutableList = mutableListOf() private val elapsedTimer: ElapsedTimer = ElapsedTimer() private val logger = SDKComponent.logger @@ -103,6 +107,9 @@ class GistModalActivity : AppCompatActivity(), ModalInAppMessageViewCallback, Tr } private fun setupMessage(message: Message) { + // Store the message this activity is displaying + activityMessage = message + logger.debug("GistModelActivity onCreate: $message") elapsedTimer.start("Displaying modal for message: ${message.messageId}") @@ -190,25 +197,26 @@ class GistModalActivity : AppCompatActivity(), ModalInAppMessageViewCallback, Tr for (job in attributesListenerJob) { job.cancel() } - // if the message has been cancelled, do not perform any further actions - // to avoid sending any callbacks to the client app - // If the message is not persistent, dismiss it and inform the callback - val state = currentMessageState + // Only dispatch dismiss if THIS activity's message is still the currently displayed message. + // This prevents a race condition where Activity1 finishes while Activity2 is already showing, + // which would cause Activity1's onDestroy to incorrectly dismiss Activity2's message. + val displayedMessage = activityMessage + val messageInQueue = currentMessageState?.message val inAppManager = inAppMessagingManager - if (state != null && inAppManager != null) { - if (!isPersistentMessage()) { - inAppManager.dispatch(InAppMessagingAction.DismissMessage(message = state.message)) + + if (displayedMessage != null && inAppManager != null && displayedMessage.queueId == messageInQueue?.queueId) { + if (!isPersistentMessage(displayedMessage)) { + inAppManager.dispatch(InAppMessagingAction.DismissMessage(message = displayedMessage)) } else { - inAppManager.dispatch(InAppMessagingAction.DismissMessage(message = state.message, shouldLog = false)) + inAppManager.dispatch(InAppMessagingAction.DismissMessage(message = displayedMessage, shouldLog = false)) } } super.onDestroy() } - private fun isPersistentMessage(message: Message? = null): Boolean { - val currentMessage = message ?: currentMessageState?.message - return currentMessage?.gistProperties?.persistent ?: false + private fun isPersistentMessage(message: Message?): Boolean { + return message?.gistProperties?.persistent ?: false } private fun onMessageShown(message: Message) { diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingMiddlewares.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingMiddlewares.kt index e00831cf..3fd21cb7 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingMiddlewares.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingMiddlewares.kt @@ -80,7 +80,7 @@ private fun handleMessageDismissal(logger: Logger, store: Store(intent) + flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Destroy the activity + scenario.moveToState(Lifecycle.State.DESTROYED) + + // Verify dismiss was dispatched since the activity's message matches the one in queue + assertCalledOnce { + messagingManager.dispatch( + match { + it.message == testMessage && it.shouldLog + } + ) + } + + scenario.close() + } + + @Test + fun onDestroy_givenActivityMessageDifferentFromMessageInQueue_expectDismissNotDispatched() { + // Initialize ModuleMessagingInApp + val messagingManager = initializeModuleMessagingInApp() + val activityMessage = MessageBuilderMock.createMessage( + queueId = "activity-queue-id", + persistent = false + ) + val queueMessage = MessageBuilderMock.createMessage( + queueId = "different-queue-id", + persistent = false + ) + + // Setup parser to return the activity's message + coEvery { mockMessageParser.parseExtras(any()) } returns ModalMessageExtras( + message = activityMessage, + messagePosition = MessagePosition.CENTER + ) + + // Set the modal state to Displayed with a DIFFERENT message (different queueId) + // This simulates the race condition where Activity2 is already showing + every { messagingManager.getCurrentState() } returns mockk(relaxed = true) { + every { modalMessageState } returns ModalMessageState.Displayed(queueMessage) + } + + val intent = createActivityIntent(activityMessage) + val scenario = ActivityScenario.launch(intent) + flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Destroy the activity + scenario.moveToState(Lifecycle.State.DESTROYED) + + // Verify dismiss was NOT dispatched since the messages don't match (race condition prevention) + verify(exactly = 0) { + messagingManager.dispatch(match { true }) + } + + scenario.close() + } + + @Test + fun onDestroy_givenPersistentMessage_expectDismissDispatchedWithShouldLogFalse() { + // Initialize ModuleMessagingInApp + val messagingManager = initializeModuleMessagingInApp() + val testMessage = MessageBuilderMock.createMessage( + queueId = "test-queue-id", + persistent = true // Persistent message + ) + + // Setup parser to return the test message + coEvery { mockMessageParser.parseExtras(any()) } returns ModalMessageExtras( + message = testMessage, + messagePosition = MessagePosition.CENTER + ) + + // Set the modal state to Displayed with the SAME message + every { messagingManager.getCurrentState() } returns mockk(relaxed = true) { + every { modalMessageState } returns ModalMessageState.Displayed(testMessage) + } + + val intent = createActivityIntent(testMessage) + val scenario = ActivityScenario.launch(intent) + flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Destroy the activity + scenario.moveToState(Lifecycle.State.DESTROYED) + + // Verify dismiss was dispatched with shouldLog = false for persistent message + assertCalledOnce { + messagingManager.dispatch( + match { + it.message == testMessage && !it.shouldLog + } + ) + } + + scenario.close() + } + + // endregion + + private fun initializeModuleMessagingInApp(): InAppMessagingManager { val moduleConfig = MessagingInAppModuleConfig.Builder( siteId = "test-site-id", region = Region.US @@ -188,6 +314,8 @@ class GistModalActivityTest : IntegrationTest() { environment = GistEnvironment.LOCAL ) ).flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + return messagingManager } private fun createTestMessage(): Message = Message(