-
Notifications
You must be signed in to change notification settings - Fork 379
feat: [SDK-5088] add device gesture that copies the push subscription ID to the clipboard #2727
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: nan/sdk-5156
Are you sure you want to change the base?
Changes from all commits
2eeeb49
e59460a
51df457
52d813d
03042cd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Long>() | ||
|
|
||
| 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)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider (Opus+Grok; Sol on the hop):
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The IO to Main hop is gone in 03042cd: the write now runs on the focus callback itself, so it happens while the app still has focus. We deliberately never read the clipboard back, since reads are what Android restricts and shows to the user; the write is a synchronous, guarded call, and nothing is recorded if it throws. |
||
| 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 | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider (Grok): This dwell is wall-clock between
onUnfocusedandonFocus, not “time the app was backgrounded.”onOrientationChangedfires those back-to-back but with other sync handlers in between (WebViewManager.onActivityStopped→removeAllViews()). If that work exceeds 250ms, aconfigChangesrotation counts as a cycle. Tests only inject a 1ms blip.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Left as is. The floor is a time filter by design, and there is no measurement of that in-between work exceeding 250 ms; if a rotation ever counts as a cycle it shows up as accidental copies in the event data, and the floor can be raised then.