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 dc55546b3..f76f40408 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 @@ -46,6 +46,15 @@ class SessionModel : Model() { setLongProperty(::focusTime.name, value) } + /** + * When this app was last unfocused, in Unix time milliseconds. Null until the first unfocus. + */ + var unfocusTime: Long? + get() = getOptLongProperty(::unfocusTime.name) + set(value) { + setOptLongProperty(::unfocusTime.name, value) + } + /** * How long this session has spent as active, in milliseconds. */ 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 a6bbd0f5e..246b48148 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 @@ -24,8 +24,13 @@ 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.operations.CreateSessionOperation +import com.onesignal.session.internal.session.operations.UpdateSessionOperation import com.onesignal.user.internal.backend.IdentityConstants import com.onesignal.user.internal.identity.IdentityModelStore +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import java.util.UUID import kotlin.reflect.KMutableProperty1 @@ -49,6 +54,10 @@ internal class SessionService( private val identityModelStore: IdentityModelStore, private val operationRepo: IOperationRepo, ) : ISessionService, IBootstrapService, IStartableService, IBackgroundService, IApplicationLifecycleHandler { + // Not a constructor parameter, since the service container can only inject registered services. + // Tests replace it with a test dispatcher so the end send stays on the virtual clock. + internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO + 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 @@ -132,21 +141,35 @@ internal class SessionService( * the SDK. */ override suspend fun backgroundRun() { - endSession() + val end = endSession() ?: return + // Android 15+ blocks network requests started after this job finishes, so send the end now + // rather than after the queue's batching delay. Flushing is bounded to once per session. + // On timeout the end stays queued and is sent on the next app open. + // BackgroundManager runs this on Dispatchers.Unconfined, so without the switch the rest of its + // loop would resume on the operation repo thread that wakes the wait. + val sent = + withTimeoutOrNull(END_SESSION_SEND_TIMEOUT_MS) { + withContext(ioDispatcher) { operationRepo.enqueueAndWait(end, flush = true) } + } + Logging.debug("SessionService.backgroundRun: session end sent: $sent") } - private fun endSession() { + /** + * @return the operation that ends the session through the sessions API, for the caller to enqueue. + */ + private fun endSession(): UpdateSessionOperation? { // 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 null val activeDuration = session.activeDuration Logging.debug("SessionService.backgroundRun: Session ended. activeDuration: $activeDuration") session.isValid = false + val end = if (session.usesSessionsApi) endSessionOperation(session, config?.appId) else null sessionLifeCycleNotifier.fire { it.onSessionEnded(activeDuration) } session.activeDuration = 0L + return end } /** @@ -184,7 +207,7 @@ internal class SessionService( // Treat app cold starts as a new session, we attempt to end any previous session to do this. if (!hasFocused) { hasFocused = true - endSession() + endSession()?.let { operationRepo.enqueue(it) } } if (!session.isValid) { @@ -194,6 +217,7 @@ internal class SessionService( session.startTime = focusTimeMs session.focusTime = session.startTime session.focusElapsedRealtime = focusElapsedMs + session.unfocusTime = null session.usesSessionsApi = featureManager.isEnabled(FeatureFlag.SDK_SESSIONS_V2_API_CUTOVER) session.onesignalId = identityModelStore.model @@ -243,6 +267,7 @@ internal class SessionService( unfocusTimeMs - session.focusTime } session.activeDuration += dt + session.unfocusTime = unfocusTimeMs Logging.log(LogLevel.DEBUG, "SessionService.onUnfocused adding time $dt for total: ${session.activeDuration}") } @@ -256,28 +281,59 @@ internal class SessionService( override val hasSubscribers: Boolean get() = sessionLifeCycleNotifier.hasSubscribers + + companion object { + // Covers the post-create delay when the create is still queued, plus the request itself. + private const val END_SESSION_SEND_TIMEOUT_MS = 30_000L + } } private fun createSessionOperation( session: SessionModel, appId: String?, externalId: String?, -): CreateSessionOperation? { +): CreateSessionOperation? = + withSessionIds(session, appId, "creating") { resolvedAppId, onesignalId, subscriptionId -> + CreateSessionOperation( + appId = resolvedAppId, + localSessionId = session.localSessionId, + onesignalId = onesignalId, + subscriptionId = subscriptionId, + startTime = session.startTime, + externalId = externalId, + ) + } + +private fun endSessionOperation( + session: SessionModel, + appId: String?, +): UpdateSessionOperation? = + withSessionIds(session, appId, "ending") { resolvedAppId, onesignalId, subscriptionId -> + UpdateSessionOperation( + appId = resolvedAppId, + sessionId = session.localSessionId, + onesignalId = onesignalId, + subscriptionId = subscriptionId, + activeDuration = session.activeDuration, + // An app killed while focused has no unfocus after its last focus. + endTime = maxOf(session.unfocusTime ?: 0L, session.focusTime), + ) + } + +private fun withSessionIds( + session: SessionModel, + appId: String?, + action: String, + block: (appId: String, onesignalId: String, subscriptionId: String) -> T, +): T? { val onesignalId = session.onesignalId val subscriptionId = session.subscriptionId if (appId == null || onesignalId == null || subscriptionId == null) { Logging.warn( - "SessionService: not creating session ${session.sessionId}, missing appId: ${appId == null}, " + + "SessionService: not $action session ${session.sessionId}, missing appId: ${appId == null}, " + "onesignalId: ${onesignalId == null}, subscriptionId: ${subscriptionId == null}", ) return null } - return CreateSessionOperation( - appId = appId, - localSessionId = session.localSessionId, - onesignalId = onesignalId, - subscriptionId = subscriptionId, - startTime = session.startTime, - externalId = externalId, - ) + return block(appId, onesignalId, subscriptionId) } 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 39b33e2fe..b19465312 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 @@ -19,11 +19,14 @@ import com.onesignal.mocks.MockHelper import com.onesignal.mocks.MockPreferencesService import com.onesignal.session.internal.session.impl.SessionService import com.onesignal.session.internal.session.operations.CreateSessionOperation +import com.onesignal.session.internal.session.operations.UpdateSessionOperation import com.onesignal.user.internal.backend.IdentityConstants import com.onesignal.user.internal.identity.IdentityModel import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -37,19 +40,22 @@ import io.mockk.unmockkStatic import io.mockk.verify import io.mockk.verifyOrder import kotlinx.coroutines.Job +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest // Mocks used by every test in this file private class Mocks( var sessionsApiEnabled: Boolean = false, ) { - val currentTime = 1111L + var currentTime = 1111L var elapsedRealtime = 5000L private val mockSessionModelStore = MockHelper.sessionModelStore() val time: ITime = mockk().also { - every { it.currentTimeMillis } returns currentTime + every { it.currentTimeMillis } answers { currentTime } every { it.elapsedRealtimeMillis } answers { elapsedRealtime } } @@ -94,6 +100,7 @@ private class Mocks( val operationRepo: IOperationRepo = mockk().also { every { it.enqueue(capture(enqueued), any()) } just runs + coEvery { it.enqueueAndWait(capture(enqueued), any()) } returns true } fun sessionModelStore(action: ((SessionModel) -> Unit)? = null): SessionModelStore { @@ -316,6 +323,25 @@ class SessionServiceTests : FunSpec({ sessionModelStore.model.usesSessionsApi shouldBe true } + test("new session clears the previous session's unfocus time") { + // Given + val mocks = Mocks(sessionsApiEnabled = true) + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + val sessionModelStore = + mocks.sessionModelStore { + it.isValid = false + it.unfocusTime = 900L + } + + // When + sessionService.onFocus(false) + + // Then + sessionModelStore.model.unfocusTime shouldBe null + } + test("new session pins onesignal and subscription IDs and a new local session ID") { // Given val mocks = Mocks() @@ -491,7 +517,7 @@ class SessionServiceTests : FunSpec({ sessionService.onFocus(false) // Then - val creates = mocks.enqueued.map { it as CreateSessionOperation } + val creates = mocks.enqueued.filterIsInstance() creates.size shouldBe 2 creates.map { it.localSessionId }.distinct().size shouldBe 2 creates.map { it.idempotencyKey }.distinct().size shouldBe 2 @@ -570,6 +596,178 @@ class SessionServiceTests : FunSpec({ verify(exactly = 1) { mocks.spyCallback.onSessionStarted() } } + test("background run sends the end with the unfocus time and final duration 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 } + sessionService.onFocus(false) + mocks.elapsedRealtime += 750L + mocks.currentTime = 2222L + sessionService.onUnfocused() + mocks.currentTime = 32222L + + // When + sessionService.backgroundRun() + + // Then + val end = mocks.enqueued.filterIsInstance().single() + end.appId shouldBe MockHelper.DEFAULT_APP_ID + end.sessionId shouldBe sessionModelStore.model.localSessionId + end.onesignalId shouldBe Mocks.ONESIGNAL_ID + end.subscriptionId shouldBe Mocks.SUBSCRIPTION_ID + end.activeDuration shouldBe 750L + end.endTime shouldBe 2222L + coVerify(exactly = 1) { mocks.operationRepo.enqueueAndWait(end, true) } + sessionModelStore.model.isValid shouldBe false + } + + test("background run waits for the end on an IO thread rather than the caller's") { + // Given + val mocks = Mocks(sessionsApiEnabled = true) + var waitThread: Thread? = null + coEvery { mocks.operationRepo.enqueueAndWait(any(), any()) } coAnswers { + waitThread = Thread.currentThread() + true + } + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + mocks.sessionModelStore { it.isValid = false } + sessionService.onFocus(false) + sessionService.onUnfocused() + + // When + sessionService.backgroundRun() + + // Then + waitThread shouldNotBe null + waitThread shouldNotBe Thread.currentThread() + } + + test("background run returns when the end is not sent in time and leaves it queued") { + // Given + val mocks = Mocks(sessionsApiEnabled = true) + coEvery { mocks.operationRepo.enqueueAndWait(any(), any()) } coAnswers { awaitCancellation() } + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + val sessionModelStore = mocks.sessionModelStore { it.isValid = false } + sessionService.onFocus(false) + sessionService.onUnfocused() + + runTest { + sessionService.ioDispatcher = StandardTestDispatcher(testScheduler) + + // When + sessionService.backgroundRun() + + // Then + testScheduler.currentTime shouldBe 30_000L + } + coVerify(exactly = 1) { mocks.operationRepo.enqueueAndWait(ofType(), true) } + coVerify(exactly = 0) { mocks.operationRepo.enqueue(ofType(), any()) } + sessionModelStore.model.isValid shouldBe false + } + + test("cold start ends the previous sessions API session before creating a new one") { + // Given + val mocks = Mocks(sessionsApiEnabled = true) + mocks.sessionModelStore { + it.isValid = true + it.sessionId = "previous-session" + it.localSessionId = "local-previous-session" + it.usesSessionsApi = true + it.onesignalId = Mocks.ONESIGNAL_ID + it.subscriptionId = Mocks.SUBSCRIPTION_ID + it.focusTime = 500L + it.unfocusTime = 900L + it.activeDuration = 400L + } + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + + // When + sessionService.onFocus(false) + + // Then + val end = mocks.enqueued[0] as UpdateSessionOperation + end.sessionId shouldBe "local-previous-session" + end.activeDuration shouldBe 400L + end.endTime shouldBe 900L + (mocks.enqueued[1] is CreateSessionOperation) shouldBe true + coVerify(exactly = 0) { mocks.operationRepo.enqueueAndWait(any(), any()) } + } + + test("cold start after a kill while focused ends the previous session at its last focus") { + // Given + val mocks = Mocks(sessionsApiEnabled = true) + mocks.sessionModelStore { + it.isValid = true + it.sessionId = "previous-session" + it.localSessionId = "local-previous-session" + it.usesSessionsApi = true + it.onesignalId = Mocks.ONESIGNAL_ID + it.subscriptionId = Mocks.SUBSCRIPTION_ID + it.unfocusTime = 300L + it.focusTime = 500L + } + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + + // When + sessionService.onFocus(false) + + // Then + val end = mocks.enqueued.filterIsInstance().single() + end.endTime shouldBe 500L + } + + test("session end does not send an end when the session uses the legacy path") { + // Given + val mocks = Mocks(sessionsApiEnabled = false) + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + mocks.sessionModelStore { it.isValid = false } + sessionService.subscribe(mocks.spyCallback) + sessionService.onFocus(false) + sessionService.onUnfocused() + mocks.sessionsApiEnabled = true + + // When + sessionService.backgroundRun() + + // Then + mocks.enqueued shouldBe emptyList() + coVerify(exactly = 0) { mocks.operationRepo.enqueueAndWait(any(), any()) } + verify(exactly = 1) { mocks.spyCallback.onSessionEnded(any()) } + } + + test("session end does not send an end without a push subscription ID") { + // Given + val mocks = Mocks(sessionsApiEnabled = true) + mocks.configModelStore.model.pushSubscriptionId = null + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + mocks.sessionModelStore { it.isValid = false } + sessionService.subscribe(mocks.spyCallback) + sessionService.onFocus(false) + sessionService.onUnfocused() + + // When + sessionService.backgroundRun() + + // Then + mocks.enqueued shouldBe emptyList() + verify(exactly = 1) { mocks.spyCallback.onSessionEnded(any()) } + } + test("sessions API active duration uses the monotonic clock") { // Given val mocks = Mocks(sessionsApiEnabled = true) @@ -637,6 +835,7 @@ class SessionServiceTests : FunSpec({ store.model.sessionId = "session-id" store.model.startTime = 123L store.model.activeDuration = 456L + store.model.unfocusTime = 321L store.model.focusElapsedRealtime = 789L store.model.usesSessionsApi = true store.model.onesignalId = Mocks.ONESIGNAL_ID @@ -649,6 +848,7 @@ class SessionServiceTests : FunSpec({ reloaded.sessionId shouldBe "session-id" reloaded.startTime shouldBe 123L reloaded.activeDuration shouldBe 456L + reloaded.unfocusTime shouldBe 321L reloaded.focusElapsedRealtime shouldBe 789L reloaded.usesSessionsApi shouldBe true reloaded.onesignalId shouldBe Mocks.ONESIGNAL_ID