feat: [SDK-5088] add device gesture that copies the push subscription ID to the pasteboard - #1730
feat: [SDK-5088] add device gesture that copies the push subscription ID to the pasteboard#1730nan-li wants to merge 6 commits into
Conversation
9d172e3 to
08a5a86
Compare
c955556 to
392edb5
Compare
… 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.
There was a problem hiding this comment.
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).
clearStaticscallsreset(), buthandleAppIdChangeresets 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. OneSignalLoginsidestateLockinonFocus(2/3 Opus+Grok).NSLockis not recursive; host log listeners run synchronously and can re-enterstart/reset. Sibling code already logs outside locks for this reason.ProcessInfo.systemUptimedoes 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
invalidatedis sampled, then the lock is dropped before the pasteboard write (2/3 Sol+Grok). A concurrentreset()can still let the stale block write. Re-check immediately beforepasteboardWriter. Current tests miss this becauseInlineQueueruns the block inline.- The 6th cycle is consumed even when
shouldAwaitis true (Grok).cycleCompletionsis cleared before the readiness check, so a gesture during withheld consent is silently spent.
Noted
- Universal Clipboard +
gesture.push_subscription_idon 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/resetas an API change (Sol): same pattern asOSFeatureFlagsRefreshService.- 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.
Sent by Cursor Automation: PR Reviews
| + (void)clearStatics { | ||
| [OSRemoteLoggingController reset]; | ||
| [OSFeatureFlagsRefreshService reset]; | ||
| [OSDeviceGestureDetector reset]; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Fixed in 389ab43: handleAppIdChange now resets the detector next to the other resets, so a fresh instance starts under the new app id.
| 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)" |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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 }, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
Replies on the three inline threads cover the app-id reset, the lock and the clock. On the rest of the review:
|


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
OSDeviceGestureDetectorin 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.UIPasteboardis allowed underAPPLICATION_EXTENSION_API_ONLY, so OSCore stays extension-safe.The detector starts next to
StartFeatureFlagsRefresh(), including the protected-data recovery path, and resets inclearStaticsand 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 whensdk_device_gesture_disabledis on for the app. That key is an inverted kill switch in the KMP catalog, read throughOSFeatureManagerlike 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 getsos: no subscription ID yetinstead, so someone following the docs can tell the gesture worked.Each recognised gesture also records
OSObservabilityEvent.deviceGesturethrough the recorder from #1732, so we can see how often the gesture is used without the gesture making a request of its own.gesture.resultiscopied,no_idordisabled. A copied event also carriesgesture.push_subscription_id, the value that went on the pasteboard.copiedandno_idare recorded after the pasteboard write. Nothing is recorded while the SDK is not ready. No event ships untilsdk_event_device_gestureis on for the app.No public API changes. The detector is
@objc publiconly soOneSignal.mcan start and reset it, likeOSFeatureFlagsRefreshService.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 injectedNotificationCenterwith 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 whentearDownlands. 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_gestureforced on through the local feature override: the gesture wrote the pasteboard and the event shipped.Affected code checklist
Checklist
Overview
Testing
Final pass