Skip to content

feat: [SDK-5088] add device gesture that marks the user as a test user - #1730

Draft
nan-li wants to merge 2 commits into
mainfrom
nan/sdk-5088
Draft

feat: [SDK-5088] add device gesture that marks the user as a test user#1730
nan-li wants to merge 2 commits into
mainfrom
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 mark the current user as a test user.

Details

Motivation

Finding your own device in the dashboard is the slowest part of sending a first test push. Today that means pulling a subscription ID out of verbose logs, or searching by external ID. This adds a gesture anyone can perform on a production build, with no app changes and no new permissions. Background and foreground the app six times within 30 seconds and the SDK sets a test_user_name property on the user, which the dashboard can then surface.

Scope

OSDeviceGestureDetector lives in OneSignalOSCore and observes app-level didEnterBackgroundNotification and didBecomeActiveNotification, never the per-scene versions, which would over-count on multi-window iPad. Pairing the two is itself a filter, since Control Center pulls and Face ID sheets fire willResignActive without ever backgrounding the app. A cycle also needs a background phase of at least 250ms, matching Android where rotation emits a synthetic sub-millisecond pair. The 30 second sliding window is the only rate rule.

OSCore cannot reach the user layer, so OneSignal.m installs a handler at start that calls markCurrentUserAsTestUser() on OneSignalUserManagerImpl. That method reuses OSRequestUpdateProperties and executes it directly through OneSignalCoreImpl.sharedClient(), bypassing the operation repo because a queued operation can replay hours later and a stale mark is worse than one that failed outright. Reusing the existing request class means it inherits the subscription-id header and the prepareForExecution readiness guard, and it picks up the JWT header for free once addJWTHeader stops being a no-op under identity verification.

The detector starts alongside StartFeatureFlagsRefresh(), including the protected-data recovery path, and resets in clearStatics.

Nothing is sent when the SDK is not ready (no appId, consent withheld, storage still locked), when the user has no backend ID yet, or when sdk_device_gesture_disabled is in the app's enabled feature keys. That key is an inverted kill switch, so absent means enabled and a device that has never fetched flags still has a working gesture. It reads the raw OSFeatureFlagsStore list rather than OSFeatureManager, because the manager only resolves keys the KMP catalog registers and this key is deliberately not in the catalog.

markCurrentUserAsTestUser() is @objc public because OneSignal.m has to call it from Objective-C, which rules out @_spi. It is internal plumbing rather than documented public API, and it is not exposed through the OneSignal.User namespace.

Other

Two commits are on this branch. The first writes the subscription ID to the pasteboard, the second replaces that with the Update User request. The net diff is the request version alone. The first commit was already pushed when the approach changed, which is why the history reads that way.

The backend does not accept test_user_name yet and the dashboard does not surface test users yet, both tracked separately. Until that lands this ships inert, which is why it is a draft.

Testing

Unit testing

OSDeviceGestureDetectorTests drives synthetic notification sequences against an injected NotificationCenter, a fake monotonic clock, and a handler that counts invocations. Fourteen tests cover the counting rules, including six cycles firing exactly once, five firing nothing, the detector re-arming afterward, sub-250ms blips not counting, cycles spread wider than the window never accumulating six, a long pause mid-gesture keeping earlier progress, repeated activations with no intervening background not counting, and cold launch not counting. The rest cover the skip conditions, idempotent registration, and observer teardown.

Manual testing

Exercised by hand in the demo app on the iOS simulator. The first version enforced maximum dwell times on both phases and cleared all progress on any miss, which made the gesture nearly impossible to perform, so the timing loosened to the single window rule now in the branch. Verbose logging of each counted cycle was added at the same time, since silent resets were the reason the failures were hard to diagnose.

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

🤖 Generated with Claude Code

nan-li added 2 commits August 26, 2026 09:58
…ption ID to the pasteboard

Backgrounding and foregrounding the app 6 times within 30 seconds copies
os:<push subscription id> to the general pasteboard with a 5 minute
expiry, ready to paste into the dashboard. A cycle is a
didEnterBackground / didBecomeActive pair observed app-level (never
per-scene, matching OSFeatureFlagsRefreshService), counted on a monotonic
clock with dwell bounds (background 250ms..2.5s, foreground between
counted cycles at most 2.5s) so ordinary app switching cannot complete
the pattern.

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_test_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.
…he pasteboard

The gesture now sends an Update User request setting test_user_name to
the device-local time, so the dashboard can surface the device without
any copy-paste step. The request reuses OSRequestUpdateProperties and
executes directly through the client, bypassing the operation repo; a
queued operation can replay hours later, and a stale raise-hand signal
is worse than a failed one. The detector in OneSignalOSCore cannot see
the user layer, so OneSignal.m installs the sending handler at start.

Manual testing showed the dwell maximums made the gesture nearly
impossible to perform, one slow icon tap silently reset all progress,
so the 30s sliding window is now the only rate rule. The 250ms
background floor stays as the rotation filter, and each counted cycle
logs at verbose so manual testing can watch progress.

Also renames OSTestDeviceGestureDetector to OSDeviceGestureDetector so
the class does not read as a test class, and renames the remote
kill-switch key to sdk_device_gesture_disabled to match; nothing on the
backend serves the old key yet.
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