Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
fadi-george marked this conversation as resolved.
.put("start_time", toIso8601(request.startTime))
.put("idempotency_key", request.idempotencyKey)
request.directAttributionId?.let { body.put("direct_attribution_id", it) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)}"
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -85,6 +90,12 @@ private class Mocks(
)
}

val enqueued = mutableListOf<Operation>()
val operationRepo: IOperationRepo =
mockk<IOperationRepo>().also {
every { it.enqueue(capture(enqueued), any()) } just runs
}

fun sessionModelStore(action: ((SessionModel) -> Unit)? = null): SessionModelStore {
if (action != null) action(mockSessionModelStore.model)
return mockSessionModelStore
Expand All @@ -98,6 +109,7 @@ private class Mocks(
time,
featureManager,
identityModelStore,
operationRepo,
)

val spyCallback = spyk<ISessionLifecycleHandler>()
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ private val createRequest =
CreateSessionRequest(
onesignalId = "onesignalId",
subscriptionId = "subscriptionId",
deviceType = 1,
subscriptionType = "AndroidPush",
startTime = 1_700_000_000L,
idempotencyKey = "create-key",
)
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ISessionsBackendService>()
Expand Down Expand Up @@ -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),
)
}
}
Expand Down Expand Up @@ -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") {
Expand Down
Loading