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 5d81224a2f..92a965975a 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
@@ -47,6 +47,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
@@ -448,7 +449,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/SessionModule.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/SessionModule.kt
index baa4ef105c..b8fd92051d 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/SessionModule.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/SessionModule.kt
@@ -18,8 +18,13 @@ import com.onesignal.session.internal.outcomes.impl.OutcomeEventsPreferences
import com.onesignal.session.internal.outcomes.impl.OutcomeEventsRepository
import com.onesignal.session.internal.session.ISessionService
import com.onesignal.session.internal.session.SessionModelStore
+import com.onesignal.session.internal.session.backend.ISessionsBackendService
+import com.onesignal.session.internal.session.backend.impl.SessionsBackendService
import com.onesignal.session.internal.session.impl.SessionListener
import com.onesignal.session.internal.session.impl.SessionService
+import com.onesignal.session.internal.session.queue.ISessionsApiQueue
+import com.onesignal.session.internal.session.queue.SessionRecordStore
+import com.onesignal.session.internal.session.queue.impl.SessionsApiQueue
internal class SessionModule : IModule {
override fun register(builder: ServiceBuilder) {
@@ -43,5 +48,10 @@ internal class SessionModule : IModule {
.provides()
builder.register().provides()
builder.register().provides()
+ builder.register().provides()
+ builder.register().provides()
+ builder.register()
+ .provides()
+ .provides()
}
}
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..c5f3ceefce 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,36 @@ 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)
+ }
+
+ /**
+ * When this app was last unfocused, in Unix time milliseconds. Earlier than [focusTime] if
+ * the app was killed while focused.
+ */
+ var unfocusTime: Long
+ get() = getLongProperty(::unfocusTime.name) { 0L }
+ set(value) {
+ setLongProperty(::unfocusTime.name, value)
+ }
}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/ISessionsBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/ISessionsBackendService.kt
new file mode 100644
index 0000000000..9ca5869a4d
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/ISessionsBackendService.kt
@@ -0,0 +1,48 @@
+package com.onesignal.session.internal.session.backend
+
+import com.onesignal.common.exceptions.BackendException
+
+/**
+ * The backend service for the public sessions API.
+ *
+ * If there is a non-successful response from the backend, a [BackendException] will be thrown with response data.
+ */
+internal interface ISessionsBackendService {
+ /**
+ * Create a session.
+ *
+ * @return The backend session ID, or null if the success response did not include one.
+ */
+ suspend fun createSession(
+ appId: String,
+ request: CreateSessionRequest,
+ ): String?
+
+ /**
+ * Update an existing session with its cumulative duration, and end it when [UpdateSessionRequest.endTime] is set.
+ */
+ suspend fun updateSession(
+ appId: String,
+ sessionId: String,
+ request: UpdateSessionRequest,
+ )
+}
+
+internal data class CreateSessionRequest(
+ val onesignalId: String,
+ val subscriptionId: String,
+ val deviceType: Int,
+ /** Unix time in seconds, sent as ISO 8601 UTC. */
+ val startTime: Long,
+ val idempotencyKey: String,
+ val directAttributionId: String? = null,
+)
+
+internal data class UpdateSessionRequest(
+ val onesignalId: String,
+ val subscriptionId: String,
+ val durationSeconds: Long,
+ val idempotencyKey: String,
+ /** Unix time in seconds, sent as ISO 8601 UTC. */
+ val endTime: Long? = null,
+)
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/impl/SessionsBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/impl/SessionsBackendService.kt
new file mode 100644
index 0000000000..c3220ca199
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/impl/SessionsBackendService.kt
@@ -0,0 +1,71 @@
+package com.onesignal.session.internal.session.backend.impl
+
+import com.onesignal.common.DateUtils
+import com.onesignal.common.exceptions.BackendException
+import com.onesignal.core.internal.http.IHttpClient
+import com.onesignal.debug.internal.logging.Logging
+import com.onesignal.session.internal.session.backend.CreateSessionRequest
+import com.onesignal.session.internal.session.backend.ISessionsBackendService
+import com.onesignal.session.internal.session.backend.UpdateSessionRequest
+import org.json.JSONException
+import org.json.JSONObject
+import java.util.Date
+
+internal class SessionsBackendService(
+ private val httpClient: IHttpClient,
+) : ISessionsBackendService {
+ override suspend fun createSession(
+ appId: String,
+ request: CreateSessionRequest,
+ ): String? {
+ val body =
+ JSONObject()
+ .put("onesignal_id", request.onesignalId)
+ .put("subscription_id", request.subscriptionId)
+ .put("device_type", request.deviceType)
+ .put("start_time", toIso8601(request.startTime))
+ .put("idempotency_key", request.idempotencyKey)
+ request.directAttributionId?.let { body.put("direct_attribution_id", it) }
+
+ val response = httpClient.post("apps/$appId/sessions", body)
+ if (!response.isSuccess) {
+ throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds)
+ }
+
+ return parseSessionId(response.payload)
+ }
+
+ override suspend fun updateSession(
+ appId: String,
+ sessionId: String,
+ request: UpdateSessionRequest,
+ ) {
+ val body =
+ JSONObject()
+ .put("onesignal_id", request.onesignalId)
+ .put("subscription_id", request.subscriptionId)
+ .put("duration_seconds", request.durationSeconds)
+ .put("idempotency_key", request.idempotencyKey)
+ request.endTime?.let { body.put("end_time", toIso8601(it)) }
+
+ val response = httpClient.patch("apps/$appId/sessions/$sessionId", body)
+ if (!response.isSuccess) {
+ throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds)
+ }
+ }
+
+ private fun toIso8601(epochSeconds: Long): String = DateUtils.iso8601Format().format(Date(epochSeconds * MILLIS_PER_SECOND))
+
+ private fun parseSessionId(payload: String?): String? =
+ try {
+ val sessionId = payload?.let { JSONObject(it) }?.optJSONObject("data")?.opt("session_id") as? String
+ sessionId?.takeIf { it.isNotEmpty() }
+ } catch (e: JSONException) {
+ Logging.warn("SessionsBackendService: unable to parse create session response", e)
+ null
+ }
+
+ private companion object {
+ const val MILLIS_PER_SECOND = 1000L
+ }
+}
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..b205851f5a 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,21 @@ 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.session.internal.session.queue.ISessionsApiQueue
+import com.onesignal.user.internal.backend.IdentityConstants
+import com.onesignal.user.internal.identity.IdentityModelStore
+import kotlinx.coroutines.withTimeoutOrNull
import java.util.UUID
/**
@@ -30,16 +36,19 @@ 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,
+ private val sessionsApiQueue: ISessionsApiQueue,
) : 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 +73,12 @@ 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)
}
/** NOTE: This triggers more often than scheduleBackgroundRunIn defined above,
@@ -77,21 +86,28 @@ internal class SessionService(
* the SDK.
*/
override suspend fun backgroundRun() {
- endSession()
+ // Android 15+ blocks network access for cached background apps, so send the end while
+ // this job still runs. On timeout it stays queued and is sent on the next app open.
+ if (endSession()) withTimeoutOrNull(END_SEND_TIMEOUT_MS) { sessionsApiQueue.flush() }
}
- private fun endSession() {
+ /** Returns true if the session ended through the sessions API. */
+ private fun endSession(): Boolean {
// Defensive: if bootstrap() has not run yet, there is no session state to end.
// This can happen when SyncJobService races with
// an in-flight initWithContext that has not yet reached bootstrapServices().
- val session = this.session ?: return
- if (!session.isValid) return
+ val session = this.session?.takeIf { it.isValid } ?: return false
val activeDuration = session.activeDuration
Logging.debug("SessionService.backgroundRun: Session ended. activeDuration: $activeDuration")
session.isValid = false
+ if (session.usesSessionsApi) {
+ val endTime = maxOf(session.unfocusTime, session.focusTime)
+ sessionsApiQueue.enqueueUpdate(session.sessionId, activeDuration, endTime)
+ }
sessionLifeCycleNotifier.fire { it.onSessionEnded(activeDuration) }
session.activeDuration = 0L
+ return session.usesSessionsApi
}
/**
@@ -106,15 +122,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,34 +154,49 @@ 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.isValid = true
Logging.debug("SessionService: New session started at ${session.startTime}")
+ if (session.usesSessionsApi) sessionsApiQueue.enqueueCreate(session, config, identityModelStore)
sessionLifeCycleNotifier.fire { it.onSessionStarted() }
} else {
// 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
+ session.unfocusTime = unfocusTimeMs
Logging.log(LogLevel.DEBUG, "SessionService.onUnfocused adding time $dt for total: ${session.activeDuration}")
}
@@ -177,4 +210,23 @@ internal class SessionService(
override val hasSubscribers: Boolean
get() = sessionLifeCycleNotifier.hasSubscribers
+
+ private companion object {
+ const val END_SEND_TIMEOUT_MS = 30_000L
+ }
+}
+
+private fun ISessionsApiQueue.enqueueCreate(
+ session: SessionModel,
+ config: ConfigModel?,
+ identityModelStore: IdentityModelStore,
+) {
+ val appId = config?.appId
+ val onesignalId = identityModelStore.model.takeIf { it.hasProperty(IdentityConstants.ONESIGNAL_ID) }?.onesignalId
+ val subscriptionId = config?.pushSubscriptionId
+ if (appId == null || onesignalId == null || subscriptionId == null) {
+ Logging.warn("SessionService: not creating session ${session.sessionId}, missing an app, user, or subscription ID")
+ return
+ }
+ enqueueCreate(session.sessionId, appId, onesignalId, subscriptionId, session.startTime)
}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/queue/ISessionsApiQueue.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/queue/ISessionsApiQueue.kt
new file mode 100644
index 0000000000..2777f74e1f
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/queue/ISessionsApiQueue.kt
@@ -0,0 +1,29 @@
+package com.onesignal.session.internal.session.queue
+
+/**
+ * Sends sessions API requests on their own path, separate from the operation repo, so a sessions
+ * outage never delays user or subscription operations. Requests are persisted and retried with
+ * backoff until they succeed, fail permanently, or exceed the attempt cap.
+ */
+internal interface ISessionsApiQueue {
+ fun enqueueCreate(
+ sessionId: String,
+ appId: String,
+ onesignalId: String,
+ subscriptionId: String,
+ startTimeMillis: Long,
+ )
+
+ /**
+ * Replaces any unsent update for the session. An end ([endTimeMillis] set) is final: later
+ * updates for the session are ignored.
+ */
+ fun enqueueUpdate(
+ sessionId: String,
+ durationMillis: Long,
+ endTimeMillis: Long? = null,
+ )
+
+ /** Sends pending requests now, skipping the failure backoff but not a backend `Retry-After`. */
+ suspend fun flush()
+}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/queue/SessionRecord.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/queue/SessionRecord.kt
new file mode 100644
index 0000000000..39563674cc
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/queue/SessionRecord.kt
@@ -0,0 +1,63 @@
+package com.onesignal.session.internal.session.queue
+
+import com.onesignal.common.modeling.Model
+import com.onesignal.common.modeling.SimpleModelStore
+import com.onesignal.core.internal.preferences.IPreferencesService
+
+/**
+ * A session that still has something to send to the sessions API. There is one record per
+ * session, keyed by the local session ID, so a newer update replaces an unsent one in place.
+ */
+internal class SessionRecord : Model() {
+ var appId: String
+ get() = getStringProperty(::appId.name)
+ set(value) = setStringProperty(::appId.name, value)
+
+ /** The session owner, pinned when the session started. Rewritten when a local ID is replaced. */
+ var onesignalId: String
+ get() = getStringProperty(::onesignalId.name)
+ set(value) = setStringProperty(::onesignalId.name, value)
+
+ var subscriptionId: String
+ get() = getStringProperty(::subscriptionId.name)
+ set(value) = setStringProperty(::subscriptionId.name, value)
+
+ /** Unix time in seconds. */
+ var startTime: Long
+ get() = getLongProperty(::startTime.name)
+ set(value) = setLongProperty(::startTime.name, value)
+
+ var createKey: String
+ get() = getStringProperty(::createKey.name)
+ set(value) = setStringProperty(::createKey.name, value)
+
+ /** Null until the create succeeds. */
+ var serverSessionId: String?
+ get() = getOptStringProperty(::serverSessionId.name)
+ set(value) = setOptStringProperty(::serverSessionId.name, value)
+
+ /** The unsent update, if any. Set together with [updateKey]. */
+ var durationSeconds: Long?
+ get() = getOptLongProperty(::durationSeconds.name)
+ set(value) = setOptLongProperty(::durationSeconds.name, value)
+
+ /** Unix time in seconds. Set when the unsent update ends the session. */
+ var endTime: Long?
+ get() = getOptLongProperty(::endTime.name)
+ set(value) = setOptLongProperty(::endTime.name, value)
+
+ var updateKey: String?
+ get() = getOptStringProperty(::updateKey.name)
+ set(value) = setOptStringProperty(::updateKey.name, value)
+
+ /** Failures the backend answered, for the request being sent now. Network errors don't count. */
+ var failedAttempts: Int
+ get() = getIntProperty(::failedAttempts.name) { 0 }
+ set(value) = setIntProperty(::failedAttempts.name, value)
+
+ val hasEnded: Boolean
+ get() = endTime != null
+}
+
+internal open class SessionRecordStore(prefs: IPreferencesService) :
+ SimpleModelStore({ SessionRecord() }, "sessionsApiQueue", prefs)
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/queue/impl/SessionsApiBackoff.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/queue/impl/SessionsApiBackoff.kt
new file mode 100644
index 0000000000..0c5e9abb09
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/queue/impl/SessionsApiBackoff.kt
@@ -0,0 +1,55 @@
+package com.onesignal.session.internal.session.queue.impl
+
+import com.onesignal.core.internal.time.ITime
+import kotlin.random.Random
+
+/**
+ * Exponential backoff with jitter after a failed send. A backend `Retry-After` is a floor that
+ * [skipFailureDelay] never shortens.
+ */
+internal class SessionsApiBackoff(
+ private val time: ITime,
+) {
+ internal var jitter: () -> Double = { Random.nextDouble(MIN_JITTER, 1.0) }
+
+ // elapsedRealtime millis.
+ @Volatile private var retryAt = 0L
+
+ @Volatile private var retryAfterAt = 0L
+
+ @Volatile private var consecutiveFailures = 0
+
+ val waitMillis: Long
+ get() = maxOf(retryAt, retryAfterAt) - time.elapsedRealtimeMillis
+
+ val isWaitingOnRetryAfter: Boolean
+ get() = retryAfterAt > time.elapsedRealtimeMillis
+
+ fun recordFailure(retryAfterSeconds: Int?) {
+ val now = time.elapsedRealtimeMillis
+ val delay = (BASE_DELAY_MS shl consecutiveFailures.coerceAtMost(MAX_DOUBLINGS)).coerceAtMost(MAX_DELAY_MS)
+ consecutiveFailures++
+ retryAt = now + (delay * jitter()).toLong()
+ retryAfterAt = now + (retryAfterSeconds ?: 0).coerceIn(0, MAX_RETRY_AFTER_SECONDS) * MILLIS_PER_SECOND
+ }
+
+ fun recordSuccess() {
+ consecutiveFailures = 0
+ }
+
+ /** Returns true if a failure delay was skipped. */
+ fun skipFailureDelay(): Boolean {
+ if (retryAt <= time.elapsedRealtimeMillis) return false
+ retryAt = 0L
+ return true
+ }
+
+ private companion object {
+ const val BASE_DELAY_MS = 5_000L
+ const val MAX_DELAY_MS = 300_000L
+ const val MAX_DOUBLINGS = 6
+ const val MIN_JITTER = 0.5
+ const val MAX_RETRY_AFTER_SECONDS = 3600
+ const val MILLIS_PER_SECOND = 1000L
+ }
+}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/queue/impl/SessionsApiQueue.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/queue/impl/SessionsApiQueue.kt
new file mode 100644
index 0000000000..89ac657c66
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/queue/impl/SessionsApiQueue.kt
@@ -0,0 +1,177 @@
+package com.onesignal.session.internal.session.queue.impl
+
+import com.onesignal.common.IDManager
+import com.onesignal.common.modeling.ISingletonModelStoreChangeHandler
+import com.onesignal.common.modeling.Model
+import com.onesignal.common.modeling.ModelChangedArgs
+import com.onesignal.common.threading.OneSignalDispatchers
+import com.onesignal.core.internal.application.IApplicationService
+import com.onesignal.core.internal.config.ConfigModel
+import com.onesignal.core.internal.config.ConfigModelStore
+import com.onesignal.core.internal.device.IDeviceService
+import com.onesignal.core.internal.startup.IStartableService
+import com.onesignal.core.internal.time.ITime
+import com.onesignal.debug.internal.logging.Logging
+import com.onesignal.session.internal.session.backend.ISessionsBackendService
+import com.onesignal.session.internal.session.queue.ISessionsApiQueue
+import com.onesignal.session.internal.session.queue.SessionRecord
+import com.onesignal.session.internal.session.queue.SessionRecordStore
+import com.onesignal.user.internal.backend.IdentityConstants
+import com.onesignal.user.internal.identity.IdentityModelStore
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withTimeoutOrNull
+import java.util.UUID
+import kotlin.reflect.KMutableProperty1
+
+internal class SessionsApiQueue(
+ private val store: SessionRecordStore,
+ backend: ISessionsBackendService,
+ deviceService: IDeviceService,
+ private val identityModelStore: IdentityModelStore,
+ private val configModelStore: ConfigModelStore,
+ applicationService: IApplicationService,
+ time: ITime,
+) : ISessionsApiQueue, IStartableService {
+ private val lock = Any()
+ private val sendMutex = Mutex()
+ private val wakeChannel = Channel(Channel.CONFLATED)
+ internal val backoff = SessionsApiBackoff(time)
+ private val sender = SessionsApiSender(store, backend, deviceService, backoff, lock)
+ private val retryTrigger = SessionsApiRetryTrigger(applicationService, ::retryNow)
+
+ override fun start() {
+ identityModelStore.subscribe(LocalIdTranslator(IdentityConstants.ONESIGNAL_ID, SessionRecord::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 the record.
+ configModelStore.subscribe(
+ LocalIdTranslator(ConfigModel::pushSubscriptionId.name, SessionRecord::subscriptionId) {
+ it.onesignalId == identityModelStore.currentOnesignalId()
+ },
+ )
+ retryTrigger.start()
+ OneSignalDispatchers.launchOnIO {
+ while (true) {
+ val wait = backoff.waitMillis
+ if (wait > 0) {
+ withTimeoutOrNull(wait) { wakeChannel.receive() }
+ continue
+ }
+ sendPending()
+ if (backoff.waitMillis <= 0) wakeChannel.receive()
+ }
+ }
+ }
+
+ override fun enqueueCreate(
+ sessionId: String,
+ appId: String,
+ onesignalId: String,
+ subscriptionId: String,
+ startTimeMillis: Long,
+ ) {
+ val record =
+ SessionRecord().also {
+ it.id = sessionId
+ it.appId = appId
+ it.onesignalId = onesignalId
+ it.subscriptionId = subscriptionId
+ it.startTime = startTimeMillis / MILLIS_PER_SECOND
+ it.createKey = UUID.randomUUID().toString()
+ }
+ synchronized(lock) {
+ val records = store.list()
+ if (records.size >= MAX_RECORDS) {
+ Logging.warn("SessionsApiQueue: queue full, dropping session ${records.first().id}")
+ store.remove(records.first().id)
+ }
+ store.add(record)
+ }
+ wakeChannel.trySend(Unit)
+ }
+
+ override fun enqueueUpdate(
+ sessionId: String,
+ durationMillis: Long,
+ endTimeMillis: Long?,
+ ) {
+ synchronized(lock) {
+ val record = store.get(sessionId)
+ if (record == null || record.hasEnded) {
+ Logging.debug("SessionsApiQueue: ignoring update for session $sessionId, not queued or already ended")
+ return
+ }
+ record.durationSeconds = durationMillis / MILLIS_PER_SECOND
+ record.endTime = endTimeMillis?.let { it / MILLIS_PER_SECOND }
+ record.updateKey = UUID.randomUUID().toString()
+ }
+ wakeChannel.trySend(Unit)
+ }
+
+ override suspend fun flush() {
+ backoff.skipFailureDelay()
+ if (!backoff.isWaitingOnRetryAfter) sendPending()
+ }
+
+ /** Ends the failure backoff early. Still waits out a backend `Retry-After`. */
+ fun retryNow() {
+ if (backoff.skipFailureDelay()) wakeChannel.trySend(Unit)
+ }
+
+ private suspend fun sendPending() =
+ sendMutex.withLock {
+ for (record in store.list()) {
+ if (hasBackendIds(record) && !sender.send(record)) return@withLock
+ }
+ }
+
+ /**
+ * A local ID is replaced once the user or subscription is created. One that is no longer
+ * current never will be, so the record can never be sent.
+ */
+ private fun hasBackendIds(record: SessionRecord): Boolean {
+ val localIds = listOf(record.onesignalId, record.subscriptionId).filter(IDManager::isLocalId)
+ if (localIds.isEmpty()) return true
+ val currentIds = setOf(identityModelStore.currentOnesignalId(), configModelStore.model.pushSubscriptionId)
+ if (!currentIds.containsAll(localIds)) {
+ Logging.warn("SessionsApiQueue: dropping session ${record.id}, its local IDs will never be created")
+ synchronized(lock) { store.remove(record.id) }
+ }
+ return false
+ }
+
+ /** Carries a backend ID that replaces a local ID in place over to the queued records. */
+ private inner class LocalIdTranslator(
+ private val sourceProperty: String,
+ private val recordId: KMutableProperty1,
+ private val appliesTo: (SessionRecord) -> 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
+
+ synchronized(lock) {
+ store.list().filter { recordId.get(it) == localId && appliesTo(it) }.forEach { recordId.set(it, backendId) }
+ }
+ wakeChannel.trySend(Unit)
+ }
+ }
+
+ private companion object {
+ const val MILLIS_PER_SECOND = 1000L
+ const val MAX_RECORDS = 100
+ }
+}
+
+private fun IdentityModelStore.currentOnesignalId(): String? =
+ model.takeIf { it.hasProperty(IdentityConstants.ONESIGNAL_ID) }?.onesignalId
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/queue/impl/SessionsApiRetryTrigger.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/queue/impl/SessionsApiRetryTrigger.kt
new file mode 100644
index 0000000000..5f86422056
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/queue/impl/SessionsApiRetryTrigger.kt
@@ -0,0 +1,43 @@
+package com.onesignal.session.internal.session.queue.impl
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import com.onesignal.core.internal.application.ApplicationLifecycleHandlerBase
+import com.onesignal.core.internal.application.IApplicationService
+import com.onesignal.debug.internal.logging.Logging
+
+/**
+ * Calls [onRetry] when the app is foregrounded, and when a network becomes available while it is
+ * foregrounded. Android 15+ blocks network access for cached background apps, so a background
+ * attempt would only fail and lengthen the backoff.
+ */
+internal class SessionsApiRetryTrigger(
+ private val applicationService: IApplicationService,
+ private val onRetry: () -> Unit,
+) {
+ fun start() {
+ applicationService.addApplicationLifecycleHandler(
+ object : ApplicationLifecycleHandlerBase() {
+ override fun onFocus(firedOnSubscribe: Boolean) = onRetry()
+ },
+ )
+ try {
+ val connectivityManager =
+ applicationService.appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ val request = NetworkRequest.Builder().addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).build()
+ connectivityManager.registerNetworkCallback(
+ request,
+ object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: Network) {
+ if (applicationService.isInForeground) onRetry()
+ }
+ },
+ )
+ } catch (e: RuntimeException) {
+ Logging.warn("SessionsApiRetryTrigger: unable to watch for network changes", e)
+ }
+ }
+}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/queue/impl/SessionsApiSender.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/queue/impl/SessionsApiSender.kt
new file mode 100644
index 0000000000..0d2a014ec1
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/queue/impl/SessionsApiSender.kt
@@ -0,0 +1,139 @@
+package com.onesignal.session.internal.session.queue.impl
+
+import com.onesignal.common.exceptions.BackendException
+import com.onesignal.core.internal.device.IDeviceService
+import com.onesignal.debug.internal.logging.Logging
+import com.onesignal.session.internal.session.backend.CreateSessionRequest
+import com.onesignal.session.internal.session.backend.ISessionsBackendService
+import com.onesignal.session.internal.session.backend.UpdateSessionRequest
+import com.onesignal.session.internal.session.queue.SessionRecord
+import com.onesignal.session.internal.session.queue.SessionRecordStore
+
+/**
+ * Sends a [SessionRecord]'s create, then its pending update, and applies the result to the record.
+ * Record changes happen under [lock], so an update enqueued mid-send isn't lost.
+ */
+internal class SessionsApiSender(
+ private val store: SessionRecordStore,
+ private val backend: ISessionsBackendService,
+ private val deviceService: IDeviceService,
+ private val backoff: SessionsApiBackoff,
+ private val lock: Any,
+) {
+ /** Returns false on a retryable failure, which pauses the queue until the backoff ends. */
+ suspend fun send(record: SessionRecord): Boolean {
+ if (record.serverSessionId == null && !sendCreate(record)) return false
+ val update = synchronized(lock) { record.takeIf { it.serverSessionId != null }?.toUpdateRequest() }
+ return update == null || sendUpdate(record, update)
+ }
+
+ private suspend fun sendCreate(record: SessionRecord): Boolean {
+ val request =
+ CreateSessionRequest(
+ onesignalId = record.onesignalId,
+ subscriptionId = record.subscriptionId,
+ deviceType = deviceService.deviceType.value,
+ startTime = record.startTime,
+ idempotencyKey = record.createKey,
+ )
+ val serverSessionId =
+ try {
+ backend.createSession(record.appId, request)
+ } catch (e: BackendException) {
+ return onFailure(record, e)
+ }
+
+ return if (serverSessionId == null) {
+ onFailure(record, BackendException(MISSING_SESSION_ID))
+ } else {
+ synchronized(lock) {
+ record.serverSessionId = serverSessionId
+ record.failedAttempts = 0
+ }
+ backoff.recordSuccess()
+ true
+ }
+ }
+
+ private suspend fun sendUpdate(
+ record: SessionRecord,
+ request: UpdateSessionRequest,
+ ): Boolean {
+ try {
+ backend.updateSession(record.appId, record.serverSessionId!!, request)
+ } catch (e: BackendException) {
+ return onFailure(record, e)
+ }
+
+ synchronized(lock) {
+ // A newer update replaced this one while it was in flight; it is sent next.
+ if (record.updateKey == request.idempotencyKey) finishUpdate(record)
+ }
+ backoff.recordSuccess()
+ return true
+ }
+
+ private fun onFailure(
+ record: SessionRecord,
+ e: BackendException,
+ ): Boolean {
+ val answered = e.statusCode != NO_RESPONSE
+ if (answered && !isRetryable(e.statusCode)) {
+ Logging.warn("SessionsApiSender: dropping request for session ${record.id}, status ${e.statusCode}")
+ synchronized(lock) { dropHeadRequest(record) }
+ return true
+ }
+ if (answered) {
+ synchronized(lock) {
+ record.failedAttempts++
+ if (record.failedAttempts >= MAX_ATTEMPTS) {
+ Logging.warn("SessionsApiSender: dropping request for session ${record.id} after $MAX_ATTEMPTS attempts")
+ dropHeadRequest(record)
+ }
+ }
+ }
+ backoff.recordFailure(e.retryAfterSeconds)
+ return false
+ }
+
+ private fun isRetryable(statusCode: Int) =
+ statusCode >= SERVER_ERROR || statusCode == REQUEST_TIMEOUT || statusCode == TOO_MANY_REQUESTS ||
+ statusCode == MISSING_SESSION_ID
+
+ /** Updates can't be sent without a session, so a failed create takes them with it. */
+ private fun dropHeadRequest(record: SessionRecord) {
+ if (record.serverSessionId == null) store.remove(record.id) else finishUpdate(record)
+ }
+
+ private fun finishUpdate(record: SessionRecord) {
+ if (record.hasEnded) {
+ store.remove(record.id)
+ } else {
+ record.updateKey = null
+ record.durationSeconds = null
+ record.failedAttempts = 0
+ }
+ }
+
+ private fun SessionRecord.toUpdateRequest(): UpdateSessionRequest? =
+ updateKey?.let {
+ UpdateSessionRequest(
+ onesignalId = onesignalId,
+ subscriptionId = subscriptionId,
+ durationSeconds = durationSeconds ?: 0L,
+ idempotencyKey = it,
+ endTime = endTime,
+ )
+ }
+
+ private companion object {
+ const val MAX_ATTEMPTS = 5
+ const val NO_RESPONSE = 0
+ const val REQUEST_TIMEOUT = 408
+ const val TOO_MANY_REQUESTS = 429
+ const val SERVER_ERROR = 500
+
+ // A create that succeeds without a session ID is retried like a server error.
+ const val MISSING_SESSION_ID = -1
+ }
+}
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..21d218ac49 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,11 +2,20 @@ 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.debug.LogLevel
+import com.onesignal.debug.internal.logging.Logging
+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.session.internal.session.queue.ISessionsApiQueue
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
+import io.mockk.coVerify
+import io.mockk.coVerifyOrder
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
@@ -21,20 +30,57 @@ 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.appId = APP_ID
+ it.pushSubscriptionId = SUBSCRIPTION_ID
+ }
+
+ val sessionsApiQueue = mockk(relaxed = true)
+
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,
+ sessionsApiQueue,
+ )
val spyCallback = spyk()
+
+ companion object {
+ const val APP_ID = "app-id"
+ const val ONESIGNAL_ID = "onesignal-id"
+ const val SUBSCRIPTION_ID = "subscription-id"
+ }
}
class SessionServiceTests : FunSpec({
@@ -183,6 +229,303 @@ 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 enqueues a sessions API create 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
+ val sessionId = sessionModelStore.model.sessionId
+ verify(exactly = 1) {
+ mocks.sessionsApiQueue.enqueueCreate(
+ sessionId,
+ Mocks.APP_ID,
+ Mocks.ONESIGNAL_ID,
+ Mocks.SUBSCRIPTION_ID,
+ mocks.currentTime,
+ )
+ }
+ }
+
+ test("new session does not enqueue a create when the flag is off") {
+ // Given
+ val mocks = Mocks(sessionsApiEnabled = false)
+ val sessionService = mocks.sessionService
+ sessionService.bootstrap()
+ sessionService.start()
+ mocks.sessionModelStore { it.isValid = false }
+
+ // When
+ sessionService.onFocus(false)
+
+ // Then
+ verify(exactly = 0) { mocks.sessionsApiQueue.enqueueCreate(any(), any(), any(), any(), any()) }
+ }
+
+ test("resuming a session does not enqueue another create") {
+ // Given
+ val mocks = Mocks(sessionsApiEnabled = true)
+ val sessionService = mocks.sessionService
+ sessionService.bootstrap()
+ sessionService.start()
+ mocks.sessionModelStore { it.isValid = false }
+ sessionService.onFocus(false)
+
+ // When
+ sessionService.onUnfocused()
+ sessionService.onFocus(false)
+
+ // Then
+ verify(exactly = 1) { mocks.sessionsApiQueue.enqueueCreate(any(), any(), any(), any(), any()) }
+ }
+
+ test("new session does not enqueue a create without a push subscription ID") {
+ // Given
+ val mocks = Mocks(sessionsApiEnabled = true)
+ mocks.configModelStore.model.pushSubscriptionId = null
+ Logging.logLevel = LogLevel.NONE
+ val sessionService = mocks.sessionService
+ sessionService.bootstrap()
+ sessionService.start()
+ mocks.sessionModelStore { it.isValid = false }
+
+ // When
+ sessionService.onFocus(false)
+
+ // Then
+ verify(exactly = 0) { mocks.sessionsApiQueue.enqueueCreate(any(), any(), any(), any(), any()) }
+ }
+
+ test("background run enqueues a sessions API end and flushes it") {
+ // Given
+ val mocks = Mocks()
+ val sessionService = mocks.sessionService
+ sessionService.bootstrap()
+ sessionService.start()
+ mocks.sessionModelStore {
+ it.sessionId = "session-id"
+ it.isValid = true
+ it.usesSessionsApi = true
+ it.focusTime = 1000L
+ it.unfocusTime = 2000L
+ it.activeDuration = 555L
+ }
+
+ // When
+ sessionService.backgroundRun()
+
+ // Then
+ coVerifyOrder {
+ mocks.sessionsApiQueue.enqueueUpdate("session-id", 555L, 2000L)
+ mocks.sessionsApiQueue.flush()
+ }
+ }
+
+ test("session end time is the last focus when the app was killed while focused") {
+ // Given
+ val mocks = Mocks()
+ val sessionService = mocks.sessionService
+ sessionService.bootstrap()
+ sessionService.start()
+ mocks.sessionModelStore {
+ it.sessionId = "session-id"
+ it.isValid = true
+ it.usesSessionsApi = true
+ it.focusTime = 3000L
+ it.unfocusTime = 2000L
+ it.activeDuration = 555L
+ }
+
+ // When
+ sessionService.backgroundRun()
+
+ // Then
+ verify { mocks.sessionsApiQueue.enqueueUpdate("session-id", 555L, 3000L) }
+ }
+
+ test("legacy session end does not use the sessions API") {
+ // Given
+ val mocks = Mocks()
+ val sessionService = mocks.sessionService
+ sessionService.bootstrap()
+ sessionService.start()
+ mocks.sessionModelStore {
+ it.isValid = true
+ it.usesSessionsApi = false
+ }
+
+ // When
+ sessionService.backgroundRun()
+
+ // Then
+ verify(exactly = 0) { mocks.sessionsApiQueue.enqueueUpdate(any(), any(), any()) }
+ coVerify(exactly = 0) { mocks.sessionsApiQueue.flush() }
+ }
+
+ test("cold start ends the previous session before creating the next one") {
+ // Given
+ val mocks = Mocks(sessionsApiEnabled = true)
+ val sessionService = mocks.sessionService
+ sessionService.bootstrap()
+ sessionService.start()
+ val sessionModelStore =
+ mocks.sessionModelStore {
+ it.sessionId = "previous-session"
+ it.isValid = true
+ it.usesSessionsApi = true
+ it.focusTime = 1000L
+ it.unfocusTime = 900L
+ it.activeDuration = 555L
+ }
+
+ // When
+ sessionService.onFocus(false)
+
+ // Then
+ val sessionId = sessionModelStore.model.sessionId
+ verifyOrder {
+ mocks.sessionsApiQueue.enqueueUpdate("previous-session", 555L, 1000L)
+ mocks.sessionsApiQueue.enqueueCreate(sessionId, any(), any(), any(), any())
+ }
+ }
+
+ 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.unfocusTime = 321L
+
+ // 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.unfocusTime shouldBe 321L
+ }
+
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/session/internal/session/backend/SessionsBackendServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/backend/SessionsBackendServiceTests.kt
new file mode 100644
index 0000000000..646852b968
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/backend/SessionsBackendServiceTests.kt
@@ -0,0 +1,181 @@
+package com.onesignal.session.internal.session.backend
+
+import com.onesignal.common.exceptions.BackendException
+import com.onesignal.core.internal.http.HttpResponse
+import com.onesignal.core.internal.http.IHttpClient
+import com.onesignal.debug.LogLevel
+import com.onesignal.debug.internal.logging.Logging
+import com.onesignal.session.internal.session.backend.impl.SessionsBackendService
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+
+private val createRequest =
+ CreateSessionRequest(
+ onesignalId = "onesignalId",
+ subscriptionId = "subscriptionId",
+ deviceType = 1,
+ startTime = 1_700_000_000L,
+ idempotencyKey = "create-key",
+ )
+
+private val updateRequest =
+ UpdateSessionRequest(
+ onesignalId = "onesignalId",
+ subscriptionId = "subscriptionId",
+ durationSeconds = 42L,
+ idempotencyKey = "update-key",
+ )
+
+class SessionsBackendServiceTests : FunSpec({
+ beforeAny {
+ Logging.logLevel = LogLevel.NONE
+ }
+
+ test("create session posts the request body") {
+ // Given
+ val http = mockk()
+ coEvery { http.post(any(), any()) } returns HttpResponse(202, """{"data":{"session_id":"server-id"}}""")
+ val service = SessionsBackendService(http)
+
+ // When
+ service.createSession("appId", createRequest)
+
+ // Then
+ coVerify {
+ http.post(
+ "apps/appId/sessions",
+ withArg {
+ it.getString("onesignal_id") shouldBe "onesignalId"
+ it.getString("subscription_id") shouldBe "subscriptionId"
+ it.getInt("device_type") shouldBe 1
+ it.getString("start_time") shouldBe "2023-11-14T22:13:20.000Z"
+ it.getString("idempotency_key") shouldBe "create-key"
+ it.has("direct_attribution_id") shouldBe false
+ },
+ )
+ }
+ }
+
+ test("create session includes direct attribution when set") {
+ // Given
+ val http = mockk()
+ coEvery { http.post(any(), any()) } returns HttpResponse(202, """{"data":{"session_id":"server-id"}}""")
+ val service = SessionsBackendService(http)
+
+ // When
+ service.createSession("appId", createRequest.copy(directAttributionId = "notificationId"))
+
+ // Then
+ coVerify {
+ http.post(
+ "apps/appId/sessions",
+ withArg { it.getString("direct_attribution_id") shouldBe "notificationId" },
+ )
+ }
+ }
+
+ test("create session returns the session ID from a 202") {
+ // Given
+ val http = mockk()
+ coEvery { http.post(any(), any()) } returns HttpResponse(202, """{"data":{"session_id":"server-id"}}""")
+ val service = SessionsBackendService(http)
+
+ // When
+ val result = service.createSession("appId", createRequest)
+
+ // Then
+ result shouldBe "server-id"
+ }
+
+ test("create session returns null for a success response without a session ID") {
+ listOf(
+ null,
+ "",
+ "not json",
+ "{}",
+ """{"data":{}}""",
+ """{"data":{"session_id":""}}""",
+ """{"data":{"session_id":null}}""",
+ """{"data":{"session_id":123}}""",
+ """{"data":"server-id"}""",
+ ).forEach { payload ->
+ // Given
+ val http = mockk()
+ coEvery { http.post(any(), any()) } returns HttpResponse(202, payload)
+ val service = SessionsBackendService(http)
+
+ // When
+ val result = service.createSession("appId", createRequest)
+
+ // Then
+ result shouldBe null
+ }
+ }
+
+ test("update session patches the request body") {
+ // Given
+ val http = mockk()
+ coEvery { http.patch(any(), any()) } returns HttpResponse(202, null)
+ val service = SessionsBackendService(http)
+
+ // When
+ service.updateSession("appId", "server-id", updateRequest)
+
+ // Then
+ coVerify {
+ http.patch(
+ "apps/appId/sessions/server-id",
+ withArg {
+ it.getString("onesignal_id") shouldBe "onesignalId"
+ it.getString("subscription_id") shouldBe "subscriptionId"
+ it.getLong("duration_seconds") shouldBe 42L
+ it.getString("idempotency_key") shouldBe "update-key"
+ it.has("end_time") shouldBe false
+ },
+ )
+ }
+ }
+
+ test("update session includes end time when set") {
+ // Given
+ val http = mockk()
+ coEvery { http.patch(any(), any()) } returns HttpResponse(202, null)
+ val service = SessionsBackendService(http)
+
+ // When
+ service.updateSession("appId", "server-id", updateRequest.copy(endTime = 1_700_000_042L))
+
+ // Then
+ coVerify {
+ http.patch(
+ "apps/appId/sessions/server-id",
+ withArg { it.getString("end_time") shouldBe "2023-11-14T22:14:02.000Z" },
+ )
+ }
+ }
+
+ test("failures throw BackendException with the status code and Retry-After") {
+ listOf(-1, 0, 400, 404, 422, 429, 503).forEach { statusCode ->
+ // Given
+ val http = mockk()
+ coEvery { http.post(any(), any()) } returns HttpResponse(statusCode, "error", retryAfterSeconds = 30)
+ coEvery { http.patch(any(), any()) } returns HttpResponse(statusCode, "error", retryAfterSeconds = 30)
+ val service = SessionsBackendService(http)
+
+ // When
+ val createException = shouldThrow { service.createSession("appId", createRequest) }
+ val updateException = shouldThrow { service.updateSession("appId", "server-id", updateRequest) }
+
+ // Then
+ listOf(createException, updateException).forEach {
+ it.statusCode shouldBe statusCode
+ it.response shouldBe "error"
+ it.retryAfterSeconds shouldBe 30
+ }
+ }
+ }
+})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/queue/SessionsApiQueueTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/queue/SessionsApiQueueTests.kt
new file mode 100644
index 0000000000..08d6b569a9
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/queue/SessionsApiQueueTests.kt
@@ -0,0 +1,380 @@
+package com.onesignal.session.internal.session.queue
+
+import com.onesignal.common.exceptions.BackendException
+import com.onesignal.common.modeling.ISingletonModelStoreChangeHandler
+import com.onesignal.common.modeling.ModelChangeTags
+import com.onesignal.common.modeling.ModelChangedArgs
+import com.onesignal.core.internal.device.IDeviceService
+import com.onesignal.core.internal.time.ITime
+import com.onesignal.debug.LogLevel
+import com.onesignal.debug.internal.logging.Logging
+import com.onesignal.mocks.MockHelper
+import com.onesignal.mocks.MockPreferencesService
+import com.onesignal.session.internal.session.backend.CreateSessionRequest
+import com.onesignal.session.internal.session.backend.ISessionsBackendService
+import com.onesignal.session.internal.session.backend.UpdateSessionRequest
+import com.onesignal.session.internal.session.queue.impl.SessionsApiQueue
+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.collections.shouldHaveSize
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.coVerifyOrder
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.runs
+import io.mockk.slot
+
+private class Mocks {
+ val prefs = MockPreferencesService()
+ val backend = mockk()
+ var elapsedRealtime = 1_000_000L
+ val time = mockk().also { every { it.elapsedRealtimeMillis } answers { elapsedRealtime } }
+ val deviceService = mockk().also { every { it.deviceType } returns IDeviceService.DeviceType.Android }
+ val identityModelStore = MockHelper.identityModelStore { it.onesignalId = ONESIGNAL_ID }
+ val configModelStore = MockHelper.configModelStore { it.pushSubscriptionId = SUBSCRIPTION_ID }
+ val applicationService =
+ MockHelper.applicationService().also {
+ every { it.appContext } throws IllegalStateException("no context in unit tests")
+ every { it.isInForeground } returns true
+ }
+ val identityChangeHandler = slot>()
+
+ val createKeys = mutableListOf()
+ val updates = mutableListOf()
+
+ init {
+ every { identityModelStore.subscribe(capture(identityChangeHandler)) } just runs
+ coEvery { backend.createSession(any(), any()) } answers {
+ createKeys.add(secondArg().idempotencyKey)
+ SERVER_SESSION_ID
+ }
+ coEvery { backend.updateSession(any(), any(), any()) } answers { updates.add(thirdArg()) }
+ }
+
+ fun queue(store: SessionRecordStore = SessionRecordStore(prefs)) =
+ SessionsApiQueue(store, backend, deviceService, identityModelStore, configModelStore, applicationService, time)
+ .also { it.backoff.jitter = { 1.0 } }
+
+ fun SessionsApiQueue.enqueueSession(
+ sessionId: String = SESSION_ID,
+ onesignalId: String = ONESIGNAL_ID,
+ subscriptionId: String = SUBSCRIPTION_ID,
+ ) = enqueueCreate(sessionId, APP_ID, onesignalId, subscriptionId, startTimeMillis = 1_700_000_000_000L)
+
+ fun createFailsWith(vararg statusCodes: Int) {
+ val responses = statusCodes.toMutableList()
+ coEvery { backend.createSession(any(), any()) } answers {
+ createKeys.add(secondArg().idempotencyKey)
+ responses.removeFirstOrNull()?.let { throw BackendException(it) }
+ SERVER_SESSION_ID
+ }
+ }
+
+ 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,
+ )
+ }
+
+ companion object {
+ const val APP_ID = "app-id"
+ const val SESSION_ID = "session-id"
+ const val ONESIGNAL_ID = "onesignal-id"
+ const val SUBSCRIPTION_ID = "subscription-id"
+ const val SERVER_SESSION_ID = "server-session-id"
+ }
+}
+
+class SessionsApiQueueTests : FunSpec({
+ beforeAny {
+ Logging.logLevel = LogLevel.NONE
+ }
+
+ test("sends the create, then the end, and forgets the session") {
+ // Given
+ val mocks = Mocks()
+ val store = SessionRecordStore(mocks.prefs)
+ val queue = with(mocks) { queue(store).apply { enqueueSession() } }
+ queue.enqueueUpdate(Mocks.SESSION_ID, durationMillis = 42_500L, endTimeMillis = 1_700_000_060_000L)
+
+ // When
+ queue.flush()
+
+ // Then
+ coVerifyOrder {
+ mocks.backend.createSession(
+ Mocks.APP_ID,
+ withArg {
+ it.onesignalId shouldBe Mocks.ONESIGNAL_ID
+ it.subscriptionId shouldBe Mocks.SUBSCRIPTION_ID
+ it.deviceType shouldBe 1
+ it.startTime shouldBe 1_700_000_000L
+ },
+ )
+ mocks.backend.updateSession(
+ Mocks.APP_ID,
+ Mocks.SERVER_SESSION_ID,
+ withArg {
+ it.durationSeconds shouldBe 42L
+ it.endTime shouldBe 1_700_000_060L
+ },
+ )
+ }
+ store.list() shouldHaveSize 0
+ }
+
+ test("a newer update replaces an unsent one") {
+ // Given
+ val mocks = Mocks()
+ val queue = with(mocks) { queue().apply { enqueueSession() } }
+ queue.enqueueUpdate(Mocks.SESSION_ID, durationMillis = 10_000L)
+
+ // When
+ queue.enqueueUpdate(Mocks.SESSION_ID, durationMillis = 20_000L)
+ queue.flush()
+
+ // Then
+ mocks.updates.map { it.durationSeconds } shouldBe listOf(20L)
+ }
+
+ test("an update after the end is ignored") {
+ // Given
+ val mocks = Mocks()
+ val queue = with(mocks) { queue().apply { enqueueSession() } }
+ queue.enqueueUpdate(Mocks.SESSION_ID, durationMillis = 10_000L, endTimeMillis = 1_700_000_060_000L)
+
+ // When
+ queue.enqueueUpdate(Mocks.SESSION_ID, durationMillis = 20_000L)
+ queue.flush()
+
+ // Then
+ mocks.updates.map { it.durationSeconds to it.endTime } shouldBe listOf(10L to 1_700_000_060L)
+ }
+
+ test("a sent duration update keeps the session for later updates") {
+ // Given
+ val mocks = Mocks()
+ val queue = with(mocks) { queue().apply { enqueueSession() } }
+ queue.enqueueUpdate(Mocks.SESSION_ID, durationMillis = 10_000L)
+ queue.flush()
+
+ // When
+ queue.enqueueUpdate(Mocks.SESSION_ID, durationMillis = 20_000L, endTimeMillis = 1_700_000_060_000L)
+ queue.flush()
+
+ // Then
+ mocks.updates.map { it.durationSeconds } shouldBe listOf(10L, 20L)
+ coVerify(exactly = 1) { mocks.backend.createSession(any(), any()) }
+ }
+
+ test("a retry reuses the idempotency key, including after a restart") {
+ // Given
+ val mocks = Mocks()
+ mocks.createFailsWith(500)
+ with(mocks) { queue().apply { enqueueSession() } }.flush()
+
+ // When
+ mocks.queue().flush()
+
+ // Then
+ mocks.createKeys shouldHaveSize 2
+ mocks.createKeys.distinct() shouldHaveSize 1
+ }
+
+ test("a create that succeeds without a session ID is retried") {
+ // Given
+ val mocks = Mocks()
+ val serverIds = mutableListOf(null, Mocks.SERVER_SESSION_ID)
+ coEvery { mocks.backend.createSession(any(), any()) } answers { serverIds.removeFirst() }
+ val queue = with(mocks) { queue().apply { enqueueSession() } }
+ queue.enqueueUpdate(Mocks.SESSION_ID, durationMillis = 10_000L)
+
+ // When
+ queue.flush()
+ queue.flush()
+
+ // Then
+ coVerify(exactly = 2) { mocks.backend.createSession(any(), any()) }
+ mocks.updates shouldHaveSize 1
+ }
+
+ test("a create rejected with a 4xx is dropped with its updates") {
+ // Given
+ val mocks = Mocks()
+ mocks.createFailsWith(422)
+ val store = SessionRecordStore(mocks.prefs)
+ val queue = with(mocks) { queue(store).apply { enqueueSession() } }
+ queue.enqueueUpdate(Mocks.SESSION_ID, durationMillis = 10_000L)
+
+ // When
+ queue.flush()
+
+ // Then
+ store.list() shouldHaveSize 0
+ mocks.updates shouldHaveSize 0
+ }
+
+ test("a request is dropped after 5 failures the backend answered") {
+ // Given
+ val mocks = Mocks()
+ mocks.createFailsWith(500, 500, 500, 500, 500, 500)
+ val store = SessionRecordStore(mocks.prefs)
+ val queue = with(mocks) { queue(store).apply { enqueueSession() } }
+
+ // When
+ repeat(5) { queue.flush() }
+
+ // Then
+ store.list() shouldHaveSize 0
+ }
+
+ test("network errors don't count toward the attempt cap") {
+ // Given
+ val mocks = Mocks()
+ mocks.createFailsWith(0, 0, 0, 0, 0, 0)
+ val store = SessionRecordStore(mocks.prefs)
+ val queue = with(mocks) { queue(store).apply { enqueueSession() } }
+
+ // When
+ repeat(6) { queue.flush() }
+ queue.flush()
+
+ // Then
+ mocks.createKeys shouldHaveSize 7
+ store.get(Mocks.SESSION_ID)?.serverSessionId shouldBe Mocks.SERVER_SESSION_ID
+ }
+
+ test("a failing session doesn't block other sessions after it is dropped") {
+ // Given
+ val mocks = Mocks()
+ mocks.createFailsWith(422)
+ val queue =
+ with(mocks) {
+ queue().apply {
+ enqueueSession(sessionId = "first")
+ enqueueSession(sessionId = "second")
+ }
+ }
+
+ // When
+ queue.flush()
+
+ // Then
+ mocks.createKeys shouldHaveSize 2
+ }
+
+ test("backoff doubles after each failure, up to the cap") {
+ // Given
+ val mocks = Mocks()
+ mocks.createFailsWith(*IntArray(9) { 0 })
+ val queue = with(mocks) { queue().apply { enqueueSession() } }
+
+ // When
+ val waits =
+ (1..9).map {
+ queue.flush()
+ queue.backoff.waitMillis
+ }
+
+ // Then
+ waits shouldBe listOf(5_000L, 10_000L, 20_000L, 40_000L, 80_000L, 160_000L, 300_000L, 300_000L, 300_000L)
+ }
+
+ test("Retry-After holds requests even when flushed") {
+ // Given
+ val mocks = Mocks()
+ coEvery { mocks.backend.createSession(any(), any()) } throws BackendException(429, retryAfterSeconds = 60)
+ val queue = with(mocks) { queue().apply { enqueueSession() } }
+ queue.flush()
+
+ // When
+ queue.retryNow()
+ queue.flush()
+
+ // Then
+ queue.backoff.waitMillis shouldBe 60_000L
+ coVerify(exactly = 1) { mocks.backend.createSession(any(), any()) }
+ }
+
+ test("retryNow ends the failure backoff") {
+ // Given
+ val mocks = Mocks()
+ mocks.createFailsWith(500)
+ val queue = with(mocks) { queue().apply { enqueueSession() } }
+ queue.flush()
+
+ // When
+ queue.retryNow()
+
+ // Then
+ queue.backoff.waitMillis shouldBe 0L
+ }
+
+ test("the oldest session is dropped when the queue is full") {
+ // Given
+ val mocks = Mocks()
+ val store = SessionRecordStore(mocks.prefs)
+ val queue = mocks.queue(store)
+
+ // When
+ with(mocks) { repeat(101) { queue.enqueueSession(sessionId = "session-$it") } }
+
+ // Then
+ store.list() shouldHaveSize 100
+ store.get("session-0") shouldBe null
+ }
+
+ test("a session waits for its user to be created, then sends with the backend ID") {
+ // Given
+ val mocks = Mocks()
+ mocks.identityModelStore.model.onesignalId = "local-user"
+ val queue = with(mocks) { queue().apply { enqueueSession(onesignalId = "local-user") } }
+ queue.start()
+ queue.flush()
+ coVerify(exactly = 0) { mocks.backend.createSession(any(), any()) }
+
+ // When
+ mocks.hydrateOnesignalId("backend-user")
+ queue.flush()
+
+ // Then
+ coVerify(exactly = 1) { mocks.backend.createSession(any(), withArg { it.onesignalId shouldBe "backend-user" }) }
+ }
+
+ test("a session whose local user ID is no longer current is dropped") {
+ // Given
+ val mocks = Mocks()
+ val store = SessionRecordStore(mocks.prefs)
+ val queue = with(mocks) { queue(store).apply { enqueueSession(onesignalId = "local-previous-user") } }
+
+ // When
+ queue.flush()
+
+ // Then
+ store.list() shouldHaveSize 0
+ coVerify(exactly = 0) { mocks.backend.createSession(any(), any()) }
+ }
+
+ test("a session only waits on its own subscription ID") {
+ // Given
+ val mocks = Mocks()
+ mocks.configModelStore.model.pushSubscriptionId = "local-subscription"
+ val store = SessionRecordStore(mocks.prefs)
+ val queue = with(mocks) { queue(store).apply { enqueueSession(subscriptionId = "local-subscription") } }
+
+ // When
+ queue.flush()
+
+ // Then
+ store.list() shouldHaveSize 1
+ coVerify(exactly = 0) { mocks.backend.createSession(any(), any()) }
+ }
+})
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
}