Skip to content
Open
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 @@ -100,6 +100,7 @@ internal class InfluenceManager(
return
}

_sessionService.attributeNextSessionTo(notificationId)
attemptSessionUpgrade(AppEntryAction.NOTIFICATION_CLICK, notificationId)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,10 @@ interface ISessionService : IEventNotifier<ISessionLifecycleHandler> {
* When the current session was started, in Unix time milliseconds.
*/
val startTime: Long

/**
* Attributes the session started by the app's next focus to [notificationId], when that focus
* comes from opening the notification. Call before the open brings the app to the foreground.
*/
fun attributeNextSessionTo(notificationId: String)
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import java.util.UUID
import java.util.concurrent.atomic.AtomicReference
import kotlin.reflect.KMutableProperty1

/**
Expand All @@ -52,6 +53,7 @@ import kotlin.reflect.KMutableProperty1
* While focused, a session that [SessionModel.usesSessionsApi] reports its duration every
* [HEARTBEAT_INTERVAL_MS] of active time, so the server keeps it open and a crash loses little duration.
*/
@Suppress("TooManyFunctions")
internal class SessionService(
private val applicationService: IApplicationService,
private val configModelStore: ConfigModelStore,
Expand Down Expand Up @@ -98,6 +100,8 @@ internal class SessionService(
// True if app has been foregrounded at least once since the app started
private var hasFocused = false

private val pendingDirectAttributionId = AtomicReference<String?>()

override fun bootstrap() {
session = sessionModelStore.model
config = configModelStore.model
Expand Down Expand Up @@ -148,6 +152,10 @@ internal class SessionService(
}
}

override fun attributeNextSessionTo(notificationId: String) {
pendingDirectAttributionId.set(notificationId)
}

/** NOTE: This triggers more often than scheduleBackgroundRunIn defined above,
* as it runs on the lowest IBackgroundService.scheduleBackgroundRunIn across
* the SDK.
Expand Down Expand Up @@ -200,15 +208,22 @@ internal class SessionService(
// arrival, not dispatcher latency (SDK-4506).
val focusTimeMs = time.currentTimeMillis
val focusElapsedMs = time.elapsedRealtimeMillis
// Any focus consumes the open, so one that didn't bring the app forward (e.g. it opened a
// browser) isn't credited to a later organic open. Entry state is read here because it
// resets on unfocus, which can run before the serial IO lane gets to this focus.
val directAttributionId =
pendingDirectAttributionId.getAndSet(null)
?.takeIf { applicationService.entryState.isNotificationClick }
runOnSerialIO {
handleOnFocus(firedOnSubscribe, focusTimeMs, focusElapsedMs)
handleOnFocus(firedOnSubscribe, focusTimeMs, focusElapsedMs, directAttributionId)
}
}

private fun handleOnFocus(
firedOnSubscribe: Boolean,
focusTimeMs: Long,
focusElapsedMs: Long,
directAttributionId: String?,
) {
Logging.log(LogLevel.DEBUG, "SessionService.onFocus() - fired from start: $firedOnSubscribe")

Expand Down Expand Up @@ -242,7 +257,8 @@ internal class SessionService(
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) }
createSessionOperation(session, config?.appId, identityModelStore.model.externalId, directAttributionId)
?.let { operationRepo.enqueue(it) }
}
sessionLifeCycleNotifier.fire { it.onSessionStarted() }
} else {
Expand Down Expand Up @@ -356,6 +372,7 @@ private fun createSessionOperation(
session: SessionModel,
appId: String?,
externalId: String?,
directAttributionId: String?,
): CreateSessionOperation? =
withSessionIds(session, appId, "creating") { resolvedAppId, onesignalId, subscriptionId ->
CreateSessionOperation(
Expand All @@ -365,6 +382,7 @@ private fun createSessionOperation(
subscriptionId = subscriptionId,
startTime = session.startTime,
externalId = externalId,
directAttributionId = directAttributionId,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ internal class CreateSessionOperation() : Operation(SessionOperationExecutor.CRE
setLongProperty(::startTime.name, value)
}

/** The opened notification that started this session. */
var directAttributionId: String?
get() = getOptStringProperty(::directAttributionId.name)
private set(value) {
setOptStringProperty(::directAttributionId.name, value)
}

/** Stable across retries and restarts, so the backend recognizes a resent create. */
val idempotencyKey: String get() = localSessionId.removePrefix(IDManager.LOCAL_PREFIX)

Expand All @@ -69,13 +76,15 @@ internal class CreateSessionOperation() : Operation(SessionOperationExecutor.CRE
subscriptionId: String,
startTime: Long,
externalId: String?,
directAttributionId: String? = null,
) : this() {
this.appId = appId
this.localSessionId = localSessionId
this.onesignalId = onesignalId
this.subscriptionId = subscriptionId
this.startTime = startTime
this.externalId = externalId
this.directAttributionId = directAttributionId
}

override fun translateIds(map: Map<String, String>) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ internal class SessionOperationExecutor(
subscriptionType = SubscriptionObjectType.fromDeviceType(deviceService.deviceType).value,
startTime = TimeUnit.MILLISECONDS.toSeconds(operation.startTime),
idempotencyKey = operation.idempotencyKey,
directAttributionId = operation.directAttributionId,
),
)
} catch (ex: BackendException) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.verify

class InfluenceManagerTests : FunSpec({

Expand Down Expand Up @@ -159,6 +160,7 @@ class InfluenceManagerTests : FunSpec({
// Given
val mockSessionService = mockk<ISessionService>()
every { mockSessionService.subscribe(any()) } just Runs
every { mockSessionService.attributeNextSessionTo(any()) } just Runs

val mockApplicationService = mockk<IApplicationService>()
val mockConfigModelStore =
Expand Down Expand Up @@ -189,6 +191,21 @@ class InfluenceManagerTests : FunSpec({
iamInfluence.ids shouldBe null
}

test("notification opened attributes the next session to the notification") {
// Given
val mockSessionService = mockk<ISessionService>()
every { mockSessionService.subscribe(any()) } just Runs
every { mockSessionService.attributeNextSessionTo(any()) } just Runs
val influenceManager =
InfluenceManager(mockSessionService, mockk(), MockHelper.configModelStore(), MockPreferencesService(), MockHelper.time(1111))

// When
influenceManager.onDirectInfluenceFromNotification("notificationId")

// Then
verify(exactly = 1) { mockSessionService.attributeNextSessionTo("notificationId") }
}

test("IAM clicked while open creates IAM direct influence") {
// Given
val mockSessionService = mockk<ISessionService>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import com.onesignal.common.modeling.ModelChangeTags
import com.onesignal.common.modeling.ModelChangedArgs
import com.onesignal.common.threading.OneSignalDispatchers
import com.onesignal.common.threading.runOnSerialIO
import com.onesignal.core.internal.application.AppEntryAction
import com.onesignal.core.internal.application.IApplicationService
import com.onesignal.core.internal.config.ConfigModel
import com.onesignal.core.internal.features.IFeatureManager
import com.onesignal.core.internal.operations.IOperationRepo
Expand Down Expand Up @@ -115,9 +117,15 @@ private class Mocks(
return mockSessionModelStore
}

var entryState = AppEntryAction.APP_OPEN
val applicationService: IApplicationService =
MockHelper.applicationService().also {
every { it.entryState } answers { entryState }
}

val sessionService =
SessionService(
MockHelper.applicationService(),
applicationService,
configModelStore,
mockSessionModelStore,
time,
Expand Down Expand Up @@ -523,6 +531,74 @@ class SessionServiceTests : FunSpec({
create.externalId shouldBe "external-id"
}

test("notification open that starts a new session attributes the create to the notification") {
// Given
val mocks = Mocks(sessionsApiEnabled = true)
val sessionService = mocks.sessionService
sessionService.bootstrap()
sessionService.start()
mocks.sessionModelStore { it.isValid = false }

// When
sessionService.attributeNextSessionTo("notification-id")
mocks.entryState = AppEntryAction.NOTIFICATION_CLICK
sessionService.onFocus(false)

// Then
val create = mocks.enqueued.single() as CreateSessionOperation
create.directAttributionId shouldBe "notification-id"
}

test("notification open during an active session does not change the session or attribute the next one") {
// Given
val mocks = Mocks(sessionsApiEnabled = true)
val sessionService = mocks.sessionService
sessionService.bootstrap()
sessionService.start()
val sessionModelStore = mocks.sessionModelStore { it.isValid = false }
sessionService.onFocus(false)
sessionService.onUnfocused()
val sessionId = sessionModelStore.model.sessionId

// When
sessionService.attributeNextSessionTo("notification-id")
mocks.entryState = AppEntryAction.NOTIFICATION_CLICK
sessionService.onFocus(false)

// Then
sessionModelStore.model.sessionId shouldBe sessionId
mocks.enqueued.filterIsInstance<CreateSessionOperation>().size shouldBe 1

// When
sessionService.onUnfocused()
sessionService.backgroundRun()
mocks.entryState = AppEntryAction.APP_OPEN
sessionService.onFocus(false)

// Then
val creates = mocks.enqueued.filterIsInstance<CreateSessionOperation>()
creates.size shouldBe 2
creates.last().directAttributionId shouldBe null
}

test("notification open that does not focus the app is not attributed to a later normal open") {
// Given
val mocks = Mocks(sessionsApiEnabled = true)
val sessionService = mocks.sessionService
sessionService.bootstrap()
sessionService.start()
mocks.sessionModelStore { it.isValid = false }

// When
sessionService.attributeNextSessionTo("notification-id")
mocks.entryState = AppEntryAction.APP_OPEN
sessionService.onFocus(false)

// Then
val create = mocks.enqueued.single() as CreateSessionOperation
create.directAttributionId shouldBe null
}

test("each new session enqueues its own create with a new idempotency key") {
// Given
val mocks = Mocks(sessionsApiEnabled = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,16 @@ private class Mocks {
)
}

private fun create() = CreateSessionOperation("appId", "local-session", "onesignalId", "subscriptionId", startTime = 1_700_000_000_500L, externalId = null)
private fun create(directAttributionId: String? = null) =
CreateSessionOperation(
"appId",
"local-session",
"onesignalId",
"subscriptionId",
startTime = 1_700_000_000_500L,
externalId = null,
directAttributionId = directAttributionId,
)

private fun update(endTime: Long? = null) =
UpdateSessionOperation("appId", "server-session", "onesignalId", "subscriptionId", activeDuration = 61_900L, endTime = endTime)
Expand Down Expand Up @@ -62,6 +71,18 @@ class SessionOperationExecutorTests : FunSpec({
}
}

test("create sends the direct attribution ID") {
// Given
val mocks = Mocks()
coEvery { mocks.backend.createSession(any(), any()) } returns "server-session"

// When
mocks.executor.execute(listOf(create(directAttributionId = "notificationId")))

// Then
coVerify { mocks.backend.createSession("appId", match { it.directAttributionId == "notificationId" }) }
}

test("create without a session_id in the response is retried, then dropped") {
val mocks = Mocks()
val op = create()
Expand Down
Loading