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..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 @@ -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. + 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 + 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 e3633255f..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 @@ -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,27 @@ 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. + // 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(), "com.onesignal.subscriptionIdCopyDisabled") + } 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.