From b4e6dda1790f759efa6f09fdb80ea3975d1f5906 Mon Sep 17 00:00:00 2001 From: Nan Date: Sun, 13 Sep 2026 21:53:09 -0700 Subject: [PATCH 01/10] fix: [SDK-5137] write the cached requirement outside the config lock `OSUserJwtConfig.hydrate` wrote the requirement to UserDefaults while holding its lock, and that write flushes to disk. Every reader of `requirement`, on the repo queue, on main for in-app messages, and on the executor queues, waited on that flush. Update the value under the lock and write after it is released, as the log and the handler already were. Two hydrates racing with different values could now leave disk holding the older value while memory holds the newer. `hydrate` has one guarded call site per session, and the next session's params fetch heals a stale cache. --- .../OneSignalOSCore/Source/Jwt/OSUserJwtConfig.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSUserJwtConfig.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSUserJwtConfig.swift index c4ad37f66..379411d37 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSUserJwtConfig.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSUserJwtConfig.swift @@ -81,15 +81,19 @@ public final class OSUserJwtConfig: NSObject { @objc public func hydrate(requiresUserAuth: Bool) { let hydrated: OSRequiresUserAuth = requiresUserAuth ? .on : .off - // Keep the log and the handler out of the lock; either can re-enter and read the requirement. + // Only the value changes under the lock. The write flushes to disk, and every reader of + // `requirement` (the repo queue, IAM on main, the executors) would wait on it; the log and the + // handler can re-enter and read the requirement. let (previous, handler) = lock.withLock { () -> (OSRequiresUserAuth, ((OSRequiresUserAuth) -> Void)?) in let previous = _requirement _requirement = hydrated - // Written even when the value is unchanged, so a launch whose write was dropped by locked - // storage still ends up with the requirement on disk. - OneSignalUserDefaults.initShared().saveInteger(forKey: OSUD_USE_IDENTITY_VERIFICATION, withValue: hydrated.rawValue) return (previous, onHydrated) } + // Written even when the value is unchanged, so a launch whose write was dropped by locked + // storage still ends up with the requirement on disk. Two hydrates racing with different values + // could leave disk holding the older one while memory holds the newer; `hydrate` has one guarded + // call site per session, and the next session's params fetch heals a stale cache. + OneSignalUserDefaults.initShared().saveInteger(forKey: OSUD_USE_IDENTITY_VERIFICATION, withValue: hydrated.rawValue) if previous != hydrated { OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSUserJwtConfig requirement changed from \(previous) to \(hydrated)") } From 8779579cc1fa13b124a8aa9a08a46c1da0f691f1 Mon Sep 17 00:00:00 2001 From: Nan Date: Sun, 13 Sep 2026 21:53:09 -0700 Subject: [PATCH 02/10] fix: [SDK-5137] report a requirement that refreshIfUnknown adopts from the cache `OSUserJwtConfig.refreshIfUnknown` moved the requirement from unknown to the cached value without firing the hydrated handler, while `hydrate` fires it for the same transition. Not a live bug today: its only caller runs in `OneSignalUserManagerImpl.start()` before any handler is registered, and a late registrant is called immediately when the requirement is already known. It was a trap for any future caller, and the User executor's doc comment cited it as the reason for re-reading the requirement on every send, which now stands on its own. Two tests in OSUserJwtConfigTests: the handler fires with the adopted value, and stays quiet while the cache is still empty. The first fails without the fix. --- .../Source/Jwt/OSUserJwtConfig.swift | 20 ++++++++++++---- .../Jwt/OSUserJwtConfigTests.swift | 23 +++++++++++++++++++ .../Source/Executors/OSUserExecutor.swift | 5 ++-- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSUserJwtConfig.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSUserJwtConfig.swift index 379411d37..c50b75a88 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSUserJwtConfig.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSUserJwtConfig.swift @@ -104,15 +104,27 @@ public final class OSUserJwtConfig: NSObject { /** Re-reads the cached requirement while it is still unknown. The read in `init` can land during an - app prewarm, before first unlock, when UserDefaults silently returns nothing. + app prewarm, before first unlock, when UserDefaults silently returns nothing. Fires the hydrated + handler when the value moves off `unknown`, the same transition `hydrate` reports, so a caller + registered before this runs is told the same way. */ public func refreshIfUnknown() { - lock.withLock { + let (refreshed, handler) = lock.withLock { () -> (OSRequiresUserAuth?, ((OSRequiresUserAuth) -> Void)?) in guard _requirement == .unknown else { - return + return (nil, nil) } - _requirement = OSUserJwtConfig.cachedRequirement() + let cached = OSUserJwtConfig.cachedRequirement() + guard cached != .unknown else { + return (nil, nil) + } + _requirement = cached + return (cached, onHydrated) + } + guard let refreshed else { + return } + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSUserJwtConfig requirement refreshed from the cache to \(refreshed)") + handler?(refreshed) } /// The Identity Verification service is the sole observer, so a second registration replaces the first. diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSUserJwtConfigTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSUserJwtConfigTests.swift index 1d536c65a..ba1107850 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSUserJwtConfigTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSUserJwtConfigTests.swift @@ -72,6 +72,29 @@ final class OSUserJwtConfigTests: XCTestCase { XCTAssertEqual(jwtConfig.requirement, .on) } + /// The same transition `hydrate` reports, so a handler registered before the refresh hears it too. + func testRefreshFiresTheHydratedHandlerWhenItAdoptsACachedRequirement() { + let jwtConfig = OSUserJwtConfig() + var reported: [OSRequiresUserAuth] = [] + jwtConfig.setOnHydratedHandler { reported.append($0) } + cacheRequirement(.on) + + jwtConfig.refreshIfUnknown() + + XCTAssertEqual(reported, [.on]) + } + + func testRefreshFiresNothingWhileTheCacheIsStillEmpty() { + let jwtConfig = OSUserJwtConfig() + var reported: [OSRequiresUserAuth] = [] + jwtConfig.setOnHydratedHandler { reported.append($0) } + + jwtConfig.refreshIfUnknown() + + XCTAssertEqual(jwtConfig.requirement, .unknown) + XCTAssertTrue(reported.isEmpty) + } + func testRefreshLeavesAKnownRequirementAlone() { let jwtConfig = OSUserJwtConfig() jwtConfig.hydrate(requiresUserAuth: false) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift index 66c6450bb..720d53aae 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift @@ -65,8 +65,9 @@ class OSUserExecutor { `login` that promoted an anonymous user while the requirement was still unknown — becomes the Create User that login would have made, or is dropped if a later `login` has superseded it. - Runs on every send because `refreshIfUnknown` can raise `requirement` with no event; reads the live - model because this executor sends nothing while `requirement` is unknown. + Runs on every send rather than only when the requirement hydrates, and reads the live model: this + executor sends nothing while `requirement` is unknown, so the queue is always judged against a known + value, and the check is cheap. */ private func reshapeInvalidRequests() { guard identityVerificationService.ivBehaviorActive else { From 8ef852fe38fa82846dd8ea94542f6a4426701a7b Mon Sep 17 00:00:00 2001 From: Nan Date: Sun, 13 Sep 2026 21:53:09 -0700 Subject: [PATCH 03/10] fix: [SDK-5137] build the JWT listener registry eagerly and warn on an ask nobody hears `OneSignalUserManagerImpl.userJwtInvalidatedObserver` was a lazy optional built with no lock. The ask path reaches it from the executor queues while `addUserJwtInvalidatedListener` runs on the app's thread, so two first touches could each build an observer and the app's listener could land in the one that was discarded. Build it in `init`, and let the JWT repo's notify closure capture it directly instead of reaching through `sharedInstance`. The observable holds listeners weakly, and the ask is the only way the SDK gets a token, so an ask nobody hears now logs at WARN naming the external ID. A cold-start ask that beats the app's registration is the normal path and is replayed when the listener is added, which the message says. The public listener methods, in the JWT extension, OneSignalFramework.h, and OneSignalSwiftInterface.swift, now say the listener is held weakly. Tests in the new UserJwtAskTests, since UserJwtLifecycleTests is at SwiftLint's type body limit: an unheard ask warns with the external ID, and a heard one is delivered without a warning. The old closure discarded the result, so there is no seam to run these against. --- .../OneSignal.xcodeproj/project.pbxproj | 4 + .../Source/OneSignalUserManagerImpl+Jwt.swift | 3 +- .../Source/OneSignalUserManagerImpl.swift | 40 ++++--- .../OneSignalUserTests/UserJwtAskTests.swift | 102 ++++++++++++++++++ .../OneSignalSDK/Source/OneSignalFramework.h | 1 + .../Source/OneSignalSwiftInterface.swift | 1 + 6 files changed, 136 insertions(+), 15 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtAskTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 9c98cfc51..67a75e20f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -361,6 +361,7 @@ 7AFE856C2368DDB80091D6A5 /* OSFocusCallParams.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFE856A2368DDB80091D6A5 /* OSFocusCallParams.m */; }; 7AFE856D2368DDB80091D6A5 /* OSFocusCallParams.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFE856A2368DDB80091D6A5 /* OSFocusCallParams.m */; }; 7EB69F3B404D0AEF46EC1536 /* UserJwtLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BFE2F960129386AFA6D5F41 /* UserJwtLifecycleTests.swift */; }; + 9345FA2E8878EA1B0216A49A /* UserJwtAskTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3567F486AA4335CCA79D659B /* UserJwtAskTests.swift */; }; 8D2F4893453206700BB60F85 /* OSOperationRepoTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5221EEDBA5A74BD565490D52 /* OSOperationRepoTestSupport.swift */; }; 8E949FF4C7A7A2C7182E53EA /* OSUserJwtConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9376A4957E9090C748BCB18 /* OSUserJwtConfigTests.swift */; }; 911E2CBD1E398AB3003112A4 /* UnitTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 911E2CBC1E398AB3003112A4 /* UnitTests.m */; }; @@ -1595,6 +1596,7 @@ 5BC1DE632C90BB9000CA8807 /* OSIamFetchReadyCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSIamFetchReadyCondition.swift; sourceTree = ""; }; 5BC1DE672C90C23E00CA8807 /* OSConsistencyManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSConsistencyManagerTests.swift; sourceTree = ""; }; 5BFE2F960129386AFA6D5F41 /* UserJwtLifecycleTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserJwtLifecycleTests.swift; sourceTree = ""; }; + 3567F486AA4335CCA79D659B /* UserJwtAskTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserJwtAskTests.swift; sourceTree = ""; }; 6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtConfig.swift; sourceTree = ""; }; 67ECA2928D863073B785F93F /* IamFetchIdentityVerificationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IamFetchIdentityVerificationTests.swift; sourceTree = ""; }; 6A8BBA843AFC81A4940CF7CC /* OSUserJwtRepo.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtRepo.swift; sourceTree = ""; }; @@ -2533,6 +2535,7 @@ 047D8F5E1095A20C9C54FD33 /* OSRequestAuthTests.swift */, 3016921C1F6B7B7793F67567 /* RequestPathEncodingTests.swift */, 5BFE2F960129386AFA6D5F41 /* UserJwtLifecycleTests.swift */, + 3567F486AA4335CCA79D659B /* UserJwtAskTests.swift */, ); path = OneSignalUserTests; sourceTree = ""; @@ -4722,6 +4725,7 @@ AAFA2D46E6C5FD3D14D39F27 /* OSRequestAuthTests.swift in Sources */, 23D66BEB40CE76DFF89744A3 /* RequestPathEncodingTests.swift in Sources */, 7EB69F3B404D0AEF46EC1536 /* UserJwtLifecycleTests.swift in Sources */, + 9345FA2E8878EA1B0216A49A /* UserJwtAskTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift index 5a424932f..152f2c258 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift @@ -68,7 +68,8 @@ extension OneSignalUserManagerImpl { /** Replays any ask that already fired this session, so a listener registered after `start` or `login` - still hears who currently owes a token. + still hears who currently owes a token. The listener is held weakly, so the app has to keep its own + reference to it. */ @objc public func addUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index dc1a55db5..aa734d449 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -180,17 +180,10 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { return userStateChangesObserver } - // JWT Invalidated Observer - private var _userJwtInvalidatedObserver: OSObservable? - var userJwtInvalidatedObserver: OSObservable { - if let observer = _userJwtInvalidatedObserver { - return observer - } - let userJwtInvalidatedObserver = OSObservable(change: #selector(OSUserJwtInvalidatedListener.onUserJwtInvalidated(event:))) - _userJwtInvalidatedObserver = userJwtInvalidatedObserver - - return userJwtInvalidatedObserver - } + // JWT Invalidated Observer. Built in `init` rather than lazily: the ask path reaches it from the + // executor queues while the app registers from its own thread, and a lazy getter with no lock could + // build two and discard the one holding the app's listener. + let userJwtInvalidatedObserver: OSObservable // Model Stores let identityModelStore = OSModelStore(changeSubscription: OSEventProducer(), storeKey: OS_IDENTITY_MODEL_STORE_KEY).registerAsUserObserver() @@ -219,12 +212,15 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { private override init() { let identityVerificationService = OSIdentityVerificationService(featureManager: featureManager, jwtConfig: jwtConfig) let operationRepo = OSOperationRepo(identityVerificationService: identityVerificationService) - // Goes through `sharedInstance` rather than capturing self: the observer it notifies is created - // lazily and must not be touched during init. + let userJwtInvalidatedObserver = OSObservable( + change: #selector(OSUserJwtInvalidatedListener.onUserJwtInvalidated(event:)) + ) + // Captures the observer, not self, so the closure is safe to build before init completes. let userJwtRepo = OSUserJwtRepo(identityModelRepo: identityModelRepo) { externalId in - OneSignalUserManagerImpl.sharedInstance.userJwtInvalidatedObserver.notifyChange(OSUserJwtInvalidatedEvent(externalId: externalId)) + OneSignalUserManagerImpl.notifyJwtInvalidated(userJwtInvalidatedObserver, externalId: externalId) } self.identityVerificationService = identityVerificationService + self.userJwtInvalidatedObserver = userJwtInvalidatedObserver self.userJwtRepo = userJwtRepo self.requestAuth = OSRequestAuth(identityVerificationService: identityVerificationService, jwt: userJwtRepo) self.operationRepo = operationRepo @@ -235,6 +231,22 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { self.pushSubscriptionImpl = OSPushSubscriptionImpl(pushSubscriptionModelStore: pushSubscriptionModelStore) } + /** + Tells the app's listeners who owes a token. A cold-start ask that beats the app's registration is + the normal path, and the replay in `addUserJwtInvalidatedListener` covers it, so an ask nobody hears + only warns. Without that line an app that never registers, or registered a listener it does not + retain, would see every user-scoped call park in silence. + */ + static func notifyJwtInvalidated( + _ observer: OSObservable, + externalId: String + ) { + if !observer.notifyChange(OSUserJwtInvalidatedEvent(externalId: externalId)) { + let unheard = "asked for a JWT for externalId \(externalId) but no OSUserJwtInvalidatedListener is registered" + OneSignalLog.onesignalLog(.LL_WARN, message: "\(unheard); the ask is replayed when one is added") + } + } + @objc public func start() { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtAskTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtAskTests.swift new file mode 100644 index 000000000..7c1b1d45d --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtAskTests.swift @@ -0,0 +1,102 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import XCTest +import OneSignalCore +import OneSignalCoreMocks +import OneSignalOSCoreMocks +import OneSignalUserMocks +@testable import OneSignalOSCore +@testable import OneSignalUser + +private final class WarningListener: NSObject, OSLogListener { + var warnings: [String] = [] + + func onLogEvent(_ event: OneSignalLogEvent) { + if event.level == .LL_WARN { + warnings.append(event.entry) + } + } +} + +/** + How the app is asked for a token: once per user per login, and audibly even when nobody is listening. + */ +final class UserJwtAskTests: XCTestCase { + private var client = MockOneSignalClient() + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + + client = MockOneSignalClient() + MockUserRequests.setDefaultCreateUserResponses(with: client, externalId: userA_EUID) + OneSignalCoreImpl.setSharedClient(client) + } + + override func tearDownWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + } + + private func makeObserver() -> OSObservable { + return OSObservable( + change: #selector(OSUserJwtInvalidatedListener.onUserJwtInvalidated(event:)) + ) + } + + // MARK: - an ask nobody hears + + /// The ask is the only way the SDK gets a token, and listeners are held weakly, so an ask nobody + /// hears has to leave a trace above DEBUG. + func testAnAskNobodyHearsWarns() { + let warnings = WarningListener() + OneSignalLog.debug().__add(warnings) + defer { OneSignalLog.debug().__remove(warnings) } + + OneSignalUserManagerImpl.notifyJwtInvalidated(makeObserver(), externalId: userA_EUID) + + XCTAssertEqual(warnings.warnings.count, 1, "\(warnings.warnings)") + XCTAssertTrue(warnings.warnings.first?.contains(userA_EUID) == true) + XCTAssertTrue(warnings.warnings.first?.contains("OSUserJwtInvalidatedListener") == true) + } + + /// A heard ask is the normal path and stays quiet. + func testAnAskWithAListenerIsDeliveredWithoutAWarning() { + let warnings = WarningListener() + OneSignalLog.debug().__add(warnings) + defer { OneSignalLog.debug().__remove(warnings) } + let listener = MockUserJwtInvalidatedListener() + let observer = makeObserver() + observer.addObserver(listener) + + OneSignalUserManagerImpl.notifyJwtInvalidated(observer, externalId: userA_EUID) + + OneSignalCoreMocks.waitUntil("The listener was not told") { listener.invalidatedExternalIds == [userA_EUID] } + XCTAssertTrue(warnings.warnings.isEmpty, "\(warnings.warnings)") + } +} diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignalFramework.h b/iOS_SDK/OneSignalSDK/Source/OneSignalFramework.h index 3b6972122..a63a14e24 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignalFramework.h +++ b/iOS_SDK/OneSignalSDK/Source/OneSignalFramework.h @@ -72,6 +72,7 @@ typedef void (^OSFailureBlock)(NSError* error); + (void)login:(NSString * _Nonnull)externalId; + (void)login:(NSString * _Nonnull)externalId withToken:(NSString * _Nullable)token NS_SWIFT_NAME(login(externalId:token:)); +/// The listener is held weakly: keep a strong reference to it, or it is released and never called. + (void)addUserJwtInvalidatedListener:(id _Nonnull)listener NS_REFINED_FOR_SWIFT; + (void)removeUserJwtInvalidatedListener:(id _Nonnull)listener NS_REFINED_FOR_SWIFT; + (void)updateUserJwt:(NSString * _Nonnull)externalId withToken:(NSString * _Nonnull)token diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignalSwiftInterface.swift b/iOS_SDK/OneSignalSDK/Source/OneSignalSwiftInterface.swift index 756ec78ac..e302481b3 100644 --- a/iOS_SDK/OneSignalSDK/Source/OneSignalSwiftInterface.swift +++ b/iOS_SDK/OneSignalSDK/Source/OneSignalSwiftInterface.swift @@ -33,6 +33,7 @@ import OneSignalNotifications import OneSignalCore public extension OneSignal { + /// The listener is held weakly: keep a strong reference to it, or it is released and never called. static func addUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) { __add(listener) } From b60adfe740a9d27cfb7532cccbd3362b27ac6c78 Mon Sep 17 00:00:00 2001 From: Nan Date: Sun, 13 Sep 2026 21:53:09 -0700 Subject: [PATCH 04/10] fix: [SDK-5137] ask for a token again when the same user logs in after a logout `OSUserJwtRepo` asks the app once per external ID per session, and only a stored token cleared that. `logout` leaves the entry in place, so a user who was asked, never answered, logged out, and logged back in without a token was never asked again: `park` got false from `askForToken` and logged nothing, and every user-scoped call for that user stayed held for the rest of the process. The same held for login(A), login(B), login(A). Clear the ask wherever `login` builds a new Identity Model for the user, in `createNewUser` and `identifyUser`, so each login is a fresh chance to be asked. Clearing the outgoing user on logout instead would re-ask for A right after a switch to B whenever A's held Create User is still queued, which the app did not invite. Tests: `clearAsk` in OSUserJwtRepoTests, and in UserJwtAskTests a login with no token, a logout, and the same login again, asserting two asks. The second fails without the fix. --- .../OneSignalUser/Source/OSUserJwtRepo.swift | 9 ++++++++ .../Source/OneSignalUserManagerImpl.swift | 10 +++++++-- .../OSUserJwtRepoTests.swift | 12 ++++++++++ .../OneSignalUserTests/UserJwtAskTests.swift | 22 +++++++++++++++++++ 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtRepo.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtRepo.swift index 8adf8f164..857373ffb 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtRepo.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtRepo.swift @@ -109,6 +109,15 @@ final class OSUserJwtRepo: OSUserJwtProviding { return lock.withLock { Array(askedForToken) } } + /** + Forgets an ask so a later park can ask again. A `login` that builds a new Identity Model for this + external ID is a fresh chance to hear about the token it owes; without this, an ask left unanswered + before a logout silenced every later token-less login as the same user for the rest of the session. + */ + func clearAsk(externalId: String) { + lock.withLock { _ = askedForToken.remove(externalId) } + } + @discardableResult func invalidateJwt(externalId: String, rejectedToken: String) -> Bool { // No model for this user means the token could not have come from here. A Request stamped diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index aa734d449..84274b6ee 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -459,8 +459,12 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { } let newUser = setNewInternalUser(externalId: externalId, pushSubscriptionModel: pushSubscriptionModel) - if let externalId = externalId, let token = token { - storeJwt(externalId: externalId, token: token) + if let externalId = externalId { + // Each login is a fresh chance to be asked for this user's token. + userJwtRepo.clearAsk(externalId: externalId) + if let token = token { + storeJwt(externalId: externalId, token: token) + } } userExecutor!.createUser(newUser) return newUser @@ -485,6 +489,8 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { let pushSubscriptionModel = pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY) prepareForNewUser() let newUser = setNewInternalUser(externalId: externalId, pushSubscriptionModel: pushSubscriptionModel) + // Each login is a fresh chance to be asked for this user's token. + userJwtRepo.clearAsk(externalId: externalId) // The token belongs on the model that carries `external_id`: the Fetch User this leads to is signed // with it, as is the Create User this becomes if the requirement turns out to be on. if let token = token { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSUserJwtRepoTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSUserJwtRepoTests.swift index dc492a9f4..7ab82f7c4 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSUserJwtRepoTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSUserJwtRepoTests.swift @@ -84,6 +84,18 @@ final class OSUserJwtRepoTests: XCTestCase { XCTAssertEqual(asked, ["user-a"]) } + /// A login that builds a new Identity Model for the user is a fresh chance to be asked. + func testClearingAnAskLetsTheUserBeAskedAgain() { + XCTAssertTrue(repo.askForToken(externalId: "user-a")) + XCTAssertFalse(repo.askForToken(externalId: "user-a")) + + repo.clearAsk(externalId: "user-a") + + XCTAssertTrue(repo.askForToken(externalId: "user-a")) + XCTAssertEqual(asked, ["user-a", "user-a"]) + XCTAssertEqual(repo.pendingTokenAsks(), ["user-a"]) + } + // MARK: - invalidateJwt func testInvalidatingParksTheTokenAndAsksTheApp() { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtAskTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtAskTests.swift index 7c1b1d45d..7cc519ccc 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtAskTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtAskTests.swift @@ -99,4 +99,26 @@ final class UserJwtAskTests: XCTestCase { OneSignalCoreMocks.waitUntil("The listener was not told") { listener.invalidatedExternalIds == [userA_EUID] } XCTAssertTrue(warnings.warnings.isEmpty, "\(warnings.warnings)") } + + // MARK: - asking again after a logout + + /// An ask left unanswered before a logout must not silence the next login as the same user: every + /// login that builds a new Identity Model is asked afresh. + func testLoggingInAgainAfterALogoutAsksAgain() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + // Held strongly for the test's lifetime: the observer keeps listeners weakly. + let listener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(listener) + defer { OneSignalUserManagerImpl.sharedInstance.removeUserJwtInvalidatedListener(listener) } + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalCoreMocks.waitUntil("The first login did not ask") { listener.invalidatedExternalIds == [userA_EUID] } + + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + + OneSignalCoreMocks.waitUntil("The second login did not ask again") { + listener.invalidatedExternalIds == [userA_EUID, userA_EUID] + } + } } From 152b89d18eb34905604f5bfbf84990daa5c20912 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 17 Sep 2026 09:33:11 -0700 Subject: [PATCH 05/10] style: [SDK-5137] say a login rearms the token ask in the comments that still said once per session `clearAsk` made three comments stale: the `askForToken` and `pendingTokenAsks` docs in OSUserJwtRepo, and the `park` doc in OSRequestAuth. Each now says a stored token or a login as that user clears the ask. --- .../OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift | 5 +++-- .../OneSignalSDK/OneSignalUser/Source/OSUserJwtRepo.swift | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift index 5678d9747..7aba9299c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift @@ -176,8 +176,9 @@ final class OSRequestAuth: OSRequestAuthorizing { /** Nothing else prompts the app when a Request merely parks: the invalidated event fires on a rejected - token, and a token the app never supplied — or supplied in a session that has since ended — leaves the - SDK holding none with nothing to reject. The repo keeps this to one ask per external ID per session. + token, and a token the app never supplied, or supplied in a session that has since ended, leaves the + SDK holding none with nothing to reject. The repo keeps this to one ask per external ID until a token + is stored or a `login` as that user rearms it. */ private func park(_ request: OSUserRequest, ownedBy externalId: String) { // Log only on the ask that reaches the app; later prepareForExecution retries stay quiet. diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtRepo.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtRepo.swift index 857373ffb..db22f24ac 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtRepo.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtRepo.swift @@ -39,8 +39,9 @@ protocol OSUserJwtProviding: AnyObject { /** Asks the app for a token for `externalId`. - Returns `true` if this call is the one that asked, which happens at most once per external ID - per session so a burst of concurrent callers does not fire the event repeatedly. + Returns `true` if this call is the one that asked. An external ID is asked once, until a usable token + is stored for it or a `login` builds it a new Identity Model, so a burst of concurrent callers does + not fire the event repeatedly. */ @discardableResult func askForToken(externalId: String) -> Bool @@ -104,7 +105,7 @@ final class OSUserJwtRepo: OSUserJwtProviding { return true } - /// External IDs already asked this session; cleared when a usable token is stored. + /// External IDs asked and not yet answered. A stored token or a `login` as that user clears the entry. func pendingTokenAsks() -> [String] { return lock.withLock { Array(askedForToken) } } From faec51860691b90a1f38815db3ee91d39d1a1e05 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 17 Sep 2026 09:33:11 -0700 Subject: [PATCH 06/10] test: [SDK-5137] wait for the mock client to go idle in the UserJwtAskTests teardown Same gate as UserJwtLifecycleTests: the mock answers late, and a Request still in flight at teardown would land in the next test and hydrate the shared models and JWT repo out from under it. --- .../OneSignalSDK/OneSignalUserTests/UserJwtAskTests.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtAskTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtAskTests.swift index 7cc519ccc..2c3aecd88 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtAskTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtAskTests.swift @@ -60,9 +60,16 @@ final class UserJwtAskTests: XCTestCase { } override func tearDownWithError() throws { + // The mock answers 50ms late, so a Request still in flight would otherwise land mid-next-test + // and hydrate the shared models and JWT repo out from under it. + OneSignalCoreMocks.waitUntil("A Request was still in flight at teardown") { self.clientIsIdle } OneSignalCoreMocks.clearUserDefaults() } + private var clientIsIdle: Bool { + return client.completedRequests.count == client.startedRequests.count + } + private func makeObserver() -> OSObservable { return OSObservable( change: #selector(OSUserJwtInvalidatedListener.onUserJwtInvalidated(event:)) From 6678c25c8c656116dbcb2d1716d40b8a506f376c Mon Sep 17 00:00:00 2001 From: Nan Date: Fri, 18 Sep 2026 08:48:10 -0700 Subject: [PATCH 07/10] chore: [SDK-5137] rearm the token ask only when a login brings no usable token `storeJwt` clears the ask itself when it stores, so the explicit clear before it was redundant on that path and left a window where a flush between the two lines asked for the token the caller had just supplied. The clear now runs only when the login has no token or the repo refused it, so a login with an empty token still asks again. --- .../Source/OneSignalUserManagerImpl+Jwt.swift | 19 +++++++++++++++---- .../Source/OneSignalUserManagerImpl.swift | 12 ++---------- .../OneSignalUserTests/UserJwtAskTests.swift | 18 ++++++++++++++++++ 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift index 152f2c258..b063161f4 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift @@ -51,19 +51,30 @@ extension OneSignalUserManagerImpl { notification — the work that travels through neither. Every app-supplied token arrives here, from `login` as well as `updateUserJwt`, so that the pending - ask for this user is cleared and a later rejection can ask again. + ask for this user is cleared and a later rejection can ask again. Returns whether the token was + stored; the repo refuses an unusable one. */ - func storeJwt(externalId: String, token: String) { + @discardableResult + func storeJwt(externalId: String, token: String) -> Bool { guard userJwtRepo.updateJwt(externalId: externalId, token: token) else { - return + return false } OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignalUserManager stored a JWT for externalId: \(externalId)") guard identityVerificationService.newCodePathsRun else { - return + return true } operationRepo.addFlushDeltaQueueToDispatchQueue() userExecutor?.executePendingRequests() NotificationCenter.default.post(name: Notification.Name(OS_ON_USER_JWT_UPDATED), object: nil) + return true + } + + /// `storeJwt` clears the ask itself when it stores; a login with no usable token rearms it instead. + func storeJwtOrRearmAsk(externalId: String, token: String?) { + if let token = token, storeJwt(externalId: externalId, token: token) { + return + } + userJwtRepo.clearAsk(externalId: externalId) } /** diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 84274b6ee..9d3b18e55 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -460,11 +460,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { let newUser = setNewInternalUser(externalId: externalId, pushSubscriptionModel: pushSubscriptionModel) if let externalId = externalId { - // Each login is a fresh chance to be asked for this user's token. - userJwtRepo.clearAsk(externalId: externalId) - if let token = token { - storeJwt(externalId: externalId, token: token) - } + storeJwtOrRearmAsk(externalId: externalId, token: token) } userExecutor!.createUser(newUser) return newUser @@ -489,13 +485,9 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { let pushSubscriptionModel = pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY) prepareForNewUser() let newUser = setNewInternalUser(externalId: externalId, pushSubscriptionModel: pushSubscriptionModel) - // Each login is a fresh chance to be asked for this user's token. - userJwtRepo.clearAsk(externalId: externalId) // The token belongs on the model that carries `external_id`: the Fetch User this leads to is signed // with it, as is the Create User this becomes if the requirement turns out to be on. - if let token = token { - storeJwt(externalId: externalId, token: token) - } + storeJwtOrRearmAsk(externalId: externalId, token: token) // Now proceed to identify the previous user userExecutor!.identifyUser( diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtAskTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtAskTests.swift index 2c3aecd88..b79b61e75 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtAskTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtAskTests.swift @@ -128,4 +128,22 @@ final class UserJwtAskTests: XCTestCase { listener.invalidatedExternalIds == [userA_EUID, userA_EUID] } } + + /// A token the repo refuses answers nothing, so that login asks like one with no token. + func testLoggingInAgainWithAnUnusableTokenAsksAgain() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + let listener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(listener) + defer { OneSignalUserManagerImpl.sharedInstance.removeUserJwtInvalidatedListener(listener) } + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalCoreMocks.waitUntil("The first login did not ask") { listener.invalidatedExternalIds == [userA_EUID] } + + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "") + + OneSignalCoreMocks.waitUntil("The login with an empty token did not ask again") { + listener.invalidatedExternalIds == [userA_EUID, userA_EUID] + } + } } From 99c693210c263c8633738e3989699db8b2912636 Mon Sep 17 00:00:00 2001 From: Nan Date: Fri, 18 Sep 2026 08:49:58 -0700 Subject: [PATCH 08/10] test: [SDK-5137] read only the warnings under test from the process-wide log Log listeners hear every line in the process, on whichever thread logged it, so the exact count and the empty check could both trip on a warning from another test class, such as the executor's blocked-request retry. The capture now filters by level and text and appends under a lock. --- .../OneSignalUserTests/UserJwtAskTests.swift | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtAskTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtAskTests.swift index b79b61e75..3b554a5e1 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtAskTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtAskTests.swift @@ -33,13 +33,18 @@ import OneSignalUserMocks @testable import OneSignalOSCore @testable import OneSignalUser -private final class WarningListener: NSObject, OSLogListener { - var warnings: [String] = [] +/// Listeners hear every line logged in the process, on the thread that logged it, so a test reads back +/// only the level and text it is about. +private final class LogCapture: NSObject, OSLogListener { + private let lock = NSLock() + private var entries: [(level: ONE_S_LOG_LEVEL, entry: String)] = [] func onLogEvent(_ event: OneSignalLogEvent) { - if event.level == .LL_WARN { - warnings.append(event.entry) - } + lock.withLock { entries.append((event.level, event.entry)) } + } + + func entries(at level: ONE_S_LOG_LEVEL, mentioning text: String) -> [String] { + return lock.withLock { entries.filter { $0.level == level && $0.entry.contains(text) }.map { $0.entry } } } } @@ -81,22 +86,22 @@ final class UserJwtAskTests: XCTestCase { /// The ask is the only way the SDK gets a token, and listeners are held weakly, so an ask nobody /// hears has to leave a trace above DEBUG. func testAnAskNobodyHearsWarns() { - let warnings = WarningListener() - OneSignalLog.debug().__add(warnings) - defer { OneSignalLog.debug().__remove(warnings) } + let log = LogCapture() + OneSignalLog.debug().__add(log) + defer { OneSignalLog.debug().__remove(log) } OneSignalUserManagerImpl.notifyJwtInvalidated(makeObserver(), externalId: userA_EUID) - XCTAssertEqual(warnings.warnings.count, 1, "\(warnings.warnings)") - XCTAssertTrue(warnings.warnings.first?.contains(userA_EUID) == true) - XCTAssertTrue(warnings.warnings.first?.contains("OSUserJwtInvalidatedListener") == true) + let warnings = log.entries(at: .LL_WARN, mentioning: userA_EUID) + XCTAssertEqual(warnings.count, 1, "\(warnings)") + XCTAssertTrue(warnings.first?.contains("OSUserJwtInvalidatedListener") == true) } /// A heard ask is the normal path and stays quiet. func testAnAskWithAListenerIsDeliveredWithoutAWarning() { - let warnings = WarningListener() - OneSignalLog.debug().__add(warnings) - defer { OneSignalLog.debug().__remove(warnings) } + let log = LogCapture() + OneSignalLog.debug().__add(log) + defer { OneSignalLog.debug().__remove(log) } let listener = MockUserJwtInvalidatedListener() let observer = makeObserver() observer.addObserver(listener) @@ -104,7 +109,8 @@ final class UserJwtAskTests: XCTestCase { OneSignalUserManagerImpl.notifyJwtInvalidated(observer, externalId: userA_EUID) OneSignalCoreMocks.waitUntil("The listener was not told") { listener.invalidatedExternalIds == [userA_EUID] } - XCTAssertTrue(warnings.warnings.isEmpty, "\(warnings.warnings)") + let warnings = log.entries(at: .LL_WARN, mentioning: userA_EUID) + XCTAssertTrue(warnings.isEmpty, "\(warnings)") } // MARK: - asking again after a logout From 322dea0953c86536c023e8052caed335e77dbfb3 Mon Sep 17 00:00:00 2001 From: Nan Date: Fri, 18 Sep 2026 09:02:09 -0700 Subject: [PATCH 09/10] chore: [SDK-5137] warn about an unheard token ask only after a listener was registered An ask that fires before the app registers its listener is the cold-start order, and the replay on registration delivers it, so it now logs at debug instead of printing a warning at the default console level on every such launch. The warning stays for an ask after a listener was registered, which means the app let its listener go. The listeners and the flag live together in OSUserJwtInvalidatedListeners, which replaces the static and the bare observer. Tests: an ask before any registration leaves no warning and one debug line; an ask after the registered listener was released warns. The first fails with the warning forced on. --- .../Source/OneSignalUserManagerImpl+Jwt.swift | 41 ++++++++++++++++++- .../Source/OneSignalUserManagerImpl.swift | 30 +++----------- .../OneSignalUserTests/UserJwtAskTests.swift | 40 +++++++++++------- 3 files changed, 71 insertions(+), 40 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift index b063161f4..d5e0b72ed 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift @@ -28,6 +28,43 @@ import OneSignalCore import OneSignalOSCore +/** + The app's JWT listeners, and whether one was ever added. An ask nobody hears is replayed when a listener + is added, so before the first registration it is the cold-start order and logs at debug; after one, the + app has let its listener go (they are held weakly) and only the warning tells it. + */ +final class OSUserJwtInvalidatedListeners { + private let observer = OSObservable( + change: #selector(OSUserJwtInvalidatedListener.onUserJwtInvalidated(event:)) + ) + private let lock = NSLock() + private var everRegistered = false + + func add(_ listener: OSUserJwtInvalidatedListener) { + lock.withLock { everRegistered = true } + observer.addObserver(listener) + } + + func remove(_ listener: OSUserJwtInvalidatedListener) { + observer.removeObserver(listener) + } + + func notify(externalId: String) { + // Read first: a listener added during delivery is told by the replay, not by this warning. + let hadListener = lock.withLock { everRegistered } + if observer.notifyChange(OSUserJwtInvalidatedEvent(externalId: externalId)) { + return + } + let unheard = "asked for a JWT for externalId \(externalId)" + let replayed = "the ask is replayed when a listener is added" + if hadListener { + OneSignalLog.onesignalLog(.LL_WARN, message: "\(unheard) but the OSUserJwtInvalidatedListener registered earlier is gone; \(replayed)") + } else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "\(unheard) before any OSUserJwtInvalidatedListener was registered; \(replayed)") + } + } +} + /** The Identity Verification surface the app talks to: it hands the SDK a token for a user, and the SDK tells it when that token stopped being accepted. @@ -84,7 +121,7 @@ extension OneSignalUserManagerImpl { */ @objc public func addUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) { - self.userJwtInvalidatedObserver.addObserver(listener) + self.userJwtInvalidatedListeners.add(listener) let pending = userJwtRepo.pendingTokenAsks() guard !pending.isEmpty else { return @@ -103,7 +140,7 @@ extension OneSignalUserManagerImpl { @objc public func removeUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) { - self.userJwtInvalidatedObserver.removeObserver(listener) + self.userJwtInvalidatedListeners.remove(listener) } @objc diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 9d3b18e55..d0b1a55a9 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -180,10 +180,10 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { return userStateChangesObserver } - // JWT Invalidated Observer. Built in `init` rather than lazily: the ask path reaches it from the + // The app's JWT listeners. Built in `init` rather than lazily: the ask path reaches them from the // executor queues while the app registers from its own thread, and a lazy getter with no lock could // build two and discard the one holding the app's listener. - let userJwtInvalidatedObserver: OSObservable + let userJwtInvalidatedListeners: OSUserJwtInvalidatedListeners // Model Stores let identityModelStore = OSModelStore(changeSubscription: OSEventProducer(), storeKey: OS_IDENTITY_MODEL_STORE_KEY).registerAsUserObserver() @@ -212,15 +212,13 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { private override init() { let identityVerificationService = OSIdentityVerificationService(featureManager: featureManager, jwtConfig: jwtConfig) let operationRepo = OSOperationRepo(identityVerificationService: identityVerificationService) - let userJwtInvalidatedObserver = OSObservable( - change: #selector(OSUserJwtInvalidatedListener.onUserJwtInvalidated(event:)) - ) - // Captures the observer, not self, so the closure is safe to build before init completes. + let userJwtInvalidatedListeners = OSUserJwtInvalidatedListeners() + // Captures the listeners, not self, so the closure is safe to build before init completes. let userJwtRepo = OSUserJwtRepo(identityModelRepo: identityModelRepo) { externalId in - OneSignalUserManagerImpl.notifyJwtInvalidated(userJwtInvalidatedObserver, externalId: externalId) + userJwtInvalidatedListeners.notify(externalId: externalId) } self.identityVerificationService = identityVerificationService - self.userJwtInvalidatedObserver = userJwtInvalidatedObserver + self.userJwtInvalidatedListeners = userJwtInvalidatedListeners self.userJwtRepo = userJwtRepo self.requestAuth = OSRequestAuth(identityVerificationService: identityVerificationService, jwt: userJwtRepo) self.operationRepo = operationRepo @@ -231,22 +229,6 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { self.pushSubscriptionImpl = OSPushSubscriptionImpl(pushSubscriptionModelStore: pushSubscriptionModelStore) } - /** - Tells the app's listeners who owes a token. A cold-start ask that beats the app's registration is - the normal path, and the replay in `addUserJwtInvalidatedListener` covers it, so an ask nobody hears - only warns. Without that line an app that never registers, or registered a listener it does not - retain, would see every user-scoped call park in silence. - */ - static func notifyJwtInvalidated( - _ observer: OSObservable, - externalId: String - ) { - if !observer.notifyChange(OSUserJwtInvalidatedEvent(externalId: externalId)) { - let unheard = "asked for a JWT for externalId \(externalId) but no OSUserJwtInvalidatedListener is registered" - OneSignalLog.onesignalLog(.LL_WARN, message: "\(unheard); the ask is replayed when one is added") - } - } - @objc public func start() { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtAskTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtAskTests.swift index 3b554a5e1..e0f73016a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtAskTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtAskTests.swift @@ -49,7 +49,7 @@ private final class LogCapture: NSObject, OSLogListener { } /** - How the app is asked for a token: once per user per login, and audibly even when nobody is listening. + How the app is asked for a token: once per user per login, and audibly when a listener it registered is gone. */ final class UserJwtAskTests: XCTestCase { private var client = MockOneSignalClient() @@ -75,22 +75,34 @@ final class UserJwtAskTests: XCTestCase { return client.completedRequests.count == client.startedRequests.count } - private func makeObserver() -> OSObservable { - return OSObservable( - change: #selector(OSUserJwtInvalidatedListener.onUserJwtInvalidated(event:)) - ) - } - // MARK: - an ask nobody hears - /// The ask is the only way the SDK gets a token, and listeners are held weakly, so an ask nobody - /// hears has to leave a trace above DEBUG. - func testAnAskNobodyHearsWarns() { + /// Before the app registers anything, an unheard ask is the cold-start order and the replay on + /// registration covers it, so it stays below the default console level. + func testAnAskBeforeAnyListenerIsRegisteredLogsAtDebug() { + let log = LogCapture() + OneSignalLog.debug().__add(log) + defer { OneSignalLog.debug().__remove(log) } + + OSUserJwtInvalidatedListeners().notify(externalId: userA_EUID) + + let warnings = log.entries(at: .LL_WARN, mentioning: userA_EUID) + XCTAssertTrue(warnings.isEmpty, "\(warnings)") + XCTAssertEqual(log.entries(at: .LL_DEBUG, mentioning: userA_EUID).count, 1) + } + + /// Listeners are held weakly, so one the app let go of leaves the ask unheard for good, and the ask + /// is the only way the SDK gets a token, so that has to leave a trace above DEBUG. + func testAnAskAfterTheListenerIsGoneWarns() { let log = LogCapture() OneSignalLog.debug().__add(log) defer { OneSignalLog.debug().__remove(log) } + let listeners = OSUserJwtInvalidatedListeners() + autoreleasepool { + listeners.add(MockUserJwtInvalidatedListener()) + } - OneSignalUserManagerImpl.notifyJwtInvalidated(makeObserver(), externalId: userA_EUID) + listeners.notify(externalId: userA_EUID) let warnings = log.entries(at: .LL_WARN, mentioning: userA_EUID) XCTAssertEqual(warnings.count, 1, "\(warnings)") @@ -103,10 +115,10 @@ final class UserJwtAskTests: XCTestCase { OneSignalLog.debug().__add(log) defer { OneSignalLog.debug().__remove(log) } let listener = MockUserJwtInvalidatedListener() - let observer = makeObserver() - observer.addObserver(listener) + let listeners = OSUserJwtInvalidatedListeners() + listeners.add(listener) - OneSignalUserManagerImpl.notifyJwtInvalidated(observer, externalId: userA_EUID) + listeners.notify(externalId: userA_EUID) OneSignalCoreMocks.waitUntil("The listener was not told") { listener.invalidatedExternalIds == [userA_EUID] } let warnings = log.entries(at: .LL_WARN, mentioning: userA_EUID) From 1f9e3362d16e766de53e1fd6e294243b28741a47 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 21 Sep 2026 22:40:08 -0700 Subject: [PATCH 10/10] chore: [SDK-5137] add the JWT listener before marking one as ever registered A notify that ran between the two statements in `add` saw the flag set and no observer, and warned that the listener registered earlier was gone. With the observer added first that notify is heard, and one that lands before both still logs the debug line for the cold-start order. No lock across the pair, since neither order loses the ask; a listener added during delivery gets the replay. No test, since the window is two statements wide and cannot be held open deterministically. --- .../OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift index d5e0b72ed..569a3febc 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift @@ -41,8 +41,9 @@ final class OSUserJwtInvalidatedListeners { private var everRegistered = false func add(_ listener: OSUserJwtInvalidatedListener) { - lock.withLock { everRegistered = true } + // Observer first, so an ask that lands between the two is heard rather than logged as a lost listener. observer.addObserver(listener) + lock.withLock { everRegistered = true } } func remove(_ listener: OSUserJwtInvalidatedListener) {