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 index 9ca5869a4..94c68009b 100644 --- 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 @@ -31,7 +31,7 @@ internal interface ISessionsBackendService { internal data class CreateSessionRequest( val onesignalId: String, val subscriptionId: String, - val deviceType: Int, + val subscriptionType: String, /** Unix time in seconds, sent as ISO 8601 UTC. */ val startTime: Long, val idempotencyKey: String, 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 index c3220ca19..8dd52e234 100644 --- 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 @@ -22,7 +22,7 @@ internal class SessionsBackendService( JSONObject() .put("onesignal_id", request.onesignalId) .put("subscription_id", request.subscriptionId) - .put("device_type", request.deviceType) + .put("subscription_type", request.subscriptionType) .put("start_time", toIso8601(request.startTime)) .put("idempotency_key", request.idempotencyKey) request.directAttributionId?.let { body.put("direct_attribution_id", it) } 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 a2a45bdfb..a6bbd0f5e 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 @@ -12,6 +12,7 @@ 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.operations.IOperationRepo import com.onesignal.core.internal.startup.IBootstrapService import com.onesignal.core.internal.startup.IStartableService import com.onesignal.core.internal.time.ITime @@ -22,6 +23,7 @@ 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.operations.CreateSessionOperation import com.onesignal.user.internal.backend.IdentityConstants import com.onesignal.user.internal.identity.IdentityModelStore import java.util.UUID @@ -45,6 +47,7 @@ internal class SessionService( private val time: ITime, private val featureManager: IFeatureManager, private val identityModelStore: IdentityModelStore, + private val operationRepo: IOperationRepo, ) : ISessionService, IBootstrapService, IStartableService, IBackgroundService, IApplicationLifecycleHandler { override val startTime: Long // Pre-bootstrap default returns "now" so call sites computing `_time.currentTimeMillis - startTime` @@ -200,6 +203,9 @@ internal class SessionService( session.localSessionId = IDManager.createLocalId() session.isValid = true Logging.debug("SessionService: New session started at ${session.startTime}") + if (session.usesSessionsApi) { + createSessionOperation(session, config?.appId, identityModelStore.model.externalId)?.let { operationRepo.enqueue(it) } + } sessionLifeCycleNotifier.fire { it.onSessionStarted() } } else { // existing session: just remember the focus time so we can calculate the active time @@ -251,3 +257,27 @@ internal class SessionService( override val hasSubscribers: Boolean get() = sessionLifeCycleNotifier.hasSubscribers } + +private fun createSessionOperation( + session: SessionModel, + appId: String?, + externalId: String?, +): CreateSessionOperation? { + 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}, " + + "onesignalId: ${onesignalId == null}, subscriptionId: ${subscriptionId == null}", + ) + return null + } + return CreateSessionOperation( + appId = appId, + localSessionId = session.localSessionId, + onesignalId = onesignalId, + subscriptionId = subscriptionId, + startTime = session.startTime, + externalId = externalId, + ) +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/operations/CreateSessionOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/operations/CreateSessionOperation.kt index c4daa9c3d..02312851b 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/operations/CreateSessionOperation.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/operations/CreateSessionOperation.kt @@ -45,7 +45,7 @@ internal class CreateSessionOperation() : Operation(SessionOperationExecutor.CRE } /** Stable across retries and restarts, so the backend recognizes a resent create. */ - val idempotencyKey: String get() = localSessionId + val idempotencyKey: String get() = localSessionId.removePrefix(IDManager.LOCAL_PREFIX) /** Responses so far that had no session_id. */ var missingSessionIdAttempts: Int diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/operations/impl/SessionOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/operations/impl/SessionOperationExecutor.kt index bf716f63a..80393f985 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/operations/impl/SessionOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/operations/impl/SessionOperationExecutor.kt @@ -12,6 +12,7 @@ import com.onesignal.session.internal.session.backend.ISessionsBackendService import com.onesignal.session.internal.session.backend.UpdateSessionRequest import com.onesignal.session.internal.session.operations.CreateSessionOperation import com.onesignal.session.internal.session.operations.UpdateSessionOperation +import com.onesignal.user.internal.backend.SubscriptionObjectType import com.onesignal.user.internal.operations.impl.states.NewRecordsState import java.util.concurrent.TimeUnit @@ -38,7 +39,7 @@ internal class SessionOperationExecutor( CreateSessionRequest( onesignalId = operation.onesignalId, subscriptionId = operation.subscriptionId, - deviceType = deviceService.deviceType.value, + subscriptionType = SubscriptionObjectType.fromDeviceType(deviceService.deviceType).value, startTime = TimeUnit.MILLISECONDS.toSeconds(operation.startTime), idempotencyKey = operation.idempotencyKey, ), diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/StartupDiagnosticsTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/StartupDiagnosticsTest.kt index 5767c9b82..90113e173 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/StartupDiagnosticsTest.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/StartupDiagnosticsTest.kt @@ -39,10 +39,11 @@ class StartupDiagnosticsTest : FunSpec({ Logging.logLevel = LogLevel.NONE oneSignal.logStartupDiagnostics(context) - events.size shouldBe 1 - events.single().level shouldBe LogLevel.WARN - events.single().entry shouldContain "observabilityModule=logger" - events.single().entry shouldContain "app=com.example@1.2.3" - events.single().entry shouldContain "crashDir=${getCrashStoragePath(context)}" + // The listener is global, so background work left by other specs can log here too. + val diagnostics = events.single { it.entry.contains("observabilityModule=") } + diagnostics.level shouldBe LogLevel.WARN + diagnostics.entry shouldContain "observabilityModule=logger" + diagnostics.entry shouldContain "app=com.example@1.2.3" + diagnostics.entry shouldContain "crashDir=${getCrashStoragePath(context)}" } }) 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 49e564867..39b33e2fe 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 @@ -8,12 +8,17 @@ import com.onesignal.common.threading.OneSignalDispatchers import com.onesignal.common.threading.runOnSerialIO import com.onesignal.core.internal.config.ConfigModel import com.onesignal.core.internal.features.IFeatureManager +import com.onesignal.core.internal.operations.IOperationRepo +import com.onesignal.core.internal.operations.Operation 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.operations.CreateSessionOperation import com.onesignal.user.internal.backend.IdentityConstants import com.onesignal.user.internal.identity.IdentityModel import io.kotest.core.spec.style.FunSpec @@ -85,6 +90,12 @@ private class Mocks( ) } + val enqueued = mutableListOf() + val operationRepo: IOperationRepo = + mockk().also { + every { it.enqueue(capture(enqueued), any()) } just runs + } + fun sessionModelStore(action: ((SessionModel) -> Unit)? = null): SessionModelStore { if (action != null) action(mockSessionModelStore.model) return mockSessionModelStore @@ -98,6 +109,7 @@ private class Mocks( time, featureManager, identityModelStore, + operationRepo, ) val spyCallback = spyk() @@ -116,6 +128,8 @@ class SessionServiceTests : FunSpec({ // their finally-unmock does not affect the earlier inline-dispatch tests. listener(IOMockHelper) + beforeEach { Logging.logLevel = LogLevel.NONE } + test("session created on focus when current session invalid") { // Given val mocks = Mocks() @@ -440,6 +454,122 @@ class SessionServiceTests : FunSpec({ sessionModelStore.model.onesignalId shouldBe "local-user" } + test("new session enqueues a create with the start time and pinned IDs when the flag is on") { + // Given + val mocks = Mocks(sessionsApiEnabled = true) + mocks.identityModelStore.model.externalId = "external-id" + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + val sessionModelStore = mocks.sessionModelStore { it.isValid = false } + + // When + sessionService.onFocus(false) + + // Then + val create = mocks.enqueued.single() as CreateSessionOperation + create.appId shouldBe MockHelper.DEFAULT_APP_ID + create.localSessionId shouldBe sessionModelStore.model.localSessionId + create.onesignalId shouldBe Mocks.ONESIGNAL_ID + create.subscriptionId shouldBe Mocks.SUBSCRIPTION_ID + create.startTime shouldBe mocks.currentTime + create.externalId shouldBe "external-id" + } + + test("each new session enqueues its own create with a new idempotency key") { + // Given + val mocks = Mocks(sessionsApiEnabled = true) + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + mocks.sessionModelStore { it.isValid = false } + sessionService.onFocus(false) + sessionService.onUnfocused() + + // When + sessionService.backgroundRun() + sessionService.onFocus(false) + + // Then + val creates = mocks.enqueued.map { it as CreateSessionOperation } + creates.size shouldBe 2 + creates.map { it.localSessionId }.distinct().size shouldBe 2 + creates.map { it.idempotencyKey }.distinct().size shouldBe 2 + } + + test("resuming within the session timeout 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 + mocks.enqueued.size shouldBe 1 + } + + test("cold start with a persisted valid session enqueues a create for the new session") { + // Given + val mocks = Mocks(sessionsApiEnabled = true) + val sessionModelStore = + mocks.sessionModelStore { + it.isValid = true + it.sessionId = "previous-session" + } + val sessionService = mocks.sessionService + sessionService.bootstrap() + sessionService.start() + + // When + sessionService.onFocus(true) + + // Then + val create = mocks.enqueued.single() as CreateSessionOperation + create.localSessionId shouldBe sessionModelStore.model.localSessionId + sessionModelStore.model.sessionId shouldNotBe "previous-session" + } + + 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 } + sessionService.subscribe(mocks.spyCallback) + + // When + sessionService.onFocus(false) + + // Then + mocks.enqueued shouldBe emptyList() + verify(exactly = 1) { mocks.spyCallback.onSessionStarted() } + } + + test("new session does not enqueue a create 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) + + // When + sessionService.onFocus(false) + + // Then + mocks.enqueued shouldBe emptyList() + verify(exactly = 1) { mocks.spyCallback.onSessionStarted() } + } + test("sessions API active duration uses the monotonic clock") { // Given val mocks = Mocks(sessionsApiEnabled = true) 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 index 646852b96..ecdd5c9a4 100644 --- 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 @@ -17,7 +17,7 @@ private val createRequest = CreateSessionRequest( onesignalId = "onesignalId", subscriptionId = "subscriptionId", - deviceType = 1, + subscriptionType = "AndroidPush", startTime = 1_700_000_000L, idempotencyKey = "create-key", ) @@ -51,7 +51,7 @@ class SessionsBackendServiceTests : FunSpec({ withArg { it.getString("onesignal_id") shouldBe "onesignalId" it.getString("subscription_id") shouldBe "subscriptionId" - it.getInt("device_type") shouldBe 1 + it.getString("subscription_type") shouldBe "AndroidPush" 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 diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/operations/SessionOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/operations/SessionOperationExecutorTests.kt index 811977702..bb04841c4 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/operations/SessionOperationExecutorTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/operations/SessionOperationExecutorTests.kt @@ -19,6 +19,7 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import java.util.UUID private class Mocks { val backend = mockk() @@ -56,7 +57,7 @@ class SessionOperationExecutorTests : FunSpec({ coVerify { mocks.backend.createSession( "appId", - CreateSessionRequest("onesignalId", "subscriptionId", 1, 1_700_000_000L, op.idempotencyKey), + CreateSessionRequest("onesignalId", "subscriptionId", "AndroidPush", 1_700_000_000L, op.idempotencyKey), ) } } @@ -145,8 +146,11 @@ class SessionOperationExecutorTests : FunSpec({ restoredUpdate.endTime shouldBe 1L } - test("a create is keyed by its local session ID") { - create().idempotencyKey shouldBe "local-session" + test("a create is keyed by the UUID of its local session ID") { + val uuid = UUID.randomUUID().toString() + val op = CreateSessionOperation("appId", "local-$uuid", "onesignalId", "subscriptionId", startTime = 1L, externalId = null) + + op.idempotencyKey shouldBe uuid } test("each update gets its own idempotency key") {