From 39e25369eee748b47004ce1b0de8e5ec6c9f1a1d Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 6 Oct 2026 10:50:34 -0700 Subject: [PATCH 1/8] feat: [SDK-5369] capture sessions v2 flag and pinned IDs on SessionModel Co-Authored-By: Claude Opus 5.5 (1M context) --- OneSignal-KMP-SDK | 2 +- .../com/onesignal/core/internal/time/ITime.kt | 6 + .../onesignal/core/internal/time/impl/Time.kt | 2 + .../session/internal/session/SessionModel.kt | 48 +++++ .../internal/session/impl/SessionService.kt | 35 +++- .../internal/session/SessionServiceTests.kt | 192 +++++++++++++++++- .../java/com/onesignal/mocks/MockHelper.kt | 6 +- 7 files changed, 283 insertions(+), 8 deletions(-) diff --git a/OneSignal-KMP-SDK b/OneSignal-KMP-SDK index ee61d46732..b2869fc165 160000 --- a/OneSignal-KMP-SDK +++ b/OneSignal-KMP-SDK @@ -1 +1 @@ -Subproject commit ee61d4673265876e5cab6328b7a0745f79b7a5aa +Subproject commit b2869fc165724656e74787cee3052bfffc89a9ae diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/ITime.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/ITime.kt index 8f1824d481..0c20a01f1a 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/ITime.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/ITime.kt @@ -15,4 +15,10 @@ interface ITime { * Returns how long the app has been running. */ val processUptimeMillis: Long + + /** + * Milliseconds since boot, including deep sleep. Monotonic, so safe for measuring + * durations when the wall clock is changed. Resets on reboot. + */ + val elapsedRealtimeMillis: Long } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/impl/Time.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/impl/Time.kt index 753ef124d5..10e80493b9 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/impl/Time.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/impl/Time.kt @@ -11,4 +11,6 @@ internal class Time : ITime { override val processUptimeMillis: Long @RequiresApi(Build.VERSION_CODES.N) get() = SystemClock.uptimeMillis() - android.os.Process.getStartUptimeMillis() + override val elapsedRealtimeMillis: Long + get() = SystemClock.elapsedRealtime() } 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 13c31ec50c..9a59b2644a 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 @@ -54,4 +54,52 @@ class SessionModel : Model() { set(value) { setLongProperty(::activeDuration.name, value) } + + /** + * [focusTime] from a monotonic clock ([com.onesignal.core.internal.time.ITime.elapsedRealtimeMillis]). + * Used instead of [focusTime] to measure [activeDuration] when [isSessionsV2Enabled]. + */ + var focusElapsedRealtime: Long + get() = getLongProperty(::focusElapsedRealtime.name) { 0L } + set(value) { + setLongProperty(::focusElapsedRealtime.name, value) + } + + /** + * Value of [com.onesignal.features.FeatureFlag.SDK_SESSIONS_V2] captured when this session + * started. Fixed for the life of the session so it never mixes the legacy and v2 paths. + */ + var isSessionsV2Enabled: Boolean + get() = getBooleanProperty(::isSessionsV2Enabled.name) { false } + set(value) { + setBooleanProperty(::isSessionsV2Enabled.name, value) + } + + /** + * The OneSignal ID when this session started. Session updates keep using it after a + * login or user switch. + */ + var onesignalId: String? + get() = getOptStringProperty(::onesignalId.name) + set(value) { + setOptStringProperty(::onesignalId.name, value) + } + + /** + * The push subscription ID when this session started. Pinned like [onesignalId]. + */ + var subscriptionId: String? + get() = getOptStringProperty(::subscriptionId.name) + set(value) { + setOptStringProperty(::subscriptionId.name, value) + } + + /** + * The ID the backend assigned to this session, once known. + */ + var serverSessionId: String? + get() = getOptStringProperty(::serverSessionId.name) + set(value) { + setOptStringProperty(::serverSessionId.name, value) + } } 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 d3d3e14664..78dc8a132d 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 @@ -7,15 +7,19 @@ import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.background.IBackgroundService import com.onesignal.core.internal.config.ConfigModel import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.core.internal.features.IFeatureManager import com.onesignal.core.internal.startup.IBootstrapService import com.onesignal.core.internal.startup.IStartableService import com.onesignal.core.internal.time.ITime import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging +import com.onesignal.features.FeatureFlag import com.onesignal.session.internal.session.ISessionLifecycleHandler import com.onesignal.session.internal.session.ISessionService import com.onesignal.session.internal.session.SessionModel import com.onesignal.session.internal.session.SessionModelStore +import com.onesignal.user.internal.backend.IdentityConstants +import com.onesignal.user.internal.identity.IdentityModelStore import java.util.UUID /** @@ -34,6 +38,8 @@ internal class SessionService( private val _configModelStore: ConfigModelStore, private val _sessionModelStore: SessionModelStore, private val _time: ITime, + private val _featureManager: IFeatureManager, + private val _identityModelStore: IdentityModelStore, ) : ISessionService, IBootstrapService, IStartableService, IBackgroundService, IApplicationLifecycleHandler { override val startTime: Long // Pre-bootstrap default returns "now" so call sites computing `_time.currentTimeMillis - startTime` @@ -107,14 +113,16 @@ internal class SessionService( // Capture focus time on the caller's thread so session timestamps reflect lifecycle // arrival, not dispatcher latency (SDK-4506). val focusTimeMs = _time.currentTimeMillis + val focusElapsedMs = _time.elapsedRealtimeMillis runOnSerialIO { - handleOnFocus(firedOnSubscribe, focusTimeMs) + handleOnFocus(firedOnSubscribe, focusTimeMs, focusElapsedMs) } } private fun handleOnFocus( firedOnSubscribe: Boolean, focusTimeMs: Long, + focusElapsedMs: Long, ) { Logging.log(LogLevel.DEBUG, "SessionService.onFocus() - fired from start: $firedOnSubscribe") @@ -136,6 +144,14 @@ internal class SessionService( session.sessionId = UUID.randomUUID().toString() session.startTime = focusTimeMs session.focusTime = session.startTime + session.focusElapsedRealtime = focusElapsedMs + session.isSessionsV2Enabled = _featureManager.isEnabled(FeatureFlag.SDK_SESSIONS_V2) + session.onesignalId = + _identityModelStore.model + .takeIf { it.hasProperty(IdentityConstants.ONESIGNAL_ID) } + ?.onesignalId + session.subscriptionId = config?.pushSubscriptionId + session.serverSessionId = null session.isValid = true Logging.debug("SessionService: New session started at ${session.startTime}") sessionLifeCycleNotifier.fire { it.onSessionStarted() } @@ -143,6 +159,7 @@ internal class SessionService( // existing session: just remember the focus time so we can calculate the active time // when onUnfocused is called. session.focusTime = focusTimeMs + session.focusElapsedRealtime = focusElapsedMs sessionLifeCycleNotifier.fire { it.onSessionActive() } } } @@ -150,19 +167,29 @@ internal class SessionService( override fun onUnfocused() { // Capture on the caller's thread so activeDuration is unaffected by dispatcher latency. val unfocusTimeMs = _time.currentTimeMillis + val unfocusElapsedMs = _time.elapsedRealtimeMillis runOnSerialIO { - handleOnUnfocused(unfocusTimeMs) + handleOnUnfocused(unfocusTimeMs, unfocusElapsedMs) } } - private fun handleOnUnfocused(unfocusTimeMs: Long) { + private fun handleOnUnfocused( + unfocusTimeMs: Long, + unfocusElapsedMs: Long, + ) { val session = this.session if (session == null) { Logging.warn("SessionService.onUnfocused called before bootstrap; ignoring.") return } // capture the amount of time the app was focused - val dt = unfocusTimeMs - session.focusTime + val dt = + if (session.isSessionsV2Enabled) { + // elapsedRealtime resets on reboot; drop the interval rather than count a negative one. + (unfocusElapsedMs - session.focusElapsedRealtime).coerceAtLeast(0L) + } else { + unfocusTimeMs - session.focusTime + } session.activeDuration += dt Logging.log(LogLevel.DEBUG, "SessionService.onUnfocused adding time $dt for total: ${session.activeDuration}") } 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 675fbda28f..d9dc48b317 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 @@ -2,8 +2,12 @@ package com.onesignal.session.internal.session import com.onesignal.common.threading.OneSignalDispatchers import com.onesignal.common.threading.runOnSerialIO +import com.onesignal.core.internal.features.IFeatureManager +import com.onesignal.core.internal.time.ITime +import com.onesignal.features.FeatureFlag import com.onesignal.mocks.IOMockHelper import com.onesignal.mocks.MockHelper +import com.onesignal.mocks.MockPreferencesService import com.onesignal.session.internal.session.impl.SessionService import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe @@ -21,20 +25,49 @@ import io.mockk.verifyOrder import kotlinx.coroutines.Job // Mocks used by every test in this file -private class Mocks { +private class Mocks( + var sessionsV2Enabled: Boolean = false, +) { val currentTime = 1111L + var elapsedRealtime = 5000L private val mockSessionModelStore = MockHelper.sessionModelStore() + val time: ITime = + mockk().also { + every { it.currentTimeMillis } returns currentTime + every { it.elapsedRealtimeMillis } answers { elapsedRealtime } + } + + val featureManager: IFeatureManager = + mockk().also { + every { it.isEnabled(FeatureFlag.SDK_SESSIONS_V2) } answers { sessionsV2Enabled } + } + + val identityModelStore = MockHelper.identityModelStore { it.onesignalId = ONESIGNAL_ID } + val configModelStore = MockHelper.configModelStore { it.pushSubscriptionId = SUBSCRIPTION_ID } + fun sessionModelStore(action: ((SessionModel) -> Unit)? = null): SessionModelStore { if (action != null) action(mockSessionModelStore.model) return mockSessionModelStore } val sessionService = - SessionService(MockHelper.applicationService(), MockHelper.configModelStore(), mockSessionModelStore, MockHelper.time(currentTime)) + SessionService( + MockHelper.applicationService(), + configModelStore, + mockSessionModelStore, + time, + featureManager, + identityModelStore, + ) val spyCallback = spyk() + + companion object { + const val ONESIGNAL_ID = "onesignal-id" + const val SUBSCRIPTION_ID = "subscription-id" + } } class SessionServiceTests : FunSpec({ @@ -183,6 +216,161 @@ class SessionServiceTests : FunSpec({ verify(exactly = 0) { mocks.spyCallback.onSessionEnded(any()) } } + test("new session captures the sessions v2 flag when on") { + // Given + val mocks = Mocks(sessionsV2Enabled = true) + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + val sessionModelStore = mocks.sessionModelStore { it.isValid = false } + + // When + sessionService.onFocus(false) + + // Then + sessionModelStore.model.isSessionsV2Enabled shouldBe true + } + + test("new session captures the sessions v2 flag when off") { + // Given + val mocks = Mocks(sessionsV2Enabled = false) + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + val sessionModelStore = mocks.sessionModelStore { it.isValid = false } + + // When + sessionService.onFocus(false) + + // Then + sessionModelStore.model.isSessionsV2Enabled shouldBe false + } + + test("sessions v2 flag does not change mid-session") { + // Given + val mocks = Mocks(sessionsV2Enabled = true) + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + val sessionModelStore = mocks.sessionModelStore { it.isValid = false } + sessionService.onFocus(false) + + // When + mocks.sessionsV2Enabled = false + sessionService.onUnfocused() + sessionService.onFocus(false) + + // Then + sessionModelStore.model.isSessionsV2Enabled shouldBe true + } + + test("new session pins onesignal and subscription IDs and clears the server session ID") { + // Given + val mocks = Mocks() + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + val sessionModelStore = + mocks.sessionModelStore { + it.isValid = false + it.serverSessionId = "previous-server-session" + } + + // When + sessionService.onFocus(false) + + // Then + sessionModelStore.model.onesignalId shouldBe Mocks.ONESIGNAL_ID + sessionModelStore.model.subscriptionId shouldBe Mocks.SUBSCRIPTION_ID + sessionModelStore.model.serverSessionId shouldBe null + } + + test("pinned IDs stay the same after login within a session") { + // Given + val mocks = Mocks() + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + val sessionModelStore = mocks.sessionModelStore { it.isValid = false } + sessionService.onFocus(false) + + // When + mocks.identityModelStore.model.onesignalId = "new-user-id" + mocks.configModelStore.model.pushSubscriptionId = "new-subscription-id" + sessionService.onUnfocused() + sessionService.onFocus(false) + + // Then + sessionModelStore.model.onesignalId shouldBe Mocks.ONESIGNAL_ID + sessionModelStore.model.subscriptionId shouldBe Mocks.SUBSCRIPTION_ID + } + + test("sessions v2 active duration uses the monotonic clock") { + // Given + val mocks = Mocks(sessionsV2Enabled = true) + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + val sessionModelStore = mocks.sessionModelStore { it.isValid = false } + sessionService.onFocus(false) + + // When + mocks.elapsedRealtime += 750L + sessionService.onUnfocused() + + // Then + sessionModelStore.model.activeDuration shouldBe 750L + } + + test("sessions v2 active duration ignores an interval spanning a reboot") { + // Given + val mocks = Mocks(sessionsV2Enabled = true) + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + val sessionModelStore = + mocks.sessionModelStore { + it.isValid = true + it.isSessionsV2Enabled = true + it.focusElapsedRealtime = 10_000L + it.activeDuration = 200L + } + + // When + mocks.elapsedRealtime = 100L + sessionService.onUnfocused() + + // Then + sessionModelStore.model.activeDuration shouldBe 200L + } + + test("session model fields survive a reload from preferences") { + // Given + val prefs = MockPreferencesService() + val store = SessionModelStore(prefs) + store.model.sessionId = "session-id" + store.model.startTime = 123L + store.model.activeDuration = 456L + store.model.focusElapsedRealtime = 789L + store.model.isSessionsV2Enabled = true + store.model.onesignalId = Mocks.ONESIGNAL_ID + store.model.subscriptionId = Mocks.SUBSCRIPTION_ID + store.model.serverSessionId = "server-session-id" + + // When + val reloaded = SessionModelStore(prefs).model + + // Then + reloaded.sessionId shouldBe "session-id" + reloaded.startTime shouldBe 123L + reloaded.activeDuration shouldBe 456L + reloaded.focusElapsedRealtime shouldBe 789L + reloaded.isSessionsV2Enabled shouldBe true + reloaded.onesignalId shouldBe Mocks.ONESIGNAL_ID + reloaded.subscriptionId shouldBe Mocks.SUBSCRIPTION_ID + reloaded.serverSessionId shouldBe "server-session-id" + } + test("onFocus dispatches the session-mutation body through runOnSerialIO (SDK-4508)") { // SDK-4508: SessionService.onFocus runs on the main thread via // ApplicationService.handleFocus -> applicationLifecycleNotifier.fire. Its body fires diff --git a/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/MockHelper.kt b/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/MockHelper.kt index 500b736e79..21c28c2080 100644 --- a/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/MockHelper.kt +++ b/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/MockHelper.kt @@ -24,9 +24,13 @@ import java.util.UUID * Singleton which provides common mock services. */ object MockHelper { - fun time(time: Long): ITime { + fun time( + time: Long, + elapsedRealtime: Long = time, + ): ITime { val mockTime = mockk() every { mockTime.currentTimeMillis } returns time + every { mockTime.elapsedRealtimeMillis } returns elapsedRealtime return mockTime } From c445554424af9bc1abc8b7d72819ed34b19508fe Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 6 Oct 2026 12:00:55 -0700 Subject: [PATCH 2/8] feat: [SDK-5369] rename flag to sdk_sessions_v2_api_cutover Co-Authored-By: Claude Opus 5.5 (1M context) --- OneSignal-KMP-SDK | 2 +- .../onesignal/session/internal/session/SessionModel.kt | 2 +- .../session/internal/session/impl/SessionService.kt | 8 ++++---- .../session/internal/session/SessionServiceTests.kt | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/OneSignal-KMP-SDK b/OneSignal-KMP-SDK index b2869fc165..a0fdbc0c54 160000 --- a/OneSignal-KMP-SDK +++ b/OneSignal-KMP-SDK @@ -1 +1 @@ -Subproject commit b2869fc165724656e74787cee3052bfffc89a9ae +Subproject commit a0fdbc0c54407905db3e5c233e1dac608004654f 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 9a59b2644a..def8cafeba 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 @@ -66,7 +66,7 @@ class SessionModel : Model() { } /** - * Value of [com.onesignal.features.FeatureFlag.SDK_SESSIONS_V2] captured when this session + * Value of [com.onesignal.features.FeatureFlag.SDK_SESSIONS_V2_API_CUTOVER] captured when this session * started. Fixed for the life of the session so it never mixes the legacy and v2 paths. */ var isSessionsV2Enabled: Boolean 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 78dc8a132d..09005295c3 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 @@ -38,8 +38,8 @@ internal class SessionService( private val _configModelStore: ConfigModelStore, private val _sessionModelStore: SessionModelStore, private val _time: ITime, - private val _featureManager: IFeatureManager, - private val _identityModelStore: IdentityModelStore, + private val featureManager: IFeatureManager, + private val identityModelStore: IdentityModelStore, ) : ISessionService, IBootstrapService, IStartableService, IBackgroundService, IApplicationLifecycleHandler { override val startTime: Long // Pre-bootstrap default returns "now" so call sites computing `_time.currentTimeMillis - startTime` @@ -145,9 +145,9 @@ internal class SessionService( session.startTime = focusTimeMs session.focusTime = session.startTime session.focusElapsedRealtime = focusElapsedMs - session.isSessionsV2Enabled = _featureManager.isEnabled(FeatureFlag.SDK_SESSIONS_V2) + session.isSessionsV2Enabled = featureManager.isEnabled(FeatureFlag.SDK_SESSIONS_V2_API_CUTOVER) session.onesignalId = - _identityModelStore.model + identityModelStore.model .takeIf { it.hasProperty(IdentityConstants.ONESIGNAL_ID) } ?.onesignalId session.subscriptionId = config?.pushSubscriptionId 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 d9dc48b317..b722064ca5 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,7 +41,7 @@ private class Mocks( val featureManager: IFeatureManager = mockk().also { - every { it.isEnabled(FeatureFlag.SDK_SESSIONS_V2) } answers { sessionsV2Enabled } + every { it.isEnabled(FeatureFlag.SDK_SESSIONS_V2_API_CUTOVER) } answers { sessionsV2Enabled } } val identityModelStore = MockHelper.identityModelStore { it.onesignalId = ONESIGNAL_ID } From 6030eedda1b7737ada2711b7708aab943800a352 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 6 Oct 2026 12:53:45 -0700 Subject: [PATCH 3/8] docs: [SDK-5369] read the session-captured sessions v2 value Co-Authored-By: Claude Opus 5.5 (1M context) --- OneSignal-KMP-SDK | 2 +- .../java/com/onesignal/session/internal/session/SessionModel.kt | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/OneSignal-KMP-SDK b/OneSignal-KMP-SDK index a0fdbc0c54..f990ab7705 160000 --- a/OneSignal-KMP-SDK +++ b/OneSignal-KMP-SDK @@ -1 +1 @@ -Subproject commit a0fdbc0c54407905db3e5c233e1dac608004654f +Subproject commit f990ab77056f558ed6a88e6f6eb3cdbb5bcc49a0 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 def8cafeba..44b5570ada 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 @@ -68,6 +68,7 @@ class SessionModel : Model() { /** * Value of [com.onesignal.features.FeatureFlag.SDK_SESSIONS_V2_API_CUTOVER] captured when this session * started. Fixed for the life of the session so it never mixes the legacy and v2 paths. + * Read this rather than the feature manager, whose value can change mid-session. */ var isSessionsV2Enabled: Boolean get() = getBooleanProperty(::isSessionsV2Enabled.name) { false } From 61d453d3c8fe7a1ca7f11ecbcc92e780cd8690e8 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 6 Oct 2026 14:38:36 -0700 Subject: [PATCH 4/8] fix: [SDK-5369] replace pinned local IDs with backend IDs Co-Authored-By: Claude Opus 5.5 (1M context) --- .../internal/session/impl/SessionService.kt | 37 ++++++ .../internal/session/SessionServiceTests.kt | 109 ++++++++++++++++++ 2 files changed, 146 insertions(+) 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 09005295c3..bfc3afebaf 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 @@ -1,6 +1,10 @@ package com.onesignal.session.internal.session.impl +import com.onesignal.common.IDManager import com.onesignal.common.events.EventProducer +import com.onesignal.common.modeling.ISingletonModelStoreChangeHandler +import com.onesignal.common.modeling.Model +import com.onesignal.common.modeling.ModelChangedArgs import com.onesignal.common.threading.runOnSerialIO import com.onesignal.core.internal.application.IApplicationLifecycleHandler import com.onesignal.core.internal.application.IApplicationService @@ -21,6 +25,7 @@ import com.onesignal.session.internal.session.SessionModelStore import com.onesignal.user.internal.backend.IdentityConstants import com.onesignal.user.internal.identity.IdentityModelStore import java.util.UUID +import kotlin.reflect.KMutableProperty1 /** * The implementation for [ISessionService] will continue a session as long as the app remains @@ -76,6 +81,38 @@ internal class SessionService( override fun start() { _applicationService.addApplicationLifecycleHandler(this) + identityModelStore.subscribe(PinnedLocalIdTranslator(IdentityConstants.ONESIGNAL_ID, SessionModel::onesignalId)) + _configModelStore.subscribe(PinnedLocalIdTranslator(ConfigModel::pushSubscriptionId.name, SessionModel::subscriptionId)) + } + + /** + * A session that starts before the user is created pins local IDs. When the backend replaces + * a local ID in place, carry it over to the pinned copy. Changes from a non-local ID, such as a + * login or user switch, leave the pinned IDs alone. + */ + private inner class PinnedLocalIdTranslator( + private val sourceProperty: String, + private val pinnedId: KMutableProperty1, + ) : ISingletonModelStoreChangeHandler { + override fun onModelReplaced( + model: TModel, + tag: String, + ) = Unit + + override fun onModelUpdated( + args: ModelChangedArgs, + tag: String, + ) { + val localId = + (args.oldValue as? String)?.takeIf { args.property == sourceProperty && IDManager.isLocalId(it) } + val backendId = (args.newValue as? String)?.takeUnless { IDManager.isLocalId(it) } + if (localId == null || backendId == null) return + + runOnSerialIO { + val session = this@SessionService.session ?: return@runOnSerialIO + if (pinnedId.get(session) == localId) pinnedId.set(session, backendId) + } + } } /** NOTE: This triggers more often than scheduleBackgroundRunIn defined above, 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 b722064ca5..903d5ad399 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 @@ -1,7 +1,11 @@ package com.onesignal.session.internal.session +import com.onesignal.common.modeling.ISingletonModelStoreChangeHandler +import com.onesignal.common.modeling.ModelChangeTags +import com.onesignal.common.modeling.ModelChangedArgs import com.onesignal.common.threading.OneSignalDispatchers import com.onesignal.common.threading.runOnSerialIO +import com.onesignal.core.internal.config.ConfigModel import com.onesignal.core.internal.features.IFeatureManager import com.onesignal.core.internal.time.ITime import com.onesignal.features.FeatureFlag @@ -9,6 +13,8 @@ import com.onesignal.mocks.IOMockHelper import com.onesignal.mocks.MockHelper import com.onesignal.mocks.MockPreferencesService import com.onesignal.session.internal.session.impl.SessionService +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.mockk.every @@ -17,6 +23,7 @@ import io.mockk.mockk import io.mockk.mockkObject import io.mockk.mockkStatic import io.mockk.runs +import io.mockk.slot import io.mockk.spyk import io.mockk.unmockkObject import io.mockk.unmockkStatic @@ -47,6 +54,35 @@ private class Mocks( val identityModelStore = MockHelper.identityModelStore { it.onesignalId = ONESIGNAL_ID } val configModelStore = MockHelper.configModelStore { it.pushSubscriptionId = SUBSCRIPTION_ID } + val identityChangeHandler = slot>() + val configChangeHandler = slot>() + + init { + every { identityModelStore.subscribe(capture(identityChangeHandler)) } just runs + every { configModelStore.subscribe(capture(configChangeHandler)) } just runs + } + + /** Mirrors the user-create executor replacing a local ID with the backend ID in place. */ + fun hydrateOnesignalId(backendId: String) { + val model = identityModelStore.model + val localId = model.onesignalId + model.onesignalId = backendId + identityChangeHandler.captured.onModelUpdated( + ModelChangedArgs(model, IdentityConstants.ONESIGNAL_ID, IdentityConstants.ONESIGNAL_ID, localId, backendId), + ModelChangeTags.HYDRATE, + ) + } + + fun hydratePushSubscriptionId(backendId: String) { + val model = configModelStore.model + val localId = model.pushSubscriptionId + model.pushSubscriptionId = backendId + configChangeHandler.captured.onModelUpdated( + ModelChangedArgs(model, ConfigModel::pushSubscriptionId.name, ConfigModel::pushSubscriptionId.name, localId, backendId), + ModelChangeTags.NORMAL, + ) + } + fun sessionModelStore(action: ((SessionModel) -> Unit)? = null): SessionModelStore { if (action != null) action(mockSessionModelStore.model) return mockSessionModelStore @@ -305,6 +341,79 @@ class SessionServiceTests : FunSpec({ sessionModelStore.model.subscriptionId shouldBe Mocks.SUBSCRIPTION_ID } + test("pinned local IDs are replaced with backend IDs when the user is created") { + // Given + val mocks = Mocks() + mocks.identityModelStore.model.onesignalId = "local-user" + mocks.configModelStore.model.pushSubscriptionId = "local-subscription" + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + val sessionModelStore = mocks.sessionModelStore { it.isValid = false } + sessionService.onFocus(false) + + // When + mocks.hydrateOnesignalId("backend-user") + mocks.hydratePushSubscriptionId("backend-subscription") + + // Then + sessionModelStore.model.onesignalId shouldBe "backend-user" + sessionModelStore.model.subscriptionId shouldBe "backend-subscription" + } + + test("pinned backend IDs are not replaced by later ID changes") { + // Given + val mocks = Mocks() + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + val sessionModelStore = mocks.sessionModelStore { it.isValid = false } + sessionService.onFocus(false) + + // When + mocks.hydrateOnesignalId("other-user") + mocks.hydratePushSubscriptionId("other-subscription") + + // Then + sessionModelStore.model.onesignalId shouldBe Mocks.ONESIGNAL_ID + sessionModelStore.model.subscriptionId shouldBe Mocks.SUBSCRIPTION_ID + } + + test("pinned local IDs are not replaced when a different local ID is translated") { + // Given + val mocks = Mocks() + mocks.identityModelStore.model.onesignalId = "local-user" + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + val sessionModelStore = mocks.sessionModelStore { it.isValid = false } + sessionService.onFocus(false) + + // When + mocks.identityModelStore.model.onesignalId = "local-next-user" + mocks.hydrateOnesignalId("backend-next-user") + + // Then + sessionModelStore.model.onesignalId shouldBe "local-user" + } + + test("pinned local IDs are not replaced by another local ID") { + // Given + val mocks = Mocks() + mocks.identityModelStore.model.onesignalId = "local-user" + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + val sessionModelStore = mocks.sessionModelStore { it.isValid = false } + sessionService.onFocus(false) + + // When + mocks.hydrateOnesignalId("local-next-user") + + // Then + sessionModelStore.model.onesignalId shouldBe "local-user" + } + test("sessions v2 active duration uses the monotonic clock") { // Given val mocks = Mocks(sessionsV2Enabled = true) From 73ed49c0082a5a1b29e3b864ac6bc6d272983f1d Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 6 Oct 2026 15:13:34 -0700 Subject: [PATCH 5/8] refactor: [SDK-5369] rename to usesSessionsApi and pin IDs only for sessions API sessions Co-Authored-By: Claude Opus 5.5 (1M context) --- .../session/internal/session/SessionModel.kt | 19 ++--- .../internal/session/impl/SessionService.kt | 17 +++-- .../internal/session/SessionServiceTests.kt | 71 ++++++++++++------- 3 files changed, 66 insertions(+), 41 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 44b5570ada..b190952557 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 @@ -57,7 +57,7 @@ class SessionModel : Model() { /** * [focusTime] from a monotonic clock ([com.onesignal.core.internal.time.ITime.elapsedRealtimeMillis]). - * Used instead of [focusTime] to measure [activeDuration] when [isSessionsV2Enabled]. + * Used instead of [focusTime] to measure [activeDuration] when [usesSessionsApi]. */ var focusElapsedRealtime: Long get() = getLongProperty(::focusElapsedRealtime.name) { 0L } @@ -66,19 +66,20 @@ class SessionModel : Model() { } /** - * Value of [com.onesignal.features.FeatureFlag.SDK_SESSIONS_V2_API_CUTOVER] captured when this session - * started. Fixed for the life of the session so it never mixes the legacy and v2 paths. - * Read this rather than the feature manager, whose value can change mid-session. + * Whether this session reports through the sessions API instead of the legacy session paths. + * Decided by [com.onesignal.features.FeatureFlag.SDK_SESSIONS_V2_API_CUTOVER] when the session + * starts and fixed until it ends, so a session never mixes paths. Read this rather than the + * feature manager, whose value can change mid-session. */ - var isSessionsV2Enabled: Boolean - get() = getBooleanProperty(::isSessionsV2Enabled.name) { false } + var usesSessionsApi: Boolean + get() = getBooleanProperty(::usesSessionsApi.name) { false } set(value) { - setBooleanProperty(::isSessionsV2Enabled.name, value) + setBooleanProperty(::usesSessionsApi.name, value) } /** - * The OneSignal ID when this session started. Session updates keep using it after a - * login or user switch. + * The OneSignal ID when this session started, set only when [usesSessionsApi]. Session + * updates keep using it after a login or user switch. */ var onesignalId: String? get() = getOptStringProperty(::onesignalId.name) 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 bfc3afebaf..c0e1c01a04 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 @@ -182,12 +182,15 @@ internal class SessionService( session.startTime = focusTimeMs session.focusTime = session.startTime session.focusElapsedRealtime = focusElapsedMs - session.isSessionsV2Enabled = featureManager.isEnabled(FeatureFlag.SDK_SESSIONS_V2_API_CUTOVER) - session.onesignalId = - identityModelStore.model - .takeIf { it.hasProperty(IdentityConstants.ONESIGNAL_ID) } - ?.onesignalId - session.subscriptionId = config?.pushSubscriptionId + session.usesSessionsApi = featureManager.isEnabled(FeatureFlag.SDK_SESSIONS_V2_API_CUTOVER) + if (session.usesSessionsApi) { + val identityModel = identityModelStore.model + session.onesignalId = if (identityModel.hasProperty(IdentityConstants.ONESIGNAL_ID)) identityModel.onesignalId else null + session.subscriptionId = config?.pushSubscriptionId + } else { + session.onesignalId = null + session.subscriptionId = null + } session.serverSessionId = null session.isValid = true Logging.debug("SessionService: New session started at ${session.startTime}") @@ -221,7 +224,7 @@ internal class SessionService( } // capture the amount of time the app was focused val dt = - if (session.isSessionsV2Enabled) { + if (session.usesSessionsApi) { // elapsedRealtime resets on reboot; drop the interval rather than count a negative one. (unfocusElapsedMs - session.focusElapsedRealtime).coerceAtLeast(0L) } else { 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 903d5ad399..a85d822ad9 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 @@ -33,7 +33,7 @@ import kotlinx.coroutines.Job // Mocks used by every test in this file private class Mocks( - var sessionsV2Enabled: Boolean = false, + var sessionsApiEnabled: Boolean = false, ) { val currentTime = 1111L var elapsedRealtime = 5000L @@ -48,7 +48,7 @@ private class Mocks( val featureManager: IFeatureManager = mockk().also { - every { it.isEnabled(FeatureFlag.SDK_SESSIONS_V2_API_CUTOVER) } answers { sessionsV2Enabled } + every { it.isEnabled(FeatureFlag.SDK_SESSIONS_V2_API_CUTOVER) } answers { sessionsApiEnabled } } val identityModelStore = MockHelper.identityModelStore { it.onesignalId = ONESIGNAL_ID } @@ -252,9 +252,9 @@ class SessionServiceTests : FunSpec({ verify(exactly = 0) { mocks.spyCallback.onSessionEnded(any()) } } - test("new session captures the sessions v2 flag when on") { + test("new session uses the sessions API when the flag is on") { // Given - val mocks = Mocks(sessionsV2Enabled = true) + val mocks = Mocks(sessionsApiEnabled = true) val sessionService = mocks.sessionService sessionService.bootstrap() sessionService.start() @@ -264,12 +264,12 @@ class SessionServiceTests : FunSpec({ sessionService.onFocus(false) // Then - sessionModelStore.model.isSessionsV2Enabled shouldBe true + sessionModelStore.model.usesSessionsApi shouldBe true } - test("new session captures the sessions v2 flag when off") { + test("new session uses the legacy path when the flag is off") { // Given - val mocks = Mocks(sessionsV2Enabled = false) + val mocks = Mocks(sessionsApiEnabled = false) val sessionService = mocks.sessionService sessionService.bootstrap() sessionService.start() @@ -279,12 +279,12 @@ class SessionServiceTests : FunSpec({ sessionService.onFocus(false) // Then - sessionModelStore.model.isSessionsV2Enabled shouldBe false + sessionModelStore.model.usesSessionsApi shouldBe false } - test("sessions v2 flag does not change mid-session") { + test("sessions API choice does not change mid-session") { // Given - val mocks = Mocks(sessionsV2Enabled = true) + val mocks = Mocks(sessionsApiEnabled = true) val sessionService = mocks.sessionService sessionService.bootstrap() sessionService.start() @@ -292,17 +292,17 @@ class SessionServiceTests : FunSpec({ sessionService.onFocus(false) // When - mocks.sessionsV2Enabled = false + mocks.sessionsApiEnabled = false sessionService.onUnfocused() sessionService.onFocus(false) // Then - sessionModelStore.model.isSessionsV2Enabled shouldBe true + sessionModelStore.model.usesSessionsApi shouldBe true } test("new session pins onesignal and subscription IDs and clears the server session ID") { // Given - val mocks = Mocks() + val mocks = Mocks(sessionsApiEnabled = true) val sessionService = mocks.sessionService sessionService.bootstrap() sessionService.start() @@ -321,9 +321,30 @@ class SessionServiceTests : FunSpec({ sessionModelStore.model.serverSessionId shouldBe null } + test("legacy session does not pin IDs") { + // Given + val mocks = Mocks(sessionsApiEnabled = false) + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + val sessionModelStore = + mocks.sessionModelStore { + it.isValid = false + it.onesignalId = "previous-user" + it.subscriptionId = "previous-subscription" + } + + // When + sessionService.onFocus(false) + + // Then + sessionModelStore.model.onesignalId shouldBe null + sessionModelStore.model.subscriptionId shouldBe null + } + test("pinned IDs stay the same after login within a session") { // Given - val mocks = Mocks() + val mocks = Mocks(sessionsApiEnabled = true) val sessionService = mocks.sessionService sessionService.bootstrap() sessionService.start() @@ -343,7 +364,7 @@ class SessionServiceTests : FunSpec({ test("pinned local IDs are replaced with backend IDs when the user is created") { // Given - val mocks = Mocks() + val mocks = Mocks(sessionsApiEnabled = true) mocks.identityModelStore.model.onesignalId = "local-user" mocks.configModelStore.model.pushSubscriptionId = "local-subscription" val sessionService = mocks.sessionService @@ -363,7 +384,7 @@ class SessionServiceTests : FunSpec({ test("pinned backend IDs are not replaced by later ID changes") { // Given - val mocks = Mocks() + val mocks = Mocks(sessionsApiEnabled = true) val sessionService = mocks.sessionService sessionService.bootstrap() sessionService.start() @@ -381,7 +402,7 @@ class SessionServiceTests : FunSpec({ test("pinned local IDs are not replaced when a different local ID is translated") { // Given - val mocks = Mocks() + val mocks = Mocks(sessionsApiEnabled = true) mocks.identityModelStore.model.onesignalId = "local-user" val sessionService = mocks.sessionService sessionService.bootstrap() @@ -399,7 +420,7 @@ class SessionServiceTests : FunSpec({ test("pinned local IDs are not replaced by another local ID") { // Given - val mocks = Mocks() + val mocks = Mocks(sessionsApiEnabled = true) mocks.identityModelStore.model.onesignalId = "local-user" val sessionService = mocks.sessionService sessionService.bootstrap() @@ -414,9 +435,9 @@ class SessionServiceTests : FunSpec({ sessionModelStore.model.onesignalId shouldBe "local-user" } - test("sessions v2 active duration uses the monotonic clock") { + test("sessions API active duration uses the monotonic clock") { // Given - val mocks = Mocks(sessionsV2Enabled = true) + val mocks = Mocks(sessionsApiEnabled = true) val sessionService = mocks.sessionService sessionService.bootstrap() sessionService.start() @@ -431,16 +452,16 @@ class SessionServiceTests : FunSpec({ sessionModelStore.model.activeDuration shouldBe 750L } - test("sessions v2 active duration ignores an interval spanning a reboot") { + test("sessions API active duration ignores an interval spanning a reboot") { // Given - val mocks = Mocks(sessionsV2Enabled = true) + val mocks = Mocks(sessionsApiEnabled = true) val sessionService = mocks.sessionService sessionService.bootstrap() sessionService.start() val sessionModelStore = mocks.sessionModelStore { it.isValid = true - it.isSessionsV2Enabled = true + it.usesSessionsApi = true it.focusElapsedRealtime = 10_000L it.activeDuration = 200L } @@ -461,7 +482,7 @@ class SessionServiceTests : FunSpec({ store.model.startTime = 123L store.model.activeDuration = 456L store.model.focusElapsedRealtime = 789L - store.model.isSessionsV2Enabled = true + store.model.usesSessionsApi = true store.model.onesignalId = Mocks.ONESIGNAL_ID store.model.subscriptionId = Mocks.SUBSCRIPTION_ID store.model.serverSessionId = "server-session-id" @@ -474,7 +495,7 @@ class SessionServiceTests : FunSpec({ reloaded.startTime shouldBe 123L reloaded.activeDuration shouldBe 456L reloaded.focusElapsedRealtime shouldBe 789L - reloaded.isSessionsV2Enabled shouldBe true + reloaded.usesSessionsApi shouldBe true reloaded.onesignalId shouldBe Mocks.ONESIGNAL_ID reloaded.subscriptionId shouldBe Mocks.SUBSCRIPTION_ID reloaded.serverSessionId shouldBe "server-session-id" From 10a610545e24352c7c8cecb7aa5553bc83753c6a Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 6 Oct 2026 15:19:32 -0700 Subject: [PATCH 6/8] refactor: [SDK-5369] pin session IDs regardless of sessions API choice Co-Authored-By: Claude Opus 5.5 (1M context) --- .../session/internal/session/SessionModel.kt | 4 +-- .../internal/session/impl/SessionService.kt | 13 +++----- .../internal/session/SessionServiceTests.kt | 33 ++++--------------- 3 files changed, 13 insertions(+), 37 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 b190952557..0e7bb60e94 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 @@ -78,8 +78,8 @@ class SessionModel : Model() { } /** - * The OneSignal ID when this session started, set only when [usesSessionsApi]. Session - * updates keep using it after a login or user switch. + * The OneSignal ID when this session started. Session updates keep using it after a + * login or user switch. */ var onesignalId: String? get() = getOptStringProperty(::onesignalId.name) 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 c0e1c01a04..927beae800 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 @@ -183,14 +183,11 @@ internal class SessionService( session.focusTime = session.startTime session.focusElapsedRealtime = focusElapsedMs session.usesSessionsApi = featureManager.isEnabled(FeatureFlag.SDK_SESSIONS_V2_API_CUTOVER) - if (session.usesSessionsApi) { - val identityModel = identityModelStore.model - session.onesignalId = if (identityModel.hasProperty(IdentityConstants.ONESIGNAL_ID)) identityModel.onesignalId else null - session.subscriptionId = config?.pushSubscriptionId - } else { - session.onesignalId = null - session.subscriptionId = null - } + session.onesignalId = + identityModelStore.model + .takeIf { it.hasProperty(IdentityConstants.ONESIGNAL_ID) } + ?.onesignalId + session.subscriptionId = config?.pushSubscriptionId session.serverSessionId = null session.isValid = true Logging.debug("SessionService: New session started at ${session.startTime}") 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 a85d822ad9..9344bcacf5 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 @@ -302,7 +302,7 @@ class SessionServiceTests : FunSpec({ test("new session pins onesignal and subscription IDs and clears the server session ID") { // Given - val mocks = Mocks(sessionsApiEnabled = true) + val mocks = Mocks() val sessionService = mocks.sessionService sessionService.bootstrap() sessionService.start() @@ -321,30 +321,9 @@ class SessionServiceTests : FunSpec({ sessionModelStore.model.serverSessionId shouldBe null } - test("legacy session does not pin IDs") { - // Given - val mocks = Mocks(sessionsApiEnabled = false) - val sessionService = mocks.sessionService - sessionService.bootstrap() - sessionService.start() - val sessionModelStore = - mocks.sessionModelStore { - it.isValid = false - it.onesignalId = "previous-user" - it.subscriptionId = "previous-subscription" - } - - // When - sessionService.onFocus(false) - - // Then - sessionModelStore.model.onesignalId shouldBe null - sessionModelStore.model.subscriptionId shouldBe null - } - test("pinned IDs stay the same after login within a session") { // Given - val mocks = Mocks(sessionsApiEnabled = true) + val mocks = Mocks() val sessionService = mocks.sessionService sessionService.bootstrap() sessionService.start() @@ -364,7 +343,7 @@ class SessionServiceTests : FunSpec({ test("pinned local IDs are replaced with backend IDs when the user is created") { // Given - val mocks = Mocks(sessionsApiEnabled = true) + val mocks = Mocks() mocks.identityModelStore.model.onesignalId = "local-user" mocks.configModelStore.model.pushSubscriptionId = "local-subscription" val sessionService = mocks.sessionService @@ -384,7 +363,7 @@ class SessionServiceTests : FunSpec({ test("pinned backend IDs are not replaced by later ID changes") { // Given - val mocks = Mocks(sessionsApiEnabled = true) + val mocks = Mocks() val sessionService = mocks.sessionService sessionService.bootstrap() sessionService.start() @@ -402,7 +381,7 @@ class SessionServiceTests : FunSpec({ test("pinned local IDs are not replaced when a different local ID is translated") { // Given - val mocks = Mocks(sessionsApiEnabled = true) + val mocks = Mocks() mocks.identityModelStore.model.onesignalId = "local-user" val sessionService = mocks.sessionService sessionService.bootstrap() @@ -420,7 +399,7 @@ class SessionServiceTests : FunSpec({ test("pinned local IDs are not replaced by another local ID") { // Given - val mocks = Mocks(sessionsApiEnabled = true) + val mocks = Mocks() mocks.identityModelStore.model.onesignalId = "local-user" val sessionService = mocks.sessionService sessionService.bootstrap() From 39f761b3d0aca4d0d966479276444051b08ea270 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 7 Oct 2026 10:12:53 -0700 Subject: [PATCH 7/8] fix: [SDK-5369] address review feedback - Point the KMP submodule at the merged commit on new-session-events - Skip pinned subscription ID updates once the session's user is no longer current - Drop cached remote feature flags when the appId changes - Cover the sessions API resume path Co-Authored-By: Claude Opus 5.5 (1M context) --- OneSignal-KMP-SDK | 2 +- .../com/onesignal/internal/OneSignalImp.kt | 3 +- .../internal/session/impl/SessionService.kt | 13 +++++- .../user/internal/AppIdResolution.kt | 15 +++++++ .../internal/session/SessionServiceTests.kt | 44 +++++++++++++++++++ .../user/internal/AppIdHelperTests.kt | 31 +++++++++++++ 6 files changed, 104 insertions(+), 4 deletions(-) diff --git a/OneSignal-KMP-SDK b/OneSignal-KMP-SDK index f990ab7705..3a4ce04154 160000 --- a/OneSignal-KMP-SDK +++ b/OneSignal-KMP-SDK @@ -1 +1 @@ -Subproject commit f990ab77056f558ed6a88e6f6eb3cdbb5bcc49a0 +Subproject commit 3a4ce041547a5a68134cc75f70a4813dec32737e diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt index 86b788c7a3..8648d1774c 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt @@ -41,6 +41,7 @@ import com.onesignal.user.UserModule import com.onesignal.user.internal.LoginHelper import com.onesignal.user.internal.LogoutHelper import com.onesignal.user.internal.UserSwitcher +import com.onesignal.user.internal.applyResolvedAppId import com.onesignal.user.internal.identity.IdentityModelStore import com.onesignal.user.internal.jwt.JwtTokenStore import com.onesignal.user.internal.properties.PropertiesModelStore @@ -441,7 +442,7 @@ internal class OneSignalImp : IOneSignal, completeInit(InitState.FAILED) return false } - configModel.appId = result.appId!! // safe because failed is false + applyResolvedAppId(configModel, result.appId!!) // safe because failed is false val forceCreateUser = result.forceCreateUser updateConfig() 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 927beae800..fc164d9909 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 @@ -82,7 +82,15 @@ internal class SessionService( override fun start() { _applicationService.addApplicationLifecycleHandler(this) identityModelStore.subscribe(PinnedLocalIdTranslator(IdentityConstants.ONESIGNAL_ID, SessionModel::onesignalId)) - _configModelStore.subscribe(PinnedLocalIdTranslator(ConfigModel::pushSubscriptionId.name, SessionModel::subscriptionId)) + // Login before the anonymous user is created carries the local push subscription over to + // the new user, so its backend ID belongs to that user, not the one pinned on this session. + _configModelStore.subscribe( + PinnedLocalIdTranslator(ConfigModel::pushSubscriptionId.name, SessionModel::subscriptionId) { session -> + val identityModel = identityModelStore.model + val currentOnesignalId = if (identityModel.hasProperty(IdentityConstants.ONESIGNAL_ID)) identityModel.onesignalId else null + session.onesignalId == currentOnesignalId + }, + ) } /** @@ -93,6 +101,7 @@ internal class SessionService( private inner class PinnedLocalIdTranslator( private val sourceProperty: String, private val pinnedId: KMutableProperty1, + private val appliesTo: (SessionModel) -> Boolean = { true }, ) : ISingletonModelStoreChangeHandler { override fun onModelReplaced( model: TModel, @@ -110,7 +119,7 @@ internal class SessionService( runOnSerialIO { val session = this@SessionService.session ?: return@runOnSerialIO - if (pinnedId.get(session) == localId) pinnedId.set(session, backendId) + if (pinnedId.get(session) == localId && appliesTo(session)) pinnedId.set(session, backendId) } } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/AppIdResolution.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/AppIdResolution.kt index b2e3cfd954..3b628ea81d 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/AppIdResolution.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/AppIdResolution.kt @@ -12,6 +12,21 @@ data class AppIdResolution( val failed: Boolean, ) +/** + * Sets [appId] on [configModel]. Cached remote feature flags belong to the previous app, so they + * are dropped when the app changes; otherwise they apply until the new app's flags are fetched. + */ +fun applyResolvedAppId( + configModel: ConfigModel, + appId: String, +) { + if (configModel.hasProperty(ConfigModel::appId.name) && configModel.appId != appId) { + configModel.sdkRemoteFeatureFlags = emptyList() + configModel.sdkRemoteFeatureFlagMetadata = null + } + configModel.appId = appId +} + fun resolveAppId( inputAppId: String?, configModel: ConfigModel, 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 9344bcacf5..746653d72b 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 @@ -361,6 +361,29 @@ class SessionServiceTests : FunSpec({ sessionModelStore.model.subscriptionId shouldBe "backend-subscription" } + test("pinned subscription ID is not replaced after login before the anonymous user is created") { + // Given + val mocks = Mocks() + mocks.identityModelStore.model.onesignalId = "local-anonymous" + mocks.configModelStore.model.pushSubscriptionId = "local-subscription" + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + val sessionModelStore = mocks.sessionModelStore { it.isValid = false } + sessionService.onFocus(false) + + // When + val loggedInModel = IdentityModel().apply { onesignalId = "local-logged-in" } + every { mocks.identityModelStore.model } returns loggedInModel + mocks.identityChangeHandler.captured.onModelReplaced(loggedInModel, ModelChangeTags.NORMAL) + mocks.hydrateOnesignalId("backend-logged-in") + mocks.hydratePushSubscriptionId("backend-subscription") + + // Then + sessionModelStore.model.onesignalId shouldBe "local-anonymous" + sessionModelStore.model.subscriptionId shouldBe "local-subscription" + } + test("pinned backend IDs are not replaced by later ID changes") { // Given val mocks = Mocks() @@ -431,6 +454,27 @@ class SessionServiceTests : FunSpec({ sessionModelStore.model.activeDuration shouldBe 750L } + test("sessions API active duration excludes background time between focuses") { + // 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 += 300L + sessionService.onUnfocused() + + // When + mocks.elapsedRealtime += 10_000L + sessionService.onFocus(false) + mocks.elapsedRealtime += 200L + sessionService.onUnfocused() + + // Then + sessionModelStore.model.activeDuration shouldBe 500L + } + test("sessions API active duration ignores an interval spanning a reboot") { // Given val mocks = Mocks(sessionsApiEnabled = true) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/AppIdHelperTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/AppIdHelperTests.kt index 97ab460fcc..3ccf9781f3 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/AppIdHelperTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/AppIdHelperTests.kt @@ -289,4 +289,35 @@ class AppIdHelperTests : FunSpec({ mockPreferencesService.getString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID) } } + + test("applyResolvedAppId drops cached feature flags when the appId changes") { + // Given + val configModel = ConfigModel() + configModel.appId = testAppId + configModel.sdkRemoteFeatureFlags = listOf("sdk_sessions_v2_api_cutover") + configModel.sdkRemoteFeatureFlagMetadata = "{}" + + // When + applyResolvedAppId(configModel, differentAppId) + + // Then + configModel.appId shouldBe differentAppId + configModel.sdkRemoteFeatureFlags shouldBe emptyList() + configModel.sdkRemoteFeatureFlagMetadata shouldBe null + } + + test("applyResolvedAppId keeps cached feature flags for the same appId") { + // Given + val configModel = ConfigModel() + configModel.appId = testAppId + configModel.sdkRemoteFeatureFlags = listOf("sdk_sessions_v2_api_cutover") + configModel.sdkRemoteFeatureFlagMetadata = "{}" + + // When + applyResolvedAppId(configModel, testAppId) + + // Then + configModel.sdkRemoteFeatureFlags shouldBe listOf("sdk_sessions_v2_api_cutover") + configModel.sdkRemoteFeatureFlagMetadata shouldBe "{}" + } }) From 2bf93feeaaf5c1386ccc4969c8f74645927328dc Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 7 Oct 2026 11:26:19 -0700 Subject: [PATCH 8/8] refactor: [SDK-5369] drop underscore prefixes from SessionService params Co-Authored-By: Claude Opus 5.5 (1M context) --- OneSignalSDK/detekt/detekt-baseline-core.xml | 4 --- .../session/internal/session/SessionModel.kt | 2 +- .../internal/session/impl/SessionService.kt | 26 +++++++++---------- 3 files changed, 14 insertions(+), 18 deletions(-) diff --git a/OneSignalSDK/detekt/detekt-baseline-core.xml b/OneSignalSDK/detekt/detekt-baseline-core.xml index 743ae6ca39..8786322cfa 100644 --- a/OneSignalSDK/detekt/detekt-baseline-core.xml +++ b/OneSignalSDK/detekt/detekt-baseline-core.xml @@ -120,10 +120,6 @@ ConstructorParameterNaming:SessionListener.kt$SessionListener$private val _propertiesModelStore: PropertiesModelStore ConstructorParameterNaming:SessionListener.kt$SessionListener$private val _sessionService: ISessionService ConstructorParameterNaming:SessionManager.kt$SessionManager$private val _outcomeController: IOutcomeEventsController - ConstructorParameterNaming:SessionService.kt$SessionService$private val _applicationService: IApplicationService - ConstructorParameterNaming:SessionService.kt$SessionService$private val _configModelStore: ConfigModelStore - ConstructorParameterNaming:SessionService.kt$SessionService$private val _sessionModelStore: SessionModelStore - ConstructorParameterNaming:SessionService.kt$SessionService$private val _time: ITime ConstructorParameterNaming:SimpleModelStore.kt$SimpleModelStore$/** * Will be called whenever a new [TModel] needs to be instantiated. */ private val _create: () -> TModel ConstructorParameterNaming:SimpleModelStore.kt$SimpleModelStore$_prefs: IPreferencesService? = null ConstructorParameterNaming:SubscriptionBackendService.kt$SubscriptionBackendService$private val _httpClient: IHttpClient 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 0e7bb60e94..9f520e0593 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 @@ -97,7 +97,7 @@ class SessionModel : Model() { } /** - * The ID the backend assigned to this session, once known. + * Null until the sessions API creates this session. Cleared when a new session starts. */ var serverSessionId: String? get() = getOptStringProperty(::serverSessionId.name) 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 fc164d9909..7ac57d91be 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 @@ -39,10 +39,10 @@ import kotlin.reflect.KMutableProperty1 * The time threshold for a session to expire is a configuration option: [ConfigModel.sessionFocusTimeout]. */ internal class SessionService( - private val _applicationService: IApplicationService, - private val _configModelStore: ConfigModelStore, - private val _sessionModelStore: SessionModelStore, - private val _time: ITime, + private val applicationService: IApplicationService, + private val configModelStore: ConfigModelStore, + private val sessionModelStore: SessionModelStore, + private val time: ITime, private val featureManager: IFeatureManager, private val identityModelStore: IdentityModelStore, ) : ISessionService, IBootstrapService, IStartableService, IBackgroundService, IApplicationLifecycleHandler { @@ -50,7 +50,7 @@ internal class SessionService( // 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 // (which is what `0L` / Jan 1970 would produce). - get() = session?.startTime ?: _time.currentTimeMillis + get() = session?.startTime ?: time.currentTimeMillis /** * Run in the background when the session would time out, only if a session is currently active. @@ -75,16 +75,16 @@ internal class SessionService( private var hasFocused = false override fun bootstrap() { - session = _sessionModelStore.model - config = _configModelStore.model + session = sessionModelStore.model + config = configModelStore.model } override fun start() { - _applicationService.addApplicationLifecycleHandler(this) + applicationService.addApplicationLifecycleHandler(this) identityModelStore.subscribe(PinnedLocalIdTranslator(IdentityConstants.ONESIGNAL_ID, SessionModel::onesignalId)) // Login before the anonymous user is created carries the local push subscription over to // the new user, so its backend ID belongs to that user, not the one pinned on this session. - _configModelStore.subscribe( + configModelStore.subscribe( PinnedLocalIdTranslator(ConfigModel::pushSubscriptionId.name, SessionModel::subscriptionId) { session -> val identityModel = identityModelStore.model val currentOnesignalId = if (identityModel.hasProperty(IdentityConstants.ONESIGNAL_ID)) identityModel.onesignalId else null @@ -158,8 +158,8 @@ internal class SessionService( override fun onFocus(firedOnSubscribe: Boolean) { // Capture focus time on the caller's thread so session timestamps reflect lifecycle // arrival, not dispatcher latency (SDK-4506). - val focusTimeMs = _time.currentTimeMillis - val focusElapsedMs = _time.elapsedRealtimeMillis + val focusTimeMs = time.currentTimeMillis + val focusElapsedMs = time.elapsedRealtimeMillis runOnSerialIO { handleOnFocus(firedOnSubscribe, focusTimeMs, focusElapsedMs) } @@ -212,8 +212,8 @@ internal class SessionService( override fun onUnfocused() { // Capture on the caller's thread so activeDuration is unaffected by dispatcher latency. - val unfocusTimeMs = _time.currentTimeMillis - val unfocusElapsedMs = _time.elapsedRealtimeMillis + val unfocusTimeMs = time.currentTimeMillis + val unfocusElapsedMs = time.elapsedRealtimeMillis runOnSerialIO { handleOnUnfocused(unfocusTimeMs, unfocusElapsedMs) }