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..4517701a5 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/gesture/DeviceGestureDetector.kt @@ -0,0 +1,195 @@ +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.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 + +/** + * 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. 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 + * [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 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. + * + * 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 featureManager: IFeatureManager, + private val eventRecorder: IObservabilityEventRecorder, +) : IStartableService, + IApplicationLifecycleHandler { + /** + * 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.elapsedRealtime() } + + 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() + // Logged after the lock: Logging calls app listeners synchronously. + var progress: String? = null + 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 -> { + progress = "ignored a ${now - backgroundedAt}ms background blip (rotation filter)" + false + } + else -> { + cycleTimestamps.add(now) + cycleTimestamps.removeAll { now - it > WINDOW_MS } + progress = + "cycle ${cycleTimestamps.size}/$REQUIRED_CYCLES within the window " + + "(background ${now - backgroundedAt}ms)" + if (cycleTimestamps.size >= REQUIRED_CYCLES) { + cycleTimestamps.clear() + true + } else { + false + } + } + } + } + progress?.let { Logging.verbose("DeviceGestureDetector: $it") } + 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 { + // 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") + featureManager.isEnabled(FeatureFlag.SDK_DEVICE_GESTURE_DISABLED) -> { + Logging.debug("DeviceGestureDetector: gesture detected but disabled remotely") + recordGesture(GestureResult.DISABLED) + } + subscriptionId.isNullOrEmpty() || IDManager.isLocalId(subscriptionId) -> + writeToClipboard(NO_SUBSCRIPTION_CLIP_TEXT, GestureResult.NO_ID) + else -> writeToClipboard(clipText(subscriptionId), GestureResult.COPIED, copiedId = subscriptionId) + } + } + + /** 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, + ) { + 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)) + 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) + } + } + + /** + * `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, + ) { + 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 + + /** Shortest background phase a human can produce; anything faster is synthetic. */ + internal const val MIN_BACKGROUND_DWELL_MS = 250L + + 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" + + /** + * 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 = 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 new file mode 100644 index 000000000..e3633255f --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/gesture/DeviceGestureDetectorTests.kt @@ -0,0 +1,357 @@ +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 +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 +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 +import io.mockk.slot +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 +} + +/** 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. 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, + killSwitchOn: Boolean = false, + consentRequired: Boolean? = null, + consentGiven: Boolean? = null, + fireOnSubscribe: Boolean = false, + clipboardAvailable: Boolean = true, +) { + var nowMs = 100_000L + + val context: Context = ApplicationProvider.getApplicationContext() + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val recorder = RecorderSpy() + + 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 + // already foregrounded at subscribe time. + if (fireOnSubscribe) { + handlerSlot.captured.onFocus(true) + } + } + val configModelStore = + MockHelper.configModelStore { + it.pushSubscriptionId = subscriptionId + it.consentRequired = consentRequired + it.consentGiven = consentGiven + } + // 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() + } + + 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({ + test("six rapid cycles copy the prefixed subscription ID to the clipboard") { + val harness = Harness() + + repeat(6) { harness.cycle() } + + 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() } + + 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) } + + 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() } + + 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) + harness.clipText() shouldBe null + + harness.cycle() + harness.clipText() shouldBe harness.expectedClip + } + + test("the detector re-arms after firing") { + val harness = Harness() + + repeat(6) { harness.cycle() } + harness.clipText() shouldBe harness.expectedClip + + harness.clipboard.setPrimaryClip(ClipData.newPlainText("other", "sentinel")) + repeat(6) { harness.cycle() } + harness.clipText() shouldBe harness.expectedClip + } + + test("the remote kill switch suppresses the copy") { + val harness = Harness(killSwitchOn = true) + + repeat(6) { harness.cycle() } + + harness.clipText() shouldBe null + } + + test("withheld privacy consent suppresses the copy") { + val harness = Harness(consentRequired = true, consentGiven = null) + + repeat(6) { harness.cycle() } + + harness.clipText() shouldBe null + } + + test("granted privacy consent allows the copy") { + val harness = Harness(consentRequired = true, consentGiven = true) + + repeat(6) { harness.cycle() } + + harness.clipText() shouldBe harness.expectedClip + } + + 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() } + + harness.clipText() shouldBe "os: no subscription ID yet" + } + + test("a local not-yet-synced push subscription ID copies the placeholder") { + val harness = Harness(subscriptionId = "local-$SUBSCRIPTION_ID") + + repeat(6) { harness.cycle() } + + harness.clipText() shouldBe "os: no subscription ID yet" + } + + test("the subscribe-time focus replay does not count as a cycle") { + val harness = Harness(fireOnSubscribe = true) + + repeat(5) { harness.cycle() } + harness.clipText() shouldBe null + + harness.cycle() + 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() } + harness.clipText() shouldBe null + + harness.cycle() + 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. + + 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() } + harness.recorder.recorded.shouldBeEmpty() + + harness.cycle() + + 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(killSwitchOn = true) + + repeat(6) { harness.cycle() } + + 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() } + + 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() } + + harness.recorder.recorded.shouldBeEmpty() + } + + test("each recognition records its own event") { + val harness = Harness() + + repeat(12) { harness.cycle() } + + 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") + } +})