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 bb2227bb2..a7b6ef0bc 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 000000000..ae021b00e --- /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 000000000..34a6c50b2 --- /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 + } +})