From 2c0927b25ca33dd30ce2edd073d11046a77605f5 Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 26 Aug 2026 09:57:31 -0700 Subject: [PATCH] feat: [SDK-5088] add device gesture that marks the user as a test user Backgrounding and foregrounding the app 6 times within 30 seconds sends an Update User PATCH setting the test_user_name property to the device-local time, so the dashboard can surface the device as a test user. The request goes straight through IHttpClient, bypassing the operation repo; a queued operation can replay hours later, and a stale raise-hand signal is worse than a failed one. The JWT rides along via the same JwtTokenStore lookup the update-user executors use. Cycles are counted on a monotonic clock. A cycle needs a real background phase of at least 250ms, which filters the synthetic rotation unfocus/focus pair from ApplicationService.onOrientationChanged, and the 30s sliding window is the only rate rule. Each counted cycle logs at verbose so manual testing can watch progress. The gesture skips when privacy consent is withheld or the user does not exist on the backend yet, and adding sdk_device_gesture_disabled to an app's enabled feature keys turns it off remotely. The raw ConfigModel.sdkRemoteFeatureFlags list is checked instead of IFeatureManager because the KMP catalog hides unregistered keys. CoreModule.register moved its misconfigured-fallback block into a helper to stay under detekt's LongMethod cap after the new registration. --- .../java/com/onesignal/core/CoreModule.kt | 12 +- .../internal/gesture/DeviceGestureDetector.kt | 168 +++++++++++ .../gesture/DeviceGestureDetectorTests.kt | 268 ++++++++++++++++++ 3 files changed, 446 insertions(+), 2 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/gesture/DeviceGestureDetector.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/gesture/DeviceGestureDetectorTests.kt 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 bb2227bb27..a7b6ef0bcb 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 0000000000..92f987f58c --- /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 0000000000..23868bd839 --- /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 + } +})