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 @@ -178,6 +178,7 @@ internal class InAppMessagesManager(
get() = _state.paused
set(value) {
Logging.debug("InAppMessagesManager.setPaused(value: $value)")
val wasPaused = _state.paused
_state.paused = value

// If paused is true and an In-App Message is showing, dismiss it
Expand All @@ -187,9 +188,23 @@ internal class InAppMessagesManager(
}
}

if (!value) {
// Only run the prune/evaluate/drain path on an actual paused -> unpaused
// transition; a redundant `paused = false` should not mutate the queue.
if (wasPaused && !value) {
suspendifyOnDefault {
// Messages queued while paused are parked in the display queue. Unpausing
// should only resume delivery for messages that are still eligible, so drop
// entries whose triggers no longer evaluate true, whose end time has passed,
// or that were dismissed, before re-evaluating and draining the queue.
messageDisplayQueueMutex.withLock {
messageDisplayQueue.removeAll {
!_triggerController.evaluateMessageTriggers(it) ||
it.isFinished ||
dismissedMessages.contains(it.messageId)
}
}
Comment thread
sherwinski marked this conversation as resolved.
evaluateInAppMessages()
attemptToShowInAppMessage()
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,17 @@ private class Mocks {
// Helper function to create InAppMessage with custom triggers (factory-style, returns new message each call)
fun createInAppMessage(
id: String = "test-message-${System.nanoTime()}", // Unique ID by default
triggers: List<Triple<String, String, String>> = emptyList() // List of (property, operator, value)
triggers: List<Triple<String, String, String>> = emptyList(), // List of (property, operator, value)
endTime: String? = null // ISO8601, e.g. "2000-01-01T00:00:00.000Z"
): InAppMessage {
val json = JSONObject().apply {
put("id", id)
put("variants", JSONObject().apply {
put("all", JSONObject().apply { put("en", "variant-id-123") })
})
if (endTime != null) {
put("end_time", endTime)
}

if (triggers.isEmpty()) {
put("triggers", JSONArray())
Expand Down Expand Up @@ -175,6 +179,24 @@ private class Mocks {
return property.get(manager) as Boolean
}

// Helper function to access private messageDisplayQueue field for testing using Kotlin reflection
fun getMessageDisplayQueue(manager: InAppMessagesManager): MutableList<InAppMessage> {
val property = InAppMessagesManager::class.memberProperties
.first { it.name == "messageDisplayQueue" }
property.isAccessible = true
@Suppress("UNCHECKED_CAST")
return property.get(manager) as MutableList<InAppMessage>
}

// Helper function to access private dismissedMessages field for testing using Kotlin reflection
fun getDismissedMessages(manager: InAppMessagesManager): MutableSet<String> {
val property = InAppMessagesManager::class.memberProperties
.first { it.name == "dismissedMessages" }
property.isAccessible = true
@Suppress("UNCHECKED_CAST")
return property.get(manager) as MutableSet<String>
}

// Helper function to create InAppMessagesManager with all dependencies
val inAppMessagesManager = InAppMessagesManager(
applicationService,
Expand Down Expand Up @@ -1005,6 +1027,181 @@ class InAppMessagesManagerTests : FunSpec({
}

context("Message Queue and Display") {
// Backs the InAppStateService mock with real state so the paused setter and the
// display flow (which reads/writes paused and inAppMessageIdShowing) behave like production.
fun statefulInAppState(initiallyPaused: Boolean) {
var pausedState = initiallyPaused
var showingId: String? = null
every { mocks.inAppStateService.paused } answers { pausedState }
every { mocks.inAppStateService.paused = any() } answers { pausedState = firstArg() }
every { mocks.inAppStateService.inAppMessageIdShowing } answers { showingId }
every { mocks.inAppStateService.inAppMessageIdShowing = any() } answers { showingId = firstArg() }
}

test("message queued while paused displays on unpause when triggers still evaluate true") {
// Given
val message = mocks.createInAppMessage()
statefulInAppState(initiallyPaused = true)
every { mocks.userManager.onesignalId } returns "onesignal-id"
every { mocks.applicationService.isInForeground } returns true
every { mocks.pushSubscription.id } returns "subscription-id"
every { mocks.triggerController.evaluateMessageTriggers(message) } returns true
every { mocks.triggerController.isTriggerOnMessage(any(), any()) } returns false
every { mocks.triggerController.messageHasOnlyDynamicTriggers(any()) } returns false
coEvery { mocks.applicationService.waitUntilSystemConditionsAvailable() } returns true
coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns listOf(message)
coEvery { mocks.inAppDisplayer.displayMessage(any()) } returns true

// Fetch while paused - message qualifies and is queued but blocked from display
mocks.inAppMessagesManager.onSessionStarted()
awaitIO()
coVerify(exactly = 0) { mocks.inAppDisplayer.displayMessage(any()) }

// When
mocks.inAppMessagesManager.paused = false
awaitIO()

// Then
coVerify(exactly = 1) { mocks.inAppDisplayer.displayMessage(message) }
}

test("message queued while paused does not display on unpause when triggers no longer evaluate true") {
// Given
val message = mocks.createInAppMessage()
statefulInAppState(initiallyPaused = true)
every { mocks.userManager.onesignalId } returns "onesignal-id"
every { mocks.applicationService.isInForeground } returns true
every { mocks.pushSubscription.id } returns "subscription-id"
every { mocks.triggerController.evaluateMessageTriggers(message) } returns true
every { mocks.triggerController.isTriggerOnMessage(any(), any()) } returns false
every { mocks.triggerController.messageHasOnlyDynamicTriggers(any()) } returns false
coEvery { mocks.applicationService.waitUntilSystemConditionsAvailable() } returns true
coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns listOf(message)
coEvery { mocks.inAppDisplayer.displayMessage(any()) } returns true

// Fetch while paused - message qualifies and is queued but blocked from display
mocks.inAppMessagesManager.onSessionStarted()
awaitIO()

// Trigger conditions change while still paused so the queued message no longer qualifies
every { mocks.triggerController.evaluateMessageTriggers(message) } returns false

// When
mocks.inAppMessagesManager.paused = false
awaitIO()

// Then - stale queued message is pruned, not displayed
coVerify(exactly = 0) { mocks.inAppDisplayer.displayMessage(any()) }

// And the message is not lost: it displays once its triggers evaluate true again
every { mocks.triggerController.evaluateMessageTriggers(message) } returns true
mocks.inAppMessagesManager.onTriggerChanged("some-trigger")
awaitIO()
coVerify(exactly = 1) { mocks.inAppDisplayer.displayMessage(message) }
}

test("message queued while paused does not display on unpause when its end time has passed") {
// Given
val expiredMessage = mocks.createInAppMessage(endTime = "2000-01-01T00:00:00.000Z")
val validMessage = mocks.createInAppMessage()
statefulInAppState(initiallyPaused = true)
every { mocks.triggerController.evaluateMessageTriggers(any()) } returns true
every { mocks.triggerController.isTriggerOnMessage(any(), any()) } returns false
every { mocks.triggerController.messageHasOnlyDynamicTriggers(any()) } returns false
coEvery { mocks.applicationService.waitUntilSystemConditionsAvailable() } returns true
coEvery { mocks.inAppDisplayer.displayMessage(any()) } returns true

// Both messages were queued before the end time passed. Evaluation never queues an
// already-expired message, so seed the queue directly to simulate expiry-while-paused.
mocks.getMessageDisplayQueue(mocks.inAppMessagesManager).add(expiredMessage)
mocks.getMessageDisplayQueue(mocks.inAppMessagesManager).add(validMessage)

// When
mocks.inAppMessagesManager.paused = false
awaitIO()

// Then - the expired message is pruned while the still-valid one displays
coVerify(exactly = 0) { mocks.inAppDisplayer.displayMessage(expiredMessage) }
coVerify(exactly = 1) { mocks.inAppDisplayer.displayMessage(validMessage) }
}

test("message queued while paused does not display on unpause when it was dismissed") {
// Given
val dismissedMessage = mocks.createInAppMessage()
val validMessage = mocks.createInAppMessage()
statefulInAppState(initiallyPaused = true)
every { mocks.triggerController.evaluateMessageTriggers(any()) } returns true
every { mocks.triggerController.isTriggerOnMessage(any(), any()) } returns false
every { mocks.triggerController.messageHasOnlyDynamicTriggers(any()) } returns false
coEvery { mocks.applicationService.waitUntilSystemConditionsAvailable() } returns true
coEvery { mocks.inAppDisplayer.displayMessage(any()) } returns true

// Seed the queue directly to simulate a message that was queued while paused
// and then dismissed (e.g. via a preview or another surface) before unpausing.
mocks.getMessageDisplayQueue(mocks.inAppMessagesManager).add(dismissedMessage)
mocks.getMessageDisplayQueue(mocks.inAppMessagesManager).add(validMessage)
mocks.getDismissedMessages(mocks.inAppMessagesManager).add(dismissedMessage.messageId)

// When
mocks.inAppMessagesManager.paused = false
awaitIO()

// Then - the dismissed message is pruned while the still-valid one displays
coVerify(exactly = 0) { mocks.inAppDisplayer.displayMessage(dismissedMessage) }
coVerify(exactly = 1) { mocks.inAppDisplayer.displayMessage(validMessage) }
}

test("redundant paused = false assignment does not prune or drain the queue") {
// Given - already unpaused, with a queued message whose triggers no longer hold
val message = mocks.createInAppMessage()
statefulInAppState(initiallyPaused = false)
every { mocks.triggerController.evaluateMessageTriggers(message) } returns false
coEvery { mocks.applicationService.waitUntilSystemConditionsAvailable() } returns true
coEvery { mocks.inAppDisplayer.displayMessage(any()) } returns true
mocks.getMessageDisplayQueue(mocks.inAppMessagesManager).add(message)

// When - setting paused = false without a paused -> unpaused transition
mocks.inAppMessagesManager.paused = false
awaitIO()

// Then - the queue is untouched and nothing is displayed
mocks.getMessageDisplayQueue(mocks.inAppMessagesManager) shouldBe listOf(message)
coVerify(exactly = 0) { mocks.inAppDisplayer.displayMessage(any()) }
}

test("message displays mid-session when trigger is satisfied after unpausing") {
// Reproduces the reported scenario: Paused = false is set first, then the
// qualifying trigger is added - the message must display without a new session.
// Given
val message = mocks.createInAppMessage()
statefulInAppState(initiallyPaused = true)
every { mocks.userManager.onesignalId } returns "onesignal-id"
every { mocks.applicationService.isInForeground } returns true
every { mocks.pushSubscription.id } returns "subscription-id"
every { mocks.triggerController.evaluateMessageTriggers(message) } returns false
every { mocks.triggerController.isTriggerOnMessage(any(), any()) } returns false
every { mocks.triggerController.messageHasOnlyDynamicTriggers(any()) } returns false
coEvery { mocks.applicationService.waitUntilSystemConditionsAvailable() } returns true
coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns listOf(message)
coEvery { mocks.inAppDisplayer.displayMessage(any()) } returns true

// Fetch while paused - message does not qualify yet
mocks.inAppMessagesManager.onSessionStarted()
awaitIO()

// When - unpause first (nothing qualifies yet), then the trigger becomes satisfied
mocks.inAppMessagesManager.paused = false
awaitIO()
coVerify(exactly = 0) { mocks.inAppDisplayer.displayMessage(any()) }

every { mocks.triggerController.evaluateMessageTriggers(message) } returns true
mocks.inAppMessagesManager.onTriggerChanged("view")
awaitIO()

// Then
coVerify(exactly = 1) { mocks.inAppDisplayer.displayMessage(message) }
}

test("messages are not queued when paused") {
// Given
val message = mocks.createInAppMessage()
Expand Down
Loading