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 }