diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/influence/impl/InfluenceManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/influence/impl/InfluenceManager.kt index 53f6a42cd0..19fc643643 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/influence/impl/InfluenceManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/influence/impl/InfluenceManager.kt @@ -100,6 +100,7 @@ internal class InfluenceManager( return } + _sessionService.attributeNextSessionTo(notificationId) attemptSessionUpgrade(AppEntryAction.NOTIFICATION_CLICK, notificationId) } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/ISessionService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/ISessionService.kt index f936cc4d47..3191cd0ee6 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/ISessionService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/ISessionService.kt @@ -13,4 +13,10 @@ interface ISessionService : IEventNotifier { * 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) } 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 ea002d6d46..02e6b19f44 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 @@ -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 /** @@ -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, @@ -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() + override fun bootstrap() { session = sessionModelStore.model config = configModelStore.model @@ -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. @@ -200,8 +208,14 @@ 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) } } @@ -209,6 +223,7 @@ internal class SessionService( firedOnSubscribe: Boolean, focusTimeMs: Long, focusElapsedMs: Long, + directAttributionId: String?, ) { Logging.log(LogLevel.DEBUG, "SessionService.onFocus() - fired from start: $firedOnSubscribe") @@ -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 { @@ -356,6 +372,7 @@ private fun createSessionOperation( session: SessionModel, appId: String?, externalId: String?, + directAttributionId: String?, ): CreateSessionOperation? = withSessionIds(session, appId, "creating") { resolvedAppId, onesignalId, subscriptionId -> CreateSessionOperation( @@ -365,6 +382,7 @@ private fun createSessionOperation( subscriptionId = subscriptionId, startTime = session.startTime, externalId = externalId, + directAttributionId = directAttributionId, ) } 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 02312851b8..675cb1a1a3 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 @@ -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) @@ -69,6 +76,7 @@ internal class CreateSessionOperation() : Operation(SessionOperationExecutor.CRE subscriptionId: String, startTime: Long, externalId: String?, + directAttributionId: String? = null, ) : this() { this.appId = appId this.localSessionId = localSessionId @@ -76,6 +84,7 @@ internal class CreateSessionOperation() : Operation(SessionOperationExecutor.CRE this.subscriptionId = subscriptionId this.startTime = startTime this.externalId = externalId + this.directAttributionId = directAttributionId } override fun translateIds(map: Map) { 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 80393f9850..93a92f65a5 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 @@ -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) { diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/influence/InfluenceManagerTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/influence/InfluenceManagerTests.kt index 4643ea0232..dbcce2b7e6 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/influence/InfluenceManagerTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/influence/InfluenceManagerTests.kt @@ -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({ @@ -159,6 +160,7 @@ class InfluenceManagerTests : FunSpec({ // Given val mockSessionService = mockk() every { mockSessionService.subscribe(any()) } just Runs + every { mockSessionService.attributeNextSessionTo(any()) } just Runs val mockApplicationService = mockk() val mockConfigModelStore = @@ -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() + 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() 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 79d803d7c7..6363fcb58e 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 @@ -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 @@ -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, @@ -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().size shouldBe 1 + + // When + sessionService.onUnfocused() + sessionService.backgroundRun() + mocks.entryState = AppEntryAction.APP_OPEN + sessionService.onFocus(false) + + // Then + val creates = mocks.enqueued.filterIsInstance() + 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) 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 bb04841c45..554b74a916 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 @@ -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) @@ -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()