From d4debce3b54d88c01fa85198ff891054695b3284 Mon Sep 17 00:00:00 2001 From: Nan Date: Fri, 12 Jun 2026 12:08:34 -0700 Subject: [PATCH 1/5] chore: [SDK-4757] demo repro: initialize OneSignal in SwiftUI App.init() Temporarily remove the demo's AppDelegate and call OneSignal.initialize from the SwiftUI App initializer, before UIApplicationMain runs. On a clean install this reproduces the 5.5.2 regression: no OSRequestCreateUser is ever sent, so no anonymous user or push subscription is created. Reverted at the end of this branch; kept in history for reference. Co-authored-by: Cursor --- examples/demo/App/App.swift | 57 +++++++++---------------------------- 1 file changed, 14 insertions(+), 43 deletions(-) diff --git a/examples/demo/App/App.swift b/examples/demo/App/App.swift index 58de08ea3..a66b7ff63 100644 --- a/examples/demo/App/App.swift +++ b/examples/demo/App/App.swift @@ -31,10 +31,23 @@ import OneSignalLiveActivities @main struct App: SwiftUI.App { - @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject private var viewModel = OneSignalViewModel() @StateObject private var toastPresenter = ToastPresenter() + // REPRO SDK-4757: pure SwiftUI lifecycle (no AppDelegate), initialize in App.init() + // like the reporter's app. Revert to the @UIApplicationDelegateAdaptor version after. + init() { + let sharedApp = UIApplication + .perform(NSSelectorFromString("sharedApplication"))? + .takeUnretainedValue() as? UIApplication + if let app = sharedApp { + print("[SDK-4757 REPRO] App.init — sharedApplication exists, isProtectedDataAvailable=\(app.isProtectedDataAvailable)") + } else { + print("[SDK-4757 REPRO] App.init — UIApplication.sharedApplication is NIL (UIApplicationMain not called yet)") + } + OneSignalService.shared.initialize(launchOptions: nil) + } + var body: some Scene { WindowGroup { ContentView() @@ -44,48 +57,6 @@ struct App: SwiftUI.App { } } -// MARK: - App Delegate - -class AppDelegate: NSObject, UIApplicationDelegate { - - func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil - ) -> Bool { - // Initialize OneSignal - OneSignalService.shared.initialize(launchOptions: launchOptions) - - // Set up notification lifecycle listeners - setupNotificationListeners() - - // Set up in-app message listeners - setupInAppMessageListeners() - - // Set up Live Activities (iOS 16.1+) - if #available(iOS 16.1, *) { - LiveActivityController.setup() - } - - return true - } - - private func setupNotificationListeners() { - // Foreground notification display - OneSignal.Notifications.addForegroundLifecycleListener(NotificationLifecycleHandler.shared) - - // Notification click handling - OneSignal.Notifications.addClickListener(NotificationClickHandler.shared) - } - - private func setupInAppMessageListeners() { - // In-app message lifecycle - OneSignal.InAppMessages.addLifecycleListener(InAppMessageLifecycleHandler.shared) - - // In-app message click handling - OneSignal.InAppMessages.addClickListener(InAppMessageClickHandler.shared) - } -} - // MARK: - Notification Handlers class NotificationLifecycleHandler: NSObject, OSNotificationLifecycleListener { From 045bcd120b9538dc0afb82f2061d1d4e5e606c78 Mon Sep 17 00:00:00 2001 From: Nan Date: Fri, 12 Jun 2026 12:09:31 -0700 Subject: [PATCH 2/5] fix: [SDK-4757] do not gate startup when initialized before UIApplicationMain ComputeInitialStorageReadable read UIApplication.sharedApplication.isProtectedDataAvailable, which messages nil before UIApplicationMain has run (e.g. SwiftUI App.init()) and silently returns NO. On a fresh install the storage-readiness latch therefore seeded NO and every gated component (user manager, operation repo, session) no-opped. Because the device is never locked in this flow, UIApplicationProtectedDataDidBecomeAvailable never posts, so the SDK stayed gated forever and OSRequestCreateUser was never sent. Handle nil sharedApplication explicitly: 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, and iOS marks those with the ActivePrewarm environment variable, so defer only in that case. The variable is cleared after launch completes, but pre-UIApplicationMain always precedes that, so the read is reliable. Co-authored-by: Cursor --- iOS_SDK/OneSignalSDK/Source/OneSignal.m | 32 ++++++++++++++++++------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 6442b63b2..a3fbc859c 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -463,10 +463,24 @@ 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() or an ObjC +load. (Messaging nil here would silently return NO and leave + // the SDK permanently gated, since the recovery notification below may never fire.) + // 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 @@ -478,12 +492,14 @@ static BOOL ComputeInitialStorageReadable(void) { /// * 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 From b10ce7799ef3761a43d04918ee1b930d7bc12574 Mon Sep 17 00:00:00 2001 From: Nan Date: Fri, 12 Jun 2026 12:09:52 -0700 Subject: [PATCH 3/5] fix: [SDK-4757] recover deferred startup on app lifecycle notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When init defers because storage is not yet readable, recovery previously hinged solely on UIApplicationProtectedDataDidBecomeAvailable, which only posts on a lock-to-unlock transition. If storage was readable all along — a prewarm-created process the user later foregrounds, or a prior-session sentinel present while UserDefaults content was lost — the notification never posts and the deferral is permanent. Also observe didFinishLaunching and didBecomeActive: once UIApplicationMain has run, sharedApplication exists, so consult the real isProtectedDataAvailable and run the same once-only recovery (start user manager, resend push token, start Live Activities and IAM, new session). The live re-check keeps background launches before first unlock deferred until the unlock notification posts, as before. Co-authored-by: Cursor --- iOS_SDK/OneSignalSDK/Source/OneSignal.m | 49 ++++++++++++++++++++----- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index a3fbc859c..2cccf049d 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -488,7 +488,8 @@ static BOOL ComputeInitialStorageReadable(void) { /// /// 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: @@ -502,31 +503,61 @@ static BOOL ComputeInitialStorageReadable(void) { /// `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 (seed + // case 5, or a conservative misjudgment), the unlock notification never posts. + // Once UIApplicationMain has run, `sharedApplication` exists and we can consult + // the real `isProtectedDataAvailable`. Skipped when it reports NO (e.g. a + // background launch before first unlock) — the unlock notification covers those. + for (NSNotificationName name in @[UIApplicationDidFinishLaunchingNotification, + 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); }; From 014e5847ea470d409c4533f8cb9f531cd69abd4e Mon Sep 17 00:00:00 2001 From: Nan Date: Fri, 12 Jun 2026 12:10:08 -0700 Subject: [PATCH 4/5] chore: [SDK-4757] restore demo AppDelegate initialization Undo the temporary SwiftUI App.init() repro setup now that the fix is in place; the demo initializes OneSignal from application(_:didFinishLaunchingWithOptions:) again, matching main. Co-authored-by: Cursor --- examples/demo/App/App.swift | 57 ++++++++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/examples/demo/App/App.swift b/examples/demo/App/App.swift index a66b7ff63..58de08ea3 100644 --- a/examples/demo/App/App.swift +++ b/examples/demo/App/App.swift @@ -31,23 +31,10 @@ import OneSignalLiveActivities @main struct App: SwiftUI.App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject private var viewModel = OneSignalViewModel() @StateObject private var toastPresenter = ToastPresenter() - // REPRO SDK-4757: pure SwiftUI lifecycle (no AppDelegate), initialize in App.init() - // like the reporter's app. Revert to the @UIApplicationDelegateAdaptor version after. - init() { - let sharedApp = UIApplication - .perform(NSSelectorFromString("sharedApplication"))? - .takeUnretainedValue() as? UIApplication - if let app = sharedApp { - print("[SDK-4757 REPRO] App.init — sharedApplication exists, isProtectedDataAvailable=\(app.isProtectedDataAvailable)") - } else { - print("[SDK-4757 REPRO] App.init — UIApplication.sharedApplication is NIL (UIApplicationMain not called yet)") - } - OneSignalService.shared.initialize(launchOptions: nil) - } - var body: some Scene { WindowGroup { ContentView() @@ -57,6 +44,48 @@ struct App: SwiftUI.App { } } +// MARK: - App Delegate + +class AppDelegate: NSObject, UIApplicationDelegate { + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + // Initialize OneSignal + OneSignalService.shared.initialize(launchOptions: launchOptions) + + // Set up notification lifecycle listeners + setupNotificationListeners() + + // Set up in-app message listeners + setupInAppMessageListeners() + + // Set up Live Activities (iOS 16.1+) + if #available(iOS 16.1, *) { + LiveActivityController.setup() + } + + return true + } + + private func setupNotificationListeners() { + // Foreground notification display + OneSignal.Notifications.addForegroundLifecycleListener(NotificationLifecycleHandler.shared) + + // Notification click handling + OneSignal.Notifications.addClickListener(NotificationClickHandler.shared) + } + + private func setupInAppMessageListeners() { + // In-app message lifecycle + OneSignal.InAppMessages.addLifecycleListener(InAppMessageLifecycleHandler.shared) + + // In-app message click handling + OneSignal.InAppMessages.addClickListener(InAppMessageClickHandler.shared) + } +} + // MARK: - Notification Handlers class NotificationLifecycleHandler: NSObject, OSNotificationLifecycleListener { From 697a2f5a7755da46406a5ef608a84f95ae720559 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 15 Jun 2026 09:26:31 -0700 Subject: [PATCH 5/5] nits: clean up comments --- iOS_SDK/OneSignalSDK/Source/OneSignal.m | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 2cccf049d..938c4914c 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -471,12 +471,11 @@ static BOOL ComputeInitialStorageReadable(void) { return sharedApp.isProtectedDataAvailable; } // sharedApplication is nil: we are running before UIApplicationMain, e.g. a SwiftUI - // App.init() or an ObjC +load. (Messaging nil here would silently return NO and leave - // the SDK permanently gated, since the recovery notification below may never fire.) + // 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 + // 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"]]; @@ -505,19 +504,19 @@ static BOOL ComputeInitialStorageReadable(void) { /// /// `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 +/// * `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 +/// 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, ^{ - // Marks storage readable, then re-drives the SDK components that init skipped — - // at most once. If `gObserverShouldRecover == YES`, atomically swap to NO and + // 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); @@ -541,11 +540,9 @@ + (void)setupProtectedDataObserverOnce { recoverIfDeferred(); }]; - // Lifecycle signals: if init deferred while storage was actually readable (seed - // case 5, or a conservative misjudgment), the unlock notification never posts. - // Once UIApplicationMain has run, `sharedApplication` exists and we can consult - // the real `isProtectedDataAvailable`. Skipped when it reports NO (e.g. a - // background launch before first unlock) — the unlock notification covers those. + // 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, UIApplicationDidBecomeActiveNotification]) { [NSNotificationCenter.defaultCenter addObserverForName:name