fix: Preserve user identity across iOS prewarm launches #1669
Conversation
| // 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() |
|
Manual testing can look for logs:
|
|
|
||
| BOOL initialState = ComputeInitialStorageReadable(); | ||
| atomic_store(&gProtectedDataAvailable, initialState); | ||
| atomic_store(&gObserverShouldRecover, !initialState); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
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
|
I dont see duplciate record but i notice live activity didnt show after restarting device |
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
NSFileProtectionNonestorage for NSE/locked-storage reads.Details
Motivation
Fixes SDK-4725: when iOS prewarms the app before first unlock, shared App Group
UserDefaultsreads return nil. The SDK then loads the cached user as empty, falls through tocreateUser, mints a new server-side user, and overwrites the prior identity / external_id / tags on disk once UD becomes readable.Scope
OSResilientStorage— file-backedNSFileProtectionNoneJSON mirror of opaque identifiers (app_id,subscription_id,receive_receipts_enabled,has_prior_session).OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsentgets a third branch via anisProtectedDataAvailableProviderclosure the main app injects at+OneSignal.init. NSE leaves the provider nil → treats storage as available (NSE uses the mirror directly).OneSignal.mseeds an atomic-BOOL one-way latch via a UD probe +UIApplication.isProtectedDataAvailabletiebreaker, registers aUIApplicationProtectedDataDidBecomeAvailableobserver (CAS-gated, runs at most once per process) that re-drivesstart()/sendPushTokenToDelegate/ LA / IAM /startNewSession:YESpost-unlock.OSModelStore.refresh()re-reads UserDefaults ifmodelsis empty so post-unlockstart()sees disk state.OneSignalIdentifiers.subscriptionId/storedAppIdfall back to the mirror when UD reads are empty.isReceiveReceiptsEnabledfalls back to the mirror._user?.pushSubscriptionModel.subscriptionId→OneSignalIdentifiers.subscriptionIdso LA requests built during prewarm still resolve a sub id.handleAppIdChangenil-guardsprevAppId(no destructive clear on a fresh install) and extends the clear set to keep UD + mirror in lockstep.What does NOT change
+initruns synchronously as before.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 thestart()defer-then-proceed contract.Manual testing
On a real device (simulators don't reproduce locked-storage reliably):
OneSignal.User.addTag(...)immediately afterinitializelands.session_count/fetchUseron the unlock.mainto this branch → OSID/cached user preserved, no orphan created, data loaded correctlyTo reproduce original behavior on
main, use physical device:Affected code checklist
Checklist
Overview
Testing
Final pass