Skip to content

feat: [SDK-5088] add device gesture that copies the push subscription ID to the clipboard - #2727

Open
nan-li wants to merge 5 commits into
nan/sdk-5156from
nan/sdk-5088
Open

feat: [SDK-5088] add device gesture that copies the push subscription ID to the clipboard#2727
nan-li wants to merge 5 commits into
nan/sdk-5156from
nan/sdk-5088

Conversation

@nan-li

@nan-li nan-li commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Description

One Line Summary

Six background/foreground cycles within 30 seconds copy the push subscription ID to the clipboard, and each recognised gesture records an observability event.

Details

Motivation

Finding your own device in the dashboard is the slowest part of sending a first test push. Today that means digging a subscription ID out of verbose logs or searching by external ID. With this, anyone can background and foreground the app six times within 30 seconds on a production build and the SDK copies the push subscription ID to the clipboard, ready to paste into the dashboard's subscription ID search. No app changes, no new permissions.

Ticket SDK-5088. Stacked on #2732 (SDK-5156), which this PR targets. GitHub retargets it to main once that merges. The catalog entry it reads arrives through #2732's KMP pin (OneSignal-KMP-SDK#24); this PR does not move the submodule.

Scope

DeviceGestureDetector counts background-to-foreground pairs on a monotonic clock that keeps counting through deep sleep, so the window means real time and brief visits spread over a day cannot add up to one. A cycle needs a background phase of at least 250ms, which filters the synthetic unfocus/focus pair that rotation fires when an activity handles orientation itself. The 30 second window is the only rate rule, so six cycles fit inside it at round trips of five seconds or faster, and rapid switching between two apps can reach it; the event below measures how often that happens. Each counted cycle logs at verbose, outside the detector lock. The clipboard write runs on the focus callback itself, with no dispatcher hop.

The clip reads os: <id>. The short prefix marks the value as a OneSignal ID for the dashboard's paste target and for anyone who copied it by accident. It is not marked sensitive, so the Android 13+ copy preview doubles as confirmation.

Nothing is copied when privacy consent is withheld or when sdk_device_gesture_disabled is on for the app. That key is an inverted kill switch in the KMP catalog (FeatureFlag.SDK_DEVICE_GESTURE_DISABLED), read through IFeatureManager like every other flag, so a device that has never fetched flags still has the gesture and a customer can be opted out per app. Without a push subscription yet the clip is os: no subscription ID yet instead, so someone following the docs can tell the gesture worked.

Each recognised gesture also records ObservabilityEvent.DEVICE_GESTURE through the recorder from #2732, so we can see how often the gesture is used without the gesture making a request of its own. gesture.result is copied, no_id or disabled. A copied event also carries gesture.push_subscription_id, the value that went on the clipboard. copied and no_id are recorded after the clip is set. Nothing is recorded under withheld consent. No event ships until sdk_event_device_gesture is on for the app.

CoreModule.register moved its misconfigured-fallback registrations into a helper to stay under detekt's method length cap. No behavior change.

Other

The dashboard paste target that recognizes the clip and extracts the ID is separate work. Until then the person pastes the ID portion by hand.

Testing

Unit testing

DeviceGestureDetectorTests, 21 tests, drives synthetic focus/unfocus sequences against a fake clock and reads back the Robolectric clipboard; the write is synchronous, so no dispatcher mocks are involved. Sixteen cover the counting rules, both edges of the 250ms floor and of the 30 second window, every skip condition and a missing clipboard service, with the feature manager as a strict mock that answers only the kill switch flag. Five use a recorder spy and pin the exact attribute names and values, since the log queries match on them.

LoggerLifecycleManagerTest, LoggerLifecycleManagerFaultTest and spotlessCheck pass on the rebased branch.

Manual testing

Run on an Android 14 emulator with sdk_event_device_gesture forced on through the local feature override: the gesture copied the ID and the event arrived in Cloud Logging with the expected attributes. Not yet run on a physical device. The iOS detection was hand-tested on the simulator, which is what settled the timing on a single window rule.

Affected code checklist

  • Notifications
    • Display
    • Open
    • Push Processing
    • Confirm Deliveries
  • Outcomes
  • Sessions
  • In-App Messaging
  • REST API requests
  • Public API changes

Checklist

Overview

  • I have filled out all REQUIRED sections above
  • PR does one thing
  • Any Public API changes are explained in the PR details and conform to existing APIs

Testing

  • I have included test coverage for these changes, or explained why they are not needed
  • All automated tests pass, or I explained why that is not possible
  • I have personally tested this on my device, or explained why that is not possible

Final pass

  • Code is as readable as possible.
  • I have reviewed this PR myself, ensuring it meets each checklist item

@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

📊 Diff Coverage Report

Diff Coverage Report (Changed Lines Only)

Gate: aggregate coverage on changed executable lines must be ≥ 80% (JaCoCo line data for lines touched in the diff).

Changed Files Coverage

  • DeviceGestureDetector.kt: 78/81 touched executable lines (96.3%) (195 touched lines in diff)

Overall (aggregate gate)

78/81 touched executable lines covered (96.3% — requires ≥ 80%)

📥 View workflow run

@nan-li nan-li changed the title feat: [SDK-5088] add device gesture that marks the user as a test user feat: [SDK-5088] add device gesture that copies the push subscription ID to the clipboard Sep 3, 2026
@nan-li
nan-li force-pushed the nan/sdk-5088 branch 2 times, most recently from 60c2bf2 to d06b7f7 Compare September 4, 2026 05:42
@nan-li
nan-li changed the base branch from main to nan/sdk-5156 September 4, 2026 05:43
@nan-li
nan-li force-pushed the nan/sdk-5088 branch 3 times, most recently from 87f6063 to 5fa9ed9 Compare September 4, 2026 16:02
@nan-li
nan-li marked this pull request as ready for review September 4, 2026 21:50
@nan-li
nan-li requested a review from a team as a code owner September 4, 2026 21:50
@nan-li
nan-li removed the request for review from a team September 4, 2026 21:50
… ID to the clipboard

Backgrounding and foregrounding the app 6 times within 30 seconds copies
the push subscription ID to the clipboard, ready to paste into the
dashboard. The clip is os: <id>. The short prefix marks the value as a
OneSignal ID for the dashboard's paste target and for anyone who copied
it by accident.

Cycles are counted on a monotonic clock. A cycle needs a real
background phase of at least 250ms, which filters the synthetic
rotation unfocus/focus pair from ApplicationService.onOrientationChanged,
and the 30s sliding window is the only rate rule. Each counted cycle
logs at verbose so manual testing can watch progress.

The gesture skips when privacy consent is withheld or the push
subscription does not exist yet, and adding sdk_device_gesture_disabled
to an app's enabled feature keys turns it off remotely. The raw
ConfigModel.sdkRemoteFeatureFlags list is checked instead of
IFeatureManager because the KMP catalog hides unregistered keys.

CoreModule.register moved its misconfigured-fallback block into a helper
to stay under detekt's LongMethod cap after the new registration.
DeviceGestureDetector now takes the IObservabilityEventRecorder from the
container and records ObservabilityEvent.DEVICE_GESTURE each time the
gesture is recognised. gesture.result is copied, no_id or disabled, and a
copied event also carries gesture.push_subscription_id, the value that
went on the clipboard.

The copied result is recorded from the main-thread block after the clip is
set, so it never overstates. A gesture under withheld privacy consent
records nothing, because the event ships to the backend.

Tests drive the detector with a recorder spy and pin the attribute names
and values, since the log backend is queried by them.
The gesture is going into the public docs, so a person who performs it
and gets nothing cannot tell whether the SDK missed it or had no ID to
give. Without a push subscription the clip is now "os: no subscription
ID yet", which says which one it was. The event result stays no_id and
is recorded after the clip is set, like copied.

Withheld consent, the remote kill switch and a gesture that was not
recognised still copy nothing.
The detector scanned the raw fetched flag list because the kill switch
key had no catalog entry. Now that it has one, DeviceGestureDetector
takes IFeatureManager and asks it for SDK_DEVICE_GESTURE_DISABLED like
every other flag, which also drops the hand-rolled case handling. The
switch still reads present-means-off, and absent still means on.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multi-model review

Models: Claude Opus 5, GPT 5.6 Sol, Cursor Grok 4.6.

Act on (3/3): monotonicMillis defaults to SystemClock.uptimeMillis(), which freezes in deep sleep. The 30s window and 250ms dwell are specified as elapsed time. Six cycles can accumulate across a doze, and a real lock-screen background can shrink below 250ms of uptime and get dropped as a rotation blip. elapsedRealtime() is still monotonic and matches the rules.

Consider

  • (Opus) No minimum gesture duration. Six ≥250ms backgrounds can fire in a couple of seconds; Recents / Custom Tabs / OAuth hops can hit it. The KDoc’s “sustained five-second round trips” is the slowest qualifying pace, not a floor.
  • (Opus+Grok, Sol related) setPrimaryClip then recordGesture(copied|no_id) does not prove the clip landed (API 29+ can drop a no-focus write silently). The IO→Main hop in suspendifyOnMain can lose foreground before the write.
  • (Grok) The 250ms rotation filter includes other sync work on ApplicationService.onOrientationChanged (IAM WebViewManager.removeAllViews sits between unfocus and focus). Tests only inject a 1ms blip.

Noted: consent not re-checked after the async hop (Sol, narrow race); no local integrator off-switch (Opus, matches default-on design); EXTRA_IS_SENSITIVE does not suppress the Android 13 preview (Opus); logging while holding the detector lock (Opus).

Dismissed: unmerged KMP pin (already a merge gate); registerMisconfiguredFallbacks extract (behavior-preserving).

Open in Web View Automation 

Sent by Cursor Automation: PR Reviews

* 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() }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Act on (3/3): uptimeMillis() pauses in deep sleep, so WINDOW_MS and MIN_BACKGROUND_DWELL_MS are uptime, not elapsed time. Six cycles can accumulate across a doze, and a lock-screen background can look like a <250ms rotation blip.

Use SystemClock.elapsedRealtime() — still monotonic / immune to NTP, and it matches the 30s / 250ms rules.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched to elapsedRealtime() in 03042cd, and iOS moved to a sleep-inclusive clock in the same round, so the 30-second window means real time on both platforms.

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, text))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider (Opus+Grok; Sol on the hop): setPrimaryClip is void and can be a silent no-op on API 29+ if the app has lost focus. Recording copied/no_id here overstates the KDoc guarantee that a result never claims a clipboard change that did not happen.

suspendifyOnMain goes IO→Main, so the 6th focus can unfocus again before this runs. Read primaryClip back before recording, or dispatch while still on the focus path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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.

// 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 -> {

Copy link
Copy Markdown

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 onUnfocused and onFocus, not “time the app was backgrounded.” onOrientationChanged fires those back-to-back but with other sync handlers in between (WebViewManager.onActivityStoppedremoveAllViews()). If that work exceeds 250ms, a configChanges rotation counts as a cycle. Tests only inject a 1ms blip.

Copy link
Copy Markdown
Contributor Author

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.

…side the lock

Three review findings and two wording fixes on the detector.

The clock is now SystemClock.elapsedRealtime(), which keeps counting
through deep sleep. uptimeMillis() stopped at every lock, so six brief
visits spread over an afternoon could add up to one "30 second" window
and copy the ID to a clipboard nobody asked about, and a lock that put
the phone to sleep could read as a sub-250ms blip and be dropped. The
window now means what the docs say.

The clipboard write runs on the focus callback itself instead of hopping
through the IO dispatcher and back to main. The callback already arrives
on main and the clipboard needs no particular thread, so the hop only
added a gap between recognising the gesture and writing. The write is
guarded so a misbehaving clipboard service cannot take the lifecycle
callback down, and the tests no longer need the dispatcher mocks.

The verbose progress lines are built inside the detector lock and logged
after it, since Logging calls app listeners synchronously.

Wording: the class doc described the slowest qualifying pace as if it
were a floor, and the recordGesture doc claimed no result is recorded
without a clipboard change, which disabled contradicts.

Tests add the 249ms versus 250ms dwell edge, the 30 second window edge,
and the missing clipboard service path.
@nan-li

nan-li commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Replies on the three inline threads cover the clock, the clipboard write and the rotation filter. On the rest of the review:

  • No minimum gesture duration, and the "sustained five-second round trips" wording. The wording is fixed in the code and the description. Not adding a minimum duration is the product call made after the max-dwell version proved unusable by hand, and the event exists to measure how often the gesture fires by accident.
  • Consent not re-checked after the async hop. No longer applies, since there is no hop.
  • No local integrator off-switch. Matches the gate decision: default on, with sdk_device_gesture_disabled as the per-app opt-out.
  • EXTRA_IS_SENSITIVE does not suppress the Android 13 preview. Right, and that is why it is not set: the preview showing the text is the confirmation.
  • Logging while holding the detector lock. Fixed: the progress line is built inside the lock and logged after it.

@nan-li
nan-li requested a review from a team September 5, 2026 00:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant