From 60c2bf2434c4a719b8f2750d38221eb240d09391 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 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 names what it is and which app copied it, so anyone who performs the gesture by accident can tell what landed on their clipboard. 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 | 150 +++++++++++ .../gesture/DeviceGestureDetectorTests.kt | 233 ++++++++++++++++++ 3 files changed, 393 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..ae021b00ea --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/gesture/DeviceGestureDetector.kt @@ -0,0 +1,150 @@ +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 so the person can paste + * it into the dashboard. The clip is self-describing (see [clipText]) because it lands on the + * clipboard of anyone who happens to perform the gesture, and a bare UUID there is a mystery. + * + * 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(context, 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" + + /** Names what the ID is and which app copied it, so an accidental copy explains itself. */ + internal fun clipText( + context: Context, + subscriptionId: String, + ): String { + val appName = + context.applicationInfo.loadLabel(context.packageManager).toString().ifBlank { context.packageName } + return "OneSignal subscription ID for $appName: $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 0000000000..34a6c50b23 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/gesture/DeviceGestureDetectorTests.kt @@ -0,0 +1,233 @@ +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.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldStartWith +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(context, SUBSCRIPTION_ID) +} + +@RobolectricTest +@Config(sdk = [Build.VERSION_CODES.O]) +class DeviceGestureDetectorTests : FunSpec({ + listener(IOMockHelper) + + test("six rapid cycles copy a self-describing subscription ID to the clipboard") { + val harness = Harness() + + repeat(6) { harness.cycle() } + awaitIO() + + harness.clipText() shouldBe harness.expectedClip + harness.clipText()!! shouldStartWith "OneSignal subscription ID for " + harness.clipText()!! shouldContain 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 + } +})