diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt index bb2227bb2..a7b6ef0bc 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt @@ -22,6 +22,7 @@ import com.onesignal.core.internal.device.impl.DeviceService import com.onesignal.core.internal.device.impl.InstallIdService import com.onesignal.core.internal.features.FeatureManager import com.onesignal.core.internal.features.IFeatureManager +import com.onesignal.core.internal.gesture.DeviceGestureDetector import com.onesignal.core.internal.http.IHttpClient import com.onesignal.core.internal.http.impl.HttpClient import com.onesignal.core.internal.http.impl.HttpConnectionFactory @@ -98,14 +99,21 @@ internal class CoreModule : IModule { .provides() .provides() + // Device gesture + builder.register().provides() + // Purchase Tracking builder.register().provides() // Crash Uploader (crash handler is initialized directly in OneSignalImp for early initialization) builder.register().provides() - // Register dummy services in the event they are not configured. These dummy services - // will throw an error message if the associated functionality is attempted to be used. + registerMisconfiguredFallbacks(builder) + } + + // Register dummy services in the event they are not configured. These dummy services + // will throw an error message if the associated functionality is attempted to be used. + private fun registerMisconfiguredFallbacks(builder: ServiceBuilder) { builder.register().provides() builder.register().provides() builder.register().provides() diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/gesture/DeviceGestureDetector.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/gesture/DeviceGestureDetector.kt new file mode 100644 index 000000000..92f987f58 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/gesture/DeviceGestureDetector.kt @@ -0,0 +1,168 @@ +package com.onesignal.core.internal.gesture + +import android.os.SystemClock +import com.onesignal.common.IDManager +import com.onesignal.common.threading.suspendifyOnIO +import com.onesignal.core.internal.application.IApplicationLifecycleHandler +import com.onesignal.core.internal.application.IApplicationService +import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.core.internal.http.IHttpClient +import com.onesignal.core.internal.http.impl.OptionalHeaders +import com.onesignal.core.internal.startup.IStartableService +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.user.internal.backend.IdentityConstants +import com.onesignal.user.internal.identity.IdentityModelStore +import com.onesignal.user.internal.jwt.JwtTokenStore +import org.json.JSONObject +import java.text.DateFormat +import java.util.Date + +/** + * Detects the test-user gesture: [REQUIRED_CYCLES] background/foreground cycles within + * [WINDOW_MS], then marks the current user as a test user by sending an Update User PATCH + * with `test_user_name` set to the device-local time. Sent straight through [IHttpClient] + * because the operation repo could replay a stale mark hours later. + * + * A cycle is an unfocus/focus pair whose background phase lasts at least + * [MIN_BACKGROUND_DWELL_MS]; the floor filters the synthetic pair + * [com.onesignal.core.internal.application.impl.ApplicationService.onOrientationChanged] + * fires when an activity declaring orientation in `configChanges` rotates. The window is the + * only rate rule; six cycles inside it takes sustained five-second round trips. + * + * Adding [KILL_SWITCH_KEY] to the app's enabled feature keys disables the gesture. Absent + * means enabled, so a device that has never fetched flags still has it. Reads the raw + * [com.onesignal.core.internal.config.ConfigModel.sdkRemoteFeatureFlags] list because + * [com.onesignal.core.internal.features.IFeatureManager] only resolves keys the KMP catalog + * registers. + */ +internal class DeviceGestureDetector( + private val applicationService: IApplicationService, + private val configModelStore: ConfigModelStore, + private val identityModelStore: IdentityModelStore, + private val jwtTokenStore: JwtTokenStore, + private val httpClient: IHttpClient, +) : IStartableService, + IApplicationLifecycleHandler { + /** + * Monotonic clock, so wall-clock jumps from NTP or manual time changes cannot stretch or + * shrink the window. Test-only override; kept out of the constructor so the IoC's + * reflection-based resolver still picks the only constructor (see the class KDoc on + * [com.onesignal.core.internal.config.impl.FeatureFlagsRefreshService]). + */ + internal var monotonicMillis: () -> Long = { SystemClock.uptimeMillis() } + + /** Wall clock for the human-readable `test_user_name` value. Test-only override. */ + internal var wallClockMillis: () -> Long = { System.currentTimeMillis() } + + private var lastUnfocusedAt: Long? = null + private val cycleTimestamps = mutableListOf() + + override fun start() { + applicationService.addApplicationLifecycleHandler(this) + } + + override fun onFocus(firedOnSubscribe: Boolean) { + // The subscribe-time replay is not a background-to-foreground transition, and it can + // arrive on a non-main thread during startup. + if (firedOnSubscribe) { + return + } + val now = monotonicMillis() + val completedGesture = + synchronized(this) { + val backgroundedAt = lastUnfocusedAt + lastUnfocusedAt = null + when { + // Cold start or first focus after start(); nothing to pair with. + backgroundedAt == null -> false + // Faster than any human app switch; rotation produces synthetic pairs like this. + now - backgroundedAt < MIN_BACKGROUND_DWELL_MS -> { + Logging.verbose( + "DeviceGestureDetector: ignored a ${now - backgroundedAt}ms background blip (rotation filter)", + ) + false + } + else -> { + cycleTimestamps.add(now) + cycleTimestamps.removeAll { now - it > WINDOW_MS } + Logging.verbose( + "DeviceGestureDetector: cycle ${cycleTimestamps.size}/$REQUIRED_CYCLES within the window " + + "(background ${now - backgroundedAt}ms)", + ) + if (cycleTimestamps.size >= REQUIRED_CYCLES) { + cycleTimestamps.clear() + true + } else { + false + } + } + } + } + if (completedGesture) { + markUserAsTestUser() + } + } + + override fun onUnfocused() { + val now = monotonicMillis() + synchronized(this) { + lastUnfocusedAt = now + } + } + + private fun markUserAsTestUser() { + val config = configModelStore.model + val identity = identityModelStore.model + val onesignalId = identity.onesignalId + when { + config.consentRequired == true && config.consentGiven != true -> + Logging.debug("DeviceGestureDetector: gesture detected but privacy consent is not granted") + config.sdkRemoteFeatureFlags.any { it.equals(KILL_SWITCH_KEY, ignoreCase = true) } -> + Logging.debug("DeviceGestureDetector: gesture detected but disabled remotely") + onesignalId.isEmpty() || IDManager.isLocalId(onesignalId) -> + Logging.info("DeviceGestureDetector: gesture detected before the user exists on the backend, nothing sent") + else -> sendTestUserUpdate(config.appId, onesignalId, identity.externalId) + } + } + + private fun sendTestUserUpdate( + appId: String, + onesignalId: String, + externalId: String?, + ) { + val testUserName = + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT) + .format(Date(wallClockMillis())) + // Same JWT lookup the update-user executors use; null skips the Authorization header. + val jwt = externalId?.let { jwtTokenStore.getJwt(it) } + suspendifyOnIO { + val body = + JSONObject().put( + "properties", + JSONObject().put(TEST_USER_NAME_PROPERTY, testUserName), + ) + val response = + httpClient.patch( + "apps/$appId/users/by/${IdentityConstants.ONESIGNAL_ID}/$onesignalId", + body, + OptionalHeaders(jwt = jwt), + ) + if (response.isSuccess) { + Logging.info("DeviceGestureDetector: marked user as test user \"$testUserName\"") + } else { + Logging.warn("DeviceGestureDetector: test user update failed (status ${response.statusCode})") + } + } + } + + companion object { + internal const val REQUIRED_CYCLES = 6 + internal const val WINDOW_MS = 30_000L + + /** Shortest background phase a human can produce; anything faster is synthetic. */ + internal const val MIN_BACKGROUND_DWELL_MS = 250L + + internal const val KILL_SWITCH_KEY = "sdk_device_gesture_disabled" + internal const val TEST_USER_NAME_PROPERTY = "test_user_name" + } +} diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/gesture/DeviceGestureDetectorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/gesture/DeviceGestureDetectorTests.kt new file mode 100644 index 000000000..23868bd83 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/gesture/DeviceGestureDetectorTests.kt @@ -0,0 +1,268 @@ +package com.onesignal.core.internal.gesture + +import android.os.Build +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.core.internal.application.IApplicationLifecycleHandler +import com.onesignal.core.internal.application.IApplicationService +import com.onesignal.core.internal.http.HttpResponse +import com.onesignal.core.internal.http.IHttpClient +import com.onesignal.core.internal.http.impl.OptionalHeaders +import com.onesignal.mocks.IOMockHelper +import com.onesignal.mocks.IOMockHelper.awaitIO +import com.onesignal.mocks.MockHelper +import com.onesignal.user.internal.jwt.JwtTokenStore +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import org.json.JSONObject +import org.robolectric.annotation.Config +import java.text.DateFormat +import java.util.Date + +private const val ONESIGNAL_ID = "aaaabbbb-cccc-dddd-eeee-ffff00001111" +private const val WALL_CLOCK_MS = 1_724_900_000_000L + +/** + * Drives the detector through synthetic focus/unfocus sequences with a controlled clock and + * records every Update User request it sends. Dwells are in milliseconds; the default cycle + * takes 2s, so six of them sit well inside the 30s window. + */ +private class Harness( + onesignalId: String = ONESIGNAL_ID, + externalId: String? = null, + jwt: String? = null, + remoteFlags: List = emptyList(), + consentRequired: Boolean? = null, + consentGiven: Boolean? = null, + fireOnSubscribe: Boolean = false, +) { + var nowMs = 100_000L + + val sentPaths = mutableListOf() + val sentBodies = mutableListOf() + val sentHeaders = mutableListOf() + + private val handlerSlot = slot() + val detector: DeviceGestureDetector + + init { + val applicationService = mockk() + every { applicationService.addApplicationLifecycleHandler(capture(handlerSlot)) } answers { + // Mirrors ApplicationService.addApplicationLifecycleHandler when the app is + // already foregrounded at subscribe time. + if (fireOnSubscribe) { + handlerSlot.captured.onFocus(true) + } + } + val configModelStore = + MockHelper.configModelStore { + it.sdkRemoteFeatureFlags = remoteFlags + it.consentRequired = consentRequired + it.consentGiven = consentGiven + } + val identityModelStore = + MockHelper.identityModelStore { + it.onesignalId = onesignalId + it.externalId = externalId + } + val jwtTokenStore = mockk() + every { jwtTokenStore.getJwt(any()) } returns jwt + val httpClient = mockk() + coEvery { httpClient.patch(any(), any(), any()) } answers { + sentPaths.add(firstArg()) + sentBodies.add(secondArg()) + sentHeaders.add(thirdArg()) + HttpResponse(200, null) + } + detector = DeviceGestureDetector(applicationService, configModelStore, identityModelStore, jwtTokenStore, httpClient) + detector.monotonicMillis = { nowMs } + detector.wallClockMillis = { WALL_CLOCK_MS } + detector.start() + } + + val handler: IApplicationLifecycleHandler get() = handlerSlot.captured + + /** One foreground-dwell + background-dwell cycle. */ + fun cycle( + backgroundDwellMs: Long = 1_000L, + foregroundDwellMs: Long = 1_000L, + ) { + nowMs += foregroundDwellMs + handler.onUnfocused() + nowMs += backgroundDwellMs + handler.onFocus(false) + } + + fun sentTestUserName(index: Int = 0): String = sentBodies[index].getJSONObject("properties").getString("test_user_name") +} + +/** The device-local readable string the detector is expected to send. */ +private fun expectedTestUserName(): String = + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(WALL_CLOCK_MS)) + +@RobolectricTest +@Config(sdk = [Build.VERSION_CODES.O]) +class DeviceGestureDetectorTests : FunSpec({ + listener(IOMockHelper) + + test("six rapid cycles send one Update User request with test_user_name") { + val harness = Harness() + + repeat(6) { harness.cycle() } + awaitIO() + + harness.sentPaths shouldBe listOf("apps/appId/users/by/onesignal_id/$ONESIGNAL_ID") + harness.sentTestUserName() shouldBe expectedTestUserName() + } + + test("five cycles send nothing") { + val harness = Harness() + + repeat(5) { harness.cycle() } + awaitIO() + + harness.sentPaths.size shouldBe 0 + } + + test("cycles slower than the window never accumulate six") { + val harness = Harness() + + // 7 seconds per round trip caps the window at five cycles, so a user who + // backgrounds the app all day at a normal pace can never fire this. + repeat(8) { harness.cycle(backgroundDwellMs = 3_000L, foregroundDwellMs = 4_000L) } + awaitIO() + + harness.sentPaths.size shouldBe 0 + } + + test("a pause mid-gesture does not reset progress") { + val harness = Harness() + + repeat(3) { harness.cycle() } + // A pause costs time, not accumulated cycles; all six still land inside the window. + harness.cycle(foregroundDwellMs = 10_000L) + repeat(2) { harness.cycle() } + awaitIO() + + harness.sentPaths.size shouldBe 1 + } + + test("a sub-human background blip does not count as a cycle") { + val harness = Harness() + + repeat(5) { harness.cycle() } + // Rotation with configChanges produces a synthetic pair this fast. It does not + // count, so one more real cycle completes the gesture. + harness.cycle(backgroundDwellMs = 1L) + awaitIO() + harness.sentPaths.size shouldBe 0 + + harness.cycle() + awaitIO() + harness.sentPaths.size shouldBe 1 + } + + test("the detector re-arms after firing") { + val harness = Harness() + + repeat(12) { harness.cycle() } + awaitIO() + + harness.sentPaths.size shouldBe 2 + } + + test("the remote kill switch suppresses the request") { + // Server casing is preserved in the stored list, so match case-insensitively. + val harness = Harness(remoteFlags = listOf("SDK_Device_Gesture_Disabled")) + + repeat(6) { harness.cycle() } + awaitIO() + + harness.sentPaths.size shouldBe 0 + } + + test("withheld privacy consent suppresses the request") { + val harness = Harness(consentRequired = true, consentGiven = null) + + repeat(6) { harness.cycle() } + awaitIO() + + harness.sentPaths.size shouldBe 0 + } + + test("granted privacy consent allows the request") { + val harness = Harness(consentRequired = true, consentGiven = true) + + repeat(6) { harness.cycle() } + awaitIO() + + harness.sentPaths.size shouldBe 1 + } + + test("a local not-yet-synced onesignal ID sends nothing") { + val harness = Harness(onesignalId = "local-$ONESIGNAL_ID") + + repeat(6) { harness.cycle() } + awaitIO() + + harness.sentPaths.size shouldBe 0 + } + + test("an empty onesignal ID sends nothing") { + val harness = Harness(onesignalId = "") + + repeat(6) { harness.cycle() } + awaitIO() + + harness.sentPaths.size shouldBe 0 + } + + test("the JWT rides along when the user has an external ID and a token") { + val harness = Harness(externalId = "ext-1", jwt = "jwt-token") + + repeat(6) { harness.cycle() } + awaitIO() + + harness.sentHeaders.size shouldBe 1 + harness.sentHeaders[0]?.jwt shouldBe "jwt-token" + } + + test("no JWT is attached for an anonymous user") { + val harness = Harness(externalId = null) + + repeat(6) { harness.cycle() } + awaitIO() + + harness.sentHeaders.size shouldBe 1 + harness.sentHeaders[0]?.jwt shouldBe null + } + + test("the subscribe-time focus replay does not count as a cycle") { + val harness = Harness(fireOnSubscribe = true) + + repeat(5) { harness.cycle() } + awaitIO() + harness.sentPaths.size shouldBe 0 + + harness.cycle() + awaitIO() + harness.sentPaths.size shouldBe 1 + } + + test("a focus without a preceding background does not count as a cycle") { + val harness = Harness() + + // Cold start: the app comes to the foreground with no background phase to pair with. + harness.handler.onFocus(false) + repeat(5) { harness.cycle() } + awaitIO() + harness.sentPaths.size shouldBe 0 + + harness.cycle() + awaitIO() + harness.sentPaths.size shouldBe 1 + } +})