From 2eeeb49d9011c33432416f8fffad21b7e9928361 Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 26 Aug 2026 09:57:31 -0700 Subject: [PATCH 1/5] feat: [SDK-5088] add device gesture that copies the push subscription ID to the clipboard Backgrounding and foregrounding the app 6 times within 30 seconds copies the push subscription ID to the clipboard, ready to paste into the dashboard. The clip is os: . The short prefix marks the value as a OneSignal ID for the dashboard's paste target and for anyone who copied it by accident. 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 push subscription does not exist 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 | 145 +++++++++++ .../gesture/DeviceGestureDetectorTests.kt | 229 ++++++++++++++++++ 3 files changed, 384 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 1a1105af9..811867103 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 @@ -24,6 +24,7 @@ import com.onesignal.core.internal.device.impl.FidEnvService 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 @@ -104,6 +105,9 @@ internal class CoreModule : IModule { .provides() .provides() + // Device gesture + builder.register().provides() + // Purchase Tracking builder.register().provides() @@ -119,8 +123,12 @@ internal class CoreModule : IModule { ) }.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..10d22560d --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/gesture/DeviceGestureDetector.kt @@ -0,0 +1,145 @@ +package com.onesignal.core.internal.gesture + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.SystemClock +import com.onesignal.common.IDManager +import com.onesignal.common.threading.suspendifyOnMain +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.startup.IStartableService +import com.onesignal.debug.internal.logging.Logging + +/** + * Detects the test-device gesture: [REQUIRED_CYCLES] background/foreground cycles within + * [WINDOW_MS], then copies the push subscription ID to the clipboard, prefixed `os:` (see + * [clipText]), so the person can paste it into the dashboard. + * + * 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, +) : 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() } + + 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) { + copySubscriptionIdToClipboard() + } + } + + override fun onUnfocused() { + val now = monotonicMillis() + synchronized(this) { + lastUnfocusedAt = now + } + } + + private fun copySubscriptionIdToClipboard() { + val config = configModelStore.model + val subscriptionId = config.pushSubscriptionId + 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") + subscriptionId.isNullOrEmpty() || IDManager.isLocalId(subscriptionId) -> + Logging.info("DeviceGestureDetector: gesture detected before the push subscription exists, nothing copied") + else -> writeToClipboard(subscriptionId) + } + } + + private fun writeToClipboard(subscriptionId: String) { + suspendifyOnMain { + val context = applicationService.appContext + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + if (clipboard == null) { + Logging.warn("DeviceGestureDetector: clipboard service unavailable, nothing copied") + } else { + // No EXTRA_IS_SENSITIVE: the Android 13+ copy preview is the person's confirmation. + clipboard.setPrimaryClip(ClipData.newPlainText(CLIP_LABEL, clipText(subscriptionId))) + Logging.info("DeviceGestureDetector: push subscription ID copied to clipboard") + } + } + } + + 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" + private const val CLIP_LABEL = "OneSignal subscription ID" + + /** + * The `os:` prefix marks the value as a OneSignal ID, for the dashboard's paste target and + * for anyone who copied it by accident. + */ + internal fun clipText(subscriptionId: String): String = "os: $subscriptionId" + } +} 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..66fb56fa2 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/gesture/DeviceGestureDetectorTests.kt @@ -0,0 +1,229 @@ +package com.onesignal.core.internal.gesture + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Build +import androidx.test.core.app.ApplicationProvider +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.mocks.IOMockHelper +import com.onesignal.mocks.IOMockHelper.awaitIO +import com.onesignal.mocks.MockHelper +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import org.robolectric.annotation.Config + +private const val SUBSCRIPTION_ID = "aaaabbbb-cccc-dddd-eeee-ffff00001111" + +/** + * Drives the detector through synthetic focus/unfocus sequences with a controlled clock and + * reads back the real (Robolectric) clipboard. Dwells are in milliseconds; the default cycle + * takes 2s, so six of them sit well inside the 30s window. + */ +private class Harness( + subscriptionId: String? = SUBSCRIPTION_ID, + remoteFlags: List = emptyList(), + consentRequired: Boolean? = null, + consentGiven: Boolean? = null, + fireOnSubscribe: Boolean = false, +) { + var nowMs = 100_000L + + val context: Context = ApplicationProvider.getApplicationContext() + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + + private val handlerSlot = slot() + val detector: DeviceGestureDetector + + init { + val applicationService = mockk() + every { applicationService.appContext } returns context + 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.pushSubscriptionId = subscriptionId + it.sdkRemoteFeatureFlags = remoteFlags + it.consentRequired = consentRequired + it.consentGiven = consentGiven + } + detector = DeviceGestureDetector(applicationService, configModelStore) + detector.monotonicMillis = { nowMs } + 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 clipText(): String? = clipboard.primaryClip?.getItemAt(0)?.text?.toString() + + val expectedClip: String get() = DeviceGestureDetector.clipText(SUBSCRIPTION_ID) +} + +@RobolectricTest +@Config(sdk = [Build.VERSION_CODES.O]) +class DeviceGestureDetectorTests : FunSpec({ + listener(IOMockHelper) + + test("six rapid cycles copy the prefixed subscription ID to the clipboard") { + val harness = Harness() + + repeat(6) { harness.cycle() } + awaitIO() + + harness.clipText() shouldBe "os: $SUBSCRIPTION_ID" + harness.clipboard.primaryClip!!.description.label shouldBe "OneSignal subscription ID" + } + + test("five cycles copy nothing") { + val harness = Harness() + + repeat(5) { harness.cycle() } + awaitIO() + + harness.clipText() shouldBe null + } + + 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.clipText() shouldBe null + } + + 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.clipText() shouldBe harness.expectedClip + } + + 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.clipText() shouldBe null + + harness.cycle() + awaitIO() + harness.clipText() shouldBe harness.expectedClip + } + + test("the detector re-arms after firing") { + val harness = Harness() + + repeat(6) { harness.cycle() } + awaitIO() + harness.clipText() shouldBe harness.expectedClip + + harness.clipboard.setPrimaryClip(ClipData.newPlainText("other", "sentinel")) + repeat(6) { harness.cycle() } + awaitIO() + harness.clipText() shouldBe harness.expectedClip + } + + test("the remote kill switch suppresses the copy") { + // 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.clipText() shouldBe null + } + + test("withheld privacy consent suppresses the copy") { + val harness = Harness(consentRequired = true, consentGiven = null) + + repeat(6) { harness.cycle() } + awaitIO() + + harness.clipText() shouldBe null + } + + test("granted privacy consent allows the copy") { + val harness = Harness(consentRequired = true, consentGiven = true) + + repeat(6) { harness.cycle() } + awaitIO() + + harness.clipText() shouldBe harness.expectedClip + } + + test("a missing push subscription copies nothing") { + val harness = Harness(subscriptionId = null) + + repeat(6) { harness.cycle() } + awaitIO() + + harness.clipText() shouldBe null + } + + test("a local not-yet-synced push subscription ID copies nothing") { + val harness = Harness(subscriptionId = "local-$SUBSCRIPTION_ID") + + repeat(6) { harness.cycle() } + awaitIO() + + harness.clipText() 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.clipText() shouldBe null + + harness.cycle() + awaitIO() + harness.clipText() shouldBe harness.expectedClip + } + + 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.clipText() shouldBe null + + harness.cycle() + awaitIO() + harness.clipText() shouldBe harness.expectedClip + } +}) From e59460abeb40d39294af28ffb4d5b45856226b9e Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 3 Sep 2026 21:16:01 -0700 Subject: [PATCH 2/5] feat: [SDK-5088] record the device gesture as an observability event DeviceGestureDetector now takes the IObservabilityEventRecorder from the container and records ObservabilityEvent.DEVICE_GESTURE each time the gesture is recognised. gesture.result is copied, no_id or disabled, and a copied event also carries gesture.push_subscription_id, the value that went on the clipboard. The copied result is recorded from the main-thread block after the clip is set, so it never overstates. A gesture under withheld privacy consent records nothing, because the event ships to the backend. Tests drive the detector with a recorder spy and pin the attribute names and values, since the log backend is queried by them. --- .../internal/gesture/DeviceGestureDetector.kt | 37 ++++++- .../gesture/DeviceGestureDetectorTests.kt | 103 +++++++++++++++++- 2 files changed, 137 insertions(+), 3 deletions(-) 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 index 10d22560d..e8b9f5e6a 100644 --- 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 @@ -11,6 +11,8 @@ import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.startup.IStartableService import com.onesignal.debug.internal.logging.Logging +import com.onesignal.logger.IObservabilityEventRecorder +import com.onesignal.logger.ObservabilityEvent /** * Detects the test-device gesture: [REQUIRED_CYCLES] background/foreground cycles within @@ -28,10 +30,14 @@ import com.onesignal.debug.internal.logging.Logging * [com.onesignal.core.internal.config.ConfigModel.sdkRemoteFeatureFlags] list because * [com.onesignal.core.internal.features.IFeatureManager] only resolves keys the KMP catalog * registers. + * + * Every recognised gesture also records [ObservabilityEvent.DEVICE_GESTURE], with its outcome + * and the copied ID, so the gesture's usage can be measured. */ internal class DeviceGestureDetector( private val applicationService: IApplicationService, private val configModelStore: ConfigModelStore, + private val eventRecorder: IObservabilityEventRecorder, ) : IStartableService, IApplicationLifecycleHandler { /** @@ -102,12 +108,17 @@ internal class DeviceGestureDetector( val config = configModelStore.model val subscriptionId = config.pushSubscriptionId when { + // Not recorded either: nothing about the device may ship before consent. 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) } -> + config.sdkRemoteFeatureFlags.any { it.equals(KILL_SWITCH_KEY, ignoreCase = true) } -> { Logging.debug("DeviceGestureDetector: gesture detected but disabled remotely") - subscriptionId.isNullOrEmpty() || IDManager.isLocalId(subscriptionId) -> + recordGesture(GestureResult.DISABLED) + } + subscriptionId.isNullOrEmpty() || IDManager.isLocalId(subscriptionId) -> { Logging.info("DeviceGestureDetector: gesture detected before the push subscription exists, nothing copied") + recordGesture(GestureResult.NO_ID) + } else -> writeToClipboard(subscriptionId) } } @@ -122,10 +133,30 @@ internal class DeviceGestureDetector( // No EXTRA_IS_SENSITIVE: the Android 13+ copy preview is the person's confirmation. clipboard.setPrimaryClip(ClipData.newPlainText(CLIP_LABEL, clipText(subscriptionId))) Logging.info("DeviceGestureDetector: push subscription ID copied to clipboard") + recordGesture(GestureResult.COPIED, copiedId = subscriptionId) } } } + /** Recorded once the outcome is known, so `copied` means the clip was actually set. */ + private fun recordGesture( + result: GestureResult, + copiedId: String? = null, + ) { + val attributes = mutableMapOf(ATTRIBUTE_RESULT to result.wire) + if (copiedId != null) { + attributes[ATTRIBUTE_PUSH_SUBSCRIPTION_ID] = copiedId + } + eventRecorder.record(ObservabilityEvent.DEVICE_GESTURE, attributes) + } + + /** Wire values of `gesture.result`, which backend queries match on. */ + private enum class GestureResult(val wire: String) { + COPIED("copied"), + NO_ID("no_id"), + DISABLED("disabled"), + } + companion object { internal const val REQUIRED_CYCLES = 6 internal const val WINDOW_MS = 30_000L @@ -135,6 +166,8 @@ internal class DeviceGestureDetector( internal const val KILL_SWITCH_KEY = "sdk_device_gesture_disabled" private const val CLIP_LABEL = "OneSignal subscription ID" + private const val ATTRIBUTE_RESULT = "gesture.result" + private const val ATTRIBUTE_PUSH_SUBSCRIPTION_ID = "gesture.push_subscription_id" /** * The `os:` prefix marks the value as a OneSignal ID, for the dashboard's paste target and 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 index 66fb56fa2..aa192b00c 100644 --- 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 @@ -8,10 +8,14 @@ import androidx.test.core.app.ApplicationProvider 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.logger.ILogTelemetry +import com.onesignal.logger.IObservabilityEventRecorder +import com.onesignal.logger.ObservabilityEvent import com.onesignal.mocks.IOMockHelper import com.onesignal.mocks.IOMockHelper.awaitIO import com.onesignal.mocks.MockHelper import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk @@ -20,6 +24,32 @@ import org.robolectric.annotation.Config private const val SUBSCRIPTION_ID = "aaaabbbb-cccc-dddd-eeee-ffff00001111" +/** + * Captures what the detector records so tests can assert the event and its attributes. The + * attach/detach/reset side belongs to the logger lifecycle and never reaches the detector. + */ +private class RecorderSpy : IObservabilityEventRecorder { + private val stored = mutableListOf>>() + + val recorded: List>> + get() = synchronized(stored) { stored.toList() } + + override fun record( + event: ObservabilityEvent, + attributes: Map, + ) { + synchronized(stored) { stored.add(event to attributes) } + } + + override fun record(event: ObservabilityEvent) = record(event, emptyMap()) + + override fun attach(telemetry: ILogTelemetry) = Unit + + override fun detach(telemetry: ILogTelemetry) = Unit + + override fun reset() = Unit +} + /** * Drives the detector through synthetic focus/unfocus sequences with a controlled clock and * reads back the real (Robolectric) clipboard. Dwells are in milliseconds; the default cycle @@ -36,6 +66,7 @@ private class Harness( val context: Context = ApplicationProvider.getApplicationContext() val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val recorder = RecorderSpy() private val handlerSlot = slot() val detector: DeviceGestureDetector @@ -57,7 +88,7 @@ private class Harness( it.consentRequired = consentRequired it.consentGiven = consentGiven } - detector = DeviceGestureDetector(applicationService, configModelStore) + detector = DeviceGestureDetector(applicationService, configModelStore, recorder) detector.monotonicMillis = { nowMs } detector.start() } @@ -226,4 +257,74 @@ class DeviceGestureDetectorTests : FunSpec({ awaitIO() harness.clipText() shouldBe harness.expectedClip } + + // ===== Observability event ===== + // Every recognised gesture records DEVICE_GESTURE with its outcome, whether or not an ID was + // copied, so the backend can answer how often the gesture happens and how often it pays off. + + test("a completed gesture records a copied event carrying the subscription ID") { + val harness = Harness() + + // Progress is silent: the event fires on recognition, not per cycle. + repeat(5) { harness.cycle() } + awaitIO() + harness.recorder.recorded.shouldBeEmpty() + + harness.cycle() + awaitIO() + + harness.recorder.recorded shouldBe + listOf( + ObservabilityEvent.DEVICE_GESTURE to + mapOf( + "gesture.result" to "copied", + "gesture.push_subscription_id" to SUBSCRIPTION_ID, + ), + ) + } + + test("the remote kill switch records a disabled result without an ID") { + val harness = Harness(remoteFlags = listOf(DeviceGestureDetector.KILL_SWITCH_KEY)) + + repeat(6) { harness.cycle() } + awaitIO() + + harness.recorder.recorded shouldBe + listOf(ObservabilityEvent.DEVICE_GESTURE to mapOf("gesture.result" to "disabled")) + } + + test("a missing or local push subscription records a no_id result") { + // Both shapes mean the same thing to the backend: the gesture ran before the device had + // anything worth pasting. + listOf(null, "local-$SUBSCRIPTION_ID").forEach { subscriptionId -> + val harness = Harness(subscriptionId = subscriptionId) + + repeat(6) { harness.cycle() } + awaitIO() + + harness.recorder.recorded shouldBe + listOf(ObservabilityEvent.DEVICE_GESTURE to mapOf("gesture.result" to "no_id")) + } + } + + test("withheld privacy consent records nothing") { + // The event would ship to the backend, and nothing may leave the device before consent. + val harness = Harness(consentRequired = true, consentGiven = null) + + repeat(6) { harness.cycle() } + awaitIO() + + harness.recorder.recorded.shouldBeEmpty() + } + + test("each recognition records its own event") { + val harness = Harness() + + repeat(12) { harness.cycle() } + awaitIO() + + harness.recorder.recorded.map { it.first } shouldBe + listOf(ObservabilityEvent.DEVICE_GESTURE, ObservabilityEvent.DEVICE_GESTURE) + harness.recorder.recorded.map { it.second["gesture.result"] } shouldBe listOf("copied", "copied") + } }) From 51df457d3708ea65df1e869be986cae3c8eb4dba Mon Sep 17 00:00:00 2001 From: Nan Date: Fri, 4 Sep 2026 00:07:52 -0700 Subject: [PATCH 3/5] feat: [SDK-5088] copy a placeholder when there is no subscription ID yet The gesture is going into the public docs, so a person who performs it and gets nothing cannot tell whether the SDK missed it or had no ID to give. Without a push subscription the clip is now "os: no subscription ID yet", which says which one it was. The event result stays no_id and is recorded after the clip is set, like copied. Withheld consent, the remote kill switch and a gesture that was not recognised still copy nothing. --- .../internal/gesture/DeviceGestureDetector.kt | 29 +++++++++++-------- .../gesture/DeviceGestureDetectorTests.kt | 9 +++--- 2 files changed, 22 insertions(+), 16 deletions(-) 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 index e8b9f5e6a..690a7be51 100644 --- 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 @@ -17,7 +17,8 @@ import com.onesignal.logger.ObservabilityEvent /** * Detects the test-device gesture: [REQUIRED_CYCLES] background/foreground cycles within * [WINDOW_MS], then copies the push subscription ID to the clipboard, prefixed `os:` (see - * [clipText]), so the person can paste it into the dashboard. + * [clipText]), so the person can paste it into the dashboard. Without a subscription it copies + * [NO_SUBSCRIPTION_CLIP_TEXT] instead, so someone following the docs can tell the gesture worked. * * A cycle is an unfocus/focus pair whose background phase lasts at least * [MIN_BACKGROUND_DWELL_MS]; the floor filters the synthetic pair @@ -115,15 +116,17 @@ internal class DeviceGestureDetector( Logging.debug("DeviceGestureDetector: gesture detected but disabled remotely") recordGesture(GestureResult.DISABLED) } - subscriptionId.isNullOrEmpty() || IDManager.isLocalId(subscriptionId) -> { - Logging.info("DeviceGestureDetector: gesture detected before the push subscription exists, nothing copied") - recordGesture(GestureResult.NO_ID) - } - else -> writeToClipboard(subscriptionId) + subscriptionId.isNullOrEmpty() || IDManager.isLocalId(subscriptionId) -> + writeToClipboard(NO_SUBSCRIPTION_CLIP_TEXT, GestureResult.NO_ID) + else -> writeToClipboard(clipText(subscriptionId), GestureResult.COPIED, copiedId = subscriptionId) } } - private fun writeToClipboard(subscriptionId: String) { + private fun writeToClipboard( + text: String, + result: GestureResult, + copiedId: String? = null, + ) { suspendifyOnMain { val context = applicationService.appContext val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager @@ -131,14 +134,14 @@ internal class DeviceGestureDetector( Logging.warn("DeviceGestureDetector: clipboard service unavailable, nothing copied") } else { // No EXTRA_IS_SENSITIVE: the Android 13+ copy preview is the person's confirmation. - clipboard.setPrimaryClip(ClipData.newPlainText(CLIP_LABEL, clipText(subscriptionId))) - Logging.info("DeviceGestureDetector: push subscription ID copied to clipboard") - recordGesture(GestureResult.COPIED, copiedId = subscriptionId) + clipboard.setPrimaryClip(ClipData.newPlainText(CLIP_LABEL, text)) + Logging.info("DeviceGestureDetector: clipboard set, gesture result ${result.wire}") + recordGesture(result, copiedId) } } } - /** Recorded once the outcome is known, so `copied` means the clip was actually set. */ + /** Recorded once the clip is set, so a result never claims a clipboard change that did not happen. */ private fun recordGesture( result: GestureResult, copiedId: String? = null, @@ -166,6 +169,8 @@ internal class DeviceGestureDetector( internal const val KILL_SWITCH_KEY = "sdk_device_gesture_disabled" private const val CLIP_LABEL = "OneSignal subscription ID" + private const val CLIP_PREFIX = "os: " + internal const val NO_SUBSCRIPTION_CLIP_TEXT = CLIP_PREFIX + "no subscription ID yet" private const val ATTRIBUTE_RESULT = "gesture.result" private const val ATTRIBUTE_PUSH_SUBSCRIPTION_ID = "gesture.push_subscription_id" @@ -173,6 +178,6 @@ internal class DeviceGestureDetector( * The `os:` prefix marks the value as a OneSignal ID, for the dashboard's paste target and * for anyone who copied it by accident. */ - internal fun clipText(subscriptionId: String): String = "os: $subscriptionId" + internal fun clipText(subscriptionId: String): String = CLIP_PREFIX + subscriptionId } } 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 index aa192b00c..d5b80fae0 100644 --- 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 @@ -214,22 +214,23 @@ class DeviceGestureDetectorTests : FunSpec({ harness.clipText() shouldBe harness.expectedClip } - test("a missing push subscription copies nothing") { + test("a missing push subscription copies the placeholder") { + // Someone following the docs gets a visible result that says why there is no ID. val harness = Harness(subscriptionId = null) repeat(6) { harness.cycle() } awaitIO() - harness.clipText() shouldBe null + harness.clipText() shouldBe "os: no subscription ID yet" } - test("a local not-yet-synced push subscription ID copies nothing") { + test("a local not-yet-synced push subscription ID copies the placeholder") { val harness = Harness(subscriptionId = "local-$SUBSCRIPTION_ID") repeat(6) { harness.cycle() } awaitIO() - harness.clipText() shouldBe null + harness.clipText() shouldBe "os: no subscription ID yet" } test("the subscribe-time focus replay does not count as a cycle") { From 52d813dee0d6239c2908d09ce0eb5fc364eca520 Mon Sep 17 00:00:00 2001 From: Nan Date: Fri, 4 Sep 2026 08:50:36 -0700 Subject: [PATCH 4/5] refactor: [SDK-5088] read the kill switch through the feature manager The detector scanned the raw fetched flag list because the kill switch key had no catalog entry. Now that it has one, DeviceGestureDetector takes IFeatureManager and asks it for SDK_DEVICE_GESTURE_DISABLED like every other flag, which also drops the hand-rolled case handling. The switch still reads present-means-off, and absent still means on. --- .../internal/gesture/DeviceGestureDetector.kt | 13 ++++++------- .../gesture/DeviceGestureDetectorTests.kt | 15 +++++++++------ 2 files changed, 15 insertions(+), 13 deletions(-) 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 index 690a7be51..dbd2b0cc8 100644 --- 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 @@ -9,8 +9,10 @@ import com.onesignal.common.threading.suspendifyOnMain 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.features.IFeatureManager import com.onesignal.core.internal.startup.IStartableService import com.onesignal.debug.internal.logging.Logging +import com.onesignal.features.FeatureFlag import com.onesignal.logger.IObservabilityEventRecorder import com.onesignal.logger.ObservabilityEvent @@ -26,11 +28,8 @@ import com.onesignal.logger.ObservabilityEvent * 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. + * [FeatureFlag.SDK_DEVICE_GESTURE_DISABLED] turns the gesture off. Absent means enabled, so a + * device that has never fetched flags still has it. * * Every recognised gesture also records [ObservabilityEvent.DEVICE_GESTURE], with its outcome * and the copied ID, so the gesture's usage can be measured. @@ -38,6 +37,7 @@ import com.onesignal.logger.ObservabilityEvent internal class DeviceGestureDetector( private val applicationService: IApplicationService, private val configModelStore: ConfigModelStore, + private val featureManager: IFeatureManager, private val eventRecorder: IObservabilityEventRecorder, ) : IStartableService, IApplicationLifecycleHandler { @@ -112,7 +112,7 @@ internal class DeviceGestureDetector( // Not recorded either: nothing about the device may ship before consent. 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) } -> { + featureManager.isEnabled(FeatureFlag.SDK_DEVICE_GESTURE_DISABLED) -> { Logging.debug("DeviceGestureDetector: gesture detected but disabled remotely") recordGesture(GestureResult.DISABLED) } @@ -167,7 +167,6 @@ internal class DeviceGestureDetector( /** 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" private const val CLIP_LABEL = "OneSignal subscription ID" private const val CLIP_PREFIX = "os: " internal const val NO_SUBSCRIPTION_CLIP_TEXT = CLIP_PREFIX + "no subscription ID yet" 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 index d5b80fae0..8c255652e 100644 --- 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 @@ -8,6 +8,8 @@ import androidx.test.core.app.ApplicationProvider 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.features.IFeatureManager +import com.onesignal.features.FeatureFlag import com.onesignal.logger.ILogTelemetry import com.onesignal.logger.IObservabilityEventRecorder import com.onesignal.logger.ObservabilityEvent @@ -57,7 +59,7 @@ private class RecorderSpy : IObservabilityEventRecorder { */ private class Harness( subscriptionId: String? = SUBSCRIPTION_ID, - remoteFlags: List = emptyList(), + killSwitchOn: Boolean = false, consentRequired: Boolean? = null, consentGiven: Boolean? = null, fireOnSubscribe: Boolean = false, @@ -84,11 +86,13 @@ private class Harness( val configModelStore = MockHelper.configModelStore { it.pushSubscriptionId = subscriptionId - it.sdkRemoteFeatureFlags = remoteFlags it.consentRequired = consentRequired it.consentGiven = consentGiven } - detector = DeviceGestureDetector(applicationService, configModelStore, recorder) + // Strict mock: only the kill switch flag is answered, so asking for anything else fails the test. + val featureManager = mockk() + every { featureManager.isEnabled(FeatureFlag.SDK_DEVICE_GESTURE_DISABLED) } returns killSwitchOn + detector = DeviceGestureDetector(applicationService, configModelStore, featureManager, recorder) detector.monotonicMillis = { nowMs } detector.start() } @@ -187,8 +191,7 @@ class DeviceGestureDetectorTests : FunSpec({ } test("the remote kill switch suppresses the copy") { - // Server casing is preserved in the stored list, so match case-insensitively. - val harness = Harness(remoteFlags = listOf("SDK_Device_Gesture_Disabled")) + val harness = Harness(killSwitchOn = true) repeat(6) { harness.cycle() } awaitIO() @@ -285,7 +288,7 @@ class DeviceGestureDetectorTests : FunSpec({ } test("the remote kill switch records a disabled result without an ID") { - val harness = Harness(remoteFlags = listOf(DeviceGestureDetector.KILL_SWITCH_KEY)) + val harness = Harness(killSwitchOn = true) repeat(6) { harness.cycle() } awaitIO() From 03042cdc18e7a25c8e47aedd143654c003dd2248 Mon Sep 17 00:00:00 2001 From: Nan Date: Fri, 4 Sep 2026 16:23:10 -0700 Subject: [PATCH 5/5] fix: [SDK-5088] count real time, write on the focus callback, log outside the lock Three review findings and two wording fixes on the detector. The clock is now SystemClock.elapsedRealtime(), which keeps counting through deep sleep. uptimeMillis() stopped at every lock, so six brief visits spread over an afternoon could add up to one "30 second" window and copy the ID to a clipboard nobody asked about, and a lock that put the phone to sleep could read as a sub-250ms blip and be dropped. The window now means what the docs say. The clipboard write runs on the focus callback itself instead of hopping through the IO dispatcher and back to main. The callback already arrives on main and the clipboard needs no particular thread, so the hop only added a gap between recognising the gesture and writing. The write is guarded so a misbehaving clipboard service cannot take the lifecycle callback down, and the tests no longer need the dispatcher mocks. The verbose progress lines are built inside the detector lock and logged after it, since Logging calls app listeners synchronously. Wording: the class doc described the slowest qualifying pace as if it were a floor, and the recordGesture doc claimed no result is recorded without a clipboard change, which disabled contradicts. Tests add the 249ms versus 250ms dwell edge, the 30 second window edge, and the missing clipboard service path. --- .../internal/gesture/DeviceGestureDetector.kt | 57 ++++++++----- .../gesture/DeviceGestureDetectorTests.kt | 83 ++++++++++++------- 2 files changed, 88 insertions(+), 52 deletions(-) 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 index dbd2b0cc8..4517701a5 100644 --- 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 @@ -5,7 +5,6 @@ import android.content.ClipboardManager import android.content.Context import android.os.SystemClock import com.onesignal.common.IDManager -import com.onesignal.common.threading.suspendifyOnMain import com.onesignal.core.internal.application.IApplicationLifecycleHandler import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.config.ConfigModelStore @@ -26,7 +25,7 @@ import com.onesignal.logger.ObservabilityEvent * [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. + * only rate rule; six cycles fit inside it at round trips of five seconds or faster. * * [FeatureFlag.SDK_DEVICE_GESTURE_DISABLED] turns the gesture off. Absent means enabled, so a * device that has never fetched flags still has it. @@ -42,12 +41,13 @@ internal class DeviceGestureDetector( ) : 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 + * Monotonic and keeps counting through deep sleep, so neither a wall-clock jump nor a doze + * can stretch or shrink the window; an awake-only clock would stop at each lock and stitch + * visits hours apart into one 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() } + internal var monotonicMillis: () -> Long = { SystemClock.elapsedRealtime() } private var lastUnfocusedAt: Long? = null private val cycleTimestamps = mutableListOf() @@ -63,6 +63,8 @@ internal class DeviceGestureDetector( return } val now = monotonicMillis() + // Logged after the lock: Logging calls app listeners synchronously. + var progress: String? = null val completedGesture = synchronized(this) { val backgroundedAt = lastUnfocusedAt @@ -72,18 +74,15 @@ internal class DeviceGestureDetector( 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)", - ) + progress = "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)", - ) + progress = + "cycle ${cycleTimestamps.size}/$REQUIRED_CYCLES within the window " + + "(background ${now - backgroundedAt}ms)" if (cycleTimestamps.size >= REQUIRED_CYCLES) { cycleTimestamps.clear() true @@ -93,6 +92,7 @@ internal class DeviceGestureDetector( } } } + progress?.let { Logging.verbose("DeviceGestureDetector: $it") } if (completedGesture) { copySubscriptionIdToClipboard() } @@ -122,26 +122,39 @@ internal class DeviceGestureDetector( } } + /** Writes on the focus callback itself: the clipboard needs no particular thread, and a hop would only add a gap. */ + @Suppress("TooGenericExceptionCaught") private fun writeToClipboard( text: String, result: GestureResult, copiedId: String? = null, ) { - suspendifyOnMain { - val context = applicationService.appContext - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager - if (clipboard == null) { - Logging.warn("DeviceGestureDetector: clipboard service unavailable, nothing copied") - } else { + val context = applicationService.appContext + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + if (clipboard == null) { + Logging.warn("DeviceGestureDetector: clipboard service unavailable, nothing copied") + return + } + val written = + try { // No EXTRA_IS_SENSITIVE: the Android 13+ copy preview is the person's confirmation. clipboard.setPrimaryClip(ClipData.newPlainText(CLIP_LABEL, text)) - Logging.info("DeviceGestureDetector: clipboard set, gesture result ${result.wire}") - recordGesture(result, copiedId) + true + } catch (e: Exception) { + // A lifecycle callback must survive a misbehaving clipboard service. + Logging.warn("DeviceGestureDetector: clipboard write failed, nothing copied", e) + false } + if (written) { + Logging.info("DeviceGestureDetector: clipboard set, gesture result ${result.wire}") + recordGesture(result, copiedId) } } - /** Recorded once the clip is set, so a result never claims a clipboard change that did not happen. */ + /** + * `copied` and `no_id` are recorded after the clip is set, so neither claims a change that + * did not happen. `disabled` is recorded at the decision, since nothing is written. + */ private fun recordGesture( result: GestureResult, copiedId: String? = null, 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 index 8c255652e..e3633255f 100644 --- 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 @@ -3,6 +3,7 @@ package com.onesignal.core.internal.gesture import android.content.ClipData import android.content.ClipboardManager import android.content.Context +import android.content.ContextWrapper import android.os.Build import androidx.test.core.app.ApplicationProvider import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest @@ -13,8 +14,6 @@ import com.onesignal.features.FeatureFlag import com.onesignal.logger.ILogTelemetry import com.onesignal.logger.IObservabilityEventRecorder import com.onesignal.logger.ObservabilityEvent -import com.onesignal.mocks.IOMockHelper -import com.onesignal.mocks.IOMockHelper.awaitIO import com.onesignal.mocks.MockHelper import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldBeEmpty @@ -52,10 +51,17 @@ private class RecorderSpy : IObservabilityEventRecorder { override fun reset() = Unit } +/** A context whose clipboard service is missing, which some stripped-down devices really lack. */ +private class NoClipboardContext(base: Context) : ContextWrapper(base) { + override fun getSystemService(name: String): Any? = + if (name == Context.CLIPBOARD_SERVICE) null else super.getSystemService(name) +} + /** * Drives the detector through synthetic focus/unfocus sequences with a controlled clock and - * reads back the real (Robolectric) clipboard. Dwells are in milliseconds; the default cycle - * takes 2s, so six of them sit well inside the 30s window. + * reads back the real (Robolectric) clipboard. The write happens on the focus callback itself, + * so every assertion can follow the cycles directly. Dwells are in milliseconds; the default + * cycle takes 2s, so six of them sit well inside the 30s window. */ private class Harness( subscriptionId: String? = SUBSCRIPTION_ID, @@ -63,6 +69,7 @@ private class Harness( consentRequired: Boolean? = null, consentGiven: Boolean? = null, fireOnSubscribe: Boolean = false, + clipboardAvailable: Boolean = true, ) { var nowMs = 100_000L @@ -75,7 +82,7 @@ private class Harness( init { val applicationService = mockk() - every { applicationService.appContext } returns context + every { applicationService.appContext } returns if (clipboardAvailable) context else NoClipboardContext(context) every { applicationService.addApplicationLifecycleHandler(capture(handlerSlot)) } answers { // Mirrors ApplicationService.addApplicationLifecycleHandler when the app is // already foregrounded at subscribe time. @@ -118,13 +125,10 @@ private class Harness( @RobolectricTest @Config(sdk = [Build.VERSION_CODES.O]) class DeviceGestureDetectorTests : FunSpec({ - listener(IOMockHelper) - test("six rapid cycles copy the prefixed subscription ID to the clipboard") { val harness = Harness() repeat(6) { harness.cycle() } - awaitIO() harness.clipText() shouldBe "os: $SUBSCRIPTION_ID" harness.clipboard.primaryClip!!.description.label shouldBe "OneSignal subscription ID" @@ -134,7 +138,6 @@ class DeviceGestureDetectorTests : FunSpec({ val harness = Harness() repeat(5) { harness.cycle() } - awaitIO() harness.clipText() shouldBe null } @@ -145,7 +148,6 @@ class DeviceGestureDetectorTests : FunSpec({ // 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.clipText() shouldBe null } @@ -157,7 +159,6 @@ class DeviceGestureDetectorTests : FunSpec({ // 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.clipText() shouldBe harness.expectedClip } @@ -169,11 +170,9 @@ class DeviceGestureDetectorTests : FunSpec({ // 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.clipText() shouldBe null harness.cycle() - awaitIO() harness.clipText() shouldBe harness.expectedClip } @@ -181,12 +180,10 @@ class DeviceGestureDetectorTests : FunSpec({ val harness = Harness() repeat(6) { harness.cycle() } - awaitIO() harness.clipText() shouldBe harness.expectedClip harness.clipboard.setPrimaryClip(ClipData.newPlainText("other", "sentinel")) repeat(6) { harness.cycle() } - awaitIO() harness.clipText() shouldBe harness.expectedClip } @@ -194,7 +191,6 @@ class DeviceGestureDetectorTests : FunSpec({ val harness = Harness(killSwitchOn = true) repeat(6) { harness.cycle() } - awaitIO() harness.clipText() shouldBe null } @@ -203,7 +199,6 @@ class DeviceGestureDetectorTests : FunSpec({ val harness = Harness(consentRequired = true, consentGiven = null) repeat(6) { harness.cycle() } - awaitIO() harness.clipText() shouldBe null } @@ -212,7 +207,6 @@ class DeviceGestureDetectorTests : FunSpec({ val harness = Harness(consentRequired = true, consentGiven = true) repeat(6) { harness.cycle() } - awaitIO() harness.clipText() shouldBe harness.expectedClip } @@ -222,7 +216,6 @@ class DeviceGestureDetectorTests : FunSpec({ val harness = Harness(subscriptionId = null) repeat(6) { harness.cycle() } - awaitIO() harness.clipText() shouldBe "os: no subscription ID yet" } @@ -231,7 +224,6 @@ class DeviceGestureDetectorTests : FunSpec({ val harness = Harness(subscriptionId = "local-$SUBSCRIPTION_ID") repeat(6) { harness.cycle() } - awaitIO() harness.clipText() shouldBe "os: no subscription ID yet" } @@ -240,11 +232,9 @@ class DeviceGestureDetectorTests : FunSpec({ val harness = Harness(fireOnSubscribe = true) repeat(5) { harness.cycle() } - awaitIO() harness.clipText() shouldBe null harness.cycle() - awaitIO() harness.clipText() shouldBe harness.expectedClip } @@ -254,14 +244,53 @@ class DeviceGestureDetectorTests : FunSpec({ // 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.clipText() shouldBe null harness.cycle() - awaitIO() harness.clipText() shouldBe harness.expectedClip } + // ===== Boundaries and failure paths ===== + + test("a 249ms background is a blip and a 250ms one is a cycle") { + // The floor is inclusive: exactly the minimum counts. Six blips leave the window empty, + // so the six real cycles right after still need all six. + val harness = Harness() + + repeat(6) { harness.cycle(backgroundDwellMs = 249L) } + harness.clipText() shouldBe null + + repeat(6) { harness.cycle(backgroundDwellMs = 250L) } + harness.clipText() shouldBe harness.expectedClip + } + + test("the window is inclusive at exactly 30 seconds") { + // Five 2s cycles complete at +2s..+10s. A sixth completing exactly 30s after the first + // still counts; one millisecond later the first has aged out and only five remain. + val exact = Harness() + repeat(5) { exact.cycle() } + exact.cycle(foregroundDwellMs = 21_000L, backgroundDwellMs = 1_000L) + exact.clipText() shouldBe exact.expectedClip + + // Same real clipboard, so mark it before the second harness runs. + val late = Harness() + late.clipboard.setPrimaryClip(ClipData.newPlainText("other", "sentinel")) + repeat(5) { late.cycle() } + late.cycle(foregroundDwellMs = 21_001L, backgroundDwellMs = 1_000L) + late.clipText() shouldBe "sentinel" + } + + test("a missing clipboard service copies nothing and records nothing") { + // The focus callback must survive a device without one, and the event must not claim + // a clip that was never set. + val harness = Harness(clipboardAvailable = false) + + repeat(6) { harness.cycle() } + + harness.clipText() shouldBe null + harness.recorder.recorded.shouldBeEmpty() + } + // ===== Observability event ===== // Every recognised gesture records DEVICE_GESTURE with its outcome, whether or not an ID was // copied, so the backend can answer how often the gesture happens and how often it pays off. @@ -271,11 +300,9 @@ class DeviceGestureDetectorTests : FunSpec({ // Progress is silent: the event fires on recognition, not per cycle. repeat(5) { harness.cycle() } - awaitIO() harness.recorder.recorded.shouldBeEmpty() harness.cycle() - awaitIO() harness.recorder.recorded shouldBe listOf( @@ -291,7 +318,6 @@ class DeviceGestureDetectorTests : FunSpec({ val harness = Harness(killSwitchOn = true) repeat(6) { harness.cycle() } - awaitIO() harness.recorder.recorded shouldBe listOf(ObservabilityEvent.DEVICE_GESTURE to mapOf("gesture.result" to "disabled")) @@ -304,7 +330,6 @@ class DeviceGestureDetectorTests : FunSpec({ val harness = Harness(subscriptionId = subscriptionId) repeat(6) { harness.cycle() } - awaitIO() harness.recorder.recorded shouldBe listOf(ObservabilityEvent.DEVICE_GESTURE to mapOf("gesture.result" to "no_id")) @@ -316,7 +341,6 @@ class DeviceGestureDetectorTests : FunSpec({ val harness = Harness(consentRequired = true, consentGiven = null) repeat(6) { harness.cycle() } - awaitIO() harness.recorder.recorded.shouldBeEmpty() } @@ -325,7 +349,6 @@ class DeviceGestureDetectorTests : FunSpec({ val harness = Harness() repeat(12) { harness.cycle() } - awaitIO() harness.recorder.recorded.map { it.first } shouldBe listOf(ObservabilityEvent.DEVICE_GESTURE, ObservabilityEvent.DEVICE_GESTURE)