From 6ffdc9f7697a4d461c2afc78eb86bf874f088f5e Mon Sep 17 00:00:00 2001 From: Fadi George Date: Fri, 9 Oct 2026 15:17:57 -0700 Subject: [PATCH 1/3] feat: [SDK-5390] send a session end when a sessions API session ends When the session uses the sessions API, enqueue an end UpdateSessionOperation with the backgrounded-at time and final duration. In the background job, flush and wait for it with a timeout so Android 15+ doesn't block the request after the job finishes. Flag off leaves the legacy end unchanged. Co-Authored-By: Claude Opus 5.5 --- .../session/internal/session/SessionModel.kt | 9 + .../internal/session/impl/SessionService.kt | 73 ++++++-- .../internal/session/SessionServiceTests.kt | 157 +++++++++++++++++- 3 files changed, 221 insertions(+), 18 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/SessionModel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/SessionModel.kt index dc55546b3..f76f40408 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/SessionModel.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/SessionModel.kt @@ -46,6 +46,15 @@ class SessionModel : Model() { setLongProperty(::focusTime.name, value) } + /** + * When this app was last unfocused, in Unix time milliseconds. Null until the first unfocus. + */ + var unfocusTime: Long? + get() = getOptLongProperty(::unfocusTime.name) + set(value) { + setOptLongProperty(::unfocusTime.name, value) + } + /** * How long this session has spent as active, in milliseconds. */ diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/impl/SessionService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/impl/SessionService.kt index a6bbd0f5e..681f6fcac 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/impl/SessionService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/impl/SessionService.kt @@ -24,8 +24,10 @@ import com.onesignal.session.internal.session.ISessionService import com.onesignal.session.internal.session.SessionModel import com.onesignal.session.internal.session.SessionModelStore import com.onesignal.session.internal.session.operations.CreateSessionOperation +import com.onesignal.session.internal.session.operations.UpdateSessionOperation import com.onesignal.user.internal.backend.IdentityConstants import com.onesignal.user.internal.identity.IdentityModelStore +import kotlinx.coroutines.withTimeoutOrNull import java.util.UUID import kotlin.reflect.KMutableProperty1 @@ -132,21 +134,30 @@ internal class SessionService( * the SDK. */ override suspend fun backgroundRun() { - endSession() + val end = endSession() ?: return + // Android 15+ blocks network requests started after this job finishes, so send the end now + // rather than after the queue's batching delay. Flushing is bounded to once per session. + // On timeout the end stays queued and is sent on the next app open. + val sent = withTimeoutOrNull(END_SESSION_SEND_TIMEOUT_MS) { operationRepo.enqueueAndWait(end, flush = true) } + Logging.debug("SessionService.backgroundRun: session end sent: $sent") } - private fun endSession() { + /** + * @return the operation that ends the session through the sessions API, for the caller to enqueue. + */ + private fun endSession(): UpdateSessionOperation? { // Defensive: if bootstrap() has not run yet, there is no session state to end. // This can happen when SyncJobService races with // an in-flight initWithContext that has not yet reached bootstrapServices(). - val session = this.session ?: return - if (!session.isValid) return + val session = this.session?.takeIf { it.isValid } ?: return null val activeDuration = session.activeDuration Logging.debug("SessionService.backgroundRun: Session ended. activeDuration: $activeDuration") session.isValid = false + val end = if (session.usesSessionsApi) endSessionOperation(session, config?.appId) else null sessionLifeCycleNotifier.fire { it.onSessionEnded(activeDuration) } session.activeDuration = 0L + return end } /** @@ -184,7 +195,7 @@ internal class SessionService( // Treat app cold starts as a new session, we attempt to end any previous session to do this. if (!hasFocused) { hasFocused = true - endSession() + endSession()?.let { operationRepo.enqueue(it) } } if (!session.isValid) { @@ -243,6 +254,7 @@ internal class SessionService( unfocusTimeMs - session.focusTime } session.activeDuration += dt + session.unfocusTime = unfocusTimeMs Logging.log(LogLevel.DEBUG, "SessionService.onUnfocused adding time $dt for total: ${session.activeDuration}") } @@ -256,28 +268,59 @@ internal class SessionService( override val hasSubscribers: Boolean get() = sessionLifeCycleNotifier.hasSubscribers + + companion object { + // Covers the post-create delay when the create is still queued, plus the request itself. + private const val END_SESSION_SEND_TIMEOUT_MS = 30_000L + } } private fun createSessionOperation( session: SessionModel, appId: String?, externalId: String?, -): CreateSessionOperation? { +): CreateSessionOperation? = + withSessionIds(session, appId, "creating") { resolvedAppId, onesignalId, subscriptionId -> + CreateSessionOperation( + appId = resolvedAppId, + localSessionId = session.localSessionId, + onesignalId = onesignalId, + subscriptionId = subscriptionId, + startTime = session.startTime, + externalId = externalId, + ) + } + +private fun endSessionOperation( + session: SessionModel, + appId: String?, +): UpdateSessionOperation? = + withSessionIds(session, appId, "ending") { resolvedAppId, onesignalId, subscriptionId -> + UpdateSessionOperation( + appId = resolvedAppId, + sessionId = session.localSessionId, + onesignalId = onesignalId, + subscriptionId = subscriptionId, + activeDuration = session.activeDuration, + // An app killed while focused has no unfocus after its last focus. + endTime = maxOf(session.unfocusTime ?: 0L, session.focusTime), + ) + } + +private fun withSessionIds( + session: SessionModel, + appId: String?, + action: String, + block: (appId: String, onesignalId: String, subscriptionId: String) -> T, +): T? { val onesignalId = session.onesignalId val subscriptionId = session.subscriptionId if (appId == null || onesignalId == null || subscriptionId == null) { Logging.warn( - "SessionService: not creating session ${session.sessionId}, missing appId: ${appId == null}, " + + "SessionService: not $action session ${session.sessionId}, missing appId: ${appId == null}, " + "onesignalId: ${onesignalId == null}, subscriptionId: ${subscriptionId == null}", ) return null } - return CreateSessionOperation( - appId = appId, - localSessionId = session.localSessionId, - onesignalId = onesignalId, - subscriptionId = subscriptionId, - startTime = session.startTime, - externalId = externalId, - ) + return block(appId, onesignalId, subscriptionId) } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/SessionServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/SessionServiceTests.kt index 39b33e2fe..00eed18c4 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/SessionServiceTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/SessionServiceTests.kt @@ -19,11 +19,14 @@ import com.onesignal.mocks.MockHelper import com.onesignal.mocks.MockPreferencesService import com.onesignal.session.internal.session.impl.SessionService import com.onesignal.session.internal.session.operations.CreateSessionOperation +import com.onesignal.session.internal.session.operations.UpdateSessionOperation import com.onesignal.user.internal.backend.IdentityConstants import com.onesignal.user.internal.identity.IdentityModel import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -37,19 +40,21 @@ import io.mockk.unmockkStatic import io.mockk.verify import io.mockk.verifyOrder import kotlinx.coroutines.Job +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.test.runTest // Mocks used by every test in this file private class Mocks( var sessionsApiEnabled: Boolean = false, ) { - val currentTime = 1111L + var currentTime = 1111L var elapsedRealtime = 5000L private val mockSessionModelStore = MockHelper.sessionModelStore() val time: ITime = mockk().also { - every { it.currentTimeMillis } returns currentTime + every { it.currentTimeMillis } answers { currentTime } every { it.elapsedRealtimeMillis } answers { elapsedRealtime } } @@ -94,6 +99,7 @@ private class Mocks( val operationRepo: IOperationRepo = mockk().also { every { it.enqueue(capture(enqueued), any()) } just runs + coEvery { it.enqueueAndWait(capture(enqueued), any()) } returns true } fun sessionModelStore(action: ((SessionModel) -> Unit)? = null): SessionModelStore { @@ -491,7 +497,7 @@ class SessionServiceTests : FunSpec({ sessionService.onFocus(false) // Then - val creates = mocks.enqueued.map { it as CreateSessionOperation } + val creates = mocks.enqueued.filterIsInstance() creates.size shouldBe 2 creates.map { it.localSessionId }.distinct().size shouldBe 2 creates.map { it.idempotencyKey }.distinct().size shouldBe 2 @@ -570,6 +576,149 @@ class SessionServiceTests : FunSpec({ verify(exactly = 1) { mocks.spyCallback.onSessionStarted() } } + test("background run sends the end with the unfocus time and final duration when the flag is on") { + // Given + val mocks = Mocks(sessionsApiEnabled = true) + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + val sessionModelStore = mocks.sessionModelStore { it.isValid = false } + sessionService.onFocus(false) + mocks.elapsedRealtime += 750L + mocks.currentTime = 2222L + sessionService.onUnfocused() + mocks.currentTime = 32222L + + // When + sessionService.backgroundRun() + + // Then + val end = mocks.enqueued.filterIsInstance().single() + end.appId shouldBe MockHelper.DEFAULT_APP_ID + end.sessionId shouldBe sessionModelStore.model.localSessionId + end.onesignalId shouldBe Mocks.ONESIGNAL_ID + end.subscriptionId shouldBe Mocks.SUBSCRIPTION_ID + end.activeDuration shouldBe 750L + end.endTime shouldBe 2222L + coVerify(exactly = 1) { mocks.operationRepo.enqueueAndWait(end, true) } + sessionModelStore.model.isValid shouldBe false + } + + test("background run returns when the end is not sent in time and leaves it queued") { + // Given + val mocks = Mocks(sessionsApiEnabled = true) + coEvery { mocks.operationRepo.enqueueAndWait(any(), any()) } coAnswers { awaitCancellation() } + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + val sessionModelStore = mocks.sessionModelStore { it.isValid = false } + sessionService.onFocus(false) + sessionService.onUnfocused() + + // When + runTest { sessionService.backgroundRun() } + + // Then + coVerify(exactly = 1) { mocks.operationRepo.enqueueAndWait(any(), true) } + sessionModelStore.model.isValid shouldBe false + } + + test("cold start ends the previous sessions API session before creating a new one") { + // Given + val mocks = Mocks(sessionsApiEnabled = true) + mocks.sessionModelStore { + it.isValid = true + it.sessionId = "previous-session" + it.localSessionId = "local-previous-session" + it.usesSessionsApi = true + it.onesignalId = Mocks.ONESIGNAL_ID + it.subscriptionId = Mocks.SUBSCRIPTION_ID + it.focusTime = 500L + it.unfocusTime = 900L + it.activeDuration = 400L + } + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + + // When + sessionService.onFocus(false) + + // Then + val end = mocks.enqueued[0] as UpdateSessionOperation + end.sessionId shouldBe "local-previous-session" + end.activeDuration shouldBe 400L + end.endTime shouldBe 900L + (mocks.enqueued[1] is CreateSessionOperation) shouldBe true + coVerify(exactly = 0) { mocks.operationRepo.enqueueAndWait(any(), any()) } + } + + test("cold start after a kill while focused ends the previous session at its last focus") { + // Given + val mocks = Mocks(sessionsApiEnabled = true) + mocks.sessionModelStore { + it.isValid = true + it.sessionId = "previous-session" + it.localSessionId = "local-previous-session" + it.usesSessionsApi = true + it.onesignalId = Mocks.ONESIGNAL_ID + it.subscriptionId = Mocks.SUBSCRIPTION_ID + it.unfocusTime = 300L + it.focusTime = 500L + } + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + + // When + sessionService.onFocus(false) + + // Then + val end = mocks.enqueued.filterIsInstance().single() + end.endTime shouldBe 500L + } + + test("session end does not send an end when the session uses the legacy path") { + // Given + val mocks = Mocks(sessionsApiEnabled = false) + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + mocks.sessionModelStore { it.isValid = false } + sessionService.subscribe(mocks.spyCallback) + sessionService.onFocus(false) + sessionService.onUnfocused() + mocks.sessionsApiEnabled = true + + // When + sessionService.backgroundRun() + + // Then + mocks.enqueued shouldBe emptyList() + coVerify(exactly = 0) { mocks.operationRepo.enqueueAndWait(any(), any()) } + verify(exactly = 1) { mocks.spyCallback.onSessionEnded(any()) } + } + + test("session end does not send an end without a push subscription ID") { + // Given + val mocks = Mocks(sessionsApiEnabled = true) + mocks.configModelStore.model.pushSubscriptionId = null + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + mocks.sessionModelStore { it.isValid = false } + sessionService.subscribe(mocks.spyCallback) + sessionService.onFocus(false) + sessionService.onUnfocused() + + // When + sessionService.backgroundRun() + + // Then + mocks.enqueued shouldBe emptyList() + verify(exactly = 1) { mocks.spyCallback.onSessionEnded(any()) } + } + test("sessions API active duration uses the monotonic clock") { // Given val mocks = Mocks(sessionsApiEnabled = true) @@ -637,6 +786,7 @@ class SessionServiceTests : FunSpec({ store.model.sessionId = "session-id" store.model.startTime = 123L store.model.activeDuration = 456L + store.model.unfocusTime = 321L store.model.focusElapsedRealtime = 789L store.model.usesSessionsApi = true store.model.onesignalId = Mocks.ONESIGNAL_ID @@ -649,6 +799,7 @@ class SessionServiceTests : FunSpec({ reloaded.sessionId shouldBe "session-id" reloaded.startTime shouldBe 123L reloaded.activeDuration shouldBe 456L + reloaded.unfocusTime shouldBe 321L reloaded.focusElapsedRealtime shouldBe 789L reloaded.usesSessionsApi shouldBe true reloaded.onesignalId shouldBe Mocks.ONESIGNAL_ID From e06a1daf7fdc2ff76384b7630baef5ab12cf5da3 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 20 Oct 2026 15:20:59 -0700 Subject: [PATCH 2/3] fix: [SDK-5390] wait for the session end on IO and clear unfocus time on a new session BackgroundManager runs background services on Dispatchers.Unconfined, so the rest of its loop resumed on the operation repo thread after the end was sent. Co-Authored-By: Claude Opus 5.5 --- .../internal/session/impl/SessionService.kt | 10 ++++- .../internal/session/SessionServiceTests.kt | 42 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/impl/SessionService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/impl/SessionService.kt index 681f6fcac..f86b103a1 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/impl/SessionService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/impl/SessionService.kt @@ -27,6 +27,8 @@ import com.onesignal.session.internal.session.operations.CreateSessionOperation import com.onesignal.session.internal.session.operations.UpdateSessionOperation import com.onesignal.user.internal.backend.IdentityConstants import com.onesignal.user.internal.identity.IdentityModelStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import java.util.UUID import kotlin.reflect.KMutableProperty1 @@ -138,7 +140,12 @@ internal class SessionService( // Android 15+ blocks network requests started after this job finishes, so send the end now // rather than after the queue's batching delay. Flushing is bounded to once per session. // On timeout the end stays queued and is sent on the next app open. - val sent = withTimeoutOrNull(END_SESSION_SEND_TIMEOUT_MS) { operationRepo.enqueueAndWait(end, flush = true) } + // BackgroundManager runs this on Dispatchers.Unconfined, so without the switch the rest of its + // loop would resume on the operation repo thread that wakes the wait. + val sent = + withTimeoutOrNull(END_SESSION_SEND_TIMEOUT_MS) { + withContext(Dispatchers.IO) { operationRepo.enqueueAndWait(end, flush = true) } + } Logging.debug("SessionService.backgroundRun: session end sent: $sent") } @@ -205,6 +212,7 @@ internal class SessionService( session.startTime = focusTimeMs session.focusTime = session.startTime session.focusElapsedRealtime = focusElapsedMs + session.unfocusTime = null session.usesSessionsApi = featureManager.isEnabled(FeatureFlag.SDK_SESSIONS_V2_API_CUTOVER) session.onesignalId = identityModelStore.model diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/SessionServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/SessionServiceTests.kt index 00eed18c4..dfabfe3b7 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/SessionServiceTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/SessionServiceTests.kt @@ -322,6 +322,25 @@ class SessionServiceTests : FunSpec({ sessionModelStore.model.usesSessionsApi shouldBe true } + test("new session clears the previous session's unfocus time") { + // Given + val mocks = Mocks(sessionsApiEnabled = true) + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + val sessionModelStore = + mocks.sessionModelStore { + it.isValid = false + it.unfocusTime = 900L + } + + // When + sessionService.onFocus(false) + + // Then + sessionModelStore.model.unfocusTime shouldBe null + } + test("new session pins onesignal and subscription IDs and a new local session ID") { // Given val mocks = Mocks() @@ -604,6 +623,29 @@ class SessionServiceTests : FunSpec({ sessionModelStore.model.isValid shouldBe false } + test("background run waits for the end on an IO thread rather than the caller's") { + // Given + val mocks = Mocks(sessionsApiEnabled = true) + var waitThread: Thread? = null + coEvery { mocks.operationRepo.enqueueAndWait(any(), any()) } coAnswers { + waitThread = Thread.currentThread() + true + } + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + mocks.sessionModelStore { it.isValid = false } + sessionService.onFocus(false) + sessionService.onUnfocused() + + // When + sessionService.backgroundRun() + + // Then + waitThread shouldNotBe null + waitThread shouldNotBe Thread.currentThread() + } + test("background run returns when the end is not sent in time and leaves it queued") { // Given val mocks = Mocks(sessionsApiEnabled = true) From 8aaf8858d4f6c67a07cb31a629b5617d1575e96c Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 21 Oct 2026 15:33:57 -0700 Subject: [PATCH 3/3] test: [SDK-5390] keep the session end timeout test on the virtual clock runTest advanced past the 30s timeout while the send was parked on the real IO dispatcher, so the wait sometimes never started. Co-Authored-By: Claude Opus 5.5 --- .../internal/session/impl/SessionService.kt | 7 ++++++- .../internal/session/SessionServiceTests.kt | 15 +++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/impl/SessionService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/impl/SessionService.kt index f86b103a1..246b48148 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/impl/SessionService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/impl/SessionService.kt @@ -27,6 +27,7 @@ import com.onesignal.session.internal.session.operations.CreateSessionOperation import com.onesignal.session.internal.session.operations.UpdateSessionOperation import com.onesignal.user.internal.backend.IdentityConstants import com.onesignal.user.internal.identity.IdentityModelStore +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull @@ -53,6 +54,10 @@ internal class SessionService( private val identityModelStore: IdentityModelStore, private val operationRepo: IOperationRepo, ) : ISessionService, IBootstrapService, IStartableService, IBackgroundService, IApplicationLifecycleHandler { + // Not a constructor parameter, since the service container can only inject registered services. + // Tests replace it with a test dispatcher so the end send stays on the virtual clock. + internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO + override val startTime: Long // Pre-bootstrap default returns "now" so call sites computing `_time.currentTimeMillis - startTime` // (e.g. IAM session-duration / SESSION_TIME triggers) see ~0ms elapsed instead of ~58 years @@ -144,7 +149,7 @@ internal class SessionService( // loop would resume on the operation repo thread that wakes the wait. val sent = withTimeoutOrNull(END_SESSION_SEND_TIMEOUT_MS) { - withContext(Dispatchers.IO) { operationRepo.enqueueAndWait(end, flush = true) } + withContext(ioDispatcher) { operationRepo.enqueueAndWait(end, flush = true) } } Logging.debug("SessionService.backgroundRun: session end sent: $sent") } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/SessionServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/SessionServiceTests.kt index dfabfe3b7..b19465312 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/SessionServiceTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/SessionServiceTests.kt @@ -41,6 +41,7 @@ import io.mockk.verify import io.mockk.verifyOrder import kotlinx.coroutines.Job import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest // Mocks used by every test in this file @@ -657,11 +658,17 @@ class SessionServiceTests : FunSpec({ sessionService.onFocus(false) sessionService.onUnfocused() - // When - runTest { sessionService.backgroundRun() } + runTest { + sessionService.ioDispatcher = StandardTestDispatcher(testScheduler) - // Then - coVerify(exactly = 1) { mocks.operationRepo.enqueueAndWait(any(), true) } + // When + sessionService.backgroundRun() + + // Then + testScheduler.currentTime shouldBe 30_000L + } + coVerify(exactly = 1) { mocks.operationRepo.enqueueAndWait(ofType(), true) } + coVerify(exactly = 0) { mocks.operationRepo.enqueue(ofType(), any()) } sessionModelStore.model.isValid shouldBe false }