diff --git a/OneSignal-KMP-SDK b/OneSignal-KMP-SDK index ee61d46732..3a4ce04154 160000 --- a/OneSignal-KMP-SDK +++ b/OneSignal-KMP-SDK @@ -1 +1 @@ -Subproject commit ee61d4673265876e5cab6328b7a0745f79b7a5aa +Subproject commit 3a4ce041547a5a68134cc75f70a4813dec32737e 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/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/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/SessionModel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/SessionModel.kt index 13c31ec50c..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 @@ -54,4 +54,54 @@ 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 [usesSessionsApi]. + */ + var focusElapsedRealtime: Long + get() = getLongProperty(::focusElapsedRealtime.name) { 0L } + set(value) { + setLongProperty(::focusElapsedRealtime.name, value) + } + + /** + * 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 usesSessionsApi: Boolean + get() = getBooleanProperty(::usesSessionsApi.name) { false } + set(value) { + setBooleanProperty(::usesSessionsApi.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) + } + + /** + * Null until the sessions API creates this session. Cleared when a new session starts. + */ + 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..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 @@ -1,22 +1,31 @@ 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 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 +import kotlin.reflect.KMutableProperty1 /** * The implementation for [ISessionService] will continue a session as long as the app remains @@ -30,16 +39,18 @@ import java.util.UUID * 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 { 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 // (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. @@ -64,12 +75,53 @@ 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( + 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 + }, + ) + } + + /** + * 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, + private val appliesTo: (SessionModel) -> Boolean = { true }, + ) : 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 && appliesTo(session)) pinnedId.set(session, backendId) + } + } } /** NOTE: This triggers more often than scheduleBackgroundRunIn defined above, @@ -106,15 +158,17 @@ 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 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 +190,14 @@ internal class SessionService( session.sessionId = UUID.randomUUID().toString() session.startTime = focusTimeMs session.focusTime = session.startTime + session.focusElapsedRealtime = focusElapsedMs + session.usesSessionsApi = featureManager.isEnabled(FeatureFlag.SDK_SESSIONS_V2_API_CUTOVER) + 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,26 +205,37 @@ 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() } } } override fun onUnfocused() { // Capture on the caller's thread so activeDuration is unaffected by dispatcher latency. - val unfocusTimeMs = _time.currentTimeMillis + 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.usesSessionsApi) { + // 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/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 675fbda28f..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 @@ -1,10 +1,20 @@ 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 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 @@ -13,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 @@ -21,20 +32,78 @@ import io.mockk.verifyOrder import kotlinx.coroutines.Job // Mocks used by every test in this file -private class Mocks { +private class Mocks( + var sessionsApiEnabled: 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_API_CUTOVER) } answers { sessionsApiEnabled } + } + + 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 } 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 +252,278 @@ class SessionServiceTests : FunSpec({ verify(exactly = 0) { mocks.spyCallback.onSessionEnded(any()) } } + test("new session uses the sessions API 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 } + + // When + sessionService.onFocus(false) + + // Then + sessionModelStore.model.usesSessionsApi shouldBe true + } + + test("new session uses the legacy path when the flag is off") { + // Given + val mocks = Mocks(sessionsApiEnabled = false) + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + val sessionModelStore = mocks.sessionModelStore { it.isValid = false } + + // When + sessionService.onFocus(false) + + // Then + sessionModelStore.model.usesSessionsApi shouldBe false + } + + test("sessions API choice does not change mid-session") { + // Given + val mocks = Mocks(sessionsApiEnabled = true) + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + val sessionModelStore = mocks.sessionModelStore { it.isValid = false } + sessionService.onFocus(false) + + // When + mocks.sessionsApiEnabled = false + sessionService.onUnfocused() + sessionService.onFocus(false) + + // Then + sessionModelStore.model.usesSessionsApi 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("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 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() + 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 API active duration uses the monotonic clock") { + // Given + val mocks = Mocks(sessionsApiEnabled = 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 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) + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + val sessionModelStore = + mocks.sessionModelStore { + it.isValid = true + it.usesSessionsApi = 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.usesSessionsApi = 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.usesSessionsApi 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/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 "{}" + } }) 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 }