From 56f4b65f4970530f22fd6a3bdba92ebfd63b10aa Mon Sep 17 00:00:00 2001 From: sherwinski Date: Tue, 7 Jul 2026 15:52:29 -0700 Subject: [PATCH 1/3] fix: [SDK-4513] display queued in-app messages on unpause when triggers still match Messages that qualified while paused were parked in messageDisplayQueue and only re-attempted when a later trigger change happened to re-qualify a message, so unpausing alone never resumed delivery mid-session. The unpause branch of the paused setter now prunes queue entries whose triggers no longer evaluate true (matching iOS, which never queues while paused), re-evaluates all messages, and drains the remaining queue. Fixes #2647 --- .../internal/InAppMessagesManager.kt | 7 ++ .../internal/InAppMessagesManagerTests.kt | 106 ++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt index cc42e2371..bc35dc89a 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt @@ -189,7 +189,14 @@ internal class InAppMessagesManager( if (!value) { suspendifyOnDefault { + // Messages queued while paused are parked in the display queue. Unpausing + // should only resume delivery for messages whose triggers currently evaluate + // true, so drop stale entries before re-evaluating and draining the queue. + messageDisplayQueueMutex.withLock { + messageDisplayQueue.removeAll { !_triggerController.evaluateMessageTriggers(it) } + } evaluateInAppMessages() + attemptToShowInAppMessage() } } } diff --git a/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt b/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt index 3f9525bf1..17323a87b 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt @@ -1005,6 +1005,112 @@ 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 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() From 88c6fa4c26ab1c21cdf84d8dd7a9bd58fa5f2652 Mon Sep 17 00:00:00 2001 From: sherwinski Date: Wed, 8 Jul 2026 11:28:14 -0700 Subject: [PATCH 2/3] fix: also prune expired and dismissed messages from display queue on unpause --- .../internal/InAppMessagesManager.kt | 11 +++-- .../internal/InAppMessagesManagerTests.kt | 40 ++++++++++++++++++- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt index bc35dc89a..494b7b41c 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt @@ -190,10 +190,15 @@ internal class InAppMessagesManager( if (!value) { suspendifyOnDefault { // Messages queued while paused are parked in the display queue. Unpausing - // should only resume delivery for messages whose triggers currently evaluate - // true, so drop stale entries before re-evaluating and draining the queue. + // 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) } + messageDisplayQueue.removeAll { + !_triggerController.evaluateMessageTriggers(it) || + it.isFinished || + dismissedMessages.contains(it.messageId) + } } evaluateInAppMessages() attemptToShowInAppMessage() diff --git a/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt b/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt index 17323a87b..1d8b6bd93 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt @@ -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> = emptyList() // List of (property, operator, value) + triggers: List> = 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()) @@ -175,6 +179,15 @@ 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 { + val property = InAppMessagesManager::class.memberProperties + .first { it.name == "messageDisplayQueue" } + property.isAccessible = true + @Suppress("UNCHECKED_CAST") + return property.get(manager) as MutableList + } + // Helper function to create InAppMessagesManager with all dependencies val inAppMessagesManager = InAppMessagesManager( applicationService, @@ -1078,6 +1091,31 @@ class InAppMessagesManagerTests : FunSpec({ 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 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. From 2f7be8469371700752773e96cb3b9a5a5ae094a7 Mon Sep 17 00:00:00 2001 From: sherwinski Date: Wed, 8 Jul 2026 12:16:14 -0700 Subject: [PATCH 3/3] fix: only run unpause queue prune on a paused -> unpaused transition --- .../internal/InAppMessagesManager.kt | 5 +- .../internal/InAppMessagesManagerTests.kt | 53 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt index 494b7b41c..b44dbd7ed 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt @@ -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 @@ -187,7 +188,9 @@ 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 diff --git a/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt b/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt index 1d8b6bd93..a8c1045d0 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt @@ -188,6 +188,15 @@ private class Mocks { return property.get(manager) as MutableList } + // Helper function to access private dismissedMessages field for testing using Kotlin reflection + fun getDismissedMessages(manager: InAppMessagesManager): MutableSet { + val property = InAppMessagesManager::class.memberProperties + .first { it.name == "dismissedMessages" } + property.isAccessible = true + @Suppress("UNCHECKED_CAST") + return property.get(manager) as MutableSet + } + // Helper function to create InAppMessagesManager with all dependencies val inAppMessagesManager = InAppMessagesManager( applicationService, @@ -1116,6 +1125,50 @@ class InAppMessagesManagerTests : FunSpec({ 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.