From 7a1ad3a60ac4814dbe68003131ebcae2c627cd08 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 10 Sep 2026 10:42:53 -0700 Subject: [PATCH 1/2] feat: [SDK-5193] let an app opt out of subscription ID copy in its manifest The gesture is on by default and, until now, only OneSignal could turn it off, through the remote kill switch. An app that does not want a clipboard write in production can now set the manifest meta-data com.onesignal.subscriptionIdCopyDisabled to true, read the same way as com.onesignal.preferHMS. With the key set the detector never registers its lifecycle handler, so nothing is counted, copied or recorded. That is deliberately stricter than the remote kill switch, which still records disabled so attempts can be counted: an app that opted out has said no. One INFO line at start says the key was honored, so a typo in it is not silent. No public API, so wrapper SDKs need no change to expose it. --- .../internal/gesture/DeviceGestureDetector.kt | 11 ++++++++- .../gesture/DeviceGestureDetectorTests.kt | 24 ++++++++++++++++++- 2 files changed, 33 insertions(+), 2 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 4517701a5..eac7849db 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 @@ -4,6 +4,7 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.os.SystemClock +import com.onesignal.common.AndroidUtils import com.onesignal.common.IDManager import com.onesignal.core.internal.application.IApplicationLifecycleHandler import com.onesignal.core.internal.application.IApplicationService @@ -28,7 +29,9 @@ import com.onesignal.logger.ObservabilityEvent * 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. + * device that has never fetched flags still has it. An app can also opt out for good with the + * manifest meta-data [MANIFEST_DISABLED_KEY]; then the detector never starts, so nothing is + * counted or recorded. * * Every recognised gesture also records [ObservabilityEvent.DEVICE_GESTURE], with its outcome * and the copied ID, so the gesture's usage can be measured. @@ -53,6 +56,11 @@ internal class DeviceGestureDetector( private val cycleTimestamps = mutableListOf() override fun start() { + // The app owner's opt-out. Logged so a developer can confirm the key took. + if (AndroidUtils.getManifestMetaBoolean(applicationService.appContext, MANIFEST_DISABLED_KEY)) { + Logging.info("DeviceGestureDetector: disabled by $MANIFEST_DISABLED_KEY in the manifest, not starting") + return + } applicationService.addApplicationLifecycleHandler(this) } @@ -180,6 +188,7 @@ 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 MANIFEST_DISABLED_KEY = "com.onesignal.subscriptionIdCopyDisabled" 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 e3633255f..6c82a5628 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 @@ -7,6 +7,7 @@ import android.content.ContextWrapper import android.os.Build import androidx.test.core.app.ApplicationProvider import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.common.AndroidUtils import com.onesignal.core.internal.application.IApplicationLifecycleHandler import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.features.IFeatureManager @@ -20,7 +21,10 @@ import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk +import io.mockk.mockkObject import io.mockk.slot +import io.mockk.unmockkObject +import io.mockk.verify import org.robolectric.annotation.Config private const val SUBSCRIPTION_ID = "aaaabbbb-cccc-dddd-eeee-ffff00001111" @@ -76,12 +80,12 @@ private class Harness( val context: Context = ApplicationProvider.getApplicationContext() val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val recorder = RecorderSpy() + val applicationService: IApplicationService = mockk() private val handlerSlot = slot() val detector: DeviceGestureDetector init { - val applicationService = mockk() every { applicationService.appContext } returns if (clipboardAvailable) context else NoClipboardContext(context) every { applicationService.addApplicationLifecycleHandler(capture(handlerSlot)) } answers { // Mirrors ApplicationService.addApplicationLifecycleHandler when the app is @@ -252,6 +256,24 @@ class DeviceGestureDetectorTests : FunSpec({ // ===== Boundaries and failure paths ===== + test("the manifest opt-out keeps the detector from starting") { + // The app owner said no: no lifecycle handler is registered, so nothing is counted, + // copied or recorded, and the only trace is one INFO line at start. + mockkObject(AndroidUtils) + try { + every { + AndroidUtils.getManifestMetaBoolean(any(), DeviceGestureDetector.MANIFEST_DISABLED_KEY) + } returns true + val harness = Harness() + + verify(exactly = 0) { harness.applicationService.addApplicationLifecycleHandler(any()) } + harness.clipText() shouldBe null + harness.recorder.recorded.shouldBeEmpty() + } finally { + unmockkObject(AndroidUtils) + } + } + 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. From 7471112e9abea0850b6ab9dd9daf8d216218a78c Mon Sep 17 00:00:00 2001 From: Nan Date: Fri, 11 Sep 2026 11:36:25 -0700 Subject: [PATCH 2/2] test: [SDK-5193] pin the manifest opt-out key literal in the test The opt-out test stubbed the manifest read with the detector's own constant, so a typo in com.onesignal.subscriptionIdCopyDisabled would have left it green. The string is the whole contract an app has, so the test now spells it out, the way DeviceServiceTests does for com.onesignal.preferHMS. With a wrong key the stub misses, the real read finds no meta-data, the handler gets added, and the test fails. Nothing else reads the constant, so it is private now. The start-up comment no longer claims the log line confirms the key, since the default WARN level hides it. The level stays INFO, matching the copy line and the iOS opt-out. --- .../onesignal/core/internal/gesture/DeviceGestureDetector.kt | 4 ++-- .../core/internal/gesture/DeviceGestureDetectorTests.kt | 5 ++++- 2 files changed, 6 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 eac7849db..52545ef25 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 @@ -56,7 +56,7 @@ internal class DeviceGestureDetector( private val cycleTimestamps = mutableListOf() override fun start() { - // The app owner's opt-out. Logged so a developer can confirm the key took. + // The app owner's opt-out. if (AndroidUtils.getManifestMetaBoolean(applicationService.appContext, MANIFEST_DISABLED_KEY)) { Logging.info("DeviceGestureDetector: disabled by $MANIFEST_DISABLED_KEY in the manifest, not starting") return @@ -188,7 +188,7 @@ 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 MANIFEST_DISABLED_KEY = "com.onesignal.subscriptionIdCopyDisabled" + private const val MANIFEST_DISABLED_KEY = "com.onesignal.subscriptionIdCopyDisabled" 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 6c82a5628..6e6a0cd82 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 @@ -259,10 +259,13 @@ class DeviceGestureDetectorTests : FunSpec({ test("the manifest opt-out keeps the detector from starting") { // The app owner said no: no lifecycle handler is registered, so nothing is counted, // copied or recorded, and the only trace is one INFO line at start. + // The key is spelled out rather than read from the detector's constant because the + // string is the whole contract an app has. A typo in the constant then misses this stub, + // the real read finds no meta-data, and the handler gets added, so the test fails. mockkObject(AndroidUtils) try { every { - AndroidUtils.getManifestMetaBoolean(any(), DeviceGestureDetector.MANIFEST_DISABLED_KEY) + AndroidUtils.getManifestMetaBoolean(any(), "com.onesignal.subscriptionIdCopyDisabled") } returns true val harness = Harness()