Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 61 additions & 17 deletions iOS_SDK/OneSignalSDK/Source/OneSignal.m
Original file line number Diff line number Diff line change
Expand Up @@ -463,54 +463,98 @@ static BOOL ComputeInitialStorageReadable(void) {
if (hasPriorSession) { // returning user during prewarm; defer
return NO;
}
if ([NSThread isMainThread]) {
return UIApplication.sharedApplication.isProtectedDataAvailable;
if (![NSThread isMainThread]) {
return YES; // off-main: can't safely read UIApplication
}
return YES; // off-main: can't safely read UIApplication
UIApplication *sharedApp = UIApplication.sharedApplication;
if (sharedApp) {
return sharedApp.isProtectedDataAvailable;
}
// sharedApplication is nil: we are running before UIApplicationMain, e.g. a SwiftUI
// App.init().
// A user-initiated launch implies the device is unlocked, so storage is readable.
// Only a prewarm-created process can reach this point while storage may still be
// locked; iOS marks those processes with the ActivePrewarm environment variable,
// which is cleared after launch completes, but pre-UIApplicationMain is always
// before that, so the read is reliable here.
BOOL isPrewarm = [NSProcessInfo.processInfo.environment[@"ActivePrewarm"] isEqualToString:@"1"];
[OneSignalLog onesignalLog:ONE_S_LL_DEBUG message:[NSString stringWithFormat:@"OneSignal initialized before UIApplicationMain (ActivePrewarm=%d); %@", isPrewarm, isPrewarm ? @"deferring storage reads until launch completes" : @"treating storage as readable"]];
return !isPrewarm;
}

/// One-time setup of the protected-data readiness check that matters during iOS app
/// prewarm, when reads from shared App Group UserDefaults silently return nil
///
/// The cached flag is a one-way latch (NO → YES, never reverses). It is:
/// * Seeded by `ComputeInitialStorageReadable` (case table below).
/// * Flipped to YES on `UIApplicationProtectedDataDidBecomeAvailable`.
/// * Flipped to YES on `UIApplicationProtectedDataDidBecomeAvailable`, or on
/// `didFinishLaunching`/`didBecomeActive` when `isProtectedDataAvailable` verifies YES.
/// * Read via `OneSignalConfig.isProtectedDataAvailableProvider`
///
/// Seed case table:
/// 1. pushModels populated → YES (UD is readable)
/// 2. `keyHasPriorSession` set, no UD → NO (returning user during prewarm; defer)
/// 3. neither + main thread → fall back to `UIApplication.isProtectedDataAvailable`
/// 4. neither + off-main thread → YES (can't safely read UIApplication)
/// 1. pushModels populated → YES (UD is readable)
/// 2. `keyHasPriorSession` set, no UD → NO (returning user during prewarm; defer)
/// 3. neither + off-main thread → YES (can't safely read UIApplication)
/// 4. neither + sharedApplication exists → `UIApplication.isProtectedDataAvailable`
/// 5. neither + sharedApplication nil → NO only when `ActivePrewarm=1` (pre-UIApplicationMain:
/// a user-initiated launch implies unlocked storage; only prewarm can mean locked storage)
///
/// `keyHasPriorSession` is the only reliable "SDK previously ran here" sentinel, and case 3's
/// `keyHasPriorSession` is the only reliable "SDK previously ran here" sentinel, and case 4's
/// `isProtectedDataAvailable` tiebreaker also protects SDK upgraders who never wrote `keyHasPriorSession`.
///
/// `gObserverShouldRecover` is set when init defers (storage isn't yet readable) and cleared by the
/// observer's first fire (tracked since `DidBecomeAvailable` posts on every device unlock).
/// `gObserverShouldRecover` is set when init defers (storage isn't yet readable) and cleared when
/// recovery runs once. Recovery triggers, whichever verifies first:
/// * `UIApplicationProtectedDataDidBecomeAvailable` posts on unlock, including the
/// first unlock after boot (the locked-prewarm case). Does NOT post if storage was
/// never locked, so it cannot be the only trigger.
/// * `didFinishLaunching` / `didBecomeActive` with a live `isProtectedDataAvailable` == YES
/// re-check covers deferrals where storage was readable all along (e.g. a prewarm-created
/// process the user later foregrounds, when the unlock notification never fires).
+ (void)setupProtectedDataObserverOnce {
static _Atomic(BOOL) gProtectedDataAvailable = NO;
static _Atomic(BOOL) gObserverShouldRecover = NO;
static dispatch_once_t protectedDataOnce;
dispatch_once(&protectedDataOnce, ^{
[NSNotificationCenter.defaultCenter addObserverForName:UIApplicationProtectedDataDidBecomeAvailable
object:nil
queue:nil
usingBlock:^(NSNotification * _Nonnull note) {
// Marks storage readable, then re-drives the SDK components that init skipped
// (at most once). If `gObserverShouldRecover == YES`, atomically swap to NO and
// proceed; otherwise bail (already consumed, or init never deferred).
void (^recoverIfDeferred)(void) = ^{
atomic_store(&gProtectedDataAvailable, YES);
// Only run the recovery if init deferred. If `gObserverShouldRecover == YES`,
// atomically swap to NO and proceed; otherwise bail (already consumed, or never set).
BOOL shouldRecover = YES;
if (!atomic_compare_exchange_strong(&gObserverShouldRecover, &shouldRecover, NO)) {
return;
}
[OneSignalLog onesignalLog:ONE_S_LL_DEBUG message:@"Device storage became readable; starting deferred OneSignal components"];
[OneSignalUserManagerImpl.sharedInstance start];
[OSNotificationsManager sendPushTokenToDelegate];
[OneSignal startLiveActivitiesManager];
[OneSignal startInAppMessages];
[OneSignal startNewSession:YES];
};

// Authoritative signal: the OS says protected data just became available.
[NSNotificationCenter.defaultCenter addObserverForName:UIApplicationProtectedDataDidBecomeAvailable
object:nil
queue:nil
usingBlock:^(NSNotification * _Nonnull note) {
recoverIfDeferred();
}];

// Lifecycle signals: if init deferred while storage was actually readable,
// the unlock notification never posts. Once UIApplicationMain has run,
// `sharedApplication` exists and we can consult the real `isProtectedDataAvailable`.
for (NSNotificationName name in @[UIApplicationDidFinishLaunchingNotification,

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.

why do we need to loop thru the notifications?

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.

We need to observe both lifecycle notifications with the same recovery block: didFinishLaunching is the earliest point after UIApplicationMain where sharedApplication exists, and didBecomeActive gives us a second chance if storage wasn’t readable at didFinish. The loop is just to avoid duplicating the same observer body twice.

UIApplicationDidBecomeActiveNotification]) {
[NSNotificationCenter.defaultCenter addObserverForName:name
object:nil
queue:NSOperationQueue.mainQueue
usingBlock:^(NSNotification * _Nonnull note) {
if (UIApplication.sharedApplication.isProtectedDataAvailable) {
recoverIfDeferred();
}
}];
}

OneSignalConfig.isProtectedDataAvailableProvider = ^BOOL {
return atomic_load(&gProtectedDataAvailable);
};
Expand Down
Loading