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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<Job> = mutableListOf()
private val elapsedTimer: ElapsedTimer = ElapsedTimer()
private val logger = SDKComponent.logger
Expand Down Expand Up @@ -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}")

Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ private fun handleMessageDismissal(logger: Logger, store: Store<InAppMessagingSt

// After the dismissal is processed, dispatch ProcessMessageQueue to show the next message
// The dismissed message will be filtered out by processMessages() since its queueId is now in shownMessageQueueIds
if (store.state.sseEnabled) {
if (store.state.shouldUseSse) {
SDKComponent.inAppSseLogger.logTryDisplayNextMessageAfterDismissal()
store.dispatch(InAppMessagingAction.ProcessMessageQueue(store.state.messagesInQueue.toList()))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,20 @@ import io.customer.messaginginapp.gist.GistEnvironment
import io.customer.messaginginapp.gist.data.listeners.GistQueue
import io.customer.messaginginapp.gist.data.model.Message
import io.customer.messaginginapp.gist.data.model.MessagePosition
import io.customer.messaginginapp.gist.utilities.ModalMessageExtras
import io.customer.messaginginapp.gist.utilities.ModalMessageParser
import io.customer.messaginginapp.state.InAppMessagingAction
import io.customer.messaginginapp.state.InAppMessagingManager
import io.customer.messaginginapp.state.MessageBuilderMock
import io.customer.messaginginapp.state.ModalMessageState
import io.customer.messaginginapp.testutils.core.IntegrationTest
import io.customer.sdk.core.di.SDKComponent
import io.customer.sdk.core.util.DispatchersProvider
import io.customer.sdk.core.util.Logger
import io.customer.sdk.core.util.ScopeProvider
import io.customer.sdk.data.model.Region
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.spyk
import io.mockk.verify
Expand Down Expand Up @@ -171,7 +175,129 @@ class GistModalActivityTest : IntegrationTest() {
scenario.close()
}

private fun initializeModuleMessagingInApp() {
// region onDestroy race condition prevention tests

@Test
fun onDestroy_givenActivityMessageMatchesMessageInQueue_expectDismissDispatched() {
// Initialize ModuleMessagingInApp
val messagingManager = initializeModuleMessagingInApp()
val testMessage = MessageBuilderMock.createMessage(
queueId = "test-queue-id",
persistent = false
)

// 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 (matching queueId)
every { messagingManager.getCurrentState() } returns mockk(relaxed = true) {
every { modalMessageState } returns ModalMessageState.Displayed(testMessage)
}

val intent = createActivityIntent(testMessage)
val scenario = ActivityScenario.launch<GistModalActivity>(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<InAppMessagingAction.DismissMessage> {
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<GistModalActivity>(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<InAppMessagingAction.DismissMessage> { 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<GistModalActivity>(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<InAppMessagingAction.DismissMessage> {
it.message == testMessage && !it.shouldLog
}
)
}

scenario.close()
}

// endregion

private fun initializeModuleMessagingInApp(): InAppMessagingManager {
val moduleConfig = MessagingInAppModuleConfig.Builder(
siteId = "test-site-id",
region = Region.US
Expand All @@ -188,6 +314,8 @@ class GistModalActivityTest : IntegrationTest() {
environment = GistEnvironment.LOCAL
)
).flushCoroutines(scopeProviderStub.inAppLifecycleScope)

return messagingManager
}

private fun createTestMessage(): Message = Message(
Expand Down
Loading