Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 @@ -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
Expand Down Expand Up @@ -48,9 +49,14 @@ internal val SDKComponent.inAppPreferenceStore: InAppPreferenceStore
internal val SDKComponent.inAppMessagingManager: InAppMessagingManager
get() = singleton<InAppMessagingManager> { InAppMessagingManager(inAppMessaging) }

internal val SDKComponent.gistSdk: GistSdk
get() = singleton<GistSdk> {
GistSdk(siteId = inAppModuleConfig.siteId, dataCenter = inAppModuleConfig.region.code)
internal val SDKComponent.pollingLifecycleManager: PollingLifecycleManager
get() = singleton<PollingLifecycleManager> {
PollingLifecycleManager(
inAppMessagingManager = inAppMessagingManager,
processLifecycleOwner = ProcessLifecycleOwner.get(),
gistQueue = gistQueue,
logger = logger
)
}

internal val SDKComponent.modalMessageParser: ModalMessageParser
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
package io.customer.messaginginapp.gist.presentation

import androidx.lifecycle.Lifecycle
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
Expand All @@ -13,9 +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 java.util.Timer
import kotlin.concurrent.timer
import kotlinx.coroutines.flow.filter

internal interface GistProvider {
fun setCurrentRoute(route: String)
Expand All @@ -38,126 +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

private val isAppForegrounded: Boolean
get() = ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)

private fun resetTimer() {
timer?.cancel()
timer = null
}

private fun onActivityResumed() {
logger.debug("GistSdk Activity resumed")
fetchInAppMessages(state.pollInterval)
}

private fun onActivityPaused() {
logger.debug("Activity paused, 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("GistSdk starting polling (sseEnabled=${currentState.sseEnabled}, isUserIdentified=${currentState.isUserIdentified}, interval=${duration}ms)")
timer?.cancel()
// create a timer to run the task after the initial run
timer = timer(name = "GistPolling", daemon = true, initialDelay = initialDelay, period = duration) {
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 -> {}
}
}
}

inAppMessagingManager.subscribeToAttribute({ it.pollInterval }) { interval ->
// Only manage polling when app is foregrounded
if (!isAppForegrounded) {
return@subscribeToAttribute
}

val currentState = state
if (currentState.shouldUseSse) {
return@subscribeToAttribute
}
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()
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
}

// 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
}

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
}
pollingLifecycleManager.fetchInAppMessages()
}

override fun setCurrentRoute(route: String) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Comment thread
mahmoud-elmorabea marked this conversation as resolved.
}

// 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copying feedback from Claude, would be nice to validate this:


This starts polling with initialDelay=0 (immediate fetch). On an anonymous→identified transition while SSE is disabled, this fires fetchUserMessages() at the same time as ModuleMessagingInApp's UserChangedEvent handler, which also calls fetchInAppMessages() — so we get two near-simultaneous fetches.

The old code deliberately avoided this: its isUserIdentified/sseEnabled subscriptions only stopped polling ("Starting polling is handled by … event handlers"). Same redundant restart happens on an sseEnabled false→true flip while the user is anonymous (shouldUseSse stays false → pointless timer reset + fetch).

Suggest restarting here without the immediate fetch (use initialDelay = currentState.pollInterval, letting the event handlers own the catch-up fetch), or guarding startPolling so it doesn't restart when a timer with the same interval is already running.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good callout, fixed!

}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -73,7 +73,7 @@ private fun handleMessageDismissal(logger: Logger, store: Store<InAppMessagingSt
logger.debug("Fetching in-app messages after message dismissal")

// When SSE is enabled, this won't fetch messages
SDKComponent.gistSdk.fetchInAppMessages()
SDKComponent.pollingLifecycleManager.fetchInAppMessages()
} else {
logger.debug("Message dismissed, not logging view for message: ${action.message}, shouldLog: ${action.shouldLog}, viaCloseAction: ${action.viaCloseAction}")
}
Expand Down
Loading
Loading