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
}