Skip to content

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

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

feat: [SDK-5088] add device gesture that copies the push subscription ID to the pasteboard#1730
nan-li wants to merge 6 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 pasteboard, 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 pasteboard, ready to paste into the dashboard's subscription ID search. No app changes, no new permissions.

Ticket SDK-5088. Stacked on #1732 (SDK-5156), which this PR targets. GitHub retargets it to the next base once that merges. The KMP submodule moves to the tip of the KMP PR branch (OneSignal-KMP-SDK#24), which adds the catalog entry, and must be re-pointed to the release tag before this merges.

Scope

OSDeviceGestureDetector in OneSignalOSCore observes the app-level background and active notifications, not the per-scene ones, which would over-count on multi-window iPad. Pairing background with active is itself a filter, since Control Center and Face ID only resign active without backgrounding the app. A cycle also needs a background phase of at least 250ms, matching Android. The 30 second window is the only rate rule, measured on a clock that keeps counting through sleep so brief visits spread over a day cannot add up to one, and six cycles fit inside it at round trips of five seconds or faster. Each counted cycle logs at verbose, outside the detector lock.

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 expires after 5 minutes, which caps how long it replaces whatever the person had copied. It is not local-only, so Universal Clipboard can carry the ID to the Mac running the dashboard. UIPasteboard is allowed under APPLICATION_EXTENSION_API_ONLY, so OSCore stays extension-safe.

The detector starts next to StartFeatureFlagsRefresh(), including the protected-data recovery path, and resets in clearStatics and on an app-id change, next to the other per-process resets. Nothing is copied when the SDK is not ready (no appId, consent withheld, storage locked) or when sdk_device_gesture_disabled is on for the app. That key is an inverted kill switch in the KMP catalog, read through OSFeatureManager 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 pasteboard gets os: no subscription ID yet instead, so someone following the docs can tell the gesture worked.

Each recognised gesture also records OSObservabilityEvent.deviceGesture through the recorder from #1732, 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 pasteboard. copied and no_id are recorded after the pasteboard write. Nothing is recorded while the SDK is not ready. No event ships until sdk_event_device_gesture is on for the app.

No public API changes. The detector is @objc public only so OneSignal.m can start and reset it, like OSFeatureFlagsRefreshService.

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

OSDeviceGestureDetectorTests, 23 tests, drives synthetic notifications through an injected NotificationCenter with a fake clock and a writer that records instead of touching the real pasteboard. Eighteen cover the counting rules, both edges of the 250ms floor and of the 30 second window, the skip conditions, the kill switch key matching the catalog entry, idempotent registration, observer teardown, and a write already queued when tearDown lands. Five use a recorder spy and pin the exact attribute names and values, since the log queries match on them.

The full OneSignalOSCore test bundle (170 tests) passes locally against an XCFramework built from the pinned KMP commit.

Manual testing

The detection was hand-tested in the demo app on the simulator. The first version had maximum dwell times and cleared progress on any miss, which made the gesture nearly impossible to perform, so the timing loosened to the single window rule. The event path was then run with sdk_event_device_gesture forced on through the local feature override: the gesture wrote the pasteboard and the event shipped.

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

@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 pasteboard Sep 3, 2026
@nan-li
nan-li force-pushed the nan/sdk-5088 branch 3 times, most recently from 9d172e3 to 08a5a86 Compare September 4, 2026 05:41
@nan-li
nan-li changed the base branch from main to nan/sdk-5156 September 4, 2026 05:42
@nan-li
nan-li force-pushed the nan/sdk-5088 branch 3 times, most recently from c955556 to 392edb5 Compare September 4, 2026 16:02
… ID to the pasteboard

Backgrounding and foregrounding the app 6 times within 30 seconds copies
the push subscription ID to the general pasteboard with a 5 minute
expiry, 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.

A cycle is a didEnterBackground / didBecomeActive pair observed
app-level (never per-scene, matching OSFeatureFlagsRefreshService),
counted on a monotonic clock. A cycle needs a background phase of at
least 250ms, and the 30s sliding window is the only rate rule. Each
counted cycle logs at verbose so manual testing can watch progress.

The detector starts alongside StartFeatureFlagsRefresh, including the
protected-data recovery path, and resets in clearStatics. It skips when
the SDK is not ready (appId, consent, storage) or the subscription does
not exist yet, and adding sdk_device_gesture_disabled to an app's
enabled feature keys turns it off remotely. The raw OSFeatureFlagsStore
list is checked instead of OSFeatureManager because the KMP catalog hides
unregistered keys.
OSDeviceGestureDetector now takes an OSObservabilityEventRecorderProtocol
(OSObservabilityEventRecorder.shared by default) and records
OSObservabilityEvent.deviceGesture 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
pasteboard.

The copied result is recorded after the pasteboard write, so it never
overstates. A gesture while the SDK is not ready (no app id, consent
withheld, storage unreadable) 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 pasteboard is now "os: no
subscription ID yet", which says which one it was. The event result
stays no_id and is recorded after the write, like copied.

An SDK that is not ready, the remote kill switch and a gesture that was
not recognised still copy nothing.
Brings in FeatureFlag.SDK_DEVICE_GESTURE_DISABLED so the detector can ask
the feature manager for the kill switch.
The detector scanned the raw fetched flag list because the kill switch
key had no catalog entry. Now that it has one, OSDeviceGestureDetector
asks OSFeatureManager for sdk_device_gesture_disabled like every other
flag, which also drops the hand-rolled case handling. A test pins the
key to the catalog entry, since the manager answers false for any key
it does not know. The switch still reads present-means-off, and absent
still means on.
@nan-li
nan-li marked this pull request as ready for review September 4, 2026 21:48

@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 (Opus 5, GPT 5.6 Sol, Grok 4.6)

Intent: Six app-level background/foreground cycles in 30s copy os: <push subscription id> to the pasteboard, with an inverted remote kill switch and an observability event.

Act on

  • App-id change does not reset the detector (3/3). clearStatics calls reset(), but handleAppIdChange resets logging, feature flags, and the persisted subscription id without touching this detector. Cycle progress and a queued copy can finish under the new app. Add [OSDeviceGestureDetector reset] next to the other resets.
  • OneSignalLog inside stateLock in onFocus (2/3 Opus+Grok). NSLock is not recursive; host log listeners run synchronously and can re-enter start/reset. Sibling code already logs outside locks for this reason.
  • ProcessInfo.systemUptime does not advance while asleep (Opus; same clock class as Android #2727). Lock/unlock cycles can accumulate inside a “30s” window over hours, or a real long-sleep background can fail the 250ms dwell. Use a sleep-inclusive monotonic clock.

Consider

  • invalidated is sampled, then the lock is dropped before the pasteboard write (2/3 Sol+Grok). A concurrent reset() can still let the stale block write. Re-check immediately before pasteboardWriter. Current tests miss this because InlineQueue runs the block inline.
  • The 6th cycle is consumed even when shouldAwait is true (Grok). cycleCompletions is cleared before the readiness check, so a gesture during withheld consent is silently spent.

Noted

  • Universal Clipboard + gesture.push_subscription_id on the event (2/3): product-stated. Blast radius is every Handoff device on the iCloud account plus the log pipeline.
  • Inclusive window (> 30) / dwell (< 0.25) boundaries are untested (2/3).
  • Tests never exercise start()/reset() on the static singleton.

Dismissed

  • @objc public start/reset as an API change (Sol): same pattern as OSFeatureFlagsRefreshService.
  • Fail-open kill switch / missing host opt-out as blockers (Opus): absent-means-on is the documented contract; the key is pinned to the catalog.
  • Scene-only notifications (Grok): matches OSFeatureFlagsRefreshService; app-level names still post.
Open in Web View Automation 

Sent by Cursor Automation: PR Reviews

+ (void)clearStatics {
[OSRemoteLoggingController reset];
[OSFeatureFlagsRefreshService reset];
[OSDeviceGestureDetector reset];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

reset() is only wired into the test-only clearStatics path. Runtime handleAppIdChange already resets OSRemoteLoggingController, OSFeatureFlagsRefreshService, and OSFeatureManager and clears the persisted subscription id, but not this detector. Cycle progress (and a queued pasteboard write) can then complete under the new app id. Consensus 3/3.

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.

Fixed in 389ab43: handleAppIdChange now resets the detector next to the other resets, so a fresh instance starts under the new app id.

Comment on lines +207 to +218
OneSignalLog.onesignalLog(
.LL_VERBOSE,
message: "OSDeviceGestureDetector: ignored a \(String(format: "%.3f", dwell))s background blip (rotation filter)"
)
return false
}
cycleCompletions.append(timestamp)
cycleCompletions.removeAll { timestamp - $0 > Self.windowSeconds }
OneSignalLog.onesignalLog(
.LL_VERBOSE,
message: "OSDeviceGestureDetector: cycle \(cycleCompletions.count)/\(Self.requiredCycles) within the window "
+ "(background \(String(format: "%.2f", dwell))s)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These onesignalLog calls run while stateLock is held. The lock is not recursive, and OneSignalLog always invokes host log listeners synchronously. A listener that re-enters start/reset deadlocks — the same hazard OSFeatureManager.shared and OSObservabilityEventRecorder already document and avoid. Capture dwell/count inside the lock, then log after. Consensus 2/3 (Opus+Grok).

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.

Fixed in 389ab43: onFocus builds the progress line inside the lock and logs it after releasing the lock.

init(
notificationCenter: NotificationCenter = .default,
mainQueue: OSDispatchQueue = DispatchQueue.main,
nowProvider: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ProcessInfo.systemUptime is awake-only (CLOCK_UPTIME_RAW): it stops while the device is asleep. Lock/unlock from inside the app can accumulate six “30s-window” cycles over hours of wall time, and a real background that is mostly sleep can fail the 250ms dwell. Prefer a sleep-inclusive monotonic source (CLOCK_MONOTONIC_RAW / mach_continuous_time). Same clock class as Android #2727 (uptimeMillis). Opus; sibling Android review was 3/3.

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.

Fixed in 389ab43: the clock is now CLOCK_MONOTONIC_RAW, which on Darwin keeps counting through sleep; Android moved to elapsedRealtime() in the same round.

…eal time

Four review findings and three wording fixes on the detector.

handleAppIdChange now resets the detector next to the remote logger,
flag refresh and feature manager. Without it, cycle progress and a queued
pasteboard write survived into the new app, and the re-run start() was a
no-op because the old instance was still marked started.

onFocus builds its verbose progress line inside stateLock and logs after
releasing it. OneSignalLog runs app listeners synchronously at every
level, and a listener that re-enters the SDK would deadlock on the
non-recursive lock, the same hazard the feature manager and the event
recorder already avoid.

The clock is now CLOCK_MONOTONIC_RAW, which on Darwin keeps counting
through sleep. ProcessInfo.systemUptime 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 pasteboard nobody asked about. The window now
means what the docs say.

Wording: the class doc described the slowest qualifying pace as if it
were a floor, the expiry comment read as if the previous pasteboard came
back, and the recordGesture doc claimed no result is recorded without a
write, which disabled contradicts.

Tests add a deferring queue that proves tearDown drops a write already
queued, which the old teardown test claimed but never exercised, plus the
249ms versus 250ms dwell edge and the 30 second window edge.
@nan-li

nan-li commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Replies on the three inline threads cover the app-id reset, the lock and the clock. On the rest of the review:

  • invalidated is sampled, then the lock is dropped before the write. Left as is. reset runs on the thread that calls initialize, normally main, so it cannot interleave with the main-queue block, and the worst case is a harmless stale copy; a new test proves that a write already queued when tearDown lands is dropped.
  • The sixth cycle is consumed when the SDK is not ready. Left as is. Nothing can be copied before the SDK is ready either way, and the person repeats the gesture after consent, so carrying partial progress across that boundary would not help them.
  • Universal Clipboard and the ID on the event. Both are product decisions and are called out in the ticket.
  • Inclusive window and dwell boundaries untested. Added: 249 versus 250 ms, and the 30-second edge in both directions.
  • Tests never exercise start() and reset(). Still true. Both are thin wrappers over registerLifecycleObserversIfNeeded() and tearDown(), which are tested, and OneSignal.m is their only caller.

@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