Skip to content

fix: Preserve user identity across iOS prewarm launches #1669

Merged
nan-li merged 11 commits into
mainfrom
nan/sdk-4725-v3
Jun 2, 2026
Merged

fix: Preserve user identity across iOS prewarm launches #1669
nan-li merged 11 commits into
mainfrom
nan/sdk-4725-v3

Conversation

@nan-li
Copy link
Copy Markdown
Contributor

@nan-li nan-li commented Jun 2, 2026

Description

One Line Summary

Defer SDK ops during iOS prewarm (before first unlock) so the cached user isn't overwritten by an orphan; mirror identifiers to NSFileProtectionNone storage for NSE/locked-storage reads.

Details

Motivation

Fixes SDK-4725: when iOS prewarms the app before first unlock, shared App Group UserDefaults reads return nil. The SDK then loads the cached user as empty, falls through to createUser, mints a new server-side user, and overwrites the prior identity / external_id / tags on disk once UD becomes readable.

Scope

  • New OSResilientStorage — file-backed NSFileProtectionNone JSON mirror of opaque identifiers (app_id, subscription_id, receive_receipts_enabled, has_prior_session).
  • OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent gets a third branch via an isProtectedDataAvailableProvider closure the main app injects at +OneSignal.init. NSE leaves the provider nil → treats storage as available (NSE uses the mirror directly).
  • OneSignal.m seeds an atomic-BOOL one-way latch via a UD probe + UIApplication.isProtectedDataAvailable tiebreaker, registers a UIApplicationProtectedDataDidBecomeAvailable observer (CAS-gated, runs at most once per process) that re-drives start() / sendPushTokenToDelegate / LA / IAM / startNewSession:YES post-unlock.
  • OSModelStore.refresh() re-reads UserDefaults if models is empty so post-unlock start() sees disk state.
  • OneSignalIdentifiers.subscriptionId / storedAppId fall back to the mirror when UD reads are empty.
  • NSE isReceiveReceiptsEnabled falls back to the mirror.
  • LA request prepareForExecution paths switched from _user?.pushSubscriptionModel.subscriptionIdOneSignalIdentifiers.subscriptionId so LA requests built during prewarm still resolve a sub id.
  • handleAppIdChange nil-guards prevAppId (no destructive clear on a fresh install) and extends the clear set to keep UD + mirror in lockstep.

What does NOT change

  • Public SDK API surface.
  • Normal unlocked-device launches: the seed reports YES and +init runs synchronously as before.
  • NSE behavior on unlocked devices is unchanged (UD reads return values, mirror fallback is never consulted).

Testing

Unit testing

  • OSResilientStorageTests — 8 tests on the public API.
  • OSModelStoreRefreshTests — 4 tests on the post-unlock refresh path.
  • OneSignalIdentifiersFallbackTests — 6 tests on UD→mirror fallback.
  • OneSignalUserTests.testStartDefersUntilProtectedDataAvailableThenProceeds — regression test for the start() defer-then-proceed contract.

Manual testing

On a real device (simulators don't reproduce locked-storage reliably):

  • Reboot → don't unlock → send a push → NSE fires receive receipt.
  • Reboot → wait for prewarm → unlock → OSID unchanged, no new orphan user server-side.
  • Normal cold-launch on an unlocked device → OneSignal.User.addTag(...) immediately after initialize lands.
  • Lock + unlock with app running → SDK calls keep working through the lock; no duplicate session_count / fetchUser on the unlock.
  • Upgrade from main to this branch → OSID/cached user preserved, no orphan created, data loaded correctly

To reproduce original behavior on main, use physical device:

  1. start a live activity on screen (causes prewarm to happen consistently)
  2. then reboot phone but don't unlock yet
  3. the app is likely to go into a prewarm
  4. Look on dashboard and see new user and subscription from that device

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 Nan/sdk 4725 v3 Fix: Preserve user identity across iOS prewarm launches Jun 2, 2026
@nan-li nan-li changed the title Fix: Preserve user identity across iOS prewarm launches fix: Preserve user identity across iOS prewarm launches Jun 2, 2026
Comment thread iOS_SDK/OneSignalSDK/Source/OneSignal.m
@nan-li nan-li requested a review from a team June 2, 2026 09:00
// microsecond window can't lose the sentinel and cause the next prewarm
// launch to misclassify us as a fresh install.
OSResilientStorage.setString("1", forKey: OSResilientStorage.keyHasPriorSession)
_ = OSResilientStorage.snapshot()
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.

remove this,

@nan-li
Copy link
Copy Markdown
Contributor Author

nan-li commented Jun 2, 2026

Manual testing can look for logs:

  • "deferred: device-protected storage is not yet available"
  • any new onesignal IDs or subscription IDs
  • requests made "network request" ...
  • when the observer fires that protected data is avail: "refresh hydrated (stored.count) model(s) from UserDefaults"


BOOL initialState = ComputeInitialStorageReadable();
atomic_store(&gProtectedDataAvailable, initialState);
atomic_store(&gObserverShouldRecover, !initialState);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It looks like there could be a race with respect to setting up the observer before the latch and provider. In reality it's probably not likely to happen but in theory could the block see gObserverShouldRecover == NO if the notification is sent between addObserverForName and the two stores?

Claude suggests: compute initialState, store both atomics, set the provider, and register the observer last

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.

Practical likelihood: for the notification to fire during the few microseconds inside dispatch_once, the device would have to unlock at exactly that instant during app init. Vanishingly rare. If this were to happen, it is not disastrous. The SDK mostly heals in the same session such as on any OneSignal.User.X call, only small gaps would be missed such as missed session_count incrementer or refresh user call. Fully self-heals on next launch.

The reorder narrows but doesn't fully eliminate the race. With the new order, there's still a window between the atomic_store and the addObserverForName where the notification could post and be missed entirely.

Base automatically changed from nan/identifier-accessors to main June 2, 2026 21:00
@nan-li nan-li requested a review from fadi-george June 2, 2026 21:00
nan-li added 11 commits June 2, 2026 14:13
NSFileProtectionNone JSON file in the App Group container that mirrors
opaque identifiers so they're readable before first unlock, when
cfprefsd-backed UserDefaults returns nil.
Adds `isProtectedDataAvailableProvider` injection point and a third
branch in `shouldAwaitAppIdAndLogMissingPrivacyConsent` so SDK ops
defer when device-protected storage isn't readable (iOS prewarm
before first unlock). NSE callers (provider nil) keep prior behavior.
Lets callers re-read from disk after protected-data becomes
available (iOS prewarm fix). No-op when models already populated;
doesn't fire listeners. Extract UD read + change-notifier
subscribe into helpers shared with init().
Push subscription id writes now hit both shared UserDefaults and
the file-backed mirror so the NSE / prewarm callers can read it
when UserDefaults is locked. nil resets clear the mirror.
Refresh all four model stores at the top of start() so a singleton
that was first touched during iOS prewarm (UD locked, dicts loaded
empty) sees what's actually on disk before Path 1's cache check.

Backfill OSResilientStorage.keySubscriptionId from Path 1 and the
v3 legacy migration so SDK upgraders and migrated users populate
the mirror on their first post-upgrade launch.

Write OSResilientStorage.keyHasPriorSession at the end of start();
drain via snapshot() so an OS kill can't lose the sentinel.
NSE reads OSUD_RECEIVE_RECEIPTS_ENABLED from shared UD, but that
returns NO when the device is locked under
NSFileProtectionCompleteUntilFirstUserAuthentication. Short-circuit
on YES, then consult the file-backed mirror so receive receipts
still fire for NSE wakes during the prewarm/locked window.
LA request prepareForExecution was reading _user?.pushSubscriptionModel,
which is nil during prewarm (predicate gates start()). Switch all 6 LA
request types to OneSignalIdentifiers.subscriptionId so they read from
persisted UD (with OSResilientStorage fallback for locked-storage cases).
Extract protected-data setup into +setupProtectedDataObserverOnce
and ComputeInitialStorageReadable, called from +init. Owns the
atomic-BOOL latch, DidBecomeAvailable observer (CAS-gated so it
runs at most once per process), provider closure, and seed.

Gate startLiveActivitiesManager / startInAppMessages on the
predicate so their singletons don't load empty UD state during
prewarm; observer re-drives them post-unlock.

Switch startNewSessionInternal to the full predicate so it defers
during prewarm.

handleAppIdChange: nil-guard prevAppId; extend clear set with
OSUD_RECEIVE_RECEIPTS_ENABLED, the push-sub model store UD entry,
and three OSResilientStorage keys; mirror app_id on every setAppId.

downloadIOSParamsWithAppId mirrors receive_receipts to
OSResilientStorage for the NSE fallback.
OSResilientStorage.swift → OneSignalOSCore framework.
OSResilientStorageTests / OSModelStoreRefreshTests /
OneSignalIdentifiersFallbackTests → OneSignalOSCoreTests.
…rt gate

- OSResilientStorageTests: 8 tests covering the public API
- OSModelStoreRefreshTests: 4 tests for the post-unlock refresh path
- OneSignalIdentifiersFallbackTests: 6 tests for the UD→mirror fallback
- OneSignalUserTests: regression test for start() defer-then-proceed
  contract, captures the async-seed bug surfaced during review
@nan-li nan-li force-pushed the nan/sdk-4725-v3 branch from 787bcc3 to d47f412 Compare June 2, 2026 21:14
@nan-li nan-li requested a review from sherwinski June 2, 2026 22:58
@fadi-george
Copy link
Copy Markdown
Collaborator

I dont see duplciate record but i notice live activity didnt show after restarting device

@nan-li nan-li merged commit 8e95157 into main Jun 2, 2026
2 of 3 checks passed
@nan-li nan-li deleted the nan/sdk-4725-v3 branch June 2, 2026 23:43
@github-actions github-actions Bot mentioned this pull request Jun 3, 2026
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.

3 participants