From b86479d0c34d0c5c5f93a2339acf12195f1de693 Mon Sep 17 00:00:00 2001 From: Mahmoud Elmorabea Date: Fri, 19 Jun 2026 15:07:17 +0400 Subject: [PATCH 1/4] fix: scope in-app polling to process lifecycle instead of per-activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-app message polling was started/stopped from per-activity ON_RESUME/ON_PAUSE callbacks. Because a modal in-app message runs in its own GistModalActivity, dismissing it resumed the host activity and triggered an immediate refetch that re-displayed a message which had failed to load — a tight retry loop. The same coupling also re-triggered polling on ordinary activity navigation. Scope polling to the process foreground lifecycle (ProcessLifecycleOwner), mirroring SseLifecycleManager, so a single polling timer survives activity navigation and modal display. A message that fails to load is now retried on the normal poll cadence (matching iOS and the web SDK) rather than in a tight loop. Added verification logs under the [Polling] tag. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../gist/presentation/GistSdk.kt | 134 +++++++++++------- .../io/customer/messaginginapp/GistSDKTest.kt | 69 ++++++++- .../ui/InlineInAppMessageIntegrationTest.kt | 6 +- .../ui/MessagingInAppIntegrationTest.kt | 6 +- ...InlineMessageViewControllerBehaviorTest.kt | 5 +- 5 files changed, 162 insertions(+), 58 deletions(-) diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/presentation/GistSdk.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/presentation/GistSdk.kt index cab0ffec5..fdb3cc674 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/presentation/GistSdk.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/presentation/GistSdk.kt @@ -1,6 +1,8 @@ package io.customer.messaginginapp.gist.presentation +import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner import io.customer.messaginginapp.di.gistQueue import io.customer.messaginginapp.di.inAppMessagingManager @@ -13,9 +15,11 @@ import io.customer.messaginginapp.state.InAppMessagingState import io.customer.messaginginapp.state.ModalMessageState import io.customer.messaginginapp.store.InAppPreferenceStore import io.customer.sdk.core.di.SDKComponent +import io.customer.sdk.core.util.HandlerMainThreadPoster +import io.customer.sdk.core.util.MainThreadPoster import java.util.Timer +import java.util.concurrent.atomic.AtomicBoolean import kotlin.concurrent.timer -import kotlinx.coroutines.flow.filter internal interface GistProvider { fun setCurrentRoute(route: String) @@ -29,7 +33,11 @@ internal interface GistProvider { internal class GistSdk( siteId: String, dataCenter: String, - environment: GistEnvironment = GistEnvironment.PROD + environment: GistEnvironment = GistEnvironment.PROD, + // Injected for testability; mirrors SseLifecycleManager so polling and SSE share the same + // process-level lifecycle source. + private val processLifecycleOwner: LifecycleOwner = ProcessLifecycleOwner.get(), + private val mainThreadPoster: MainThreadPoster = HandlerMainThreadPoster() ) : GistProvider { private val inAppMessagingManager = SDKComponent.inAppMessagingManager private val state: InAppMessagingState @@ -42,21 +50,49 @@ internal class GistSdk( private val gistQueue = SDKComponent.gistQueue private val sseLifecycleManager = SDKComponent.sseLifecycleManager - private val isAppForegrounded: Boolean - get() = ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + // Tracks process foreground state. Polling is scoped to the *process* lifecycle (matching + // SseLifecycleManager), not to individual activities, so a single polling timer survives + // activity navigation and the display/dismissal of our own GistModalActivity. + private val isForegrounded = AtomicBoolean(false) + + private val lifecycleObserver = object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + onAppForegrounded() + } + + override fun onStop(owner: LifecycleOwner) { + onAppBackgrounded() + } + } private fun resetTimer() { timer?.cancel() timer = null } - private fun onActivityResumed() { - logger.debug("GistSdk Activity resumed") - fetchInAppMessages(state.pollInterval) + private fun onAppForegrounded() { + if (!isForegrounded.compareAndSet(false, true)) { + logger.debug("[Polling] App foreground event ignored - already foregrounded") + return + } + + val currentState = state + logger.debug("[Polling] App foregrounded (shouldUseSse=${currentState.shouldUseSse}, sseEnabled=${currentState.sseEnabled}, isUserIdentified=${currentState.isUserIdentified})") + if (currentState.shouldUseSse) { + // SSE is active; SseLifecycleManager owns fetching/connection while foregrounded. + logger.debug("[Polling] Not starting polling on foreground - SSE is active") + return + } + // Start polling with an immediate catch-up fetch for messages received while backgrounded. + fetchInAppMessages(duration = currentState.pollInterval) } - private fun onActivityPaused() { - logger.debug("Activity paused, stopping polling") + private fun onAppBackgrounded() { + if (!isForegrounded.compareAndSet(true, false)) { + logger.debug("[Polling] App background event ignored - already backgrounded") + return + } + logger.debug("[Polling] App backgrounded - stopping polling") resetTimer() } @@ -85,36 +121,32 @@ internal class GistSdk( return } - logger.debug("GistSdk starting polling (sseEnabled=${currentState.sseEnabled}, isUserIdentified=${currentState.isUserIdentified}, interval=${duration}ms)") + logger.debug("[Polling] Starting polling (sseEnabled=${currentState.sseEnabled}, isUserIdentified=${currentState.isUserIdentified}, interval=${duration}ms, initialDelay=${initialDelay}ms)") timer?.cancel() // create a timer to run the task after the initial run timer = timer(name = "GistPolling", daemon = true, initialDelay = initialDelay, period = duration) { + logger.debug("[Polling] Poll tick - fetching user messages") gistQueue.fetchUserMessages() } } private fun subscribeToEvents() { - SDKComponent.activityLifecycleCallbacks.subscribe { events -> - events - .filter { state -> - state.event == Lifecycle.Event.ON_RESUME || state.event == Lifecycle.Event.ON_PAUSE - } - .filter { state -> - // ignore events from GistModalActivity to prevent polling/stopping polling when the in-app is displayed - state.activity.get() != null && state.activity.get() !is GistModalActivity - } - .collect { state -> - when (state.event) { - Lifecycle.Event.ON_RESUME -> onActivityResumed() - Lifecycle.Event.ON_PAUSE -> onActivityPaused() - else -> {} - } - } + // Scope polling to the *process* foreground lifecycle (foreground/background) rather than + // individual activity resume/pause. This keeps a single polling timer alive across + // activity navigation and while our own GistModalActivity is shown, and removes the + // immediate refetch that previously fired whenever the host activity resumed after a + // modal closed (the source of the tight retry loop when a modal failed to load). + // Mirrors SseLifecycleManager. Lifecycle registration must happen on the main thread. + mainThreadPoster.post { + processLifecycleOwner.lifecycle.addObserver(lifecycleObserver) + if (processLifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { + onAppForegrounded() + } } inAppMessagingManager.subscribeToAttribute({ it.pollInterval }) { interval -> // Only manage polling when app is foregrounded - if (!isAppForegrounded) { + if (!isForegrounded.get()) { return@subscribeToAttribute } @@ -122,41 +154,35 @@ internal class GistSdk( if (currentState.shouldUseSse) { return@subscribeToAttribute } + logger.debug("[Polling] Poll interval changed to ${interval}ms - restarting polling") fetchInAppMessages(duration = interval, initialDelay = interval) } - // Subscribe to SSE flag changes - only manage timer state, not triggering fetches - // Fetches are controlled by ModuleMessagingInApp event handlers and onActivityResumed() + // Keep the poll timer in sync with SSE availability while foregrounded: stop polling when + // SSE becomes active, resume polling when it is no longer active (e.g. SSE flag disabled + // or the user becomes anonymous). inAppMessagingManager.subscribeToAttribute({ it.sseEnabled }) { _ -> - // Only manage polling when app is foregrounded - if (!isAppForegrounded) { - return@subscribeToAttribute - } - - val currentState = state - if (currentState.shouldUseSse) { - // SSE is now active - stop polling - logger.debug("SSE enabled for identified user, stopping polling") - resetTimer() - } - // Note: Starting polling is handled by onActivityResumed() or event handlers + updatePollingForSseAvailability(reason = "SSE flag changed") } - // Subscribe to user identification changes - only manage timer state, not triggering fetches - // Fetches are controlled by ModuleMessagingInApp event handlers and onActivityResumed() inAppMessagingManager.subscribeToAttribute({ it.isUserIdentified }) { _ -> - // Only manage polling when app is foregrounded - if (!isAppForegrounded) { - return@subscribeToAttribute - } + updatePollingForSseAvailability(reason = "user identification changed") + } + } - val currentState = state - if (currentState.shouldUseSse) { - // SSE is now active - stop polling - logger.debug("User identified with SSE enabled, stopping polling") - resetTimer() - } - // Note: Starting polling is handled by onActivityResumed() or event handlers + private fun updatePollingForSseAvailability(reason: String) { + // Only manage polling when app is foregrounded + if (!isForegrounded.get()) { + return + } + + val currentState = state + if (currentState.shouldUseSse) { + logger.debug("[Polling] $reason - SSE now active, stopping polling") + resetTimer() + } else { + logger.debug("[Polling] $reason - SSE not active, ensuring polling is running") + fetchInAppMessages(duration = currentState.pollInterval) } } diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/GistSDKTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/GistSDKTest.kt index d90a43b93..a9cdefec7 100644 --- a/messaginginapp/src/test/java/io/customer/messaginginapp/GistSDKTest.kt +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/GistSDKTest.kt @@ -1,5 +1,7 @@ package io.customer.messaginginapp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner import io.customer.commontest.config.TestConfig import io.customer.commontest.config.testConfigurationDefault import io.customer.commontest.extensions.assertCalledOnce @@ -19,12 +21,14 @@ import io.customer.messaginginapp.state.ModalMessageState import io.customer.messaginginapp.store.InAppPreferenceStore import io.customer.messaginginapp.testutils.core.JUnitTest import io.customer.sdk.core.di.SDKComponent +import io.customer.sdk.core.util.MainThreadPoster import io.customer.sdk.lifecycle.CustomerIOActivityLifecycleCallbacks import io.mockk.Runs import io.mockk.clearMocks import io.mockk.every import io.mockk.just import io.mockk.mockk +import io.mockk.slot import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -39,6 +43,12 @@ class GistSDKTest : JUnitTest() { private lateinit var mockGistQueue: GistQueue private lateinit var testState: InAppMessagingState + // Process lifecycle doubles. The default instance uses a no-op main thread poster so the + // lifecycle observer is never registered and the app is treated as not-foregrounded, keeping + // these tests isolated from polling. Tests that need foregrounding build their own instance. + private val mockProcessLifecycleOwner = mockk(relaxed = true) + private val noOpMainThreadPoster = mockk(relaxed = true) + override fun setup(testConfig: TestConfig) { super.setup( testConfigurationDefault { @@ -71,7 +81,64 @@ class GistSDKTest : JUnitTest() { every { mockInAppMessagingManager.getCurrentState() } returns testState - gistSdk = GistSdk(siteId = String.random, dataCenter = String.random) + gistSdk = GistSdk( + siteId = String.random, + dataCenter = String.random, + processLifecycleOwner = mockProcessLifecycleOwner, + mainThreadPoster = noOpMainThreadPoster + ) + } + + /** + * Builds a GistSdk whose process lifecycle reports [foregrounded] and whose main thread poster + * runs synchronously, so lifecycle wiring executes inline during construction. + */ + private fun buildGistSdkWithLifecycle(foregrounded: Boolean): GistSdk { + val lifecycle = mockk(relaxed = true) { + every { currentState } returns if (foregrounded) Lifecycle.State.STARTED else Lifecycle.State.CREATED + } + val processOwner = mockk(relaxed = true) { + every { this@mockk.lifecycle } returns lifecycle + } + val synchronousPoster = mockk { + val postSlot = slot<() -> Unit>() + every { post(capture(postSlot)) } answers { postSlot.captured.invoke() } + } + return GistSdk( + siteId = String.random, + dataCenter = String.random, + processLifecycleOwner = processOwner, + mainThreadPoster = synchronousPoster + ) + } + + @Test + fun appForegrounded_whenSseNotActive_expectPollingStartedAndMessagesFetched() = runTest { + // testState has sseEnabled=false and userId=null, so shouldUseSse is false. + buildGistSdkWithLifecycle(foregrounded = true) + + // Foregrounding the process (not a specific activity) starts polling, which fetches messages. + verify(timeout = 2000) { mockGistQueue.fetchUserMessages() } + } + + @Test + fun appForegrounded_whenSseActive_expectPollingNotStarted() = runTest { + // Make SSE active: enabled flag + identified user. + testState = testState.copy(sseEnabled = true, userId = "identified-user") + every { mockInAppMessagingManager.getCurrentState() } returns testState + + buildGistSdkWithLifecycle(foregrounded = true) + + // SSE owns delivery while foregrounded; polling must not fetch. + verify(exactly = 0) { mockGistQueue.fetchUserMessages() } + } + + @Test + fun appNotForegrounded_expectPollingNotStarted() = runTest { + buildGistSdkWithLifecycle(foregrounded = false) + + // Process is only CREATED (not STARTED), so polling must not start. + verify(exactly = 0) { mockGistQueue.fetchUserMessages() } } @Test diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/ui/InlineInAppMessageIntegrationTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/ui/InlineInAppMessageIntegrationTest.kt index bef383504..4a6bbbce3 100644 --- a/messaginginapp/src/test/java/io/customer/messaginginapp/ui/InlineInAppMessageIntegrationTest.kt +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/ui/InlineInAppMessageIntegrationTest.kt @@ -81,7 +81,11 @@ class InlineInAppMessageIntegrationTest : JUnitTest() { gistProvider = GistSdk( siteId = moduleConfig.siteId, dataCenter = gistDataCenter, - environment = gistEnvironment + environment = gistEnvironment, + // No-op poster keeps the process-lifecycle polling wiring inert in this test; message + // flows are driven manually via dispatched actions below. + processLifecycleOwner = mockk(relaxed = true), + mainThreadPoster = mockk(relaxed = true) ).also { SDKComponent.overrideDependency(it) } messagingManager = spyk(SDKComponent.inAppMessagingManager) diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/ui/MessagingInAppIntegrationTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/ui/MessagingInAppIntegrationTest.kt index ed5244d34..787970343 100644 --- a/messaginginapp/src/test/java/io/customer/messaginginapp/ui/MessagingInAppIntegrationTest.kt +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/ui/MessagingInAppIntegrationTest.kt @@ -69,7 +69,11 @@ class MessagingInAppIntegrationTest : JUnitTest() { gistProvider = GistSdk( siteId = moduleConfig.siteId, dataCenter = gistDataCenter, - environment = gistEnvironment + environment = gistEnvironment, + // No-op poster keeps the process-lifecycle polling wiring inert in this test; message + // flows are driven manually via dispatched actions below. + processLifecycleOwner = mockk(relaxed = true), + mainThreadPoster = mockk(relaxed = true) ).also { SDKComponent.overrideDependency(it) } messagingManager = spyk(SDKComponent.inAppMessagingManager) 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 7ddc6ac8b..05a926699 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 @@ -87,7 +87,10 @@ class InlineMessageViewControllerBehaviorTest : JUnitTest() { gistProvider = GistSdk( siteId = moduleConfig.siteId, dataCenter = gistDataCenter, - environment = gistEnvironment + environment = gistEnvironment, + // No-op poster keeps the process-lifecycle polling wiring inert in this test. + processLifecycleOwner = mockk(relaxed = true), + mainThreadPoster = mockk(relaxed = true) ).also { SDKComponent.overrideDependency(it) } messagingManager = spyk(SDKComponent.inAppMessagingManager) .also { SDKComponent.overrideDependency(it) } From 41abdcbfd1885dcd61efcd962a64a1b0e72263c9 Mon Sep 17 00:00:00 2001 From: Mahmoud Elmorabea Date: Fri, 19 Jun 2026 15:24:53 +0400 Subject: [PATCH 2/4] refactor: extract polling into PollingLifecycleManager Move the process-lifecycle polling logic out of GistSdk into a dedicated PollingLifecycleManager, parallel to SseLifecycleManager. GistSdk becomes a thin GistProvider facade again (no lifecycle/timer/subscription state). Both transports are now singletons scoped to the same process lifecycle, which also removes a latent hazard: the GistProvider- and GistSdk-typed DI singletons each constructed a separate GistSdk (the middleware used the latter), so a second instance with its own polling timer could be created. Polling now lives in a single PollingLifecycleManager singleton; the middleware and the dismissal fetch path call it directly, and the duplicate gistSdk singleton is removed. No behavior change. Polling unit coverage moves from GistSDKTest into PollingLifecycleManagerTest. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../di/DIGraphMessagingInApp.kt | 12 +- .../gist/presentation/GistSdk.kt | 146 +-------------- .../presentation/PollingLifecycleManager.kt | 167 ++++++++++++++++++ .../state/InAppMessagingMiddlewares.kt | 4 +- .../io/customer/messaginginapp/GistSDKTest.kt | 82 +-------- .../PollingLifecycleManagerTest.kt | 143 +++++++++++++++ .../state/InAppMessagingMiddlewaresTest.kt | 12 +- .../ui/InlineInAppMessageIntegrationTest.kt | 8 +- .../ui/MessagingInAppIntegrationTest.kt | 8 +- ...InlineMessageViewControllerBehaviorTest.kt | 7 +- 10 files changed, 349 insertions(+), 240 deletions(-) create mode 100644 messaginginapp/src/main/java/io/customer/messaginginapp/gist/presentation/PollingLifecycleManager.kt create mode 100644 messaginginapp/src/test/java/io/customer/messaginginapp/gist/presentation/PollingLifecycleManagerTest.kt diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/di/DIGraphMessagingInApp.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/di/DIGraphMessagingInApp.kt index 08476305f..ae41b0dee 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/di/DIGraphMessagingInApp.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/di/DIGraphMessagingInApp.kt @@ -16,6 +16,7 @@ import io.customer.messaginginapp.gist.data.sse.SseRetryHelper import io.customer.messaginginapp.gist.data.sse.SseService import io.customer.messaginginapp.gist.presentation.GistProvider import io.customer.messaginginapp.gist.presentation.GistSdk +import io.customer.messaginginapp.gist.presentation.PollingLifecycleManager import io.customer.messaginginapp.gist.presentation.SseLifecycleManager import io.customer.messaginginapp.gist.utilities.ModalMessageGsonParser import io.customer.messaginginapp.gist.utilities.ModalMessageParser @@ -48,9 +49,14 @@ internal val SDKComponent.inAppPreferenceStore: InAppPreferenceStore internal val SDKComponent.inAppMessagingManager: InAppMessagingManager get() = singleton { InAppMessagingManager(inAppMessaging) } -internal val SDKComponent.gistSdk: GistSdk - get() = singleton { - GistSdk(siteId = inAppModuleConfig.siteId, dataCenter = inAppModuleConfig.region.code) +internal val SDKComponent.pollingLifecycleManager: PollingLifecycleManager + get() = singleton { + PollingLifecycleManager( + inAppMessagingManager = inAppMessagingManager, + processLifecycleOwner = ProcessLifecycleOwner.get(), + gistQueue = gistQueue, + logger = logger + ) } internal val SDKComponent.modalMessageParser: ModalMessageParser diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/presentation/GistSdk.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/presentation/GistSdk.kt index fdb3cc674..fa37a6fd7 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/presentation/GistSdk.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/presentation/GistSdk.kt @@ -1,12 +1,8 @@ package io.customer.messaginginapp.gist.presentation -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.ProcessLifecycleOwner -import io.customer.messaginginapp.di.gistQueue import io.customer.messaginginapp.di.inAppMessagingManager import io.customer.messaginginapp.di.inAppPreferenceStore +import io.customer.messaginginapp.di.pollingLifecycleManager import io.customer.messaginginapp.di.sseLifecycleManager import io.customer.messaginginapp.gist.GistEnvironment import io.customer.messaginginapp.gist.data.model.Message @@ -15,11 +11,6 @@ import io.customer.messaginginapp.state.InAppMessagingState import io.customer.messaginginapp.state.ModalMessageState import io.customer.messaginginapp.store.InAppPreferenceStore import io.customer.sdk.core.di.SDKComponent -import io.customer.sdk.core.util.HandlerMainThreadPoster -import io.customer.sdk.core.util.MainThreadPoster -import java.util.Timer -import java.util.concurrent.atomic.AtomicBoolean -import kotlin.concurrent.timer internal interface GistProvider { fun setCurrentRoute(route: String) @@ -33,11 +24,7 @@ internal interface GistProvider { internal class GistSdk( siteId: String, dataCenter: String, - environment: GistEnvironment = GistEnvironment.PROD, - // Injected for testability; mirrors SseLifecycleManager so polling and SSE share the same - // process-level lifecycle source. - private val processLifecycleOwner: LifecycleOwner = ProcessLifecycleOwner.get(), - private val mainThreadPoster: MainThreadPoster = HandlerMainThreadPoster() + environment: GistEnvironment = GistEnvironment.PROD ) : GistProvider { private val inAppMessagingManager = SDKComponent.inAppMessagingManager private val state: InAppMessagingState @@ -46,144 +33,25 @@ internal class GistSdk( private val inAppPreferenceStore: InAppPreferenceStore get() = SDKComponent.inAppPreferenceStore - private var timer: Timer? = null - private val gistQueue = SDKComponent.gistQueue + // Referenced so the lifecycle-scoped managers are instantiated and register their observers. + // Polling and SSE are each scoped to the process foreground lifecycle (see their classes). + private val pollingLifecycleManager = SDKComponent.pollingLifecycleManager private val sseLifecycleManager = SDKComponent.sseLifecycleManager - // Tracks process foreground state. Polling is scoped to the *process* lifecycle (matching - // SseLifecycleManager), not to individual activities, so a single polling timer survives - // activity navigation and the display/dismissal of our own GistModalActivity. - private val isForegrounded = AtomicBoolean(false) - - private val lifecycleObserver = object : DefaultLifecycleObserver { - override fun onStart(owner: LifecycleOwner) { - onAppForegrounded() - } - - override fun onStop(owner: LifecycleOwner) { - onAppBackgrounded() - } - } - - private fun resetTimer() { - timer?.cancel() - timer = null - } - - private fun onAppForegrounded() { - if (!isForegrounded.compareAndSet(false, true)) { - logger.debug("[Polling] App foreground event ignored - already foregrounded") - return - } - - val currentState = state - logger.debug("[Polling] App foregrounded (shouldUseSse=${currentState.shouldUseSse}, sseEnabled=${currentState.sseEnabled}, isUserIdentified=${currentState.isUserIdentified})") - if (currentState.shouldUseSse) { - // SSE is active; SseLifecycleManager owns fetching/connection while foregrounded. - logger.debug("[Polling] Not starting polling on foreground - SSE is active") - return - } - // Start polling with an immediate catch-up fetch for messages received while backgrounded. - fetchInAppMessages(duration = currentState.pollInterval) - } - - private fun onAppBackgrounded() { - if (!isForegrounded.compareAndSet(true, false)) { - logger.debug("[Polling] App background event ignored - already backgrounded") - return - } - logger.debug("[Polling] App backgrounded - stopping polling") - resetTimer() - } - init { inAppMessagingManager.dispatch(InAppMessagingAction.Initialize(siteId = siteId, dataCenter = dataCenter, environment = environment)) - subscribeToEvents() } override fun reset() { inAppMessagingManager.dispatch(InAppMessagingAction.Reset) // Remove user token from preferences inAppPreferenceStore.clearAll() - resetTimer() + pollingLifecycleManager.reset() sseLifecycleManager.reset() } override fun fetchInAppMessages() { - fetchInAppMessages(duration = state.pollInterval) - } - - private fun fetchInAppMessages(duration: Long, initialDelay: Long = 0) { - val currentState = state - // Only skip polling if SSE should be used (both flag enabled AND user identified) - if (currentState.shouldUseSse) { - logger.debug("GistSdk skipping polling - SSE is active (sseEnabled=${currentState.sseEnabled}, isUserIdentified=${currentState.isUserIdentified})") - return - } - - logger.debug("[Polling] Starting polling (sseEnabled=${currentState.sseEnabled}, isUserIdentified=${currentState.isUserIdentified}, interval=${duration}ms, initialDelay=${initialDelay}ms)") - timer?.cancel() - // create a timer to run the task after the initial run - timer = timer(name = "GistPolling", daemon = true, initialDelay = initialDelay, period = duration) { - logger.debug("[Polling] Poll tick - fetching user messages") - gistQueue.fetchUserMessages() - } - } - - private fun subscribeToEvents() { - // Scope polling to the *process* foreground lifecycle (foreground/background) rather than - // individual activity resume/pause. This keeps a single polling timer alive across - // activity navigation and while our own GistModalActivity is shown, and removes the - // immediate refetch that previously fired whenever the host activity resumed after a - // modal closed (the source of the tight retry loop when a modal failed to load). - // Mirrors SseLifecycleManager. Lifecycle registration must happen on the main thread. - mainThreadPoster.post { - processLifecycleOwner.lifecycle.addObserver(lifecycleObserver) - if (processLifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { - onAppForegrounded() - } - } - - inAppMessagingManager.subscribeToAttribute({ it.pollInterval }) { interval -> - // Only manage polling when app is foregrounded - if (!isForegrounded.get()) { - return@subscribeToAttribute - } - - val currentState = state - if (currentState.shouldUseSse) { - return@subscribeToAttribute - } - logger.debug("[Polling] Poll interval changed to ${interval}ms - restarting polling") - fetchInAppMessages(duration = interval, initialDelay = interval) - } - - // Keep the poll timer in sync with SSE availability while foregrounded: stop polling when - // SSE becomes active, resume polling when it is no longer active (e.g. SSE flag disabled - // or the user becomes anonymous). - inAppMessagingManager.subscribeToAttribute({ it.sseEnabled }) { _ -> - updatePollingForSseAvailability(reason = "SSE flag changed") - } - - inAppMessagingManager.subscribeToAttribute({ it.isUserIdentified }) { _ -> - updatePollingForSseAvailability(reason = "user identification changed") - } - } - - private fun updatePollingForSseAvailability(reason: String) { - // Only manage polling when app is foregrounded - if (!isForegrounded.get()) { - return - } - - val currentState = state - if (currentState.shouldUseSse) { - logger.debug("[Polling] $reason - SSE now active, stopping polling") - resetTimer() - } else { - logger.debug("[Polling] $reason - SSE not active, ensuring polling is running") - fetchInAppMessages(duration = currentState.pollInterval) - } + pollingLifecycleManager.fetchInAppMessages() } override fun setCurrentRoute(route: String) { diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/presentation/PollingLifecycleManager.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/presentation/PollingLifecycleManager.kt new file mode 100644 index 000000000..9b7fa6c8c --- /dev/null +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/presentation/PollingLifecycleManager.kt @@ -0,0 +1,167 @@ +package io.customer.messaginginapp.gist.presentation + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import io.customer.messaginginapp.gist.data.listeners.GistQueue +import io.customer.messaginginapp.state.InAppMessagingManager +import io.customer.messaginginapp.state.InAppMessagingState +import io.customer.sdk.core.util.HandlerMainThreadPoster +import io.customer.sdk.core.util.Logger +import io.customer.sdk.core.util.MainThreadPoster +import java.util.Timer +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.concurrent.timer + +/** + * Manages lifecycle-aware in-app message polling. + * + * Polling is scoped to the *process* foreground lifecycle, not individual activities, mirroring + * [SseLifecycleManager]. A single polling timer survives activity navigation and the display of + * our own [GistModalActivity], so dismissing a modal (normally or after a load failure) never + * triggers an immediate refetch. Polling stays disabled while SSE is active. + */ +internal class PollingLifecycleManager( + private val inAppMessagingManager: InAppMessagingManager, + processLifecycleOwner: LifecycleOwner, + private val gistQueue: GistQueue, + private val logger: Logger, + private val mainThreadPoster: MainThreadPoster = HandlerMainThreadPoster() +) { + private val isForegrounded = AtomicBoolean(false) + private var timer: Timer? = null + + private val state: InAppMessagingState + get() = inAppMessagingManager.getCurrentState() + + private val lifecycleObserver = object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + handleForegrounded() + } + + override fun onStop(owner: LifecycleOwner) { + handleBackgrounded() + } + } + + init { + // Lifecycle registration must happen on the main thread. + mainThreadPoster.post { + processLifecycleOwner.lifecycle.addObserver(lifecycleObserver) + if (processLifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { + handleForegrounded() + } + } + + subscribeToPollIntervalChanges() + subscribeToSseFlagChanges() + subscribeToUserIdentificationChanges() + } + + /** + * Triggers an immediate message fetch and (re)starts polling unless SSE is active. + * Called by external events such as user identification and message dismissal. + */ + fun fetchInAppMessages() { + startPolling(duration = state.pollInterval) + } + + fun reset() { + resetTimer() + } + + private fun handleForegrounded() { + if (!isForegrounded.compareAndSet(false, true)) { + logger.debug("[Polling] App foreground event ignored - already foregrounded") + return + } + + val currentState = state + logger.debug("[Polling] App foregrounded (shouldUseSse=${currentState.shouldUseSse}, sseEnabled=${currentState.sseEnabled}, isUserIdentified=${currentState.isUserIdentified})") + if (currentState.shouldUseSse) { + // SSE is active; SseLifecycleManager owns fetching/connection while foregrounded. + logger.debug("[Polling] Not starting polling on foreground - SSE is active") + return + } + // Start polling with an immediate catch-up fetch for messages received while backgrounded. + startPolling(duration = currentState.pollInterval) + } + + private fun handleBackgrounded() { + if (!isForegrounded.compareAndSet(true, false)) { + logger.debug("[Polling] App background event ignored - already backgrounded") + return + } + logger.debug("[Polling] App backgrounded - stopping polling") + resetTimer() + } + + private fun startPolling(duration: Long, initialDelay: Long = 0) { + val currentState = state + // Only skip polling if SSE should be used (both flag enabled AND user identified) + if (currentState.shouldUseSse) { + logger.debug("[Polling] Skipping polling - SSE is active (sseEnabled=${currentState.sseEnabled}, isUserIdentified=${currentState.isUserIdentified})") + return + } + + logger.debug("[Polling] Starting polling (sseEnabled=${currentState.sseEnabled}, isUserIdentified=${currentState.isUserIdentified}, interval=${duration}ms, initialDelay=${initialDelay}ms)") + timer?.cancel() + // create a timer to run the task after the initial run + timer = timer(name = "GistPolling", daemon = true, initialDelay = initialDelay, period = duration) { + logger.debug("[Polling] Poll tick - fetching user messages") + gistQueue.fetchUserMessages() + } + } + + private fun resetTimer() { + timer?.cancel() + timer = null + } + + private fun subscribeToPollIntervalChanges() { + inAppMessagingManager.subscribeToAttribute({ it.pollInterval }) { interval -> + // Only manage polling when app is foregrounded + if (!isForegrounded.get()) { + return@subscribeToAttribute + } + + val currentState = state + if (currentState.shouldUseSse) { + return@subscribeToAttribute + } + logger.debug("[Polling] Poll interval changed to ${interval}ms - restarting polling") + startPolling(duration = interval, initialDelay = interval) + } + } + + // Keep the poll timer in sync with SSE availability while foregrounded: stop polling when SSE + // becomes active, resume polling when it is no longer active (SSE flag disabled or user + // becomes anonymous). + private fun subscribeToSseFlagChanges() { + inAppMessagingManager.subscribeToAttribute({ it.sseEnabled }) { _ -> + updatePollingForSseAvailability(reason = "SSE flag changed") + } + } + + private fun subscribeToUserIdentificationChanges() { + inAppMessagingManager.subscribeToAttribute({ it.isUserIdentified }) { _ -> + updatePollingForSseAvailability(reason = "user identification changed") + } + } + + private fun updatePollingForSseAvailability(reason: String) { + // Only manage polling when app is foregrounded + if (!isForegrounded.get()) { + return + } + + val currentState = state + if (currentState.shouldUseSse) { + logger.debug("[Polling] $reason - SSE now active, stopping polling") + resetTimer() + } else { + logger.debug("[Polling] $reason - SSE not active, ensuring polling is running") + startPolling(duration = currentState.pollInterval) + } + } +} 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 50108cb0f..7fe541738 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingMiddlewares.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingMiddlewares.kt @@ -4,8 +4,8 @@ import android.content.Intent import com.google.gson.Gson import io.customer.messaginginapp.di.anonymousMessageManager import io.customer.messaginginapp.di.gistQueue -import io.customer.messaginginapp.di.gistSdk import io.customer.messaginginapp.di.inAppSseLogger +import io.customer.messaginginapp.di.pollingLifecycleManager import io.customer.messaginginapp.gist.data.model.InboxMessage import io.customer.messaginginapp.gist.data.model.Message import io.customer.messaginginapp.gist.data.model.isMessageAnonymous @@ -73,7 +73,7 @@ private fun handleMessageDismissal(logger: Logger, store: Store(relaxed = true) - private val noOpMainThreadPoster = mockk(relaxed = true) - override fun setup(testConfig: TestConfig) { super.setup( testConfigurationDefault { @@ -57,11 +45,10 @@ class GistSDKTest : JUnitTest() { overrideDependency(mockk(relaxed = true)) overrideDependency(mockk(relaxed = true)) overrideDependency(mockk(relaxed = true)) - overrideDependency( - mockk(relaxed = true) { - every { subscribe(any()) } just Runs - } - ) + // GistSdk is now a thin facade; polling/SSE live in their own lifecycle + // managers, mocked here so constructing GistSdk does not touch real + // process-lifecycle wiring. + overrideDependency(mockk(relaxed = true)) overrideDependency(mockk(relaxed = true)) } } @@ -81,64 +68,7 @@ class GistSDKTest : JUnitTest() { every { mockInAppMessagingManager.getCurrentState() } returns testState - gistSdk = GistSdk( - siteId = String.random, - dataCenter = String.random, - processLifecycleOwner = mockProcessLifecycleOwner, - mainThreadPoster = noOpMainThreadPoster - ) - } - - /** - * Builds a GistSdk whose process lifecycle reports [foregrounded] and whose main thread poster - * runs synchronously, so lifecycle wiring executes inline during construction. - */ - private fun buildGistSdkWithLifecycle(foregrounded: Boolean): GistSdk { - val lifecycle = mockk(relaxed = true) { - every { currentState } returns if (foregrounded) Lifecycle.State.STARTED else Lifecycle.State.CREATED - } - val processOwner = mockk(relaxed = true) { - every { this@mockk.lifecycle } returns lifecycle - } - val synchronousPoster = mockk { - val postSlot = slot<() -> Unit>() - every { post(capture(postSlot)) } answers { postSlot.captured.invoke() } - } - return GistSdk( - siteId = String.random, - dataCenter = String.random, - processLifecycleOwner = processOwner, - mainThreadPoster = synchronousPoster - ) - } - - @Test - fun appForegrounded_whenSseNotActive_expectPollingStartedAndMessagesFetched() = runTest { - // testState has sseEnabled=false and userId=null, so shouldUseSse is false. - buildGistSdkWithLifecycle(foregrounded = true) - - // Foregrounding the process (not a specific activity) starts polling, which fetches messages. - verify(timeout = 2000) { mockGistQueue.fetchUserMessages() } - } - - @Test - fun appForegrounded_whenSseActive_expectPollingNotStarted() = runTest { - // Make SSE active: enabled flag + identified user. - testState = testState.copy(sseEnabled = true, userId = "identified-user") - every { mockInAppMessagingManager.getCurrentState() } returns testState - - buildGistSdkWithLifecycle(foregrounded = true) - - // SSE owns delivery while foregrounded; polling must not fetch. - verify(exactly = 0) { mockGistQueue.fetchUserMessages() } - } - - @Test - fun appNotForegrounded_expectPollingNotStarted() = runTest { - buildGistSdkWithLifecycle(foregrounded = false) - - // Process is only CREATED (not STARTED), so polling must not start. - verify(exactly = 0) { mockGistQueue.fetchUserMessages() } + gistSdk = GistSdk(siteId = String.random, dataCenter = String.random) } @Test diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/gist/presentation/PollingLifecycleManagerTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/gist/presentation/PollingLifecycleManagerTest.kt new file mode 100644 index 000000000..d1827c39f --- /dev/null +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/gist/presentation/PollingLifecycleManagerTest.kt @@ -0,0 +1,143 @@ +package io.customer.messaginginapp.gist.presentation + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.LifecycleOwner +import io.customer.messaginginapp.gist.data.listeners.GistQueue +import io.customer.messaginginapp.state.InAppMessagingManager +import io.customer.messaginginapp.state.InAppMessagingState +import io.customer.messaginginapp.testutils.core.JUnitTest +import io.customer.sdk.core.util.Logger +import io.customer.sdk.core.util.MainThreadPoster +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class PollingLifecycleManagerTest : JUnitTest() { + + private val inAppMessagingManager = mockk(relaxed = true) + private val processLifecycleOwner = mockk(relaxed = true) + private val lifecycle = mockk(relaxed = true) + private val gistQueue = mockk(relaxed = true) + private val logger = mockk(relaxed = true) + private val mainThreadPoster = mockk(relaxed = true) + + // SSE inactive by default (sseEnabled=false), so polling is the active transport. + private var state = InAppMessagingState(pollInterval = 600000L) + + @BeforeEach + fun setupManager() { + every { processLifecycleOwner.lifecycle } returns lifecycle + every { lifecycle.currentState } returns Lifecycle.State.CREATED + every { lifecycle.addObserver(any()) } just Runs + every { inAppMessagingManager.getCurrentState() } answers { state } + + // Run the lifecycle-registration block synchronously so construction is deterministic. + val postSlot = slot<() -> Unit>() + every { mainThreadPoster.post(capture(postSlot)) } answers { postSlot.captured.invoke() } + } + + private fun createManager(): PollingLifecycleManager = PollingLifecycleManager( + inAppMessagingManager = inAppMessagingManager, + processLifecycleOwner = processLifecycleOwner, + gistQueue = gistQueue, + logger = logger, + mainThreadPoster = mainThreadPoster + ) + + private fun captureObserver(): DefaultLifecycleObserver { + val observerSlot = slot() + verify { lifecycle.addObserver(capture(observerSlot)) } + return observerSlot.captured as DefaultLifecycleObserver + } + + @Test + fun init_whenAppCreated_registersObserverAndDoesNotPoll() { + every { lifecycle.currentState } returns Lifecycle.State.CREATED + + createManager() + + verify { lifecycle.addObserver(any()) } + verify(exactly = 0) { gistQueue.fetchUserMessages() } + } + + @Test + fun init_whenAppStartedAndSseInactive_startsPollingAndFetches() { + every { lifecycle.currentState } returns Lifecycle.State.STARTED + state = InAppMessagingState(pollInterval = 600000L, sseEnabled = false) + + createManager() + + verify(timeout = 2000) { gistQueue.fetchUserMessages() } + } + + @Test + fun onStart_whenSseInactive_startsPollingAndFetches() { + createManager() + val observer = captureObserver() + + observer.onStart(processLifecycleOwner) + + verify(timeout = 2000) { gistQueue.fetchUserMessages() } + } + + @Test + fun onStart_whenSseActive_doesNotPoll() { + // SSE active: enabled flag + identified user. + state = InAppMessagingState(pollInterval = 600000L, sseEnabled = true, userId = "user-123") + createManager() + val observer = captureObserver() + + observer.onStart(processLifecycleOwner) + + verify(exactly = 0) { gistQueue.fetchUserMessages() } + verify { logger.debug(match { it.contains("Not starting polling") }) } + } + + @Test + fun onStart_whenAlreadyForegrounded_skipsSecondTime() { + createManager() + val observer = captureObserver() + + observer.onStart(processLifecycleOwner) + observer.onStart(processLifecycleOwner) + + verify { logger.debug(match { it.contains("already foregrounded") }) } + } + + @Test + fun onStop_afterForeground_stopsPolling() { + createManager() + val observer = captureObserver() + + observer.onStart(processLifecycleOwner) + observer.onStop(processLifecycleOwner) + + verify { logger.debug(match { it.contains("App backgrounded - stopping polling") }) } + } + + @Test + fun fetchInAppMessages_whenSseInactive_fetches() { + val manager = createManager() + + manager.fetchInAppMessages() + + verify(timeout = 2000) { gistQueue.fetchUserMessages() } + } + + @Test + fun fetchInAppMessages_whenSseActive_doesNotFetch() { + state = InAppMessagingState(pollInterval = 600000L, sseEnabled = true, userId = "user-123") + val manager = createManager() + + manager.fetchInAppMessages() + + verify(exactly = 0) { gistQueue.fetchUserMessages() } + } +} diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/state/InAppMessagingMiddlewaresTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/state/InAppMessagingMiddlewaresTest.kt index e2d20e2e4..5c8e59ca9 100644 --- a/messaginginapp/src/test/java/io/customer/messaginginapp/state/InAppMessagingMiddlewaresTest.kt +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/state/InAppMessagingMiddlewaresTest.kt @@ -5,7 +5,7 @@ import io.customer.commontest.config.testConfigurationDefault import io.customer.commontest.extensions.random import io.customer.messaginginapp.gist.data.listeners.GistQueue import io.customer.messaginginapp.gist.presentation.GistListener -import io.customer.messaginginapp.gist.presentation.GistSdk +import io.customer.messaginginapp.gist.presentation.PollingLifecycleManager import io.customer.messaginginapp.state.MessageBuilderMock.createMessage import io.customer.messaginginapp.testutils.core.JUnitTest import io.customer.messaginginapp.testutils.extension.createInAppMessage @@ -32,7 +32,7 @@ class InAppMessagingMiddlewaresTest : JUnitTest() { private val mockGistQueue: GistQueue = mockk(relaxed = true) private val mockGistListener: GistListener = mockk(relaxed = true) private val mockLogger: Logger = mockk(relaxed = true) - private val mockGistSdk: GistSdk = mockk(relaxed = true) + private val mockPollingLifecycleManager: PollingLifecycleManager = mockk(relaxed = true) private val mockEventBus: EventBus = mockk(relaxed = true) override fun setup(testConfig: TestConfig) { @@ -46,7 +46,7 @@ class InAppMessagingMiddlewaresTest : JUnitTest() { sdk { overrideDependency(mockGistQueue) overrideDependency(mockLogger) - overrideDependency(mockGistSdk) + overrideDependency(mockPollingLifecycleManager) overrideDependency(mockEventBus) } } @@ -389,7 +389,7 @@ class InAppMessagingMiddlewaresTest : JUnitTest() { verify { mockGistQueue.logView(message) } // Verify that fetchInAppMessages was called - verify { mockGistSdk.fetchInAppMessages() } + verify { mockPollingLifecycleManager.fetchInAppMessages() } // Verify next action was called verify { nextFn(action) } @@ -408,7 +408,7 @@ class InAppMessagingMiddlewaresTest : JUnitTest() { // Should not log view or fetch messages since shouldMarkMessageAsShown() returns false for this case verify(exactly = 0) { mockGistQueue.logView(message) } - verify(exactly = 0) { mockGistSdk.fetchInAppMessages() } + verify(exactly = 0) { mockPollingLifecycleManager.fetchInAppMessages() } // Verify next action was called verify { nextFn(action) } @@ -426,7 +426,7 @@ class InAppMessagingMiddlewaresTest : JUnitTest() { // Should not log view or fetch messages since viaCloseAction is false verify(exactly = 0) { mockGistQueue.logView(message) } - verify(exactly = 0) { mockGistSdk.fetchInAppMessages() } + verify(exactly = 0) { mockPollingLifecycleManager.fetchInAppMessages() } // Verify next action was called verify { nextFn(action) } diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/ui/InlineInAppMessageIntegrationTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/ui/InlineInAppMessageIntegrationTest.kt index 4a6bbbce3..7db4eda7e 100644 --- a/messaginginapp/src/test/java/io/customer/messaginginapp/ui/InlineInAppMessageIntegrationTest.kt +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/ui/InlineInAppMessageIntegrationTest.kt @@ -12,6 +12,7 @@ import io.customer.messaginginapp.gist.GistEnvironment import io.customer.messaginginapp.gist.data.listeners.GistQueue import io.customer.messaginginapp.gist.presentation.GistProvider import io.customer.messaginginapp.gist.presentation.GistSdk +import io.customer.messaginginapp.gist.presentation.PollingLifecycleManager import io.customer.messaginginapp.gist.presentation.SseLifecycleManager import io.customer.messaginginapp.state.InAppMessagingAction import io.customer.messaginginapp.state.InAppMessagingManager @@ -67,6 +68,7 @@ class InlineInAppMessageIntegrationTest : JUnitTest() { every { subscribe(any()) } just Runs } ) + overrideDependency(mockk(relaxed = true)) overrideDependency(mockk(relaxed = true)) } } @@ -81,11 +83,7 @@ class InlineInAppMessageIntegrationTest : JUnitTest() { gistProvider = GistSdk( siteId = moduleConfig.siteId, dataCenter = gistDataCenter, - environment = gistEnvironment, - // No-op poster keeps the process-lifecycle polling wiring inert in this test; message - // flows are driven manually via dispatched actions below. - processLifecycleOwner = mockk(relaxed = true), - mainThreadPoster = mockk(relaxed = true) + environment = gistEnvironment ).also { SDKComponent.overrideDependency(it) } messagingManager = spyk(SDKComponent.inAppMessagingManager) diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/ui/MessagingInAppIntegrationTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/ui/MessagingInAppIntegrationTest.kt index 787970343..0120e4cec 100644 --- a/messaginginapp/src/test/java/io/customer/messaginginapp/ui/MessagingInAppIntegrationTest.kt +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/ui/MessagingInAppIntegrationTest.kt @@ -12,6 +12,7 @@ import io.customer.messaginginapp.gist.GistEnvironment import io.customer.messaginginapp.gist.data.listeners.GistQueue import io.customer.messaginginapp.gist.presentation.GistProvider import io.customer.messaginginapp.gist.presentation.GistSdk +import io.customer.messaginginapp.gist.presentation.PollingLifecycleManager import io.customer.messaginginapp.gist.presentation.SseLifecycleManager import io.customer.messaginginapp.state.InAppMessagingAction import io.customer.messaginginapp.state.InAppMessagingManager @@ -58,6 +59,7 @@ class MessagingInAppIntegrationTest : JUnitTest() { every { subscribe(any()) } just Runs } ) + overrideDependency(mockk(relaxed = true)) overrideDependency(mockk(relaxed = true)) } } @@ -69,11 +71,7 @@ class MessagingInAppIntegrationTest : JUnitTest() { gistProvider = GistSdk( siteId = moduleConfig.siteId, dataCenter = gistDataCenter, - environment = gistEnvironment, - // No-op poster keeps the process-lifecycle polling wiring inert in this test; message - // flows are driven manually via dispatched actions below. - processLifecycleOwner = mockk(relaxed = true), - mainThreadPoster = mockk(relaxed = true) + environment = gistEnvironment ).also { SDKComponent.overrideDependency(it) } messagingManager = spyk(SDKComponent.inAppMessagingManager) 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 05a926699..170749471 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 @@ -19,6 +19,7 @@ import io.customer.messaginginapp.gist.data.model.Message import io.customer.messaginginapp.gist.data.model.engine.EngineWebConfiguration import io.customer.messaginginapp.gist.presentation.GistProvider import io.customer.messaginginapp.gist.presentation.GistSdk +import io.customer.messaginginapp.gist.presentation.PollingLifecycleManager import io.customer.messaginginapp.gist.presentation.SseLifecycleManager import io.customer.messaginginapp.gist.utilities.ElapsedTimer import io.customer.messaginginapp.state.InAppMessagingAction @@ -76,6 +77,7 @@ class InlineMessageViewControllerBehaviorTest : JUnitTest() { every { subscribe(any()) } just Runs } ) + overrideDependency(mockk(relaxed = true)) overrideDependency(mockk(relaxed = true)) } } @@ -87,10 +89,7 @@ class InlineMessageViewControllerBehaviorTest : JUnitTest() { gistProvider = GistSdk( siteId = moduleConfig.siteId, dataCenter = gistDataCenter, - environment = gistEnvironment, - // No-op poster keeps the process-lifecycle polling wiring inert in this test. - processLifecycleOwner = mockk(relaxed = true), - mainThreadPoster = mockk(relaxed = true) + environment = gistEnvironment ).also { SDKComponent.overrideDependency(it) } messagingManager = spyk(SDKComponent.inAppMessagingManager) .also { SDKComponent.overrideDependency(it) } From 6c6fa01449d70ba3cf25a938c967d85ca05c55ab Mon Sep 17 00:00:00 2001 From: Mahmoud Elmorabea Date: Sat, 20 Jun 2026 19:41:07 +0400 Subject: [PATCH 3/4] fix: avoid redundant polling restarts in PollingLifecycleManager Two guards so the attribute subscriptions only act on genuine transitions: - Skip the pollInterval subscription's initial replay emission. The initial/ foreground start is owned by handleForegrounded()/fetchInAppMessages(); reacting to the replay could cancel that in-flight catch-up fetch and reschedule a full interval out (addresses the Bugbot finding). - In the SSE-availability handler, only (re)start polling when the timer isn't already running, i.e. when transitioning away from SSE. Previously every identification or sseEnabled flip that left shouldUseSse=false caused a redundant timer reset + duplicate fetch (e.g. anon->identified with SSE off, where ModuleMessagingInApp already fetches; or an sseEnabled flip while anonymous). Restores the original "subscriptions only stop polling, event handlers own starting" behavior while still resuming on a real SSE->off flip. timer is now @Volatile for cross-thread visibility. Adds unit tests for both guards. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../presentation/PollingLifecycleManager.kt | 26 +++++++- .../PollingLifecycleManagerTest.kt | 65 +++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/presentation/PollingLifecycleManager.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/presentation/PollingLifecycleManager.kt index 9b7fa6c8c..01cf4e40e 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/presentation/PollingLifecycleManager.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/presentation/PollingLifecycleManager.kt @@ -29,8 +29,17 @@ internal class PollingLifecycleManager( private val mainThreadPoster: MainThreadPoster = HandlerMainThreadPoster() ) { private val isForegrounded = AtomicBoolean(false) + + // @Volatile for cross-thread visibility: written on the main thread (foreground/background) and + // read from the attribute-subscription coroutines that guard against redundant restarts. + @Volatile private var timer: Timer? = null + // Guards the poll-interval subscription's initial replay emission. The initial/foreground start + // is owned by handleForegrounded()/fetchInAppMessages(); reacting to the replay would race with + // and could cancel that catch-up fetch. Only genuine interval changes should restart polling. + private val pollIntervalInitialized = AtomicBoolean(false) + private val state: InAppMessagingState get() = inAppMessagingManager.getCurrentState() @@ -120,6 +129,12 @@ internal class PollingLifecycleManager( private fun subscribeToPollIntervalChanges() { inAppMessagingManager.subscribeToAttribute({ it.pollInterval }) { interval -> + // Skip the initial replay emission; the initial start is owned by + // handleForegrounded()/fetchInAppMessages(). Only react to genuine interval changes. + if (pollIntervalInitialized.compareAndSet(false, true)) { + return@subscribeToAttribute + } + // Only manage polling when app is foregrounded if (!isForegrounded.get()) { return@subscribeToAttribute @@ -159,9 +174,16 @@ internal class PollingLifecycleManager( if (currentState.shouldUseSse) { logger.debug("[Polling] $reason - SSE now active, stopping polling") resetTimer() - } else { - logger.debug("[Polling] $reason - SSE not active, ensuring polling is running") + } else if (timer == null) { + // Resume polling only when it isn't already running, i.e. we're transitioning away + // from SSE (which had stopped the timer). When polling is already active this branch + // would otherwise cause a redundant restart + duplicate fetch on every identification + // or SSE-flag flip that leaves shouldUseSse false (e.g. anon->identified with SSE off, + // where ModuleMessagingInApp already fetches, or an sseEnabled flip while anonymous). + logger.debug("[Polling] $reason - SSE not active and polling stopped, starting polling") startPolling(duration = currentState.pollInterval) + } else { + logger.debug("[Polling] $reason - SSE not active and polling already running, no action") } } } diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/gist/presentation/PollingLifecycleManagerTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/gist/presentation/PollingLifecycleManagerTest.kt index d1827c39f..21fe97376 100644 --- a/messaginginapp/src/test/java/io/customer/messaginginapp/gist/presentation/PollingLifecycleManagerTest.kt +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/gist/presentation/PollingLifecycleManagerTest.kt @@ -31,13 +31,30 @@ class PollingLifecycleManagerTest : JUnitTest() { // SSE inactive by default (sseEnabled=false), so polling is the active transport. private var state = InAppMessagingState(pollInterval = 600000L) + // Captured attribute-subscription listeners, in registration order: + // 0 = pollInterval, 1 = sseEnabled, 2 = isUserIdentified. They are captured (not auto-invoked) + // so tests can drive specific transitions deterministically. + private val subscriptionListeners = mutableListOf<(Any?) -> Unit>() + private val pollIntervalListener get() = subscriptionListeners[0] + private val sseEnabledListener get() = subscriptionListeners[1] + private val userIdentifiedListener get() = subscriptionListeners[2] + @BeforeEach fun setupManager() { + subscriptionListeners.clear() every { processLifecycleOwner.lifecycle } returns lifecycle every { lifecycle.currentState } returns Lifecycle.State.CREATED every { lifecycle.addObserver(any()) } just Runs every { inAppMessagingManager.getCurrentState() } answers { state } + // Capture each attribute listener without invoking it (the real impl replays the current + // value asynchronously; capturing lets tests trigger genuine transitions on demand). + @Suppress("UNCHECKED_CAST") + every { inAppMessagingManager.subscribeToAttribute(any(), any(), any()) } answers { + subscriptionListeners.add(thirdArg<(Any?) -> Unit>()) + mockk(relaxed = true) + } + // Run the lifecycle-registration block synchronously so construction is deterministic. val postSlot = slot<() -> Unit>() every { mainThreadPoster.post(capture(postSlot)) } answers { postSlot.captured.invoke() } @@ -140,4 +157,52 @@ class PollingLifecycleManagerTest : JUnitTest() { verify(exactly = 0) { gistQueue.fetchUserMessages() } } + + @Test + fun pollIntervalSubscription_initialEmissionIsSkipped_butGenuineChangeRestarts() { + createManager() + val observer = captureObserver() + observer.onStart(processLifecycleOwner) // foreground -> polling started + + // Initial replay emission of the current interval must not restart polling (would race + // with / cancel the foreground catch-up fetch). + pollIntervalListener(600000L) + verify(exactly = 0) { logger.debug(match { it.contains("Poll interval changed") }) } + + // A genuine interval change restarts polling. + state = state.copy(pollInterval = 10000L) + pollIntervalListener(10000L) + verify { logger.debug(match { it.contains("Poll interval changed to 10000ms") }) } + } + + @Test + fun sseAvailabilityChange_whenPollingAlreadyRunning_doesNotRestart() { + createManager() + val observer = captureObserver() + observer.onStart(processLifecycleOwner) // foreground -> polling running, SSE inactive + + // An identification change that leaves shouldUseSse=false (e.g. anon->identified with SSE + // off) must not restart polling - ModuleMessagingInApp owns that catch-up fetch. + state = state.copy(userId = "user-123") // sseEnabled still false -> shouldUseSse false + userIdentifiedListener(true) + + verify { logger.debug(match { it.contains("polling already running, no action") }) } + verify(exactly = 0) { logger.debug(match { it.contains("starting polling") }) } + } + + @Test + fun sseAvailabilityChange_whenPollingStopped_resumesPolling() { + // Start with SSE active so foregrounding does not start polling (timer stays null). + state = InAppMessagingState(pollInterval = 600000L, sseEnabled = true, userId = "user-123") + createManager() + val observer = captureObserver() + observer.onStart(processLifecycleOwner) + verify(exactly = 0) { gistQueue.fetchUserMessages() } + + // SSE flag flips off while foregrounded+identified -> shouldUseSse false, timer null -> resume. + state = state.copy(sseEnabled = false) + sseEnabledListener(false) + + verify(timeout = 2000) { gistQueue.fetchUserMessages() } + } } From 8bcf3c92c465390ad811081064cac6c19f30d0a4 Mon Sep 17 00:00:00 2001 From: Mahmoud Elmorabea Date: Sun, 21 Jun 2026 15:28:27 +0400 Subject: [PATCH 4/4] fix: prevent orphaned polling timers under concurrent starts startPolling()/resetTimer() did `timer?.cancel(); timer = ...` without synchronization. startPolling is reachable concurrently from the main-thread foreground handler, the redux/event-bus fetchInAppMessages() path, and the attribute-subscription coroutines. Interleaved calls each created a Timer while only the last was retained in `timer`, orphaning the others: they kept polling forever (even while backgrounded) and resetTimer() could never cancel them, causing duplicate queue fetches that multiplied across foreground/identify/ interval-change events. Found via on-device testing (3+ poll ticks per interval, continuing after background). Make startPolling()/resetTimer() @Synchronized so cancel-then-assign is atomic and exactly one live timer ever exists; this also makes the `timer == null` resume guard truthful. Adds a concurrency regression test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../presentation/PollingLifecycleManager.kt | 7 ++++++ .../PollingLifecycleManagerTest.kt | 25 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/presentation/PollingLifecycleManager.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/presentation/PollingLifecycleManager.kt index 01cf4e40e..fff61dfa7 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/presentation/PollingLifecycleManager.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/presentation/PollingLifecycleManager.kt @@ -105,6 +105,12 @@ internal class PollingLifecycleManager( resetTimer() } + // @Synchronized so the cancel-then-assign is atomic. startPolling is reachable concurrently + // (main-thread foreground handler, redux/event-bus fetchInAppMessages, and the attribute + // subscription coroutines); without serialization two calls could each create a Timer while + // only the last is retained in `timer`, orphaning the other so it keeps polling forever and + // resetTimer() can never cancel it. + @Synchronized private fun startPolling(duration: Long, initialDelay: Long = 0) { val currentState = state // Only skip polling if SSE should be used (both flag enabled AND user identified) @@ -122,6 +128,7 @@ internal class PollingLifecycleManager( } } + @Synchronized private fun resetTimer() { timer?.cancel() timer = null diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/gist/presentation/PollingLifecycleManagerTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/gist/presentation/PollingLifecycleManagerTest.kt index 21fe97376..e0adfa2ed 100644 --- a/messaginginapp/src/test/java/io/customer/messaginginapp/gist/presentation/PollingLifecycleManagerTest.kt +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/gist/presentation/PollingLifecycleManagerTest.kt @@ -16,6 +16,8 @@ import io.mockk.just import io.mockk.mockk import io.mockk.slot import io.mockk.verify +import java.util.concurrent.atomic.AtomicInteger +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -190,6 +192,29 @@ class PollingLifecycleManagerTest : JUnitTest() { verify(exactly = 0) { logger.debug(match { it.contains("starting polling") }) } } + @Test + fun startPolling_underConcurrentCalls_leavesNoOrphanTimerAfterReset() { + // Regression for the orphaned-timer leak: concurrent startPolling calls (main-thread + // foreground + subscription coroutines + dismiss/identify fetch) must not leave any + // Timer alive after reset(). A fast interval surfaces orphans quickly. + val fetchCount = AtomicInteger(0) + every { gistQueue.fetchUserMessages() } answers { fetchCount.incrementAndGet() } + state = InAppMessagingState(pollInterval = 30L) + val manager = createManager() + + val threads = (1..8).map { Thread { repeat(25) { manager.fetchInAppMessages() } } } + threads.forEach(Thread::start) + threads.forEach(Thread::join) + + // Stop polling; after a settle window no further ticks should occur if exactly one timer + // existed (without the fix, orphaned timers keep firing every 30ms). + manager.reset() + Thread.sleep(100) // let any in-flight tick + cancellation settle + val countAfterReset = fetchCount.get() + Thread.sleep(300) // ~10 periods would elapse if an orphan timer were still alive + assertEquals(countAfterReset, fetchCount.get(), "Polling continued after reset - orphaned timer leak") + } + @Test fun sseAvailabilityChange_whenPollingStopped_resumesPolling() { // Start with SSE active so foregrounding does not start polling (timer stays null).