From 02b56a080b6666ea49c30af0dacbb8a6529291d7 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 10 Sep 2026 07:13:30 -0700 Subject: [PATCH 01/25] feat: [JWT-3] feature flags, JWT config, and the Identity Verification gate (#1707) Co-authored-by: Cursor --- .../OneSignal.xcodeproj/project.pbxproj | 34 +++ .../Source/OneSignalCommonDefines.h | 12 +- .../Jwt/OSIdentityVerificationService.swift | 116 ++++++++ .../Source/Jwt/OSUserJwtConfig.swift | 134 ++++++++++ .../OSIdentityVerificationServiceTests.swift | 249 ++++++++++++++++++ .../Jwt/OSUserJwtConfigTests.swift | 95 +++++++ iOS_SDK/OneSignalSDK/Source/OneSignal.m | 55 +++- 7 files changed, 691 insertions(+), 4 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSIdentityVerificationService.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSUserJwtConfig.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSIdentityVerificationServiceTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSUserJwtConfigTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index f7ad19547..db14cf227 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -348,6 +348,7 @@ 7AFE856B2368DDB80091D6A5 /* OSFocusCallParams.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFE856A2368DDB80091D6A5 /* OSFocusCallParams.m */; }; 7AFE856C2368DDB80091D6A5 /* OSFocusCallParams.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFE856A2368DDB80091D6A5 /* OSFocusCallParams.m */; }; 7AFE856D2368DDB80091D6A5 /* OSFocusCallParams.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFE856A2368DDB80091D6A5 /* OSFocusCallParams.m */; }; + 8E949FF4C7A7A2C7182E53EA /* OSUserJwtConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9376A4957E9090C748BCB18 /* OSUserJwtConfigTests.swift */; }; 911E2CBD1E398AB3003112A4 /* UnitTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 911E2CBC1E398AB3003112A4 /* UnitTests.m */; }; 911E2CC51E398B53003112A4 /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3E08E2701D49A5C8002176DE /* SystemConfiguration.framework */; }; 911E2CC61E398B97003112A4 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37E6B2BA19D9CAF300D0C601 /* UIKit.framework */; }; @@ -383,8 +384,10 @@ 9D34853A233D2E3600EB81C9 /* OneSignalLocationOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 9D348539233D2E3600EB81C9 /* OneSignalLocationOverrider.m */; }; 9D59C2F82321C7720008ECCF /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CACBAAAB218A662B000ACAA5 /* WebKit.framework */; }; 9D59C2F92321C7780008ECCF /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9D3300F923149DAE000F0A83 /* CoreGraphics.framework */; }; + 9FF50E2A40C88E4533033A38 /* OSIdentityVerificationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4855B81F170253FB0C1749D /* OSIdentityVerificationServiceTests.swift */; }; A662399326850DDE00D52FD8 /* LanguageTest.m in Sources */ = {isa = PBXBuildFile; fileRef = A662399026850DDE00D52FD8 /* LanguageTest.m */; }; A66239952686612F00D52FD8 /* OneSignalFramework.h in Headers */ = {isa = PBXBuildFile; fileRef = 912411F01E73342200E41FD7 /* OneSignalFramework.h */; settings = {ATTRIBUTES = (Public, ); }; }; + BE737361D82E74544B7A1996 /* OSUserJwtConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */; }; CA08FC871FE99BB4004C445F /* OneSignalClientOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = CA08FC831FE99BB4004C445F /* OneSignalClientOverrider.m */; }; CA1A6E6920DC2E31001C41B9 /* OneSignalDialogController.h in Headers */ = {isa = PBXBuildFile; fileRef = CA1A6E6720DC2E31001C41B9 /* OneSignalDialogController.h */; }; CA1A6E6A20DC2E31001C41B9 /* OneSignalDialogController.m in Sources */ = {isa = PBXBuildFile; fileRef = CA1A6E6820DC2E31001C41B9 /* OneSignalDialogController.m */; }; @@ -670,6 +673,7 @@ DEFB3E632BB731BD00E65DAD /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DEFB3E622BB731BD00E65DAD /* ActivityKit.framework */; platformFilter = ios; }; DEFB3E652BB7346D00E65DAD /* OSLiveActivities.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */; }; DEFB3E672BB735B500E65DAD /* OSStubLiveActivities.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */; }; + ECD9DF65FB98056806A43541 /* OSIdentityVerificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4EAEA1BC62D8FC57927511 /* OSIdentityVerificationService.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1349,6 +1353,7 @@ 16664C5425DDB2CB003B8A14 /* NSTimeZoneOverrider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NSTimeZoneOverrider.h; sourceTree = ""; }; 1AF75EAC1E8567FD0097B315 /* NSString+OneSignal.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSString+OneSignal.h"; sourceTree = ""; }; 1AF75EAD1E8567FD0097B315 /* NSString+OneSignal.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSString+OneSignal.m"; sourceTree = ""; }; + 1C4EAEA1BC62D8FC57927511 /* OSIdentityVerificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityVerificationService.swift; sourceTree = ""; }; 37747F9319147D6500558FAD /* libOneSignal.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libOneSignal.a; sourceTree = BUILT_PRODUCTS_DIR; }; 37E6B2BA19D9CAF300D0C601 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; 3C01518E2C2E298E0079E076 /* OneSignalInAppMessagesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OneSignalInAppMessagesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1544,6 +1549,7 @@ 5BC1DE612C90B85A00CA8807 /* OSIamFetchOffsetKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSIamFetchOffsetKey.swift; sourceTree = ""; }; 5BC1DE632C90BB9000CA8807 /* OSIamFetchReadyCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSIamFetchReadyCondition.swift; sourceTree = ""; }; 5BC1DE672C90C23E00CA8807 /* OSConsistencyManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSConsistencyManagerTests.swift; sourceTree = ""; }; + 6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtConfig.swift; sourceTree = ""; }; 7A123294235DFE3B002B6CE3 /* OutcomeTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OutcomeTests.m; sourceTree = ""; }; 7A12EBD523060A6F005C4FA5 /* OSSessionManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OSSessionManager.m; sourceTree = ""; }; 7A12EBD623060A6F005C4FA5 /* OSSessionManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OSSessionManager.h; sourceTree = ""; }; @@ -1870,6 +1876,8 @@ DEFB3E622BB731BD00E65DAD /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; }; DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLiveActivities.swift; sourceTree = ""; }; DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSStubLiveActivities.swift; sourceTree = ""; }; + E9376A4957E9090C748BCB18 /* OSUserJwtConfigTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtConfigTests.swift; sourceTree = ""; }; + F4855B81F170253FB0C1749D /* OSIdentityVerificationServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityVerificationServiceTests.swift; sourceTree = ""; }; FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIamFetchReadyConditionTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -2311,6 +2319,7 @@ 4710EA522B8FCFB200435356 /* OSDispatchQueue.swift */, DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */, DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */, + A843B922174496E99F2D00A8 /* Jwt */, ); path = Source; sourceTree = ""; @@ -2617,10 +2626,21 @@ 3C23A21E2FCE0AA1001D32E3 /* OSResilientStorageTests.swift */, 3C23A21C2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift */, FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */, + 5B47CE0CE255AC7128442FFF /* Jwt */, ); path = OneSignalOSCoreTests; sourceTree = ""; }; + 5B47CE0CE255AC7128442FFF /* Jwt */ = { + isa = PBXGroup; + children = ( + F4855B81F170253FB0C1749D /* OSIdentityVerificationServiceTests.swift */, + E9376A4957E9090C748BCB18 /* OSUserJwtConfigTests.swift */, + ); + name = Jwt; + path = Jwt; + sourceTree = ""; + }; 5BC1DE652C90BC9F00CA8807 /* Consistency */ = { isa = PBXGroup; children = ( @@ -2776,6 +2796,16 @@ name = Categories; sourceTree = ""; }; + A843B922174496E99F2D00A8 /* Jwt */ = { + isa = PBXGroup; + children = ( + 1C4EAEA1BC62D8FC57927511 /* OSIdentityVerificationService.swift */, + 6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */, + ); + name = Jwt; + path = Jwt; + sourceTree = ""; + }; DE51DDE2294262670073D5C4 /* RemoteParameters */ = { isa = PBXGroup; children = ( @@ -4518,6 +4548,8 @@ 3C11518D289AF5E800565C41 /* OSModelChangedHandler.swift in Sources */, 3C14E3B32FAE54C006ED053 /* OSLoggerPlatformProvider.swift in Sources */, 3C8E6DF928A6D89E0031E48A /* OSOperationExecutor.swift in Sources */, + ECD9DF65FB98056806A43541 /* OSIdentityVerificationService.swift in Sources */, + BE737361D82E74544B7A1996 /* OSUserJwtConfig.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4664,6 +4696,8 @@ 32D3A6EA8AD44274B5CE378A /* FileLogStoreRetentionTests.swift in Sources */, 3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */, DD2A89A8052E2D1912B0038B /* OSIamFetchReadyConditionTests.swift in Sources */, + 9FF50E2A40C88E4533033A38 /* OSIdentityVerificationServiceTests.swift in Sources */, + 8E949FF4C7A7A2C7182E53EA /* OSUserJwtConfigTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h index 7c0567be9..0c3b6cd96 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h @@ -79,6 +79,7 @@ // Remote SDK feature flags (Turbine GET apps/{app_id}/sdk/features/{platform}/{sdk_version}) #define OSUD_SDK_REMOTE_FEATURE_FLAGS @"OSUD_SDK_REMOTE_FEATURE_FLAGS" #define OSUD_SDK_REMOTE_FEATURE_FLAG_METADATA @"OSUD_SDK_REMOTE_FEATURE_FLAG_METADATA" +#define OSUD_USE_IDENTITY_VERIFICATION @"OSUD_USE_IDENTITY_VERIFICATION" // Remote Params - Receive Receipts #define OSUD_RECEIVE_RECEIPTS_ENABLED @"OS_ENABLE_RECEIVE_RECEIPTS" // * OSUD_RECEIVE_RECEIPTS_ENABLED // Outcomes @@ -132,7 +133,7 @@ #define IOS_USES_PROVISIONAL_AUTHORIZATION @"uses_provisional_auth" #define IOS_REQUIRES_EMAIL_AUTHENTICATION @"require_email_auth" #define IOS_REQUIRES_SMS_AUTHENTICATION @"require_sms_auth" -#define IOS_REQUIRES_USER_ID_AUTHENTICATION @"require_user_id_auth" +#define IOS_JWT_REQUIRED @"jwt_required" #define IOS_RECEIVE_RECEIPTS_ENABLE @"receive_receipts_enable" #define IOS_OUTCOMES_V2_SERVICE_ENABLE @"v2_enabled" #define IOS_LOCATION_SHARED @"location_shared" @@ -321,6 +322,15 @@ typedef enum {GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT, TRACE, PATCH} HTTP #define OS_SNAPSHOT_ONESIGNAL_ID @"OS_SNAPSHOT_ONESIGNAL_ID" #define OS_SNAPSHOT_EXTERNAL_ID @"OS_SNAPSHOT_EXTERNAL_ID" +/* Identity Verification */ +#define OS_JWT_BEARER_TOKEN @"OS_JWT_BEARER_TOKEN" +#define OS_JWT_TOKEN_INVALID @"OS_JWT_TOKEN_INVALID" + +// Posted when work held for want of an answer can be reattempted: the requirement hydrated, or the app +// supplied a token. Listeners re-read the current state rather than take anything from the post. +#define OS_ON_JWT_CONFIG_HYDRATED @"OS_ON_JWT_CONFIG_HYDRATED" +#define OS_ON_USER_JWT_UPDATED @"OS_ON_USER_JWT_UPDATED" + // Models and Model Stores #define OS_IDENTITY_MODEL_KEY @"OS_IDENTITY_MODEL_KEY" #define OS_IDENTITY_MODEL_STORE_KEY @"OS_IDENTITY_MODEL_STORE_KEY" diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSIdentityVerificationService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSIdentityVerificationService.swift new file mode 100644 index 000000000..4e25f1813 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSIdentityVerificationService.swift @@ -0,0 +1,116 @@ +/* + 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 Foundation +import OneSignalCore +@_implementationOnly import OneSignalKMP + +/// Who is waiting on hydration. Keyed so re-registering replaces rather than stacking a duplicate. +public enum OSJwtConfigHydratedObserver { + case userExecutor + case operationRepo + case userManager +} + +/** + Decides Identity Verification gating from the rollout feature flag and the app's `jwt_required` + setting. + + - `ivBehaviorActive`: whether IV behavior is in effect (JWT on requests, `external_id` alias, 401 + handling). + - `newCodePathsRun`: whether IV code paths should run at all (feature flag, or always when the app + requires auth). + - `requirement`: use when you must tell `unknown` apart from `off` — both booleans are `false` while + the requirement is unknown, which is fine for apps that do not require Identity Verification, but + callers that must not send an unsigned request should wait until it is known. + */ +public final class OSIdentityVerificationService { + private let featureManager: OSFeatureManager + private let jwtConfig: OSUserJwtConfig + + private let handlerLock = NSLock() + // Ordered by registration: User executor's held Create User before Deltas that need its onesignal_id. + private var jwtConfigHydratedHandlers: [(observer: OSJwtConfigHydratedObserver, handler: (OSRequiresUserAuth) -> Void)] = [] + + /// The raw `jwt_required` value, including `unknown` before remote params arrive. + public var requirement: OSRequiresUserAuth { + return jwtConfig.requirement + } + + /// Whether Identity Verification behavior applies: JWT on requests, `external_id` alias, 401 handling. + public var ivBehaviorActive: Bool { + return jwtConfig.requirement == .on + } + + /// Whether the new Identity Verification code paths run at all. An app that requires auth is always in, + /// no matter how the rollout flag is set. + public var newCodePathsRun: Bool { + return featureManager.isEnabled(featureKey: FeatureFlag.sdkIdentityVerification.key) || ivBehaviorActive + } + + public init(featureManager: OSFeatureManager, jwtConfig: OSUserJwtConfig) { + self.featureManager = featureManager + self.jwtConfig = jwtConfig + jwtConfig.setOnHydratedHandler { [weak self] requirement in + self?.fireJwtConfigHydrated(requirement) + } + } + + /** + Fires on every hydration, including an unchanged value — deferred work waits on that. A handler + registered after `requirement` is already known runs immediately, since that hydration is not repeated. + */ + public func addOnJwtConfigHydratedHandler(for observer: OSJwtConfigHydratedObserver, _ handler: @escaping (OSRequiresUserAuth) -> Void) { + let alreadyKnown = handlerLock.withLock { () -> OSRequiresUserAuth in + if let index = jwtConfigHydratedHandlers.firstIndex(where: { $0.observer == observer }) { + jwtConfigHydratedHandlers[index] = (observer, handler) + } else { + jwtConfigHydratedHandlers.append((observer, handler)) + } + return jwtConfig.requirement + } + + guard alreadyKnown != .unknown else { + return + } + handler(alreadyKnown) + } + + public func removeOnJwtConfigHydratedHandler(for observer: OSJwtConfigHydratedObserver) { + handlerLock.withLock { + jwtConfigHydratedHandlers.removeAll { $0.observer == observer } + } + } + + private func fireJwtConfigHydrated(_ requirement: OSRequiresUserAuth) { + // Snapshot: a handler can register another, and handlers take locks of their own. + let handlers = handlerLock.withLock { jwtConfigHydratedHandlers } + for entry in handlers { + entry.handler(requirement) + } + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSUserJwtConfig.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSUserJwtConfig.swift new file mode 100644 index 000000000..c4ad37f66 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSUserJwtConfig.swift @@ -0,0 +1,134 @@ +/* + 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 Foundation +import OneSignalCore + +/** + Whether the app requires Identity Verification, mirroring the `jwt_required` remote param. + `unknown` has to stay distinguishable from `off` so nothing is sent unsigned on behalf of an app + that turns out to require auth. + */ +public enum OSRequiresUserAuth: Int { + // Raw values are cached, and 0 is what UserDefaults returns for a missing integer, so `unknown` owns it. + case on = 1 + case off = -1 + case unknown = 0 +} + +/** + Holds the Identity Verification requirement and caches it across launches. + Deliberately knows nothing about gating: `OSIdentityVerificationService` makes every such decision + and is the only observer here. + */ +@objc(OSUserJwtConfig) +public final class OSUserJwtConfig: NSObject { + /** + Remote params hydrate the requirement from `OneSignal.m`, which runs before the User Manager is + started, and keeps running in sessions where it never starts at all because consent is pending. + Reaching the requirement through a shared instance keeps that path from constructing the User + Manager just to hand over a boolean. + + Only `OneSignal.m` and `OneSignalUserManagerImpl` should reference this. Everything below them — + the operation repo, the executors, the request layer — is handed the config when it is created, + which keeps the shared instance contained to the two places that cannot avoid it. + */ + @objc public static let shared = OSUserJwtConfig() + + private let lock = NSLock() + private var _requirement: OSRequiresUserAuth + private var onHydrated: ((OSRequiresUserAuth) -> Void)? + + public var requirement: OSRequiresUserAuth { + return lock.withLock { _requirement } + } + + public override init() { + _requirement = OSUserJwtConfig.cachedRequirement() + super.init() + } + + /** + Applies the requirement carried by a successful remote params response. A response that omits + `jwt_required` means the app has Identity Verification off, so callers pass `false` for it rather + than leaving the requirement unknown. A response with no body at all answers nothing, so callers + skip this and leave the cached requirement in place. + */ + @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. + 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) + } + if previous != hydrated { + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSUserJwtConfig requirement changed from \(previous) to \(hydrated)") + } + // Fires even when the value is unchanged, because work deferred while the requirement was + // unknown is waiting on this to run. + handler?(hydrated) + } + + /** + 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. + */ + public func refreshIfUnknown() { + lock.withLock { + guard _requirement == .unknown else { + return + } + _requirement = OSUserJwtConfig.cachedRequirement() + } + } + + /// The Identity Verification service is the sole observer, so a second registration replaces the first. + func setOnHydratedHandler(_ handler: ((OSRequiresUserAuth) -> Void)?) { + lock.withLock { onHydrated = handler } + } + + /** + Test seam. The shared instance outlives a test case, so a test + that hydrates the requirement has to hand the next one an unhydrated config, and `hydrate` can + only reach `on` or `off`. Leaves the hydrated handler alone: the Identity Verification service + that registered it is built once and lives as long as the process. + */ + func resetRequirementToUnknownForTests() { + lock.withLock { _requirement = .unknown } + } + + private static func cachedRequirement() -> OSRequiresUserAuth { + let rawValue = OneSignalUserDefaults.initShared().getSavedInteger(forKey: OSUD_USE_IDENTITY_VERIFICATION, + defaultValue: OSRequiresUserAuth.unknown.rawValue) + return OSRequiresUserAuth(rawValue: rawValue) ?? .unknown + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSIdentityVerificationServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSIdentityVerificationServiceTests.swift new file mode 100644 index 000000000..60dca0a32 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSIdentityVerificationServiceTests.swift @@ -0,0 +1,249 @@ +/* + 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 Foundation +import XCTest +import OneSignalCore +import OneSignalKMP +@testable import OneSignalOSCore + +final class OSIdentityVerificationServiceTests: XCTestCase { + + private var jwtConfig = OSUserJwtConfig() + private var flagsStore: OSFeatureFlagsStore! + private var featureManager: OSFeatureManager! + + override func setUp() { + super.setUp() + clearCache() + jwtConfig = OSUserJwtConfig() + flagsStore = OSFeatureFlagsStore() + featureManager = OSFeatureManager(store: flagsStore) + } + + override func tearDown() { + clearCache() + super.tearDown() + } + + private func clearCache() { + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_USE_IDENTITY_VERIFICATION) + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAGS) + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAG_METADATA) + } + + private func makeService() -> OSIdentityVerificationService { + return OSIdentityVerificationService(featureManager: featureManager, jwtConfig: jwtConfig) + } + + // MARK: - Gates + + func testBothGatesAreOffForAnAppWithoutTheFlagOrTheRequirement() { + let service = makeService() + jwtConfig.hydrate(requiresUserAuth: false) + + XCTAssertFalse(service.newCodePathsRun) + XCTAssertFalse(service.ivBehaviorActive) + } + + func testTheFlagAloneRunsTheNewCodePathsWithoutTurningOnTheBehavior() { + flagsStore.applyRemoteFlags([FeatureFlag.sdkIdentityVerification.key], metadata: nil) + let service = makeService() + jwtConfig.hydrate(requiresUserAuth: false) + + XCTAssertTrue(service.newCodePathsRun) + XCTAssertFalse(service.ivBehaviorActive) + } + + func testAnAppThatRequiresAuthIsGatedInRegardlessOfTheFlag() { + let service = makeService() + jwtConfig.hydrate(requiresUserAuth: true) + + XCTAssertTrue(service.newCodePathsRun) + XCTAssertTrue(service.ivBehaviorActive) + } + + func testBothGatesAreOffBeforeRemoteParamsAreRead() { + let service = makeService() + + XCTAssertFalse(service.newCodePathsRun) + XCTAssertFalse(service.ivBehaviorActive) + } + + func testAnUnknownRequirementStaysVisibleWhileTheFlagRunsTheNewCodePaths() { + flagsStore.applyRemoteFlags([FeatureFlag.sdkIdentityVerification.key], metadata: nil) + let service = makeService() + + XCTAssertTrue(service.newCodePathsRun) + // Neither gate can tell unknown from off, so callers about to send unsigned work read this instead + XCTAssertFalse(service.ivBehaviorActive) + XCTAssertEqual(service.requirement, .unknown) + } + + func testGatesFollowTheFlagWithinTheSameRun() { + let service = makeService() + jwtConfig.hydrate(requiresUserAuth: false) + + // sdk_identity_verification is IMMEDIATE in the catalog, so a mid-run store change flows through + flagsStore.applyRemoteFlags([FeatureFlag.sdkIdentityVerification.key], metadata: nil) + XCTAssertTrue(service.newCodePathsRun) + + flagsStore.applyRemoteFlags([], metadata: nil) + XCTAssertFalse(service.newCodePathsRun) + } + + // MARK: - Hydration handler + + func testTheHandlerRunsForEveryHydration() { + let service = makeService() + var requirements: [OSRequiresUserAuth] = [] + service.addOnJwtConfigHydratedHandler(for: .operationRepo) { requirements.append($0) } + + jwtConfig.hydrate(requiresUserAuth: true) + jwtConfig.hydrate(requiresUserAuth: true) + + // Work deferred while the requirement was unknown needs the repeat too, not just the change + XCTAssertEqual(requirements, [.on, .on]) + } + + func testTheHandlerReceivesTheHydratedRequirement() { + let service = makeService() + var requirement: OSRequiresUserAuth? + service.addOnJwtConfigHydratedHandler(for: .operationRepo) { requirement = $0 } + + jwtConfig.hydrate(requiresUserAuth: false) + + XCTAssertEqual(requirement, .off) + } + + func testRemovingTheHandlerStopsTheCallbacks() { + let service = makeService() + var callCount = 0 + service.addOnJwtConfigHydratedHandler(for: .operationRepo) { _ in callCount += 1 } + service.removeOnJwtConfigHydratedHandler(for: .operationRepo) + + jwtConfig.hydrate(requiresUserAuth: true) + + XCTAssertEqual(callCount, 0) + } + + func testAHandlerRegisteredAfterHydrationRunsImmediately() { + let service = makeService() + jwtConfig.hydrate(requiresUserAuth: true) + + var requirement: OSRequiresUserAuth? + service.addOnJwtConfigHydratedHandler(for: .operationRepo) { requirement = $0 } + + // Remote params can return before the repo subscribes, and that hydration does not come again + XCTAssertEqual(requirement, .on) + } + + func testAHandlerRegisteredBeforeRemoteParamsWaitsForThem() { + let service = makeService() + var callCount = 0 + + service.addOnJwtConfigHydratedHandler(for: .operationRepo) { _ in callCount += 1 } + + XCTAssertEqual(callCount, 0) + } + + func testHydratingAfterTheServiceIsReleasedIsANoOp() { + var service: OSIdentityVerificationService? = makeService() + var callCount = 0 + service?.addOnJwtConfigHydratedHandler(for: .operationRepo) { _ in callCount += 1 } + service = nil + + jwtConfig.hydrate(requiresUserAuth: true) + + XCTAssertEqual(callCount, 0) + } + + // MARK: - Multiple observers + + /// The User executor and the operation repo both wait on hydration; neither may displace the other. + func testEveryObserverIsNotified() { + let service = makeService() + var notified: [OSJwtConfigHydratedObserver] = [] + service.addOnJwtConfigHydratedHandler(for: .userExecutor) { _ in notified.append(.userExecutor) } + service.addOnJwtConfigHydratedHandler(for: .operationRepo) { _ in notified.append(.operationRepo) } + + jwtConfig.hydrate(requiresUserAuth: true) + + XCTAssertEqual(notified, [.userExecutor, .operationRepo]) + } + + /// Registration order, so a held Create User goes out before Deltas that need its `onesignal_id`. + func testObserversAreNotifiedInRegistrationOrder() { + let service = makeService() + var notified: [OSJwtConfigHydratedObserver] = [] + service.addOnJwtConfigHydratedHandler(for: .operationRepo) { _ in notified.append(.operationRepo) } + service.addOnJwtConfigHydratedHandler(for: .userExecutor) { _ in notified.append(.userExecutor) } + + jwtConfig.hydrate(requiresUserAuth: true) + + XCTAssertEqual(notified, [.operationRepo, .userExecutor]) + } + + /// A rebuilt observer replaces its own registration rather than leaving the old closure behind. + func testReRegisteringTheSameObserverReplacesIt() { + let service = makeService() + var firstCallCount = 0 + var secondCallCount = 0 + service.addOnJwtConfigHydratedHandler(for: .userExecutor) { _ in firstCallCount += 1 } + service.addOnJwtConfigHydratedHandler(for: .userExecutor) { _ in secondCallCount += 1 } + + jwtConfig.hydrate(requiresUserAuth: true) + + XCTAssertEqual(firstCallCount, 0) + XCTAssertEqual(secondCallCount, 1) + } + + /// Replacing keeps the original position, so ordering does not shift under a rebuild. + func testReplacingAnObserverKeepsItsPosition() { + let service = makeService() + var notified: [String] = [] + service.addOnJwtConfigHydratedHandler(for: .userExecutor) { _ in notified.append("user-executor-original") } + service.addOnJwtConfigHydratedHandler(for: .operationRepo) { _ in notified.append("operation-repo") } + service.addOnJwtConfigHydratedHandler(for: .userExecutor) { _ in notified.append("user-executor-replacement") } + + jwtConfig.hydrate(requiresUserAuth: true) + + XCTAssertEqual(notified, ["user-executor-replacement", "operation-repo"]) + } + + func testRemovingOneObserverLeavesTheOther() { + let service = makeService() + var notified: [OSJwtConfigHydratedObserver] = [] + service.addOnJwtConfigHydratedHandler(for: .userExecutor) { _ in notified.append(.userExecutor) } + service.addOnJwtConfigHydratedHandler(for: .operationRepo) { _ in notified.append(.operationRepo) } + service.removeOnJwtConfigHydratedHandler(for: .userExecutor) + + jwtConfig.hydrate(requiresUserAuth: true) + + XCTAssertEqual(notified, [.operationRepo]) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSUserJwtConfigTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSUserJwtConfigTests.swift new file mode 100644 index 000000000..1d536c65a --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSUserJwtConfigTests.swift @@ -0,0 +1,95 @@ +/* + 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 Foundation +import XCTest +import OneSignalCore +@testable import OneSignalOSCore + +final class OSUserJwtConfigTests: XCTestCase { + + override func setUp() { + super.setUp() + clearCachedRequirement() + } + + override func tearDown() { + clearCachedRequirement() + super.tearDown() + } + + private func clearCachedRequirement() { + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_USE_IDENTITY_VERIFICATION) + } + + private func cacheRequirement(_ requirement: OSRequiresUserAuth) { + OneSignalUserDefaults.initShared().saveInteger(forKey: OSUD_USE_IDENTITY_VERIFICATION, withValue: requirement.rawValue) + } + + func testRequirementIsUnknownUntilRemoteParamsAreRead() { + XCTAssertEqual(OSUserJwtConfig().requirement, .unknown) + } + + func testHydratedRequirementIsReadBackOnTheNextLaunch() { + OSUserJwtConfig().hydrate(requiresUserAuth: true) + XCTAssertEqual(OSUserJwtConfig().requirement, .on) + + // `off` has to be cached as its own value, not as the absence of one + OSUserJwtConfig().hydrate(requiresUserAuth: false) + XCTAssertEqual(OSUserJwtConfig().requirement, .off) + } + + func testRefreshAdoptsACachedRequirementThatTheInitialReadMissed() { + let jwtConfig = OSUserJwtConfig() + cacheRequirement(.on) + + jwtConfig.refreshIfUnknown() + + XCTAssertEqual(jwtConfig.requirement, .on) + } + + func testRefreshLeavesAKnownRequirementAlone() { + let jwtConfig = OSUserJwtConfig() + jwtConfig.hydrate(requiresUserAuth: false) + cacheRequirement(.on) + + jwtConfig.refreshIfUnknown() + + XCTAssertEqual(jwtConfig.requirement, .off) + } + + func testHydrateCachesTheRequirementEvenWhenTheValueIsUnchanged() { + let jwtConfig = OSUserJwtConfig() + jwtConfig.hydrate(requiresUserAuth: true) + // Stands in for a write that locked storage dropped, leaving memory and disk out of step + clearCachedRequirement() + + jwtConfig.hydrate(requiresUserAuth: true) + + XCTAssertEqual(OSUserJwtConfig().requirement, .on) + } +} diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 1ce666fd0..573d82e12 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -129,6 +129,16 @@ + (BOOL)downloadedParameters { return _downloadedParameters; } +/* + Remote params carry `jwt_required`, and the operation repo holds every queued operation until it knows + that value. A first launch has no cached answer to fall back on, so a params request that fails and is + never retried costs the app every tag, session count and event for the rest of the session. Retry with + a backoff, then leave it to the next session. + */ +static int _downloadParametersAttempts = 0; +static const int MAX_DOWNLOAD_PARAMETERS_ATTEMPTS = 5; +static const NSTimeInterval DOWNLOAD_PARAMETERS_RETRY_BASE_SECONDS = 5.0; + static OneSignalReceiveReceiptsController* _receiveReceiptsController; + (OneSignalReceiveReceiptsController*)receiveReceiptsController { if (!_receiveReceiptsController) @@ -152,7 +162,8 @@ + (void)clearStatics { _downloadedParameters = false; _didCallDownloadParameters = false; - + _downloadParametersAttempts = 0; + // sessionLaunchTime = [NSDate date]; [OSOutcomes clearStatics]; @@ -368,6 +379,12 @@ + (void)startNewSessionInternal { if ([OneSignalConfig shouldAwaitAppIdAndLogMissingPrivacyConsentForMethod:nil]) return; + // A new session is a fresh chance at params after the launch attempt exhausted its retries + if (!_downloadedParameters && !_didCallDownloadParameters && OneSignalIdentifiers.currentAppId) { + _downloadParametersAttempts = 0; + [self downloadIOSParamsWithAppId:OneSignalIdentifiers.currentAppId]; + } + [OSOutcomes.sharedController clearOutcomes]; [[OSSessionManager sharedSessionManager] restartSessionIfNeeded]; @@ -707,6 +724,7 @@ + (void)handleAppIdChange:(NSString*)appId { initDone = false; _downloadedParameters = false; _didCallDownloadParameters = false; + _downloadParametersAttempts = 0; [OSRemoteLoggingController reset]; [OSFeatureFlagsRefreshService reset]; // Flags are app-scoped but stored unscoped, and APP_STARTUP flags never unlatch @@ -723,6 +741,8 @@ + (void)handleAppIdChange:(NSString*)appId { [sharedUserDefaults removeValueForKey:OSUD_RECEIVE_RECEIPTS_ENABLED]; [sharedUserDefaults removeValueForKey:OS_PUSH_SUBSCRIPTION_MODEL_STORE_KEY]; + [sharedUserDefaults removeValueForKey:OSUD_USE_IDENTITY_VERIFICATION]; + // Drop cached identifiers — a real app-id change invalidates them. [OSResilientStorage setStrings:@{ OSResilientStorage.keySubscriptionId: @"", @@ -768,6 +788,7 @@ + (void)setConsentGiven:(BOOL)granted { + (void)downloadIOSParamsWithAppId:(NSString *)appId { [OneSignalLog onesignalLog:ONE_S_LL_DEBUG message:@"Downloading iOS parameters for this application"]; _didCallDownloadParameters = true; + _downloadParametersAttempts++; // This will be nil unless we have a cached user // TODO: Commented out. This will init the User Manager too early, and userId is not needed anyway. // NSString *userId = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId; @@ -775,8 +796,12 @@ + (void)downloadIOSParamsWithAppId:(NSString *)appId { [OneSignalCoreImpl.sharedClient executeRequest:[OSRequestGetIosParams withUserId:userId appId:appId] onSuccess:^(NSDictionary *result) { - if (result[IOS_REQUIRES_USER_ID_AUTHENTICATION]) { - OneSignalUserManagerImpl.sharedInstance.requiresUserAuth = [result[IOS_REQUIRES_USER_ID_AUTHENTICATION] boolValue]; + // A response that omits the key means Identity Verification is off for this app; an empty + // response answers nothing, so the cached requirement stands + if (result != nil) { + id jwtRequired = result[IOS_JWT_REQUIRED]; + BOOL requiresUserAuth = jwtRequired != (id)[NSNull null] && [jwtRequired boolValue]; + [OSUserJwtConfig.shared hydrateWithRequiresUserAuth:requiresUserAuth]; } if (result[IOS_USES_PROVISIONAL_AUTHORIZATION] != (id)[NSNull null]) { @@ -818,9 +843,33 @@ + (void)downloadIOSParamsWithAppId:(NSString *)appId { } onFailure:^(OneSignalClientError *error) { _didCallDownloadParameters = false; + if ([OSNetworkingUtils getResponseStatusType:error.code] == OSResponseStatusRetryable) { + [self scheduleDownloadIOSParamsRetryWithAppId:appId]; + } else { + [OneSignalLog onesignalLog:ONE_S_LL_WARN message:[NSString stringWithFormat:@"Could not download iOS parameters (HTTP %ld); not retrying this session.", (long)error.code]]; + } }]; } ++ (void)scheduleDownloadIOSParamsRetryWithAppId:(NSString *)appId { + if (_downloadParametersAttempts >= MAX_DOWNLOAD_PARAMETERS_ATTEMPTS) { + [OneSignalLog onesignalLog:ONE_S_LL_WARN message:@"Could not download iOS parameters; retrying on the next session. Operations that wait on the Identity Verification setting stay queued until then."]; + return; + } + + // 5s, 10s, 20s, 40s across the remaining attempts + NSTimeInterval delay = DOWNLOAD_PARAMETERS_RETRY_BASE_SECONDS * (1 << MAX(_downloadParametersAttempts - 1, 0)); + [OneSignalLog onesignalLog:ONE_S_LL_DEBUG message:[NSString stringWithFormat:@"Retrying the iOS parameters download in %.0f seconds", delay]]; + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delay * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + // A session start or a re-init may have already succeeded or have one in flight + if (_downloadedParameters || _didCallDownloadParameters) + return; + + [self downloadIOSParamsWithAppId:appId]; + }); +} + //TODO: consolidate in one place. Where??? + (void)launchWebURL:(NSString*)openUrl { From 272bb2c69c4ff12a8797a510fabde2854d940373 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 10 Sep 2026 07:20:36 -0700 Subject: [PATCH 02/25] feat: [JWT-4] identity model JWT, the JWT repo, and the public API (#1709) Co-authored-by: Cursor --- .../OneSignal.xcodeproj/project.pbxproj | 49 +++++ .../OneSignalOSCoreMocks/OSCoreMocks.swift | 10 + .../Source/OSIdentityModel.swift | 79 +++++-- .../Source/OSIdentityModelRepo.swift | 61 ++++++ .../Source/OSUserJwtInvalidatedEvent.swift | 48 +++++ .../OneSignalUser/Source/OSUserJwtRepo.swift | 128 +++++++++++ .../Source/OneSignalUserManagerImpl+Jwt.swift | 78 +++++++ .../Source/OneSignalUserManagerImpl.swift | 66 ++++-- .../MockUserJwtInvalidatedListener.swift | 48 +++++ .../OneSignalUserMocks.swift | 12 ++ .../OSIdentityModelRepoTests.swift | 198 ++++++++++++++++++ .../OSIdentityModelTests.swift | 146 +++++++++++++ .../OSUserJwtRepoTests.swift | 196 +++++++++++++++++ .../OneSignalUserTests/UserJwtApiTests.swift | 182 ++++++++++++++++ iOS_SDK/OneSignalSDK/Source/OneSignal.m | 12 ++ .../OneSignalSDK/Source/OneSignalFramework.h | 5 + .../Source/OneSignalSwiftInterface.swift | 8 + 17 files changed, 1289 insertions(+), 37 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtInvalidatedEvent.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtRepo.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserMocks/MockUserJwtInvalidatedListener.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelRepoTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/OSUserJwtRepoTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtApiTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index db14cf227..87c4f680c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -52,6 +52,8 @@ 03CCCC852835F291004BF794 /* UIApplicationDelegateSwizzlingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 03CCCC842835F291004BF794 /* UIApplicationDelegateSwizzlingTests.m */; }; 03E56DD328405F4A006AA1DA /* OneSignalAppDelegateOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 03E56DD228405F4A006AA1DA /* OneSignalAppDelegateOverrider.m */; }; 16664C4C25DDB195003B8A14 /* NSTimeZoneOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 16664C4B25DDB195003B8A14 /* NSTimeZoneOverrider.m */; }; + 257E219608960B8545199057 /* OneSignalUserManagerImpl+Jwt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */; }; + 2DB99C76F3532383C3B81D09 /* OSUserJwtRepoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5809B47EB4F2478099697CE /* OSUserJwtRepoTests.swift */; }; 37E6B2BB19D9CAF300D0C601 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37E6B2BA19D9CAF300D0C601 /* UIKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 3C0151922C2E298F0079E076 /* OneSignalInAppMessages.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DEBAAE282A4211D900BF2C1C /* OneSignalInAppMessages.framework */; }; 3C01519C2C2E29F90079E076 /* IAMRequestTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3C01519B2C2E29F90079E076 /* IAMRequestTests.m */; }; @@ -379,6 +381,8 @@ 918CB0301E73388E0067130F /* OneSignalFramework.h in CopyFiles */ = {isa = PBXBuildFile; fileRef = 912411F01E73342200E41FD7 /* OneSignalFramework.h */; }; 91C7725E1E7CCE1000D612D0 /* OneSignalInternal.h in Headers */ = {isa = PBXBuildFile; fileRef = 91C7725D1E7CCE1000D612D0 /* OneSignalInternal.h */; }; 91F60F7D1E80E4E400706E60 /* UncaughtExceptionHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = 91F60F7C1E80E4E400706E60 /* UncaughtExceptionHandler.m */; }; + 9224347AAE3E092B5743380D /* UserJwtApiTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80DC5517E6EB5B26CF980CC5 /* UserJwtApiTests.swift */; }; + 94D9F5AFE86B16453A36C9A2 /* MockUserJwtInvalidatedListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F214EE6C5FE133672D6622F /* MockUserJwtInvalidatedListener.swift */; }; 9D3300FA23149DAE000F0A83 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9D3300F923149DAE000F0A83 /* CoreGraphics.framework */; }; 9D348537233C669E00EB81C9 /* CoreLocation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9D348536233C669E00EB81C9 /* CoreLocation.framework */; }; 9D34853A233D2E3600EB81C9 /* OneSignalLocationOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 9D348539233D2E3600EB81C9 /* OneSignalLocationOverrider.m */; }; @@ -387,6 +391,7 @@ 9FF50E2A40C88E4533033A38 /* OSIdentityVerificationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4855B81F170253FB0C1749D /* OSIdentityVerificationServiceTests.swift */; }; A662399326850DDE00D52FD8 /* LanguageTest.m in Sources */ = {isa = PBXBuildFile; fileRef = A662399026850DDE00D52FD8 /* LanguageTest.m */; }; A66239952686612F00D52FD8 /* OneSignalFramework.h in Headers */ = {isa = PBXBuildFile; fileRef = 912411F01E73342200E41FD7 /* OneSignalFramework.h */; settings = {ATTRIBUTES = (Public, ); }; }; + B5FBED8247288744EB484CB5 /* OSIdentityModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5953656ACCC21358BC0CF2F0 /* OSIdentityModelTests.swift */; }; BE737361D82E74544B7A1996 /* OSUserJwtConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */; }; CA08FC871FE99BB4004C445F /* OneSignalClientOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = CA08FC831FE99BB4004C445F /* OneSignalClientOverrider.m */; }; CA1A6E6920DC2E31001C41B9 /* OneSignalDialogController.h in Headers */ = {isa = PBXBuildFile; fileRef = CA1A6E6720DC2E31001C41B9 /* OneSignalDialogController.h */; }; @@ -415,6 +420,8 @@ CACBAAAA218A65AE000ACAA5 /* InAppMessagingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CACBAAA9218A65AE000ACAA5 /* InAppMessagingTests.m */; }; CACBAAAC218A662B000ACAA5 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CACBAAAB218A662B000ACAA5 /* WebKit.framework */; }; CACBAAB4218A7113000ACAA5 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CACBAAAB218A662B000ACAA5 /* WebKit.framework */; }; + D465D9B81F58B242ADF14874 /* OSIdentityModelRepoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89DE5BB0EDD3964C20C5169F /* OSIdentityModelRepoTests.swift */; }; + DAF9C81134248FCDB0C12E5B /* OSUserJwtInvalidatedEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F2FC6C922FF8104F3197DD4 /* OSUserJwtInvalidatedEvent.swift */; }; DD2A89A8052E2D1912B0038B /* OSIamFetchReadyConditionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */; }; DE16C14424D3724700670EFA /* OneSignalLifecycleObserver.m in Sources */ = {isa = PBXBuildFile; fileRef = DE16C14324D3724700670EFA /* OneSignalLifecycleObserver.m */; }; DE16C14524D3724700670EFA /* OneSignalLifecycleObserver.m in Sources */ = {isa = PBXBuildFile; fileRef = DE16C14324D3724700670EFA /* OneSignalLifecycleObserver.m */; }; @@ -557,6 +564,8 @@ DEA4B4672888C5F200E9FE12 /* OneSignalExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE7D17F927026BA3002D3A5D /* OneSignalExtension.framework */; }; DEA69F452C190045009BB128 /* OneSignalCoreMocks.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3CC0639A2B6D7A8C002BB07F /* OneSignalCoreMocks.framework */; }; DEA69F462C190045009BB128 /* OneSignalCoreMocks.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3CC0639A2B6D7A8C002BB07F /* OneSignalCoreMocks.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 99D4C0052C86D43D045CB2E1 /* OneSignalOSCoreMocks.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C8544B62C5AEFF600F542A9 /* OneSignalOSCoreMocks.framework */; }; + 3A4E998D5F92749E8F19A708 /* OneSignalOSCoreMocks.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3C8544B62C5AEFF600F542A9 /* OneSignalOSCoreMocks.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; DEA98C1928C90EE5000C6856 /* OneSignalCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE7D17E627026B95002D3A5D /* OneSignalCore.framework */; }; DEA98C1C28C90EE6000C6856 /* OneSignalOSCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C115161289A259500565C41 /* OneSignalOSCore.framework */; }; DEA98C1E28C90EE9000C6856 /* OneSignalCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE7D17E627026B95002D3A5D /* OneSignalCore.framework */; }; @@ -674,6 +683,7 @@ DEFB3E652BB7346D00E65DAD /* OSLiveActivities.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */; }; DEFB3E672BB735B500E65DAD /* OSStubLiveActivities.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */; }; ECD9DF65FB98056806A43541 /* OSIdentityVerificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4EAEA1BC62D8FC57927511 /* OSIdentityVerificationService.swift */; }; + FD1F1FCA05D555623DD53B54 /* OSUserJwtRepo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8BBA843AFC81A4940CF7CC /* OSUserJwtRepo.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1062,6 +1072,13 @@ remoteGlobalIDString = 3CC063992B6D7A8C002BB07F; remoteInfo = OneSignalCoreMocks; }; + CEA00158D5C00F9E4DA3B777 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 37747F8B19147D6400558FAD /* Project object */; + proxyType = 1; + remoteGlobalIDString = 3C8544B52C5AEFF600F542A9; + remoteInfo = OneSignalOSCoreMocks; + }; DEBA2A1F2C20E35E00E234DB /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 37747F8B19147D6400558FAD /* Project object */; @@ -1277,6 +1294,7 @@ 3CEE93542B7C78EC008440BD /* OneSignalUser.framework in Embed Frameworks */, 3CA8B8832BEC2FCB0010ADA1 /* XCTest.framework in Embed Frameworks */, DEA69F462C190045009BB128 /* OneSignalCoreMocks.framework in Embed Frameworks */, + 3A4E998D5F92749E8F19A708 /* OneSignalOSCoreMocks.framework in Embed Frameworks */, 3CEE934F2B7C787B008440BD /* OneSignalOSCore.framework in Embed Frameworks */, ); name = "Embed Frameworks"; @@ -1354,6 +1372,8 @@ 1AF75EAC1E8567FD0097B315 /* NSString+OneSignal.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSString+OneSignal.h"; sourceTree = ""; }; 1AF75EAD1E8567FD0097B315 /* NSString+OneSignal.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSString+OneSignal.m"; sourceTree = ""; }; 1C4EAEA1BC62D8FC57927511 /* OSIdentityVerificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityVerificationService.swift; sourceTree = ""; }; + 1F214EE6C5FE133672D6622F /* MockUserJwtInvalidatedListener.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MockUserJwtInvalidatedListener.swift; sourceTree = ""; }; + 2F2FC6C922FF8104F3197DD4 /* OSUserJwtInvalidatedEvent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtInvalidatedEvent.swift; sourceTree = ""; }; 37747F9319147D6500558FAD /* libOneSignal.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libOneSignal.a; sourceTree = BUILT_PRODUCTS_DIR; }; 37E6B2BA19D9CAF300D0C601 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; 3C01518E2C2E298E0079E076 /* OneSignalInAppMessagesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OneSignalInAppMessagesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1539,6 +1559,8 @@ 475F471E2B8E398D00EC05B3 /* OneSignalLiveActivities.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = OneSignalLiveActivities.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 475F47202B8E398E00EC05B3 /* OneSignalLiveActivities.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalLiveActivities.h; sourceTree = ""; }; 475F47482B8E3A4400EC05B3 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "OneSignalUserManagerImpl+Jwt.swift"; sourceTree = ""; }; + 5953656ACCC21358BC0CF2F0 /* OSIdentityModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityModelTests.swift; sourceTree = ""; }; 5B053FB82CAE07EB002F30C4 /* OneSignalOSCoreTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OneSignalOSCoreTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5B58E4F3237CE7B3009401E0 /* UIDeviceOverrider.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = UIDeviceOverrider.h; sourceTree = ""; }; 5B58E4F6237CE7B4009401E0 /* UIDeviceOverrider.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = UIDeviceOverrider.m; sourceTree = ""; }; @@ -1550,6 +1572,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 = ""; }; 6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtConfig.swift; sourceTree = ""; }; + 6A8BBA843AFC81A4940CF7CC /* OSUserJwtRepo.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtRepo.swift; sourceTree = ""; }; 7A123294235DFE3B002B6CE3 /* OutcomeTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OutcomeTests.m; sourceTree = ""; }; 7A12EBD523060A6F005C4FA5 /* OSSessionManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OSSessionManager.m; sourceTree = ""; }; 7A12EBD623060A6F005C4FA5 /* OSSessionManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OSSessionManager.h; sourceTree = ""; }; @@ -1623,6 +1646,8 @@ 7AF98691244A567B00C36EAE /* OSOutcomeEventsCache.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OSOutcomeEventsCache.m; sourceTree = ""; }; 7AFE856A2368DDB80091D6A5 /* OSFocusCallParams.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OSFocusCallParams.m; sourceTree = ""; }; 7AFE856E2368DDC50091D6A5 /* OSFocusCallParams.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSFocusCallParams.h; sourceTree = ""; }; + 80DC5517E6EB5B26CF980CC5 /* UserJwtApiTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserJwtApiTests.swift; sourceTree = ""; }; + 89DE5BB0EDD3964C20C5169F /* OSIdentityModelRepoTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityModelRepoTests.swift; sourceTree = ""; }; 911E2CBA1E398AB3003112A4 /* UnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = UnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 911E2CBC1E398AB3003112A4 /* UnitTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UnitTests.m; sourceTree = ""; }; 911E2CBE1E398AB3003112A4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -1656,6 +1681,7 @@ 9D348538233D2DCF00EB81C9 /* OneSignalLocationOverrider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalLocationOverrider.h; sourceTree = ""; }; 9D348539233D2E3600EB81C9 /* OneSignalLocationOverrider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalLocationOverrider.m; sourceTree = ""; }; A662399026850DDE00D52FD8 /* LanguageTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = LanguageTest.m; sourceTree = ""; }; + B5809B47EB4F2478099697CE /* OSUserJwtRepoTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtRepoTests.swift; sourceTree = ""; }; CA08FC821FE99BB4004C445F /* OneSignalClientOverrider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalClientOverrider.h; sourceTree = ""; }; CA08FC831FE99BB4004C445F /* OneSignalClientOverrider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalClientOverrider.m; sourceTree = ""; }; CA1A6E6720DC2E31001C41B9 /* OneSignalDialogController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalDialogController.h; sourceTree = ""; }; @@ -1949,6 +1975,7 @@ 3CEE93532B7C78EC008440BD /* OneSignalUser.framework in Frameworks */, 3CA8B8822BEC2FCB0010ADA1 /* XCTest.framework in Frameworks */, DEA69F452C190045009BB128 /* OneSignalCoreMocks.framework in Frameworks */, + 99D4C0052C86D43D045CB2E1 /* OneSignalOSCoreMocks.framework in Frameworks */, 3CEE934E2B7C787B008440BD /* OneSignalOSCore.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -2441,6 +2468,7 @@ 3C87066F2BDE0957000D8CD2 /* MockUserRequests.swift */, 3C8706712BDEE076000D8CD2 /* MockUserDefines.swift */, 3CC063E52B6D7F96002BB07F /* OneSignalUserMocks.swift */, + 1F214EE6C5FE133672D6622F /* MockUserJwtInvalidatedListener.swift */, ); path = OneSignalUserMocks; sourceTree = ""; @@ -2456,6 +2484,10 @@ 3CB331672F281679000E1801 /* CustomEventsIntegrationTests.swift */, 3C67F7792BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift */, 3CDE664B2BFC2A56006DA114 /* OneSignalUserObjcTests.m */, + 89DE5BB0EDD3964C20C5169F /* OSIdentityModelRepoTests.swift */, + 5953656ACCC21358BC0CF2F0 /* OSIdentityModelTests.swift */, + B5809B47EB4F2478099697CE /* OSUserJwtRepoTests.swift */, + 80DC5517E6EB5B26CF980CC5 /* UserJwtApiTests.swift */, ); path = OneSignalUserTests; sourceTree = ""; @@ -2843,6 +2875,9 @@ 3CF8629F28A1964F00776CA4 /* OSPropertiesModel.swift */, 3CE795F828DB99B500736BD4 /* OSSubscriptionModelStoreListener.swift */, 3C5117162B15C31E00563465 /* OSUserState.swift */, + 2F2FC6C922FF8104F3197DD4 /* OSUserJwtInvalidatedEvent.swift */, + 6A8BBA843AFC81A4940CF7CC /* OSUserJwtRepo.swift */, + 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */, ); path = Source; sourceTree = ""; @@ -3726,6 +3761,7 @@ 3CEE93512B7C787C008440BD /* PBXTargetDependency */, 3CEE93562B7C78EC008440BD /* PBXTargetDependency */, DEA69F482C190045009BB128 /* PBXTargetDependency */, + 59E28F54BB3A1F1DAD13355C /* PBXTargetDependency */, ); name = OneSignalUserMocks; productName = OneSignalUserMocks; @@ -4600,6 +4636,7 @@ 3C8706702BDE0957000D8CD2 /* MockUserRequests.swift in Sources */, 3C8706722BDEE076000D8CD2 /* MockUserDefines.swift in Sources */, 3CC063E62B6D7F96002BB07F /* OneSignalUserMocks.swift in Sources */, + 94D9F5AFE86B16453A36C9A2 /* MockUserJwtInvalidatedListener.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4616,6 +4653,10 @@ 3CB3316A2F281692000E1801 /* OSCustomEventsExecutorTests.swift in Sources */, 3CA93BC4300AEFFA000724B3 /* SubscriptionUpdateRaceTests.swift in Sources */, 3CDE664C2BFC2A56006DA114 /* OneSignalUserObjcTests.m in Sources */, + D465D9B81F58B242ADF14874 /* OSIdentityModelRepoTests.swift in Sources */, + B5FBED8247288744EB484CB5 /* OSIdentityModelTests.swift in Sources */, + 2DB99C76F3532383C3B81D09 /* OSUserJwtRepoTests.swift in Sources */, + 9224347AAE3E092B5743380D /* UserJwtApiTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4822,6 +4863,9 @@ 3CE9227A289FA88B001B1062 /* OSIdentityModelStoreListener.swift in Sources */, DE69E19F282ED8060090BB3D /* OneSignalUser.docc in Sources */, 3CA6CE0A28E4F19B00CA0585 /* OSUserRequest.swift in Sources */, + DAF9C81134248FCDB0C12E5B /* OSUserJwtInvalidatedEvent.swift in Sources */, + FD1F1FCA05D555623DD53B54 /* OSUserJwtRepo.swift in Sources */, + 257E219608960B8545199057 /* OneSignalUserManagerImpl+Jwt.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5255,6 +5299,11 @@ target = 3CC063992B6D7A8C002BB07F /* OneSignalCoreMocks */; targetProxy = DEA69F472C190045009BB128 /* PBXContainerItemProxy */; }; + 59E28F54BB3A1F1DAD13355C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 3C8544B52C5AEFF600F542A9 /* OneSignalOSCoreMocks */; + targetProxy = CEA00158D5C00F9E4DA3B777 /* PBXContainerItemProxy */; + }; DEBA2A202C20E35E00E234DB /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = DEF784282912DEB600A1F3A5 /* OneSignalNotifications */; diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift index 6a144eef7..1f8cc7a27 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift @@ -34,6 +34,16 @@ public class OSCoreMocks: NSObject { public static func resetOperationRepo() { OSOperationRepo.sharedInstance.reset() } + + /// Puts the shared JWT config back to unhydrated. + public static func resetSharedJwtConfig() { + OSUserJwtConfig.shared.resetRequirementToUnknownForTests() + } + + /// Hydrates the shared JWT config. Non-IV tests hydrate `false` so the Operation Repo will flush. + public static func hydrateSharedJwtConfig(requiresUserAuth: Bool) { + OSUserJwtConfig.shared.hydrate(requiresUserAuth: requiresUserAuth) + } } extension OSOperationRepo { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift index 6e70b5057..6df132faf 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift @@ -38,12 +38,55 @@ class OSIdentityModel: OSModel { return internalGetAlias(OS_EXTERNAL_ID) } - // All access to aliases should go through helper methods with locking + // All access to aliases and the JWT bearer token must go through the lock var aliases: [String: String] = [:] - private let aliasesLock = NSRecursiveLock() + private let lock = NSRecursiveLock() - // TODO: We need to make this token secure - public var jwtBearerToken: String? + // MARK: - JWT + + private var jwtBearerTokenLocked: String? + public var jwtBearerToken: String? { + get { + lock.withLock { jwtBearerTokenLocked } + } + set { + // Notify outside the lock: the change notifier fires synchronously into listeners that + // take locks of their own. + let changed = lock.withLock { + guard newValue != jwtBearerTokenLocked else { return false } + jwtBearerTokenLocked = newValue + return true + } + if changed { + self.set(property: OS_JWT_BEARER_TOKEN, newValue: newValue, preventServerUpdate: true) + } + } + } + + /// Returns the bearer token if it is valid, otherwise nil, snapshots once + func getValidJwt() -> String? { + let token = jwtBearerToken + guard let token = token, !token.isEmpty, token != OS_JWT_TOKEN_INVALID else { + return nil + } + return token + } + + /// Returns `true` if the transition occurred, `false` if `rejectedToken` is no longer the stored + /// token. Comparing against the rejected token rather than the sentinel is what keeps a failure + /// response that was already in flight from parking the replacement supplied after it left. + @discardableResult + func invalidateJwtBearerToken(rejectedToken: String) -> Bool { + let changed = lock.withLock { + guard jwtBearerTokenLocked == rejectedToken else { return false } + jwtBearerTokenLocked = OS_JWT_TOKEN_INVALID + return true + } + if changed { + self.set(property: OS_JWT_BEARER_TOKEN, newValue: OS_JWT_TOKEN_INVALID, preventServerUpdate: true) + } + return changed + } // MARK: - Initialization @@ -54,9 +97,10 @@ class OSIdentityModel: OSModel { } override func encode(with coder: NSCoder) { - aliasesLock.withLock { + lock.withLock { super.encode(with: coder) coder.encode(aliases, forKey: "aliases") + coder.encode(jwtBearerTokenLocked, forKey: OS_JWT_BEARER_TOKEN) } } @@ -66,19 +110,20 @@ class OSIdentityModel: OSModel { // log error return nil } + self.jwtBearerTokenLocked = coder.decodeObject(forKey: OS_JWT_BEARER_TOKEN) as? String self.aliases = aliases } /** Threadsafe getter for an alias */ private func internalGetAlias(_ label: String) -> String? { - aliasesLock.withLock { + lock.withLock { return self.aliases[label] } } /** Threadsafe setter or removal for aliases */ private func internalAddAliases(_ aliases: [String: String]) { - aliasesLock.withLock { + lock.withLock { for (label, id) in aliases { // Remove the alias if the ID field is "" self.aliases[label] = id.isEmpty ? nil : id @@ -91,7 +136,7 @@ class OSIdentityModel: OSModel { Called to clear the model's data in preparation for hydration via a fetch user call. */ func clearData() { - aliasesLock.withLock { + lock.withLock { self.aliases = [:] } } @@ -120,14 +165,20 @@ class OSIdentityModel: OSModel { let newExternalId = remoteAliases[OS_EXTERNAL_ID] internalAddAliases(remoteAliases) - fireUserStateChanged(newOnesignalId: newOnesignalId, newExternalId: newExternalId) + OSUserStateSnapshot.fireUserStateChanged(newOnesignalId: newOnesignalId, newExternalId: newExternalId) } +} - /** - Fires the user observer if `onesignal_id` OR `external_id` has changed from the previous snapshot (previous hydration). - */ - private func fireUserStateChanged(newOnesignalId: String?, newExternalId: String?) { - let prevOnesignalId = OneSignalUserDefaults.initShared().getSavedString(forKey: OS_SNAPSHOT_ONESIGNAL_ID, defaultValue: nil) +/** + Owns the last user state the app was told about, so the observer only hears real changes. + + Hydration is the usual source, but `logout` under Identity Verification also reports here: it creates + no user on the server, so there is no hydration to carry the news that nobody is signed in. + */ +enum OSUserStateSnapshot { + /// Fires the user observer if `onesignal_id` OR `external_id` differs from the last reported pair. + static func fireUserStateChanged(newOnesignalId: String?, newExternalId: String?) { + let prevOnesignalId = OneSignalUserDefaults.initShared().getSavedString(forKey: OS_SNAPSHOT_ONESIGNAL_ID, defaultValue: nil) let prevExternalId = OneSignalUserDefaults.initShared().getSavedString(forKey: OS_SNAPSHOT_EXTERNAL_ID, defaultValue: nil) guard prevOnesignalId != newOnesignalId || prevExternalId != newExternalId else { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelRepo.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelRepo.swift index 781fe4e8f..c1468632e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelRepo.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelRepo.swift @@ -26,6 +26,7 @@ */ import Foundation +import OneSignalCore /** This class stores all Identity Models that are being used during an app session. @@ -52,4 +53,64 @@ class OSIdentityModelRepo { return models[modelId] } } + + func get(externalId: String) -> OSIdentityModel? { + lock.withLock { + return models.values.first { $0.externalId == externalId } + } + } + + /** + Repeated logins as the same user each create an Identity Model, so update them all. + This can be optimized in the future to re-use an Identity Model if multiple logins are made for the same user. + + Returns `false` if no Identity Model carries this external ID, in which case the token was not stored + anywhere and nothing can sign with it. + */ + @discardableResult + func updateJwtToken(externalId: String, token: String) -> Bool { + let matchingModels = modelsMatching(externalId: externalId) + guard !matchingModels.isEmpty else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSIdentityModelRepo.updateJwtToken called for unknown external ID \(externalId)") + return false + } + for model in matchingModels { + model.jwtBearerToken = token + } + return true + } + + /// The token this user can currently sign with, or nil if there is none. + func validJwt(externalId: String) -> String? { + return modelsMatching(externalId: externalId).lazy.compactMap { $0.getValidJwt() }.first + } + + /** + Invalidates the token on every Identity Model with this external ID, since repeated logins as the + same user each create one. Only the models still holding `rejectedToken` transition, so a + replacement that landed while the rejected request was in flight survives. + + Returns `false` if no Identity Model carries this external ID, in which case there was nothing + to park and nothing to tell the app about. + */ + @discardableResult + func invalidateJwtToken(externalId: String, rejectedToken: String) -> Bool { + let matchingModels = modelsMatching(externalId: externalId) + guard !matchingModels.isEmpty else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSIdentityModelRepo.invalidateJwtToken called for unknown external ID \(externalId)") + return false + } + for model in matchingModels { + model.invalidateJwtBearerToken(rejectedToken: rejectedToken) + } + return true + } + + /// Snapshot before touching the tokens: writing one fires the model's change notifier + /// synchronously into listeners that take locks of their own. + private func modelsMatching(externalId: String) -> [OSIdentityModel] { + lock.withLock { + models.values.filter { $0.externalId == externalId } + } + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtInvalidatedEvent.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtInvalidatedEvent.swift new file mode 100644 index 000000000..35d7a7e33 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtInvalidatedEvent.swift @@ -0,0 +1,48 @@ +/* + 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. + */ + +/** + Tells the app that the JWT it supplied for `externalId` is no longer accepted, so it should mint a + fresh one and hand it back through `OneSignal.updateUserJwt(externalId:token:)`. + */ +@objc public class OSUserJwtInvalidatedEvent: NSObject { + @objc public let externalId: String + + init(externalId: String) { + self.externalId = externalId + } + + @objc public func jsonRepresentation() -> NSDictionary { + return [ + "externalId": externalId + ] + } +} + +@objc public protocol OSUserJwtInvalidatedListener { + @objc func onUserJwtInvalidated(event: OSUserJwtInvalidatedEvent) +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtRepo.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtRepo.swift new file mode 100644 index 000000000..8adf8f164 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtRepo.swift @@ -0,0 +1,128 @@ +/* + 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 Foundation +import OneSignalCore + +/** + Identity Verification token access, keyed by `external_id`. Executors depend on this rather than on + the User Manager so that no JWT lookup reaches for a singleton. + */ +protocol OSUserJwtProviding: AnyObject { + /// The token this user can sign with, or nil if the SDK holds none. + func validJwt(externalId: String) -> String? + + /** + 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. + */ + @discardableResult + func askForToken(externalId: String) -> Bool + + /// Parks the rejected token and asks the app for a replacement. Returns `true` if this call asked. + @discardableResult + func invalidateJwt(externalId: String, rejectedToken: String) -> Bool +} + +final class OSUserJwtRepo: OSUserJwtProviding { + private let identityModelRepo: OSIdentityModelRepo + private let notifyInvalidated: (String) -> Void + + let lock = NSLock() + /** + External IDs the app has already been asked to re-sign. + + In memory only. A model decoded at launch can already hold the invalid sentinel, leaving nothing + to transition, so a fresh session has to be able to ask again — otherwise an app that was asked + in a previous run is never told it still owes a token. + */ + var askedForToken: Set = [] + + init(identityModelRepo: OSIdentityModelRepo, notifyInvalidated: @escaping (String) -> Void) { + self.identityModelRepo = identityModelRepo + self.notifyInvalidated = notifyInvalidated + } + + func validJwt(externalId: String) -> String? { + return identityModelRepo.validJwt(externalId: externalId) + } + + /** + Stores a token supplied by the app, and lets this user be asked again if it is ever rejected. + Returns `false` for a token that was not stored, so callers do not go looking for held work to release. + + An unusable token is ignored rather than stored: it would replace a good token with nothing to sign + with, and clearing the ask for it would have the SDK and the app trade asks and replies on every flush. + A token for an external ID the SDK has no Identity Model for lands nowhere, so it is not treated as an + answer either. + */ + @discardableResult + func updateJwt(externalId: String, token: String) -> Bool { + guard !token.isEmpty, token != OS_JWT_TOKEN_INVALID else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSUserJwtRepo.updateJwt ignored an unusable token for \(externalId)") + return false + } + guard identityModelRepo.updateJwtToken(externalId: externalId, token: token) else { + return false + } + lock.withLock { _ = askedForToken.remove(externalId) } + return true + } + + @discardableResult + func askForToken(externalId: String) -> Bool { + guard lock.withLock({ askedForToken.insert(externalId).inserted }) else { + return false + } + notifyInvalidated(externalId) + return true + } + + /// External IDs already asked this session; cleared when a usable token is stored. + func pendingTokenAsks() -> [String] { + return lock.withLock { Array(askedForToken) } + } + + @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 + // with an owner whose model was cleared for hydration lands here, and it retries once the + // aliases come back. + guard identityModelRepo.invalidateJwtToken(externalId: externalId, rejectedToken: rejectedToken) else { + return false + } + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSUserJwtRepo invalidated JWT for externalId: \(externalId)") + // A replacement that landed while the rejected Request was in flight leaves the token above + // untouched, and the retry signs with it, so there is nothing to ask the app for. + guard identityModelRepo.validJwt(externalId: externalId) == nil else { + return false + } + return askForToken(externalId: externalId) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift new file mode 100644 index 000000000..eaf4c7784 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift @@ -0,0 +1,78 @@ +/* + 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 OneSignalCore +import OneSignalOSCore + +/** + 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. + */ +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. + */ + @objc + public func addUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) { + self.userJwtInvalidatedObserver.addObserver(listener) + let pending = userJwtRepo.pendingTokenAsks() + guard !pending.isEmpty else { + return + } + // Same queue as OSObservable; skip any ask answered between registration and delivery. + DispatchQueue.main.async { [weak self] in + guard let self else { + return + } + let stillPending = Set(self.userJwtRepo.pendingTokenAsks()) + for externalId in pending where stillPending.contains(externalId) { + listener.onUserJwtInvalidated(event: OSUserJwtInvalidatedEvent(externalId: externalId)) + } + } + } + + @objc + public func removeUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) { + self.userJwtInvalidatedObserver.removeObserver(listener) + } + + @objc + public func updateUserJwt(externalId: String, token: String) { + guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "updateUserJwt") else { + return + } + guard !externalId.isEmpty, !token.isEmpty, token != OS_JWT_TOKEN_INVALID else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OneSignal.updateUserJwt called with empty externalId or an unusable token. This is not allowed.") + return + } + // TODO: omit the token from this log before shipping — keep for testing. + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignal.updateUserJwt called for externalId: \(externalId) with token: \(token)") + + storeJwt(externalId: externalId, token: token) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index d9850691f..d991b31a7 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -37,6 +37,7 @@ import OneSignalNotifications var User: OSUser { get } func login(externalId: String, token: String?) func logout() + func updateUserJwt(externalId: String, token: String) // Location func setLocation(latitude: Float, longitude: Float) // Purchase Tracking @@ -84,10 +85,6 @@ import OneSignalNotifications */ func trackEvent(name: String, properties: [String: Any]?) // ^ TODO: After alpha feedback, confirm value type for properties dict - // JWT Token Expire - typealias OSJwtCompletionBlock = (_ newJwtToken: String) -> Void - typealias OSJwtExpiredHandler = (_ externalId: String, _ completion: OSJwtCompletionBlock) -> Void - func onJwtExpired(expiredHandler: @escaping OSJwtExpiredHandler) } /** @@ -131,11 +128,15 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { let newRecordsState = OSNewRecordsState() + // Hydrated before this class is started, jwtConfig by remote params and featureManager by + // OSFeatureFlagsRefreshService, so a fresh instance here would read none of it. + let featureManager = OSFeatureManager.shared + let jwtConfig = OSUserJwtConfig.shared + let identityVerificationService: OSIdentityVerificationService + private let startQueue = DispatchQueue(label: "com.onesignal.user.start") var hasCalledStart = false - private var jwtExpiredHandler: OSJwtExpiredHandler? - var user: OSUserInternal { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else { return _mockUser @@ -167,8 +168,6 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { propertiesModel: OSPropertiesModel(changeNotifier: OSEventProducer()), pushSubscriptionModel: OSSubscriptionModel(type: .push, address: nil, subscriptionId: nil, reachable: false, isDisabled: true, changeNotifier: OSEventProducer())) - @objc public var requiresUserAuth = false - // User State Observer private var _userStateChangesObserver: OSObservable? var userStateChangesObserver: OSObservable { @@ -181,6 +180,18 @@ 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 + } + // Model Stores let identityModelStore = OSModelStore(changeSubscription: OSEventProducer(), storeKey: OS_IDENTITY_MODEL_STORE_KEY).registerAsUserObserver() let propertiesModelStore = OSModelStore(changeSubscription: OSEventProducer(), storeKey: OS_PROPERTIES_MODEL_STORE_KEY).registerAsUserObserver() @@ -190,6 +201,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { let pushSubscriptionModelStore = OSModelStore(changeSubscription: OSEventProducer(), storeKey: OS_PUSH_SUBSCRIPTION_MODEL_STORE_KEY) // These must be initialized in init() + let userJwtRepo: OSUserJwtRepo let identityModelStoreListener: OSIdentityModelStoreListener let propertiesModelStoreListener: OSPropertiesModelStoreListener let subscriptionModelStoreListener: OSSubscriptionModelStoreListener @@ -203,6 +215,14 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { var customEventsExecutor: OSCustomEventsExecutor? private override init() { + let identityVerificationService = OSIdentityVerificationService(featureManager: featureManager, jwtConfig: jwtConfig) + // Goes through `sharedInstance` rather than capturing self: the observer it notifies is created + // lazily and must not be touched during init. + let userJwtRepo = OSUserJwtRepo(identityModelRepo: identityModelRepo) { externalId in + OneSignalUserManagerImpl.sharedInstance.userJwtInvalidatedObserver.notifyChange(OSUserJwtInvalidatedEvent(externalId: externalId)) + } + self.identityVerificationService = identityVerificationService + self.userJwtRepo = userJwtRepo self.identityModelStoreListener = OSIdentityModelStoreListener(store: identityModelStore) self.propertiesModelStoreListener = OSPropertiesModelStoreListener(store: propertiesModelStore) self.subscriptionModelStoreListener = OSSubscriptionModelStoreListener(store: subscriptionModelStore) @@ -233,6 +253,8 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { propertiesModelStore.refresh() subscriptionModelStore.refresh() pushSubscriptionModelStore.refresh() + // Same prewarm gap as the stores: init may have read UserDefaults while it was locked. + jwtConfig.refreshIfUnknown() OSNotificationsManager.delegate = self @@ -468,6 +490,19 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { createUserIfNil() } + /** + Stores a token for `externalId`, so that the pending ask for this user is cleared and a later + rejection can ask again. + + Every app-supplied token arrives here, from `login` as well as `updateUserJwt`. + */ + func storeJwt(externalId: String, token: String) { + guard userJwtRepo.updateJwt(externalId: externalId, token: token) else { + return + } + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignalUserManager stored a JWT for externalId: \(externalId)") + } + @objc public func clearAllModelsFromStores() { prepareForNewUser() @@ -579,17 +614,6 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { updatePropertiesDeltas(property: .purchases, value: purchases) } - private func fireJwtExpired() { - guard let externalId = user.identityModel.externalId, let jwtExpiredHandler = self.jwtExpiredHandler else { - return - } - jwtExpiredHandler(externalId) { [self] (newToken) in - guard user.identityModel.externalId == externalId else { - return - } - user.identityModel.jwtBearerToken = newToken - } - } } // MARK: - Sessions @@ -665,10 +689,6 @@ extension OneSignalUserManagerImpl { } extension OneSignalUserManagerImpl: OSUser { - public func onJwtExpired(expiredHandler: @escaping OSJwtExpiredHandler) { - jwtExpiredHandler = expiredHandler - } - public var User: OSUser { start() return self diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/MockUserJwtInvalidatedListener.swift b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/MockUserJwtInvalidatedListener.swift new file mode 100644 index 000000000..bc8b3207f --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/MockUserJwtInvalidatedListener.swift @@ -0,0 +1,48 @@ +/* + 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 OneSignalUser + +public class MockUserJwtInvalidatedListener: NSObject, OSUserJwtInvalidatedListener { + public private(set) var invalidatedExternalIds: [String] = [] + public var invalidatedCallbackWasCalled: Bool { + return !invalidatedExternalIds.isEmpty + } + + private var callback: (() -> Void)? + + public override init() { } + + public func setCallback(_ callback: @escaping () -> Void) { + self.callback = callback + } + + public func onUserJwtInvalidated(event: OSUserJwtInvalidatedEvent) { + invalidatedExternalIds.append(event.externalId) + callback?() + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift index 1e0f513c2..8554757b9 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift @@ -45,6 +45,9 @@ public class OneSignalUserMocks: NSObject { ]) _ = OSResilientStorage.snapshot() OSCoreMocks.resetOperationRepo() + OSCoreMocks.resetSharedJwtConfig() + // Hydrate `off` so the Operation Repo's unknown-requirement deferral does not stall non-IV tests. + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) OneSignalUserManagerImpl.sharedInstance.reset() } @@ -66,6 +69,14 @@ extension OSIdentityModelRepo { } } +extension OSUserJwtRepo { + /// Clears which external IDs have already been asked for a token, which lives for a session in + /// production and would otherwise silence the invalidated event in the next test. + func reset() { + lock.withLock { askedForToken.removeAll() } + } +} + extension OneSignalUserManagerImpl { /** User Manager needs to reset between tests until we dependency inject the User Manager. @@ -74,6 +85,7 @@ extension OneSignalUserManagerImpl { */ func reset() { identityModelRepo.reset() + userJwtRepo.reset() // Model store listeners unsubscribe to their models // User Manager start() will subscribe them diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelRepoTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelRepoTests.swift new file mode 100644 index 000000000..40bf57230 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelRepoTests.swift @@ -0,0 +1,198 @@ +/* + 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 OneSignalUserMocks +@testable import OneSignalOSCore +@testable import OneSignalUser + +/// Covers the repo's external-ID lookups. The repo is constructible, so these build their own +/// instance rather than reaching through the User Manager's shared one. +final class OSIdentityModelRepoTests: XCTestCase { + + private var repo = OSIdentityModelRepo() + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + repo = OSIdentityModelRepo() + } + + override func tearDownWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + } + + @discardableResult + private func addModel(externalId: String?) -> OSIdentityModel { + let aliases = externalId.map { [OS_EXTERNAL_ID: $0] } + let model = OSIdentityModel(aliases: aliases, changeNotifier: OSEventProducer()) + repo.add(model: model) + return model + } + + // MARK: - get(externalId:) + + func testGetByExternalIdFindsTheMatchingModel() { + addModel(externalId: "user-a") + let userB = addModel(externalId: "user-b") + + XCTAssertIdentical(repo.get(externalId: "user-b"), userB) + } + + func testGetByExternalIdReturnsNilWhenNoModelMatches() { + addModel(externalId: "user-a") + + XCTAssertNil(repo.get(externalId: "user-b")) + } + + /// Anonymous users have no external ID, and must not be matched by one. + func testGetByExternalIdSkipsAnonymousModels() { + addModel(externalId: nil) + + XCTAssertNil(repo.get(externalId: "user-a")) + } + + // MARK: - updateJwtToken + + func testUpdateJwtTokenWritesTheTokenOntoTheMatchingModel() { + let userA = addModel(externalId: "user-a") + + repo.updateJwtToken(externalId: "user-a", token: "token-a") + + XCTAssertEqual(userA.jwtBearerToken, "token-a") + } + + /// Repeated logins as the same user can leave more than one model carrying that external ID; + /// a token that only reaches one of them would leave the others stuck on a rejected token. + func testUpdateJwtTokenWritesToEveryModelWithThatExternalId() { + let first = addModel(externalId: "user-a") + let second = addModel(externalId: "user-a") + + repo.updateJwtToken(externalId: "user-a", token: "token-a") + + XCTAssertEqual(first.jwtBearerToken, "token-a") + XCTAssertEqual(second.jwtBearerToken, "token-a") + } + + func testUpdateJwtTokenLeavesOtherUsersAlone() { + let userA = addModel(externalId: "user-a") + let userB = addModel(externalId: "user-b") + + repo.updateJwtToken(externalId: "user-a", token: "token-a") + + XCTAssertEqual(userA.jwtBearerToken, "token-a") + XCTAssertNil(userB.jwtBearerToken) + } + + /// A token supplied for a user the SDK has never seen is dropped rather than applied to whoever + /// happens to be current. + func testUpdateJwtTokenForAnUnknownExternalIdChangesNothing() { + let userA = addModel(externalId: "user-a") + + repo.updateJwtToken(externalId: "user-b", token: "token-b") + + XCTAssertNil(userA.jwtBearerToken) + } + + func testUpdateJwtTokenReplacesTheInvalidSentinel() { + let userA = addModel(externalId: "user-a") + userA.invalidateJwtBearerToken(rejectedToken: "token-a") + + repo.updateJwtToken(externalId: "user-a", token: "fresh-token") + + XCTAssertEqual(userA.getValidJwt(), "fresh-token") + } + + // MARK: - validJwt + + func testValidJwtReturnsTheStoredToken() { + let userA = addModel(externalId: "user-a") + userA.jwtBearerToken = "token-a" + + XCTAssertEqual(repo.validJwt(externalId: "user-a"), "token-a") + } + + func testValidJwtIsNilForAnUnknownExternalIdAndForAParkedToken() { + let userA = addModel(externalId: "user-a") + userA.jwtBearerToken = "token-a" + + XCTAssertNil(repo.validJwt(externalId: "user-b")) + + repo.invalidateJwtToken(externalId: "user-a", rejectedToken: "token-a") + XCTAssertNil(repo.validJwt(externalId: "user-a")) + } + + // MARK: - invalidateJwtToken + + /// A model left unparked would keep signing requests with a token the server already rejected. + func testInvalidateJwtTokenParksEveryModelWithThatExternalId() { + let first = addModel(externalId: "user-a") + let second = addModel(externalId: "user-a") + first.jwtBearerToken = "token-a" + second.jwtBearerToken = "token-a" + + repo.invalidateJwtToken(externalId: "user-a", rejectedToken: "token-a") + + XCTAssertNil(first.getValidJwt()) + XCTAssertNil(second.getValidJwt()) + } + + /// A login that landed while the rejected request was in flight leaves a newer token behind. + func testInvalidateJwtTokenLeavesAModelHoldingADifferentToken() { + let stale = addModel(externalId: "user-a") + let fresh = addModel(externalId: "user-a") + stale.jwtBearerToken = "token-a" + fresh.jwtBearerToken = "token-b" + + repo.invalidateJwtToken(externalId: "user-a", rejectedToken: "token-a") + + XCTAssertNil(stale.getValidJwt()) + XCTAssertEqual(fresh.getValidJwt(), "token-b") + } + + func testInvalidateJwtTokenLeavesOtherUsersAlone() { + let userA = addModel(externalId: "user-a") + let userB = addModel(externalId: "user-b") + userA.jwtBearerToken = "token-a" + userB.jwtBearerToken = "token-a" + + repo.invalidateJwtToken(externalId: "user-a", rejectedToken: "token-a") + + XCTAssertEqual(userB.getValidJwt(), "token-a") + } + + func testInvalidateJwtTokenForAnUnknownExternalIdChangesNothing() { + let userA = addModel(externalId: "user-a") + userA.jwtBearerToken = "token-a" + + repo.invalidateJwtToken(externalId: "user-b", rejectedToken: "token-a") + + XCTAssertEqual(userA.getValidJwt(), "token-a") + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift new file mode 100644 index 000000000..a29434e21 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift @@ -0,0 +1,146 @@ +/* + 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 OneSignalUserMocks +@testable import OneSignalOSCore +@testable import OneSignalUser + +/// Covers the JWT bearer token on `OSIdentityModel`: which tokens count as usable, the +/// compare-and-set on invalidation, and what survives an archive round trip. +final class OSIdentityModelTests: XCTestCase { + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + } + + override func tearDownWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + } + + private func makeModel(token: String? = nil) -> OSIdentityModel { + let model = OSIdentityModel(aliases: [:], changeNotifier: OSEventProducer()) + model.jwtBearerToken = token + return model + } + + private func archiveThenUnarchive(_ model: OSIdentityModel) throws -> OSIdentityModel { + let data = try NSKeyedArchiver.archivedData(withRootObject: model, requiringSecureCoding: false) + let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) + unarchiver.requiresSecureCoding = false + defer { unarchiver.finishDecoding() } + return try XCTUnwrap(unarchiver.decodeObject(forKey: NSKeyedArchiveRootObjectKey) as? OSIdentityModel) + } + + // MARK: - getValidJwt() + + func testGetValidJwtReturnsNilWhenTokenIsNil() { + XCTAssertNil(makeModel(token: nil).getValidJwt()) + } + + func testGetValidJwtReturnsNilWhenTokenIsEmptyString() { + XCTAssertNil(makeModel(token: "").getValidJwt()) + } + + func testGetValidJwtReturnsNilWhenTokenIsInvalidSentinel() { + XCTAssertNil(makeModel(token: OS_JWT_TOKEN_INVALID).getValidJwt()) + } + + func testGetValidJwtReturnsTokenWhenTokenIsValid() { + let token = "eyJhbGciOiJFUzI1NiJ9.payload.sig" + XCTAssertEqual(makeModel(token: token).getValidJwt(), token) + } + + // MARK: - invalidateJwtBearerToken(rejectedToken:) + + func testInvalidateReturnsTrueOnFirstTransitionAndSetsInvalidSentinel() { + let model = makeModel(token: "valid-token") + + XCTAssertTrue(model.invalidateJwtBearerToken(rejectedToken: "valid-token")) + XCTAssertEqual(model.jwtBearerToken, OS_JWT_TOKEN_INVALID) + } + + /// Two requests can be rejected at once; only one of them should tell the app to mint a token. + func testInvalidateReturnsFalseWhenAlreadyInvalid() { + let model = makeModel(token: "valid-token") + _ = model.invalidateJwtBearerToken(rejectedToken: "valid-token") + + XCTAssertFalse(model.invalidateJwtBearerToken(rejectedToken: "valid-token")) + XCTAssertEqual(model.jwtBearerToken, OS_JWT_TOKEN_INVALID) + } + + /// The app can supply a replacement while the rejected request is still in flight. + func testInvalidateLeavesAReplacementTokenAlone() { + let model = makeModel(token: "replacement-token") + + XCTAssertFalse(model.invalidateJwtBearerToken(rejectedToken: "stale-token")) + XCTAssertEqual(model.jwtBearerToken, "replacement-token") + } + + func testInvalidateDoesNothingWhenThereIsNoToken() { + let model = makeModel(token: nil) + + XCTAssertFalse(model.invalidateJwtBearerToken(rejectedToken: "stale-token")) + XCTAssertNil(model.jwtBearerToken) + } + + // MARK: - Persistence + + func testTokenSurvivesAnArchiveRoundTrip() throws { + let model = makeModel(token: "cached-token") + model.addAliases([OS_EXTERNAL_ID: "user-a"]) + + let decoded = try archiveThenUnarchive(model) + + XCTAssertEqual(decoded.jwtBearerToken, "cached-token") + XCTAssertEqual(decoded.externalId, "user-a") + } + + /// A model archived by a build that never encoded a token has no value under the key. + func testAModelArchivedWithoutATokenDecodesWithANilToken() throws { + let model = makeModel(token: nil) + model.addAliases([OS_ONESIGNAL_ID: "osid-a"]) + + let decoded = try archiveThenUnarchive(model) + + XCTAssertNil(decoded.jwtBearerToken) + XCTAssertEqual(decoded.onesignalId, "osid-a") + } + + func testTheInvalidSentinelIsWhatPersists() throws { + let model = makeModel(token: "valid-token") + model.invalidateJwtBearerToken(rejectedToken: "valid-token") + + let decoded = try archiveThenUnarchive(model) + + XCTAssertEqual(decoded.jwtBearerToken, OS_JWT_TOKEN_INVALID) + XCTAssertNil(decoded.getValidJwt()) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSUserJwtRepoTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSUserJwtRepoTests.swift new file mode 100644 index 000000000..dc492a9f4 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSUserJwtRepoTests.swift @@ -0,0 +1,196 @@ +/* + 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 OneSignalOSCore +@testable import OneSignalUser + +/// Covers when the app is asked for a replacement token. Parking of the models themselves is +/// `OSIdentityModelRepoTests`. +final class OSUserJwtRepoTests: XCTestCase { + private var identityModelRepo: OSIdentityModelRepo! + private var repo: OSUserJwtRepo! + private var asked: [String] = [] + + override func setUp() { + super.setUp() + identityModelRepo = OSIdentityModelRepo() + asked = [] + repo = OSUserJwtRepo(identityModelRepo: identityModelRepo) { [weak self] externalId in + self?.asked.append(externalId) + } + } + + @discardableResult + private func addModel(externalId: String, token: String?) -> OSIdentityModel { + let model = OSIdentityModel(aliases: [OS_EXTERNAL_ID: externalId], changeNotifier: OSEventProducer()) + model.jwtBearerToken = token + identityModelRepo.add(model: model) + return model + } + + // MARK: - validJwt + + func testValidJwtReadsThroughToTheIdentityModel() { + addModel(externalId: "user-a", token: "token-a") + + XCTAssertEqual(repo.validJwt(externalId: "user-a"), "token-a") + XCTAssertNil(repo.validJwt(externalId: "user-b")) + } + + // MARK: - askForToken + + /// A Request that parks for want of a token has nothing to reject, so it asks directly. + func testAskingForATokenAsksOncePerExternalId() { + XCTAssertTrue(repo.askForToken(externalId: "user-a")) + XCTAssertFalse(repo.askForToken(externalId: "user-a")) + XCTAssertTrue(repo.askForToken(externalId: "user-b")) + + XCTAssertEqual(asked, ["user-a", "user-b"]) + } + + /// Whichever path asks first, the other stays quiet for the rest of the session. + func testAParkedRequestAndARejectedTokenShareTheOneAsk() { + addModel(externalId: "user-a", token: "token-a") + + XCTAssertTrue(repo.askForToken(externalId: "user-a")) + XCTAssertFalse(repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a")) + + XCTAssertEqual(asked, ["user-a"]) + } + + // MARK: - invalidateJwt + + func testInvalidatingParksTheTokenAndAsksTheApp() { + let model = addModel(externalId: "user-a", token: "token-a") + + XCTAssertTrue(repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a")) + XCTAssertNil(model.getValidJwt()) + XCTAssertEqual(asked, ["user-a"]) + } + + /// Several Requests can be rejected before the app answers; asking once is enough. + func testInvalidatingTwiceAsksOnce() { + addModel(externalId: "user-a", token: "token-a") + + XCTAssertTrue(repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a")) + XCTAssertFalse(repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a")) + XCTAssertEqual(asked, ["user-a"]) + } + + /// The replacement arrived while the rejected Request was in flight, so the retry can use it. + func testInvalidatingAStaleTokenLeavesTheReplacementAndDoesNotAsk() { + let model = addModel(externalId: "user-a", token: "token-b") + + XCTAssertFalse(repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a")) + XCTAssertEqual(model.getValidJwt(), "token-b") + XCTAssertTrue(asked.isEmpty) + } + + func testInvalidatingAnUnknownExternalIdAsksNobody() { + addModel(externalId: "user-a", token: "token-a") + + XCTAssertFalse(repo.invalidateJwt(externalId: "user-b", rejectedToken: "token-a")) + XCTAssertTrue(asked.isEmpty) + } + + /// A model restored from cache already holds the sentinel, so there is no transition left to make. + /// The app still has to be told once this session that it owes a token. + func testInvalidatingAnAlreadyParkedTokenStillAsksOnceThisSession() { + addModel(externalId: "user-a", token: OS_JWT_TOKEN_INVALID) + + XCTAssertTrue(repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a")) + XCTAssertEqual(asked, ["user-a"]) + } + + // MARK: - updateJwt + + func testANewTokenRearmsTheRequestForAnother() { + addModel(externalId: "user-a", token: "token-a") + _ = repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a") + + repo.updateJwt(externalId: "user-a", token: "token-b") + XCTAssertEqual(repo.validJwt(externalId: "user-a"), "token-b") + + XCTAssertTrue(repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-b")) + XCTAssertEqual(asked, ["user-a", "user-a"]) + } + + /// An app answering with nothing usable must not be able to trade asks and replies with the SDK. + func testAnEmptyTokenDoesNotRearmTheRequestForAnother() { + addModel(externalId: "user-a", token: "token-a") + _ = repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a") + + XCTAssertFalse(repo.updateJwt(externalId: "user-a", token: "")) + + XCTAssertFalse(repo.askForToken(externalId: "user-a")) + XCTAssertEqual(asked, ["user-a"]) + } + + /// An app answering for an external ID the SDK has no model for stores nothing, so treating it as an + /// answer would have the SDK and the app trade asks and replies on every flush. + func testATokenForAnUnknownExternalIdIsNotStoredAndDoesNotRearmTheRequestForAnother() { + addModel(externalId: "user-a", token: "token-a") + _ = repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a") + + XCTAssertFalse(repo.updateJwt(externalId: "typo-id", token: "token-b")) + + XCTAssertNil(repo.validJwt(externalId: "user-a")) + XCTAssertFalse(repo.askForToken(externalId: "user-a")) + XCTAssertEqual(asked, ["user-a"]) + } + + /// The sentinel is what a rejection writes, so storing it as an update would park a working token. + func testTheInvalidSentinelIsIgnored() { + let model = addModel(externalId: "user-a", token: "token-a") + + XCTAssertFalse(repo.updateJwt(externalId: "user-a", token: OS_JWT_TOKEN_INVALID)) + + XCTAssertEqual(model.getValidJwt(), "token-a") + } + + /// A usable replacement must reach a user whose token was already rejected. + func testAReplacementTokenOverwritesTheParkedSentinel() { + addModel(externalId: "user-a", token: "token-a") + _ = repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a") + + repo.updateJwt(externalId: "user-a", token: "token-b") + + XCTAssertEqual(repo.validJwt(externalId: "user-a"), "token-b") + } + + func testUsersAreAskedForIndependently() { + addModel(externalId: "user-a", token: "token-a") + addModel(externalId: "user-b", token: "token-b") + + _ = repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a") + _ = repo.invalidateJwt(externalId: "user-b", rejectedToken: "token-b") + + XCTAssertEqual(asked, ["user-a", "user-b"]) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtApiTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtApiTests.swift new file mode 100644 index 000000000..f24b44667 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtApiTests.swift @@ -0,0 +1,182 @@ +/* + 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 OneSignalUserMocks +@testable import OneSignalOSCore +@testable import OneSignalUser + +/** + Public JWT surface on the User Manager: store a token, and tell the app when it stopped being accepted. + Goes through `sharedInstance` because the JWT config and IV service are shared. + + Asks are driven through `userJwtRepo` the way a rejected Request is: there is no public invalidate API. + */ +final class UserJwtApiTests: XCTestCase { + + private var listener = MockUserJwtInvalidatedListener() + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + OneSignalCoreImpl.setSharedClient(MockOneSignalClient()) + + // Held strongly for the test's lifetime: OSObservable keeps observers weakly. + listener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(listener) + } + + override func tearDownWithError() throws { + OneSignalUserManagerImpl.sharedInstance.removeUserJwtInvalidatedListener(listener) + OneSignalCoreMocks.clearUserDefaults() + } + + /// `OSObservable` delivers on the main queue, so a block enqueued after the notification runs + /// once the notification has — deterministic, rather than waiting out a timeout. + private func drainMainQueue() { + let drained = expectation(description: "main queue drained") + DispatchQueue.main.async { drained.fulfill() } + waitForExpectations(timeout: 5) + } + + /// Parks the token and asks the app the way a 401 on a user Request does. + @discardableResult + private func invalidate(externalId: String, rejectedToken: String) -> Bool { + return OneSignalUserManagerImpl.sharedInstance.userJwtRepo.invalidateJwt( + externalId: externalId, + rejectedToken: rejectedToken + ) + } + + // MARK: - updateUserJwt + + func testUpdateUserJwtStoresTheTokenOnThatUsersIdentityModel() { + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") + + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: "user-a", token: "token-a") + + XCTAssertEqual(user.identityModel.getValidJwt(), "token-a") + } + + func testUpdateUserJwtForAnUnknownExternalIdLeavesTheCurrentUserAlone() { + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") + + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: "user-b", token: "token-b") + + XCTAssertNil(user.identityModel.jwtBearerToken) + } + + /// An empty token reads as no token at all, so it would leave the user unable to sign a request + /// and unable to be asked for another one. + func testUpdateUserJwtWithAnEmptyTokenLeavesThePreviousTokenInPlace() { + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") + user.identityModel.jwtBearerToken = "token-a" + + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: "user-a", token: "") + + XCTAssertEqual(user.identityModel.getValidJwt(), "token-a") + } + + /// Storing the sentinel would look like an already-invalidated token, so the app is never asked again. + func testUpdateUserJwtWithTheInvalidSentinelIsRejected() { + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") + + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: "user-a", token: OS_JWT_TOKEN_INVALID) + + XCTAssertNil(user.identityModel.jwtBearerToken) + } + + // MARK: - Invalidated listener + + func testInvalidatingAJwtParksTheTokenAndNotifiesTheApp() { + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") + user.identityModel.jwtBearerToken = "token-a" + + invalidate(externalId: "user-a", rejectedToken: "token-a") + drainMainQueue() + + XCTAssertNil(user.identityModel.getValidJwt()) + XCTAssertEqual(listener.invalidatedExternalIds, ["user-a"]) + } + + /// Several requests can be rejected before the app supplies a new token; asking it once is enough. + func testInvalidatingAJwtTwiceNotifiesOnce() { + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") + user.identityModel.jwtBearerToken = "token-a" + + invalidate(externalId: "user-a", rejectedToken: "token-a") + invalidate(externalId: "user-a", rejectedToken: "token-a") + drainMainQueue() + + XCTAssertEqual(listener.invalidatedExternalIds, ["user-a"]) + } + + func testARemovedListenerIsNotNotified() { + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") + user.identityModel.jwtBearerToken = "token-a" + OneSignalUserManagerImpl.sharedInstance.removeUserJwtInvalidatedListener(listener) + + invalidate(externalId: "user-a", rejectedToken: "token-a") + drainMainQueue() + + XCTAssertTrue(listener.invalidatedExternalIds.isEmpty) + } + + /// An ask that fires before the app registers still has to reach a late listener. + func testAListenerAddedAfterAnAskStillHearsWhoOwesAToken() { + OneSignalUserManagerImpl.sharedInstance.removeUserJwtInvalidatedListener(listener) + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") + user.identityModel.jwtBearerToken = "token-a" + + invalidate(externalId: "user-a", rejectedToken: "token-a") + drainMainQueue() + XCTAssertTrue(listener.invalidatedExternalIds.isEmpty) + + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(listener) + drainMainQueue() + + XCTAssertEqual(listener.invalidatedExternalIds, ["user-a"]) + } + + /// A token that lands before the late listener is delivered must not be asked for again. + func testALateListenerIsNotToldAboutAnAskThatWasAlreadyAnswered() { + OneSignalUserManagerImpl.sharedInstance.removeUserJwtInvalidatedListener(listener) + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") + user.identityModel.jwtBearerToken = "token-a" + + invalidate(externalId: "user-a", rejectedToken: "token-a") + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: "user-a", token: "token-b") + + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(listener) + drainMainQueue() + + XCTAssertTrue(listener.invalidatedExternalIds.isEmpty) + } +} diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 573d82e12..96e1bec0f 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -195,6 +195,18 @@ + (void)login:(NSString * _Nonnull)externalId withToken:(NSString * _Nullable)to [OneSignalUserManagerImpl.sharedInstance loginWithExternalId:externalId token:token]; } ++ (void)addUserJwtInvalidatedListener:(id _Nonnull)listener { + [OneSignalUserManagerImpl.sharedInstance addUserJwtInvalidatedListener:listener]; +} + ++ (void)removeUserJwtInvalidatedListener:(id _Nonnull)listener { + [OneSignalUserManagerImpl.sharedInstance removeUserJwtInvalidatedListener:listener]; +} + ++ (void)updateUserJwt:(NSString * _Nonnull)externalId withToken:(NSString * _Nonnull)token { + [OneSignalUserManagerImpl.sharedInstance updateUserJwtWithExternalId:externalId token:token]; +} + + (void)logout { [OneSignalUserManagerImpl.sharedInstance logout]; } diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignalFramework.h b/iOS_SDK/OneSignalSDK/Source/OneSignalFramework.h index 4fc3ce899..3b6972122 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignalFramework.h +++ b/iOS_SDK/OneSignalSDK/Source/OneSignalFramework.h @@ -52,6 +52,7 @@ // Forward declarations for Objective-C++ compatibility @protocol OSUser; @protocol OSLiveActivities; +@protocol OSUserJwtInvalidatedListener; #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wstrict-prototypes" @@ -71,6 +72,10 @@ 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:)); ++ (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 +NS_SWIFT_NAME(updateUserJwt(externalId:token:)); + (void)logout; #pragma mark Notifications diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignalSwiftInterface.swift b/iOS_SDK/OneSignalSDK/Source/OneSignalSwiftInterface.swift index bf399005c..756ec78ac 100644 --- a/iOS_SDK/OneSignalSDK/Source/OneSignalSwiftInterface.swift +++ b/iOS_SDK/OneSignalSDK/Source/OneSignalSwiftInterface.swift @@ -33,6 +33,14 @@ import OneSignalNotifications import OneSignalCore public extension OneSignal { + static func addUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) { + __add(listener) + } + + static func removeUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) { + __remove(listener) + } + static var User: OSUser { return __user() } From b4e8aff33886c1955848fef4ec07ee4392bbf2cf Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 10 Sep 2026 07:25:33 -0700 Subject: [PATCH 03/25] feat: [JWT-5] bind each Delta to the user that owns it (#1710) Co-authored-by: Cursor --- .../OneSignal.xcodeproj/project.pbxproj | 8 + .../OneSignalOSCore/Source/OSDelta.swift | 7 +- .../Source/OSModelStoreListener.swift | 11 +- .../OneSignalOSCoreTests/OSDeltaTests.swift | 74 ++++++ .../OSOperationRepoFlushTests.swift | 1 + .../Source/OSIdentityModelStoreListener.swift | 10 +- .../OSPropertiesModelStoreListener.swift | 14 +- .../OSSubscriptionModelStoreListener.swift | 26 +- .../Source/OneSignalUserManagerImpl.swift | 14 +- .../DeltaOwnershipTests.swift | 250 ++++++++++++++++++ .../OSCustomEventsExecutorTests.swift | 1 + .../SubscriptionUpdateRaceTests.swift | 6 + .../UserConcurrencyTests.swift | 12 +- 13 files changed, 405 insertions(+), 29 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeltaTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 87c4f680c..897f0a9a1 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -299,6 +299,7 @@ 475F474A2B8E3B4600EC05B3 /* OneSignalLiveActivities.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 475F471E2B8E398D00EC05B3 /* OneSignalLiveActivities.framework */; platformFilter = ios; }; 475F474F2B8E3B5400EC05B3 /* OneSignalLiveActivities.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 475F471E2B8E398D00EC05B3 /* OneSignalLiveActivities.framework */; }; 475F47502B8E3B5400EC05B3 /* OneSignalLiveActivities.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 475F471E2B8E398D00EC05B3 /* OneSignalLiveActivities.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 4E8E880086C66B4120CC0CD4 /* OSDeltaTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 557653D2007BFF86EA8342E4 /* OSDeltaTests.swift */; }; 5B053FBC2CAE07EB002F30C4 /* OneSignalOSCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C115161289A259500565C41 /* OneSignalOSCore.framework */; }; 5B053FC32CAE0843002F30C4 /* OSConsistencyManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC1DE672C90C23E00CA8807 /* OSConsistencyManagerTests.swift */; }; 5B58E4F8237CE7B4009401E0 /* UIDeviceOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 5B58E4F6237CE7B4009401E0 /* UIDeviceOverrider.m */; }; @@ -383,6 +384,7 @@ 91F60F7D1E80E4E400706E60 /* UncaughtExceptionHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = 91F60F7C1E80E4E400706E60 /* UncaughtExceptionHandler.m */; }; 9224347AAE3E092B5743380D /* UserJwtApiTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80DC5517E6EB5B26CF980CC5 /* UserJwtApiTests.swift */; }; 94D9F5AFE86B16453A36C9A2 /* MockUserJwtInvalidatedListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F214EE6C5FE133672D6622F /* MockUserJwtInvalidatedListener.swift */; }; + 99B1615D0132AFAA981A9AD3 /* DeltaOwnershipTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3881024646E7F0DE05158442 /* DeltaOwnershipTests.swift */; }; 9D3300FA23149DAE000F0A83 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9D3300F923149DAE000F0A83 /* CoreGraphics.framework */; }; 9D348537233C669E00EB81C9 /* CoreLocation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9D348536233C669E00EB81C9 /* CoreLocation.framework */; }; 9D34853A233D2E3600EB81C9 /* OneSignalLocationOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 9D348539233D2E3600EB81C9 /* OneSignalLocationOverrider.m */; }; @@ -1376,6 +1378,7 @@ 2F2FC6C922FF8104F3197DD4 /* OSUserJwtInvalidatedEvent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtInvalidatedEvent.swift; sourceTree = ""; }; 37747F9319147D6500558FAD /* libOneSignal.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libOneSignal.a; sourceTree = BUILT_PRODUCTS_DIR; }; 37E6B2BA19D9CAF300D0C601 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; + 3881024646E7F0DE05158442 /* DeltaOwnershipTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeltaOwnershipTests.swift; sourceTree = ""; }; 3C01518E2C2E298E0079E076 /* OneSignalInAppMessagesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OneSignalInAppMessagesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3C01519B2C2E29F90079E076 /* IAMRequestTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = IAMRequestTests.m; sourceTree = ""; }; 3C0EF49D28A1DBCB00E5434B /* OSUserInternalImpl.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSUserInternalImpl.swift; sourceTree = ""; }; @@ -1560,6 +1563,7 @@ 475F47202B8E398E00EC05B3 /* OneSignalLiveActivities.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalLiveActivities.h; sourceTree = ""; }; 475F47482B8E3A4400EC05B3 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "OneSignalUserManagerImpl+Jwt.swift"; sourceTree = ""; }; + 557653D2007BFF86EA8342E4 /* OSDeltaTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSDeltaTests.swift; sourceTree = ""; }; 5953656ACCC21358BC0CF2F0 /* OSIdentityModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityModelTests.swift; sourceTree = ""; }; 5B053FB82CAE07EB002F30C4 /* OneSignalOSCoreTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OneSignalOSCoreTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5B58E4F3237CE7B3009401E0 /* UIDeviceOverrider.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = UIDeviceOverrider.h; sourceTree = ""; }; @@ -2488,6 +2492,7 @@ 5953656ACCC21358BC0CF2F0 /* OSIdentityModelTests.swift */, B5809B47EB4F2478099697CE /* OSUserJwtRepoTests.swift */, 80DC5517E6EB5B26CF980CC5 /* UserJwtApiTests.swift */, + 3881024646E7F0DE05158442 /* DeltaOwnershipTests.swift */, ); path = OneSignalUserTests; sourceTree = ""; @@ -2659,6 +2664,7 @@ 3C23A21C2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift */, FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */, 5B47CE0CE255AC7128442FFF /* Jwt */, + 557653D2007BFF86EA8342E4 /* OSDeltaTests.swift */, ); path = OneSignalOSCoreTests; sourceTree = ""; @@ -4657,6 +4663,7 @@ B5FBED8247288744EB484CB5 /* OSIdentityModelTests.swift in Sources */, 2DB99C76F3532383C3B81D09 /* OSUserJwtRepoTests.swift in Sources */, 9224347AAE3E092B5743380D /* UserJwtApiTests.swift in Sources */, + 99B1615D0132AFAA981A9AD3 /* DeltaOwnershipTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4739,6 +4746,7 @@ DD2A89A8052E2D1912B0038B /* OSIamFetchReadyConditionTests.swift in Sources */, 9FF50E2A40C88E4533033A38 /* OSIdentityVerificationServiceTests.swift in Sources */, 8E949FF4C7A7A2C7182E53EA /* OSUserJwtConfigTests.swift in Sources */, + 4E8E880086C66B4120CC0CD4 /* OSDeltaTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDelta.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDelta.swift index 9cbba04e3..6feb7eced 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDelta.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDelta.swift @@ -36,6 +36,8 @@ open class OSDelta: NSObject, NSCoding { public let deltaId: String public let timestamp: Date public let identityModelId: String + /// The `external_id` of the user this change belongs to, `nil` if that user was anonymous. + public let externalId: String? public var model: OSModel public let property: String public let value: Any @@ -44,11 +46,12 @@ open class OSDelta: NSObject, NSCoding { return "" } - public init(name: String, identityModelId: String, model: OSModel, property: String, value: Any) { + public init(name: String, identityModelId: String, externalId: String?, model: OSModel, property: String, value: Any) { self.name = name self.deltaId = UUID().uuidString self.timestamp = Date() self.identityModelId = identityModelId + self.externalId = externalId self.model = model self.property = property self.value = value @@ -59,6 +62,7 @@ open class OSDelta: NSObject, NSCoding { coder.encode(deltaId, forKey: "deltaId") coder.encode(timestamp, forKey: "timestamp") coder.encode(identityModelId, forKey: "identityModelId") + coder.encode(externalId, forKey: "externalId") coder.encode(model, forKey: "model") coder.encode(property, forKey: "property") coder.encode(value, forKey: "value") @@ -81,6 +85,7 @@ open class OSDelta: NSObject, NSCoding { self.deltaId = deltaId self.timestamp = timestamp self.identityModelId = identityModelId + self.externalId = coder.decodeObject(forKey: "externalId") as? String self.model = model self.property = property self.value = value diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSModelStoreListener.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSModelStoreListener.swift index 540dcca40..3d3c046e4 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSModelStoreListener.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSModelStoreListener.swift @@ -33,7 +33,10 @@ public protocol OSModelStoreListener: OSModelStoreChangedHandler { var store: OSModelStore { get } - init(store: OSModelStore) + // Injected: the shared enqueue below lives in OSCore, which cannot reach the User Manager. + var operationRepo: OSOperationRepo { get } + + init(store: OSModelStore, operationRepo: OSOperationRepo) func getAddModelDelta(_ model: TModel) -> OSDelta? @@ -57,13 +60,13 @@ extension OSModelStoreListener { return } if let delta = getAddModelDelta(addedModel) { - OSOperationRepo.sharedInstance.enqueueDelta(delta) + operationRepo.enqueueDelta(delta) } } public func onUpdated(_ args: OSModelChangedArgs) { if let delta = getUpdateModelDelta(args) { - OSOperationRepo.sharedInstance.enqueueDelta(delta) + operationRepo.enqueueDelta(delta) } } @@ -74,7 +77,7 @@ extension OSModelStoreListener { return } if let delta = getRemoveModelDelta(removedModel) { - OSOperationRepo.sharedInstance.enqueueDelta(delta) + operationRepo.enqueueDelta(delta) } } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeltaTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeltaTests.swift new file mode 100644 index 000000000..ea887c944 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeltaTests.swift @@ -0,0 +1,74 @@ +/* + 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 +@testable import OneSignalOSCore + +/// Covers `OSDelta` archive round trips, including the owning user's external ID. +final class OSDeltaTests: XCTestCase { + + private func makeDelta(externalId: String?) -> OSDelta { + OSDelta( + name: "test_delta", + identityModelId: "identity-model-a", + externalId: externalId, + model: OSModel(changeNotifier: OSEventProducer()), + property: "language", + value: "en" + ) + } + + private func archiveThenUnarchive(_ delta: OSDelta) throws -> OSDelta { + let data = try NSKeyedArchiver.archivedData(withRootObject: delta, requiringSecureCoding: false) + let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) + unarchiver.requiresSecureCoding = false + defer { unarchiver.finishDecoding() } + return try XCTUnwrap(unarchiver.decodeObject(forKey: NSKeyedArchiveRootObjectKey) as? OSDelta) + } + + func testExternalIdSurvivesAnArchiveRoundTrip() throws { + let delta = makeDelta(externalId: "user-a") + + let decoded = try archiveThenUnarchive(delta) + + XCTAssertEqual(decoded.externalId, "user-a") + XCTAssertEqual(decoded.identityModelId, "identity-model-a") + XCTAssertEqual(decoded.deltaId, delta.deltaId) + } + + /// Decode must succeed when externalId is absent, or queued work is dropped on upgrade. + func testADeltaWithoutAnExternalIdStillDecodes() throws { + let delta = makeDelta(externalId: nil) + + let decoded = try archiveThenUnarchive(delta) + + XCTAssertNil(decoded.externalId) + XCTAssertEqual(decoded.identityModelId, "identity-model-a") + XCTAssertEqual(decoded.property, "language") + XCTAssertEqual(decoded.value as? String, "en") + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift index fe665da6d..c35c2c783 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift @@ -111,6 +111,7 @@ final class OSOperationRepoFlushTests: XCTestCase { OSDelta( name: name, identityModelId: UUID().uuidString, + externalId: nil, model: OSModel(changeNotifier: OSEventProducer()), property: property, value: property diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelStoreListener.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelStoreListener.swift index 4edabb321..937403966 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelStoreListener.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelStoreListener.swift @@ -31,9 +31,11 @@ import OneSignalOSCore class OSIdentityModelStoreListener: OSModelStoreListener { var store: OSModelStore + let operationRepo: OSOperationRepo - required init(store: OSModelStore) { + required init(store: OSModelStore, operationRepo: OSOperationRepo) { self.store = store + self.operationRepo = operationRepo } func getAddModelDelta(_ model: OSIdentityModel) -> OSDelta? { @@ -50,6 +52,7 @@ class OSIdentityModelStoreListener: OSModelStoreListener { func getUpdateModelDelta(_ args: OSModelChangedArgs) -> OSDelta? { // TODO: Let users call addAliases with "" IDs? If so, this will change... guard + let identityModel = args.model as? OSIdentityModel, let aliasesDict = args.newValue as? [String: String], let (_, id) = aliasesDict.first else { @@ -60,8 +63,9 @@ class OSIdentityModelStoreListener: OSModelStoreListener { return OSDelta( name: name, - identityModelId: args.model.modelId, - model: args.model, + identityModelId: identityModel.modelId, + externalId: identityModel.externalId, + model: identityModel, property: args.property, value: args.newValue ) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModelStoreListener.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModelStoreListener.swift index d1cea8929..ec4d5da5b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModelStoreListener.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModelStoreListener.swift @@ -31,9 +31,11 @@ import OneSignalOSCore class OSPropertiesModelStoreListener: OSModelStoreListener { var store: OSModelStore + let operationRepo: OSOperationRepo - required init(store: OSModelStore) { + required init(store: OSModelStore, operationRepo: OSOperationRepo) { self.store = store + self.operationRepo = operationRepo } func getAddModelDelta(_ model: OSPropertiesModel) -> OSDelta? { @@ -45,15 +47,19 @@ class OSPropertiesModelStoreListener: OSModelStoreListener { } func getUpdateModelDelta(_ args: OSModelChangedArgs) -> OSDelta? { + // Drop if this isn't the current user's properties model, the user has switched since guard let _ = OSPropertiesSupportedProperty(rawValue: args.property), - let userInstance = OneSignalUserManagerImpl.sharedInstance._user + let userInstance = OneSignalUserManagerImpl.sharedInstance._user, + userInstance.propertiesModel.modelId == args.model.modelId else { - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSPropertiesModelStoreListener.getUpdateModelDelta encountered unsupported property: \(args.property) or no user instance") + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSPropertiesModelStoreListener.getUpdateModelDelta encountered unsupported property: \(args.property), no user instance, or a properties model that is not the current user's") return nil } + let identityModel = userInstance.identityModel return OSDelta( name: OS_UPDATE_PROPERTIES_DELTA, - identityModelId: userInstance.identityModel.modelId, + identityModelId: identityModel.modelId, + externalId: identityModel.externalId, model: args.model, property: args.property, value: args.newValue diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModelStoreListener.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModelStoreListener.swift index a51afc244..4d8224442 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModelStoreListener.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModelStoreListener.swift @@ -31,19 +31,26 @@ import OneSignalOSCore class OSSubscriptionModelStoreListener: OSModelStoreListener { var store: OSModelStore + let operationRepo: OSOperationRepo - required init(store: OSModelStore) { + required init(store: OSModelStore, operationRepo: OSOperationRepo) { self.store = store + self.operationRepo = operationRepo } func getAddModelDelta(_ model: OSSubscriptionModel) -> OSDelta? { - guard let userInstance = OneSignalUserManagerImpl.sharedInstance._user else { - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionModelStoreListener.getAddModelDelta has no user instance") + // Stale - drop if the model is no longer in this store, the user has switched since + guard let userInstance = OneSignalUserManagerImpl.sharedInstance._user, + store.getModel(modelId: model.modelId) != nil + else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionModelStoreListener.getAddModelDelta has no user instance or the model is not in the current store") return nil } + let identityModel = userInstance.identityModel return OSDelta( name: OS_ADD_SUBSCRIPTION_DELTA, - identityModelId: userInstance.identityModel.modelId, + identityModelId: identityModel.modelId, + externalId: identityModel.externalId, model: model, property: model.type.rawValue, // push, email, sms value: model.address ?? "" @@ -52,15 +59,18 @@ class OSSubscriptionModelStoreListener: OSModelStoreListener { /** The `property` and `value` is not needed for a remove operation, so just pass in some model data as placeholders. + Stamps the current user: `onRemoved` runs after the model has left the store, so membership can't be checked. */ func getRemoveModelDelta(_ model: OSSubscriptionModel) -> OSDelta? { guard let userInstance = OneSignalUserManagerImpl.sharedInstance._user else { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionModelStoreListener.getRemoveModelDelta has no user instance") return nil } + let identityModel = userInstance.identityModel return OSDelta( name: OS_REMOVE_SUBSCRIPTION_DELTA, - identityModelId: userInstance.identityModel.modelId, + identityModelId: identityModel.modelId, + externalId: identityModel.externalId, model: model, property: model.type.rawValue, // push, email, sms value: model.address ?? "" @@ -78,14 +88,16 @@ class OSSubscriptionModelStoreListener: OSModelStoreListener { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionModelStoreListener.getUpdateModelDelta has no user instance") return nil } - if let onesignalId = userInstance.identityModel.onesignalId { + let identityModel = userInstance.identityModel + if let onesignalId = identityModel.onesignalId { let condition = OSIamFetchReadyCondition.sharedInstance(withId: onesignalId) condition.setSubscriptionUpdatePending(value: true) } return OSDelta( name: OS_UPDATE_SUBSCRIPTION_DELTA, - identityModelId: userInstance.identityModel.modelId, + identityModelId: identityModel.modelId, + externalId: identityModel.externalId, model: args.model, property: args.property, value: args.newValue diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index d991b31a7..7cee71be5 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -128,6 +128,10 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { let newRecordsState = OSNewRecordsState() + // Injected into the model store listeners so a Delta is enqueued against a known repo rather + // than reaching for the singleton. A later PR replaces this with an owned instance. + let operationRepo = OSOperationRepo.sharedInstance + // Hydrated before this class is started, jwtConfig by remote params and featureManager by // OSFeatureFlagsRefreshService, so a fresh instance here would read none of it. let featureManager = OSFeatureManager.shared @@ -223,10 +227,10 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { } self.identityVerificationService = identityVerificationService self.userJwtRepo = userJwtRepo - self.identityModelStoreListener = OSIdentityModelStoreListener(store: identityModelStore) - self.propertiesModelStoreListener = OSPropertiesModelStoreListener(store: propertiesModelStore) - self.subscriptionModelStoreListener = OSSubscriptionModelStoreListener(store: subscriptionModelStore) - self.pushSubscriptionModelStoreListener = OSSubscriptionModelStoreListener(store: pushSubscriptionModelStore) + self.identityModelStoreListener = OSIdentityModelStoreListener(store: identityModelStore, operationRepo: operationRepo) + self.propertiesModelStoreListener = OSPropertiesModelStoreListener(store: propertiesModelStore, operationRepo: operationRepo) + self.subscriptionModelStoreListener = OSSubscriptionModelStoreListener(store: subscriptionModelStore, operationRepo: operationRepo) + self.pushSubscriptionModelStoreListener = OSSubscriptionModelStoreListener(store: pushSubscriptionModelStore, operationRepo: operationRepo) self.pushSubscriptionImpl = OSPushSubscriptionImpl(pushSubscriptionModelStore: pushSubscriptionModelStore) } @@ -662,6 +666,7 @@ extension OneSignalUserManagerImpl { let delta = OSDelta( name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, + externalId: identityModel.externalId, model: propertiesModel, property: property.rawValue, value: value @@ -886,6 +891,7 @@ extension OneSignalUserManagerImpl: OSUser { let delta = OSDelta( name: OS_CUSTOM_EVENT_DELTA, identityModelId: identityModel.modelId, + externalId: identityModel.externalId, model: identityModel, property: name, value: processedProperties diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift new file mode 100644 index 000000000..0f8cbbdad --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift @@ -0,0 +1,250 @@ +/* + 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 OneSignalUserMocks +@testable import OneSignalOSCore +@testable import OneSignalUser + +/// Deltas and the requests built from them stay bound to the user whose change produced them. +final class DeltaOwnershipTests: XCTestCase { + + private let userA = "user-a" + private let userB = "user-b" + private let emailAddress = "person@example.com" + + private var manager: OneSignalUserManagerImpl { + return OneSignalUserManagerImpl.sharedInstance + } + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + OneSignalCoreImpl.setSharedClient(MockOneSignalClient()) + // These tests drive the listeners and executors themselves, so keep start() from + // rebuilding the user underneath them. + manager.hasCalledStart = true + // Deltas have to stay in the repo queue long enough to be inspected. + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true + } + + override func tearDownWithError() throws { + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = false + OneSignalCoreMocks.clearUserDefaults() + } + + // MARK: - Model store listeners + + func testPropertiesUpdateDeltaCarriesTheOwningUsersExternalId() throws { + let user = newUser(externalId: userA) + + let delta = try XCTUnwrap(manager.propertiesModelStoreListener.getUpdateModelDelta( + OSModelChangedArgs(model: user.propertiesModel, property: "tags", newValue: ["tag": "value"]) + )) + + XCTAssertEqual(delta.externalId, userA) + XCTAssertEqual(delta.identityModelId, user.identityModel.modelId) + } + + /// Anonymous work must stay stamped nil; filling it in later would attribute it to the wrong user. + func testPropertiesUpdateDeltaFromAnAnonymousUserHasNoExternalId() throws { + let user = newUser(externalId: nil) + + let delta = try XCTUnwrap(manager.propertiesModelStoreListener.getUpdateModelDelta( + OSModelChangedArgs(model: user.propertiesModel, property: "tags", newValue: ["tag": "value"]) + )) + + XCTAssertNil(delta.externalId) + XCTAssertEqual(delta.identityModelId, user.identityModel.modelId) + } + + func testAliasDeltaCarriesTheOwningUsersExternalId() throws { + let user = newUser(externalId: userA) + + let delta = try XCTUnwrap(manager.identityModelStoreListener.getUpdateModelDelta( + OSModelChangedArgs(model: user.identityModel, property: "aliases", newValue: ["my_alias": "my-alias-id"]) + )) + + XCTAssertEqual(delta.name, OS_ADD_ALIAS_DELTA) + XCTAssertEqual(delta.externalId, userA) + XCTAssertEqual(delta.identityModelId, user.identityModel.modelId) + } + + /// Stamped from the changed Identity Model, so a post-switch alias change keeps its owner. + func testAliasDeltaIsStampedFromTheChangedIdentityNotTheCurrentUser() throws { + let first = newUser(externalId: userA) + newUser(externalId: userB) + + let delta = try XCTUnwrap(manager.identityModelStoreListener.getUpdateModelDelta( + OSModelChangedArgs(model: first.identityModel, property: "aliases", newValue: ["my_alias": "my-alias-id"]) + )) + + XCTAssertEqual(delta.externalId, userA) + XCTAssertEqual(delta.identityModelId, first.identityModel.modelId) + } + + /// Stale properties model after a switch: drop rather than stamp the new current user. + func testPropertiesUpdateDeltaIsDroppedWhenTheChangedModelIsNotTheCurrentUser() throws { + let first = newUser(externalId: userA) + newUser(externalId: userB) + + let delta = manager.propertiesModelStoreListener.getUpdateModelDelta( + OSModelChangedArgs(model: first.propertiesModel, property: "tags", newValue: ["tag": "value"]) + ) + + XCTAssertNil(delta) + } + + func testSubscriptionAddDeltaCarriesTheOwningUsersExternalId() throws { + let user = newUser(externalId: userA) + let model = emailSubscriptionModel() + manager.subscriptionModelStore.add(id: emailAddress, model: model, hydrating: true) + + let delta = try XCTUnwrap(manager.subscriptionModelStoreListener.getAddModelDelta(model)) + + XCTAssertEqual(delta.externalId, userA) + XCTAssertEqual(delta.identityModelId, user.identityModel.modelId) + } + + func testSubscriptionRemoveDeltaCarriesTheOwningUsersExternalId() throws { + let user = newUser(externalId: userA) + + let delta = try XCTUnwrap(manager.subscriptionModelStoreListener.getRemoveModelDelta(emailSubscriptionModel())) + + XCTAssertEqual(delta.externalId, userA) + XCTAssertEqual(delta.identityModelId, user.identityModel.modelId) + } + + func testSubscriptionUpdateDeltaCarriesTheOwningUsersExternalId() throws { + let user = newUser(externalId: userA) + + let delta = try XCTUnwrap(manager.subscriptionModelStoreListener.getUpdateModelDelta( + OSModelChangedArgs(model: emailSubscriptionModel(), property: "enabled", newValue: true) + )) + + XCTAssertEqual(delta.externalId, userA) + XCTAssertEqual(delta.identityModelId, user.identityModel.modelId) + } + + /// Email/SMS models are cleared on switch but stay subscribed; refuse to stamp the new user. + func testSubscriptionAddDeltaIsDroppedWhenTheChangedModelIsNotInTheCurrentStore() throws { + newUser(externalId: userA) + let model = emailSubscriptionModel() + manager.subscriptionModelStore.add(id: emailAddress, model: model, hydrating: true) + // What makes the model stale is the store clear, which setNewInternalUser does not do. + manager.clearAllModelsFromStores() + newUser(externalId: userB) + + XCTAssertNil(manager.subscriptionModelStoreListener.getAddModelDelta(model)) + } + + // MARK: - Deltas the User Manager enqueues itself + + /// Session time and custom events skip the model stores, so they are stamped at enqueue. + func testDeltasEnqueuedByTheUserManagerCarryTheOwningUsersExternalId() throws { + let user = newUser(externalId: userA) + + manager.sendSessionTime(100) + manager.trackEvent(name: "test_event", properties: nil) + OneSignalCoreMocks.waitUntil("Session time and custom event Deltas were not enqueued") { + self.queuedDelta( + named: OS_UPDATE_PROPERTIES_DELTA, + property: OSPropertiesSupportedProperty.session_time.rawValue + ) != nil + && self.queuedDelta(named: OS_CUSTOM_EVENT_DELTA, property: "test_event") != nil + } + + let sessionDelta = try XCTUnwrap(queuedDelta( + named: OS_UPDATE_PROPERTIES_DELTA, + property: OSPropertiesSupportedProperty.session_time.rawValue + )) + let eventDelta = try XCTUnwrap(queuedDelta(named: OS_CUSTOM_EVENT_DELTA, property: "test_event")) + + XCTAssertEqual(sessionDelta.externalId, userA) + XCTAssertEqual(sessionDelta.identityModelId, user.identityModel.modelId) + XCTAssertEqual(eventDelta.externalId, userA) + XCTAssertEqual(eventDelta.identityModelId, user.identityModel.modelId) + } + + /// A queued Delta is already owned; a user switch must not rewrite it. + func testAQueuedDeltaKeepsItsOwnerAfterTheCurrentUserChanges() throws { + let first = newUser(externalId: userA) + + manager.sendSessionTime(100) + // The Delta has to be queued before the switch, or the test is not exercising anything. + OneSignalCoreMocks.waitUntil("Session time Delta was not enqueued") { + self.queuedDelta( + named: OS_UPDATE_PROPERTIES_DELTA, + property: OSPropertiesSupportedProperty.session_time.rawValue + ) != nil + } + newUser(externalId: userB) + + let delta = try XCTUnwrap(queuedDelta( + named: OS_UPDATE_PROPERTIES_DELTA, + property: OSPropertiesSupportedProperty.session_time.rawValue + )) + + XCTAssertEqual(delta.externalId, userA) + XCTAssertEqual(delta.identityModelId, first.identityModel.modelId) + } + + // MARK: - Helpers + + @discardableResult + private func newUser(externalId: String?) -> OSUserInternal { + return manager.setNewInternalUser( + externalId: externalId, + pushSubscriptionModel: OSSubscriptionModel( + type: .push, + address: "", + subscriptionId: UUID().uuidString, + reachable: false, + isDisabled: false, + changeNotifier: OSEventProducer() + ) + ) + } + + private func emailSubscriptionModel() -> OSSubscriptionModel { + return OSSubscriptionModel( + type: .email, + address: emailAddress, + subscriptionId: "test-subscription-id", + reachable: true, + isDisabled: false, + changeNotifier: OSEventProducer() + ) + } + + private func queuedDelta(named name: String, property: String) -> OSDelta? { + return OneSignalUserManagerImpl.sharedInstance.operationRepo.deltaQueue.first { $0.name == name && $0.property == property } + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift index dedab9808..eada431f9 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift @@ -53,6 +53,7 @@ final class OSCustomEventsExecutorTests: XCTestCase { return OSDelta( name: OS_CUSTOM_EVENT_DELTA, identityModelId: identityModel.modelId, + externalId: identityModel.externalId, model: identityModel, property: name, value: properties ?? [:] diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift index 0517b6d8b..c5e98c78e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift @@ -93,6 +93,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { executor.enqueueDelta(OSDelta( name: OS_UPDATE_SUBSCRIPTION_DELTA, identityModelId: identityModelId, + externalId: nil, model: model, property: "notificationTypes", value: promptedNeverAnswered @@ -116,6 +117,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { executor.enqueueDelta(OSDelta( name: OS_UPDATE_SUBSCRIPTION_DELTA, identityModelId: identityModelId, + externalId: nil, model: model, property: "notificationTypes", value: subscribedNotificationTypes @@ -159,6 +161,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { executor.enqueueDelta(OSDelta( name: OS_UPDATE_SUBSCRIPTION_DELTA, identityModelId: identityModelId, + externalId: nil, model: model, property: "notificationTypes", value: promptedNeverAnswered @@ -182,6 +185,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { executor.enqueueDelta(OSDelta( name: OS_UPDATE_SUBSCRIPTION_DELTA, identityModelId: identityModelId, + externalId: nil, model: model, property: "notificationTypes", value: subscribedNotificationTypes @@ -231,6 +235,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { executor.enqueueDelta(OSDelta( name: OS_UPDATE_SUBSCRIPTION_DELTA, identityModelId: identityModelId, + externalId: nil, model: model, property: "notificationTypes", value: promptedNeverAnswered @@ -250,6 +255,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { executor.enqueueDelta(OSDelta( name: OS_UPDATE_SUBSCRIPTION_DELTA, identityModelId: identityModelId, + externalId: nil, model: model, property: "notificationTypes", value: subscribedNotificationTypes diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift index c3835f051..5bf4466ab 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift @@ -99,8 +99,8 @@ final class UserConcurrencyTests: XCTestCase { DispatchQueue.concurrentPerform(iterations: 50) { _ in // 1. Enqueue Remove Subscription Deltas to the Operation Repo - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: UUID().uuidString, model: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: "email", value: "email")) - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: UUID().uuidString, model: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: "email", value: "email")) + OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: "email", value: "email")) + OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: "email", value: "email")) // 2. Flush Operation Repo OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() @@ -139,8 +139,8 @@ final class UserConcurrencyTests: XCTestCase { DispatchQueue.concurrentPerform(iterations: 50) { _ in // 1. Enqueue Add Alias Deltas to the Operation Repo - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: UUID().uuidString, model: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), property: "aliases", value: aliases)) - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: UUID().uuidString, model: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), property: "aliases", value: aliases)) + OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), property: "aliases", value: aliases)) + OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), property: "aliases", value: aliases)) // 2. Flush Operation Repo OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() @@ -181,8 +181,8 @@ final class UserConcurrencyTests: XCTestCase { DispatchQueue.concurrentPerform(iterations: 50) { _ in // 1. Enqueue Deltas to the Operation Repo - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "language", value: UUID().uuidString)) - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "language", value: UUID().uuidString)) + OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, externalId: identityModel.externalId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "language", value: UUID().uuidString)) + OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, externalId: identityModel.externalId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "language", value: UUID().uuidString)) // 2. Flush Operation Repo OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() From addf79a7c9432518fa556aa4fcf652fb4ffa54d8 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 10 Sep 2026 07:28:40 -0700 Subject: [PATCH 04/25] feat: [JWT-6] Identity Verification for the request pipeline (#1711) Co-authored-by: Cursor --- .../OneSignal.xcodeproj/project.pbxproj | 44 ++ .../MockOneSignalClient.swift | 4 + .../Source/Jwt/OSAliasPair.swift | 40 ++ .../Source/OSOperationExecutor.swift | 6 + .../Source/OSOperationRepo.swift | 190 +++++-- .../OneSignalOSCore/Source/OSUrlPath.swift | 47 ++ .../MockNewRecordsState.swift | 14 + .../OneSignalOSCoreMocks/OSCoreMocks.swift | 18 +- .../OSOperationRepoFlushTests.swift | 90 +-- ...erationRepoIdentityVerificationTests.swift | 270 +++++++++ .../OSOperationRepoTestSupport.swift | 108 ++++ .../Executors/OSCustomEventsExecutor.swift | 48 +- .../OSIdentityOperationExecutor.swift | 53 +- .../OSPropertyOperationExecutor.swift | 39 +- .../OSSubscriptionOperationExecutor.swift | 101 +++- .../Source/Executors/OSUserExecutor.swift | 190 +++++-- .../Source/OSPushSubscriptionImpl.swift | 103 ++++ .../OneSignalUser/Source/OSRequestAuth.swift | 256 +++++++++ .../Source/OSSubscriptionModel.swift | 63 +- .../Source/OneSignalUserManagerImpl+Jwt.swift | 32 ++ .../Source/OneSignalUserManagerImpl.swift | 194 +++---- .../Source/Requests/OSRequestAddAliases.swift | 17 +- .../OSRequestCreateSubscription.swift | 17 +- .../Source/Requests/OSRequestCreateUser.swift | 28 +- .../Requests/OSRequestCustomEvents.swift | 29 +- .../OSRequestDeleteSubscription.swift | 19 +- ...OSRequestFetchIdentityBySubscription.swift | 6 +- .../Source/Requests/OSRequestFetchUser.swift | 12 +- .../Requests/OSRequestIdentifyUser.swift | 22 +- .../Requests/OSRequestRemoveAlias.swift | 18 +- .../OSRequestTransferSubscription.swift | 4 +- .../Requests/OSRequestUpdateProperties.swift | 18 +- .../OSRequestUpdateSubscription.swift | 35 +- .../Source/Requests/OSUserRequest.swift | 49 +- .../OneSignalUserMocks.swift | 5 +- .../CustomEventsIntegrationTests.swift | 12 +- .../DeltaOwnershipTests.swift | 53 ++ .../ExecutorAnonymousPurgeTests.swift | 427 ++++++++++++++ .../OSCustomEventsExecutorTests.swift | 2 +- .../SubscriptionUpdateRaceTests.swift | 10 +- .../Executors/UserExecutorTests.swift | 312 +++++++++- .../OSRequestAuthTests.swift | 364 ++++++++++++ .../OneSignalUserTests.swift | 32 +- .../RequestPathEncodingTests.swift | 125 ++++ .../SwitchUserIntegrationTests.swift | 16 +- .../UserConcurrencyTests.swift | 59 +- .../UserJwtLifecycleTests.swift | 536 ++++++++++++++++++ .../XCTestCase+AsyncWork.swift | 46 ++ 48 files changed, 3783 insertions(+), 400 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSAliasPair.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSUrlPath.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoIdentityVerificationTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPushSubscriptionImpl.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/OSRequestAuthTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/RequestPathEncodingTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtLifecycleTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/XCTestCase+AsyncWork.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 897f0a9a1..80cd69da4 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -52,8 +52,12 @@ 03CCCC852835F291004BF794 /* UIApplicationDelegateSwizzlingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 03CCCC842835F291004BF794 /* UIApplicationDelegateSwizzlingTests.m */; }; 03E56DD328405F4A006AA1DA /* OneSignalAppDelegateOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 03E56DD228405F4A006AA1DA /* OneSignalAppDelegateOverrider.m */; }; 16664C4C25DDB195003B8A14 /* NSTimeZoneOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 16664C4B25DDB195003B8A14 /* NSTimeZoneOverrider.m */; }; + 1B7E5A0AEB23050C398E6111 /* ExecutorAnonymousPurgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE740F9E6B87D215510B5982 /* ExecutorAnonymousPurgeTests.swift */; }; + 23D66BEB40CE76DFF89744A3 /* RequestPathEncodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3016921C1F6B7B7793F67567 /* RequestPathEncodingTests.swift */; }; 257E219608960B8545199057 /* OneSignalUserManagerImpl+Jwt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */; }; 2DB99C76F3532383C3B81D09 /* OSUserJwtRepoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5809B47EB4F2478099697CE /* OSUserJwtRepoTests.swift */; }; + 322B62F85070DCB5C5599D6D /* OSOperationRepoIdentityVerificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFBB451F692A15639A1A08AE /* OSOperationRepoIdentityVerificationTests.swift */; }; + 32601EF1960CD92605D1ABF9 /* OSRequestAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BF72AAEB5284C97B864A1A8 /* OSRequestAuth.swift */; }; 37E6B2BB19D9CAF300D0C601 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37E6B2BA19D9CAF300D0C601 /* UIKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 3C0151922C2E298F0079E076 /* OneSignalInAppMessages.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DEBAAE282A4211D900BF2C1C /* OneSignalInAppMessages.framework */; }; 3C01519C2C2E29F90079E076 /* IAMRequestTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3C01519B2C2E29F90079E076 /* IAMRequestTests.m */; }; @@ -351,6 +355,8 @@ 7AFE856B2368DDB80091D6A5 /* OSFocusCallParams.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFE856A2368DDB80091D6A5 /* OSFocusCallParams.m */; }; 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 */; }; + 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 */; }; 911E2CC51E398B53003112A4 /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3E08E2701D49A5C8002176DE /* SystemConfiguration.framework */; }; @@ -383,6 +389,7 @@ 91C7725E1E7CCE1000D612D0 /* OneSignalInternal.h in Headers */ = {isa = PBXBuildFile; fileRef = 91C7725D1E7CCE1000D612D0 /* OneSignalInternal.h */; }; 91F60F7D1E80E4E400706E60 /* UncaughtExceptionHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = 91F60F7C1E80E4E400706E60 /* UncaughtExceptionHandler.m */; }; 9224347AAE3E092B5743380D /* UserJwtApiTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80DC5517E6EB5B26CF980CC5 /* UserJwtApiTests.swift */; }; + AE95DD5B72F1D102F6D6062A /* XCTestCase+AsyncWork.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54FC986DDF6546AB251EA0A7 /* XCTestCase+AsyncWork.swift */; }; 94D9F5AFE86B16453A36C9A2 /* MockUserJwtInvalidatedListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F214EE6C5FE133672D6622F /* MockUserJwtInvalidatedListener.swift */; }; 99B1615D0132AFAA981A9AD3 /* DeltaOwnershipTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3881024646E7F0DE05158442 /* DeltaOwnershipTests.swift */; }; 9D3300FA23149DAE000F0A83 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9D3300F923149DAE000F0A83 /* CoreGraphics.framework */; }; @@ -393,6 +400,8 @@ 9FF50E2A40C88E4533033A38 /* OSIdentityVerificationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4855B81F170253FB0C1749D /* OSIdentityVerificationServiceTests.swift */; }; A662399326850DDE00D52FD8 /* LanguageTest.m in Sources */ = {isa = PBXBuildFile; fileRef = A662399026850DDE00D52FD8 /* LanguageTest.m */; }; A66239952686612F00D52FD8 /* OneSignalFramework.h in Headers */ = {isa = PBXBuildFile; fileRef = 912411F01E73342200E41FD7 /* OneSignalFramework.h */; settings = {ATTRIBUTES = (Public, ); }; }; + AAFA2D46E6C5FD3D14D39F27 /* OSRequestAuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047D8F5E1095A20C9C54FD33 /* OSRequestAuthTests.swift */; }; + ABAFB38CC150EE24ED595EF7 /* OSAliasPair.swift in Sources */ = {isa = PBXBuildFile; fileRef = F83E7BF2B518EA8B0B51B276 /* OSAliasPair.swift */; }; B5FBED8247288744EB484CB5 /* OSIdentityModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5953656ACCC21358BC0CF2F0 /* OSIdentityModelTests.swift */; }; BE737361D82E74544B7A1996 /* OSUserJwtConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */; }; CA08FC871FE99BB4004C445F /* OneSignalClientOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = CA08FC831FE99BB4004C445F /* OneSignalClientOverrider.m */; }; @@ -422,9 +431,11 @@ CACBAAAA218A65AE000ACAA5 /* InAppMessagingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CACBAAA9218A65AE000ACAA5 /* InAppMessagingTests.m */; }; CACBAAAC218A662B000ACAA5 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CACBAAAB218A662B000ACAA5 /* WebKit.framework */; }; CACBAAB4218A7113000ACAA5 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CACBAAAB218A662B000ACAA5 /* WebKit.framework */; }; + D14FEB74CBF964F5BF0FD615 /* OSUrlPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8596A3E728FB90691E43BCA0 /* OSUrlPath.swift */; }; D465D9B81F58B242ADF14874 /* OSIdentityModelRepoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89DE5BB0EDD3964C20C5169F /* OSIdentityModelRepoTests.swift */; }; DAF9C81134248FCDB0C12E5B /* OSUserJwtInvalidatedEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F2FC6C922FF8104F3197DD4 /* OSUserJwtInvalidatedEvent.swift */; }; DD2A89A8052E2D1912B0038B /* OSIamFetchReadyConditionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */; }; + DDE652EF2123473B808F8CCA /* OSPushSubscriptionImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = E78603AF16454A648A96DF7B /* OSPushSubscriptionImpl.swift */; }; DE16C14424D3724700670EFA /* OneSignalLifecycleObserver.m in Sources */ = {isa = PBXBuildFile; fileRef = DE16C14324D3724700670EFA /* OneSignalLifecycleObserver.m */; }; DE16C14524D3724700670EFA /* OneSignalLifecycleObserver.m in Sources */ = {isa = PBXBuildFile; fileRef = DE16C14324D3724700670EFA /* OneSignalLifecycleObserver.m */; }; DE16C14724D3727200670EFA /* OneSignalLifecycleObserver.h in Headers */ = {isa = PBXBuildFile; fileRef = DE16C14624D3727200670EFA /* OneSignalLifecycleObserver.h */; }; @@ -1369,6 +1380,7 @@ 03CCCC842835F291004BF794 /* UIApplicationDelegateSwizzlingTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UIApplicationDelegateSwizzlingTests.m; sourceTree = ""; }; 03E56DD128405F4A006AA1DA /* OneSignalAppDelegateOverrider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalAppDelegateOverrider.h; sourceTree = ""; }; 03E56DD228405F4A006AA1DA /* OneSignalAppDelegateOverrider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalAppDelegateOverrider.m; sourceTree = ""; }; + 047D8F5E1095A20C9C54FD33 /* OSRequestAuthTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSRequestAuthTests.swift; sourceTree = ""; }; 16664C4B25DDB195003B8A14 /* NSTimeZoneOverrider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = NSTimeZoneOverrider.m; sourceTree = ""; }; 16664C5425DDB2CB003B8A14 /* NSTimeZoneOverrider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NSTimeZoneOverrider.h; sourceTree = ""; }; 1AF75EAC1E8567FD0097B315 /* NSString+OneSignal.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSString+OneSignal.h"; sourceTree = ""; }; @@ -1376,6 +1388,7 @@ 1C4EAEA1BC62D8FC57927511 /* OSIdentityVerificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityVerificationService.swift; sourceTree = ""; }; 1F214EE6C5FE133672D6622F /* MockUserJwtInvalidatedListener.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MockUserJwtInvalidatedListener.swift; sourceTree = ""; }; 2F2FC6C922FF8104F3197DD4 /* OSUserJwtInvalidatedEvent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtInvalidatedEvent.swift; sourceTree = ""; }; + 3016921C1F6B7B7793F67567 /* RequestPathEncodingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RequestPathEncodingTests.swift; sourceTree = ""; }; 37747F9319147D6500558FAD /* libOneSignal.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libOneSignal.a; sourceTree = BUILT_PRODUCTS_DIR; }; 37E6B2BA19D9CAF300D0C601 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; 3881024646E7F0DE05158442 /* DeltaOwnershipTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeltaOwnershipTests.swift; sourceTree = ""; }; @@ -1563,6 +1576,7 @@ 475F47202B8E398E00EC05B3 /* OneSignalLiveActivities.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalLiveActivities.h; sourceTree = ""; }; 475F47482B8E3A4400EC05B3 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "OneSignalUserManagerImpl+Jwt.swift"; sourceTree = ""; }; + 5221EEDBA5A74BD565490D52 /* OSOperationRepoTestSupport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSOperationRepoTestSupport.swift; sourceTree = ""; }; 557653D2007BFF86EA8342E4 /* OSDeltaTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSDeltaTests.swift; sourceTree = ""; }; 5953656ACCC21358BC0CF2F0 /* OSIdentityModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityModelTests.swift; sourceTree = ""; }; 5B053FB82CAE07EB002F30C4 /* OneSignalOSCoreTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OneSignalOSCoreTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1575,6 +1589,7 @@ 5BC1DE612C90B85A00CA8807 /* OSIamFetchOffsetKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSIamFetchOffsetKey.swift; sourceTree = ""; }; 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 = ""; }; 6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtConfig.swift; sourceTree = ""; }; 6A8BBA843AFC81A4940CF7CC /* OSUserJwtRepo.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtRepo.swift; sourceTree = ""; }; 7A123294235DFE3B002B6CE3 /* OutcomeTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OutcomeTests.m; sourceTree = ""; }; @@ -1651,6 +1666,8 @@ 7AFE856A2368DDB80091D6A5 /* OSFocusCallParams.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OSFocusCallParams.m; sourceTree = ""; }; 7AFE856E2368DDC50091D6A5 /* OSFocusCallParams.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSFocusCallParams.h; sourceTree = ""; }; 80DC5517E6EB5B26CF980CC5 /* UserJwtApiTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserJwtApiTests.swift; sourceTree = ""; }; + 54FC986DDF6546AB251EA0A7 /* XCTestCase+AsyncWork.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "XCTestCase+AsyncWork.swift"; sourceTree = ""; }; + 8596A3E728FB90691E43BCA0 /* OSUrlPath.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUrlPath.swift; sourceTree = ""; }; 89DE5BB0EDD3964C20C5169F /* OSIdentityModelRepoTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityModelRepoTests.swift; sourceTree = ""; }; 911E2CBA1E398AB3003112A4 /* UnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = UnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 911E2CBC1E398AB3003112A4 /* UnitTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UnitTests.m; sourceTree = ""; }; @@ -1675,6 +1692,7 @@ 91C7725D1E7CCE1000D612D0 /* OneSignalInternal.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OneSignalInternal.h; sourceTree = ""; }; 91F60F7B1E80E49A00706E60 /* UncaughtExceptionHandler.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = UncaughtExceptionHandler.h; sourceTree = ""; }; 91F60F7C1E80E4E400706E60 /* UncaughtExceptionHandler.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = UncaughtExceptionHandler.m; sourceTree = ""; }; + 9BF72AAEB5284C97B864A1A8 /* OSRequestAuth.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSRequestAuth.swift; sourceTree = ""; }; 9D1BD95D237663BF00A064F7 /* OSInfluenceDataDefines.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSInfluenceDataDefines.h; sourceTree = ""; }; 9D1BD95E2379E7A900A064F7 /* OSOutcomeEvent.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSOutcomeEvent.h; sourceTree = ""; }; 9D1BD95F2379E7C300A064F7 /* OSOutcomeEvent.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OSOutcomeEvent.m; sourceTree = ""; }; @@ -1906,9 +1924,13 @@ DEFB3E622BB731BD00E65DAD /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; }; DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLiveActivities.swift; sourceTree = ""; }; DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSStubLiveActivities.swift; sourceTree = ""; }; + E78603AF16454A648A96DF7B /* OSPushSubscriptionImpl.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSPushSubscriptionImpl.swift; sourceTree = ""; }; E9376A4957E9090C748BCB18 /* OSUserJwtConfigTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtConfigTests.swift; sourceTree = ""; }; F4855B81F170253FB0C1749D /* OSIdentityVerificationServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityVerificationServiceTests.swift; sourceTree = ""; }; + F83E7BF2B518EA8B0B51B276 /* OSAliasPair.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSAliasPair.swift; sourceTree = ""; }; + FE740F9E6B87D215510B5982 /* ExecutorAnonymousPurgeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExecutorAnonymousPurgeTests.swift; sourceTree = ""; }; FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIamFetchReadyConditionTests.swift; sourceTree = ""; }; + FFBB451F692A15639A1A08AE /* OSOperationRepoIdentityVerificationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSOperationRepoIdentityVerificationTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -2351,6 +2373,7 @@ DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */, DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */, A843B922174496E99F2D00A8 /* Jwt */, + 8596A3E728FB90691E43BCA0 /* OSUrlPath.swift */, ); path = Source; sourceTree = ""; @@ -2492,7 +2515,11 @@ 5953656ACCC21358BC0CF2F0 /* OSIdentityModelTests.swift */, B5809B47EB4F2478099697CE /* OSUserJwtRepoTests.swift */, 80DC5517E6EB5B26CF980CC5 /* UserJwtApiTests.swift */, + 54FC986DDF6546AB251EA0A7 /* XCTestCase+AsyncWork.swift */, 3881024646E7F0DE05158442 /* DeltaOwnershipTests.swift */, + 047D8F5E1095A20C9C54FD33 /* OSRequestAuthTests.swift */, + 3016921C1F6B7B7793F67567 /* RequestPathEncodingTests.swift */, + 5BFE2F960129386AFA6D5F41 /* UserJwtLifecycleTests.swift */, ); path = OneSignalUserTests; sourceTree = ""; @@ -2511,6 +2538,7 @@ 3CF11E3C2C6D6155002856F5 /* UserExecutorTests.swift */, 3CA93BC3300AEFFA000724B3 /* SubscriptionUpdateRaceTests.swift */, 3CB331692F281692000E1801 /* OSCustomEventsExecutorTests.swift */, + FE740F9E6B87D215510B5982 /* ExecutorAnonymousPurgeTests.swift */, ); path = Executors; sourceTree = ""; @@ -2665,6 +2693,8 @@ FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */, 5B47CE0CE255AC7128442FFF /* Jwt */, 557653D2007BFF86EA8342E4 /* OSDeltaTests.swift */, + FFBB451F692A15639A1A08AE /* OSOperationRepoIdentityVerificationTests.swift */, + 5221EEDBA5A74BD565490D52 /* OSOperationRepoTestSupport.swift */, ); path = OneSignalOSCoreTests; sourceTree = ""; @@ -2839,6 +2869,7 @@ children = ( 1C4EAEA1BC62D8FC57927511 /* OSIdentityVerificationService.swift */, 6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */, + F83E7BF2B518EA8B0B51B276 /* OSAliasPair.swift */, ); name = Jwt; path = Jwt; @@ -2884,6 +2915,8 @@ 2F2FC6C922FF8104F3197DD4 /* OSUserJwtInvalidatedEvent.swift */, 6A8BBA843AFC81A4940CF7CC /* OSUserJwtRepo.swift */, 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */, + E78603AF16454A648A96DF7B /* OSPushSubscriptionImpl.swift */, + 9BF72AAEB5284C97B864A1A8 /* OSRequestAuth.swift */, ); path = Source; sourceTree = ""; @@ -4592,6 +4625,8 @@ 3C8E6DF928A6D89E0031E48A /* OSOperationExecutor.swift in Sources */, ECD9DF65FB98056806A43541 /* OSIdentityVerificationService.swift in Sources */, BE737361D82E74544B7A1996 /* OSUserJwtConfig.swift in Sources */, + D14FEB74CBF964F5BF0FD615 /* OSUrlPath.swift in Sources */, + ABAFB38CC150EE24ED595EF7 /* OSAliasPair.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4663,7 +4698,12 @@ B5FBED8247288744EB484CB5 /* OSIdentityModelTests.swift in Sources */, 2DB99C76F3532383C3B81D09 /* OSUserJwtRepoTests.swift in Sources */, 9224347AAE3E092B5743380D /* UserJwtApiTests.swift in Sources */, + AE95DD5B72F1D102F6D6062A /* XCTestCase+AsyncWork.swift in Sources */, 99B1615D0132AFAA981A9AD3 /* DeltaOwnershipTests.swift in Sources */, + 1B7E5A0AEB23050C398E6111 /* ExecutorAnonymousPurgeTests.swift in Sources */, + AAFA2D46E6C5FD3D14D39F27 /* OSRequestAuthTests.swift in Sources */, + 23D66BEB40CE76DFF89744A3 /* RequestPathEncodingTests.swift in Sources */, + 7EB69F3B404D0AEF46EC1536 /* UserJwtLifecycleTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4747,6 +4787,8 @@ 9FF50E2A40C88E4533033A38 /* OSIdentityVerificationServiceTests.swift in Sources */, 8E949FF4C7A7A2C7182E53EA /* OSUserJwtConfigTests.swift in Sources */, 4E8E880086C66B4120CC0CD4 /* OSDeltaTests.swift in Sources */, + 322B62F85070DCB5C5599D6D /* OSOperationRepoIdentityVerificationTests.swift in Sources */, + 8D2F4893453206700BB60F85 /* OSOperationRepoTestSupport.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4874,6 +4916,8 @@ DAF9C81134248FCDB0C12E5B /* OSUserJwtInvalidatedEvent.swift in Sources */, FD1F1FCA05D555623DD53B54 /* OSUserJwtRepo.swift in Sources */, 257E219608960B8545199057 /* OneSignalUserManagerImpl+Jwt.swift in Sources */, + DDE652EF2123473B808F8CCA /* OSPushSubscriptionImpl.swift in Sources */, + 32601EF1960CD92605D1ABF9 /* OSRequestAuth.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift b/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift index 88265f72c..1b5288797 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift @@ -203,12 +203,16 @@ public class MockOneSignalClient: NSObject, IOneSignalClient { executionQueue.sync {} } + // A request has one outcome: whichever of these was called for it last. Otherwise a test could not + // override a default its setUp registered, nor let a retry succeed after the first attempt failed. public func setMockResponseForRequest(request: String, response: [String: Any]) { mockResponses[request] = response + mockFailureResponses.removeValue(forKey: request) } public func setMockFailureResponseForRequest(request: String, error: OneSignalClientError) { mockFailureResponses[request] = error + mockResponses.removeValue(forKey: request) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSAliasPair.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSAliasPair.swift new file mode 100644 index 000000000..3db01bfc6 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSAliasPair.swift @@ -0,0 +1,40 @@ +/* + 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. + */ + +/** + Requests address a user by one alias or the other depending on Identity Verification: `external_id` + when it is active, `onesignal_id` otherwise. + */ +@objc public class OSAliasPair: NSObject { + @objc public let label: String + @objc public let id: String + + public init(_ label: String, _ id: String) { + self.label = label + self.id = id + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationExecutor.swift index 4afcf0ec7..0c0a753c9 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationExecutor.swift @@ -36,4 +36,10 @@ public protocol OSOperationExecutor { func enqueueDelta(_ delta: OSDelta) func cacheDeltaQueue() func processDeltaQueue(inBackground: Bool) + + /** + Drop queued Deltas and Requests that belong to an anonymous user. Driven by `OSOperationRepo` + so the policy stays there; only the storage is per-executor. + */ + func removeOperationsWithoutExternalId() } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift index 394dc5798..d1b3a36e8 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift @@ -29,17 +29,26 @@ import Foundation import OneSignalCore /** - The OSOperationRepo is a static singleton. - OSDeltas are enqueued when model store observers observe changes to their models, and sorted to their appropriate executors. + Enqueues OSDeltas from model-store observers and routes them to executors. + + Also owns Identity Verification decisions for queued work: hold flushes until `requirement` is known, + and drop anonymous Deltas while IV is active — except push subscription updates, which stay unsigned + and have to keep flowing with or without an identified user. */ public class OSOperationRepo: NSObject { - public static let sharedInstance = OSOperationRepo() - private var hasCalledStart = false + private let identityVerificationService: OSIdentityVerificationService + + /** + Serial, and the only place `deltaQueue`, the executor registry, and the two start flags may be + touched once this instance is handed out — `init` runs before anything else can reach it. Every + private method here assumes it is already running on this queue. + Non-private so test helpers can synchronize with it. + */ + let dispatchQueue = DispatchQueue(label: "OneSignal.OSOperationRepo", target: .global()) - // The Operation Repo dispatch queue, serial. This synchronizes access to `deltaQueue` and flushing behavior. - private let dispatchQueue = DispatchQueue(label: "OneSignal.OSOperationRepo", target: .global()) + private var hasCalledStart = false + private var hasBegunObserving = false - // Maps delta names to the interfaces for the operation executors var deltasToExecutorMap: [String: OSOperationExecutor] = [:] var executors: [OSOperationExecutor] = [] var deltaQueue: [OSDelta] = [] // non-private for unit test access @@ -48,35 +57,96 @@ public class OSOperationRepo: NSObject { var pollIntervalMilliseconds = Int(POLL_INTERVAL_MS) public var paused = false + // Uncache in init so an enqueue before start cannot persist over a previous session's queue. + public init(identityVerificationService: OSIdentityVerificationService) { + self.identityVerificationService = identityVerificationService + super.init() + uncacheDeltaQueue() + } + /** - Initilize this Operation Repo. Read from the cache. Executors may not be available by this time. - If everything starts up on initialize(), order can matter, ideally not but it can. - Likely call init on this from oneSignal but exeuctors can come from diff modules. + Re-reads the cache while the in-memory queue is still empty. `init` can run during prewarm before + first unlock, when UserDefaults silently returns nothing — same gap as `OSModelStore.refresh`. */ + public func refreshIfEmpty() { + dispatchQueue.async { + guard self.deltaQueue.isEmpty else { + return + } + self.uncacheDeltaQueue() + } + } + + private func uncacheDeltaQueue() { + guard let cached = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY, defaultValue: []) as? [OSDelta] else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSOperationRepo is unable to uncache the OSDelta queue.") + return + } + deltaQueue = cached + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSOperationRepo uncached deltaQueue: \(cached)") + } + public func start() { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else { return } + dispatchQueue.async { + self.startPolling() + } + } + + /** + While `requirement` is unknown, returns without setting `hasCalledStart` so hydration can call + `start()` again once remote params answer. + */ + private func startPolling() { guard !hasCalledStart else { return } + + beginObserving() + + guard identityVerificationService.requirement != .unknown else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSOperationRepo.start() deferred until the Identity Verification requirement is known") + return + } hasCalledStart = true OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSOperationRepo calling start()") - // register as user observer + pollFlushQueue() + } + + // Subscribe ahead of the requirement gate so a never-hydrated session still hears late hydration. + private func beginObserving() { + guard !hasBegunObserving else { + return + } + hasBegunObserving = true + NotificationCenter.default.addObserver(self, selector: #selector(self.addFlushDeltaQueueToDispatchQueue), name: Notification.Name(OS_ON_USER_WILL_CHANGE), object: nil) - // Read the Deltas from cache, if any... - if let deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY, defaultValue: []) as? [OSDelta] { - self.deltaQueue = deltaQueue - OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSOperationRepo.start() with deltaQueue: \(deltaQueue)") - } else { - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSOperationRepo.start() is unable to uncache the OSDelta queue.") + + // Callback rather than a repo dependency, which would cycle. + identityVerificationService.addOnJwtConfigHydratedHandler(for: .operationRepo) { [weak self] _ in + self?.onJwtConfigHydrated() } + } - pollFlushQueue() + /** + Runs on every hydration, including an unchanged value — deferred work is waiting on it. + + Hop onto `dispatchQueue`: `hydrate` calls this from whichever thread received remote params, and a + handler registered when the requirement is already cached fires synchronously from inside + `startPolling()`, where the hop defers this until that call finishes. + */ + private func onJwtConfigHydrated() { + dispatchQueue.async { + // Flush now rather than wait out a poll interval for work held since launch. + self.startPolling() + self.flushDeltaQueue() + } } private func pollFlushQueue() { @@ -87,23 +157,26 @@ public class OSOperationRepo: NSObject { } /** - Add and start an executor. + Registers before starting rather than after: once remote params are cached, `startPolling()` fires + the hydration handler synchronously and that flush reads the registry being written here. */ public func addExecutor(_ executor: OSOperationExecutor) { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else { return } - start() - executors.append(executor) - for delta in executor.supportedDeltas { - deltasToExecutorMap[delta] = executor + dispatchQueue.async { + self.executors.append(executor) + for delta in executor.supportedDeltas { + self.deltasToExecutorMap[delta] = executor + } + self.startPolling() } } /** Enqueueing is driven by model changes and called manually by the User Manager to add session time, session count and purchase data. - + // TODO: We can make this method internal once there is no manual adding of a Delta except through stores. This can happen when session data and purchase data use the model / store / listener infrastructure. */ @@ -111,12 +184,19 @@ public class OSOperationRepo: NSObject { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else { return } - start() + self.dispatchQueue.async { + self.startPolling() + + // Drop here too so it is never persisted; flush still covers deltas restored from cache. + guard !self.shouldDropAnonymousDelta(delta, ivActive: self.shouldDropAnonymousDeltas) else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSOperationRepo dropping anonymous Delta, Identity Verification is required: \(delta)") + return + } + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSOperationRepo enqueueDelta: \(delta)") self.deltaQueue.append(delta) - // Persist the deltas (including new delta) to storage OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY, withValue: self.deltaQueue) if flush { @@ -137,6 +217,26 @@ public class OSOperationRepo: NSObject { } } + /// An anonymous Delta can never be signed, so drop it while Identity Verification is active. + private var shouldDropAnonymousDeltas: Bool { + return identityVerificationService.ivBehaviorActive + } + + /** + `OS_UPDATE_SUBSCRIPTION_DELTA` is exempt. In practice it is only ever the device's own push + subscription — nothing updates an email or SMS subscription model — and that channel exists before + any login and outlives every logout, so its token and device state have to keep flowing whether or + not a user is identified. Its endpoint is addressed by subscription ID and takes no user JWT. + + That leaves the exemption resting on the invariant that email and SMS subscriptions are only ever + added and removed, never updated. Should an update path for them appear, this has to narrow to the + push type, which the repo cannot see from here: `OSSubscriptionModel` lives in OneSignalUser, so + the Delta would have to carry the distinction the way it carries `externalId`. + */ + private func shouldDropAnonymousDelta(_ delta: OSDelta, ivActive: Bool) -> Bool { + return ivActive && delta.externalId == nil && delta.name != OS_UPDATE_SUBSCRIPTION_DELTA + } + private func flushDeltaQueue(inBackground: Bool = false) { guard !paused else { OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSOperationRepo not flushing queue due to being paused") @@ -147,29 +247,55 @@ public class OSOperationRepo: NSObject { return } + // Before the requirement gate so a first flush still registers the hydration handler. + self.startPolling() + + /* + Hold until `requirement` is known. `newCodePathsRun` / `ivBehaviorActive` both read false while + it is unknown. + */ + guard identityVerificationService.requirement != .unknown else { + let heldCount = self.deltaQueue.count + if heldCount > 0 { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSOperationRepo holding \(heldCount) Deltas until the requirement is known") + } + return + } + if inBackground { OSBackgroundTaskManager.beginBackgroundTask(OPERATION_REPO_BACKGROUND_TASK) } - self.start() - if !self.deltaQueue.isEmpty { OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSOperationRepo flushDeltaQueue in background: \(inBackground) with queue: \(self.deltaQueue)") } + // Snapshot once so every Delta in this pass sees the same gate values. + let dropAnonymous = shouldDropAnonymousDeltas + var unmatched: [OSDelta] = [] for delta in self.deltaQueue { - if let executor = self.deltasToExecutorMap[delta.name] { + if shouldDropAnonymousDelta(delta, ivActive: dropAnonymous) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSOperationRepo dropping anonymous Delta, Identity Verification is required: \(delta)") + } else if let executor = self.deltasToExecutorMap[delta.name] { executor.enqueueDelta(delta) } else { // Keep if no executor matches yet (module may not have started). unmatched.append(delta) } } - self.deltaQueue = unmatched + // Persist only when the queue changed: a no-op write before `refreshIfEmpty` can clobber a + // cache that prewarm failed to read. + if unmatched.count != self.deltaQueue.count { + self.deltaQueue = unmatched + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY, withValue: self.deltaQueue) + } - // Persist the deltas (including removed deltas) to storage after they are divvy'd up to executors. - OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY, withValue: self.deltaQueue) + if dropAnonymous { + for executor in self.executors { + executor.removeOperationsWithoutExternalId() + } + } for executor in self.executors { executor.cacheDeltaQueue() diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSUrlPath.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSUrlPath.swift new file mode 100644 index 000000000..e104d7d8d --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSUrlPath.swift @@ -0,0 +1,47 @@ +/* + 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 Foundation + +/// Shared path-segment encoding for Swift and ObjC request builders. +@objc(OSUrlPath) +public final class OSUrlPath: NSObject { + /** + Returns `value` percent-encoded for use as one path segment, or nil if it cannot be encoded. + + `urlUserAllowed` rather than `urlPathAllowed`, which leaves `/` alone: the values the SDK + interpolates into a path — `external_id`, alias labels, Live Activity types — come from the app, + and one containing a slash, `?`, `#` or `%` would otherwise reach a different endpoint than intended. + + Encode once, where the path is built. A value that has already been through this comes back with its + `%` escaped again. + */ + @objc(segment:) + public static func segment(_ value: String) -> String? { + return value.addingPercentEncoding(withAllowedCharacters: .urlUserAllowed) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/MockNewRecordsState.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/MockNewRecordsState.swift index f76aae04d..94389bac0 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/MockNewRecordsState.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/MockNewRecordsState.swift @@ -41,6 +41,13 @@ public class MockNewRecordsState: OSNewRecordsState { lock.withLock { storedRecords } } + /** + When true, an ID stays inaccessible for as long as it is present. Under TEST the post-create + delay is zero, so the production timer would otherwise release immediately and a Request would + leave the executor queue before a purge test can see it. + */ + public var holdWhilePresent = false + override public func add(_ key: String, _ overwrite: Bool = false) { let record = MockNewRecord(key: key, overwrite: overwrite) lock.withLock { @@ -50,6 +57,13 @@ public class MockNewRecordsState: OSNewRecordsState { super.add(key, overwrite) } + override public func canAccess(_ key: String) -> Bool { + if holdWhilePresent { + return !contains(key) + } + return super.canAccess(key) + } + public func get(_ key: String?) -> [MockNewRecord] { return records.filter { $0.key == key } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift index 1f8cc7a27..4dae89435 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift @@ -31,10 +31,6 @@ import OneSignalCore @objc public class OSCoreMocks: NSObject { - public static func resetOperationRepo() { - OSOperationRepo.sharedInstance.reset() - } - /// Puts the shared JWT config back to unhydrated. public static func resetSharedJwtConfig() { OSUserJwtConfig.shared.resetRequirementToUnknownForTests() @@ -48,13 +44,15 @@ public class OSCoreMocks: NSObject { extension OSOperationRepo { /** - The Operation Repo needs to reset between tests until we dependency inject the Operation Repo, - to prevent state from carrying over between tests. + Clears queue state between tests that reach the repo through the User Manager singleton. + Leaves `hasCalledStart` alone so the next `start()` does not schedule a second poller. */ - func reset() { - deltaQueue.removeAll() - executors.removeAll() - deltasToExecutorMap.removeAll() + public func reset() { + dispatchQueue.sync { + deltaQueue.removeAll() + executors.removeAll() + deltasToExecutorMap.removeAll() + } paused = false } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift index c35c2c783..2e01ca899 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift @@ -28,7 +28,7 @@ import Foundation import XCTest import OneSignalCore -import OneSignalOSCoreMocks +import OneSignalCoreMocks @testable import OneSignalOSCore /// Covers `flushDeltaQueue` routing: matched deltas go to executors and leave the repo @@ -38,57 +38,61 @@ final class OSOperationRepoFlushTests: XCTestCase { private let knownDelta = "test_known_delta" private let unknownDelta = "test_unknown_delta" + private var jwtConfig = OSUserJwtConfig() + private var repo: OSOperationRepo! + override func setUp() { super.setUp() OneSignalIdentifiers.currentAppId = "test-app-id" - OSCoreMocks.resetOperationRepo() - // Pause so the poller (started by addExecutor/start) cannot flush mid-setup. - OSOperationRepo.sharedInstance.paused = true - OSOperationRepo.sharedInstance.pollIntervalMilliseconds = 60_000 + OSOperationRepoTestEnvironment.clearCache() + + jwtConfig = OSUserJwtConfig() + // Hydrate `off` before building the repo so routing tests are not held by unknown-requirement. + jwtConfig.hydrate(requiresUserAuth: false) + repo = OSOperationRepoTestEnvironment.makeRepo(jwtConfig: jwtConfig) + + // Pause so addExecutor/start cannot flush mid-setup. + repo.paused = true + repo.pollIntervalMilliseconds = 60_000 } override func tearDown() { - OSCoreMocks.resetOperationRepo() + OSOperationRepoTestEnvironment.clearCache() super.tearDown() } func testFlush_sendsMatchedDeltasToExecutorAndClearsRepoQueue() { let executor = MockOperationExecutor(supportedDeltas: [knownDelta]) - let repo = OSOperationRepo.sharedInstance repo.addExecutor(executor) - let deltaA = makeDelta(name: knownDelta, property: "a") - let deltaB = makeDelta(name: knownDelta, property: "b") - repo.enqueueDelta(deltaA) - repo.enqueueDelta(deltaB) + repo.enqueueDelta(makeDelta(name: knownDelta, property: "a")) + repo.enqueueDelta(makeDelta(name: knownDelta, property: "b")) + OneSignalCoreMocks.waitUntil("both deltas enqueued") { self.repo.snapshotDeltaQueue().count == 2 } repo.paused = false repo.flushAndWait() XCTAssertEqual(executor.enqueued.map(\.property), ["a", "b"]) - XCTAssertTrue(repo.deltaQueue.isEmpty) + XCTAssertTrue(repo.snapshotDeltaQueue().isEmpty) } func testFlush_keepsUnmatchedDeltasInRepoQueue() { let executor = MockOperationExecutor(supportedDeltas: [knownDelta]) - let repo = OSOperationRepo.sharedInstance repo.addExecutor(executor) - let deltaA = makeDelta(name: unknownDelta, property: "a") - let deltaB = makeDelta(name: unknownDelta, property: "b") - repo.enqueueDelta(deltaA) - repo.enqueueDelta(deltaB) + repo.enqueueDelta(makeDelta(name: unknownDelta, property: "a")) + repo.enqueueDelta(makeDelta(name: unknownDelta, property: "b")) + OneSignalCoreMocks.waitUntil("both deltas enqueued") { self.repo.snapshotDeltaQueue().count == 2 } repo.paused = false repo.flushAndWait() XCTAssertTrue(executor.enqueued.isEmpty) - XCTAssertEqual(repo.deltaQueue.map(\.property), ["a", "b"]) + XCTAssertEqual(repo.snapshotDeltaQueue().map(\.property), ["a", "b"]) } func testFlush_routesMatchedAndPreservesUnmatchedOrder() { let executor = MockOperationExecutor(supportedDeltas: [knownDelta]) - let repo = OSOperationRepo.sharedInstance repo.addExecutor(executor) // Interleaved matched/unmatched: assert dispatch order and retained queue order. @@ -97,41 +101,43 @@ final class OSOperationRepoFlushTests: XCTestCase { repo.enqueueDelta(makeDelta(name: knownDelta, property: "known-2")) repo.enqueueDelta(makeDelta(name: unknownDelta, property: "unknown-2")) repo.enqueueDelta(makeDelta(name: knownDelta, property: "known-3")) + OneSignalCoreMocks.waitUntil("all deltas enqueued") { self.repo.snapshotDeltaQueue().count == 5 } repo.paused = false repo.flushAndWait() XCTAssertEqual(executor.enqueued.map(\.property), ["known-1", "known-2", "known-3"]) - XCTAssertEqual(repo.deltaQueue.map(\.property), ["unknown-1", "unknown-2"]) + XCTAssertEqual(repo.snapshotDeltaQueue().map(\.property), ["unknown-1", "unknown-2"]) } - // MARK: - Helpers + /** + Registration used to write the executor list and the name map on the caller's thread, where a + flush or another registration could tear them. Every executor added concurrently has to end up + routable. + */ + func testEveryExecutorRegisteredConcurrentlyIsRoutable() { + let names = (0..<50).map { "concurrent_delta_\($0)" } + let executors = names.map { MockOperationExecutor(supportedDeltas: [$0]) } - private func makeDelta(name: String, property: String) -> OSDelta { - OSDelta( - name: name, - identityModelId: UUID().uuidString, - externalId: nil, - model: OSModel(changeNotifier: OSEventProducer()), - property: property, - value: property - ) - } -} + DispatchQueue.concurrentPerform(iterations: executors.count) { index in + self.repo.addExecutor(executors[index]) + } -private final class MockOperationExecutor: OSOperationExecutor { - let supportedDeltas: [String] - private(set) var enqueued: [OSDelta] = [] + for name in names { + repo.enqueueDelta(makeDelta(name: name, property: name)) + } + OneSignalCoreMocks.waitUntil("all deltas enqueued") { self.repo.snapshotDeltaQueue().count == names.count } - init(supportedDeltas: [String]) { - self.supportedDeltas = supportedDeltas - } + repo.paused = false + repo.flushAndWait() - func enqueueDelta(_ delta: OSDelta) { - enqueued.append(delta) + XCTAssertTrue(repo.snapshotDeltaQueue().isEmpty) + XCTAssertEqual(executors.map { $0.enqueued.map(\.property) }, names.map { [$0] }) } - func cacheDeltaQueue() {} + // MARK: - Helpers - func processDeltaQueue(inBackground: Bool) {} + private func makeDelta(name: String, property: String) -> OSDelta { + OSOperationRepoTestEnvironment.makeDelta(name: name, externalId: nil, property: property) + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoIdentityVerificationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoIdentityVerificationTests.swift new file mode 100644 index 000000000..47ad71e0d --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoIdentityVerificationTests.swift @@ -0,0 +1,270 @@ +/* + 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 Foundation +import XCTest +import OneSignalCore +import OneSignalCoreMocks +import OneSignalKMP +@testable import OneSignalOSCore + +/// Covers Operation Repo Identity Verification: hold until `requirement` is known, drop unsigned work. +final class OSOperationRepoIdentityVerificationTests: XCTestCase { + + private let deltaName = "test_delta" + + private var jwtConfig = OSUserJwtConfig() + private var flagsStore: OSFeatureFlagsStore! + private var featureManager: OSFeatureManager! + + override func setUp() { + super.setUp() + OneSignalIdentifiers.currentAppId = "test-app-id" + OSOperationRepoTestEnvironment.clearCache() + jwtConfig = OSUserJwtConfig() + flagsStore = OSFeatureFlagsStore() + featureManager = OSFeatureManager(store: flagsStore) + } + + override func tearDown() { + OSOperationRepoTestEnvironment.clearCache() + super.tearDown() + } + + // MARK: - Deferral while the requirement is unknown + + func testNothingFlushesWhileTheRequirementIsUnknown() { + let repo = makeRepo() + let executor = MockOperationExecutor(supportedDeltas: [deltaName]) + let notProcessed = expectation(description: "processDeltaQueue is not called") + notProcessed.isInverted = true + executor.onProcessDeltaQueue = { notProcessed.fulfill() } + repo.addExecutor(executor) + + repo.enqueueDelta(makeDelta(externalId: "user-1", property: "a")) + repo.enqueueDelta(makeDelta(externalId: "user-1", property: "b")) + OneSignalCoreMocks.waitUntil("both deltas enqueued") { repo.snapshotDeltaQueue().count == 2 } + + repo.addFlushDeltaQueueToDispatchQueue() + + wait(for: [notProcessed], timeout: 0.5) + XCTAssertTrue(executor.enqueued.isEmpty) + XCTAssertEqual(repo.snapshotDeltaQueue().map(\.property), ["a", "b"]) + } + + /// Enqueue still persists while unknown; only flush waits. + func testDeltasEnqueuedWhileTheRequirementIsUnknownArePersisted() { + let repo = makeRepo() + repo.enqueueDelta(makeDelta(externalId: "user-1", property: "a")) + OneSignalCoreMocks.waitUntil("delta enqueued") { repo.snapshotDeltaQueue().count == 1 } + + let cached = OSOperationRepoTestEnvironment.cachedDeltaQueue() + XCTAssertEqual(cached?.map(\.property), ["a"]) + } + + /// Hydration must flush immediately rather than wait out a poll interval. + func testHydratingTheRequirementReleasesHeldDeltasImmediately() { + let repo = makeRepo() + let executor = MockOperationExecutor(supportedDeltas: [deltaName]) + let processed = flushExpectation(on: executor) + repo.addExecutor(executor) + + repo.enqueueDelta(makeDelta(externalId: "user-1", property: "a")) + OneSignalCoreMocks.waitUntil("delta enqueued") { repo.snapshotDeltaQueue().count == 1 } + + jwtConfig.hydrate(requiresUserAuth: false) + + wait(for: [processed], timeout: 2.0) + XCTAssertEqual(executor.enqueued.map(\.property), ["a"]) + XCTAssertTrue(repo.snapshotDeltaQueue().isEmpty) + } + + // MARK: - Anonymous suppression + + /// No `externalId` means nothing to sign with, so drop at enqueue while IV is required. + func testAnonymousDeltasAreDroppedAtEnqueueWhileIdentityVerificationIsRequired() { + // Hydrate first and pause so this asserts the enqueue drop, not a flush. + jwtConfig.hydrate(requiresUserAuth: true) + let repo = makeRepo() + repo.paused = true + + repo.enqueueDelta(makeDelta(externalId: nil, property: "anonymous")) + repo.enqueueDelta(makeDelta(externalId: "user-1", property: "identified")) + + // Identified Delta is the sync point; the queue is serial. + OneSignalCoreMocks.waitUntil("identified delta enqueued") { repo.snapshotDeltaQueue().count == 1 } + XCTAssertEqual(repo.snapshotDeltaQueue().map(\.property), ["identified"]) + } + + /// Flush must drop restored anonymous Deltas; they never pass through enqueue. + func testAnonymousDeltasRestoredFromTheCacheAreDroppedAtFlush() { + OSOperationRepoTestEnvironment.seedCachedDeltaQueue([ + makeDelta(externalId: nil, property: "anonymous"), + makeDelta(externalId: "user-1", property: "identified") + ]) + + let repo = makeRepo() + XCTAssertEqual(repo.snapshotDeltaQueue().count, 2, "the repo should restore both Deltas before judging them") + + let executor = MockOperationExecutor(supportedDeltas: [deltaName]) + let processed = flushExpectation(on: executor) + repo.addExecutor(executor) + + jwtConfig.hydrate(requiresUserAuth: true) + + wait(for: [processed], timeout: 2.0) + XCTAssertEqual(executor.enqueued.map(\.property), ["identified"]) + XCTAssertTrue(repo.snapshotDeltaQueue().isEmpty) + } + + /** + The push subscription has no owner to sign for before login or after logout, and its updates still + have to go out, so `OS_UPDATE_SUBSCRIPTION_DELTA` survives the enqueue drop. + */ + func testAnonymousSubscriptionUpdatesAreExemptFromTheEnqueueDrop() { + jwtConfig.hydrate(requiresUserAuth: true) + let repo = makeRepo() + repo.paused = true + + repo.enqueueDelta(OSOperationRepoTestEnvironment.makeDelta(name: OS_UPDATE_SUBSCRIPTION_DELTA, externalId: nil, property: "token")) + repo.enqueueDelta(makeDelta(externalId: nil, property: "anonymous")) + repo.enqueueDelta(makeDelta(externalId: "user-1", property: "identified")) + + // The identified Delta is the sync point; the queue is serial. + OneSignalCoreMocks.waitUntil("identified delta enqueued") { repo.snapshotDeltaQueue().count == 2 } + XCTAssertEqual(repo.snapshotDeltaQueue().map(\.property), ["token", "identified"]) + } + + /// Same exemption for a Delta restored from a previous session, which never passes through enqueue. + func testAnonymousSubscriptionUpdatesAreExemptFromTheFlushDrop() { + OSOperationRepoTestEnvironment.seedCachedDeltaQueue([ + OSOperationRepoTestEnvironment.makeDelta(name: OS_UPDATE_SUBSCRIPTION_DELTA, externalId: nil, property: "token"), + makeDelta(externalId: nil, property: "anonymous") + ]) + + let repo = makeRepo() + let executor = MockOperationExecutor(supportedDeltas: [OS_UPDATE_SUBSCRIPTION_DELTA, deltaName]) + let processed = flushExpectation(on: executor) + repo.addExecutor(executor) + + jwtConfig.hydrate(requiresUserAuth: true) + + wait(for: [processed], timeout: 2.0) + XCTAssertEqual(executor.enqueued.map(\.property), ["token"]) + } + + /// The rollout flag alone must not suppress; only `jwt_required` turns it on. + func testAnonymousDeltasSurviveWhenTheFlagIsOnButTheAppDoesNotRequireAuth() { + flagsStore.applyRemoteFlags([FeatureFlag.sdkIdentityVerification.key], metadata: nil) + + let repo = makeRepo() + let executor = MockOperationExecutor(supportedDeltas: [deltaName]) + let processed = flushExpectation(on: executor) + repo.addExecutor(executor) + + repo.enqueueDelta(makeDelta(externalId: nil, property: "anonymous")) + OneSignalCoreMocks.waitUntil("delta enqueued") { repo.snapshotDeltaQueue().count == 1 } + + jwtConfig.hydrate(requiresUserAuth: false) + + wait(for: [processed], timeout: 2.0) + XCTAssertEqual(executor.enqueued.map(\.property), ["anonymous"]) + } + + // MARK: - Purge when the requirement arrives as required + + /// Unsupported delta name on purpose so survival is by `externalId`, not routing. + func testLearningThatAuthIsRequiredDropsAnonymousDeltasAndKeepsIdentifiedOnes() { + OSOperationRepoTestEnvironment.seedCachedDeltaQueue([ + makeDelta(externalId: nil, property: "anonymous"), + makeDelta(externalId: "user-1", property: "identified") + ]) + + let repo = makeRepo() + let executor = MockOperationExecutor(supportedDeltas: ["some_other_delta"]) + let processed = flushExpectation(on: executor) + repo.addExecutor(executor) + + jwtConfig.hydrate(requiresUserAuth: true) + + wait(for: [processed], timeout: 2.0) + XCTAssertEqual(repo.snapshotDeltaQueue().map(\.property), ["identified"]) + + let cached = OSOperationRepoTestEnvironment.cachedDeltaQueue() + XCTAssertEqual(cached?.map(\.property), ["identified"], "the drop has to survive a restart") + } + + /// Executor caches hold last session's deltas, so the purge must reach them too. + func testFlushingWhileAuthIsRequiredDrivesThePurgeIntoExecutors() { + let repo = makeRepo() + let executor = MockOperationExecutor(supportedDeltas: [deltaName]) + repo.addExecutor(executor) + + jwtConfig.hydrate(requiresUserAuth: true) + + OneSignalCoreMocks.waitUntil("executor asked to purge") { executor.removeOperationsWithoutExternalIdCallCount >= 1 } + } + + func testFlushingWhileAuthIsNotRequiredLeavesAnonymousDeltasAlone() { + OSOperationRepoTestEnvironment.seedCachedDeltaQueue([ + makeDelta(externalId: nil, property: "anonymous") + ]) + + let repo = makeRepo() + let executor = MockOperationExecutor(supportedDeltas: ["some_other_delta"]) + let processed = flushExpectation(on: executor) + repo.addExecutor(executor) + + jwtConfig.hydrate(requiresUserAuth: false) + + wait(for: [processed], timeout: 2.0) + XCTAssertEqual(repo.snapshotDeltaQueue().map(\.property), ["anonymous"]) + XCTAssertEqual(executor.removeOperationsWithoutExternalIdCallCount, 0) + } + + // MARK: - Helpers + + /** + Fulfills once the executor is asked to process. Repeats are allowed: a handler that registers while + `hydrate` is running is delivered both by the fire and by `addOnJwtConfigHydratedHandler`'s catch-up, + so the same hydration can flush twice. These tests assert on what the flush did, not on how many ran. + */ + private func flushExpectation(on executor: MockOperationExecutor) -> XCTestExpectation { + let processed = expectation(description: "processDeltaQueue") + processed.assertForOverFulfill = false + executor.onProcessDeltaQueue = { processed.fulfill() } + return processed + } + + private func makeRepo() -> OSOperationRepo { + return OSOperationRepoTestEnvironment.makeRepo(jwtConfig: jwtConfig, featureManager: featureManager) + } + + private func makeDelta(externalId: String?, property: String) -> OSDelta { + return OSOperationRepoTestEnvironment.makeDelta(name: deltaName, externalId: externalId, property: property) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift new file mode 100644 index 000000000..fc84a9fba --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift @@ -0,0 +1,108 @@ +/* + 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 Foundation +import XCTest +import OneSignalCore +@testable import OneSignalOSCore + +/** + Builds an Operation Repo per test. Clear the cache before constructing one (`init` uncachees); + seed the cache first when the test starts from a restored queue. + */ +enum OSOperationRepoTestEnvironment { + static func clearCache() { + OneSignalUserDefaults.initShared().removeValue(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY) + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_USE_IDENTITY_VERIFICATION) + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAGS) + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAG_METADATA) + } + + static func seedCachedDeltaQueue(_ deltas: [OSDelta]) { + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY, withValue: deltas) + } + + static func cachedDeltaQueue() -> [OSDelta]? { + let key = OS_OPERATION_REPO_DELTA_QUEUE_KEY + return OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: key, defaultValue: []) as? [OSDelta] + } + + // Pin the poller out of reach: DEBUG uses 100ms and would flush underneath expectations. + static func makeRepo(jwtConfig: OSUserJwtConfig, featureManager: OSFeatureManager = OSFeatureManager(store: OSFeatureFlagsStore())) -> OSOperationRepo { + let service = OSIdentityVerificationService(featureManager: featureManager, jwtConfig: jwtConfig) + let repo = OSOperationRepo(identityVerificationService: service) + repo.pollIntervalMilliseconds = 60_000 + return repo + } + + static func makeDelta(name: String, externalId: String?, property: String) -> OSDelta { + return OSDelta( + name: name, + identityModelId: UUID().uuidString, + externalId: externalId, + model: OSModel(changeNotifier: OSEventProducer()), + property: property, + value: property + ) + } +} + +extension OSOperationRepo { + /** + The queue as of right now. Tests poll it while the repo appends on its own queue, so reading + `deltaQueue` directly is a data race even when only the count is wanted. + */ + func snapshotDeltaQueue() -> [OSDelta] { + return dispatchQueue.sync { deltaQueue } + } +} + +/// Records what the Operation Repo hands it, so tests can assert on routing rather than on requests. +final class MockOperationExecutor: OSOperationExecutor { + let supportedDeltas: [String] + private(set) var enqueued: [OSDelta] = [] + private(set) var removeOperationsWithoutExternalIdCallCount = 0 + var onProcessDeltaQueue: (() -> Void)? + + init(supportedDeltas: [String]) { + self.supportedDeltas = supportedDeltas + } + + func enqueueDelta(_ delta: OSDelta) { + enqueued.append(delta) + } + + func cacheDeltaQueue() {} + + func processDeltaQueue(inBackground: Bool) { + onProcessDeltaQueue?() + } + + func removeOperationsWithoutExternalId() { + removeOperationsWithoutExternalIdCallCount += 1 + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift index 7feb01a05..b6571ac08 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift @@ -49,12 +49,14 @@ class OSCustomEventsExecutor: OSOperationExecutor { private var deltaQueue: [OSDelta] = [] private var requestQueue: [OSRequestCustomEvents] = [] private let newRecordsState: OSNewRecordsState + private let auth: OSRequestAuthorizing // The executor dispatch queue, serial. This synchronizes access to `deltaQueue` and `requestQueue`. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSCustomEventsExecutor", target: .global()) - init(newRecordsState: OSNewRecordsState) { + init(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) { self.newRecordsState = newRecordsState + self.auth = auth // Read unfinished deltas and requests from cache, if any... uncacheDeltas() uncacheRequests() @@ -85,8 +87,8 @@ class OSCustomEventsExecutor: OSOperationExecutor { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { // 1. The identity model exist in the repo, set it to be the Request's model request.identityModel = identityModel - } else if request.prepareForExecution(newRecordsState: newRecordsState) { - // 2. The request can be sent, add the model to the repo + } else if auth.keepUncachedOwned(request) || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + // 2. Owned while Identity Verification is on, so a token can still arrive; or it can be sent as is OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) } else { // 3. The identitymodel do not exist AND this request cannot be sent, drop this Request @@ -116,6 +118,24 @@ class OSCustomEventsExecutor: OSOperationExecutor { } } + func removeOperationsWithoutExternalId() { + self.dispatchQueue.async { + let remainingDeltas = self.deltaQueue.filter { $0.externalId != nil } + if remainingDeltas.count != self.deltaQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSCustomEventsExecutor dropped \(self.deltaQueue.count - remainingDeltas.count) anonymous Deltas, Identity Verification is required") + self.deltaQueue = remainingDeltas + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY, withValue: self.deltaQueue) + } + + let remainingRequests = self.requestQueue.filter { $0.ownerExternalId != nil } + if remainingRequests.count != self.requestQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSCustomEventsExecutor dropped \(self.requestQueue.count - remainingRequests.count) anonymous Requests, Identity Verification is required") + self.requestQueue = remainingRequests + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, withValue: self.requestQueue) + } + } + } + /// The `deltaQueue` can contain events for multiple users. They will remain as Deltas if there is no onesignal ID yet for its user. /// This method will be used in an upcoming release that combine multiple events. func processDeltaQueueWithBatching(inBackground: Bool) { @@ -127,8 +147,8 @@ class OSCustomEventsExecutor: OSOperationExecutor { } OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSCustomEventsExecutor processDeltaQueue with queue: \(self.deltaQueue)") - // Holds mapping of identity model ID to the events for it - var combinedEvents: [String: [[String: Any]]] = [:] + // Holds mapping of identity model ID to the events for it, with the owner the Deltas stamped + var combinedEvents: [String: (events: [[String: Any]], ownerExternalId: String?)] = [:] // 1. Combine the events for every distinct user for (index, delta) in self.deltaQueue.enumerated().reversed() { @@ -154,20 +174,21 @@ class OSCustomEventsExecutor: OSOperationExecutor { EventConstants.payload: self.addSdkMetadata(properties: properties) ] - combinedEvents[identityModel.modelId, default: []].append(event) + combinedEvents[identityModel.modelId, default: ([], delta.externalId)].events.append(event) self.deltaQueue.remove(at: index) } // 2. Turn each user's events into a Request - for (modelId, events) in combinedEvents { + for (modelId, combined) in combinedEvents { guard let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(modelId) else { // This should never happen as we already checked this during Deltas processing above continue } let request = OSRequestCustomEvents( - events: events, - identityModel: identityModel + events: combined.events, + identityModel: identityModel, + ownerExternalId: combined.ownerExternalId ) self.requestQueue.append(request) } @@ -216,7 +237,8 @@ class OSCustomEventsExecutor: OSOperationExecutor { let request = OSRequestCustomEvents( events: [event], - identityModel: identityModel + identityModel: identityModel, + ownerExternalId: delta.externalId ) self.requestQueue.append(request) } @@ -262,7 +284,7 @@ class OSCustomEventsExecutor: OSOperationExecutor { guard !request.sentToClient else { return } - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { return } request.sentToClient = true @@ -284,7 +306,9 @@ class OSCustomEventsExecutor: OSOperationExecutor { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSCustomEventsExecutor request failed with error: \(error.debugDescription)") self.dispatchQueue.async { let responseType = OSNetworkingUtils.getResponseStatusType(error.code) - if responseType != .retryable { + if responseType == .unauthorized, self.auth.handleUnauthorized(request) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSCustomEventsExecutor holding \(request) for a new token") + } else if responseType != .retryable { // Fail, no retry, remove from cache and queue self.requestQueue.removeAll(where: { $0 == request}) OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, withValue: self.requestQueue) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift index 516ca0c02..c3e1825bc 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift @@ -35,12 +35,14 @@ class OSIdentityOperationExecutor: OSOperationExecutor { private var addRequestQueue: [OSRequestAddAliases] = [] private var removeRequestQueue: [OSRequestRemoveAlias] = [] private let newRecordsState: OSNewRecordsState + private let auth: OSRequestAuthorizing // The Identity executor dispatch queue, serial. This synchronizes access to the delta and request queues. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSIdentityOperationExecutor", target: .global()) - init(newRecordsState: OSNewRecordsState) { + init(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) { self.newRecordsState = newRecordsState + self.auth = auth // Read unfinished deltas and requests from cache, if any... uncacheDeltas() uncacheAddAliasRequests() @@ -74,9 +76,9 @@ class OSIdentityOperationExecutor: OSOperationExecutor { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { // 1. The model exists in the repo, so set it to be the Request's models request.identityModel = identityModel - } else if request.prepareForExecution(newRecordsState: newRecordsState) { - // 2. The request can be sent, add the model to the repo - OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) + } else if auth.keepUncachedOwned(request) || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + // 2. Owned while Identity Verification is on, so a token can still arrive; or it can be sent as is + OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) } else { // 3. The model do not exist AND this request cannot be sent, drop this Request OneSignalLog.onesignalLog(.LL_ERROR, message: "OSIdentityOperationExecutor.init dropped \(request)") @@ -97,8 +99,8 @@ class OSIdentityOperationExecutor: OSOperationExecutor { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { // 1. The model exists in the repo, so set it to be the Request's model request.identityModel = identityModel - } else if request.prepareForExecution(newRecordsState: newRecordsState) { - // 2. The request can be sent, add the model to the repo + } else if auth.keepUncachedOwned(request) || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + // 2. Owned while Identity Verification is on, so a token can still arrive; or it can be sent as is OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) } else { // 3. The model does not exist AND this request cannot be sent, drop this Request @@ -126,6 +128,31 @@ class OSIdentityOperationExecutor: OSOperationExecutor { } } + func removeOperationsWithoutExternalId() { + self.dispatchQueue.async { + let remainingDeltas = self.deltaQueue.filter { $0.externalId != nil } + if remainingDeltas.count != self.deltaQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSIdentityOperationExecutor dropped \(self.deltaQueue.count - remainingDeltas.count) anonymous Deltas, Identity Verification is required") + self.deltaQueue = remainingDeltas + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_DELTA_QUEUE_KEY, withValue: self.deltaQueue) + } + + let remainingAdd = self.addRequestQueue.filter { $0.ownerExternalId != nil } + if remainingAdd.count != self.addRequestQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSIdentityOperationExecutor dropped \(self.addRequestQueue.count - remainingAdd.count) anonymous add Requests, Identity Verification is required") + self.addRequestQueue = remainingAdd + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) + } + + let remainingRemove = self.removeRequestQueue.filter { $0.ownerExternalId != nil } + if remainingRemove.count != self.removeRequestQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSIdentityOperationExecutor dropped \(self.removeRequestQueue.count - remainingRemove.count) anonymous remove Requests, Identity Verification is required") + self.removeRequestQueue = remainingRemove + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, withValue: self.removeRequestQueue) + } + } + } + func processDeltaQueue(inBackground: Bool) { self.dispatchQueue.async { if !self.deltaQueue.isEmpty { @@ -141,12 +168,12 @@ class OSIdentityOperationExecutor: OSOperationExecutor { switch delta.name { case OS_ADD_ALIAS_DELTA: - let request = OSRequestAddAliases(aliases: aliases, identityModel: model) + let request = OSRequestAddAliases(aliases: aliases, identityModel: model, ownerExternalId: delta.externalId) self.addRequestQueue.append(request) case OS_REMOVE_ALIAS_DELTA: for (label, _) in aliases { - let request = OSRequestRemoveAlias(labelToRemove: label, identityModel: model) + let request = OSRequestRemoveAlias(labelToRemove: label, identityModel: model, ownerExternalId: delta.externalId) self.removeRequestQueue.append(request) } @@ -193,7 +220,7 @@ class OSIdentityOperationExecutor: OSOperationExecutor { guard !request.sentToClient else { return } - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { return } request.sentToClient = true @@ -234,6 +261,8 @@ class OSIdentityOperationExecutor: OSOperationExecutor { // The subscription has been deleted along with the user, so remove the subscription_id but keep the same push subscription model OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId = nil OneSignalUserManagerImpl.sharedInstance._logout() + } else if responseType == .unauthorized, self.auth.handleUnauthorized(request) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSIdentityOperationExecutor holding \(request) for a new token") } else if responseType != .retryable { // Fail, no retry, remove from cache and queue self.addRequestQueue.removeAll(where: { $0 == request}) @@ -250,7 +279,7 @@ class OSIdentityOperationExecutor: OSOperationExecutor { guard !request.sentToClient else { return } - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { return } request.sentToClient = true @@ -276,7 +305,9 @@ class OSIdentityOperationExecutor: OSOperationExecutor { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSIdentityOperationExecutor remove alias request failed with error: \(error.debugDescription)") self.dispatchQueue.async { let responseType = OSNetworkingUtils.getResponseStatusType(error.code) - if responseType != .retryable { + if responseType == .unauthorized, self.auth.handleUnauthorized(request) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSIdentityOperationExecutor holding \(request) for a new token") + } else if responseType != .retryable { // Fail, no retry, remove from cache and queue // A response of .missing could mean the alias doesn't exist on this user OR this user has been deleted self.removeRequestQueue.removeAll(where: { $0 == request}) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift index 0df165df2..c91eb50fe 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift @@ -35,6 +35,11 @@ private struct OSCombinedProperties { var location: OSLocationPoint? var refreshDeviceMetadata = false + /// Carried from the Deltas so the Request inherits their stamped owner. The Deltas combined here + /// share one Identity Model; the last one wins if that model gained an `external_id` partway, which + /// keeps the combined work rather than dropping it. + var ownerExternalId: String? + // Items of Properties Deltas var sessionTime: Int = 0 var sessionCount: Int = 0 @@ -65,12 +70,14 @@ class OSPropertyOperationExecutor: OSOperationExecutor { private var deltaQueue: [OSDelta] = [] private var updateRequestQueue: [OSRequestUpdateProperties] = [] private let newRecordsState: OSNewRecordsState + private let auth: OSRequestAuthorizing // The property executor dispatch queue, serial. This synchronizes access to `deltaQueue` and `updateRequestQueue`. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSPropertyOperationExecutor", target: .global()) - init(newRecordsState: OSNewRecordsState) { + init(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) { self.newRecordsState = newRecordsState + self.auth = auth // Read unfinished deltas and requests from cache, if any... // Note that we should only have deltas for the current user as old ones are flushed.. uncacheDeltas() @@ -100,8 +107,8 @@ class OSPropertyOperationExecutor: OSOperationExecutor { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { // 1. The identity model exist in the repo, set it to be the Request's model request.identityModel = identityModel - } else if request.prepareForExecution(newRecordsState: newRecordsState) { - // 2. The request can be sent, add the model to the repo + } else if auth.keepUncachedOwned(request) || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + // 2. Owned while Identity Verification is on, so a token can still arrive; or it can be sent as is OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) } else { // 3. The identitymodel do not exist AND this request cannot be sent, drop this Request @@ -129,6 +136,24 @@ class OSPropertyOperationExecutor: OSOperationExecutor { } } + func removeOperationsWithoutExternalId() { + self.dispatchQueue.async { + let remainingDeltas = self.deltaQueue.filter { $0.externalId != nil } + if remainingDeltas.count != self.deltaQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSPropertyOperationExecutor dropped \(self.deltaQueue.count - remainingDeltas.count) anonymous Deltas, Identity Verification is required") + self.deltaQueue = remainingDeltas + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_PROPERTIES_EXECUTOR_DELTA_QUEUE_KEY, withValue: self.deltaQueue) + } + + let remainingRequests = self.updateRequestQueue.filter { $0.ownerExternalId != nil } + if remainingRequests.count != self.updateRequestQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSPropertyOperationExecutor dropped \(self.updateRequestQueue.count - remainingRequests.count) anonymous Requests, Identity Verification is required") + self.updateRequestQueue = remainingRequests + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: self.updateRequestQueue) + } + } + } + /// The `deltaQueue` should only contain updates for one user. /// Even when login -> addTag -> login -> addTag are called in immediate succession. func processDeltaQueue(inBackground: Bool) { @@ -168,7 +193,8 @@ class OSPropertyOperationExecutor: OSOperationExecutor { } let request = OSRequestUpdateProperties( params: properties.jsonRepresentation(), - identityModel: identityModel + identityModel: identityModel, + ownerExternalId: properties.ownerExternalId ) self.updateRequestQueue.append(request) } @@ -186,6 +212,7 @@ class OSPropertyOperationExecutor: OSOperationExecutor { /// Helper method to combine the information in an `OSDelta` to the existing `OSCombinedProperties` so far. private func combineProperties(existing: OSCombinedProperties?, delta: OSDelta) -> OSCombinedProperties { var combinedProperties = existing ?? OSCombinedProperties() + combinedProperties.ownerExternalId = delta.externalId guard let property = OSPropertiesSupportedProperty(rawValue: delta.property) else { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSPropertyOperationExecutor.combineProperties dropped unsupported property: \(delta.property)") @@ -235,7 +262,7 @@ class OSPropertyOperationExecutor: OSOperationExecutor { guard !request.sentToClient else { return } - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { return } request.sentToClient = true @@ -298,6 +325,8 @@ class OSPropertyOperationExecutor: OSOperationExecutor { // The subscription has been deleted along with the user, so remove the subscription_id but keep the same push subscription model OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId = nil OneSignalUserManagerImpl.sharedInstance._logout() + } else if responseType == .unauthorized, self.auth.handleUnauthorized(request) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSPropertyOperationExecutor holding \(request) for a new token") } else if responseType != .retryable { // Fail, no retry, remove from cache and queue self.updateRequestQueue.removeAll(where: { $0 == request}) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift index f8c985cce..43d3f63b9 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift @@ -37,12 +37,14 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { private var updateRequestQueue: [OSRequestUpdateSubscription] = [] private var subscriptionModels: [String: OSSubscriptionModel] = [:] private let newRecordsState: OSNewRecordsState + private let auth: OSRequestAuthorizing // The Subscription executor dispatch queue, serial. This synchronizes access to the delta and request queues. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSSubscriptionOperationExecutor", target: .global()) - init(newRecordsState: OSNewRecordsState) { + init(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) { self.newRecordsState = newRecordsState + self.auth = auth // Read unfinished deltas and requests from cache, if any... uncacheDeltas() uncacheCreateSubscriptionRequests() @@ -91,8 +93,8 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { // a. The model exist in the repo request.identityModel = identityModel - } else if request.prepareForExecution(newRecordsState: newRecordsState) { - // b. The request can be sent, add the model to the repo + } else if auth.keepUncachedOwned(request) || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + // b. Owned while Identity Verification is on, so a token can still arrive; or it can be sent as is OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) } else { // c. The model do not exist AND this request cannot be sent, drop this Request @@ -118,10 +120,12 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } else if let subscriptionModel = subscriptionModels[request.subscriptionModel.modelId] { // 2. The model exists in the dict of seen subscription models request.subscriptionModel = subscriptionModel - } else if !request.prepareForExecution(newRecordsState: newRecordsState) { - // 3. The model does not exist AND this request cannot be sent, drop this Request + } else if !auth.keepUncachedOwned(request), + !request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + // 3. The model does not exist AND no token can arrive to make this sendable, drop it OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionOperationExecutor.init dropped \(request)") removeRequestQueue.remove(at: index) + continue } } self.removeRequestQueue = removeRequestQueue @@ -141,11 +145,13 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } else if let subscriptionModel = subscriptionModels[request.subscriptionModel.modelId] { // 2. The model exists in the dict of seen subscription models request.subscriptionModel = subscriptionModel - } else if !request.prepareForExecution(newRecordsState: newRecordsState) { + } else if !request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { // 3. The models do not exist AND this request cannot be sent, drop this Request OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionOperationExecutor.init dropped \(request)") updateRequestQueue.remove(at: index) + continue } + request.identityModel = liveIdentityModel(request.identityModel) } self.updateRequestQueue = updateRequestQueue OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: self.updateRequestQueue) @@ -154,6 +160,21 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } } + /** + Returns the repo's instance for this Identity Model, registering the decoded one if missing, + so every request for a user shares one instance. + */ + private func liveIdentityModel(_ identityModel: OSIdentityModel?) -> OSIdentityModel? { + guard let identityModel = identityModel else { + return nil + } + if let modelInRepo = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(identityModel.modelId) { + return modelInRepo + } + OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(identityModel) + return identityModel + } + /** Since there are 2 subscription stores, we need to check both stores for the model with a particular `modelId`. */ @@ -180,6 +201,39 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } } + /** + Drops anonymous add/remove Deltas and Requests. Updates are kept — in practice only the device's + own push subscription is ever updated, and that has to keep reporting with or without an identified + user. See `OSOperationRepo.shouldDropAnonymousDelta` for what the exemption rests on. `logout()`'s + unsubscribe travels in `updateRequestQueue`, which is also left alone. + */ + func removeOperationsWithoutExternalId() { + self.dispatchQueue.async { + let remainingDeltas = self.deltaQueue.filter { + $0.externalId != nil || $0.name == OS_UPDATE_SUBSCRIPTION_DELTA + } + if remainingDeltas.count != self.deltaQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSubscriptionOperationExecutor dropped \(self.deltaQueue.count - remainingDeltas.count) anonymous Deltas, Identity Verification is required") + self.deltaQueue = remainingDeltas + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_DELTA_QUEUE_KEY, withValue: self.deltaQueue) + } + + let remainingAdd = self.addRequestQueue.filter { $0.ownerExternalId != nil } + if remainingAdd.count != self.addRequestQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSubscriptionOperationExecutor dropped \(self.addRequestQueue.count - remainingAdd.count) anonymous add Requests, Identity Verification is required") + self.addRequestQueue = remainingAdd + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) + } + + let remainingRemove = self.removeRequestQueue.filter { $0.ownerExternalId != nil } + if remainingRemove.count != self.removeRequestQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSubscriptionOperationExecutor dropped \(self.removeRequestQueue.count - remainingRemove.count) anonymous remove Requests, Identity Verification is required") + self.removeRequestQueue = remainingRemove + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, withValue: self.removeRequestQueue) + } + } + } + func processDeltaQueue(inBackground: Bool) { self.dispatchQueue.async { if !self.deltaQueue.isEmpty { @@ -192,13 +246,16 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { continue } + let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) + switch delta.name { case OS_ADD_SUBSCRIPTION_DELTA: // Only create the request if the identity model exists - if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) { + if let identityModel = identityModel { let request = OSRequestCreateSubscription( subscriptionModel: subModel, - identityModel: identityModel + identityModel: identityModel, + ownerExternalId: delta.externalId ) self.addRequestQueue.append(request) } else { @@ -206,7 +263,8 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } case OS_REMOVE_SUBSCRIPTION_DELTA: let request = OSRequestDeleteSubscription( - subscriptionModel: subModel + subscriptionModel: subModel, + ownerExternalId: delta.externalId ) self.removeRequestQueue.append(request) @@ -216,7 +274,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { self.updateRequestQueue.removeAll { request in !request.sentToClient && request.subscriptionModel.modelId == modelId } - let request = OSRequestUpdateSubscription(subscriptionModel: subModel) + let request = OSRequestUpdateSubscription(subscriptionModel: subModel, identityModel: identityModel) self.updateRequestQueue.append(request) default: @@ -240,13 +298,20 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { // Bypasses the operation repo to create a push subscription request func createPushSubscription(subscriptionModel: OSSubscriptionModel, identityModel: OSIdentityModel) { - let request = OSRequestCreateSubscription(subscriptionModel: subscriptionModel, identityModel: identityModel) + // No Delta to inherit ownership from, so read the owner directly. + let request = OSRequestCreateSubscription( + subscriptionModel: subscriptionModel, + identityModel: identityModel, + ownerExternalId: identityModel.externalId + ) self.dispatchQueue.async { self.addRequestQueue.append(request) OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) } } +} +extension OSSubscriptionOperationExecutor { /// This method is called by `processDeltaQueue` only and does not need to be added to the dispatchQueue. private func processRequestQueue(inBackground: Bool) { let requestQueue: [OneSignalRequest] = addRequestQueue + removeRequestQueue + updateRequestQueue @@ -275,7 +340,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { guard !request.sentToClient else { return } - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { return } request.sentToClient = true @@ -338,6 +403,8 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { // The subscription has been deleted along with the user, so remove the subscription_id but keep the same push subscription model OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId = nil OneSignalUserManagerImpl.sharedInstance._logout() + } else if responseType == .unauthorized, self.auth.handleUnauthorized(request) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSubscriptionOperationExecutor holding \(request) for a new token") } else if responseType != .retryable { // Fail, no retry, remove from cache and queue self.addRequestQueue.removeAll(where: { $0 == request}) @@ -354,7 +421,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { guard !request.sentToClient else { return } - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { return } request.sentToClient = true @@ -380,7 +447,9 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionOperationExecutor delete subscription request failed with error: \(error.debugDescription)") self.dispatchQueue.async { let responseType = OSNetworkingUtils.getResponseStatusType(error.code) - if responseType != .retryable { + if responseType == .unauthorized, self.auth.handleUnauthorized(request) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSubscriptionOperationExecutor holding \(request) for a new token") + } else if responseType != .retryable { // Fail, no retry, remove from cache and queue // If this request returns a missing status, that is ok as this is a delete request self.removeRequestQueue.removeAll(where: { $0 == request}) @@ -403,7 +472,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { guard !updateRequestQueue.contains(where: { $0 !== request && $0.sentToClient && $0.subscriptionModel.modelId == modelId }) else { return } - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { return } request.sentToClient = true @@ -420,7 +489,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { self.updateRequestQueue.removeAll(where: { $0 == request}) OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: self.updateRequestQueue) - if let onesignalId = OneSignalUserManagerImpl.sharedInstance.onesignalId { + if let onesignalId = request.identityModel?.onesignalId { if let rywToken = response?["ryw_token"] as? String { let rywDelay = response?["ryw_delay"] as? NSNumber diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift index 477f11540..66c6450bb 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift @@ -36,19 +36,90 @@ import OneSignalOSCore class OSUserExecutor { var userRequestQueue: [OSUserRequest] = [] private let newRecordsState: OSNewRecordsState + private let identityVerificationService: OSIdentityVerificationService + private let auth: OSRequestAuthorizing /// Delay by the "cool down" period plus a buffer of a set amount of milliseconds private let flushDelayMilliseconds = Int(OP_REPO_POST_CREATE_DELAY_SECONDS * 1_000 + 200) // TODO: This could come from a config, plist, method, remote params /// The User executor dispatch queue, serial. This synchronizes access to the request queues. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSUserExecutor", target: .global()) - init(newRecordsState: OSNewRecordsState) { + init(newRecordsState: OSNewRecordsState, identityVerificationService: OSIdentityVerificationService, auth: OSRequestAuthorizing) { self.newRecordsState = newRecordsState + self.identityVerificationService = identityVerificationService + self.auth = auth uncacheUserRequests() migrateTransferSubscriptionRequests() + + identityVerificationService.addOnJwtConfigHydratedHandler(for: .userExecutor) { [weak self] _ in + // Including an unchanged value: Requests held while `requirement` was unknown wait on this. + self?.executePendingRequests() + } + executePendingRequests() } + /** + Reshapes the queue once `requirement` is known, so nothing that cannot be signed is sent: a Create User + with no `external_id` and every Fetch Identity By Subscription are dropped, and an Identify User — a + `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. + */ + private func reshapeInvalidRequests() { + guard identityVerificationService.ivBehaviorActive else { + return + } + + var reshaped: [OSUserRequest] = [] + var changed = false + + for request in userRequestQueue { + if let identifyUser = request as? OSRequestIdentifyUser { + changed = true + if let createUser = promotionAsCreateUser(identifyUser) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSUserExecutor replaced \(identifyUser) with \(createUser), Identity Verification is required") + reshaped.append(createUser) + } else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSUserExecutor dropped \(identifyUser), Identity Verification is required") + } + } else if isInvalidUnderIdentityVerification(request) { + changed = true + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSUserExecutor dropped \(request), Identity Verification is required") + } else { + reshaped.append(request) + } + } + + guard changed else { + return + } + userRequestQueue = reshaped + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, withValue: userRequestQueue) + } + + /// The Create User the promoting `login` would have made, or nil if that user is no longer the current one. + private func promotionAsCreateUser(_ request: OSRequestIdentifyUser) -> OSRequestCreateUser? { + guard let user = OneSignalUserManagerImpl.sharedInstance.currentUser(matching: request.identityModelToUpdate.modelId) else { + return nil + } + return OSRequestCreateUser( + identityModel: user.identityModel, + propertiesModel: user.propertiesModel, + pushSubscriptionModel: user.pushSubscriptionModel, + originalPushToken: user.pushSubscriptionModel.address + ) + } + + private func isInvalidUnderIdentityVerification(_ request: OSUserRequest) -> Bool { + if let createUser = request as? OSRequestCreateUser { + return createUser.identityModel.externalId == nil + } + return request is OSRequestFetchIdentityBySubscription + } + /// Read in requests from the cache, do not read in FetchUser requests as this is not needed. private func uncacheUserRequests() { var userRequestQueue: [OSUserRequest] = [] @@ -79,29 +150,28 @@ class OSUserExecutor { userRequestQueue.append(req) } else if request.isKind(of: OSRequestIdentifyUser.self), let req = request as? OSRequestIdentifyUser { + let identifyInRepo = getIdentityModel(req.identityModelToIdentify.modelId) + let updateInRepo = getIdentityModel(req.identityModelToUpdate.modelId) + if let identifyInRepo { + req.identityModelToIdentify = identifyInRepo + } + if let updateInRepo { + req.identityModelToUpdate = updateInRepo + } - if let identityModelToIdentify = getIdentityModel(req.identityModelToIdentify.modelId), - let identityModelToUpdate = getIdentityModel(req.identityModelToUpdate.modelId) { - // 1. Both models exist in the repo, set it to be the Request's models - req.identityModelToIdentify = identityModelToIdentify - req.identityModelToUpdate = identityModelToUpdate - } else if let identityModelToIdentify = getIdentityModel(req.identityModelToIdentify.modelId), - getIdentityModel(req.identityModelToUpdate.modelId) == nil { - // 2. A model is in the repo, the other model does not exist - req.identityModelToIdentify = identityModelToIdentify - addIdentityModel(req.identityModelToUpdate) - } else { - // 3. Both models don't exist yet - // Drop the request if the identityModelToIdentify does not already exist AND the request is missing OSID - // Otherwise, this request will forever fail `prepareForExecution` and block pending requests such as recovery calls to `logout` or `login` - guard request.prepareForExecution(newRecordsState: newRecordsState) else { - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSUserExecutor.start() dropped: \(request)") - continue + // `prepareForExecution` is false under IV so `reshapeInvalidRequests` can promote + // this login; do not treat that as a permanent drop. + if auth.ivBehaviorActive || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + if identifyInRepo == nil { + addIdentityModel(req.identityModelToIdentify) } - addIdentityModel(req.identityModelToIdentify) - addIdentityModel(req.identityModelToUpdate) + if updateInRepo == nil { + addIdentityModel(req.identityModelToUpdate) + } + userRequestQueue.append(req) + } else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSUserExecutor.start() dropped: \(request)") } - userRequestQueue.append(req) } } } @@ -164,33 +234,56 @@ class OSUserExecutor { } private func _executePendingRequests() { + // Hold until known: a Create User sent now would go out unsigned if `requirement` later becomes on. + guard identityVerificationService.requirement != .unknown else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSUserExecutor holding \(self.userRequestQueue.count) Requests until the Identity Verification requirement is known") + return + } + reshapeInvalidRequests() + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSUserExecutor.executePendingRequests called with queue \(self.userRequestQueue)") + var awaitingToken = false + var executed = false + for request in self.userRequestQueue { // Return as soon as we reach an un-executable request - guard request.prepareForExecution(newRecordsState: self.newRecordsState) + guard request.prepareForExecution(newRecordsState: self.newRecordsState, auth: self.auth) else { + // Only the app can end this wait (`updateUserJwt` → `storeJwt`); do not poll for it. + // A login for another user behind this one must not be stranded, so step over it. + if self.auth.awaitsToken(request) { + awaitingToken = true + continue + } OneSignalLog.onesignalLog(.LL_WARN, message: "OSUserExecutor.executePendingRequests() is blocked by unexecutable request \(request)") executePendingRequests(withDelay: true) return } + // One Request per pass; its response re-enters here for the next. + executed = true if request.isKind(of: OSRequestFetchIdentityBySubscription.self), let fetchIdentityRequest = request as? OSRequestFetchIdentityBySubscription { self.executeFetchIdentityBySubscriptionRequest(fetchIdentityRequest) - return + break } else if request.isKind(of: OSRequestCreateUser.self), let createUserRequest = request as? OSRequestCreateUser { self.executeCreateUserRequest(createUserRequest) - return + break } else if request.isKind(of: OSRequestIdentifyUser.self), let identifyUserRequest = request as? OSRequestIdentifyUser { self.executeIdentifyUserRequest(identifyUserRequest) - return + break } else if request.isKind(of: OSRequestFetchUser.self), let fetchUserRequest = request as? OSRequestFetchUser { self.executeFetchUserRequest(fetchUserRequest) - return + break } else { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSUserExecutor met incompatible Request type that cannot be executed.") } } + + // Wait-only pass: `storeJwt` / hydrate / a later enqueue wakes us. Do not reschedule. + if awaitingToken, !executed { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSUserExecutor has Requests waiting for a token") + } } } @@ -220,14 +313,23 @@ extension OSUserExecutor { return } - // Hook up push subscription model if exists, it may be updated with a subscription_id, etc. - if let modelId = request.pushSubscriptionModel?.modelId, - let pushSubscriptionModel = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModelStore.getModel(modelId: modelId) { - request.pushSubscriptionModel = pushSubscriptionModel - request.updatePushSubscriptionModel(pushSubscriptionModel) + if OneSignalUserManagerImpl.sharedInstance.currentUser(matching: request.identityModel.modelId) != nil { + // Refresh so a subscription_id / token that landed after enqueue is included. + if let modelId = request.pushSubscriptionModel?.modelId, + let pushSubscriptionModel = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModelStore.getModel(modelId: modelId) { + request.pushSubscriptionModel = pushSubscriptionModel + request.updatePushSubscriptionModel(pushSubscriptionModel) + } + } else if request.identityModel.externalId != nil { + // Identified but not current: omit push so a parked Create User can't transfer the device + // subscription after another login took it. Keep push for anonymous creates — the server + // requires a subscription, and with IV off those requests don't sit behind a later user. + request.parameters?.removeValue(forKey: "subscriptions") + request.pushSubscriptionModel = nil + request.originalPushToken = nil } - guard request.prepareForExecution(newRecordsState: newRecordsState) + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { executePendingRequests(withDelay: true) return @@ -240,13 +342,12 @@ extension OSUserExecutor { // Create User's response won't send us the user's complete info if this user already exists if let response = response { - let shouldAddNewRecords = request.pushSubscriptionModel != nil // Parse the response for any data we need to update self.parseFetchUserResponse( response: response, identityModel: request.identityModel, originalPushToken: request.originalPushToken, - addNewRecords: shouldAddNewRecords + addNewRecords: request.addsNewRecords ) // If this user already exists and we logged into an external_id, fetch the user data @@ -275,15 +376,22 @@ extension OSUserExecutor { } } } - OSOperationRepo.sharedInstance.paused = false + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = false } onFailure: { error in OneSignalLog.onesignalLog(.LL_ERROR, message: "OSUserExecutor create user request failed with error: \(error.debugDescription)") let responseType = OSNetworkingUtils.getResponseStatusType(error.code) - if responseType != .retryable { + if responseType == .unauthorized, self.auth.handleUnauthorized(request) { + // Held rather than paused: `updateUserJwt` resumes work by flushing, which a paused Repo drops. + // Ordering does not need the pause — every Request for this user waits on an `onesignal_id`. + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSUserExecutor holding \(request) for a new token") + // A replacement token supplied while this was in flight has already released what it could; + // re-enter so that token is used now rather than waiting on another wake. + self.executePendingRequests() + } else if responseType != .retryable { // A failed create user request would leave the SDK in a bad state // Don't remove the request from cache and pause the operation repo // We will retry this request on a new session - OSOperationRepo.sharedInstance.paused = true + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true request.sentToClient = false } } @@ -305,7 +413,7 @@ extension OSUserExecutor { } // newRecordsState is unused for this request - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { executePendingRequests(withDelay: true) return } @@ -358,7 +466,7 @@ extension OSUserExecutor { return } - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { executePendingRequests(withDelay: true) return } @@ -437,7 +545,7 @@ extension OSUserExecutor { return } - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { executePendingRequests(withDelay: true) return } @@ -489,6 +597,8 @@ extension OSUserExecutor { // The subscription has been deleted along with the user, so remove the subscription_id but keep the same push subscription model OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId = nil OneSignalUserManagerImpl.sharedInstance._logout() + } else if responseType == .unauthorized, self.auth.handleUnauthorized(request) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSUserExecutor holding \(request) for a new token") } else if responseType != .retryable { // If the error is not retryable, remove from cache and queue self.removeFromQueue(request) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPushSubscriptionImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPushSubscriptionImpl.swift new file mode 100644 index 000000000..61e85e755 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPushSubscriptionImpl.swift @@ -0,0 +1,103 @@ +/* + Modified MIT License + + Copyright 2023 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 OneSignalCore +import OneSignalOSCore +import OneSignalNotifications + +/** + Implements the push subscription namespace. Lives on `OneSignalUserManagerImpl` so User and Push + Subscription can both expose `addObserver` without colliding on one type. + */ +@objc +public class OSPushSubscriptionImpl: NSObject, OSPushSubscription { + + let pushSubscriptionModelStore: OSModelStore + + private var _pushSubscriptionStateChangesObserver: OSObservable? + var pushSubscriptionStateChangesObserver: OSObservable { + if let observer = _pushSubscriptionStateChangesObserver { + return observer + } + let pushSubscriptionStateChangesObserver = OSObservable(change: #selector(OSPushSubscriptionObserver.onPushSubscriptionDidChange(state:))) + _pushSubscriptionStateChangesObserver = pushSubscriptionStateChangesObserver + + return pushSubscriptionStateChangesObserver + } + + init(pushSubscriptionModelStore: OSModelStore) { + self.pushSubscriptionModelStore = pushSubscriptionModelStore + } + + public func addObserver(_ observer: OSPushSubscriptionObserver) { + // Push Subscription namespace; does not require privacy consent first. + self.pushSubscriptionStateChangesObserver.addObserver(observer) + } + + public func removeObserver(_ observer: OSPushSubscriptionObserver) { + self.pushSubscriptionStateChangesObserver.removeObserver(observer) + } + + public var id: String? { + guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.id") else { + return nil + } + return pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?.subscriptionId + } + + public var token: String? { + guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.token") else { + return nil + } + return pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?.address + } + + public var optedIn: Bool { + guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.optedIn") else { + return false + } + return pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?.optedIn ?? false + } + + /** + Enable the push subscription, and prompts if needed. `optedIn` can still be `false` after `optIn()` is called if permission is not granted. + */ + public func optIn() { + guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.optIn") else { + return + } + pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?._isDisabled = false + OSNotificationsManager.requestPermission(nil, fallbackToSettings: true) + } + + public func optOut() { + guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.optOut") else { + return + } + pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?._isDisabled = true + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift new file mode 100644 index 000000000..5678d9747 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift @@ -0,0 +1,256 @@ +/* + 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 Foundation +import OneSignalCore +import OneSignalOSCore + +/** + The one place a Request's addressing alias and `Authorization` header are decided. + + While Identity Verification is not in effect every method here resolves what the SDK sent before it + existed: the `onesignal_id` alias and no header. + */ +protocol OSRequestAuthorizing: AnyObject { + /// Whether Identity Verification behavior applies, for the few Requests whose body changes with it. + var ivBehaviorActive: Bool { get } + + /** + Resolves the alias a user-scoped path should address, attaching a Bearer header when Identity + Verification is in effect. `legacyAlias` is what the Request addresses without it, which is + `onesignal_id` for everything except a Fetch User that was built to read some other alias. + + Returns nil when Identity Verification is in effect and the Request cannot be signed, leaving it in + the queue it already sits in. An owner with no usable token is parked and the app is asked for one; + `updateUserJwt` (via `storeJwt`) wakes the executor, or the requirement turning off resolves it and + sends. A Request with no owner at all is refused, since only the purge can resolve it. + */ + func authorizeUserScoped(_ request: OSUserRequest, legacyAlias: OSAliasPair) -> OSAliasPair? + + /// The same decision for endpoints that take a token but no alias, because their path names a + /// subscription or the app. Returns `false` under the same conditions as `authorizeUserScoped`, + /// except that a `sendsUnsigned` Request with no owner is allowed through. + func authorize(_ request: OSUserRequest) -> Bool + + /** + Returns `true` if the Request's owner has no token to sign with, which is why the two methods above + parked it. Reads only: it does not ask the app for a token, so call it after one of them has. + + Lets a caller that stops at its first unsendable Request tell "nothing can send until the app hands + over a token for this user" from "not addressable yet", which resolves on its own. + */ + func awaitsToken(_ request: OSUserRequest) -> Bool + + /** + Parks the token an unauthorized response rejected and clears `sentToClient` so the Request is + re-signed on a later flush. + + Returns `true` when the caller must leave the Request queued, `false` to fall through to its + existing non-retryable handling. + */ + func handleUnauthorized(_ request: OSUserRequest) -> Bool + + /** + The same alias and token decision for a user-scoped call that does not travel through the Request + queues, currently the in-app message fetch. Pass the ids of the user the call is for. + + Returns nil when it cannot be sent yet, having asked the app for a token if that is what is missing. + */ + func authorization(onesignalId: String?, externalId: String?) -> OSUserRequestAuthorization? +} + +extension OSRequestAuthorizing { + /// Returns `true` when `ivBehaviorActive` and the Request has an owner, so a token can still arrive for an uncached Request whose identity model is gone. + func keepUncachedOwned(_ request: OSUserRequest) -> Bool { + ivBehaviorActive && request.ownerExternalId != nil + } +} + +/** + How another module should address and sign one user-scoped call. + + `alias` nil means address it the way it was addressed before Identity Verification: no user in the + path and nothing to sign with. + */ +@objc(OSUserRequestAuthorization) +public final class OSUserRequestAuthorization: NSObject { + @objc public let alias: OSAliasPair? + /// Merge into the request's headers. Empty unless the call is signed. + @objc public let headers: [String: String] + /// The token `headers` signs with, nil when the call is unsigned. + @objc public let token: String? + + fileprivate init(alias: OSAliasPair?, headers: [String: String] = [:], token: String? = nil) { + self.alias = alias + self.headers = headers + self.token = token + } +} + +final class OSRequestAuth: OSRequestAuthorizing { + private static let authorizationHeader = "Authorization" + private static let bearerPrefix = "Bearer " + private static let legacyAddressing = OSUserRequestAuthorization(alias: nil) + + private let identityVerificationService: OSIdentityVerificationService + private let jwt: OSUserJwtProviding + + var ivBehaviorActive: Bool { + return identityVerificationService.ivBehaviorActive + } + + init(identityVerificationService: OSIdentityVerificationService, jwt: OSUserJwtProviding) { + self.identityVerificationService = identityVerificationService + self.jwt = jwt + } + + func authorizeUserScoped(_ request: OSUserRequest, legacyAlias: OSAliasPair) -> OSAliasPair? { + guard ivBehaviorActive else { + return legacyAlias + } + guard let externalId = request.ownerExternalId else { + // A user-scoped path with no owner has nobody to sign for, and addressing it by + // `onesignal_id` would send it unsigned. The Operation Repo suppresses anonymous work long + // before it becomes a Request, and the purge clears leftovers on hydration, so this holds. + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSRequestAuth: refusing \(request), it has no owner under Identity Verification") + return nil + } + guard let token = jwt.validJwt(externalId: externalId) else { + park(request, ownedBy: externalId) + return nil + } + setBearer(token, on: request) + return OSAliasPair(OS_EXTERNAL_ID, externalId) + } + + func authorize(_ request: OSUserRequest) -> Bool { + guard ivBehaviorActive else { + return true + } + guard let externalId = request.ownerExternalId else { + // Anything not exempt is a leftover the purge has yet to clear, and unsendable until it does. + guard request.sendsUnsigned else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSRequestAuth: refusing \(request), it has no owner under Identity Verification") + return false + } + return true + } + guard let token = jwt.validJwt(externalId: externalId) else { + park(request, ownedBy: externalId) + return false + } + setBearer(token, on: request) + return true + } + + func awaitsToken(_ request: OSUserRequest) -> Bool { + guard ivBehaviorActive, let externalId = request.ownerExternalId else { + return false + } + return jwt.validJwt(externalId: externalId) == nil + } + + /** + 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. + */ + private func park(_ request: OSUserRequest, ownedBy externalId: String) { + // Log only on the ask that reaches the app; later prepareForExecution retries stay quiet. + if jwt.askForToken(externalId: externalId) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSRequestAuth: holding \(request) until \(externalId) has a token") + } + } + + func handleUnauthorized(_ request: OSUserRequest) -> Bool { + guard ivBehaviorActive, + let externalId = request.ownerExternalId, + let rejectedToken = signedToken(of: request) + else { + return false + } + jwt.invalidateJwt(externalId: externalId, rejectedToken: rejectedToken) + removeBearer(from: request) + request.sentToClient = false + return true + } + + func authorization(onesignalId: String?, externalId: String?) -> OSUserRequestAuthorization? { + // Not decided yet: legacy vs onesignal_id vs external_id+JWT. Callers reattempt on hydration. + guard identityVerificationService.requirement != .unknown else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSRequestAuth: holding a user-scoped call until the requirement is known") + return nil + } + // Requirement is known. Flag-only rollout still uses the pre-IV subscription path. + guard identityVerificationService.newCodePathsRun else { + return Self.legacyAddressing + } + guard ivBehaviorActive else { + // Nothing to address the call to until the server assigns an `onesignal_id`. + guard let onesignalId = onesignalId else { + return nil + } + return OSUserRequestAuthorization(alias: OSAliasPair(OS_ONESIGNAL_ID, onesignalId)) + } + // Under Identity Verification there is nothing the server will serve for a device with no + // identified user, so this waits for a login rather than falling back to `onesignal_id`. + guard let externalId = externalId else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSRequestAuth: holding a user-scoped call until a user is identified") + return nil + } + guard let token = jwt.validJwt(externalId: externalId) else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSRequestAuth: holding a user-scoped call until \(externalId) has a token") + jwt.askForToken(externalId: externalId) + return nil + } + return OSUserRequestAuthorization(alias: OSAliasPair(OS_EXTERNAL_ID, externalId), + headers: [Self.authorizationHeader: Self.bearerPrefix + token], + token: token) + } + + private func setBearer(_ token: String, on request: OneSignalRequest) { + var headers = request.additionalHeaders ?? [String: String]() + headers[Self.authorizationHeader] = Self.bearerPrefix + token + request.additionalHeaders = headers + } + + private func removeBearer(from request: OneSignalRequest) { + var headers = request.additionalHeaders + headers?.removeValue(forKey: Self.authorizationHeader) + request.additionalHeaders = headers + } + + /// The token the Request carries, which is the one the server just rejected. + private func signedToken(of request: OneSignalRequest) -> String? { + guard let header = request.additionalHeaders?[Self.authorizationHeader], + header.hasPrefix(Self.bearerPrefix) + else { + return nil + } + return String(header.dropFirst(Self.bearerPrefix.count)) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift index cc9dd2211..a12407f7b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift @@ -107,6 +107,7 @@ class OSSubscriptionModel: OSModel { var subscriptionId: String? var reachable: Bool var isDisabled: Bool + var isDisabledInternally: Bool var notificationTypes: Int var testType: Int? var deviceOs: String @@ -261,6 +262,54 @@ class OSSubscriptionModel: OSModel { } } + /** + Set by the SDK on `logout` under Identity Verification, where no anonymous user is created to send to. + Only `reportedEnablement` reads it, so `_isDisabled` and `notificationTypes` keep the app's own opt-in + state and clearing this restores what the app asked for. + + The app's push subscription observer does not fire — its opt-in preference has not changed, only what + the SDK reports while there is no user to report it for — but setting this does queue an Update + Subscription, which is how the server learns the device stopped listening. + */ + var _isDisabledInternally: Bool { + get { stateLock.withLock { state.isDisabledInternally } } + set { setDisabledInternally(newValue, sendUpdate: true) } + } + + /// Restores reporting without an Update Subscription, for `login`: its Create User already carries + /// the re-enabled subscription. + func clearDisabledInternallyForLogin() { + setDisabledInternally(false, sendUpdate: false) + } + + private func setDisabledInternally(_ disabled: Bool, sendUpdate: Bool) { + let oldValue = swapValue(\.isDisabledInternally, to: disabled) + guard disabled != oldValue else { + return + } + self.set(property: "isDisabledInternally", newValue: disabled, preventServerUpdate: !sendUpdate) + } + + /** + The `enabled` and `notification_types` to report, which the server reads as a pair. An internal + disable overrides both. `notificationTypes` is nil when there is no value to send. + + Taken from one snapshot so the two cannot disagree, and shared with Update Subscription so a + subscription reports the same thing however the Request was built. + */ + func reportedEnablement() -> (enabled: Bool, notificationTypes: Int?) { + return reportedEnablement(from: snapshot()) + } + + private func reportedEnablement(from state: State) -> (enabled: Bool, notificationTypes: Int?) { + guard !state.isDisabledInternally else { + return (false, -2) + } + let enabled = calculateIsEnabled(address: state.address, reachable: state.reachable, isDisabled: state.isDisabled) + // notificationTypes defaults to -1 instead of nil, don't send if it's -1 + return (enabled, state.notificationTypes == -1 ? nil : state.notificationTypes) + } + // Properties for push subscription var testType: Int? { get { stateLock.withLock { state.testType } } @@ -365,6 +414,7 @@ class OSSubscriptionModel: OSModel { subscriptionId: subscriptionId, reachable: reachable, isDisabled: isDisabled, + isDisabledInternally: false, notificationTypes: notificationTypes, testType: testType, deviceOs: UIDevice.current.systemVersion, @@ -386,6 +436,7 @@ class OSSubscriptionModel: OSModel { coder.encode(state.subscriptionId, forKey: "subscriptionId") coder.encode(state.reachable, forKey: "_reachable") coder.encode(state.isDisabled, forKey: "_isDisabled") + coder.encode(state.isDisabledInternally, forKey: "isDisabledInternally") coder.encode(state.notificationTypes, forKey: "notificationTypes") coder.encode(state.testType, forKey: "testType") coder.encode(state.deviceOs, forKey: "deviceOs") @@ -409,6 +460,9 @@ class OSSubscriptionModel: OSModel { subscriptionId: coder.decodeObject(forKey: "subscriptionId") as? String, reachable: coder.decodeBool(forKey: "_reachable"), isDisabled: coder.decodeBool(forKey: "_isDisabled"), + // A model archived while logged out under Identity Verification stays internally disabled + // until the next login clears it. + isDisabledInternally: coder.decodeBool(forKey: "isDisabledInternally"), notificationTypes: coder.decodeInteger(forKey: "notificationTypes"), testType: coder.decodeObject(forKey: "testType") as? Int, deviceOs: coder.decodeObject(forKey: "deviceOs") as? String ?? UIDevice.current.systemVersion, @@ -457,16 +511,17 @@ class OSSubscriptionModel: OSModel { json["id"] = state.subscriptionId json["type"] = state.type.rawValue json["token"] = state.address - json["enabled"] = calculateIsEnabled(address: state.address, reachable: state.reachable, isDisabled: state.isDisabled) json["test_type"] = state.testType json["device_os"] = state.deviceOs json["sdk"] = state.sdk json["device_model"] = state.deviceModel json["app_version"] = state.appVersion json["net_type"] = state.netType - // notificationTypes defaults to -1 instead of nil, don't send if it's -1 - if state.notificationTypes != -1 { - json["notification_types"] = state.notificationTypes + + let enablement = reportedEnablement(from: state) + json["enabled"] = enablement.enabled + if let notificationTypes = enablement.notificationTypes { + json["notification_types"] = notificationTypes } return json } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift index eaf4c7784..859353d90 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift @@ -33,6 +33,38 @@ import OneSignalOSCore tells it when that token stopped being accepted. */ extension OneSignalUserManagerImpl { + /** + Whether `login` may promote the current anonymous user with Identify User instead of creating a new one. + + Identify User adds an `external_id` to a user that has none, and under Identity Verification no such + user is ever sent to the server, so every login has to create its user instead. While the requirement is + unknown this still promotes: the queue is held until it is known, and `OSUserExecutor` then turns the + promotion into the Create User it should have been if the app turns out to require auth. + */ + var canPromoteAnonymousUser: Bool { + return !identityVerificationService.ivBehaviorActive + } + + /** + Stores a token for `externalId` and releases everything held for want of one, so it goes out now: + the Repo's Deltas, the User executor's own queue (which is not Repo-driven), and — over the + 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. + */ + func storeJwt(externalId: String, token: String) { + guard userJwtRepo.updateJwt(externalId: externalId, token: token) else { + return + } + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignalUserManager stored a JWT for externalId: \(externalId)") + guard identityVerificationService.newCodePathsRun else { + return + } + operationRepo.addFlushDeltaQueueToDispatchQueue() + userExecutor?.executePendingRequests() + } + /** Replays any ask that already fired this session, so a listener registered after `start` or `login` still hears who currently owes a token. diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 7cee71be5..77a9e374d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -128,10 +128,6 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { let newRecordsState = OSNewRecordsState() - // Injected into the model store listeners so a Delta is enqueued against a known repo rather - // than reaching for the singleton. A later PR replaces this with an owned instance. - let operationRepo = OSOperationRepo.sharedInstance - // Hydrated before this class is started, jwtConfig by remote params and featureManager by // OSFeatureFlagsRefreshService, so a fresh instance here would read none of it. let featureManager = OSFeatureManager.shared @@ -206,6 +202,8 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { // These must be initialized in init() let userJwtRepo: OSUserJwtRepo + let requestAuth: OSRequestAuthorizing + let operationRepo: OSOperationRepo let identityModelStoreListener: OSIdentityModelStoreListener let propertiesModelStoreListener: OSPropertiesModelStoreListener let subscriptionModelStoreListener: OSSubscriptionModelStoreListener @@ -220,6 +218,7 @@ 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 userJwtRepo = OSUserJwtRepo(identityModelRepo: identityModelRepo) { externalId in @@ -227,6 +226,8 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { } self.identityVerificationService = identityVerificationService self.userJwtRepo = userJwtRepo + self.requestAuth = OSRequestAuth(identityVerificationService: identityVerificationService, jwt: userJwtRepo) + self.operationRepo = operationRepo self.identityModelStoreListener = OSIdentityModelStoreListener(store: identityModelStore, operationRepo: operationRepo) self.propertiesModelStoreListener = OSPropertiesModelStoreListener(store: propertiesModelStore, operationRepo: operationRepo) self.subscriptionModelStoreListener = OSSubscriptionModelStoreListener(store: subscriptionModelStore, operationRepo: operationRepo) @@ -259,6 +260,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { pushSubscriptionModelStore.refresh() // Same prewarm gap as the stores: init may have read UserDefaults while it was locked. jwtConfig.refreshIfUnknown() + operationRepo.refreshIfEmpty() OSNotificationsManager.delegate = self @@ -282,24 +284,44 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { // TODO: Update the push sub model with any new state from NotificationsManager + /* + Clears an internal disable that Identity Verification no longer needs: either the app turned + the requirement off while logged out, or `logout` guessed on while it was still unknown. + `login` is otherwise the only clear, and would leave the subscription silenced until the next + one. Sends an update, unlike the login path: nothing else will tell the server. + + Registered before the User executor's handler so anything it sends on this hydration already + carries the restored subscription — a Create User response hydrates `enabled` back onto the + app's own opt-in, which would make the silencing permanent. + */ + identityVerificationService.addOnJwtConfigHydratedHandler(for: .userManager) { [weak self] requirement in + guard requirement == .off else { + return + } + self?.pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?._isDisabledInternally = false + } + // Setup the executors // The OSUserExecutor has to run first, before other executors - self.userExecutor = OSUserExecutor(newRecordsState: newRecordsState) - OSOperationRepo.sharedInstance.start() + self.userExecutor = OSUserExecutor(newRecordsState: newRecordsState, identityVerificationService: identityVerificationService, auth: requestAuth) // Cannot initialize these executors in `init` as they reference the sharedInstance - let propertyExecutor = OSPropertyOperationExecutor(newRecordsState: newRecordsState) - let identityExecutor = OSIdentityOperationExecutor(newRecordsState: newRecordsState) - let subscriptionExecutor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState) - let customEventsExecutor = OSCustomEventsExecutor(newRecordsState: newRecordsState) + let propertyExecutor = OSPropertyOperationExecutor(newRecordsState: newRecordsState, auth: requestAuth) + let identityExecutor = OSIdentityOperationExecutor(newRecordsState: newRecordsState, auth: requestAuth) + let subscriptionExecutor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState, auth: requestAuth) + let customEventsExecutor = OSCustomEventsExecutor(newRecordsState: newRecordsState, auth: requestAuth) self.propertyExecutor = propertyExecutor self.identityExecutor = identityExecutor self.subscriptionExecutor = subscriptionExecutor self.customEventsExecutor = customEventsExecutor - OSOperationRepo.sharedInstance.addExecutor(identityExecutor) - OSOperationRepo.sharedInstance.addExecutor(propertyExecutor) - OSOperationRepo.sharedInstance.addExecutor(subscriptionExecutor) - OSOperationRepo.sharedInstance.addExecutor(customEventsExecutor) + operationRepo.addExecutor(identityExecutor) + operationRepo.addExecutor(propertyExecutor) + operationRepo.addExecutor(subscriptionExecutor) + operationRepo.addExecutor(customEventsExecutor) + + // After the executors: a cached requirement makes `start()` flush right away, and the + // Deltas restored at launch can only route once the map above is populated. + operationRepo.start() // Path 2. There is a legacy player to migrate if let legacyPlayerId = OneSignalUserDefaults.initShared().getSavedString(forKey: OSUD_LEGACY_PLAYER_ID, defaultValue: nil) { @@ -356,10 +378,13 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { } OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignal.User login called with externalId: \(externalId)") + // Ungated: a subscription internally disabled by a previous logout has to come back even if + // Identity Verification has since been turned off. + pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?.clearDisabledInternallyForLogin() + // Logging into an identified user from an anonymous user - if let user = _user, user.isAnonymous { - user.identityModel.jwtBearerToken = token - identifyUser(externalId: externalId, currentUser: user) + if let user = _user, user.isAnonymous, canPromoteAnonymousUser { + identifyUser(externalId: externalId, currentUser: user, token: token) } else { // Logging into identified -> anon, identified -> identified, or nil -> identified _ = createNewUser(externalId: externalId, token: token) @@ -402,6 +427,11 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { if let user = _user { guard user.identityModel.externalId != externalId || externalId == nil else { OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignalUserManager.createNewUser: not creating new user due to logging into the same user.)") + // Re-logging in is how an app hands over a replacement token, so take it and let anything + // held for want of one go out. + if let externalId = externalId, let token = token { + storeJwt(externalId: externalId, token: token) + } return user } } @@ -414,7 +444,9 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { } let newUser = setNewInternalUser(externalId: externalId, pushSubscriptionModel: pushSubscriptionModel) - newUser.identityModel.jwtBearerToken = token + if let externalId = externalId, let token = token { + storeJwt(externalId: externalId, token: token) + } userExecutor!.createUser(newUser) return newUser } @@ -426,7 +458,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { 1. This externalId already exists on another user. We create a new SDK user and fetch that user's information. 2. This externalId doesn't exist on any users. We successfully identify the user, but we still create a new SDK user and fetch to update it. */ - private func identifyUser(externalId: String, currentUser: OSUserInternal) { + private func identifyUser(externalId: String, currentUser: OSUserInternal, token: String?) { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else { return } @@ -438,6 +470,11 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { let pushSubscriptionModel = pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY) prepareForNewUser() let newUser = setNewInternalUser(externalId: externalId, pushSubscriptionModel: pushSubscriptionModel) + // 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) + } // Now proceed to identify the previous user userExecutor!.identifyUser( @@ -485,7 +522,30 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { OneSignalLog.onesignalLog(.LL_DEBUG, message: "OneSignal.User logout called, but the user is currently anonymous, so not logging out.") return } + /* + The replacement anonymous user is never created on the server under Identity Verification, so two + things it would otherwise have done have to happen here: stop reporting the push subscription, which + still carries the logged-out user's subscription ID, and tell observers that nobody is signed in. + + Only the app's own `logout()`. `_logout()` also runs as 404 recovery, where the SDK is replacing a + user the server no longer has and the subscription should keep reporting. + + While the requirement is still unknown, guess on: the wrong guess over-silences until hydrate-to-off + restores reporting, and the other guess would keep delivering the logged-out user's pushes. Read + once so hydration cannot flip the two sides mid-logout. + */ + let shouldSilenceForLogout = identityVerificationService.requirement != .off + + if shouldSilenceForLogout { + // Before the switch, so the unsubscribe is stamped with the outgoing user. + user.pushSubscriptionModel._isDisabledInternally = true + } + _logout() + + if shouldSilenceForLogout { + OSUserStateSnapshot.fireUserStateChanged(newOnesignalId: nil, newExternalId: nil) + } } public func _logout() { @@ -493,20 +553,11 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { _user = nil createUserIfNil() } +} - /** - Stores a token for `externalId`, so that the pending ask for this user is cleared and a later - rejection can ask again. - - Every app-supplied token arrives here, from `login` as well as `updateUserJwt`. - */ - func storeJwt(externalId: String, token: String) { - guard userJwtRepo.updateJwt(externalId: externalId, token: token) else { - return - } - OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignalUserManager stored a JWT for externalId: \(externalId)") - } +// MARK: - User setup helpers +extension OneSignalUserManagerImpl { @objc public func clearAllModelsFromStores() { prepareForNewUser() @@ -617,7 +668,6 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { } updatePropertiesDeltas(property: .purchases, value: purchases) } - } // MARK: - Sessions @@ -632,7 +682,7 @@ extension OneSignalUserManagerImpl { start() userExecutor!.executePendingRequests() - OSOperationRepo.sharedInstance.paused = false + operationRepo.paused = false updatePropertiesDeltas(property: .session_count, value: 1, flush: true) // Fetch the user's data if there is a onesignal_id @@ -671,7 +721,7 @@ extension OneSignalUserManagerImpl { property: property.rawValue, value: value ) - OSOperationRepo.sharedInstance.enqueueDelta(delta, flush: flush) + operationRepo.enqueueDelta(delta, flush: flush) } /// Time processors forward the session time to this method. @@ -689,10 +739,9 @@ extension OneSignalUserManagerImpl { */ @objc public func runBackgroundTasks() { - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue(inBackground: true) + operationRepo.addFlushDeltaQueueToDispatchQueue(inBackground: true) } } - extension OneSignalUserManagerImpl: OSUser { public var User: OSUser { start() @@ -896,78 +945,7 @@ extension OneSignalUserManagerImpl: OSUser { property: name, value: processedProperties ) - OSOperationRepo.sharedInstance.enqueueDelta(delta) - } -} - -extension OneSignalUserManagerImpl { - @objc - public class OSPushSubscriptionImpl: NSObject, OSPushSubscription { - - let pushSubscriptionModelStore: OSModelStore - - private var _pushSubscriptionStateChangesObserver: OSObservable? - var pushSubscriptionStateChangesObserver: OSObservable { - if let observer = _pushSubscriptionStateChangesObserver { - return observer - } - let pushSubscriptionStateChangesObserver = OSObservable(change: #selector(OSPushSubscriptionObserver.onPushSubscriptionDidChange(state:))) - _pushSubscriptionStateChangesObserver = pushSubscriptionStateChangesObserver - - return pushSubscriptionStateChangesObserver - } - - init(pushSubscriptionModelStore: OSModelStore) { - self.pushSubscriptionModelStore = pushSubscriptionModelStore - } - - public func addObserver(_ observer: OSPushSubscriptionObserver) { - // This is a method in the User namespace that doesn't require privacy consent first - self.pushSubscriptionStateChangesObserver.addObserver(observer) - } - - public func removeObserver(_ observer: OSPushSubscriptionObserver) { - self.pushSubscriptionStateChangesObserver.removeObserver(observer) - } - - public var id: String? { - guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.id") else { - return nil - } - return pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?.subscriptionId - } - - public var token: String? { - guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.token") else { - return nil - } - return pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?.address - } - - public var optedIn: Bool { - guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.optedIn") else { - return false - } - return pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?.optedIn ?? false - } - - /** - Enable the push subscription, and prompts if needed. `optedIn` can still be `false` after `optIn()` is called if permission is not granted. - */ - public func optIn() { - guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.optIn") else { - return - } - pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?._isDisabled = false - OSNotificationsManager.requestPermission(nil, fallbackToSettings: true) - } - - public func optOut() { - guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.optOut") else { - return - } - pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?._isDisabled = true - } + operationRepo.enqueueDelta(delta) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestAddAliases.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestAddAliases.swift index a4ad372a0..9e3549e7c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestAddAliases.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestAddAliases.swift @@ -38,23 +38,28 @@ class OSRequestAddAliases: OneSignalRequest, OSUserRequest { var identityModel: OSIdentityModel let aliases: [String: String] + /// See the ownership convention in `OSUserRequest.swift`. + let ownerExternalId: String? + /// requires a `onesignal_id` to send this request - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { if let onesignalId = identityModel.onesignalId, newRecordsState.canAccess(onesignalId), - let appId = OneSignalIdentifiers.currentAppId + let appId = OneSignalIdentifiers.currentAppId, + let alias = auth.authorizeUserScoped(self, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, onesignalId)), + let aliasId = OSUrlPath.segment(alias.id) { - self.addJWTHeader(identityModel: identityModel) - self.path = "apps/\(appId)/users/by/\(OS_ONESIGNAL_ID)/\(onesignalId)/identity" + self.path = "apps/\(appId)/users/by/\(alias.label)/\(aliasId)/identity" return true } else { return false } } - init(aliases: [String: String], identityModel: OSIdentityModel) { + init(aliases: [String: String], identityModel: OSIdentityModel, ownerExternalId: String?) { self.identityModel = identityModel self.aliases = aliases + self.ownerExternalId = ownerExternalId self.stringDescription = "" super.init() self.parameters = ["identity": aliases] @@ -63,6 +68,7 @@ class OSRequestAddAliases: OneSignalRequest, OSUserRequest { func encode(with coder: NSCoder) { coder.encode(identityModel, forKey: "identityModel") + coder.encode(ownerExternalId, forKey: "ownerExternalId") coder.encode(aliases, forKey: "aliases") coder.encode(parameters, forKey: "parameters") coder.encode(method.rawValue, forKey: "method") // Encodes as String @@ -82,6 +88,7 @@ class OSRequestAddAliases: OneSignalRequest, OSUserRequest { } self.identityModel = identityModel self.aliases = aliases + self.ownerExternalId = coder.decodeObject(forKey: "ownerExternalId") as? String self.stringDescription = "" super.init() self.parameters = parameters diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateSubscription.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateSubscription.swift index e95281e5e..90467757d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateSubscription.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateSubscription.swift @@ -43,23 +43,28 @@ class OSRequestCreateSubscription: OneSignalRequest, OSUserRequest { var subscriptionModel: OSSubscriptionModel var identityModel: OSIdentityModel + /// See the ownership convention in `OSUserRequest.swift`. + let ownerExternalId: String? + // Need the onesignal_id of the user - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { if let onesignalId = identityModel.onesignalId, newRecordsState.canAccess(onesignalId), - let appId = OneSignalIdentifiers.currentAppId + let appId = OneSignalIdentifiers.currentAppId, + let alias = auth.authorizeUserScoped(self, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, onesignalId)), + let aliasId = OSUrlPath.segment(alias.id) { - self.addJWTHeader(identityModel: identityModel) - self.path = "apps/\(appId)/users/by/\(OS_ONESIGNAL_ID)/\(onesignalId)/subscriptions" + self.path = "apps/\(appId)/users/by/\(alias.label)/\(aliasId)/subscriptions" return true } else { return false } } - init(subscriptionModel: OSSubscriptionModel, identityModel: OSIdentityModel) { + init(subscriptionModel: OSSubscriptionModel, identityModel: OSIdentityModel, ownerExternalId: String?) { self.subscriptionModel = subscriptionModel self.identityModel = identityModel + self.ownerExternalId = ownerExternalId self.stringDescription = "" super.init() self.parameters = ["subscription": subscriptionModel.jsonRepresentation()] @@ -69,6 +74,7 @@ class OSRequestCreateSubscription: OneSignalRequest, OSUserRequest { func encode(with coder: NSCoder) { coder.encode(subscriptionModel, forKey: "subscriptionModel") coder.encode(identityModel, forKey: "identityModel") + coder.encode(ownerExternalId, forKey: "ownerExternalId") coder.encode(parameters, forKey: "parameters") coder.encode(method.rawValue, forKey: "method") // Encodes as String coder.encode(timestamp, forKey: "timestamp") @@ -87,6 +93,7 @@ class OSRequestCreateSubscription: OneSignalRequest, OSUserRequest { } self.subscriptionModel = subscriptionModel self.identityModel = identityModel + self.ownerExternalId = coder.decodeObject(forKey: "ownerExternalId") as? String self.stringDescription = "" super.init() self.parameters = parameters diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateUser.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateUser.swift index a74514f11..d039ce53a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateUser.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateUser.swift @@ -45,8 +45,20 @@ class OSRequestCreateUser: OneSignalRequest, OSUserRequest { var pushSubscriptionModel: OSSubscriptionModel? var originalPushToken: String? + /** + Whether the response's IDs should enter `newRecordsState`. + + `true` for a real create; `false` for the Identify-409 recovery Create, which only hydrates the + `onesignal_id` of a user that already exists. Stamped at init so stripping push later cannot + flip a real create into the recovery path. + */ + let addsNewRecords: Bool + + /// See the ownership convention in `OSUserRequest.swift`. + var ownerExternalId: String? { return identityModel.externalId } + /// Checks if the subscription ID can be accessed, if a subscription is being included in the request - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { guard let appId = OneSignalIdentifiers.currentAppId else { OneSignalLog.onesignalLog(.LL_ERROR, message: "Cannot generate the create user request due to null app ID.") return false @@ -59,8 +71,13 @@ class OSRequestCreateUser: OneSignalRequest, OSUserRequest { return false } + // The path is app-scoped and the identity object already names the user, so there is no + // alias to swap — only the token. + guard auth.authorize(self) else { + return false + } + _ = self.addPushSubscriptionIdToAdditionalHeaders() - self.addJWTHeader(identityModel: identityModel) self.path = "apps/\(appId)/users" return true } @@ -79,6 +96,7 @@ class OSRequestCreateUser: OneSignalRequest, OSUserRequest { self.identityModel = identityModel self.pushSubscriptionModel = pushSubscriptionModel self.originalPushToken = originalPushToken + self.addsNewRecords = true self.stringDescription = "" super.init() @@ -104,6 +122,7 @@ class OSRequestCreateUser: OneSignalRequest, OSUserRequest { init(aliasLabel: String, aliasId: String, identityModel: OSIdentityModel) { self.identityModel = identityModel + self.addsNewRecords = false self.stringDescription = "" super.init() self.parameters = [ @@ -117,6 +136,7 @@ class OSRequestCreateUser: OneSignalRequest, OSUserRequest { coder.encode(identityModel, forKey: "identityModel") coder.encode(pushSubscriptionModel, forKey: "pushSubscriptionModel") coder.encode(originalPushToken, forKey: "originalPushToken") + coder.encode(addsNewRecords, forKey: "addsNewRecords") coder.encode(parameters, forKey: "parameters") coder.encode(method.rawValue, forKey: "method") // Encodes as String coder.encode(timestamp, forKey: "timestamp") @@ -135,6 +155,10 @@ class OSRequestCreateUser: OneSignalRequest, OSUserRequest { self.identityModel = identityModel self.pushSubscriptionModel = coder.decodeObject(forKey: "pushSubscriptionModel") as? OSSubscriptionModel self.originalPushToken = coder.decodeObject(forKey: "originalPushToken") as? String + // Safe if the key was never written: extra cool-down, not a skipped one. + self.addsNewRecords = coder.containsValue(forKey: "addsNewRecords") + ? coder.decodeBool(forKey: "addsNewRecords") + : true self.stringDescription = "" super.init() self.parameters = parameters diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCustomEvents.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCustomEvents.swift index 83ec1e565..4104ff711 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCustomEvents.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCustomEvents.swift @@ -37,12 +37,19 @@ class OSRequestCustomEvents: OneSignalRequest, OSUserRequest { var identityModel: OSIdentityModel - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + /// See the ownership convention in `OSUserRequest.swift`. + let ownerExternalId: String? + + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { if let onesignalId = identityModel.onesignalId, newRecordsState.canAccess(onesignalId), - let appId = OneSignalIdentifiers.currentAppId + let appId = OneSignalIdentifiers.currentAppId, + auth.authorize(self) { _ = self.addPushSubscriptionIdToAdditionalHeaders() + if auth.ivBehaviorActive, let externalId = ownerExternalId { + addExternalIdToEvents(externalId) + } self.path = "apps/\(appId)/custom_events" return true } else { @@ -50,8 +57,22 @@ class OSRequestCustomEvents: OneSignalRequest, OSUserRequest { } } - init(events: [[String: Any]], identityModel: OSIdentityModel) { + /// The path is app-scoped, so the owner rides in each event's body rather than in the path. + /// Written at send time rather than at init so a cached payload cannot outlive the gate. + private func addExternalIdToEvents(_ externalId: String) { + guard let events = self.parameters?["events"] as? [[String: Any]] else { + return + } + self.parameters?["events"] = events.map { event in + var event = event + event[OS_EXTERNAL_ID] = externalId + return event + } + } + + init(events: [[String: Any]], identityModel: OSIdentityModel, ownerExternalId: String?) { self.identityModel = identityModel + self.ownerExternalId = ownerExternalId self.stringDescription = "" super.init() self.parameters = [ @@ -62,6 +83,7 @@ class OSRequestCustomEvents: OneSignalRequest, OSUserRequest { func encode(with coder: NSCoder) { coder.encode(identityModel, forKey: "identityModel") + coder.encode(ownerExternalId, forKey: "ownerExternalId") coder.encode(parameters, forKey: "parameters") coder.encode(method.rawValue, forKey: "method") // Encodes as String coder.encode(timestamp, forKey: "timestamp") @@ -78,6 +100,7 @@ class OSRequestCustomEvents: OneSignalRequest, OSUserRequest { return nil } self.identityModel = identityModel + self.ownerExternalId = coder.decodeObject(forKey: "ownerExternalId") as? String self.stringDescription = "" super.init() self.parameters = parameters diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestDeleteSubscription.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestDeleteSubscription.swift index 43cd37e3b..cf162e809 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestDeleteSubscription.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestDeleteSubscription.swift @@ -42,11 +42,19 @@ class OSRequestDeleteSubscription: OneSignalRequest, OSUserRequest { var subscriptionModel: OSSubscriptionModel + /** + See the ownership convention in `OSUserRequest.swift`. Removing an email or SMS subscription is a + deliberate action on one user, so an anonymous one is dropped under Identity Verification even + though the path addresses a subscription rather than a user. + */ + let ownerExternalId: String? + // Need the subscription_id - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { if let subscriptionId = subscriptionModel.subscriptionId, newRecordsState.canAccess(subscriptionId), - let appId = OneSignalIdentifiers.currentAppId + let appId = OneSignalIdentifiers.currentAppId, + auth.authorize(self) { self.path = "apps/\(appId)/subscriptions/\(subscriptionId)" return true @@ -55,8 +63,9 @@ class OSRequestDeleteSubscription: OneSignalRequest, OSUserRequest { } } - init(subscriptionModel: OSSubscriptionModel) { + init(subscriptionModel: OSSubscriptionModel, ownerExternalId: String?) { self.subscriptionModel = subscriptionModel + self.ownerExternalId = ownerExternalId self.stringDescription = "" super.init() self.method = DELETE @@ -64,6 +73,7 @@ class OSRequestDeleteSubscription: OneSignalRequest, OSUserRequest { func encode(with coder: NSCoder) { coder.encode(subscriptionModel, forKey: "subscriptionModel") + coder.encode(ownerExternalId, forKey: "ownerExternalId") coder.encode(method.rawValue, forKey: "method") // Encodes as String coder.encode(timestamp, forKey: "timestamp") } @@ -77,7 +87,8 @@ class OSRequestDeleteSubscription: OneSignalRequest, OSUserRequest { // Log error return nil } - self.subscriptionModel = subscriptionModel + self.subscriptionModel = subscriptionModel + self.ownerExternalId = coder.decodeObject(forKey: "ownerExternalId") as? String self.stringDescription = "" super.init() self.method = HTTPMethod(rawValue: rawMethod) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchIdentityBySubscription.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchIdentityBySubscription.swift index 207209f3e..e7ea9757a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchIdentityBySubscription.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchIdentityBySubscription.swift @@ -39,7 +39,11 @@ class OSRequestFetchIdentityBySubscription: OneSignalRequest, OSUserRequest { var identityModel: OSIdentityModel var pushSubscriptionModel: OSSubscriptionModel - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + /// Always nil, so this Request is never signed. It discovers which user owns a subscription during + /// the v4 upgrade, before there is an `external_id` to sign with. + var ownerExternalId: String? { return nil } + + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { // newRecordsState is unused for this request guard let appId = OneSignalIdentifiers.currentAppId else { OneSignalLog.onesignalLog(.LL_DEBUG, message: "Cannot generate the FetchIdentityBySubscription request due to null app ID.") diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchUser.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchUser.swift index 8bf9b2973..5c49b96be 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchUser.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchUser.swift @@ -44,15 +44,19 @@ class OSRequestFetchUser: OneSignalRequest, OSUserRequest { let aliasId: String let onNewSession: Bool - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + /// See the ownership convention in `OSUserRequest.swift`. + var ownerExternalId: String? { return identityModel.externalId } + + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { guard let appId = OneSignalIdentifiers.currentAppId, - newRecordsState.canAccess(aliasId) + newRecordsState.canAccess(aliasId), + let alias = auth.authorizeUserScoped(self, legacyAlias: OSAliasPair(aliasLabel, aliasId)), + let encodedAliasId = OSUrlPath.segment(alias.id) else { OneSignalLog.onesignalLog(.LL_DEBUG, message: "Cannot generate the fetch user request for \(aliasLabel): \(aliasId) yet.") return false } - self.addJWTHeader(identityModel: identityModel) - self.path = "apps/\(appId)/users/by/\(aliasLabel)/\(aliasId)" + self.path = "apps/\(appId)/users/by/\(alias.label)/\(encodedAliasId)" return true } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestIdentifyUser.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestIdentifyUser.swift index 880b2e405..3fd6f4fbd 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestIdentifyUser.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestIdentifyUser.swift @@ -47,14 +47,24 @@ class OSRequestIdentifyUser: OneSignalRequest, OSUserRequest { let aliasLabel: String let aliasId: String - /// requires a `onesignal_id` to send this request - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { - if let onesignalId = identityModelToIdentify.onesignalId, + /** + Always nil, so this Request is never signed. It adds an `external_id` to an anonymous user, and + Identity Verification does not allow anonymous users — `login` goes straight to Create User + instead, and the purge drops any that are already queued. + */ + var ownerExternalId: String? { return nil } + + /// Requires a `onesignal_id`, and refuses outright once Identity Verification is active: there is no + /// signed way to promote an anonymous user, so this can only sit until the purge reshapes it. + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { + if !auth.ivBehaviorActive, + let onesignalId = identityModelToIdentify.onesignalId, newRecordsState.canAccess(onesignalId), - let appId = OneSignalIdentifiers.currentAppId + let appId = OneSignalIdentifiers.currentAppId, + let alias = auth.authorizeUserScoped(self, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, onesignalId)), + let aliasId = OSUrlPath.segment(alias.id) { - self.addJWTHeader(identityModel: identityModelToIdentify) - self.path = "apps/\(appId)/users/by/\(OS_ONESIGNAL_ID)/\(onesignalId)/identity" + self.path = "apps/\(appId)/users/by/\(alias.label)/\(aliasId)/identity" return true } else { // self.path is non-nil, so set to empty string diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestRemoveAlias.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestRemoveAlias.swift index 207cebd5a..ff5a986d6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestRemoveAlias.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestRemoveAlias.swift @@ -38,22 +38,28 @@ class OSRequestRemoveAlias: OneSignalRequest, OSUserRequest { let labelToRemove: String var identityModel: OSIdentityModel - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + /// See the ownership convention in `OSUserRequest.swift`. + let ownerExternalId: String? + + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { if let onesignalId = identityModel.onesignalId, newRecordsState.canAccess(onesignalId), - let appId = OneSignalIdentifiers.currentAppId + let appId = OneSignalIdentifiers.currentAppId, + let alias = auth.authorizeUserScoped(self, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, onesignalId)), + let aliasId = OSUrlPath.segment(alias.id), + let encodedLabelToRemove = OSUrlPath.segment(labelToRemove) { - self.addJWTHeader(identityModel: identityModel) - self.path = "apps/\(appId)/users/by/\(OS_ONESIGNAL_ID)/\(onesignalId)/identity/\(labelToRemove)" + self.path = "apps/\(appId)/users/by/\(alias.label)/\(aliasId)/identity/\(encodedLabelToRemove)" return true } else { return false } } - init(labelToRemove: String, identityModel: OSIdentityModel) { + init(labelToRemove: String, identityModel: OSIdentityModel, ownerExternalId: String?) { self.labelToRemove = labelToRemove self.identityModel = identityModel + self.ownerExternalId = ownerExternalId self.stringDescription = "OSRequestRemoveAlias with aliasLabel: \(labelToRemove)" super.init() self.method = DELETE @@ -62,6 +68,7 @@ class OSRequestRemoveAlias: OneSignalRequest, OSUserRequest { func encode(with coder: NSCoder) { coder.encode(labelToRemove, forKey: "labelToRemove") coder.encode(identityModel, forKey: "identityModel") + coder.encode(ownerExternalId, forKey: "ownerExternalId") coder.encode(method.rawValue, forKey: "method") // Encodes as String coder.encode(timestamp, forKey: "timestamp") } @@ -78,6 +85,7 @@ class OSRequestRemoveAlias: OneSignalRequest, OSUserRequest { } self.labelToRemove = labelToRemove self.identityModel = identityModel + self.ownerExternalId = coder.decodeObject(forKey: "ownerExternalId") as? String self.stringDescription = "OSRequestRemoveAlias with aliasLabel: \(labelToRemove)" super.init() self.method = HTTPMethod(rawValue: rawMethod) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestTransferSubscription.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestTransferSubscription.swift index df0e589a8..1e074cc69 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestTransferSubscription.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestTransferSubscription.swift @@ -43,7 +43,9 @@ class OSRequestTransferSubscription: OneSignalRequest, OSUserRequest { let aliasLabel: String let aliasId: String - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + var ownerExternalId: String? { return nil } + + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { return false } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateProperties.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateProperties.swift index aed93ee04..96dd3d93c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateProperties.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateProperties.swift @@ -37,24 +37,29 @@ class OSRequestUpdateProperties: OneSignalRequest, OSUserRequest { var identityModel: OSIdentityModel + /// See the ownership convention in `OSUserRequest.swift`. + let ownerExternalId: String? + // TODO: Decide if addPushSubscriptionIdToAdditionalHeadersIfNeeded should block. // Note Android adds it to requests, if the push sub ID exists - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { if let onesignalId = identityModel.onesignalId, newRecordsState.canAccess(onesignalId), - let appId = OneSignalIdentifiers.currentAppId + let appId = OneSignalIdentifiers.currentAppId, + let alias = auth.authorizeUserScoped(self, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, onesignalId)), + let aliasId = OSUrlPath.segment(alias.id) { _ = self.addPushSubscriptionIdToAdditionalHeaders() - self.addJWTHeader(identityModel: identityModel) - self.path = "apps/\(appId)/users/by/\(OS_ONESIGNAL_ID)/\(onesignalId)" + self.path = "apps/\(appId)/users/by/\(alias.label)/\(aliasId)" return true } else { return false } } - init(params: [String: Any], identityModel: OSIdentityModel) { + init(params: [String: Any], identityModel: OSIdentityModel, ownerExternalId: String?) { self.identityModel = identityModel + self.ownerExternalId = ownerExternalId self.stringDescription = "" super.init() self.parameters = params @@ -63,6 +68,7 @@ class OSRequestUpdateProperties: OneSignalRequest, OSUserRequest { func encode(with coder: NSCoder) { coder.encode(identityModel, forKey: "identityModel") + coder.encode(ownerExternalId, forKey: "ownerExternalId") coder.encode(parameters, forKey: "parameters") coder.encode(method.rawValue, forKey: "method") // Encodes as String coder.encode(timestamp, forKey: "timestamp") @@ -79,6 +85,8 @@ class OSRequestUpdateProperties: OneSignalRequest, OSUserRequest { return nil } self.identityModel = identityModel + // Absent in caches written before ownership was stamped; nil reads as anonymous. + self.ownerExternalId = coder.decodeObject(forKey: "ownerExternalId") as? String self.stringDescription = "" super.init() self.parameters = parameters diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateSubscription.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateSubscription.swift index 3f211fad5..55fe178d0 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateSubscription.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateSubscription.swift @@ -39,12 +39,29 @@ class OSRequestUpdateSubscription: OneSignalRequest, OSUserRequest { } var subscriptionModel: OSSubscriptionModel + /// The user this update was made for; used to file the response's RYW token under their + /// `onesignal_id`. `nil` drops the token. Held as the model because that ID may not exist + /// yet when the request is built. + var identityModel: OSIdentityModel? + + /** + Always nil, so this Request is never signed. Its path names a subscription rather than a user and the + endpoint ignores the token, while owning it would stall the device's push token and notification types + behind an identified user whose token went invalid. + + The Delta it comes from is owned, which is what decides whether the update survives the anonymous purge. + */ + var ownerExternalId: String? { return nil } + + /// The one Request Identity Verification lets through unowned, for the reason `ownerExternalId` gives. + var sendsUnsigned: Bool { return true } // Need the subscription_id - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { if let subscriptionId = subscriptionModel.subscriptionId, newRecordsState.canAccess(subscriptionId), - let appId = OneSignalIdentifiers.currentAppId + let appId = OneSignalIdentifiers.currentAppId, + auth.authorize(self) { self.path = "apps/\(appId)/subscriptions/\(subscriptionId)" // Refresh so a stale snapshot queued earlier can't overwrite newer local state. @@ -62,16 +79,18 @@ class OSRequestUpdateSubscription: OneSignalRequest, OSUserRequest { subscriptionParams["device_os"] = subscriptionModel.deviceOs subscriptionParams["sdk"] = subscriptionModel.sdk subscriptionParams["app_version"] = subscriptionModel.appVersion - // notificationTypes defaults to -1 instead of nil, don't send if it's -1 - if subscriptionModel.notificationTypes != -1 { - subscriptionParams["notification_types"] = subscriptionModel.notificationTypes + + let enablement = subscriptionModel.reportedEnablement() + subscriptionParams["enabled"] = enablement.enabled + if let notificationTypes = enablement.notificationTypes { + subscriptionParams["notification_types"] = notificationTypes } - subscriptionParams["enabled"] = subscriptionModel.enabled self.parameters = ["subscription": subscriptionParams] } - init(subscriptionModel: OSSubscriptionModel) { + init(subscriptionModel: OSSubscriptionModel, identityModel: OSIdentityModel?) { self.subscriptionModel = subscriptionModel + self.identityModel = identityModel self.stringDescription = "OSRequestUpdateSubscription with model: \(subscriptionModel.modelId)" super.init() refreshParametersFromLiveModel() @@ -80,6 +99,7 @@ class OSRequestUpdateSubscription: OneSignalRequest, OSUserRequest { func encode(with coder: NSCoder) { coder.encode(subscriptionModel, forKey: "subscriptionModel") + coder.encode(identityModel, forKey: "identityModel") coder.encode(parameters, forKey: "parameters") coder.encode(method.rawValue, forKey: "method") // Encodes as String coder.encode(timestamp, forKey: "timestamp") @@ -96,6 +116,7 @@ class OSRequestUpdateSubscription: OneSignalRequest, OSUserRequest { return nil } self.subscriptionModel = subscriptionModel + self.identityModel = coder.decodeObject(forKey: "identityModel") as? OSIdentityModel self.stringDescription = "OSRequestUpdateSubscription with parameters: \(parameters)" super.init() self.parameters = parameters diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift index 133473ba8..7842b4708 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift @@ -30,19 +30,48 @@ import OneSignalOSCore protocol OSUserRequest: OneSignalRequest, NSCoding { var sentToClient: Bool { get set } - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool + + /// The user this Request belongs to; also selects its token. See the ownership convention below. + var ownerExternalId: String? { get } + + /// Whether this Request may still be sent with no `Authorization` header once Identity Verification + /// is in effect. Only Update Subscription may: its path names a subscription rather than a user, so + /// there is no user for the server to authorize. Everything else with no owner is refused. + var sendsUnsigned: Bool { get } + + /// Builds the path and resolves authorization. `false` leaves the Request queued, whether it is + /// waiting on a record it cannot address yet or on a token it cannot sign with yet. A caller deciding + /// whether to *discard* a cached Request must not read `false` as permanent: an owned Request becomes + /// sendable once `updateUserJwt` supplies its token. + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool } -internal extension OneSignalRequest { - func addJWTHeader(identityModel: OSIdentityModel) { -// guard let token = identityModel.jwtBearerToken else { -// return -// } -// var additionalHeaders = self.additionalHeaders ?? [String:String]() -// additionalHeaders["Authorization"] = "Bearer \(token)" -// self.additionalHeaders = additionalHeaders - } +extension OSUserRequest { + var sendsUnsigned: Bool { return false } +} + +/* + Ownership convention: a Request that Identity Verification can purge stores `ownerExternalId`, the + owner's `external_id` as of when the Request was built, and both the purge and the token lookup + judge it by that rather than by its `identityModel`. + + The live model cannot answer the question. `clearUserData` empties an Identity Model's aliases before + a fetch response hydrates them, so for that window an identified user reads as anonymous and a purge + running alongside it would delete signed work. The stamp also matches how `OSDelta` carries + `externalId`, which keeps a Delta and the Request built from it judged the same way. + + nil means anonymous, including for caches written before ownership was stamped. + + Create User and Fetch User are not built from a Delta and the purge does not consider them, so they + read the owner off their Identity Model. Nothing is in flight when a purge runs, and the User + executor sends nothing while the requirement is unknown, so the live read is sound there. + Three Requests are nil by construction and so are never signed: Identify User and Fetch Identity By + Subscription both address a user that has no `external_id` yet, and Update Subscription is the + device's own push subscription. Each says why at its declaration. + */ + +internal extension OneSignalRequest { /** Returns if the `OneSignal-Subscription-Id` header was added successfully. */ func addPushSubscriptionIdToAdditionalHeaders() -> Bool { if let pushSubscriptionId = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift index 8554757b9..031b8ffb8 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift @@ -44,7 +44,10 @@ public class OneSignalUserMocks: NSObject { OSResilientStorage.keyHasPriorSession: "" ]) _ = OSResilientStorage.snapshot() - OSCoreMocks.resetOperationRepo() + // Drop the previous test's handlers before the hydrate below, or leftover Requests hydrate shared models. + OneSignalUserManagerImpl.sharedInstance.identityVerificationService.removeOnJwtConfigHydratedHandler(for: .userExecutor) + OneSignalUserManagerImpl.sharedInstance.identityVerificationService.removeOnJwtConfigHydratedHandler(for: .userManager) + OneSignalUserManagerImpl.sharedInstance.operationRepo.reset() OSCoreMocks.resetSharedJwtConfig() // Hydrate `off` so the Operation Repo's unknown-requirement deferral does not stall non-IV tests. OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/CustomEventsIntegrationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/CustomEventsIntegrationTests.swift index dfd03fda9..e9f567514 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/CustomEventsIntegrationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/CustomEventsIntegrationTests.swift @@ -62,7 +62,7 @@ final class CustomEventsIntegrationTests: XCTestCase { /* When */ userManager.trackEvent(name: "test_event", properties: properties) - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() OneSignalCoreMocks.waitUntil("Custom event request did not complete") { client.hasCompletedRequestOfType(OSRequestCustomEvents.self) } @@ -82,7 +82,7 @@ final class CustomEventsIntegrationTests: XCTestCase { /* When */ userManager.trackEvent(name: "test_event", properties: nil) - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() OneSignalCoreMocks.waitUntil("Custom event request did not complete") { client.hasCompletedRequestOfType(OSRequestCustomEvents.self) } @@ -102,7 +102,7 @@ final class CustomEventsIntegrationTests: XCTestCase { /* When */ userManager.trackEvent(name: "test_event", properties: [:]) - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() OneSignalCoreMocks.waitUntil("Custom event request did not complete") { client.hasCompletedRequestOfType(OSRequestCustomEvents.self) } @@ -125,7 +125,7 @@ final class CustomEventsIntegrationTests: XCTestCase { /* When */ userManager.trackEvent(name: "test_event", properties: invalidProperties) - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() /* Then - No request should be made */ XCTAssertFalse(client.hasExecutedRequestOfType(OSRequestCustomEvents.self)) @@ -159,7 +159,7 @@ final class CustomEventsIntegrationTests: XCTestCase { /* When */ userManager.trackEvent(name: "complex_event", properties: complexProperties) - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() OneSignalCoreMocks.waitUntil("Custom event request did not complete") { client.hasCompletedRequestOfType(OSRequestCustomEvents.self) } @@ -227,7 +227,7 @@ final class CustomEventsIntegrationTests: XCTestCase { /* When */ userManager.trackEvent(name: "array_event", properties: properties) - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() OneSignalCoreMocks.waitUntil("Custom event request did not complete") { client.hasCompletedRequestOfType(OSRequestCustomEvents.self) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift index 0f8cbbdad..f798e56ee 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift @@ -216,6 +216,40 @@ final class DeltaOwnershipTests: XCTestCase { XCTAssertEqual(delta.identityModelId, first.identityModel.modelId) } + // MARK: - Requests built from a Delta + + func testUpdateSubscriptionRequestIsBoundToTheDeltasOwner() throws { + let client = executingClient() + let user = newUser(externalId: userA) + let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState(), auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) + + executor.enqueueDelta(subscriptionUpdateDelta(owner: user.identityModel)) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("Update Subscription was not sent") { + client.executedRequests.contains { $0 is OSRequestUpdateSubscription } + } + + let request = try XCTUnwrap(client.executedRequests.compactMap { $0 as? OSRequestUpdateSubscription }.first) + XCTAssertTrue(request.identityModel === user.identityModel) + } + + /// Unknown owner still sends; only the RYW token is dropped, not misfiled under the current user. + func testASubscriptionRequestStillSendsWhenTheOwningIdentityIsUnknown() throws { + let client = executingClient() + newUser(externalId: userB) + let unknownOwner = OSIdentityModel(aliases: [OS_EXTERNAL_ID: userA], changeNotifier: OSEventProducer()) + let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState(), auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) + + executor.enqueueDelta(subscriptionUpdateDelta(owner: unknownOwner)) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("Update Subscription was not sent") { + client.executedRequests.contains { $0 is OSRequestUpdateSubscription } + } + + let request = try XCTUnwrap(client.executedRequests.compactMap { $0 as? OSRequestUpdateSubscription }.first) + XCTAssertNil(request.identityModel) + } + // MARK: - Helpers @discardableResult @@ -233,6 +267,13 @@ final class DeltaOwnershipTests: XCTestCase { ) } + private func executingClient() -> MockOneSignalClient { + let client = MockOneSignalClient() + client.fireSuccessForAllRequests = true + OneSignalCoreImpl.setSharedClient(client) + return client + } + private func emailSubscriptionModel() -> OSSubscriptionModel { return OSSubscriptionModel( type: .email, @@ -244,6 +285,18 @@ final class DeltaOwnershipTests: XCTestCase { ) } + private func subscriptionUpdateDelta(owner: OSIdentityModel) -> OSDelta { + let model = emailSubscriptionModel() + return OSDelta( + name: OS_UPDATE_SUBSCRIPTION_DELTA, + identityModelId: owner.modelId, + externalId: owner.externalId, + model: model, + property: model.type.rawValue, + value: model.address ?? "" + ) + } + private func queuedDelta(named name: String, property: String) -> OSDelta? { return OneSignalUserManagerImpl.sharedInstance.operationRepo.deltaQueue.first { $0.name == name && $0.property == property } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift new file mode 100644 index 000000000..ff103ab63 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift @@ -0,0 +1,427 @@ +/* + 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 OneSignalOSCore +import OneSignalCoreMocks +import OneSignalOSCoreMocks +import OneSignalUserMocks +@testable import OneSignalUser + +/** + What each executor drops from its queues when Identity Verification turns out to be required. + + Only ownership is judged: a Delta or Request carries the `external_id` of the user it was built for, and + one carrying none can never be signed. The auth layer refuses to send those, so the purge is what keeps + them from sitting in the queues unsendable for the rest of the session. + + Uncache also drops an owned Request whose identity model is gone, unless Identity Verification is on: + without it that Request can never become sendable. + */ +final class ExecutorAnonymousPurgeTests: XCTestCase { + private let anonymousOSID = "test-anonymous-onesignal-id" + private let ownedToken = "token-a" + private let anonymousSubscriptionId = "test-anonymous-subscription-id" + private let ownedSubscriptionId = "test-owned-subscription-id" + + private var client = MockOneSignalClient() + private var newRecordsState = MockNewRecordsState() + private var anonymous = OSIdentityModel(aliases: nil, changeNotifier: OSEventProducer()) + private var owned = OSIdentityModel(aliases: nil, changeNotifier: OSEventProducer()) + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + + client = MockOneSignalClient() + client.fireSuccessForAllRequests = true + OneSignalCoreImpl.setSharedClient(client) + newRecordsState = MockNewRecordsState() + // Presence is the hold: the production timer is a no-op under TEST. + newRecordsState.holdWhilePresent = true + + // The purge only ever runs because the requirement came back requiring auth. + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + anonymous = addUserToRepo(externalId: nil, onesignalId: anonymousOSID) + owned = addUserToRepo(externalId: userA_EUID, onesignalId: userA_OSID) + } + + override func tearDownWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + } + + // MARK: - Setup helpers + + /// A user the executors can resolve Deltas and Requests against. The identified one can sign. + private func addUserToRepo(externalId: String?, onesignalId: String) -> OSIdentityModel { + var aliases = [OS_ONESIGNAL_ID: onesignalId] + if let externalId = externalId { + aliases[OS_EXTERNAL_ID] = externalId + } + let model = OSIdentityModel(aliases: aliases, changeNotifier: OSEventProducer()) + if externalId != nil { + model.jwtBearerToken = ownedToken + } + OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(model) + return model + } + + /// Holds both users' ids, and any others passed, so nothing can be sent while they propagate. The + /// Requests the Deltas became then wait in the executor's queues, which is where the purge is visible. + private func holdIds(_ ids: String...) { + for id in [anonymousOSID, userA_OSID] + ids { + newRecordsState.add(id) + } + } + + private var auth: OSRequestAuthorizing { + return OneSignalUserManagerImpl.sharedInstance.requestAuth + } + + private func delta(_ name: String, for identityModel: OSIdentityModel, model: OSModel, property: String, value: Any) -> OSDelta { + return OSDelta( + name: name, + identityModelId: identityModel.modelId, + externalId: identityModel.externalId, + model: model, + property: property, + value: value + ) + } + + private func subscription(id: String) -> OSSubscriptionModel { + return OSSubscriptionModel( + type: .email, + address: "\(id)@example.com", + subscriptionId: id, + reachable: true, + isDisabled: false, + changeNotifier: OSEventProducer() + ) + } + + // MARK: - Assertion helpers + + private func cachedRequestOwners(_ key: String, of type: T.Type) -> [String?] { + let requests = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: key, defaultValue: []) as? [T] ?? [] + return requests.map { $0.ownerExternalId } + } + + private func executedPaths() -> [String] { + return client.executedRequests.map { $0.path } + } + + private func userPath(_ suffix: String = "") -> String { + return "apps/test-app-id/users/by/\(OS_EXTERNAL_ID)/\(userA_EUID)" + suffix + } + + // MARK: - Properties + + func testThePropertyExecutorSendsOnlyTheIdentifiedUsersUpdate() { + let executor = OSPropertyOperationExecutor(newRecordsState: newRecordsState, auth: auth) + executor.enqueueDelta(propertiesDelta(for: anonymous)) + executor.enqueueDelta(propertiesDelta(for: owned)) + + executor.removeOperationsWithoutExternalId() + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("The identified user's Update Properties was not sent") { + self.client.hasExecutedRequestOfType(OSRequestUpdateProperties.self, expectedCount: 1) + } + + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestUpdateProperties.self, expectedCount: 1)) + XCTAssertEqual(executedPaths(), [userPath()]) + } + + func testThePropertyExecutorDropsTheAnonymousUpdateRequest() { + holdIds() + let executor = OSPropertyOperationExecutor(newRecordsState: newRecordsState, auth: auth) + executor.enqueueDelta(propertiesDelta(for: anonymous)) + executor.enqueueDelta(propertiesDelta(for: owned)) + executor.processDeltaQueue(inBackground: false) + allowAsyncWorkToRun() + XCTAssertEqual(client.executedRequests.count, 0) + + executor.removeOperationsWithoutExternalId() + OneSignalCoreMocks.waitUntil("The purge did not reach the cached Update Properties queue") { + self.cachedRequestOwners(OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, of: OSRequestUpdateProperties.self) == [userA_EUID] + } + + XCTAssertEqual(cachedRequestOwners(OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, of: OSRequestUpdateProperties.self), [userA_EUID]) + } + + /// The purge writes through to the cache, so a relaunch reads back the identified user's Request alone. + func testAnAnonymousUpdateRequestIsNotRestoredAfterThePurge() { + holdIds() + let executor = OSPropertyOperationExecutor(newRecordsState: newRecordsState, auth: auth) + executor.enqueueDelta(propertiesDelta(for: anonymous)) + executor.enqueueDelta(propertiesDelta(for: owned)) + executor.processDeltaQueue(inBackground: false) + executor.removeOperationsWithoutExternalId() + // The relaunch reads the cache, so the purge has to have been written before it is built. + OneSignalCoreMocks.waitUntil("The purge did not reach the cached Update Properties queue") { + self.cachedRequestOwners(OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, of: OSRequestUpdateProperties.self) == [userA_EUID] + } + + // A records state with nothing held stands in for ids that have since propagated. + newRecordsState = MockNewRecordsState() + let relaunched = OSPropertyOperationExecutor(newRecordsState: newRecordsState, auth: auth) + relaunched.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("The restored Update Properties was not sent") { + self.client.hasExecutedRequestOfType(OSRequestUpdateProperties.self, expectedCount: 1) + } + + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestUpdateProperties.self, expectedCount: 1)) + XCTAssertEqual(executedPaths(), [userPath()]) + } + + private func propertiesDelta(for identityModel: OSIdentityModel) -> OSDelta { + return delta( + OS_UPDATE_PROPERTIES_DELTA, + for: identityModel, + model: OSModel(changeNotifier: OSEventProducer()), + property: "language", + value: "en" + ) + } + + // MARK: - Custom events + + func testTheCustomEventsExecutorSendsOnlyTheIdentifiedUsersEvents() { + let executor = OSCustomEventsExecutor(newRecordsState: newRecordsState, auth: auth) + executor.enqueueDelta(customEventDelta(for: anonymous)) + executor.enqueueDelta(customEventDelta(for: owned)) + + executor.removeOperationsWithoutExternalId() + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("The identified user's Custom Events was not sent") { + self.client.hasExecutedRequestOfType(OSRequestCustomEvents.self, expectedCount: 1) + } + + // The path names the app rather than the user, so the events themselves say who survived. + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestCustomEvents.self, expectedCount: 1)) + let events = client.executedRequests.first?.parameters?["events"] as? [[String: Any]] + XCTAssertEqual(events?.count, 1) + XCTAssertEqual(events?.first?["onesignal_id"] as? String, userA_OSID) + } + + func testTheCustomEventsExecutorDropsTheAnonymousEventsRequest() { + holdIds() + let executor = OSCustomEventsExecutor(newRecordsState: newRecordsState, auth: auth) + executor.enqueueDelta(customEventDelta(for: anonymous)) + executor.enqueueDelta(customEventDelta(for: owned)) + executor.processDeltaQueue(inBackground: false) + allowAsyncWorkToRun() + XCTAssertEqual(client.executedRequests.count, 0) + + executor.removeOperationsWithoutExternalId() + OneSignalCoreMocks.waitUntil("The purge did not reach the cached Custom Events queue") { + self.cachedRequestOwners(OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, of: OSRequestCustomEvents.self) == [userA_EUID] + } + + XCTAssertEqual(cachedRequestOwners(OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, of: OSRequestCustomEvents.self), [userA_EUID]) + } + + private func customEventDelta(for identityModel: OSIdentityModel) -> OSDelta { + return delta( + OS_CUSTOM_EVENT_DELTA, + for: identityModel, + model: OSModel(changeNotifier: OSEventProducer()), + property: "test_event", + value: ["test_property": "test-value"] + ) + } + + // MARK: - Identity + + func testTheIdentityExecutorSendsOnlyTheIdentifiedUsersAlias() { + let executor = OSIdentityOperationExecutor(newRecordsState: newRecordsState, auth: auth) + executor.enqueueDelta(addAliasDelta(for: anonymous)) + executor.enqueueDelta(addAliasDelta(for: owned)) + + executor.removeOperationsWithoutExternalId() + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("The identified user's Add Aliases was not sent") { + self.client.hasExecutedRequestOfType(OSRequestAddAliases.self, expectedCount: 1) + } + + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestAddAliases.self, expectedCount: 1)) + XCTAssertEqual(executedPaths(), [userPath("/identity")]) + } + + func testTheIdentityExecutorDropsBothAnonymousAliasRequests() { + holdIds() + let executor = OSIdentityOperationExecutor(newRecordsState: newRecordsState, auth: auth) + for identity in [anonymous, owned] { + executor.enqueueDelta(addAliasDelta(for: identity)) + executor.enqueueDelta(removeAliasDelta(for: identity)) + } + executor.processDeltaQueue(inBackground: false) + allowAsyncWorkToRun() + XCTAssertEqual(client.executedRequests.count, 0) + + executor.removeOperationsWithoutExternalId() + OneSignalCoreMocks.waitUntil("The purge did not reach both cached alias queues") { + self.cachedRequestOwners(OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, of: OSRequestAddAliases.self) == [userA_EUID] + && self.cachedRequestOwners(OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, of: OSRequestRemoveAlias.self) == [userA_EUID] + } + + XCTAssertEqual(cachedRequestOwners(OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, of: OSRequestAddAliases.self), [userA_EUID]) + XCTAssertEqual(cachedRequestOwners(OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, of: OSRequestRemoveAlias.self), [userA_EUID]) + } + + private func addAliasDelta(for identityModel: OSIdentityModel) -> OSDelta { + return delta( + OS_ADD_ALIAS_DELTA, + for: identityModel, + model: identityModel, + property: "aliases", + value: ["test_alias_label": "test-alias-id"] + ) + } + + private func removeAliasDelta(for identityModel: OSIdentityModel) -> OSDelta { + return delta( + OS_REMOVE_ALIAS_DELTA, + for: identityModel, + model: identityModel, + property: "aliases", + value: ["test_alias_label": ""] + ) + } + + // MARK: - Subscriptions + + func testTheSubscriptionExecutorSendsOnlyTheIdentifiedUsersNewSubscription() { + let executor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState, auth: auth) + executor.enqueueDelta(addSubscriptionDelta(for: anonymous, subscriptionId: anonymousSubscriptionId)) + executor.enqueueDelta(addSubscriptionDelta(for: owned, subscriptionId: ownedSubscriptionId)) + + executor.removeOperationsWithoutExternalId() + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("The identified user's Create Subscription was not sent") { + self.client.hasExecutedRequestOfType(OSRequestCreateSubscription.self, expectedCount: 1) + } + + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestCreateSubscription.self, expectedCount: 1)) + XCTAssertEqual(executedPaths(), [userPath("/subscriptions")]) + } + + func testTheSubscriptionExecutorDropsTheAnonymousAddAndDeleteRequests() { + holdIds(anonymousSubscriptionId, ownedSubscriptionId) + let executor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState, auth: auth) + enqueueSubscriptionWork(on: executor) + allowAsyncWorkToRun() + XCTAssertEqual(client.executedRequests.count, 0) + + executor.removeOperationsWithoutExternalId() + OneSignalCoreMocks.waitUntil("The purge did not reach both cached subscription queues") { + self.cachedRequestOwners(OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, of: OSRequestCreateSubscription.self) == [userA_EUID] + && self.cachedRequestOwners(OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, of: OSRequestDeleteSubscription.self) == [userA_EUID] + } + + XCTAssertEqual(cachedRequestOwners(OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, of: OSRequestCreateSubscription.self), [userA_EUID]) + XCTAssertEqual(cachedRequestOwners(OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, of: OSRequestDeleteSubscription.self), [userA_EUID]) + } + + /// An Update Subscription is addressed by subscription ID and never signed, so it has no owner to be + /// judged by and the purge has to leave that queue alone: `logout()`'s unsubscribe travels in it. + func testTheSubscriptionExecutorKeepsEveryUpdateRequest() { + holdIds(anonymousSubscriptionId, ownedSubscriptionId) + let executor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState, auth: auth) + enqueueSubscriptionWork(on: executor) + OneSignalCoreMocks.waitUntil("Both Update Subscriptions were not cached") { + self.cachedRequestOwners(OS_SUBSCRIPTION_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, of: OSRequestUpdateSubscription.self).count == 2 + } + + executor.removeOperationsWithoutExternalId() + // Nothing to poll for: the assertion is that the purge left this queue as it found it. + allowAsyncWorkToRun() + + let updateOwners = cachedRequestOwners(OS_SUBSCRIPTION_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, of: OSRequestUpdateSubscription.self) + XCTAssertEqual(updateOwners.count, 2) + XCTAssertTrue(updateOwners.allSatisfy { $0 == nil }) + } + + /// An add, a delete and an update for each user, turned into Requests that cannot be sent yet. + private func enqueueSubscriptionWork(on executor: OSSubscriptionOperationExecutor) { + for (identity, subscriptionId) in [(anonymous, anonymousSubscriptionId), (owned, ownedSubscriptionId)] { + let model = subscription(id: subscriptionId) + for name in [OS_ADD_SUBSCRIPTION_DELTA, OS_REMOVE_SUBSCRIPTION_DELTA, OS_UPDATE_SUBSCRIPTION_DELTA] { + executor.enqueueDelta(subscriptionDelta(name, for: identity, subscription: model)) + } + } + executor.processDeltaQueue(inBackground: false) + } + + private func addSubscriptionDelta(for identityModel: OSIdentityModel, subscriptionId: String) -> OSDelta { + return subscriptionDelta(OS_ADD_SUBSCRIPTION_DELTA, for: identityModel, subscription: subscription(id: subscriptionId)) + } + + private func subscriptionDelta(_ name: String, for identityModel: OSIdentityModel, subscription: OSSubscriptionModel) -> OSDelta { + return delta(name, for: identityModel, model: subscription, property: "optedIn", value: true) + } + + // MARK: - Uncache of owned Requests + + /// Without Identity Verification, an owned Request whose identity model is gone can never become sendable. + func testAnOwnedRequestWhoseModelIsGoneIsDroppedWhenIdentityVerificationIsOff() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + let modelId = cacheOwnedPropertyUpdateWithNoIdentityModelInRepo() + + _ = OSPropertyOperationExecutor(newRecordsState: newRecordsState, auth: auth) + + XCTAssertEqual(cachedRequestOwners(OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, of: OSRequestUpdateProperties.self), []) + XCTAssertNil(OneSignalUserManagerImpl.sharedInstance.getIdentityModel(modelId)) + } + + /// The same Request is kept: a token can still arrive for its owner. + func testAnOwnedRequestWhoseModelIsGoneIsKeptWhenIdentityVerificationIsOn() { + let modelId = cacheOwnedPropertyUpdateWithNoIdentityModelInRepo() + + _ = OSPropertyOperationExecutor(newRecordsState: newRecordsState, auth: auth) + + XCTAssertEqual(cachedRequestOwners(OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, of: OSRequestUpdateProperties.self), [userA_EUID]) + XCTAssertNotNil(OneSignalUserManagerImpl.sharedInstance.getIdentityModel(modelId)) + } + + /// No `onesignal_id`, so `prepareForExecution` fails. + private func cacheOwnedPropertyUpdateWithNoIdentityModelInRepo() -> String { + let orphan = OSIdentityModel(aliases: [OS_EXTERNAL_ID: userA_EUID], changeNotifier: OSEventProducer()) + let request = OSRequestUpdateProperties( + params: ["properties": ["language": "en"]], + identityModel: orphan, + ownerExternalId: userA_EUID + ) + OneSignalUserDefaults.initShared().saveCodeableData( + forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, + withValue: [request] + ) + return orphan.modelId + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift index eada431f9..079f9cc5c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift @@ -40,7 +40,7 @@ private class CustomEventsMocks { init() { OneSignalCoreImpl.setSharedClient(client) - customEventsExecutor = OSCustomEventsExecutor(newRecordsState: newRecordsState) + customEventsExecutor = OSCustomEventsExecutor(newRecordsState: newRecordsState, auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift index c5e98c78e..887eb083f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift @@ -59,7 +59,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { let model = makePushSubscriptionModel(notificationTypes: promptedNeverAnswered, subscriptionId: subscriptionId) XCTAssertFalse(model.enabled) - let request = OSRequestUpdateSubscription(subscriptionModel: model) + let request = OSRequestUpdateSubscription(subscriptionModel: model, identityModel: nil) let atInit = try XCTUnwrap(request.parameters?["subscription"] as? [String: Any]) XCTAssertEqual(atInit["notification_types"] as? Int, promptedNeverAnswered) XCTAssertEqual(atInit["enabled"] as? Bool, false) @@ -68,7 +68,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { model.notificationTypes = subscribedNotificationTypes XCTAssertTrue(model.enabled) - XCTAssertTrue(request.prepareForExecution(newRecordsState: OSNewRecordsState())) + XCTAssertTrue(request.prepareForExecution(newRecordsState: OSNewRecordsState(), auth: OneSignalUserManagerImpl.sharedInstance.requestAuth)) let refreshed = try XCTUnwrap(request.parameters?["subscription"] as? [String: Any]) XCTAssertEqual(refreshed["notification_types"] as? Int, subscribedNotificationTypes) @@ -85,7 +85,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { client.fireSuccessForAllRequests = true OneSignalCoreImpl.setSharedClient(client) - let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState()) + let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState(), auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) // Without a subscriptionId, prepareForExecution keeps the update pending. let model = makePushSubscriptionModel(notificationTypes: promptedNeverAnswered, subscriptionId: nil) let identityModelId = UUID().uuidString @@ -154,7 +154,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { client.fireSuccessForAllRequests = true OneSignalCoreImpl.setSharedClient(client) - let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState()) + let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState(), auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) let model = makePushSubscriptionModel(notificationTypes: promptedNeverAnswered, subscriptionId: subscriptionId) let identityModelId = UUID().uuidString @@ -221,7 +221,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { client.fireSuccessForAllRequests = true OneSignalCoreImpl.setSharedClient(client) - let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState()) + let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState(), auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) let model = makePushSubscriptionModel(notificationTypes: promptedNeverAnswered, subscriptionId: subscriptionId) let identityModelId = UUID().uuidString diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift index 36a2c712d..3f85a3774 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift @@ -39,9 +39,11 @@ private class Mocks { let newRecordsState = MockNewRecordsState() let userExecutor: OSUserExecutor - init() { + /// Stub before building the executor so a seeded Request cache cannot race the init-time send. + init(stubResponses: (MockOneSignalClient) -> Void = { _ in }) { OneSignalCoreImpl.setSharedClient(client) - userExecutor = OSUserExecutor(newRecordsState: newRecordsState) + stubResponses(client) + userExecutor = OSUserExecutor(newRecordsState: newRecordsState, identityVerificationService: OneSignalUserManagerImpl.sharedInstance.identityVerificationService, auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) } func createUserInstance(externalId: String) -> OSUserInternal { @@ -69,9 +71,12 @@ final class UserExecutorTests: XCTestCase { /* Setup */ let mocks = Mocks() MockUserRequests.setDefaultCreateUserResponses(with: mocks.client, externalId: userA_EUID, subscriptionId: "push-sub-id") + let user = mocks.createUserInstance(externalId: userA_EUID) + // Current so Create User keeps push; otherwise a prior-user create omits subscriptions. + OneSignalUserManagerImpl.sharedInstance._user = user /* When */ - mocks.userExecutor.createUser(mocks.createUserInstance(externalId: userA_EUID)) + mocks.userExecutor.createUser(user) OneSignalCoreMocks.waitUntil("Create user response was not applied") { mocks.newRecordsState.contains(userA_OSID) && mocks.newRecordsState.contains("push-sub-id") @@ -82,6 +87,61 @@ final class UserExecutorTests: XCTestCase { XCTAssertTrue(mocks.newRecordsState.contains("push-sub-id")) } + /// A Create User for a prior login must not include the device push subscription, which the + /// current user now owns — sending it would transfer that push on the server. + func testCreateUser_forPriorIdentifiedUser_omitsPushSubscription() { + /* Setup */ + let mocks = Mocks() + MockUserRequests.setDefaultCreateUserResponses(with: mocks.client, externalId: userA_EUID, subscriptionId: "push-sub-id") + + let sharedPush = OSSubscriptionModel( + type: .push, + address: "test-push-token", + subscriptionId: "shared-push-id", + reachable: true, + isDisabled: false, + changeNotifier: OSEventProducer() + ) + let priorIdentity = OSIdentityModel(aliases: [OS_EXTERNAL_ID: userA_EUID], changeNotifier: OSEventProducer()) + let priorProperties = OSPropertiesModel(changeNotifier: OSEventProducer()) + let priorCreate = OSRequestCreateUser( + identityModel: priorIdentity, + propertiesModel: priorProperties, + pushSubscriptionModel: sharedPush, + originalPushToken: sharedPush.address + ) + XCTAssertNotNil(priorCreate.parameters?["subscriptions"]) + + // Device push now belongs to the current user (B); the parked create still holds the same model. + let currentUser = mocks.createUserInstance(externalId: userB_EUID) + currentUser.pushSubscriptionModel.subscriptionId = "shared-push-id" + OneSignalUserManagerImpl.sharedInstance._user = currentUser + OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModelStore.add( + id: OS_PUSH_SUBSCRIPTION_MODEL_KEY, + model: sharedPush, + hydrating: false + ) + + /* When */ + mocks.userExecutor.executeCreateUserRequest(priorCreate) + // The new-record cool-down is stamped while handling the response, so it lands after the send. + OneSignalCoreMocks.waitUntil("Create User response was not handled") { + mocks.newRecordsState.contains(userA_OSID) + } + + /* Then */ + guard let sent = mocks.client.executedRequests.compactMap({ $0 as? OSRequestCreateUser }).first else { + XCTFail("Expected Create User to be sent") + return + } + XCTAssertNil(sent.parameters?["subscriptions"], "Must not transfer the current user's push to a prior Create User") + // Built as a real create, so cool-down the onesignal_id even though push was omitted at send. + XCTAssertTrue(mocks.newRecordsState.contains(userA_OSID)) + XCTAssertFalse(mocks.newRecordsState.contains("push-sub-id")) + XCTAssertFalse(mocks.newRecordsState.contains("shared-push-id")) + } + + /// Identify-409 recovery Create only hydrates an existing user, so its IDs are not new. func testCreateUser_withoutPushSubscription_doesNot_addToNewRecords() { /* Setup */ let mocks = Mocks() @@ -263,4 +323,250 @@ final class UserExecutorTests: XCTestCase { XCTAssertNil(currentUser.identityModel.aliases["stale_label"]) XCTAssertEqual(currentUser.identityModel.externalId, userA_EUID) } + + // MARK: - Identity Verification + + /// Cached Create User with no `external_id` must not go out once Identity Verification is required. + func testAnonymousCachedCreateUserIsDroppedWhenIdentityVerificationIsRequired() { + /* Setup */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + cacheUserRequests([makeAnonymousCreateUserRequest()]) + + /* When */ + let mocks = Mocks() + allowAsyncWorkToRun() + + /* Then */ + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + } + + /// Same restored Create User goes out when Identity Verification is off. + func testAnonymousCachedCreateUserIsSentWhenIdentityVerificationIsOff() { + /* Setup */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + cacheUserRequests([makeAnonymousCreateUserRequest()]) + + /* When */ + let mocks = Mocks { MockUserRequests.setDefaultCreateAnonUserResponses(with: $0) } + OneSignalCoreMocks.waitUntil("Create User was not sent") { + mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self) + } + + /* Then */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + } + + /// Nothing is sent while `requirement` is unknown; hydration releases the held Requests. + func testRequestsAreHeldUntilTheRequirementIsKnown() { + /* Setup */ + OSCoreMocks.resetSharedJwtConfig() + let mocks = Mocks() + MockUserRequests.setDefaultCreateUserResponses(with: mocks.client, externalId: userA_EUID) + + /* When */ + mocks.userExecutor.createUser(mocks.createUserInstance(externalId: userA_EUID)) + allowAsyncWorkToRun() + + /* Then */ + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + + /* When the requirement arrives */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + OneSignalCoreMocks.waitUntil("Hydration did not release the held Create User") { + mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self) + } + + /* Then */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + } + + /// Identify User promotes an anonymous user, which Identity Verification does not allow. A restored one + /// whose `identityModelToUpdate` is no longer current has no login left to carry over. + func testRestoredIdentifyUserIsDroppedWhenIdentityVerificationIsRequired() { + /* Setup */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + cacheUserRequests([makeIdentifyUserRequest()]) + + /* When */ + let mocks = Mocks { MockUserRequests.setDefaultIdentifyUserResponses(with: $0, externalId: userA_EUID, conflicted: false) } + allowAsyncWorkToRun() + + /* Then */ + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + } + + /// Cold start: the anon `identityModelToIdentify` is gone from the repo, but ToUpdate is still the + /// current user, so reshape must turn the restored Identify into a Create User. + func testRestoredIdentifyUserBecomesACreateUserWhenItIsStillTheCurrentUser() { + /* Setup */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: nil) + user.identityModel.jwtBearerToken = "token-a" + cacheUserRequests([ + OSRequestIdentifyUser( + aliasLabel: OS_EXTERNAL_ID, + aliasId: userA_EUID, + identityModelToIdentify: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: userA_OSID], changeNotifier: OSEventProducer()), + identityModelToUpdate: user.identityModel + ) + ]) + + /* When */ + let mocks = Mocks { MockUserRequests.setDefaultCreateUserResponses(with: $0, externalId: userA_EUID) } + OneSignalCoreMocks.waitUntil("Restored Identify was not reshaped into a Create User") { + mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self) + } + + /* Then */ + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + } + + /// Same restored Identify User goes out when Identity Verification is off. + func testRestoredIdentifyUserIsSentWhenIdentityVerificationIsOff() { + /* Setup */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + cacheUserRequests([makeIdentifyUserRequest()]) + + /* When */ + let mocks = Mocks { MockUserRequests.setDefaultIdentifyUserResponses(with: $0, externalId: userA_EUID, conflicted: false) } + OneSignalCoreMocks.waitUntil("Restored Identify User was not sent") { + mocks.client.hasExecutedRequestOfType(OSRequestIdentifyUser.self) + } + + /* Then */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + } + + /// `login` promotes while the requirement is still unknown, so turning out to require auth must not + /// strand that login: it becomes the Create User it would have been. + func testInSessionIdentifyUserBecomesACreateUserWhenIdentityVerificationIsRequired() { + /* Setup */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + let mocks = Mocks() + MockUserRequests.setDefaultIdentifyUserResponses(with: mocks.client, externalId: userA_EUID, conflicted: false) + MockUserRequests.setDefaultCreateUserResponses(with: mocks.client, externalId: userA_EUID) + + let anonIdentityModel = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: userA_OSID], changeNotifier: OSEventProducer()) + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: nil) + user.identityModel.jwtBearerToken = "token-a" + + /* When */ + mocks.userExecutor.identifyUser(externalId: userA_EUID, identityModelToIdentify: anonIdentityModel, identityModelToUpdate: user.identityModel) + OneSignalCoreMocks.waitUntil("In-session Identify was not reshaped into a Create User") { + mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self) + } + + /* Then */ + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + } + + /// A promotion whose user a later `login` has already replaced has no login left to carry over. + func testInSessionIdentifyUserForAReplacedUserIsDroppedWhenIdentityVerificationIsRequired() { + /* Setup */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + let mocks = Mocks() + MockUserRequests.setDefaultIdentifyUserResponses(with: mocks.client, externalId: userA_EUID, conflicted: false) + + let anonIdentityModel = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: userA_OSID], changeNotifier: OSEventProducer()) + let replacedIdentityModel = OSIdentityModel(aliases: [OS_EXTERNAL_ID: userA_EUID], changeNotifier: OSEventProducer()) + _ = OneSignalUserMocks.setUserManagerInternalUser(externalId: userB_EUID, onesignalId: nil) + + /* When */ + mocks.userExecutor.identifyUser(externalId: userA_EUID, identityModelToIdentify: anonIdentityModel, identityModelToUpdate: replacedIdentityModel) + allowAsyncWorkToRun() + + /* Then */ + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + } + + private func cacheUserRequests(_ requests: [OSUserRequest]) { + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, withValue: requests) + } + + private func makeIdentifyUserRequest() -> OSRequestIdentifyUser { + return OSRequestIdentifyUser( + aliasLabel: OS_EXTERNAL_ID, + aliasId: userA_EUID, + identityModelToIdentify: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: userA_OSID], changeNotifier: OSEventProducer()), + identityModelToUpdate: OSIdentityModel(aliases: [OS_EXTERNAL_ID: userA_EUID], changeNotifier: OSEventProducer()) + ) + } + + private func makeAnonymousCreateUserRequest() -> OSRequestCreateUser { + let pushModel = OSSubscriptionModel(type: .push, address: nil, subscriptionId: nil, reachable: false, isDisabled: false, changeNotifier: OSEventProducer()) + return OSRequestCreateUser( + identityModel: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: userA_OSID], changeNotifier: OSEventProducer()), + propertiesModel: OSPropertiesModel(changeNotifier: OSEventProducer()), + pushSubscriptionModel: pushModel, + originalPushToken: nil + ) + } +} + +/// Upgrade decode of `addsNewRecords` on a cached Create User. +final class OSRequestCreateUserArchiveTests: XCTestCase { + + func testAddsNewRecordsSurvivesAnArchiveRoundTrip() throws { + XCTAssertTrue(try archiveThenUnarchive(makeCreateWithPush()).addsNewRecords) + XCTAssertFalse(try archiveThenUnarchive(makeRecoveryCreate()).addsNewRecords) + } + + /// Omitting the key cools down, even when the body has no push subscription. + func testOmittingAddsNewRecordsDecodesAsTrue() throws { + XCTAssertTrue(try decodeOmittingAddsNewRecords(makeCreateWithPush()).addsNewRecords) + XCTAssertTrue(try decodeOmittingAddsNewRecords(makeRecoveryCreate()).addsNewRecords) + } + + private func makeCreateWithPush() -> OSRequestCreateUser { + OSRequestCreateUser( + identityModel: OSIdentityModel(aliases: [OS_EXTERNAL_ID: userA_EUID], changeNotifier: OSEventProducer()), + propertiesModel: OSPropertiesModel(changeNotifier: OSEventProducer()), + pushSubscriptionModel: OSSubscriptionModel( + type: .push, + address: "", + subscriptionId: "test-subscription-id", + reachable: false, + isDisabled: false, + changeNotifier: OSEventProducer() + ), + originalPushToken: nil + ) + } + + private func makeRecoveryCreate() -> OSRequestCreateUser { + OSRequestCreateUser( + aliasLabel: OS_EXTERNAL_ID, + aliasId: userA_EUID, + identityModel: OSIdentityModel(aliases: [OS_EXTERNAL_ID: userA_EUID], changeNotifier: OSEventProducer()) + ) + } + + private func archiveThenUnarchive(_ request: OSRequestCreateUser) throws -> OSRequestCreateUser { + let data = try NSKeyedArchiver.archivedData(withRootObject: request, requiringSecureCoding: false) + let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) + unarchiver.requiresSecureCoding = false + defer { unarchiver.finishDecoding() } + return try XCTUnwrap(unarchiver.decodeObject(forKey: NSKeyedArchiveRootObjectKey) as? OSRequestCreateUser) + } + + /// Encodes the same fields as `OSRequestCreateUser.encode`, without `addsNewRecords`. + private func decodeOmittingAddsNewRecords(_ request: OSRequestCreateUser) throws -> OSRequestCreateUser { + let archiver = NSKeyedArchiver(requiringSecureCoding: false) + archiver.encode(request.identityModel, forKey: "identityModel") + archiver.encode(request.pushSubscriptionModel, forKey: "pushSubscriptionModel") + archiver.encode(request.originalPushToken, forKey: "originalPushToken") + archiver.encode(request.parameters, forKey: "parameters") + archiver.encode(request.method.rawValue, forKey: "method") + archiver.encode(request.timestamp, forKey: "timestamp") + archiver.finishEncoding() + + let unarchiver = try NSKeyedUnarchiver(forReadingFrom: archiver.encodedData) + unarchiver.requiresSecureCoding = false + defer { unarchiver.finishDecoding() } + return try XCTUnwrap(OSRequestCreateUser(coder: unarchiver)) + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSRequestAuthTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSRequestAuthTests.swift new file mode 100644 index 000000000..62455a1d7 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSRequestAuthTests.swift @@ -0,0 +1,364 @@ +/* + 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 +@testable import OneSignalOSCore +@testable import OneSignalUser + +/// A stand-in for whichever concrete Request is being authorized; only ownership and the header matter here. +/// Named for the runtime because `OSUserRequest` requires `NSCoding` and a private class has no stable name. +@objc(OSStubUserRequest) +private class StubUserRequest: OneSignalRequest, OSUserRequest { + var sentToClient = false + let ownerExternalId: String? + let sendsUnsigned: Bool + + init(ownerExternalId: String?, sendsUnsigned: Bool = false) { + self.ownerExternalId = ownerExternalId + self.sendsUnsigned = sendsUnsigned + super.init() + } + + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { + return true + } + + func encode(with coder: NSCoder) { + coder.encode(ownerExternalId, forKey: "ownerExternalId") + coder.encode(sendsUnsigned, forKey: "sendsUnsigned") + } + + required init?(coder: NSCoder) { + self.ownerExternalId = coder.decodeObject(forKey: "ownerExternalId") as? String + self.sendsUnsigned = coder.decodeBool(forKey: "sendsUnsigned") + super.init() + } + + var authorizationHeader: String? { + return additionalHeaders?["Authorization"] + } +} + +private class StubJwtProvider: OSUserJwtProviding { + var tokens: [String: String] = [:] + private(set) var invalidatedCalls: [(externalId: String, rejectedToken: String)] = [] + private(set) var askedFor: [String] = [] + + func validJwt(externalId: String) -> String? { + return tokens[externalId] + } + + @discardableResult + func askForToken(externalId: String) -> Bool { + askedFor.append(externalId) + return true + } + + @discardableResult + func invalidateJwt(externalId: String, rejectedToken: String) -> Bool { + invalidatedCalls.append((externalId, rejectedToken)) + tokens.removeValue(forKey: externalId) + return true + } +} + +final class OSRequestAuthTests: XCTestCase { + private var jwtConfig: OSUserJwtConfig! + private var jwt: StubJwtProvider! + + override func setUp() { + super.setUp() + clearCache() + jwtConfig = OSUserJwtConfig() + jwt = StubJwtProvider() + } + + override func tearDown() { + clearCache() + super.tearDown() + } + + private func clearCache() { + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_USE_IDENTITY_VERIFICATION) + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAGS) + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAG_METADATA) + } + + private func makeAuth(requiresUserAuth: Bool) -> OSRequestAuth { + jwtConfig.hydrate(requiresUserAuth: requiresUserAuth) + return makeAuth(enabledKeys: []) + } + + /// For the cases that turn on the rollout flag, or leave the requirement unhydrated, or both. + private func makeAuth(enabledKeys: Set) -> OSRequestAuth { + let store = OSFeatureFlagsStore() + store.applyRemoteFlags(Array(enabledKeys), metadata: nil) + let service = OSIdentityVerificationService(featureManager: OSFeatureManager(store: store), jwtConfig: jwtConfig) + return OSRequestAuth(identityVerificationService: service, jwt: jwt) + } + + // MARK: - authorizeUserScoped + + func testUserScopedKeepsTheLegacyAliasAndSendsNoHeaderWhileIdentityVerificationIsOff() { + let auth = makeAuth(requiresUserAuth: false) + jwt.tokens["user-a"] = "token-a" + let request = StubUserRequest(ownerExternalId: "user-a") + + let alias = auth.authorizeUserScoped(request, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, "osid-a")) + + XCTAssertEqual(alias?.label, OS_ONESIGNAL_ID) + XCTAssertEqual(alias?.id, "osid-a") + XCTAssertNil(request.authorizationHeader) + } + + func testUserScopedSwapsToExternalIdAndSignsWhileIdentityVerificationIsOn() { + let auth = makeAuth(requiresUserAuth: true) + jwt.tokens["user-a"] = "token-a" + let request = StubUserRequest(ownerExternalId: "user-a") + + let alias = auth.authorizeUserScoped(request, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, "osid-a")) + + XCTAssertEqual(alias?.label, OS_EXTERNAL_ID) + XCTAssertEqual(alias?.id, "user-a") + XCTAssertEqual(request.authorizationHeader, "Bearer token-a") + } + + /// nil is the park signal: the caller leaves the Request queued rather than sending it unsigned. + func testUserScopedReturnsNilWhenTheOwnerHasNoToken() { + let auth = makeAuth(requiresUserAuth: true) + let request = StubUserRequest(ownerExternalId: "user-a") + + XCTAssertNil(auth.authorizeUserScoped(request, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, "osid-a"))) + XCTAssertNil(request.authorizationHeader) + // Nothing else prompts the app for a token the SDK never held, so parking has to. + XCTAssertEqual(jwt.askedFor, ["user-a"]) + } + + /// Addressing it by `onesignal_id` would send a user-scoped path unsigned, so it waits for the purge. + func testUserScopedRefusesAnUnownedRequest() { + let auth = makeAuth(requiresUserAuth: true) + let request = StubUserRequest(ownerExternalId: nil) + + XCTAssertNil(auth.authorizeUserScoped(request, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, "osid-a"))) + XCTAssertNil(request.authorizationHeader) + // There is no owner to ask on behalf of, so the app must not be prompted. + XCTAssertTrue(jwt.askedFor.isEmpty) + } + + func testUserScopedKeepsAddressingAnUnownedRequestWhileIdentityVerificationIsOff() { + let auth = makeAuth(requiresUserAuth: false) + let request = StubUserRequest(ownerExternalId: nil) + + let alias = auth.authorizeUserScoped(request, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, "osid-a")) + + XCTAssertEqual(alias?.label, OS_ONESIGNAL_ID) + XCTAssertEqual(alias?.id, "osid-a") + } + + /// Fetch User can be built to read an alias other than `onesignal_id`, and that survives the gate being off. + func testUserScopedPreservesACallerSuppliedLegacyAlias() { + let auth = makeAuth(requiresUserAuth: false) + let request = StubUserRequest(ownerExternalId: "user-a") + + let alias = auth.authorizeUserScoped(request, legacyAlias: OSAliasPair(OS_EXTERNAL_ID, "user-a")) + + XCTAssertEqual(alias?.label, OS_EXTERNAL_ID) + XCTAssertEqual(alias?.id, "user-a") + } + + // MARK: - authorize + + func testAuthorizeSendsNoHeaderWhileIdentityVerificationIsOff() { + let auth = makeAuth(requiresUserAuth: false) + jwt.tokens["user-a"] = "token-a" + let request = StubUserRequest(ownerExternalId: "user-a") + + XCTAssertTrue(auth.authorize(request)) + XCTAssertNil(request.authorizationHeader) + } + + func testAuthorizeSignsAnOwnedRequestWhileIdentityVerificationIsOn() { + let auth = makeAuth(requiresUserAuth: true) + jwt.tokens["user-a"] = "token-a" + let request = StubUserRequest(ownerExternalId: "user-a") + + XCTAssertTrue(auth.authorize(request)) + XCTAssertEqual(request.authorizationHeader, "Bearer token-a") + } + + func testAuthorizeParksAnOwnedRequestWithNoToken() { + let auth = makeAuth(requiresUserAuth: true) + let request = StubUserRequest(ownerExternalId: "user-a") + + XCTAssertFalse(auth.authorize(request)) + XCTAssertEqual(jwt.askedFor, ["user-a"]) + } + + /// A signed Request must not re-ask: the app has already answered for this user. + func testAuthorizeDoesNotAskWhenTheOwnerHasAToken() { + let auth = makeAuth(requiresUserAuth: true) + jwt.tokens["user-a"] = "token-a" + let request = StubUserRequest(ownerExternalId: "user-a") + + XCTAssertTrue(auth.authorize(request)) + XCTAssertTrue(jwt.askedFor.isEmpty) + } + + /// The push subscription update has no owner and must keep flowing under Identity Verification. + func testAuthorizeSendsAnUnownedRequestUnsignedWhenItIsExempt() { + let auth = makeAuth(requiresUserAuth: true) + let request = StubUserRequest(ownerExternalId: nil, sendsUnsigned: true) + + XCTAssertTrue(auth.authorize(request)) + XCTAssertNil(request.authorizationHeader) + } + + /// Everything else with no owner is a leftover the purge has yet to clear, and must not go out unsigned. + func testAuthorizeRefusesAnUnownedRequestThatIsNotExempt() { + let auth = makeAuth(requiresUserAuth: true) + let request = StubUserRequest(ownerExternalId: nil) + + XCTAssertFalse(auth.authorize(request)) + XCTAssertTrue(jwt.askedFor.isEmpty) + } + + func testAuthorizeSendsAnUnownedRequestWhileIdentityVerificationIsOff() { + let auth = makeAuth(requiresUserAuth: false) + let request = StubUserRequest(ownerExternalId: nil) + + XCTAssertTrue(auth.authorize(request)) + XCTAssertNil(request.authorizationHeader) + } + + // MARK: - handleUnauthorized + + func testHandleUnauthorizedInvalidatesTheSignedTokenAndRequeuesTheRequest() { + let auth = makeAuth(requiresUserAuth: true) + jwt.tokens["user-a"] = "token-a" + let request = StubUserRequest(ownerExternalId: "user-a") + _ = auth.authorize(request) + request.sentToClient = true + + XCTAssertTrue(auth.handleUnauthorized(request)) + + // The token that went out is the one invalidated, and the Request is left ready to re-sign. + XCTAssertEqual(jwt.invalidatedCalls.count, 1) + XCTAssertEqual(jwt.invalidatedCalls.first?.externalId, "user-a") + XCTAssertEqual(jwt.invalidatedCalls.first?.rejectedToken, "token-a") + XCTAssertNil(request.authorizationHeader) + XCTAssertFalse(request.sentToClient) + } + + /// The header carries the token, so a Request sent before the app supplied one has nothing to reject. + func testHandleUnauthorizedDeclinesAnUnsignedRequest() { + let auth = makeAuth(requiresUserAuth: true) + let request = StubUserRequest(ownerExternalId: "user-a") + request.sentToClient = true + + XCTAssertFalse(auth.handleUnauthorized(request)) + XCTAssertTrue(jwt.invalidatedCalls.isEmpty) + XCTAssertTrue(request.sentToClient) + } + + /// With the gate off a 401 stays on the executor's existing non-retryable path. + func testHandleUnauthorizedDeclinesWhileIdentityVerificationIsOff() { + let auth = makeAuth(requiresUserAuth: false) + let request = StubUserRequest(ownerExternalId: "user-a") + request.additionalHeaders = ["Authorization": "Bearer token-a"] + request.sentToClient = true + + XCTAssertFalse(auth.handleUnauthorized(request)) + XCTAssertTrue(jwt.invalidatedCalls.isEmpty) + XCTAssertTrue(request.sentToClient) + } + + // MARK: - authorization, for callers outside the Request queues + + /// The in-app message fetch addressed the subscription on its own before Identity Verification. + func testAuthorizationCarriesNoUserWhileTheNewCodePathsAreOff() { + let auth = makeAuth(requiresUserAuth: false) + jwt.tokens["user-a"] = "token-a" + + let authorization = auth.authorization(onesignalId: "osid-a", externalId: "user-a") + + XCTAssertNotNil(authorization) + XCTAssertNil(authorization?.alias) + XCTAssertEqual(authorization?.headers, [:]) + XCTAssertNil(authorization?.token) + } + + func testAuthorizationAddressesTheOnesignalIdWhileIdentityVerificationIsOff() { + jwtConfig.hydrate(requiresUserAuth: false) + let auth = makeAuth(enabledKeys: ["sdk_identity_verification"]) + jwt.tokens["user-a"] = "token-a" + + let authorization = auth.authorization(onesignalId: "osid-a", externalId: "user-a") + + XCTAssertEqual(authorization?.alias?.label, OS_ONESIGNAL_ID) + XCTAssertEqual(authorization?.alias?.id, "osid-a") + XCTAssertEqual(authorization?.headers, [:]) + XCTAssertNil(authorization?.token) + } + + func testAuthorizationAddressesTheExternalIdAndSignsWhileIdentityVerificationIsOn() { + let auth = makeAuth(requiresUserAuth: true) + jwt.tokens["user-a"] = "token-a" + + let authorization = auth.authorization(onesignalId: "osid-a", externalId: "user-a") + + XCTAssertEqual(authorization?.alias?.label, OS_EXTERNAL_ID) + XCTAssertEqual(authorization?.alias?.id, "user-a") + XCTAssertEqual(authorization?.headers, ["Authorization": "Bearer token-a"]) + XCTAssertEqual(authorization?.token, "token-a") + } + + /// nil is the defer signal: an unsigned call would be rejected if the app turns out to require auth. + /// Holds even when the rollout flag is off — otherwise production would send legacy unsigned before + /// the first params answer. + func testAuthorizationDefersWhileTheRequirementIsUnknown() { + let auth = makeAuth(enabledKeys: []) + + XCTAssertNil(auth.authorization(onesignalId: "osid-a", externalId: "user-a")) + XCTAssertTrue(jwt.askedFor.isEmpty) + } + + /// Under Identity Verification the server has nothing to serve a device with no identified user. + func testAuthorizationDefersWhileNobodyIsLoggedIn() { + let auth = makeAuth(requiresUserAuth: true) + + XCTAssertNil(auth.authorization(onesignalId: "osid-a", externalId: nil)) + XCTAssertTrue(jwt.askedFor.isEmpty) + } + + func testAuthorizationDefersAndAsksWhenTheUserHasNoToken() { + let auth = makeAuth(requiresUserAuth: true) + + XCTAssertNil(auth.authorization(onesignalId: "osid-a", externalId: "user-a")) + XCTAssertEqual(jwt.askedFor, ["user-a"]) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift index 77efb2f8a..212569198 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift @@ -119,9 +119,22 @@ final class OneSignalUserTests: XCTestCase { */ func testBasicCombiningUserUpdateDeltas_resultsInOneRequest() throws { /* Setup */ + let client = MockOneSignalClient() - let operationRepo = OSOperationRepo.sharedInstance - startUserManagerWithPausedOperations(client: client, operationRepo: operationRepo) + client.executeInstantaneously = true + MockUserRequests.setDefaultCreateAnonUserResponses(with: client) + OneSignalCoreImpl.setSharedClient(client) + + OneSignalUserManagerImpl.sharedInstance.start() + OneSignalCoreMocks.waitUntil("Anonymous user creation did not complete") { + client.hasCompletedRequestOfType(OSRequestCreateUser.self) + } + + // Hold the queue for the batch below. Widening the poll interval instead left the test + // dependent on where the running poller happened to be in its cycle, and a poll landing + // mid-batch splits it into two Requests. + OneSignalUserManagerImpl.sharedInstance.operationRepo.flushAndWait() + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true /* When */ @@ -160,9 +173,8 @@ final class OneSignalUserTests: XCTestCase { /* Then */ - operationRepo.paused = false - operationRepo.flushAndWait() - + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = false + OneSignalUserManagerImpl.sharedInstance.operationRepo.flushAndWait() OneSignalCoreMocks.waitUntil("Combined property update did not complete") { client.hasCompletedRequestOfType(OSRequestUpdateProperties.self) } @@ -195,16 +207,6 @@ final class OneSignalUserTests: XCTestCase { ) } - private func startUserManagerWithPausedOperations( - client: MockOneSignalClient, - operationRepo: OSOperationRepo - ) { - MockUserRequests.setDefaultCreateAnonUserResponses(with: client) - OneSignalCoreImpl.setSharedClient(client) - operationRepo.paused = true - OneSignalUserManagerImpl.sharedInstance.start() - } - /** Unit test for the tag-merge primitive. diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/RequestPathEncodingTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/RequestPathEncodingTests.swift new file mode 100644 index 000000000..8ddc3f750 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/RequestPathEncodingTests.swift @@ -0,0 +1,125 @@ +/* + 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 OneSignalOSCore +import OneSignalCoreMocks +import OneSignalOSCoreMocks +import OneSignalUserMocks +@testable import OneSignalUser + +/// Values the app chooses reach a URL path once Identity Verification addresses users by `external_id`, +/// so a path built from one has to survive characters that would otherwise change which endpoint it names. +final class RequestPathEncodingTests: XCTestCase { + private let appId = "test-app-id" + private let onesignalId = "test-onesignal-id" + private let externalId = "us er/a?b#c%d" + private let encodedExternalId = "us%20er%2Fa%3Fb%23c%25d" + + private var newRecordsState = MockNewRecordsState() + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = appId + newRecordsState = MockNewRecordsState() + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + } + + override func tearDownWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + } + + private var auth: OSRequestAuthorizing { + return OneSignalUserManagerImpl.sharedInstance.requestAuth + } + + /// A user the auth layer can address by `external_id` and sign for. + @discardableResult + private func addIdentifiedUser() -> OSIdentityModel { + let model = OSIdentityModel( + aliases: [OS_ONESIGNAL_ID: onesignalId, OS_EXTERNAL_ID: externalId], + changeNotifier: OSEventProducer() + ) + model.jwtBearerToken = "token-a" + OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(model) + return model + } + + func testFetchUserPercentEncodesTheExternalId() { + let request = OSRequestFetchUser( + identityModel: addIdentifiedUser(), + aliasLabel: OS_ONESIGNAL_ID, + aliasId: onesignalId, + onNewSession: false + ) + + XCTAssertTrue(request.prepareForExecution(newRecordsState: newRecordsState, auth: auth)) + XCTAssertEqual(request.path, "apps/\(appId)/users/by/\(OS_EXTERNAL_ID)/\(encodedExternalId)") + } + + func testUpdatePropertiesPercentEncodesTheExternalId() { + let request = OSRequestUpdateProperties( + params: ["properties": ["language": "en"]], + identityModel: addIdentifiedUser(), + ownerExternalId: externalId + ) + + XCTAssertTrue(request.prepareForExecution(newRecordsState: newRecordsState, auth: auth)) + XCTAssertEqual(request.path, "apps/\(appId)/users/by/\(OS_EXTERNAL_ID)/\(encodedExternalId)") + } + + /// The label the app asks to remove is the other app-chosen value in a path. + func testRemoveAliasPercentEncodesBothTheExternalIdAndTheLabel() { + let request = OSRequestRemoveAlias( + labelToRemove: "my label/x", + identityModel: addIdentifiedUser(), + ownerExternalId: externalId + ) + + XCTAssertTrue(request.prepareForExecution(newRecordsState: newRecordsState, auth: auth)) + XCTAssertEqual( + request.path, + "apps/\(appId)/users/by/\(OS_EXTERNAL_ID)/\(encodedExternalId)/identity/my%20label%2Fx" + ) + } + + /// Server-assigned ids need no escaping, so the path an app without Identity Verification sends is byte + /// for byte what it was. + func testTheOnesignalIdPathIsUnchangedWhileIdentityVerificationIsOff() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + let request = OSRequestUpdateProperties( + params: ["properties": ["language": "en"]], + identityModel: addIdentifiedUser(), + ownerExternalId: externalId + ) + + XCTAssertTrue(request.prepareForExecution(newRecordsState: newRecordsState, auth: auth)) + XCTAssertEqual(request.path, "apps/\(appId)/users/by/\(OS_ONESIGNAL_ID)/\(onesignalId)") + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift index 2c04b9c0a..01c2c8fe4 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift @@ -120,7 +120,7 @@ final class SwitchUserIntegrationTests: XCTestCase { OneSignalCoreMocks.waitUntil("Anonymous user creation did not complete") { client.hasCompletedRequestOfType(OSRequestCreateUser.self) } - OSOperationRepo.sharedInstance.paused = true + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true /* When */ @@ -137,8 +137,8 @@ final class SwitchUserIntegrationTests: XCTestCase { OneSignalUserManagerImpl.sharedInstance.addAlias(label: "alias_a", id: "id_a") OneSignalUserManagerImpl.sharedInstance.addEmail("email_a@example.com") - OSOperationRepo.sharedInstance.paused = false - OSOperationRepo.sharedInstance.flushAndWait() + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = false + OneSignalUserManagerImpl.sharedInstance.operationRepo.flushAndWait() OneSignalCoreMocks.waitUntil("User A updates and hydration did not complete") { self.userAUpdatesAndHydrationCompleted(client, tagsUserA) @@ -235,7 +235,7 @@ final class SwitchUserIntegrationTests: XCTestCase { OneSignalCoreMocks.waitUntil("Anonymous user creation did not complete") { client.hasCompletedRequestOfType(OSRequestCreateUser.self) } - OSOperationRepo.sharedInstance.paused = true + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true /* When */ @@ -259,8 +259,8 @@ final class SwitchUserIntegrationTests: XCTestCase { OneSignalUserManagerImpl.sharedInstance.addAlias(label: "alias_b", id: "id_b") OneSignalUserManagerImpl.sharedInstance.addEmail("email_b@example.com") - OSOperationRepo.sharedInstance.paused = false - OSOperationRepo.sharedInstance.flushAndWait() + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = false + OneSignalUserManagerImpl.sharedInstance.operationRepo.flushAndWait() OneSignalCoreMocks.waitUntil("Logged-out user updates were not sent") { self.userUpdatesCompleted(client, tagsUserA, tagsUserB, anonUserOSID) @@ -345,8 +345,8 @@ final class SwitchUserIntegrationTests: XCTestCase { OneSignalCoreImpl.setSharedClient(client) // Increase flush interval to allow all the updates to batch - OSOperationRepo.sharedInstance.pollIntervalMilliseconds = 300 - OSOperationRepo.sharedInstance.flushAndWait() + OneSignalUserManagerImpl.sharedInstance.operationRepo.pollIntervalMilliseconds = 300 + OneSignalUserManagerImpl.sharedInstance.operationRepo.flushAndWait() // 1. Set up mock responses for the first anonymous user let tagsUserAnon = ["tag_anon": "value_anon"] diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift index 5bf4466ab..1357e0eb0 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift @@ -46,7 +46,20 @@ final class UserConcurrencyTests: XCTestCase { OneSignalLog.setLogLevel(.LL_VERBOSE) } - override func tearDownWithError() throws { } + override func tearDownWithError() throws { + // The user manager and its repo outlive each test; a delta left queued here flushes + // mid-next-test against that test's client. Kick flushes rather than wait out the poller. + let repo = OneSignalUserManagerImpl.sharedInstance.operationRepo + repo.paused = false + repo.addFlushDeltaQueueToDispatchQueue() + OneSignalCoreMocks.waitUntil("Leftover deltas did not drain") { + if repo.dispatchQueue.sync(execute: { repo.deltaQueue.isEmpty }) { + return true + } + repo.addFlushDeltaQueueToDispatchQueue() + return false + } + } /** This test reproduces a crash in the Operation Repo's flushing delta queue. @@ -68,7 +81,7 @@ final class UserConcurrencyTests: XCTestCase { for _ in 1...4 { DispatchQueue.global().async { print("🧪 flushDeltaQueue on thread \(Thread.current)") - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() } } @@ -92,22 +105,22 @@ final class UserConcurrencyTests: XCTestCase { ) OneSignalCoreImpl.setSharedClient(client) - let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState()) - OSOperationRepo.sharedInstance.addExecutor(executor) + let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState(), auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) + OneSignalUserManagerImpl.sharedInstance.operationRepo.addExecutor(executor) /* When */ DispatchQueue.concurrentPerform(iterations: 50) { _ in // 1. Enqueue Remove Subscription Deltas to the Operation Repo - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: "email", value: "email")) - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: "email", value: "email")) + OneSignalUserManagerImpl.sharedInstance.operationRepo.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: "email", value: "email")) + OneSignalUserManagerImpl.sharedInstance.operationRepo.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: "email", value: "email")) // 2. Flush Operation Repo - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() // 3. Simulate updating the executor's request queue from a network response - executor.executeDeleteSubscriptionRequest(OSRequestDeleteSubscription(subscriptionModel: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer())), inBackground: false) - executor.executeDeleteSubscriptionRequest(OSRequestDeleteSubscription(subscriptionModel: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer())), inBackground: false) + executor.executeDeleteSubscriptionRequest(OSRequestDeleteSubscription(subscriptionModel: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), ownerExternalId: nil), inBackground: false) + executor.executeDeleteSubscriptionRequest(OSRequestDeleteSubscription(subscriptionModel: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), ownerExternalId: nil), inBackground: false) } OneSignalCoreMocks.waitUntil("Concurrent subscription requests did not complete") { @@ -132,22 +145,22 @@ final class UserConcurrencyTests: XCTestCase { OneSignalCoreImpl.setSharedClient(client) MockUserRequests.setAddAliasesResponse(with: client, aliases: aliases) - let executor = OSIdentityOperationExecutor(newRecordsState: OSNewRecordsState()) - OSOperationRepo.sharedInstance.addExecutor(executor) + let executor = OSIdentityOperationExecutor(newRecordsState: OSNewRecordsState(), auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) + OneSignalUserManagerImpl.sharedInstance.operationRepo.addExecutor(executor) /* When */ DispatchQueue.concurrentPerform(iterations: 50) { _ in // 1. Enqueue Add Alias Deltas to the Operation Repo - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), property: "aliases", value: aliases)) - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), property: "aliases", value: aliases)) + OneSignalUserManagerImpl.sharedInstance.operationRepo.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), property: "aliases", value: aliases)) + OneSignalUserManagerImpl.sharedInstance.operationRepo.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), property: "aliases", value: aliases)) // 2. Flush Operation Repo - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() // 3. Simulate updating the executor's request queue from a network response - executor.executeAddAliasesRequest(OSRequestAddAliases(aliases: aliases, identityModel: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer())), inBackground: false) - executor.executeAddAliasesRequest(OSRequestAddAliases(aliases: aliases, identityModel: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer())), inBackground: false) + executor.executeAddAliasesRequest(OSRequestAddAliases(aliases: aliases, identityModel: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), ownerExternalId: nil), inBackground: false) + executor.executeAddAliasesRequest(OSRequestAddAliases(aliases: aliases, identityModel: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), ownerExternalId: nil), inBackground: false) } OneSignalCoreMocks.waitUntil("Concurrent identity requests did not complete") { @@ -174,21 +187,21 @@ final class UserConcurrencyTests: XCTestCase { let identityModel = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()) OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(identityModel) - let executor = OSPropertyOperationExecutor(newRecordsState: OSNewRecordsState()) - OSOperationRepo.sharedInstance.addExecutor(executor) + let executor = OSPropertyOperationExecutor(newRecordsState: OSNewRecordsState(), auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) + OneSignalUserManagerImpl.sharedInstance.operationRepo.addExecutor(executor) /* When */ DispatchQueue.concurrentPerform(iterations: 50) { _ in // 1. Enqueue Deltas to the Operation Repo - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, externalId: identityModel.externalId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "language", value: UUID().uuidString)) - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, externalId: identityModel.externalId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "language", value: UUID().uuidString)) + OneSignalUserManagerImpl.sharedInstance.operationRepo.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, externalId: identityModel.externalId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "language", value: UUID().uuidString)) + OneSignalUserManagerImpl.sharedInstance.operationRepo.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, externalId: identityModel.externalId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "language", value: UUID().uuidString)) // 2. Flush Operation Repo - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() // 3. Simulate updating the executor's request queue from a network response - executor.executeUpdatePropertiesRequest(OSRequestUpdateProperties(params: ["properties": ["language": UUID().uuidString], "refresh_device_metadata": false], identityModel: identityModel), inBackground: false) + executor.executeUpdatePropertiesRequest(OSRequestUpdateProperties(params: ["properties": ["language": UUID().uuidString], "refresh_device_metadata": false], identityModel: identityModel, ownerExternalId: identityModel.externalId), inBackground: false) } OneSignalCoreMocks.waitUntil("Concurrent property requests did not complete") { @@ -216,7 +229,7 @@ final class UserConcurrencyTests: XCTestCase { let identityModel1 = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()) let identityModel2 = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()) - let userExecutor = OSUserExecutor(newRecordsState: OSNewRecordsState()) + let userExecutor = OSUserExecutor(newRecordsState: OSNewRecordsState(), identityVerificationService: OneSignalUserManagerImpl.sharedInstance.identityVerificationService, auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) /* When */ diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtLifecycleTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtLifecycleTests.swift new file mode 100644 index 000000000..58514b954 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtLifecycleTests.swift @@ -0,0 +1,536 @@ +/* + 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 class MockUserStateObserver: NSObject, OSUserStateObserver { + var states: [OSUserState] = [] + + func onUserStateDidChange(state: OSUserChangedState) { + states.append(state.current) + } +} + +/** + What `login` and `logout` do differently under Identity Verification: no anonymous user is ever sent to + the server, so login creates rather than promotes and logout has to silence the push subscription itself. + */ +final class UserJwtLifecycleTests: XCTestCase { + /// Any opted-in value; the point is that it survives unchanged, or is replaced by -2. + private let optedInNotificationTypes = 7 + + private var client = MockOneSignalClient() + private var observer = MockUserStateObserver() + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + + client = MockOneSignalClient() + MockUserRequests.setDefaultCreateAnonUserResponses(with: client) + MockUserRequests.setDefaultCreateUserResponses(with: client, externalId: userA_EUID) + MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: userA_EUID) + OneSignalCoreImpl.setSharedClient(client) + + // Held strongly for the test's lifetime: OSObservable keeps observers weakly. + observer = MockUserStateObserver() + OneSignalUserManagerImpl.sharedInstance.addObserver(observer) + } + + 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 } + OneSignalUserManagerImpl.sharedInstance.removeObserver(observer) + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = false + OneSignalCoreMocks.clearUserDefaults() + OSFeatureFlagsStore.shared.clear() + OSFeatureManager.reset() + } + + /// The push subscription as the server would see it right now. + private func pushSubscriptionPayload() -> [String: Any] { + return OneSignalUserManagerImpl.sharedInstance.user.pushSubscriptionModel.jsonRepresentation() + } + + /// Reports a token and notification types, so a silenced payload is distinguishable from the default. + @discardableResult + private func optInPushSubscription() -> OSSubscriptionModel { + let model = OneSignalUserManagerImpl.sharedInstance.user.pushSubscriptionModel + model.address = "push-token" + model.notificationTypes = optedInNotificationTypes + return model + } + + private func queuedUserRequests() -> [OSUserRequest] { + return OneSignalUserManagerImpl.sharedInstance.userExecutor?.userRequestQueue ?? [] + } + + /// The `external_id` of every queued Create User, so a test can tell the anonymous one apart. + private func queuedCreateUserExternalIds() -> [String] { + return queuedUserRequests().compactMap { ($0 as? OSRequestCreateUser)?.identityModel.externalId } + } + + /// The header the Create User went out with, so a test can tell that it was signed. + private func executedCreateUserAuthorization() -> String? { + return client.executedRequests.first { $0 is OSRequestCreateUser }?.additionalHeaders?["Authorization"] + } + + /// The Delta `logout()` produces by silencing the push subscription. + private func silencingDelta() -> OSDelta? { + return OneSignalUserManagerImpl.sharedInstance.operationRepo.deltaQueue.first { + $0.name == OS_UPDATE_SUBSCRIPTION_DELTA && $0.property == "isDisabledInternally" + } + } + + /// `start()` re-reads the cached requirement, so the setup default has to be cleared too. + private func makeRequirementUnknown() { + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_USE_IDENTITY_VERIFICATION) + OSCoreMocks.resetSharedJwtConfig() + } + + // MARK: - Waits + + private var clientIsIdle: Bool { + return client.completedRequests.count == client.startedRequests.count + } + + /** + Tests opt the push subscription in and count observer states after this returns, so it has to + outlast every effect of the login. The mock records a Request as completed only once the response + handler has returned, so nothing in flight means the hydration it drives has already run. + + A response can also start a follow-up Request, and the mock answers each one 50ms later, so an + idle client is only meaningful once it has stayed idle longer than that. + */ + private func waitForTheLoginToSettle() { + OneSignalCoreMocks.waitUntil("The login did not reach a reported user") { + OneSignalUserManagerImpl.sharedInstance.user.identityModel.onesignalId != nil + && self.observer.states.contains { $0.onesignalId != nil } + && self.clientIsIdle + } + allowAsyncWorkToRun(seconds: 0.1) + OneSignalCoreMocks.waitUntil("The login left a Request in flight") { self.clientIsIdle } + } + + private func waitForThePushSubscriptionToBeSilenced() { + OneSignalCoreMocks.waitUntil("The push subscription was not silenced") { + self.pushSubscriptionPayload()["notification_types"] as? Int == -2 + } + } + + /// The logged-out state reaching the app, which is the last thing `logout()` does. + private func waitForTheUserStateToBeReported() { + OneSignalCoreMocks.waitUntil("The logged-out state was not reported") { + self.observer.states.count == 1 + } + } + + private func waitForThePushSubscriptionToReportAgain() { + OneSignalCoreMocks.waitUntil("The push subscription did not report the app's opt-in again") { + self.pushSubscriptionPayload()["notification_types"] as? Int == self.optedInNotificationTypes + } + } + + // MARK: - login + + func testLoginFromAnonymousPromotesTheUserWhileIdentityVerificationIsOff() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + _ = OneSignalUserManagerImpl.sharedInstance.user // anonymous user first + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalCoreMocks.waitUntil("Identify User was not sent") { + self.client.hasExecutedRequestOfType(OSRequestIdentifyUser.self) + } + + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + } + + /// Identify User adds an `external_id` to an anonymous user, which Identity Verification does not allow. + func testLoginFromAnonymousCreatesANewUserWhileIdentityVerificationIsRequired() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + _ = OneSignalUserManagerImpl.sharedInstance.user + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitUntil("Create User was not sent") { + self.client.hasExecutedRequestOfType(OSRequestCreateUser.self) + } + + XCTAssertFalse(client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + } + + /// Promoting before remote params answer is safe because nothing is sent while the requirement is + /// unknown, so the queued promotion can still be reshaped into the Create User auth requires. + func testLoginWhileTheRequirementIsUnknownBecomesACreateUserOnceAuthIsRequired() { + OSFeatureFlagsStore.shared.applyRemoteFlags(["sdk_identity_verification"], metadata: nil) + makeRequirementUnknown() + _ = OneSignalUserManagerImpl.sharedInstance.user + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitUntil("The promotion was not queued") { + self.queuedUserRequests().contains { $0 is OSRequestIdentifyUser } + } + + XCTAssertTrue(queuedUserRequests().contains { $0 is OSRequestIdentifyUser }) + XCTAssertFalse(client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + XCTAssertFalse(client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + OneSignalCoreMocks.waitUntil("Hydration did not release the promotion as a Create User") { + self.client.hasExecutedRequestOfType(OSRequestCreateUser.self, expectedCount: 1) + } + + // The login reaches the server as the Create User it should have been, signed with its own token, + // and the anonymous user it replaced is never created. + XCTAssertFalse(client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestCreateUser.self, expectedCount: 1)) + XCTAssertEqual(executedCreateUserAuthorization(), "Bearer token-a") + } + + /// The same promotion when remote params answer the other way is simply sent. + func testLoginWhileTheRequirementIsUnknownIsSentOnceAuthIsKnownToBeOff() { + OSFeatureFlagsStore.shared.applyRemoteFlags(["sdk_identity_verification"], metadata: nil) + makeRequirementUnknown() + _ = OneSignalUserManagerImpl.sharedInstance.user + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalCoreMocks.waitUntil("The promotion was not queued") { + self.queuedUserRequests().contains { $0 is OSRequestIdentifyUser } + } + + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + OneSignalCoreMocks.waitUntil("Hydration did not release the promotion") { + self.client.hasExecutedRequestOfType(OSRequestIdentifyUser.self) + } + + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + } + + /// With the rollout flag off, an unknown requirement has to behave exactly as it did before Identity + /// Verification existed. + func testLoginFromAnonymousPromotesTheUserWhileTheRequirementIsUnknownAndTheFlagIsOff() { + makeRequirementUnknown() + _ = OneSignalUserManagerImpl.sharedInstance.user + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalCoreMocks.waitUntil("The promotion was not queued") { + self.queuedUserRequests().contains { $0 is OSRequestIdentifyUser } + } + + XCTAssertTrue(queuedUserRequests().contains { $0 is OSRequestIdentifyUser }) + XCTAssertFalse(queuedCreateUserExternalIds().contains(userA_EUID)) + } + + /// Re-logging in is how an app hands over a replacement token. + func testLoggingInAgainAsTheSameUserStoresTheNewToken() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + waitForTheLoginToSettle() + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-b") + + XCTAssertEqual(OneSignalUserManagerImpl.sharedInstance.userJwtRepo.validJwt(externalId: userA_EUID), "token-b") + } + + /// A token supplied by `login` answers the ask the same way `updateUserJwt` does, so a later rejection + /// can ask again. An ask left standing would silence the app for the rest of the session. + func testLoggingInWithATokenAnswersAPendingAskForThatUser() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + let jwtRepo = OneSignalUserManagerImpl.sharedInstance.userJwtRepo + + // The Create User parks for want of a token, which asks the app once. Asking is what the + // assertion reads, so it cannot be polled: the first poll would consume the pending ask. + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + allowAsyncWorkToRun() + XCTAssertFalse(jwtRepo.askForToken(externalId: userA_EUID)) + + // Log back in as the same user, which builds a new Identity Model rather than reusing the parked one. + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + // Rejecting the token reads the Identity Model the login hydrates, so let the login finish + // rather than stopping at the point the token is merely stored. + waitForTheLoginToSettle() + OneSignalCoreMocks.waitUntil("The replacement token was not stored") { + jwtRepo.validJwt(externalId: userA_EUID) == "token-a" + } + + XCTAssertEqual(jwtRepo.validJwt(externalId: userA_EUID), "token-a") + // `true` means this rejection reached the app, which only happens once the earlier ask was answered. + XCTAssertTrue(jwtRepo.invalidateJwt(externalId: userA_EUID, rejectedToken: "token-a")) + } + + // MARK: - a rejected token on the first Create User + + /** + A 401 on Create User is the likeliest one under Identity Verification, and the token that answers it + arrives through `updateUserJwt`, which resumes work by flushing. A paused Repo drops that flush, so the + app would supply a good token and see nothing happen until the next session. + */ + func testARejectedCreateUserLeavesTheRepoAbleToFlushTheReplacementToken() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + client.setMockFailureResponseForRequest( + request: "", + error: OneSignalClientError(code: 401, message: "unauthorized", responseHeaders: nil, response: nil, underlyingError: nil) + ) + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + // Handling the 401 parks the Request first and drops the rejected token after, so wait for both. + OneSignalCoreMocks.waitUntil("The 401 was not fully handled") { + self.queuedCreateUserExternalIds().contains(userA_EUID) + && OneSignalUserManagerImpl.sharedInstance.userJwtRepo.validJwt(externalId: userA_EUID) == nil + } + + XCTAssertFalse(OneSignalUserManagerImpl.sharedInstance.operationRepo.paused) + // Parked, not dropped, and the rejected token is gone so the retry cannot reuse it. + XCTAssertTrue(queuedCreateUserExternalIds().contains(userA_EUID)) + XCTAssertNil(OneSignalUserManagerImpl.sharedInstance.userJwtRepo.validJwt(externalId: userA_EUID)) + } + + /// Nothing else will send the held Create User: it is not a Delta, so the Repo flush does not reach it, + /// and the hold leaves no attempt in flight whose response would drive the queue on. + func testUpdateUserJwtSendsTheCreateUserThatARejectedTokenHeld() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + client.setMockFailureResponseForRequest( + request: "", + error: OneSignalClientError(code: 401, message: "unauthorized", responseHeaders: nil, response: nil, underlyingError: nil) + ) + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitUntil("The rejected Create User was not parked") { + self.client.hasExecutedRequestOfType(OSRequestCreateUser.self, expectedCount: 1) + && self.queuedCreateUserExternalIds().contains(userA_EUID) + } + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestCreateUser.self, expectedCount: 1)) + + // The token the app mints in answer to the invalidated event, which the server accepts. + MockUserRequests.setDefaultCreateUserResponses(with: client, externalId: userA_EUID) + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userA_EUID, token: "token-b") + // Accepting the retry is what takes it out of the queue, which happens after the send. + OneSignalCoreMocks.waitUntil("The held Create User was not re-sent and accepted") { + self.client.hasExecutedRequestOfType(OSRequestCreateUser.self, expectedCount: 2) + && !self.queuedCreateUserExternalIds().contains(userA_EUID) + } + + // The same Request re-signed and accepted, so it carries the replacement token and leaves the queue. + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestCreateUser.self, expectedCount: 2)) + XCTAssertEqual(executedCreateUserAuthorization(), "Bearer token-b") + XCTAssertFalse(queuedCreateUserExternalIds().contains(userA_EUID)) + } + + /// A failure the token cannot fix still stops the queue, since the user will never exist this session. + func testACreateUserThatFailsForAnotherReasonStillPausesTheRepo() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + client.setMockFailureResponseForRequest( + request: "", + error: OneSignalClientError(code: 400, message: "bad-request", responseHeaders: nil, response: nil, underlyingError: nil) + ) + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitUntil("The failure did not pause the Repo") { + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused + } + + XCTAssertTrue(OneSignalUserManagerImpl.sharedInstance.operationRepo.paused) + } + + // MARK: - logout + + func testLogoutUnderIdentityVerificationSilencesThePushSubscriptionAndReportsNoUser() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + waitForTheLoginToSettle() + optInPushSubscription() + observer.states.removeAll() + + OneSignalUserManagerImpl.sharedInstance.logout() + // Observers are told after the subscription is silenced, so this is the later of the two. + waitForTheUserStateToBeReported() + + let payload = pushSubscriptionPayload() + XCTAssertEqual(payload["enabled"] as? Bool, false) + XCTAssertEqual(payload["notification_types"] as? Int, -2) + // The replacement anonymous user is never created on the server, so nothing else would report it. + XCTAssertEqual(observer.states.count, 1) + XCTAssertNil(observer.states.first?.onesignalId) + XCTAssertNil(observer.states.first?.externalId) + } + + /// The silencing has to be stamped with the user being logged out so the unsubscribe is attributed to + /// them rather than the anonymous replacement. + func testLogoutStampsTheSilencedPushSubscriptionWithTheOutgoingUser() throws { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + waitForTheLoginToSettle() + optInPushSubscription() + // Deltas have to stay in the repo queue long enough to be inspected. + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true + + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalCoreMocks.waitUntil("The silencing Delta was not queued") { + self.silencingDelta() != nil + } + + XCTAssertEqual(try XCTUnwrap(silencingDelta()).externalId, userA_EUID) + } + + /// Only the reported payload changes, so a later `login` can restore what the app asked for. + func testLogoutUnderIdentityVerificationLeavesTheAppsOptInAlone() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + waitForTheLoginToSettle() + let pushSubscription = optInPushSubscription() + + OneSignalUserManagerImpl.sharedInstance.logout() + // The reported payload is what logout changes, so it marks the point the model must not have. + waitForThePushSubscriptionToBeSilenced() + + XCTAssertFalse(pushSubscription._isDisabled) + XCTAssertEqual(pushSubscription.notificationTypes, optedInNotificationTypes) + XCTAssertTrue(pushSubscription.optedIn) + } + + func testLogoutWhileIdentityVerificationIsOffLeavesThePushSubscriptionReporting() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + waitForTheLoginToSettle() + optInPushSubscription() + + OneSignalUserManagerImpl.sharedInstance.logout() + // Nothing to poll for: the assertion is that logout left the reported payload alone. + allowAsyncWorkToRun() + + XCTAssertEqual(pushSubscriptionPayload()["notification_types"] as? Int, optedInNotificationTypes) + } + + /// While the requirement is unknown, silence: the false positive is undone by hydrate-to-off, and the + /// other guess would keep delivering the logged-out user's pushes. + func testLogoutWhileIdentityVerificationIsUnknownSilencesThePushSubscription() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + waitForTheLoginToSettle() + optInPushSubscription() + observer.states.removeAll() + makeRequirementUnknown() + + OneSignalUserManagerImpl.sharedInstance.logout() + waitForTheUserStateToBeReported() + + let payload = pushSubscriptionPayload() + XCTAssertEqual(payload["enabled"] as? Bool, false) + XCTAssertEqual(payload["notification_types"] as? Int, -2) + XCTAssertEqual(observer.states.count, 1) + XCTAssertNil(observer.states.first?.onesignalId) + XCTAssertNil(observer.states.first?.externalId) + } + + /// The unknown-logout guess over-silences if the app does not require Identity Verification; hydrate + /// has to undo it the same way it undoes an on→off flip while logged out. + func testLogoutWhileIdentityVerificationIsUnknownThenOffRestoresThePushSubscription() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + waitForTheLoginToSettle() + optInPushSubscription() + makeRequirementUnknown() + OneSignalUserManagerImpl.sharedInstance.logout() + waitForThePushSubscriptionToBeSilenced() + + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + waitForThePushSubscriptionToReportAgain() + + XCTAssertEqual(pushSubscriptionPayload()["notification_types"] as? Int, optedInNotificationTypes) + } + + /// 404 recovery replaces a user the server no longer has; the device should keep reporting through it. + func testInternalLogoutLeavesThePushSubscriptionReportingUnderIdentityVerification() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + waitForTheLoginToSettle() + optInPushSubscription() + + OneSignalUserManagerImpl.sharedInstance._logout() + // Nothing to poll for: the assertion is that 404 recovery left the reported payload alone. + allowAsyncWorkToRun() + + XCTAssertEqual(pushSubscriptionPayload()["notification_types"] as? Int, optedInNotificationTypes) + } + + func testLoggingBackInRestoresThePushSubscription() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + waitForTheLoginToSettle() + optInPushSubscription() + OneSignalUserManagerImpl.sharedInstance.logout() + waitForThePushSubscriptionToBeSilenced() + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + waitForThePushSubscriptionToReportAgain() + + XCTAssertEqual(pushSubscriptionPayload()["notification_types"] as? Int, optedInNotificationTypes) + } + + /// A device left logged out across a restart has to stay silenced, otherwise the next device-property + /// change would re-enable the logged-out user's subscription. + func testAnInternallyDisabledPushSubscriptionSurvivesArchiving() throws { + let model = OSSubscriptionModel(type: .push, address: "push-token", subscriptionId: testPushSubId, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()) + model.notificationTypes = optedInNotificationTypes + model._isDisabledInternally = true + + let data = try NSKeyedArchiver.archivedData(withRootObject: model, requiringSecureCoding: false) + let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) + unarchiver.requiresSecureCoding = false + defer { unarchiver.finishDecoding() } + let decoded = try XCTUnwrap(unarchiver.decodeObject(forKey: NSKeyedArchiveRootObjectKey) as? OSSubscriptionModel) + + XCTAssertTrue(decoded._isDisabledInternally) + XCTAssertEqual(decoded.jsonRepresentation()["notification_types"] as? Int, -2) + } + + /// `login` is otherwise the only thing that clears the internal disable, which would leave an app that + /// turns Identity Verification off while logged out — or one whose logout guessed on while the + /// requirement was still unknown — silenced until the next login. + func testTurningIdentityVerificationOffRestoresAnInternallyDisabledPushSubscription() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + waitForTheLoginToSettle() + optInPushSubscription() + OneSignalUserManagerImpl.sharedInstance.logout() + waitForThePushSubscriptionToBeSilenced() + + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + waitForThePushSubscriptionToReportAgain() + + XCTAssertEqual(pushSubscriptionPayload()["notification_types"] as? Int, optedInNotificationTypes) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/XCTestCase+AsyncWork.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/XCTestCase+AsyncWork.swift new file mode 100644 index 000000000..0d2e65d5e --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/XCTestCase+AsyncWork.swift @@ -0,0 +1,46 @@ +/* + 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 + +extension XCTestCase { + /** + Gives queued async work a chance to run, for assertions that something did *not* happen. + + An absence has no condition to poll, so proving one needs either a fixed wait or a + deterministic drain of whatever drives the work. Use `OneSignalCoreMocks.waitUntil` + anywhere a condition does exist: it returns as soon as the condition holds and it fails + the test with a message when it never does. + + Waits through `XCTWaiter` rather than sleeping, so the run loop keeps pumping and work + dispatched to the main queue can actually run. + */ + func allowAsyncWorkToRun(seconds: Double = 0.5) { + let unfulfilled = XCTestExpectation(description: "Allow \(seconds)s for async work") + _ = XCTWaiter.wait(for: [unfulfilled], timeout: seconds) + } +} From 1bf0c8143a8a597354f02766520be13875a9c291 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 10 Sep 2026 07:57:40 -0700 Subject: [PATCH 05/25] feat: [JWT-7] in-app messages under Identity Verification (#1712) Co-authored-by: Cursor --- .../OneSignal.xcodeproj/project.pbxproj | 4 + .../Controller/OSMessagingController.m | 225 ++++++++++- .../Requests/OSInAppMessagingRequests.h | 6 +- .../Requests/OSInAppMessagingRequests.m | 16 +- .../IamFetchIdentityVerificationTests.swift | 351 ++++++++++++++++++ .../OSMessagingControllerUserStateTests.swift | 84 +++-- ...SignalInAppMessagesTests-Bridging-Header.h | 1 + .../Source/OneSignalUserManagerImpl+Jwt.swift | 25 ++ .../Source/OneSignalUserManagerImpl.swift | 3 + 9 files changed, 672 insertions(+), 43 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IamFetchIdentityVerificationTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 80cd69da4..f1a5ce93e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -313,6 +313,7 @@ 5BC1DE602C90B83900CA8807 /* OSConsistencyKeyEnum.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC1DE5F2C90B83900CA8807 /* OSConsistencyKeyEnum.swift */; }; 5BC1DE622C90B85A00CA8807 /* OSIamFetchOffsetKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC1DE612C90B85A00CA8807 /* OSIamFetchOffsetKey.swift */; }; 5BC1DE642C90BB9000CA8807 /* OSIamFetchReadyCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC1DE632C90BB9000CA8807 /* OSIamFetchReadyCondition.swift */; }; + 6F894909CB6D09258DCCA1C9 /* IamFetchIdentityVerificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67ECA2928D863073B785F93F /* IamFetchIdentityVerificationTests.swift */; }; 7A123295235DFE3B002B6CE3 /* OutcomeTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 7A123294235DFE3B002B6CE3 /* OutcomeTests.m */; }; 7A1232A2235E1743002B6CE3 /* OneSignal.m in Sources */ = {isa = PBXBuildFile; fileRef = 912411F11E73342200E41FD7 /* OneSignal.m */; }; 7A2E90622460DA1500B3428C /* OutcomeIntegrationV2Tests.m in Sources */ = {isa = PBXBuildFile; fileRef = 7A2E90612460DA1500B3428C /* OutcomeIntegrationV2Tests.m */; }; @@ -1591,6 +1592,7 @@ 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 = ""; }; 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 = ""; }; 7A123294235DFE3B002B6CE3 /* OutcomeTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OutcomeTests.m; sourceTree = ""; }; 7A12EBD523060A6F005C4FA5 /* OSSessionManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OSSessionManager.m; sourceTree = ""; }; @@ -2334,6 +2336,7 @@ 3C30FE352F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift */, 3CB35FCA2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift */, 3C7021E72ECF0CF3001768C6 /* OneSignalInAppMessagesTests-Bridging-Header.h */, + 67ECA2928D863073B785F93F /* IamFetchIdentityVerificationTests.swift */, ); path = OneSignalInAppMessagesTests; sourceTree = ""; @@ -4580,6 +4583,7 @@ 3C7021E92ECF0CF4001768C6 /* IAMIntegrationTests.swift in Sources */, 3C01519C2C2E29F90079E076 /* IAMRequestTests.m in Sources */, 3CB35FCB2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift in Sources */, + 6F894909CB6D09258DCCA1C9 /* IamFetchIdentityVerificationTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m index 5e1256482..20d7e0655 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m @@ -146,8 +146,16 @@ @interface OSMessagingController () @property (nonatomic) BOOL calledLoadTags; -/// set when we attempt getInAppMessagesFromServer and no onesignal ID is available yet -@property (strong, nonatomic, nullable) NSString *shouldFetchOnUserChangeWithSubscriptionID; +/** + Set when a fetch could not go out: no onesignal ID yet, or Identity Verification has not answered what + the call should be addressed and signed with. Read through `takeDeferredFetchSubscriptionId`, since a + user change, a hydration and a new token can all arrive at once and only one of them should refetch. + */ +@property (strong, nonatomic, nullable) NSString *deferredFetchSubscriptionId; + +/// Bumped on login and logout while `newCodePathsRun`. A fetch carries the value it started with, so a +/// response that arrives after the user changed is discarded instead of showing one user's messages to another. +@property (nonatomic) NSUInteger userGeneration; /// Tracks whether the first IAM fetch has completed since this cold start @property (nonatomic) BOOL hasCompletedFirstFetch; @@ -240,6 +248,11 @@ - (instancetype)init { _isInAppMessagingPaused = false; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleIAMPreview:) name:ONESIGNAL_POST_PREVIEW_IAM object:nil]; + // A deferred fetch waits on how to address it and what to sign it with; hydration answers the + // first, a supplied token the second. + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(retryDeferredFetch) name:OS_ON_JWT_CONFIG_HYDRATED object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(retryDeferredFetch) name:OS_ON_USER_JWT_UPDATED object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onUserWillChange) name:OS_ON_USER_WILL_CHANGE object:nil]; } return self; @@ -253,7 +266,15 @@ - (void)initializeTriggerController { dateFromString:timeSinceLastMessage]]; } +/** + Fetches in-app messages for `subscriptionId`, addressed as whoever is the current user. + + Nothing checks that the server has those two paired — the subscription comes from the caller and can + predate a login, while the alias is read here — so a well-formed fetch can still be refused. Pairing + them would mean tracking subscription ownership, which the SDK does not do. + */ - (void)getInAppMessagesFromServer:(NSString *)subscriptionId { + NSUInteger generation = [self currentUserGeneration]; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"getInAppMessagesFromServer"]; @@ -267,7 +288,12 @@ - (void)getInAppMessagesFromServer:(NSString *)subscriptionId { // NOTE: Check for subscription ID above first, before checking for OneSignal ID next if (!onesignalId) { [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Failed to get in app messages due to no OneSignal ID, will reattempt"]; - self.shouldFetchOnUserChangeWithSubscriptionID = subscriptionId; + [self deferFetchWithSubscriptionId:subscriptionId]; + return; + } + + // Park before the wait when the fetch cannot go out; attemptFetchWithRetries reads again after. + if (![self authorizationForFetchOrDefer:subscriptionId]) { return; } @@ -288,22 +314,156 @@ - (void)getInAppMessagesFromServer:(NSString *)subscriptionId { [self attemptFetchWithRetries:subscriptionId rywData:rywData attempts:@0 // Starting with 0 attempts - retryLimit:nil]; // Retry limit to be set dynamically on first failure + retryLimit:nil // Retry limit to be set dynamically on first failure + userGeneration:generation]; }); }); } +- (NSUInteger)currentUserGeneration { + @synchronized (self) { + return self.userGeneration; + } +} + +/// Whether a fetch that started in `generation` is still for the user it was addressed and signed for. +- (BOOL)isCurrentUserGeneration:(NSUInteger)generation { + @synchronized (self) { + return self.userGeneration == generation; + } +} + +/** + Drops the outgoing user's in-app messages and invalidates any fetch still in flight for them, then + queues one for whoever is signing in. Only while `newCodePathsRun`. The new user's fetch goes out + from `onUserStateDidChange`, once there is a `onesignal_id` to address it by. + */ +- (void)onUserWillChange { + if (!OneSignalUserManagerImpl.sharedInstance.newCodePathsRun) { + return; + } + @synchronized (self) { + self.userGeneration += 1; + } + [self deferFetchWithSubscriptionId:OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId]; + // On main, where every other write to `messages` happens. + dispatch_async(dispatch_get_main_queue(), ^{ + self.messages = @[]; + [self dismissOutgoingUserInAppMessages]; + }); +} + +/// Leaves preview IAMs; dismisses a showing non-preview so it cannot stay up under the next user. +- (void)dismissOutgoingUserInAppMessages { + BOOL shouldDismiss = NO; + @synchronized (self.messageDisplayQueue) { + OSInAppMessageInternal *showing = nil; + if (self.isInAppMessageShowing && self.messageDisplayQueue.count > 0) { + OSInAppMessageInternal *first = self.messageDisplayQueue.firstObject; + if (!first.isPreview) { + showing = first; + } + } + NSMutableArray *kept = [NSMutableArray new]; + if (showing) { + [kept addObject:showing]; + } + for (OSInAppMessageInternal *message in self.messageDisplayQueue) { + if (message.isPreview) { + [kept addObject:message]; + } + } + [self.messageDisplayQueue setArray:kept]; + shouldDismiss = showing != nil; + } + if (shouldDismiss) { + [self.viewController dismissCurrentInAppMessage]; + } +} + +- (void)deferFetchWithSubscriptionId:(NSString *)subscriptionId { + @synchronized (self) { + self.deferredFetchSubscriptionId = subscriptionId; + } +} + +/// The deferred subscription ID, if there is one, taken so that two signals arriving together refetch once. +- (NSString * _Nullable)takeDeferredFetchSubscriptionId { + @synchronized (self) { + NSString *subscriptionId = self.deferredFetchSubscriptionId; + self.deferredFetchSubscriptionId = nil; + return subscriptionId; + } +} + +- (void)retryDeferredFetch { + NSString *subscriptionId = [self takeDeferredFetchSubscriptionId]; + if (subscriptionId) { + [self getInAppMessagesFromServer:subscriptionId]; + } +} + +/// How to address and sign this fetch. Nil means it is parked until hydration or a token. +- (OSUserRequestAuthorization *)authorizationForFetchOrDefer:(NSString *)subscriptionId { + OSUserRequestAuthorization *authorization = [OneSignalUserManagerImpl.sharedInstance authorizationForCurrentUser]; + if (authorization) { + return authorization; + } + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Failed to get in app messages due to Identity Verification, will reattempt"]; + [self deferFetchWithSubscriptionId:subscriptionId]; + // OS_ON_USER_JWT_UPDATED can fire before deferredFetchSubscriptionId is set. + authorization = [OneSignalUserManagerImpl.sharedInstance authorizationForCurrentUser]; + if (authorization) { + [self takeDeferredFetchSubscriptionId]; + } + return authorization; +} + +/** + Parks the fetch for a later token to reattempt, without reporting the one it was signed with. + + A rejection here is ambiguous: the path names a user and a subscription the server may simply not have + paired, and a replacement token would not change that. Invalidating is left to the user requests, which + address a user alone; the token the app supplies after one of those releases what is parked here. + */ +- (void)handleUnauthorizedFetch:(OSUserRequestAuthorization *)authorization subscriptionId:(NSString *)subscriptionId { + // An unsigned fetch has no token to replace, so there would be nothing different to reattempt with. + if (!authorization.token) { + return; + } + OSUserRequestAuthorization *current = [OneSignalUserManagerImpl.sharedInstance authorizationForCurrentUser]; + if (current.token.length && ![current.token isEqualToString:authorization.token]) { + // OS_ON_USER_JWT_UPDATED already fired with nothing parked. + [self getInAppMessagesFromServer:subscriptionId]; + return; + } + [self deferFetchWithSubscriptionId:subscriptionId]; +} + - (void)attemptFetchWithRetries:(NSString *)subscriptionId rywData:(OSReadYourWriteData *)rywData attempts:(NSNumber *)attempts - retryLimit:(NSNumber *)retryLimit { + retryLimit:(NSNumber *)retryLimit + userGeneration:(NSUInteger)generation { + if (![self isCurrentUserGeneration:generation]) { + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Abandoning an in app message fetch for a previous user"]; + return; + } + + OSUserRequestAuthorization *authorization = [self authorizationForFetchOrDefer:subscriptionId]; + if (!authorization) { + return; + } + NSNumber *sessionDuration = @([OSSessionManager.sharedSessionManager getTimeFocusedElapsed]); NSString *rywToken = rywData.rywToken; NSNumber *rywDelay = rywData.rywDelay; // Create the request with the current attempt count OSRequestGetInAppMessages *request = [OSRequestGetInAppMessages withSubscriptionId:subscriptionId + withAlias:authorization.alias + withUserHeaders:authorization.headers withSessionDuration:sessionDuration withRetryCount:attempts withRywToken:rywToken]; @@ -314,6 +474,10 @@ - (void)attemptFetchWithRetries:(NSString *)subscriptionId onSuccess:^(NSDictionary *result) { dispatch_async(dispatch_get_main_queue(), ^{ [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"getInAppMessagesFromServer success"]; + if (![self isCurrentUserGeneration:generation]) { + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Discarding in app messages fetched for a previous user"]; + return; + } if (result[@"in_app_messages"]) { NSMutableArray *messages = [NSMutableArray new]; @@ -333,7 +497,12 @@ - (void)attemptFetchWithRetries:(NSString *)subscriptionId NSDictionary* responseHeaders = error.responseHeaders; [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"getInAppMessagesFromServer failure: %@", error.description]]; - + + // A retry, and the token this was signed with, both belong to the user it started for. + if (![self isCurrentUserGeneration:generation]) { + return; + } + if (error.code == 425 || error.code == 429) { // 425 Too Early or 429 Too Many Requests NSInteger retryAfter = [responseHeaders[@"Retry-After"] integerValue] ?: DEFAULT_RETRY_AFTER_SECONDS; @@ -348,11 +517,14 @@ - (void)attemptFetchWithRetries:(NSString *)subscriptionId subscriptionId:subscriptionId rywData:rywData attempts:@(nextAttempt) - retryLimit:blockRetryLimit]; + retryLimit:blockRetryLimit + userGeneration:generation]; } else { // Final attempt without rywToken - [self fetchInAppMessagesWithoutToken:subscriptionId]; + [self fetchInAppMessagesWithoutToken:subscriptionId userGeneration:generation]; } + } else if ([OSNetworkingUtils getResponseStatusType:error.code] == OSResponseStatusUnauthorized) { + [self handleUnauthorizedFetch:authorization subscriptionId:subscriptionId]; } else if (error.code >= 500 && error.code <= 599) { [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Server error, skipping retries"]; } @@ -363,21 +535,36 @@ - (void)retryAfterDelay:(NSInteger)retryAfter subscriptionId:(NSString *)subscriptionId rywData:(OSReadYourWriteData *)rywData attempts:(NSNumber *)attempts - retryLimit:(NSNumber *)retryLimit { + retryLimit:(NSNumber *)retryLimit + userGeneration:(NSUInteger)generation { dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(retryAfter * NSEC_PER_SEC)), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ [self attemptFetchWithRetries:subscriptionId rywData:rywData attempts:attempts - retryLimit:retryLimit]; + retryLimit:retryLimit + userGeneration:generation]; }); } -- (void)fetchInAppMessagesWithoutToken:(NSString *)subscriptionId { +- (void)fetchInAppMessagesWithoutToken:(NSString *)subscriptionId + userGeneration:(NSUInteger)generation { + if (![self isCurrentUserGeneration:generation]) { + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Abandoning an in app message fetch for a previous user"]; + return; + } + + OSUserRequestAuthorization *authorization = [self authorizationForFetchOrDefer:subscriptionId]; + if (!authorization) { + return; + } + NSNumber *sessionDuration = @([OSSessionManager.sharedSessionManager getTimeFocusedElapsed]); OSRequestGetInAppMessages *request = [OSRequestGetInAppMessages withSubscriptionId:subscriptionId + withAlias:authorization.alias + withUserHeaders:authorization.headers withSessionDuration:sessionDuration withRetryCount:nil withRywToken:nil]; // No retries for the final attempt @@ -386,6 +573,10 @@ - (void)fetchInAppMessagesWithoutToken:(NSString *)subscriptionId { onSuccess:^(NSDictionary *result) { dispatch_async(dispatch_get_main_queue(), ^{ [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Final attempt without token success"]; + if (![self isCurrentUserGeneration:generation]) { + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Discarding in app messages fetched for a previous user"]; + return; + } if (result[@"in_app_messages"]) { NSMutableArray *messages = [NSMutableArray new]; @@ -402,6 +593,12 @@ - (void)fetchInAppMessagesWithoutToken:(NSString *)subscriptionId { }); } onFailure:^(OneSignalClientError *error) { [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"getInAppMessagesFromServer failure: %@", error.description]]; + if (![self isCurrentUserGeneration:generation]) { + return; + } + if ([OSNetworkingUtils getResponseStatusType:error.code] == OSResponseStatusUnauthorized) { + [self handleUnauthorizedFetch:authorization subscriptionId:subscriptionId]; + } }]; } @@ -1235,11 +1432,9 @@ - (void)onPushSubscriptionDidChangeWithState:(OSPushSubscriptionChangedState * _ } - (void)onUserStateDidChangeWithState:(OSUserChangedState * _Nonnull)state { - if (state.current.onesignalId != nil && self.shouldFetchOnUserChangeWithSubscriptionID) { + if (state.current.onesignalId != nil) { [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"OSMessagingController onUserStateDidChangeWithState: changed to new valid onesignal id"]; - NSString *subscriptionID = self.shouldFetchOnUserChangeWithSubscriptionID; - self.shouldFetchOnUserChangeWithSubscriptionID = nil; - [self getInAppMessagesFromServer:subscriptionID]; + [self retryDeferredFetch]; } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.h b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.h index 047592e63..6be5af702 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.h +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.h @@ -28,8 +28,12 @@ #import #import "OSInAppMessageClickResult.h" +@class OSAliasPair; + @interface OSRequestGetInAppMessages : OneSignalRequest -+ (instancetype _Nonnull)withSubscriptionId:(NSString * _Nonnull)subscriptionId withSessionDuration:(NSNumber * _Nonnull)sessionDuration withRetryCount:(NSNumber *)retryCount withRywToken:(NSString *)rywToken; +/// A nil `alias` addresses the subscription on its own, which is how this was addressed before Identity +/// Verification; `userHeaders` carries the Bearer when the call is signed. ++ (instancetype _Nonnull)withSubscriptionId:(NSString * _Nonnull)subscriptionId withAlias:(OSAliasPair * _Nullable)alias withUserHeaders:(NSDictionary * _Nullable)userHeaders withSessionDuration:(NSNumber * _Nonnull)sessionDuration withRetryCount:(NSNumber *)retryCount withRywToken:(NSString *)rywToken; @end @interface OSRequestInAppMessageViewed : OneSignalRequest diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.m index 6cf041dae..574b9854b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.m @@ -35,13 +35,15 @@ - (NSString *)description { } + (instancetype _Nonnull) withSubscriptionId:(NSString * _Nonnull)subscriptionId + withAlias:(OSAliasPair * _Nullable)alias + withUserHeaders:(NSDictionary * _Nullable)userHeaders withSessionDuration:(NSNumber * _Nonnull)sessionDuration withRetryCount:(NSNumber *)retryCount withRywToken:(NSString *)rywToken { let request = [OSRequestGetInAppMessages new]; request.method = GET; - let headers = [NSMutableDictionary new]; + NSMutableDictionary *headers = userHeaders ? [userHeaders mutableCopy] : [NSMutableDictionary new]; if (sessionDuration != nil) { // convert to ms & round @@ -56,7 +58,17 @@ + (instancetype _Nonnull) withSubscriptionId:(NSString * _Nonnull)subscription request.additionalHeaders = headers; NSString *appId = OneSignalIdentifiers.currentAppId; - request.path = [NSString stringWithFormat:@"apps/%@/subscriptions/%@/iams", appId, subscriptionId]; + if (alias) { + // Encode so an app-chosen external_id cannot change which endpoint the path names. + NSString *encodedAliasId = [OSUrlPath segment:alias.id]; + if (!encodedAliasId) { + [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:@"OSRequestGetInAppMessages: cannot encode alias id for path"]; + return request; + } + request.path = [NSString stringWithFormat:@"apps/%@/users/by/%@/%@/subscriptions/%@/iams", appId, alias.label, encodedAliasId, subscriptionId]; + } else { + request.path = [NSString stringWithFormat:@"apps/%@/subscriptions/%@/iams", appId, subscriptionId]; + } return request; } @end diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IamFetchIdentityVerificationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IamFetchIdentityVerificationTests.swift new file mode 100644 index 000000000..223f47b37 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IamFetchIdentityVerificationTests.swift @@ -0,0 +1,351 @@ +/* + 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 +@testable import OneSignalOSCore +import OneSignalCoreMocks +import OneSignalOSCoreMocks +import OneSignalUserMocks +import OneSignalInAppMessagesMocks +@testable import OneSignalUser + +/** + How the in-app message fetch is addressed and signed. It does not travel through the Request queues, so + it makes the Identity Verification decision itself and holds the fetch until the answer arrives. + */ +final class IamFetchIdentityVerificationTests: XCTestCase { + private let appId = "test-app-id" + + private var client = MockOneSignalClient() + private var jwtListener = MockUserJwtInvalidatedListener() + + private var legacyPath: String { "apps/\(appId)/subscriptions/\(testPushSubId)/iams" } + private var anonymousUserPath: String { userPath(alias: OS_ONESIGNAL_ID, id: anonUserOSID) } + private var identifiedUserPath: String { userPath(alias: OS_EXTERNAL_ID, id: userA_EUID) } + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + ConsistencyManagerTestHelpers.reset() + OSMessagingController.removeInstance() + OneSignalIdentifiers.currentAppId = appId + + client = MockOneSignalClient() + MockUserRequests.setDefaultCreateAnonUserResponses(with: client) + MockUserRequests.setDefaultCreateUserResponses(with: client, externalId: userA_EUID) + OneSignalCoreImpl.setSharedClient(client) + for path in [legacyPath, anonymousUserPath, identifiedUserPath] { + respondToFetch(from: path) + } + + // Held strongly for the test's lifetime: the observable keeps listeners weakly. + jwtListener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(jwtListener) + + OSMessagingController.start() + } + + override func tearDownWithError() throws { + OneSignalUserManagerImpl.sharedInstance.removeUserJwtInvalidatedListener(jwtListener) + OSMessagingController.removeInstance() + OSFeatureFlagsStore.shared.clear() + OSFeatureManager.reset() + OneSignalCoreMocks.clearUserDefaults() + } + + // MARK: - Setup helpers + + private func userPath(alias: String, id: String) -> String { + return "apps/\(appId)/users/by/\(alias)/\(id)/subscriptions/\(testPushSubId)/iams" + } + + private func respondToFetch(from path: String) { + client.setMockResponseForRequest( + request: "", + response: IAMTestHelpers.testFetchMessagesResponse(messages: [])) + } + + private func rejectFetch(from path: String) { + client.setMockFailureResponseForRequest( + request: "", + error: OneSignalClientError(code: 401, message: "unauthorized", responseHeaders: nil, response: nil, underlyingError: nil)) + } + + private func turnOnTheRolloutFlag() { + OSFeatureFlagsStore.shared.applyRemoteFlags(["sdk_identity_verification"], metadata: nil) + } + + /** + Nothing in flight. The mock records a Request as completed only once its response handler has + returned, so this also means the hydration a response drives has already run. + + Tests baseline their fetch counts on whatever the setup below left behind, so the setup has to be + finished sending before they start counting. + */ + private var clientIsIdle: Bool { + return client.completedRequests.count == client.startedRequests.count + } + + /// An anonymous user with an `onesignal_id`, which the fetch needs before it does anything else. + private func startAnonymousUser() { + ConsistencyManagerTestHelpers.setDefaultRywToken(id: anonUserOSID) + OneSignalUserManagerImpl.sharedInstance.start() + OneSignalCoreMocks.waitUntil("The anonymous user was not created") { + OneSignalUserManagerImpl.sharedInstance.user.identityModel.onesignalId != nil && self.clientIsIdle + } + } + + private func login(token: String?) { + ConsistencyManagerTestHelpers.setDefaultRywToken(id: userA_OSID) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: token) + OneSignalCoreMocks.waitUntil("The login did not reach a hydrated user") { + OneSignalUserManagerImpl.sharedInstance.user.identityModel.onesignalId != nil && self.clientIsIdle + } + } + + /// The fetch reaching the server and its response being handled, which covers a rejected one too. + private func fetch() { + let answeredBefore = completedFetches().count + OneSignalInAppMessages.getFromServer(testPushSubId) + OneSignalCoreMocks.waitUntil("The fetch was not answered") { + self.completedFetches().count > answeredBefore + } + } + + /// A fetch Identity Verification holds, which parks the subscription id instead of sending. + private func fetchThatIsHeld() { + OneSignalInAppMessages.getFromServer(testPushSubId) + OneSignalCoreMocks.waitUntil("The fetch was not held") { + self.deferredSubscriptionId() == testPushSubId + } + } + + // MARK: - Assertion helpers + + private func executedFetches() -> [OneSignalRequest] { + return client.executedRequests.filter { $0 is OSRequestGetInAppMessages } + } + + private func startedFetches() -> [OneSignalRequest] { + return client.startedRequests.filter { $0 is OSRequestGetInAppMessages } + } + + private func completedFetches() -> [OneSignalRequest] { + return client.completedRequests.filter { $0 is OSRequestGetInAppMessages } + } + + private func lastFetchPath() -> String? { + return executedFetches().last?.path + } + + private func lastFetchAuthorization() -> String? { + return executedFetches().last?.additionalHeaders?["Authorization"] + } + + private func deferredSubscriptionId() -> String? { + return OSMessagingController.sharedInstance().deferredFetchSubscriptionId + } + + // MARK: - How the fetch is addressed + + func testTheFetchAddressesTheSubscriptionAloneWhileTheNewCodePathsAreOff() { + startAnonymousUser() + + fetch() + + XCTAssertEqual(lastFetchPath(), legacyPath) + XCTAssertNil(lastFetchAuthorization()) + } + + func testTheFetchAddressesTheOnesignalIdWhileIdentityVerificationIsOff() { + turnOnTheRolloutFlag() + startAnonymousUser() + + fetch() + + XCTAssertEqual(lastFetchPath(), anonymousUserPath) + XCTAssertNil(lastFetchAuthorization()) + } + + func testTheFetchAddressesTheExternalIdAndIsSignedUnderIdentityVerification() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + login(token: "token-a") + + fetch() + + XCTAssertEqual(lastFetchPath(), identifiedUserPath) + XCTAssertEqual(lastFetchAuthorization(), "Bearer token-a") + } + + // MARK: - Holding the fetch until Identity Verification answers + + /// An unsigned fetch on behalf of an app that turns out to require auth would be rejected, so a fetch + /// that runs before remote params answer waits for them — including when the rollout flag is off. + func testAFetchHeldForAnUnknownRequirementGoesOutOnHydration() { + startAnonymousUser() + let fetchesBefore = executedFetches().count + OSCoreMocks.resetSharedJwtConfig() + + fetchThatIsHeld() + + XCTAssertEqual(executedFetches().count, fetchesBefore) + XCTAssertEqual(deferredSubscriptionId(), testPushSubId) + + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + OneSignalCoreMocks.waitUntil("Hydration did not release the held fetch") { + self.deferredSubscriptionId() == nil && self.executedFetches().count > fetchesBefore + } + + XCTAssertEqual(lastFetchPath(), legacyPath) + XCTAssertNil(deferredSubscriptionId()) + } + + /// A user whose token was rejected has none until the app supplies another, and the fetch has to wait + /// rather than fall back to sending unsigned. + func testAFetchIsHeldRatherThanSentUnsignedWhenTheUserHasNoToken() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + login(token: "token-a") + OneSignalUserManagerImpl.sharedInstance.userJwtRepo.invalidateJwt(externalId: userA_EUID, rejectedToken: "token-a") + let fetchesBefore = executedFetches().count + + fetchThatIsHeld() + + XCTAssertEqual(executedFetches().count, fetchesBefore) + XCTAssertEqual(deferredSubscriptionId(), testPushSubId) + + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userA_EUID, token: "token-b") + OneSignalCoreMocks.waitUntil("The replacement token did not release the held fetch") { + self.executedFetches().count > fetchesBefore + } + + XCTAssertEqual(lastFetchPath(), identifiedUserPath) + XCTAssertEqual(lastFetchAuthorization(), "Bearer token-b") + } + + // MARK: - A rejected fetch + + /// The fetch is not a source of truth for whether a token is good, because the server can refuse it + /// over a user and subscription it does not have paired. It parks, and the next token releases it. + func testARejectedFetchParksWithoutReportingTheTokenItUsed() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + login(token: "token-a") + rejectFetch(from: identifiedUserPath) + let fetchesBefore = executedFetches().count + + fetch() + + XCTAssertEqual(executedFetches().count, fetchesBefore + 1) + XCTAssertEqual(jwtListener.invalidatedExternalIds, []) + XCTAssertEqual(deferredSubscriptionId(), testPushSubId) + + // A token the app supplies for its own reasons, since the fetch never asked for one. + respondToFetch(from: identifiedUserPath) + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userA_EUID, token: "token-b") + OneSignalCoreMocks.waitUntil("The replacement token did not release the parked fetch") { + self.executedFetches().count == fetchesBefore + 2 + } + + XCTAssertEqual(executedFetches().count, fetchesBefore + 2) + XCTAssertEqual(lastFetchAuthorization(), "Bearer token-b") + } + + /// A replacement that lands while the fetch is in flight is not parked: `OS_ON_USER_JWT_UPDATED` + /// already fired, and waiting for another would leave the fetch stuck. + func testARejectedFetchRefetchesWhenAReplacementTokenAlreadyLanded() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + login(token: "token-a") + rejectFetch(from: identifiedUserPath) + let startedBefore = startedFetches().count + let executedBefore = executedFetches().count + + client.holdResponses = true + OneSignalInAppMessages.getFromServer(testPushSubId) + OneSignalCoreMocks.waitUntil("The fetch did not reach the held client") { + self.startedFetches().count == startedBefore + 1 + } + + XCTAssertEqual(startedFetches().count, startedBefore + 1) + XCTAssertEqual(executedFetches().count, executedBefore) + + client.holdResponses = false + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userA_EUID, token: "token-b") + client.releaseHeldResponses() + respondToFetch(from: identifiedUserPath) + OneSignalCoreMocks.waitUntil("The rejected fetch was not retried") { + self.executedFetches().count == executedBefore + 2 + } + + XCTAssertEqual(executedFetches().count, executedBefore + 2) + XCTAssertEqual(lastFetchAuthorization(), "Bearer token-b") + XCTAssertNil(deferredSubscriptionId()) + XCTAssertEqual(jwtListener.invalidatedExternalIds, []) + } + + /// The token the fetch was signed with stays usable, so everything else keeps going out signed. + func testARejectedFetchLeavesTheTokenInPlaceForTheRequests() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + login(token: "token-a") + rejectFetch(from: identifiedUserPath) + + fetch() + + XCTAssertEqual(lastFetchAuthorization(), "Bearer token-a") + XCTAssertEqual(OneSignalUserManagerImpl.sharedInstance.user.identityModel.jwtBearerToken, "token-a") + } + + /// With the gate off a 401 stays as it was: logged and dropped, with no reattempt left pending. + func testARejectedFetchIsLeftAloneWhileTheNewCodePathsAreOff() { + startAnonymousUser() + rejectFetch(from: legacyPath) + let fetchesBefore = executedFetches().count + + fetch() + + XCTAssertEqual(executedFetches().count, fetchesBefore + 1) + XCTAssertNil(deferredSubscriptionId()) + } + + /// Under Identity Verification the alias id is an app-chosen external_id, so it has to be encoded. + func testTheFetchPathPercentEncodesTheAliasId() { + OneSignalIdentifiers.currentAppId = appId + let externalId = "us er/a?b#c%d" + let request = OSRequestGetInAppMessages.withSubscriptionId( + testPushSubId, + withAlias: OSAliasPair(OS_EXTERNAL_ID, externalId), + withUserHeaders: nil, + withSessionDuration: 0, + withRetryCount: 0, + withRywToken: nil + ) + + XCTAssertEqual( + request.path, + "apps/\(appId)/users/by/\(OS_EXTERNAL_ID)/us%20er%2Fa%3Fb%23c%25d/subscriptions/\(testPushSubId)/iams" + ) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift index 62ceeba99..bf3b649eb 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift @@ -26,7 +26,7 @@ with services provided by OneSignal. */ import XCTest -import OneSignalOSCore +@testable import OneSignalOSCore import OneSignalCoreMocks import OneSignalOSCoreMocks import OneSignalUserMocks @@ -59,6 +59,8 @@ final class OSMessagingControllerUserStateTests: XCTestCase { } override func tearDownWithError() throws { + OSFeatureFlagsStore.shared.clear() + OSFeatureManager.reset() OSMessagingController.removeInstance() } @@ -71,7 +73,7 @@ final class OSMessagingControllerUserStateTests: XCTestCase { - IAM fetch should be deferred Expected: - - shouldFetchOnUserChangeWithSubscriptionID property is set with the subscription ID + - deferredFetchSubscriptionId property is set with the subscription ID - No IAM fetch actually occurs */ func testStoresSubscriptionIDWhenOneSignalIDUnavailable() throws { @@ -84,21 +86,19 @@ final class OSMessagingControllerUserStateTests: XCTestCase { /* Execute */ OneSignalInAppMessages.getFromServer(testSubscriptionId) OneSignalCoreMocks.waitUntil("Deferred IAM subscription ID was not stored") { - OSMessagingController.sharedInstance() - .value(forKey: "shouldFetchOnUserChangeWithSubscriptionID") as? String == self.testSubscriptionId + OSMessagingController.sharedInstance().deferredFetchSubscriptionId == self.testSubscriptionId } /* Verify */ // The controller should have stored the subscription ID for retry - let shouldFetchOnUserChangeWithSubscriptionID = OSMessagingController.sharedInstance().value(forKey: "shouldFetchOnUserChangeWithSubscriptionID") - XCTAssertEqual(shouldFetchOnUserChangeWithSubscriptionID as! String, testSubscriptionId) + XCTAssertEqual(OSMessagingController.sharedInstance().deferredFetchSubscriptionId, testSubscriptionId) // Verify no IAM request was actually made (since we don't have OneSignal ID) XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 0)) } /** - Test that when user state changes with a valid OneSignal ID and shouldFetchOnUserChangeWithSubscriptionID is set, it retries the fetch. + Test that when user state changes with a valid OneSignal ID and deferredFetchSubscriptionId is set, it retries the fetch. Scenario: - IAM fetch was previously deferred due to missing OneSignal ID @@ -107,7 +107,7 @@ final class OSMessagingControllerUserStateTests: XCTestCase { Expected: - IAM fetch is retried with the stored subscription ID - - shouldFetchOnUserChangeWithSubscriptionID is cleared + - deferredFetchSubscriptionId is cleared */ func testRetriesFetchWhenUserStateChangesWithValidOneSignalID() throws { /* Setup */ @@ -130,11 +130,11 @@ final class OSMessagingControllerUserStateTests: XCTestCase { // First attempt: Try to fetch IAMs without OneSignal ID (should be deferred) OneSignalInAppMessages.getFromServer(testSubscriptionId) OneSignalCoreMocks.waitUntil("Deferred IAM subscription ID was not stored") { - controller.value(forKey: "shouldFetchOnUserChangeWithSubscriptionID") as? String == self.testSubscriptionId + controller.deferredFetchSubscriptionId == self.testSubscriptionId } // Verify the subscription ID was stored and no IAM fetch occurred - XCTAssertEqual(controller.value(forKey: "shouldFetchOnUserChangeWithSubscriptionID") as! String, testSubscriptionId) + XCTAssertEqual(controller.deferredFetchSubscriptionId, testSubscriptionId) XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 0)) // Now let the login succeed, receive onesignal ID which fires user state observer @@ -143,7 +143,7 @@ final class OSMessagingControllerUserStateTests: XCTestCase { OneSignalUserManagerImpl.sharedInstance.userExecutor?.executePendingRequests() OneSignalCoreMocks.waitUntil("Deferred IAM fetch was not retried") { client.hasCompletedRequestOfType(OSRequestGetInAppMessages.self) - && controller.value(forKey: "shouldFetchOnUserChangeWithSubscriptionID") == nil + && controller.deferredFetchSubscriptionId == nil } /* Verify */ @@ -151,22 +151,24 @@ final class OSMessagingControllerUserStateTests: XCTestCase { XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1)) // The stored subscription ID should be cleared after successful retry - XCTAssertNil(controller.value(forKey: "shouldFetchOnUserChangeWithSubscriptionID")) + XCTAssertNil(controller.deferredFetchSubscriptionId) } /** - Test that when user state changes but shouldFetchOnUserChangeWithSubscriptionID is not set, it does nothing. - + Test that logging in refetches in-app messages for the user signing in. + Scenario: - - Normal user state change occurs - - No deferred fetch was pending - + - Identity Verification code paths are on + - A user's in-app messages have already been fetched + - The app logs in as someone else + Expected: - - No retry logic is triggered - - Normal operation continues + - A second fetch goes out once the new user has a OneSignal ID, so the previous user's messages are + not what gets evaluated */ - func testDoesNothingWhenNoRetryPending() throws { + func testLoginRefetchesInAppMessagesForTheIncomingUser() throws { /* Setup */ + OSFeatureFlagsStore.shared.applyRemoteFlags(["sdk_identity_verification"], metadata: nil) let client = MockOneSignalClient() OneSignalCoreImpl.setSharedClient(client) OneSignalInAppMessages.start() @@ -187,18 +189,50 @@ final class OSMessagingControllerUserStateTests: XCTestCase { /* Verify */ // IAM is fetched and no retry is pending XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1)) - XCTAssertNil(controller.value(forKey: "shouldFetchOnUserChangeWithSubscriptionID")) + XCTAssertNil(controller.deferredFetchSubscriptionId) /* Execute */ - // Trigger a normal user state change by login MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: testExternalId) OneSignalUserManagerImpl.sharedInstance.login(externalId: testExternalId, token: nil) - OneSignalCoreMocks.waitUntil("Identify user request did not complete") { - client.hasCompletedRequestOfType(OSRequestIdentifyUser.self) + OneSignalCoreMocks.waitUntil("IAM fetch for the incoming user did not complete") { + client.hasCompletedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 2) } /* Verify */ - // Does not fetch IAMs again + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 2)) + XCTAssertNil(controller.deferredFetchSubscriptionId) + } + + /// Without the rollout flag or `jwt_required`, a login leaves the current user's messages as they were. + func testLoginDoesNotRefetchInAppMessagesWhenIdentityVerificationCodePathsAreOff() throws { + let client = MockOneSignalClient() + OneSignalCoreImpl.setSharedClient(client) + OneSignalInAppMessages.start() + let controller = OSMessagingController.sharedInstance() + + MockUserRequests.setDefaultCreateAnonUserResponses( + with: client, + onesignalId: testOneSignalId, + subscriptionId: testSubscriptionId + ) + ConsistencyManagerTestHelpers.setDefaultRywToken(id: testOneSignalId) + OneSignalUserManagerImpl.sharedInstance.start() + OneSignalCoreMocks.waitUntil("Initial IAM fetch did not complete") { + client.hasCompletedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1) + } + + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1)) + + MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: testExternalId) + OneSignalUserManagerImpl.sharedInstance.login(externalId: testExternalId, token: nil) + // The claim is that no second fetch follows, so wait for the login to run out of work rather + // than for a fetch that should never arrive. + OneSignalCoreMocks.waitUntil("The login left a Request in flight") { + client.hasCompletedRequestOfType(OSRequestIdentifyUser.self) + && client.completedRequests.count == client.startedRequests.count + } + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1)) + XCTAssertNil(controller.deferredFetchSubscriptionId) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h index 813316635..8f671baae 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h @@ -14,6 +14,7 @@ @property (strong, nonatomic, nonnull) NSMutableDictionary *redisplayedInAppMessages; @property (strong, nonatomic, nonnull) NSMutableArray *messages; @property (strong, nonatomic, nonnull) OSTriggerController *triggerController; +@property (strong, nonatomic, nullable) NSString *deferredFetchSubscriptionId; + (void)start; + (void)removeInstance; - (void)presentInAppPreviewMessage:(OSInAppMessageInternal *)message; diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift index 859353d90..5a424932f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift @@ -63,6 +63,7 @@ extension OneSignalUserManagerImpl { } operationRepo.addFlushDeltaQueueToDispatchQueue() userExecutor?.executePendingRequests() + NotificationCenter.default.post(name: Notification.Name(OS_ON_USER_JWT_UPDATED), object: nil) } /** @@ -107,4 +108,28 @@ extension OneSignalUserManagerImpl { storeJwt(externalId: externalId, token: token) } + + /// Rollout flag, or always when the app requires Identity Verification. + @objc public var newCodePathsRun: Bool { + identityVerificationService.newCodePathsRun + } + + /** + How another module should address and sign a user-scoped call for the current user, decided in one + read so the alias and the token cannot come from different users. + + Returns nil when the call cannot be sent yet — the requirement is still unknown, nobody is logged in + under Identity Verification, or the app owes a token, which this asks for. Callers reattempt when + `OS_ON_JWT_CONFIG_HYDRATED` or `OS_ON_USER_JWT_UPDATED` is posted. + */ + @objc + public func authorizationForCurrentUser() -> OSUserRequestAuthorization? { + // `_user` rather than `user`, which would create a guest user for a caller that only reads. + guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil), + let identityModel = _user?.identityModel + else { + return nil + } + return requestAuth.authorization(onesignalId: identityModel.onesignalId, externalId: identityModel.externalId) + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 77a9e374d..dc1a55db5 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -295,6 +295,9 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { app's own opt-in, which would make the silencing permanent. */ identityVerificationService.addOnJwtConfigHydratedHandler(for: .userManager) { [weak self] requirement in + // Tells work that does not travel through the Repo, such as the in-app message fetch, + // that how to address a user-scoped call is now decided. + NotificationCenter.default.post(name: Notification.Name(OS_ON_JWT_CONFIG_HYDRATED), object: nil) guard requirement == .off else { return } From 5f5336d7c64af210f29a8f73f8eb9ea65508f7f7 Mon Sep 17 00:00:00 2001 From: Nan Date: Fri, 11 Sep 2026 09:53:00 -0700 Subject: [PATCH 06/25] feat: [SDK-5156] wire named events into the log pipeline on iOS (#1732) (cherry picked from commit 25b6f9bf3170c22251878bb4c9c66232e6ce9c31) --- OneSignal-KMP-SDK | 2 +- .../OneSignal.xcodeproj/project.pbxproj | 8 + .../Source/Logging/FileLogStore.swift | 4 +- .../Source/Logging/OSLogCrashHandler.swift | 13 +- .../OSObservabilityEventRecorder.swift | 127 ++++++++ .../Source/Logging/OSRemoteLogger.swift | 27 +- .../OSLogCrashHandlerTests.swift | 2 +- .../OSLoggerAdaptersTests.swift | 17 +- .../OSObservabilityEventRecorderTests.swift | 278 ++++++++++++++++++ .../Source/OSRemoteLoggingController.swift | 3 + 10 files changed, 462 insertions(+), 19 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSObservabilityEventRecorder.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSObservabilityEventRecorderTests.swift diff --git a/OneSignal-KMP-SDK b/OneSignal-KMP-SDK index fe14ac899..ee61d4673 160000 --- a/OneSignal-KMP-SDK +++ b/OneSignal-KMP-SDK @@ -1 +1 @@ -Subproject commit fe14ac899d349822f30132e0de08a644d899fbe3 +Subproject commit ee61d4673265876e5cab6328b7a0745f79b7a5aa diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index f1a5ce93e..47704cc30 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -81,6 +81,7 @@ 3C14E3B32FAE54C006ED053 /* OSLoggerPlatformProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C14E3AC2FAE54C006ED053 /* OSLoggerPlatformProvider.swift */; }; 3C14E3B42FAE54C006ED053 /* KotlinByteArray+Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C14E3AD2FAE54C006ED053 /* KotlinByteArray+Data.swift */; }; 3C14E3B52FAE54C006ED053 /* OSLoggerAdaptersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C14E3AE2FAE54C006ED053 /* OSLoggerAdaptersTests.swift */; }; + 5156E0E0A1B2C3D4E5F60004 /* OSObservabilityEventRecorderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5156E0E0A1B2C3D4E5F60003 /* OSObservabilityEventRecorderTests.swift */; }; A5048F01A1B2C3D4E5F60002 /* OSFeatureFlagsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F60001 /* OSFeatureFlagsStore.swift */; }; A5048F01A1B2C3D4E5F60004 /* OSFeatureFlagsBackendService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F60003 /* OSFeatureFlagsBackendService.swift */; }; A5048F01A1B2C3D4E5F60006 /* OSFeatureManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F60005 /* OSFeatureManager.swift */; }; @@ -98,6 +99,7 @@ C73D4E5F60718293A4B5C6D7 /* OSRemoteLoggingConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C91A2B0D84F1E9A3C5B6D8E /* OSRemoteLoggingConfiguration.swift */; }; ACE2175908241BB46C9F1829 /* OSRemoteLoggingControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 658E6E9E6BC6BBF702BCBD33 /* OSRemoteLoggingControllerTests.swift */; }; 7732574D325D34CC7C498199 /* OSRemoteLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6972EE491A57C79EFE56D4C8 /* OSRemoteLogger.swift */; }; + 5156E0E0A1B2C3D4E5F60002 /* OSObservabilityEventRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5156E0E0A1B2C3D4E5F60001 /* OSObservabilityEventRecorder.swift */; }; 698F58A488FCE503DFD5247F /* OSLogCrashHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACD3284210A7DF2597594778 /* OSLogCrashHandler.swift */; }; 3C19C6322E919F0C00D6731E /* OSRequestLiveActivityClicked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C19C6312E919F0C00D6731E /* OSRequestLiveActivityClicked.swift */; }; 3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */; }; @@ -1866,6 +1868,7 @@ 3C14E3AC2FAE54C006ED053 /* OSLoggerPlatformProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLoggerPlatformProvider.swift; sourceTree = ""; }; 3C14E3AD2FAE54C006ED053 /* KotlinByteArray+Data.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KotlinByteArray+Data.swift"; sourceTree = ""; }; 3C14E3AE2FAE54C006ED053 /* OSLoggerAdaptersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLoggerAdaptersTests.swift; sourceTree = ""; }; + 5156E0E0A1B2C3D4E5F60003 /* OSObservabilityEventRecorderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSObservabilityEventRecorderTests.swift; sourceTree = ""; }; A5048F01A1B2C3D4E5F60001 /* OSFeatureFlagsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsStore.swift; sourceTree = ""; }; A5048F01A1B2C3D4E5F60003 /* OSFeatureFlagsBackendService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsBackendService.swift; sourceTree = ""; }; A5048F01A1B2C3D4E5F60005 /* OSFeatureManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureManager.swift; sourceTree = ""; }; @@ -1880,6 +1883,7 @@ C0462F96E1AADF655F3B3765 /* OSRemoteLoggingController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSRemoteLoggingController.h; sourceTree = ""; }; 658E6E9E6BC6BBF702BCBD33 /* OSRemoteLoggingControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingControllerTests.swift; sourceTree = ""; }; 6972EE491A57C79EFE56D4C8 /* OSRemoteLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLogger.swift; sourceTree = ""; }; + 5156E0E0A1B2C3D4E5F60001 /* OSObservabilityEventRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSObservabilityEventRecorder.swift; sourceTree = ""; }; ACD3284210A7DF2597594778 /* OSLogCrashHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLogCrashHandler.swift; sourceTree = ""; }; DEF5CCF12539321A0003E9CC /* UnitTestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = UnitTestApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; DEF5CCF32539321A0003E9CC /* AppDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; @@ -2391,6 +2395,7 @@ ACD3284210A7DF2597594778 /* OSLogCrashHandler.swift */, 3C14E3AC2FAE54C006ED053 /* OSLoggerPlatformProvider.swift */, 6972EE491A57C79EFE56D4C8 /* OSRemoteLogger.swift */, + 5156E0E0A1B2C3D4E5F60001 /* OSObservabilityEventRecorder.swift */, ); path = Logging; sourceTree = ""; @@ -2686,6 +2691,7 @@ 5BC1DE672C90C23E00CA8807 /* OSConsistencyManagerTests.swift */, 3C427AC8301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift */, 3C14E3AE2FAE54C006ED053 /* OSLoggerAdaptersTests.swift */, + 5156E0E0A1B2C3D4E5F60003 /* OSObservabilityEventRecorderTests.swift */, A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */, A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */, 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */, @@ -4605,6 +4611,7 @@ 3C14E3B02FAE54C006ED053 /* IOSLogger.swift in Sources */, 698F58A488FCE503DFD5247F /* OSLogCrashHandler.swift in Sources */, 7732574D325D34CC7C498199 /* OSRemoteLogger.swift in Sources */, + 5156E0E0A1B2C3D4E5F60002 /* OSObservabilityEventRecorder.swift in Sources */, 3C11518B289ADEEB00565C41 /* OSEventProducer.swift in Sources */, 3C115165289A259500565C41 /* OneSignalOSCore.docc in Sources */, 5BC1DE5E2C90B80E00CA8807 /* OSCondition.swift in Sources */, @@ -4782,6 +4789,7 @@ 3C23A21D2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift in Sources */, 3C427AC9301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift in Sources */, 3C14E3B52FAE54C006ED053 /* OSLoggerAdaptersTests.swift in Sources */, + 5156E0E0A1B2C3D4E5F60004 /* OSObservabilityEventRecorderTests.swift in Sources */, A5048F01A1B2C3D4E5F6000A /* OSFeatureFlagsTests.swift in Sources */, A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */, C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/FileLogStore.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/FileLogStore.swift index 492e52fc6..8b59731a9 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/FileLogStore.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/FileLogStore.swift @@ -74,8 +74,8 @@ final class FileLogStore: ILogFileStore { ) { self.rootURL = URL(fileURLWithPath: rootPath, isDirectory: true) self.fileManager = fileManager - let crashLogger = OSCrashLogger() - self.crashWarn = crashWarn ?? { crashLogger.warn(message: $0) } + let consoleLogger = OSConsoleLogger() + self.crashWarn = crashWarn ?? { consoleLogger.warn(message: $0) } self.attributeLookup = attributeLookup try? createRootDirectory() } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift index b3e00c70a..a406fb41d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift @@ -41,9 +41,14 @@ struct OSResolvedStackFrame: Equatable { let symbolName: String? } -/// Prints KMP crash-reporter diagnostics with `NSLog`. Honors the console log level -/// without going through `OneSignalLog` (listeners, alert UI, remote sink). -final class OSCrashLogger: ILogger { +/// Prints KMP diagnostics with `NSLog`, honoring the console log level without going through +/// `OneSignalLog` (listeners, alert UI, remote telemetry). Each caller documents why it cannot +/// afford that path. +final class OSConsoleLogger: ILogger { + /// Marks the lines that skipped `OneSignalLog`. Every message arrives already naming its + /// own component, so nothing finer is needed here. + private static let prefix = "[OSConsoleLogger]" + private let consoleLogLevel: () -> ONE_S_LOG_LEVEL private let write: (String) -> Void @@ -75,7 +80,7 @@ final class OSCrashLogger: ILogger { guard level.rawValue <= consoleLogLevel().rawValue else { return } - write("[OneSignal crash] \(label): \(message)") + write("\(Self.prefix) \(label): \(message)") } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSObservabilityEventRecorder.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSObservabilityEventRecorder.swift new file mode 100644 index 000000000..6c2bce16c --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSObservabilityEventRecorder.swift @@ -0,0 +1,127 @@ +/* + 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 Foundation +import OneSignalCore +@_implementationOnly import OneSignalKMP + +/// Mirrors the KMP `ObservabilityEvent` catalog so a call site can name an event without seeing KMP +/// types, which this module imports implementation-only. +@_spi(OneSignalInternal) +public enum OSObservabilityEvent: CaseIterable { + /// Temporary: comes out once its usage question is answered. + case deviceGesture + + /// The `event.name` of the KMP entry, so a test can check the mirror against the catalog. + var eventName: String { + kmpEvent.eventName + } +} + +/// The producer-facing contract, so a call site can take a spy in tests. +@_spi(OneSignalInternal) +public protocol OSObservabilityEventRecorderProtocol: AnyObject { + /// Never throws or blocks. Drops when the event's flag is off or the per-process cap is + /// reached; queues, bounded, until remote telemetry is attached. + func record(event: OSObservabilityEvent, attributes: [String: String]) +} + +/// The attach side, driven by `OSRemoteLogger`. Internal because the telemetry is a KMP type. +protocol OSObservabilityEventRecorderAttaching: AnyObject { + func attach(_ telemetry: ILogTelemetry) + + /// Ignored unless `telemetry` is the attached one, so a logger that lost the install race + /// cannot detach the winner. + func detach(_ telemetry: ILogTelemetry) +} + +/// Wraps the shared KMP recorder, which owns the flag check, the pre-attach queue and the +/// per-process cap. Events ride the remote logger's telemetry, so they share the crash gate +/// rather than the severity filter. +@_spi(OneSignalInternal) +public final class OSObservabilityEventRecorder: OSObservabilityEventRecorderProtocol, OSObservabilityEventRecorderAttaching { + public static let shared = OSObservabilityEventRecorder(isFeatureEnabled: featureIsEnabledIfInitialized) + + private let recorder: IObservabilityEventRecorder + + /// - Parameter isFeatureEnabled: the feature-manager read for a catalog flag key. + init(isFeatureEnabled: @escaping (String) -> Bool) { + // Console-only logger: `attach` runs under the remote logger's lifecycle lock, and + // `OneSignalLog` reaches app listeners synchronously, so a listener that re-enters the + // SDK would deadlock on that lock. + recorder = LoggerFactory.shared.createObservabilityEventRecorder( + flags: OSFeatureFlagReader(isFeatureEnabled: isFeatureEnabled), + logger: OSConsoleLogger() + ) + } + + /// Never constructs the feature manager: first touch latches every APP_STARTUP flag from + /// whatever storage returns, and a record can arrive from any thread at any time. Off until + /// the manager exists, which is also the documented state before the first flags fetch. + static func featureIsEnabledIfInitialized(_ key: String) -> Bool { + OSFeatureManager.enabledFeatureKeysIfInitialized().contains(key) + } + + public func record(event: OSObservabilityEvent, attributes: [String: String]) { + recorder.record(event: event.kmpEvent, attributes: attributes) + } + + /// Drops whatever an earlier app id queued; called beside the other app-id-change resets. + public func reset() { + recorder.reset() + } + + func attach(_ telemetry: ILogTelemetry) { + recorder.attach(telemetry: telemetry) + } + + func detach(_ telemetry: ILogTelemetry) { + recorder.detach(telemetry: telemetry) + } +} + +/// Answers the KMP recorder's flag lookups; each event's own gate decides which flag to ask about. +private final class OSFeatureFlagReader: IFeatureFlagReader { + private let isFeatureEnabled: (String) -> Bool + + init(isFeatureEnabled: @escaping (String) -> Bool) { + self.isFeatureEnabled = isFeatureEnabled + } + + func isEnabled(flag: FeatureFlag) -> Bool { + isFeatureEnabled(flag.key) + } +} + +private extension OSObservabilityEvent { + var kmpEvent: ObservabilityEvent { + switch self { + case .deviceGesture: + return .deviceGesture + } + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift index 74fbddcf1..8124a862f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift @@ -208,6 +208,10 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { private let crashUploader: LogCrashUploader private let logger: IOSLogger private let lifecycle: OSRemoteLoggerLifecycle + private let eventRecorder: OSObservabilityEventRecorderAttaching + /// Guarded by `lifecycleOperationLock`. The recorder is shared across instances, and a logger + /// that lost the install race is shut down without ever starting; it must not detach the winner. + private var didAttachEventRecorder = false private let lifecycleOperationLock = NSLock() private let uploaderOwner = UUID() @@ -262,7 +266,8 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { ) } - private init( + /// Internal so tests can stand in an event recorder; production goes through the convenience initializers. + init( installIdProvider: @escaping () -> String, onesignalIdProvider: @escaping () -> String?, pushSubscriptionIdProvider: @escaping () -> String?, @@ -270,7 +275,8 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { featureFlagsProvider: @escaping () -> [String], remoteLogLevelProvider: @escaping () -> String?, exporterLoggingEnabledProvider: @escaping () -> Bool, - requestSenderOverride: RequestSender? + requestSenderOverride: RequestSender?, + eventRecorder: OSObservabilityEventRecorderAttaching = OSObservabilityEventRecorder.shared ) { let provider = OSLoggerPlatformProvider( installIdProvider: installIdProvider, @@ -282,7 +288,7 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { exporterLoggingEnabledProvider: exporterLoggingEnabledProvider ) let logger = IOSLogger() - let crashLogger = OSCrashLogger() + let consoleLogger = OSConsoleLogger() let lifecycle = OSRemoteLoggerLifecycle() let fileStore = FileLogStore(rootPath: provider.crashStoragePath) // Console-only logger on purpose. Exporter diagnostics describe the POST that @@ -290,7 +296,7 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { // back into the export queue as a new record and never settle. let httpSender = Self.makeHttpSender( requestSender: requestSenderOverride, - logger: crashLogger, + logger: consoleLogger, isDiagnosticsEnabled: exporterLoggingEnabledProvider, lifecycle: lifecycle ) @@ -304,7 +310,7 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { ) let crashReporter = LoggerFactory.shared.createCrashReporter( crashTelemetry: crashTelemetry, - logger: crashLogger + logger: consoleLogger ) let crashHandler = OSLogCrashHandler(reporter: crashReporter) let crashUploader = LoggerFactory.shared.createCrashUploader( @@ -320,6 +326,7 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { self.crashUploader = crashUploader self.logger = logger self.lifecycle = lifecycle + self.eventRecorder = eventRecorder } private static func makeHttpSender( @@ -354,6 +361,12 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { } crashHandler.initialize() + // Events ride this telemetry, so the recorder follows it here and in shutdown(). At NONE it + // stays detached and events queue until a logger with a real level starts. + if platformProvider.isRemoteLoggingEnabled { + eventRecorder.attach(telemetry) + didAttachEventRecorder = true + } lifecycleOperationLock.unlock() let owner = uploaderOwner let crashUploader = self.crashUploader @@ -439,6 +452,10 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { OSCrashUploaderCoordinator.shared.cancel(owner: uploaderOwner) crashHandler.unregister() + if didAttachEventRecorder { + eventRecorder.detach(telemetry) + didAttachEventRecorder = false + } lifecycleOperationLock.unlock() // `telemetry.shutdown()` blocks for up to five seconds draining buffered diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLogCrashHandlerTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLogCrashHandlerTests.swift index fa237b7a4..475ef5bf5 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLogCrashHandlerTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLogCrashHandlerTests.swift @@ -340,7 +340,7 @@ extension OSLogCrashHandlerTests { ) let reporter = LoggerFactory.shared.createCrashReporter( crashTelemetry: telemetry, - logger: OSCrashLogger() + logger: OSConsoleLogger() ) return OSLogCrashHandler(reporter: reporter) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLoggerAdaptersTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLoggerAdaptersTests.swift index dc3f9ad27..3c382a252 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLoggerAdaptersTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLoggerAdaptersTests.swift @@ -265,12 +265,15 @@ final class OSLoggerAdaptersTests: XCTestCase { XCTAssertEqual(listener.levels, [.LL_ERROR, .LL_WARN, .LL_INFO, .LL_DEBUG]) } - func testCrashLoggerHonorsConsoleLogLevelWithoutOneSignalLog() { + func testConsoleLoggerHonorsConsoleLogLevelWithoutOneSignalLog() { + // The point of this logger: it must never reach OneSignalLog, whose listeners run + // synchronously and would deadlock a caller holding the lifecycle lock, or feed the + // export queue a record about the export. let listener = LoggerAdapterListener() OneSignalLog.debug().__add(listener) defer { OneSignalLog.debug().__remove(listener) } var lines: [String] = [] - let logger = OSCrashLogger( + let logger = OSConsoleLogger( consoleLogLevel: { .LL_WARN }, write: { lines.append($0) } ) @@ -280,19 +283,21 @@ final class OSLoggerAdaptersTests: XCTestCase { logger.info(message: "info") logger.debug(message: "debug") + // The prefix marks the console-only path; the component is named by the message the + // caller passes, so this logger does not repeat it. XCTAssertEqual( lines, [ - "[OneSignal crash] ERROR: error", - "[OneSignal crash] WARN: warn" + "[OSConsoleLogger] ERROR: error", + "[OSConsoleLogger] WARN: warn" ] ) XCTAssertTrue(listener.levels.isEmpty) } - func testCrashLoggerSilentWhenConsoleLogLevelIsNone() { + func testConsoleLoggerSilentWhenConsoleLogLevelIsNone() { var lines: [String] = [] - let logger = OSCrashLogger( + let logger = OSConsoleLogger( consoleLogLevel: { .LL_NONE }, write: { lines.append($0) } ) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSObservabilityEventRecorderTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSObservabilityEventRecorderTests.swift new file mode 100644 index 000000000..fc57b5390 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSObservabilityEventRecorderTests.swift @@ -0,0 +1,278 @@ +/* + 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 Foundation +import OneSignalCore +import OneSignalKMP +@_spi(OneSignalInternal) @testable import OneSignalOSCore +import XCTest + +/// Stands in for the remote telemetry and records what the shared KMP recorder emits. +private final class TelemetrySpy: ILogTelemetry { + private let lock = NSLock() + var emitExpectation: XCTestExpectation? + private var storedRecords: [LogRecord] = [] + + var records: [LogRecord] { + lock.lock() + defer { lock.unlock() } + return storedRecords + } + + func emit(record: LogRecord, completionHandler: @escaping (Error?) -> Void) { + lock.lock() + storedRecords.append(record) + lock.unlock() + emitExpectation?.fulfill() + completionHandler(nil) + } + + func forceFlush(completionHandler: @escaping (Error?) -> Void) { + completionHandler(nil) + } + + func shutdown() {} +} + +/// Observes the seam `OSRemoteLogger` drives. +private final class EventRecorderSpy: OSObservabilityEventRecorderAttaching { + private(set) var attached: [ILogTelemetry] = [] + private(set) var detached: [ILogTelemetry] = [] + + func attach(_ telemetry: ILogTelemetry) { + attached.append(telemetry) + } + + func detach(_ telemetry: ILogTelemetry) { + detached.append(telemetry) + } +} + +final class OSObservabilityEventRecorderTests: XCTestCase { + override func tearDown() { + OSFeatureManager.didConstructForTesting = nil + OSFeatureManager.reset() + OSFeatureFlagsStore.shared.clear() + super.tearDown() + } + + private func makeRemoteLogger(recorder: EventRecorderSpy, remoteLogLevel: String? = "ERROR") -> OSRemoteLogger { + OSRemoteLogger( + installIdProvider: { "install-id" }, + onesignalIdProvider: { nil }, + pushSubscriptionIdProvider: { nil }, + appStateProvider: { "foreground" }, + featureFlagsProvider: { [] }, + remoteLogLevelProvider: { remoteLogLevel }, + exporterLoggingEnabledProvider: { false }, + requestSenderOverride: { _, completion in completion(nil, nil, nil) }, + eventRecorder: recorder + ) + } + + // MARK: - Recording through the shared KMP recorder + + func testRecordShipsAnInfoRecordNamedAfterTheEvent() throws { + let telemetry = TelemetrySpy() + telemetry.emitExpectation = expectation(description: "emits the event") + var askedKeys: [String] = [] + let recorder = OSObservabilityEventRecorder(isFeatureEnabled: { key in + askedKeys.append(key) + return true + }) + recorder.attach(telemetry) + + recorder.record(event: .deviceGesture, attributes: ["gesture.result": "copied"]) + + wait(for: [telemetry.emitExpectation!], timeout: 2) + let record = try XCTUnwrap(telemetry.records.first) + XCTAssertEqual(record.severity, LogSeverity.info) + XCTAssertEqual(record.body, "sdk.device_gesture") + XCTAssertEqual(record.attributes["event.name"], "sdk.device_gesture") + XCTAssertEqual(record.attributes["gesture.result"], "copied") + XCTAssertEqual(askedKeys, ["sdk_event_device_gesture"]) + } + + func testRecordDropsWhenTheEventFlagIsOff() { + let telemetry = TelemetrySpy() + telemetry.emitExpectation = expectation(description: "does not emit") + telemetry.emitExpectation?.isInverted = true + let recorder = OSObservabilityEventRecorder(isFeatureEnabled: { _ in false }) + recorder.attach(telemetry) + + recorder.record(event: .deviceGesture, attributes: [:]) + + wait(for: [telemetry.emitExpectation!], timeout: 0.2) + XCTAssertTrue(telemetry.records.isEmpty) + } + + func testEventsRecordedBeforeAttachFlushOnAttach() { + let telemetry = TelemetrySpy() + telemetry.emitExpectation = expectation(description: "flushes the queue") + let recorder = OSObservabilityEventRecorder(isFeatureEnabled: { _ in true }) + + recorder.record(event: .deviceGesture, attributes: ["n": "1"]) + recorder.attach(telemetry) + + wait(for: [telemetry.emitExpectation!], timeout: 2) + XCTAssertEqual(telemetry.records.map { $0.attributes["n"] }, ["1"]) + } + + func testDetachHoldsEventsUntilTheNextAttach() { + let first = TelemetrySpy() + let second = TelemetrySpy() + second.emitExpectation = expectation(description: "the next telemetry receives the held event") + let recorder = OSObservabilityEventRecorder(isFeatureEnabled: { _ in true }) + recorder.attach(first) + recorder.detach(first) + + recorder.record(event: .deviceGesture, attributes: [:]) + recorder.attach(second) + + wait(for: [second.emitExpectation!], timeout: 2) + XCTAssertTrue(first.records.isEmpty) + XCTAssertEqual(second.records.count, 1) + } + + func testDetachOfTelemetryThatIsNotAttachedIsIgnored() { + let winner = TelemetrySpy() + winner.emitExpectation = expectation(description: "the attached telemetry still receives the event") + let loser = TelemetrySpy() + let recorder = OSObservabilityEventRecorder(isFeatureEnabled: { _ in true }) + recorder.attach(winner) + + recorder.detach(loser) + recorder.record(event: .deviceGesture, attributes: [:]) + + wait(for: [winner.emitExpectation!], timeout: 2) + XCTAssertEqual(winner.records.count, 1) + XCTAssertTrue(loser.records.isEmpty) + } + + func testResetDropsTheQueue() { + let telemetry = TelemetrySpy() + telemetry.emitExpectation = expectation(description: "nothing from before the reset") + telemetry.emitExpectation?.isInverted = true + let recorder = OSObservabilityEventRecorder(isFeatureEnabled: { _ in true }) + recorder.record(event: .deviceGesture, attributes: ["n": "old app"]) + + recorder.reset() + recorder.attach(telemetry) + + wait(for: [telemetry.emitExpectation!], timeout: 0.2) + XCTAssertTrue(telemetry.records.isEmpty) + } + + // MARK: - The flag read + + func testTheFlagReadDoesNotConstructTheFeatureManager() { + OSFeatureManager.reset() + var constructed = false + OSFeatureManager.didConstructForTesting = { constructed = true } + + let enabled = OSObservabilityEventRecorder.featureIsEnabledIfInitialized(FeatureFlag.sdkEventDeviceGesture.key) + + XCTAssertFalse(enabled) + XCTAssertFalse(constructed) + } + + func testTheFlagReadSeesTheBuiltFeatureManager() { + OSFeatureManager.reset() + OSFeatureFlagsStore.shared.applyRemoteFlags([FeatureFlag.sdkEventDeviceGesture.key], metadata: nil) + _ = OSFeatureManager.shared + + XCTAssertTrue(OSObservabilityEventRecorder.featureIsEnabledIfInitialized(FeatureFlag.sdkEventDeviceGesture.key)) + XCTAssertFalse(OSObservabilityEventRecorder.featureIsEnabledIfInitialized(FeatureFlag.sdkIdentityVerification.key)) + } + + // MARK: - The mirror enum + + func testTheMirrorEnumMatchesTheKmpCatalog() { + // A KMP event added without a Swift case, or the reverse, fails here instead of compiling silently. + XCTAssertEqual( + Set(OSObservabilityEvent.allCases.map { $0.eventName }), + Set(ObservabilityEvent.entries.map { $0.eventName }) + ) + XCTAssertEqual(OSObservabilityEvent.allCases.count, ObservabilityEvent.entries.count) + } + + // MARK: - The remote logger seam + + func testRemoteLoggerAttachesOnStartAndDetachesTheSameTelemetryOnShutdown() { + let recorder = EventRecorderSpy() + let logger = makeRemoteLogger(recorder: recorder) + + logger.start() + XCTAssertEqual(recorder.attached.count, 1) + XCTAssertTrue(recorder.detached.isEmpty) + + logger.shutdown() + XCTAssertEqual(recorder.detached.count, 1) + XCTAssertTrue((recorder.detached.first as AnyObject?) === (recorder.attached.first as AnyObject?)) + } + + func testRemoteLoggerDetachesOnlyOncePerShutdown() { + let recorder = EventRecorderSpy() + let logger = makeRemoteLogger(recorder: recorder) + logger.start() + + logger.shutdown() + logger.shutdown() + logger.start() + + XCTAssertEqual(recorder.detached.count, 1) + XCTAssertEqual(recorder.attached.count, 1) + } + + func testRemoteLoggerThatNeverStartedDoesNotDetachOnShutdown() { + // The controller shuts down a logger that lost the install race without starting it, + // while the winner is attached to the shared recorder. + let recorder = EventRecorderSpy() + let logger = makeRemoteLogger(recorder: recorder) + + logger.shutdown() + + XCTAssertTrue(recorder.attached.isEmpty) + XCTAssertTrue(recorder.detached.isEmpty) + } + + func testRemoteLoggerDoesNotAttachAtNone() { + // NONE is the remote off switch: log lines are filtered and the crash uploader refuses it, + // and Android never starts at NONE at all. A NONE logger still starts here (see + // testNoneLogLevelStartsLoggerButDoesNotSend), so the recorder has to be gated at attach + // time or events would ship while everything else is off. Shutdown must then leave the + // recorder alone, since it may be attached to a live logger from a later level change. + let recorder = EventRecorderSpy() + let logger = makeRemoteLogger(recorder: recorder, remoteLogLevel: "NONE") + + logger.start() + logger.shutdown() + + XCTAssertTrue(recorder.attached.isEmpty) + XCTAssertTrue(recorder.detached.isEmpty) + } +} diff --git a/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift b/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift index baf1733b9..b60a1ad77 100644 --- a/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift +++ b/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift @@ -109,6 +109,9 @@ final class OSRemoteLoggingController: NSObject, OSInternalLogSink { @objc static func reset() { shared.shutdown() + // An app-id change comes through here; events queued under the old app must not ship + // under the new one. + OSObservabilityEventRecorder.shared.reset() } private static func cache(configuration: OSRemoteLoggingConfiguration) { From 251eedf24892445baa2f678ef23f4afb3a019e97 Mon Sep 17 00:00:00 2001 From: Nan Date: Fri, 11 Sep 2026 10:25:26 -0700 Subject: [PATCH 07/25] feat: [SDK-5088] add device gesture that copies the push subscription ID to the pasteboard (#1730) (cherry picked from commit c23c6f99b035b0058f43690d28ff762ad08a6514) --- .../OneSignal.xcodeproj/project.pbxproj | 8 + .../Source/OSDeviceGestureDetector.swift | 298 ++++++++++++ .../OSDeviceGestureDetectorTests.swift | 434 ++++++++++++++++++ iOS_SDK/OneSignalSDK/Source/OneSignal.m | 4 + 4 files changed, 744 insertions(+) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 47704cc30..9c98cfc51 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -89,6 +89,8 @@ A5048F01A1B2C3D4E5F60008 /* OSFeatureFlagsRefreshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F60007 /* OSFeatureFlagsRefreshService.swift */; }; A5048F01A1B2C3D4E5F6000A /* OSFeatureFlagsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */; }; A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */; }; + A5062F01A1B2C3D4E5F60002 /* OSDeviceGestureDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */; }; + A5062F01A1B2C3D4E5F60004 /* OSDeviceGestureDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */; }; C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */; }; 32D3A6EA8AD44274B5CE378A /* FileLogStoreRetentionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */; }; B96A3B6BA8CC49EE4796D9BF /* OSRemoteLoggingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */; }; @@ -1876,6 +1878,8 @@ A5048F01A1B2C3D4E5F60007 /* OSFeatureFlagsRefreshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsRefreshService.swift; sourceTree = ""; }; A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsTests.swift; sourceTree = ""; }; A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsRefreshServiceTests.swift; sourceTree = ""; }; + A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDeviceGestureDetector.swift; sourceTree = ""; }; + A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDeviceGestureDetectorTests.swift; sourceTree = ""; }; 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLogCrashHandlerTests.swift; sourceTree = ""; }; 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogStoreRetentionTests.swift; sourceTree = ""; }; 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingController.swift; sourceTree = ""; }; @@ -2377,6 +2381,7 @@ 3C5C70072FCBAA5C00102E2C /* OneSignalConfig.swift */, 3C448BA12936B474002F96BC /* OSBackgroundTaskManager.swift */, 4710EA522B8FCFB200435356 /* OSDispatchQueue.swift */, + A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */, DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */, DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */, A843B922174496E99F2D00A8 /* Jwt */, @@ -2694,6 +2699,7 @@ 5156E0E0A1B2C3D4E5F60003 /* OSObservabilityEventRecorderTests.swift */, A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */, A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */, + A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */, 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */, 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */, 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */, @@ -4607,6 +4613,7 @@ A5048F01A1B2C3D4E5F60006 /* OSFeatureManager.swift in Sources */, A5048F01A1B2C3D4E5F6000D /* OSFeatureManagerImpl.swift in Sources */, A5048F01A1B2C3D4E5F60008 /* OSFeatureFlagsRefreshService.swift in Sources */, + A5062F01A1B2C3D4E5F60002 /* OSDeviceGestureDetector.swift in Sources */, 3C4F9E4428A4466C009F453A /* OSOperationRepo.swift in Sources */, 3C14E3B02FAE54C006ED053 /* IOSLogger.swift in Sources */, 698F58A488FCE503DFD5247F /* OSLogCrashHandler.swift in Sources */, @@ -4792,6 +4799,7 @@ 5156E0E0A1B2C3D4E5F60004 /* OSObservabilityEventRecorderTests.swift in Sources */, A5048F01A1B2C3D4E5F6000A /* OSFeatureFlagsTests.swift in Sources */, A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */, + A5062F01A1B2C3D4E5F60004 /* OSDeviceGestureDetectorTests.swift in Sources */, C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */, 32D3A6EA8AD44274B5CE378A /* FileLogStoreRetentionTests.swift in Sources */, 3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift new file mode 100644 index 000000000..606aaac3d --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift @@ -0,0 +1,298 @@ +/* + 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 Foundation +import OneSignalCore +import UIKit + +/// Detects the test-device gesture: `requiredCycles` background/foreground cycles within +/// `windowSeconds`, then copies the push subscription ID to the general pasteboard, prefixed +/// `os:` (see `clipText`), so the person can paste it into the dashboard. Without a subscription +/// it copies `noSubscriptionClipText` instead, so someone following the docs can tell the gesture +/// worked. +/// +/// A cycle is a `didEnterBackground`/`didBecomeActive` pair whose background phase lasts at +/// least `minBackgroundDwellSeconds`. Pairing keeps `willResignActive`-only blips (Control +/// Center, Face ID) from counting; the floor matches Android, where rotation emits a +/// synthetic sub-millisecond pair. The window is the only rate rule; six cycles fit inside it +/// at round trips of five seconds or faster. +/// +/// The `killSwitchKey` catalog flag turns the gesture off. Absent means enabled, so a device +/// that has never fetched flags still has it. +/// +/// Every recognised gesture also records `OSObservabilityEvent.deviceGesture`, with its outcome +/// and the copied ID, so the gesture's usage can be measured. +@objc(OSDeviceGestureDetector) +public final class OSDeviceGestureDetector: NSObject { + static let requiredCycles = 6 + static let windowSeconds: TimeInterval = 30 + + /// Shortest background phase a human can produce; anything faster is synthetic. + static let minBackgroundDwellSeconds: TimeInterval = 0.25 + + static let killSwitchKey = "sdk_device_gesture_disabled" + + /// The copied ID expires after five minutes. Whatever it replaced is not restored. + static let pasteboardExpirySeconds: TimeInterval = 300 + + private static let lock = NSLock() + private static var _shared: OSDeviceGestureDetector? + + static var shared: OSDeviceGestureDetector { + lock.withLock { + if let existing = _shared { + return existing + } + let created = OSDeviceGestureDetector() + _shared = created + return created + } + } + + private let notificationCenter: NotificationCenter + private let mainQueue: OSDispatchQueue + /// Monotonic and keeps counting through sleep, so neither a wall-clock jump nor a nap can + /// stretch or shrink the window; `systemUptime` would stop at each lock and stitch visits + /// hours apart into one window. + private let nowProvider: () -> TimeInterval + private let isDisabledRemotelyProvider: () -> Bool + private let subscriptionIdProvider: () -> String? + private let shouldAwaitProvider: () -> Bool + private let pasteboardWriter: (String) -> Void + private let eventRecorder: OSObservabilityEventRecorderProtocol + + private let stateLock = NSLock() + private var started = false + /// Set by `tearDown` and never cleared, so work already queued for an instance that + /// `shared` has dropped cannot write to the pasteboard or re-register observers. + private var invalidated = false + private var notificationTokens: [NSObjectProtocol] = [] + private var lastBackgroundedAt: TimeInterval? + private var cycleCompletions: [TimeInterval] = [] + + init( + notificationCenter: NotificationCenter = .default, + mainQueue: OSDispatchQueue = DispatchQueue.main, + nowProvider: @escaping () -> TimeInterval = { + TimeInterval(clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW)) / TimeInterval(NSEC_PER_SEC) + }, + isDisabledRemotelyProvider: @escaping () -> Bool = { + OSFeatureManager.shared.isEnabled(featureKey: OSDeviceGestureDetector.killSwitchKey) + }, + subscriptionIdProvider: @escaping () -> String? = { OneSignalIdentifiers.subscriptionId }, + shouldAwaitProvider: @escaping () -> Bool = { + OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) + }, + pasteboardWriter: @escaping (String) -> Void = OSDeviceGestureDetector.writeToGeneralPasteboard, + eventRecorder: OSObservabilityEventRecorderProtocol = OSObservabilityEventRecorder.shared + ) { + self.notificationCenter = notificationCenter + self.mainQueue = mainQueue + self.nowProvider = nowProvider + self.isDisabledRemotelyProvider = isDisabledRemotelyProvider + self.subscriptionIdProvider = subscriptionIdProvider + self.shouldAwaitProvider = shouldAwaitProvider + self.pasteboardWriter = pasteboardWriter + self.eventRecorder = eventRecorder + super.init() + } + + /// Idempotent: registers the lifecycle observers once and keeps counting from there. + @objc public static func start() { + shared.registerLifecycleObserversIfNeeded() + } + + @objc public static func reset() { + lock.withLock { + _shared?.tearDown() + _shared = nil + } + } + + /// App-level rather than per-scene: UIKit posts `didEnterBackgroundNotification` only + /// once the last scene backgrounds, exactly the whole-app signal a cycle counter needs. + /// Per-scene events would over-count on multi-window iPad. + func registerLifecycleObserversIfNeeded() { + let shouldSkip = stateLock.withLock { () -> Bool in + if started || invalidated { + return true + } + started = true + return false + } + guard !shouldSkip else { + return + } + + observe(UIApplication.didEnterBackgroundNotification) { [weak self] in + self?.onUnfocused() + } + observe(UIApplication.didBecomeActiveNotification) { [weak self] in + self?.onFocus() + } + } + + private func observe(_ name: Notification.Name, handler: @escaping () -> Void) { + let token = notificationCenter.addObserver(forName: name, object: nil, queue: nil) { _ in + handler() + } + // A reset can land between registering above and recording below. Nothing else + // holds this token by then, so it has to be torn down here or it outlives the + // service with no way to reach it. + let recorded = stateLock.withLock { () -> Bool in + guard !invalidated else { + return false + } + notificationTokens.append(token) + return true + } + if !recorded { + notificationCenter.removeObserver(token) + } + } + + func tearDown() { + let tokens: [NSObjectProtocol] = stateLock.withLock { + invalidated = true + let current = notificationTokens + notificationTokens.removeAll() + started = false + lastBackgroundedAt = nil + cycleCompletions.removeAll() + return current + } + tokens.forEach(notificationCenter.removeObserver) + } + + func onUnfocused() { + let timestamp = nowProvider() + stateLock.withLock { + lastBackgroundedAt = timestamp + } + } + + func onFocus() { + let timestamp = nowProvider() + // Logged after the lock: OneSignalLog runs app listeners synchronously, and one that + // re-enters the SDK would deadlock on it. + var progress: String? + let completedGesture: Bool = stateLock.withLock { + let backgroundedAt = lastBackgroundedAt + lastBackgroundedAt = nil + guard let backgroundedAt else { + // Cold launch, or a repeated activation with no background in between. + return false + } + let dwell = timestamp - backgroundedAt + if dwell < Self.minBackgroundDwellSeconds { + // Faster than any human app switch; Android rotation emits pairs like this. + progress = "ignored a \(String(format: "%.3f", dwell))s background blip (rotation filter)" + return false + } + cycleCompletions.append(timestamp) + cycleCompletions.removeAll { timestamp - $0 > Self.windowSeconds } + progress = "cycle \(cycleCompletions.count)/\(Self.requiredCycles) within the window " + + "(background \(String(format: "%.2f", dwell))s)" + if cycleCompletions.count >= Self.requiredCycles { + cycleCompletions.removeAll() + return true + } + return false + } + if let progress { + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSDeviceGestureDetector: \(progress)") + } + if completedGesture { + copySubscriptionIdToPasteboard() + } + } + + private func copySubscriptionIdToPasteboard() { + mainQueue.async { [weak self] in + guard let self, !self.stateLock.withLock({ self.invalidated }) else { + return + } + // Not recorded either: without an app id or consent, nothing about the device may ship. + guard !self.shouldAwaitProvider() else { + OneSignalLog.onesignalLog( + .LL_DEBUG, + message: "OSDeviceGestureDetector: gesture detected but the SDK is not ready (appId, consent, or storage)" + ) + return + } + guard !self.isDisabledRemotelyProvider() else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSDeviceGestureDetector: gesture detected but disabled remotely") + self.recordGesture(.disabled) + return + } + guard let subscriptionId = self.subscriptionIdProvider(), !subscriptionId.isEmpty else { + self.pasteboardWriter(Self.noSubscriptionClipText) + OneSignalLog.onesignalLog(.LL_INFO, message: "OSDeviceGestureDetector: pasteboard set, gesture result no_id") + self.recordGesture(.noId) + return + } + self.pasteboardWriter(Self.clipText(subscriptionId: subscriptionId)) + OneSignalLog.onesignalLog(.LL_INFO, message: "OSDeviceGestureDetector: pasteboard set, gesture result copied") + self.recordGesture(.copied, copiedId: subscriptionId) + } + } + + /// Wire values of `gesture.result`, which backend queries match on. + private enum GestureResult: String { + case copied + case noId = "no_id" + case disabled + } + + /// `copied` and `noId` are recorded after the pasteboard write, so neither claims a change that + /// did not happen. `disabled` is recorded at the decision, since nothing is written. + private func recordGesture(_ result: GestureResult, copiedId: String? = nil) { + var attributes = ["gesture.result": result.rawValue] + if let copiedId { + attributes["gesture.push_subscription_id"] = copiedId + } + eventRecorder.record(event: .deviceGesture, attributes: attributes) + } + + private static let clipPrefix = "os: " + static let noSubscriptionClipText = clipPrefix + "no subscription ID yet" + + /// The `os:` prefix marks the value as a OneSignal ID, for the dashboard's paste target and for + /// anyone who copied it by accident. + static func clipText(subscriptionId: String) -> String { + clipPrefix + subscriptionId + } + + /// No `localOnly` option: Universal Clipboard carrying the ID to the Mac running the + /// dashboard is the point, not a leak. + private static func writeToGeneralPasteboard(_ value: String) { + UIPasteboard.general.setItems( + [["public.utf8-plain-text": value]], + options: [.expirationDate: Date().addingTimeInterval(pasteboardExpirySeconds)] + ) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift new file mode 100644 index 000000000..61d39ba23 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift @@ -0,0 +1,434 @@ +/* + 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 OneSignalKMP +import UIKit +import XCTest +@_spi(OneSignalInternal) @testable import OneSignalOSCore + +private let subscriptionId = "aaaabbbb-cccc-dddd-eeee-ffff00001111" +private let expectedWrite = OSDeviceGestureDetector.clipText(subscriptionId: subscriptionId) + +/// Runs queued work inline so pasteboard writes are observable synchronously. +private final class InlineQueue: OSDispatchQueue { + func async(execute work: @escaping @convention(block) () -> Void) { + work() + } + + func asyncAfterTime(deadline: DispatchTime, execute work: @escaping @Sendable @convention(block) () -> Void) { + work() + } +} + +/// Captures queued work so a test can tear the detector down before the write runs. +private final class DeferringQueue: OSDispatchQueue { + private(set) var pending: [() -> Void] = [] + + func async(execute work: @escaping @convention(block) () -> Void) { + pending.append(work) + } + + func asyncAfterTime(deadline: DispatchTime, execute work: @escaping @Sendable @convention(block) () -> Void) { + pending.append(work) + } + + func drain() { + let work = pending + pending.removeAll() + work.forEach { $0() } + } +} + +/// Counts live observer registrations so tests can assert teardown. +private final class ObserverTrackingCenter: NotificationCenter { + private(set) var liveObservers = 0 + + override func addObserver( + forName name: NSNotification.Name?, + object obj: Any?, + queue: OperationQueue?, + using block: @escaping (Notification) -> Void + ) -> NSObjectProtocol { + liveObservers += 1 + return super.addObserver(forName: name, object: obj, queue: queue, using: block) + } + + override func removeObserver(_ observer: Any) { + liveObservers -= 1 + super.removeObserver(observer) + } +} + +/// Captures what the detector records so tests can assert the event and its attributes. +private final class EventRecorderSpy: OSObservabilityEventRecorderProtocol { + private(set) var events: [OSObservabilityEvent] = [] + private(set) var attributes: [[String: String]] = [] + + func record(event: OSObservabilityEvent, attributes: [String: String]) { + events.append(event) + self.attributes.append(attributes) + } +} + +/// Owns a detector driven through an injected notification center, a fake monotonic clock, +/// a writer that records instead of touching the real pasteboard, and a recorder spy. +private final class Harness { + let center: NotificationCenter + var now: TimeInterval = 1_000 + var killSwitchOn = false + var currentSubscriptionId: String? = subscriptionId + var shouldAwait = false + private(set) var writes: [String] = [] + let recorder = EventRecorderSpy() + private(set) var detector: OSDeviceGestureDetector! + + init(center: NotificationCenter = NotificationCenter(), queue: OSDispatchQueue = InlineQueue()) { + self.center = center + detector = OSDeviceGestureDetector( + notificationCenter: center, + mainQueue: queue, + nowProvider: { [unowned self] in self.now }, + isDisabledRemotelyProvider: { [unowned self] in self.killSwitchOn }, + subscriptionIdProvider: { [unowned self] in self.currentSubscriptionId }, + shouldAwaitProvider: { [unowned self] in self.shouldAwait }, + pasteboardWriter: { [unowned self] in self.writes.append($0) }, + eventRecorder: recorder + ) + detector.registerLifecycleObserversIfNeeded() + } + + /// One foreground-dwell + background-dwell cycle, 2s in total by default, so six of + /// them sit well inside the 30s window. + func cycle(backgroundDwell: TimeInterval = 1.0, foregroundDwell: TimeInterval = 1.0) { + now += foregroundDwell + center.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + now += backgroundDwell + center.post(name: UIApplication.didBecomeActiveNotification, object: nil) + } +} + +final class OSDeviceGestureDetectorTests: XCTestCase { + func testSixRapidCyclesWriteThePrefixedSubscriptionId() { + let harness = Harness() + + for _ in 1...6 { + harness.cycle() + } + + XCTAssertEqual(harness.writes, [expectedWrite]) + XCTAssertEqual(expectedWrite, "os: \(subscriptionId)") + } + + func testFiveCyclesWriteNothing() { + let harness = Harness() + + for _ in 1...5 { + harness.cycle() + } + + XCTAssertEqual(harness.writes, []) + } + + func testCyclesSlowerThanTheWindowNeverAccumulateSix() { + let harness = Harness() + + // 7 seconds per round trip caps the window at five cycles, so a user who + // backgrounds the app all day at a normal pace can never fire this. + for _ in 1...8 { + harness.cycle(backgroundDwell: 3.0, foregroundDwell: 4.0) + } + + XCTAssertEqual(harness.writes, []) + } + + func testPauseMidGestureDoesNotResetProgress() { + let harness = Harness() + + for _ in 1...3 { + harness.cycle() + } + // A pause costs time, not accumulated cycles; all six still land inside the window. + harness.cycle(foregroundDwell: 10.0) + for _ in 1...2 { + harness.cycle() + } + + XCTAssertEqual(harness.writes, [expectedWrite]) + } + + func testSubHumanBackgroundBlipDoesNotCountAsACycle() { + let harness = Harness() + + for _ in 1...5 { + harness.cycle() + } + // Faster than any human app switch: it does not count, so one more real cycle + // completes the gesture. + harness.cycle(backgroundDwell: 0.001) + XCTAssertEqual(harness.writes, []) + + harness.cycle() + XCTAssertEqual(harness.writes, [expectedWrite]) + } + + func testRepeatedActivationsWithoutABackgroundDoNotCount() { + let harness = Harness() + + for _ in 1...5 { + harness.cycle() + } + // UIKit can post didBecomeActive repeatedly with no background event in between. + for _ in 1...6 { + harness.now += 0.5 + harness.center.post(name: UIApplication.didBecomeActiveNotification, object: nil) + } + XCTAssertEqual(harness.writes, []) + + harness.cycle() + XCTAssertEqual(harness.writes, [expectedWrite]) + } + + func testColdStartActivationDoesNotCount() { + let harness = Harness() + + harness.center.post(name: UIApplication.didBecomeActiveNotification, object: nil) + for _ in 1...5 { + harness.cycle() + } + XCTAssertEqual(harness.writes, []) + + harness.cycle() + XCTAssertEqual(harness.writes, [expectedWrite]) + } + + func testDetectorReArmsAfterFiring() { + let harness = Harness() + + for _ in 1...12 { + harness.cycle() + } + + XCTAssertEqual(harness.writes, [expectedWrite, expectedWrite]) + } + + func testKillSwitchSuppressesTheWrite() { + let harness = Harness() + harness.killSwitchOn = true + + for _ in 1...6 { + harness.cycle() + } + + XCTAssertEqual(harness.writes, []) + } + + func testKillSwitchKeyMatchesTheCatalog() { + // The feature manager only answers for catalog keys, so a drift here would silently + // turn the switch into a no-op. + XCTAssertEqual(OSDeviceGestureDetector.killSwitchKey, FeatureFlag.sdkDeviceGestureDisabled.key) + } + + func testNotReadySdkSuppressesTheWrite() { + let harness = Harness() + harness.shouldAwait = true + + for _ in 1...6 { + harness.cycle() + } + + XCTAssertEqual(harness.writes, []) + } + + func testMissingSubscriptionIdWritesThePlaceholder() { + // Someone following the docs gets a visible result that says why there is no ID. + let harness = Harness() + harness.currentSubscriptionId = nil + + for _ in 1...6 { + harness.cycle() + } + + XCTAssertEqual(harness.writes, ["os: no subscription ID yet"]) + } + + func testEmptySubscriptionIdWritesThePlaceholder() { + let harness = Harness() + harness.currentSubscriptionId = "" + + for _ in 1...6 { + harness.cycle() + } + + XCTAssertEqual(harness.writes, ["os: no subscription ID yet"]) + } + + func testRegistrationIsIdempotent() { + let center = ObserverTrackingCenter() + let harness = Harness(center: center) + + harness.detector.registerLifecycleObserversIfNeeded() + + XCTAssertEqual(center.liveObservers, 2) + } + + func testTearDownLeavesNoLifecycleObserversBehind() { + let center = ObserverTrackingCenter() + let harness = Harness(center: center) + XCTAssertEqual(center.liveObservers, 2) + + harness.detector.tearDown() + XCTAssertEqual(center.liveObservers, 0) + } + + func testTearDownDropsAWriteAlreadyQueued() { + // A gesture can complete just before a reset lands. The write is on the main queue by + // then, so the block itself has to notice the instance is gone. + let queue = DeferringQueue() + let harness = Harness(queue: queue) + + for _ in 1...6 { + harness.cycle() + } + XCTAssertEqual(queue.pending.count, 1) + XCTAssertEqual(harness.writes, []) + + harness.detector.tearDown() + queue.drain() + + XCTAssertEqual(harness.writes, []) + XCTAssertEqual(harness.recorder.events, []) + } + + func testTwoHundredFortyNineMillisecondsIsABlipAndTwoHundredFiftyIsACycle() { + // The floor is inclusive: exactly the minimum counts. Six blips leave the window empty, + // so the six real cycles right after still need all six. + let harness = Harness() + + for _ in 1...6 { + harness.cycle(backgroundDwell: 0.249) + } + XCTAssertEqual(harness.writes, []) + + for _ in 1...6 { + harness.cycle(backgroundDwell: 0.25) + } + XCTAssertEqual(harness.writes, [expectedWrite]) + } + + func testWindowIsInclusiveAtExactlyThirtySeconds() { + // Five 2s cycles complete at +2s..+10s. A sixth completing exactly 30s after the first + // still counts; one millisecond later the first has aged out and only five remain. + let exact = Harness() + for _ in 1...5 { + exact.cycle() + } + exact.cycle(backgroundDwell: 1.0, foregroundDwell: 21.0) + XCTAssertEqual(exact.writes, [expectedWrite]) + + let late = Harness() + for _ in 1...5 { + late.cycle() + } + late.cycle(backgroundDwell: 1.0, foregroundDwell: 21.001) + XCTAssertEqual(late.writes, []) + } + + // MARK: - Observability event + + // Every recognised gesture records deviceGesture with its outcome, whether or not an ID was + // copied, so the backend can answer how often the gesture happens and how often it pays off. + + func testCompletedGestureRecordsACopiedEventCarryingTheSubscriptionId() { + let harness = Harness() + + // Progress is silent: the event fires on recognition, not per cycle. + for _ in 1...5 { + harness.cycle() + } + XCTAssertEqual(harness.recorder.events, []) + + harness.cycle() + + XCTAssertEqual(harness.recorder.events, [.deviceGesture]) + XCTAssertEqual(harness.recorder.attributes, [[ + "gesture.result": "copied", + "gesture.push_subscription_id": subscriptionId + ]]) + } + + func testKillSwitchRecordsADisabledResultWithoutAnId() { + let harness = Harness() + harness.killSwitchOn = true + + for _ in 1...6 { + harness.cycle() + } + + XCTAssertEqual(harness.recorder.events, [.deviceGesture]) + XCTAssertEqual(harness.recorder.attributes, [["gesture.result": "disabled"]]) + } + + func testMissingOrEmptySubscriptionIdRecordsANoIdResult() { + // Both shapes mean the same thing to the backend: the gesture ran before the device had + // anything worth pasting. + for missingId in [nil, ""] { + let harness = Harness() + harness.currentSubscriptionId = missingId + + for _ in 1...6 { + harness.cycle() + } + + XCTAssertEqual(harness.recorder.events, [.deviceGesture]) + XCTAssertEqual(harness.recorder.attributes, [["gesture.result": "no_id"]]) + } + } + + func testNotReadySdkRecordsNothing() { + // The event would ship to the backend, and nothing may leave the device before the app id + // and consent are in place. + let harness = Harness() + harness.shouldAwait = true + + for _ in 1...6 { + harness.cycle() + } + + XCTAssertEqual(harness.recorder.events, []) + } + + func testEachRecognitionRecordsItsOwnEvent() { + let harness = Harness() + + for _ in 1...12 { + harness.cycle() + } + + XCTAssertEqual(harness.recorder.events, [.deviceGesture, .deviceGesture]) + XCTAssertEqual(harness.recorder.attributes.map { $0["gesture.result"] }, ["copied", "copied"]) + } +} diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 96e1bec0f..c4400d757 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -151,6 +151,7 @@ + (OneSignalReceiveReceiptsController*)receiveReceiptsController { + (void)clearStatics { [OSRemoteLoggingController reset]; [OSFeatureFlagsRefreshService reset]; + [OSDeviceGestureDetector reset]; [OSFeatureManager reset]; [OneSignalIdentifiers setCurrentAppId:nil]; launchOptions = false; @@ -609,6 +610,7 @@ + (void)setupProtectedDataObserverOnce { [OneSignal startLiveActivitiesManager]; [OneSignal startInAppMessages]; StartFeatureFlagsRefresh(); + [OSDeviceGestureDetector start]; [OneSignal startNewSession:YES]; }; @@ -710,6 +712,7 @@ + (void)init { [self startLiveActivitiesManager]; [self startInAppMessages]; StartFeatureFlagsRefresh(); + [OSDeviceGestureDetector start]; } [self startNewSession:YES]; @@ -739,6 +742,7 @@ + (void)handleAppIdChange:(NSString*)appId { _downloadParametersAttempts = 0; [OSRemoteLoggingController reset]; [OSFeatureFlagsRefreshService reset]; + [OSDeviceGestureDetector reset]; // Flags are app-scoped but stored unscoped, and APP_STARTUP flags never unlatch // within a process, so both the cache and the latch have to go. [OSFeatureManager resetAndClearCachedFlags]; From 6b202a5326b2920167bad40569e0e0aca22ec899 Mon Sep 17 00:00:00 2001 From: Nan Date: Fri, 11 Sep 2026 13:47:51 -0700 Subject: [PATCH 08/25] feat: [SDK-5193] let an app opt out of subscription ID copy in Info.plist (#1739) (cherry picked from commit 7e5b53855a5400b16ff6138f061626e2ae50da50) --- .../Source/OSDeviceGestureDetector.swift | 31 +++++++- .../OSDeviceGestureDetectorTests.swift | 78 ++++++++++++++++++- 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift index 606aaac3d..68bf6c6a4 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift @@ -41,8 +41,10 @@ import UIKit /// synthetic sub-millisecond pair. The window is the only rate rule; six cycles fit inside it /// at round trips of five seconds or faster. /// -/// The `killSwitchKey` catalog flag turns the gesture off. Absent means enabled, so a device -/// that has never fetched flags still has it. +/// The `remoteKillSwitchKey` catalog flag turns the gesture off. Absent means enabled, so a device +/// that has never fetched flags still has it. An app can also opt out for good with the +/// Info.plist key `disableInfoPlistKey`; then the detector never starts, so nothing is counted +/// or recorded. /// /// Every recognised gesture also records `OSObservabilityEvent.deviceGesture`, with its outcome /// and the copied ID, so the gesture's usage can be measured. @@ -54,7 +56,8 @@ public final class OSDeviceGestureDetector: NSObject { /// Shortest background phase a human can produce; anything faster is synthetic. static let minBackgroundDwellSeconds: TimeInterval = 0.25 - static let killSwitchKey = "sdk_device_gesture_disabled" + static let remoteKillSwitchKey = "sdk_device_gesture_disabled" + static let disableInfoPlistKey = "OneSignal_disable_subscription_id_copy" /// The copied ID expires after five minutes. Whatever it replaced is not restored. static let pasteboardExpirySeconds: TimeInterval = 300 @@ -80,6 +83,7 @@ public final class OSDeviceGestureDetector: NSObject { /// hours apart into one window. private let nowProvider: () -> TimeInterval private let isDisabledRemotelyProvider: () -> Bool + private let isDisabledByAppProvider: () -> Bool private let subscriptionIdProvider: () -> String? private let shouldAwaitProvider: () -> Bool private let pasteboardWriter: (String) -> Void @@ -101,7 +105,12 @@ public final class OSDeviceGestureDetector: NSObject { TimeInterval(clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW)) / TimeInterval(NSEC_PER_SEC) }, isDisabledRemotelyProvider: @escaping () -> Bool = { - OSFeatureManager.shared.isEnabled(featureKey: OSDeviceGestureDetector.killSwitchKey) + OSFeatureManager.shared.isEnabled(featureKey: OSDeviceGestureDetector.remoteKillSwitchKey) + }, + isDisabledByAppProvider: @escaping () -> Bool = { + OSDeviceGestureDetector.isDisabledByApp( + infoPlistValue: Bundle.main.object(forInfoDictionaryKey: OSDeviceGestureDetector.disableInfoPlistKey) + ) }, subscriptionIdProvider: @escaping () -> String? = { OneSignalIdentifiers.subscriptionId }, shouldAwaitProvider: @escaping () -> Bool = { @@ -114,6 +123,7 @@ public final class OSDeviceGestureDetector: NSObject { self.mainQueue = mainQueue self.nowProvider = nowProvider self.isDisabledRemotelyProvider = isDisabledRemotelyProvider + self.isDisabledByAppProvider = isDisabledByAppProvider self.subscriptionIdProvider = subscriptionIdProvider self.shouldAwaitProvider = shouldAwaitProvider self.pasteboardWriter = pasteboardWriter @@ -137,6 +147,8 @@ public final class OSDeviceGestureDetector: NSObject { /// once the last scene backgrounds, exactly the whole-app signal a cycle counter needs. /// Per-scene events would over-count on multi-window iPad. func registerLifecycleObserversIfNeeded() { + // Read before the lock; it touches the app bundle, not the SDK. + let optedOut = isDisabledByAppProvider() let shouldSkip = stateLock.withLock { () -> Bool in if started || invalidated { return true @@ -147,6 +159,11 @@ public final class OSDeviceGestureDetector: NSObject { guard !shouldSkip else { return } + // The app owner's opt-out. Logged so a developer can confirm the key took. + guard !optedOut else { + OneSignalLog.onesignalLog(.LL_INFO, message: "OSDeviceGestureDetector: disabled by \(Self.disableInfoPlistKey), not starting") + return + } observe(UIApplication.didEnterBackgroundNotification) { [weak self] in self?.onUnfocused() @@ -287,6 +304,12 @@ public final class OSDeviceGestureDetector: NSObject { clipPrefix + subscriptionId } + /// Dynamic `boolValue`, as Objective-C sends to `id`. A Boolean, Number or String such as + /// `YES` opts out. Anything else, including a missing key, does not. + static func isDisabledByApp(infoPlistValue value: Any?) -> Bool { + (value as AnyObject?)?.boolValue ?? false + } + /// No `localOnly` option: Universal Clipboard carrying the ID to the Mac running the /// dashboard is the point, not a leak. private static func writeToGeneralPasteboard(_ value: String) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift index 61d39ba23..e06356591 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift @@ -106,13 +106,14 @@ private final class Harness { let recorder = EventRecorderSpy() private(set) var detector: OSDeviceGestureDetector! - init(center: NotificationCenter = NotificationCenter(), queue: OSDispatchQueue = InlineQueue()) { + init(center: NotificationCenter = NotificationCenter(), queue: OSDispatchQueue = InlineQueue(), disabledByApp: Bool = false) { self.center = center detector = OSDeviceGestureDetector( notificationCenter: center, mainQueue: queue, nowProvider: { [unowned self] in self.now }, isDisabledRemotelyProvider: { [unowned self] in self.killSwitchOn }, + isDisabledByAppProvider: { disabledByApp }, subscriptionIdProvider: { [unowned self] in self.currentSubscriptionId }, shouldAwaitProvider: { [unowned self] in self.shouldAwait }, pasteboardWriter: { [unowned self] in self.writes.append($0) }, @@ -249,7 +250,7 @@ final class OSDeviceGestureDetectorTests: XCTestCase { func testKillSwitchKeyMatchesTheCatalog() { // The feature manager only answers for catalog keys, so a drift here would silently // turn the switch into a no-op. - XCTAssertEqual(OSDeviceGestureDetector.killSwitchKey, FeatureFlag.sdkDeviceGestureDisabled.key) + XCTAssertEqual(OSDeviceGestureDetector.remoteKillSwitchKey, FeatureFlag.sdkDeviceGestureDisabled.key) } func testNotReadySdkSuppressesTheWrite() { @@ -304,6 +305,23 @@ final class OSDeviceGestureDetectorTests: XCTestCase { XCTAssertEqual(center.liveObservers, 0) } + func testInfoPlistOptOutKeepsTheDetectorFromStarting() { + // The app owner said no: no observers are registered, so nothing is counted, written + // or recorded, and a second start does not register either because the latch holds. + let center = ObserverTrackingCenter() + let harness = Harness(center: center, disabledByApp: true) + XCTAssertEqual(center.liveObservers, 0) + + for _ in 1...6 { + harness.cycle() + } + XCTAssertEqual(harness.writes, []) + XCTAssertEqual(harness.recorder.events, []) + + harness.detector.registerLifecycleObserversIfNeeded() + XCTAssertEqual(center.liveObservers, 0) + } + func testTearDownDropsAWriteAlreadyQueued() { // A gesture can complete just before a reset lands. The write is on the main queue by // then, so the block itself has to notice the instance is gone. @@ -432,3 +450,59 @@ final class OSDeviceGestureDetectorTests: XCTestCase { XCTAssertEqual(harness.recorder.attributes.map { $0["gesture.result"] }, ["copied", "copied"]) } } + +/// Parsing of the Info.plist opt-out value. A separate class so the one above stays under +/// SwiftLint's body length limit. +final class OSDeviceGestureDetectorInfoPlistTests: XCTestCase { + func testReadsTheValueTypesTheOlderSwitchesAccept() throws { + // Each case is the literal Info.plist text. A String such as YES has to count too. + let optedOut = [ + "", + "1", + "1.0", + "YES", + "yes", + "true", + "1" + ] + for xml in optedOut { + XCTAssertTrue(OSDeviceGestureDetector.isDisabledByApp(infoPlistValue: try plistValue(xml)), xml) + } + + let stillOn = [ + "", + "0", + "NO", + "false", + "0", + "" + ] + for xml in stillOn { + XCTAssertFalse(OSDeviceGestureDetector.isDisabledByApp(infoPlistValue: try plistValue(xml)), xml) + } + } + + func testIgnoresAMissingOrMistypedValue() throws { + // A missing or mistyped value leaves the gesture on and must not crash. + XCTAssertFalse(OSDeviceGestureDetector.isDisabledByApp(infoPlistValue: nil)) + let mistyped = [ + "YES", + "enabled", + "2026-09-11T00:00:00Z", + "WUVT" + ] + for xml in mistyped { + XCTAssertFalse(OSDeviceGestureDetector.isDisabledByApp(infoPlistValue: try plistValue(xml)), xml) + } + } + + /// The value of one Info.plist key written as `xml`, with the type `Bundle.main` would return. + private func plistValue(_ xml: String) throws -> Any { + let plist = """ + + k\(xml) + """ + let dict = try XCTUnwrap(PropertyListSerialization.propertyList(from: Data(plist.utf8), format: nil) as? [String: Any]) + return try XCTUnwrap(dict["k"], xml) + } +} From 57e62e594615444fbaa636e9c46eed8a81f26e5b Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 17 Sep 2026 19:36:28 -0700 Subject: [PATCH 09/25] feat: [JWT-8] demo app Identity Verification UI (#1708) Co-authored-by: Cursor --- examples/demo/App.xcodeproj/project.pbxproj | 4 + examples/demo/App/Models/AppModels.swift | 9 +- .../demo/App/Services/OneSignalService.swift | 62 +++++- .../App/Services/PreferencesService.swift | 28 ++- .../demo/App/Services/UserFetchService.swift | 53 ++++- .../App/ViewModels/OneSignalViewModel.swift | 195 +++++++++++++----- .../App/Views/Components/AddItemDialog.swift | 13 ++ .../Views/Components/LoginUserDialog.swift | 70 +++++++ .../demo/App/Views/Sections/UserSection.swift | 75 ++++++- 9 files changed, 435 insertions(+), 74 deletions(-) create mode 100644 examples/demo/App/Views/Components/LoginUserDialog.swift diff --git a/examples/demo/App.xcodeproj/project.pbxproj b/examples/demo/App.xcodeproj/project.pbxproj index ae17cd23e..04b561eb5 100644 --- a/examples/demo/App.xcodeproj/project.pbxproj +++ b/examples/demo/App.xcodeproj/project.pbxproj @@ -42,6 +42,7 @@ 5B959D44AB09CB821C00AFBF /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35F726E64F9B6817F917227C /* ContentView.swift */; }; 5F4B7EC8437D1A8D80DF7674 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20B46F63592FC67B655BEB8 /* NotificationService.swift */; }; 638B81D9DA5FD8636BB038B0 /* OneSignalUser.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EF49509A218369322ECFA3B9 /* OneSignalUser.framework */; }; + 673E0146B4B27EB81FCF50DA /* LoginUserDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DD26AED9EA84B0308C1BFE8 /* LoginUserDialog.swift */; }; 674995A7A55C13341317E19B /* OSDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = D261D46C404E325CBA87A9E0 /* OSDialog.swift */; }; 68BC99D15FDCB26EB35EBB07 /* OneSignalLocation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FE9834773C437CC373607693 /* OneSignalLocation.framework */; }; 6E3E040FD8A750248E70E46F /* AppModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EA9D80191548D49F09D30B3 /* AppModels.swift */; }; @@ -324,6 +325,7 @@ 38138523A8A81A60A77800CA /* NotificationSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSender.swift; sourceTree = ""; }; 3A654457BF0A55B54220E669 /* RemoveMultiDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoveMultiDialog.swift; sourceTree = ""; }; 3B81D7E1A7EB9BB4466C768F /* SendPushSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendPushSection.swift; sourceTree = ""; }; + 3DD26AED9EA84B0308C1BFE8 /* LoginUserDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginUserDialog.swift; sourceTree = ""; }; 3FD8258E807E6672642A32E6 /* UserFetchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserFetchService.swift; sourceTree = ""; }; 4072202943CA64D2CBC38CB5 /* MultiPairInputDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiPairInputDialog.swift; sourceTree = ""; }; 4078B5F096680AFA83D1CB85 /* OutcomesSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutcomesSection.swift; sourceTree = ""; }; @@ -512,6 +514,7 @@ 2D959B1636916DAEE5FE6278 /* CustomNotificationDialog.swift */, 5984B93007C6B85AFE09045A /* KeyValueRow.swift */, 280B23B41935EAB89C8C6FCB /* ListWidgets.swift */, + 3DD26AED9EA84B0308C1BFE8 /* LoginUserDialog.swift */, 4072202943CA64D2CBC38CB5 /* MultiPairInputDialog.swift */, D261D46C404E325CBA87A9E0 /* OSDialog.swift */, D54B9DAAEDBE67B73893C522 /* OutcomeDialog.swift */, @@ -828,6 +831,7 @@ 7D2BA9022E77B00205453467 /* LiveActivityController.swift in Sources */, DBDA727D6317A3CCC73A1699 /* LiveActivitySection.swift in Sources */, B3AE701075398C6A369DBBE0 /* LocationSection.swift in Sources */, + 673E0146B4B27EB81FCF50DA /* LoginUserDialog.swift in Sources */, E3725231A3FD5F5A88BAA758 /* MultiPairInputDialog.swift in Sources */, 4C18E3D284BB28BD846162F3 /* NotificationSender.swift in Sources */, 674995A7A55C13341317E19B /* OSDialog.swift in Sources */, diff --git a/examples/demo/App/Models/AppModels.swift b/examples/demo/App/Models/AppModels.swift index 70ccd615a..d98591df0 100644 --- a/examples/demo/App/Models/AppModels.swift +++ b/examples/demo/App/Models/AppModels.swift @@ -80,6 +80,7 @@ enum AddItemType { case tag case trigger case externalUserId + case updateUserJwt var title: String { switch self { @@ -89,12 +90,13 @@ enum AddItemType { case .tag: return "Add Tag" case .trigger: return "Add Trigger" case .externalUserId: return "Login User" + case .updateUserJwt: return "Update User JWT" } } var requiresKeyValue: Bool { switch self { - case .alias, .tag, .trigger: return true + case .alias, .tag, .trigger, .updateUserJwt: return true case .email, .sms, .externalUserId: return false } } @@ -103,6 +105,7 @@ enum AddItemType { switch self { case .alias: return "Label" case .tag, .trigger: return "Key" + case .updateUserJwt: return "External User Id" default: return "Key" } } @@ -114,6 +117,7 @@ enum AddItemType { case .sms: return "Phone Number" case .tag, .trigger: return "Value" case .externalUserId: return "External User Id" + case .updateUserJwt: return "JWT Token" } } @@ -141,6 +145,7 @@ enum AddItemType { case .tag: return "tag" case .trigger: return "trigger" case .externalUserId: return "login_user_id" + case .updateUserJwt: return "update_jwt" } } @@ -152,6 +157,7 @@ enum AddItemType { case .alias: return "alias_label_input" case .tag: return "tag_key_input" case .trigger: return "trigger_key_input" + case .updateUserJwt: return "update_jwt_external_id_input" default: return "\(accessibilityKey)_key_input" } } @@ -165,6 +171,7 @@ enum AddItemType { case .alias: return "alias_id_input" case .tag: return "tag_value_input" case .trigger: return "trigger_value_input" + case .updateUserJwt: return "update_jwt_token_input" default: return "\(accessibilityKey)_input" } } diff --git a/examples/demo/App/Services/OneSignalService.swift b/examples/demo/App/Services/OneSignalService.swift index 598189ea0..d55fa0df8 100644 --- a/examples/demo/App/Services/OneSignalService.swift +++ b/examples/demo/App/Services/OneSignalService.swift @@ -49,10 +49,13 @@ final class OneSignalService { // MARK: - Initialization /// Mirrors the Capacitor demo's `useOneSignal` init order: feed cached - /// consent into the SDK BEFORE `initialize`, then restore IAM-paused, - /// location-shared, and a previously-logged-in external user id once the - /// SDK is ready. Without this, toggles flip back to defaults on every - /// cold launch. + /// consent into the SDK BEFORE `initialize`, then restore IAM-paused and + /// location-shared once the SDK is ready. Without this, toggles flip back + /// to defaults on every cold launch. + /// + /// No login here. The SDK restores the user from its own cache, and the stored JWT + /// stays with the demo so the SDK's own ask through `OSUserJwtInvalidatedListener` + /// stays observable on cold start. func initialize(launchOptions: [UIApplication.LaunchOptionsKey: Any]?) { OneSignal.Debug.setLogLevel(.LL_VERBOSE) @@ -63,10 +66,6 @@ final class OneSignalService { OneSignal.InAppMessages.paused = prefs.getIamPaused() OneSignal.Location.isShared = prefs.getLocationShared() - - if let storedExternalId = prefs.getExternalUserId() { - OneSignal.login(storedExternalId) - } } // MARK: - Identity @@ -96,16 +95,51 @@ final class OneSignalService { // MARK: - User - func login(externalId: String) { + func login(externalId: String, jwtToken: String? = nil) { + // Demo REST fetch only. Not replayed to the SDK at launch. A same-user login + // without a token keeps the stored one, matching the SDK, which keeps its own. + if jwtToken != nil || externalId != OneSignal.User.externalId { + prefs.setSessionJwtToken(jwtToken) + } prefs.setExternalUserId(externalId) - OneSignal.login(externalId) + if let jwtToken = jwtToken { + OneSignal.login(externalId: externalId, token: jwtToken) + } else { + OneSignal.login(externalId) + } + } + + func updateUserJwt(externalId: String, token: String) { + // The demo's REST bearer belongs to the current user. A token for anyone else + // still goes to the SDK, which owns the parked work for that id. + if externalId == OneSignal.User.externalId { + prefs.setSessionJwtToken(token) + } + OneSignal.updateUserJwt(externalId: externalId, token: token) } func logout() { prefs.setExternalUserId(nil) + prefs.setSessionJwtToken(nil) OneSignal.logout() } + /// Called when the SDK rejects the token, so the REST fetch stops sending it. + func clearSessionJwtToken() { + prefs.setSessionJwtToken(nil) + } + + // MARK: - Identity Verification (demo REST fetch) + + /// Demo toggle for addressing the REST user fetch by `external_id`. Persisted across launches. + var useIdentityVerification: Bool { + get { prefs.getUseIdentityVerification() } + set { prefs.setUseIdentityVerification(newValue) } + } + + /// Token from the last login / updateUserJwt, for the demo REST fetch only. Cleared when the SDK rejects it. + var sessionJwtToken: String? { prefs.getSessionJwtToken() } + // MARK: - Aliases func addAlias(label: String, id: String) { OneSignal.User.addAlias(label: label, id: id) } @@ -222,6 +256,14 @@ final class OneSignalService { OneSignal.Notifications.addPermissionObserver(observer) } + func addUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) { + OneSignal.addUserJwtInvalidatedListener(listener) + } + + func removeUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) { + OneSignal.removeUserJwtInvalidatedListener(listener) + } + func addNotificationClickListener(_ listener: OSNotificationClickListener) { OneSignal.Notifications.addClickListener(listener) } diff --git a/examples/demo/App/Services/PreferencesService.swift b/examples/demo/App/Services/PreferencesService.swift index b055224f7..f10e31358 100644 --- a/examples/demo/App/Services/PreferencesService.swift +++ b/examples/demo/App/Services/PreferencesService.swift @@ -28,9 +28,9 @@ import Foundation /// `UserDefaults`-backed cache for state the demo restores across cold launches: -/// consent flags, IAM paused, location shared, and the last-logged-in external -/// user id. Mirrors the Capacitor demo's `PreferencesService` so the iOS demo -/// re-feeds these into the SDK during initialization. +/// consent flags, IAM paused, location shared, the last-logged-in external user id, +/// the Identity Verification toggle, and the demo REST JWT. The JWT is only for the +/// demo's `/users` fetch — it is not automatically passed to `login` / `updateUserJwt`. final class PreferencesService { static let shared = PreferencesService() @@ -47,6 +47,8 @@ final class PreferencesService { static let iamPaused = "onesignal.demo.iamPaused" static let locationShared = "onesignal.demo.locationShared" static let externalUserId = "onesignal.demo.externalUserId" + static let useIdentityVerification = "onesignal.demo.useIdentityVerification" + static let sessionJwtToken = "onesignal.demo.sessionJwtToken" } // MARK: - Consent @@ -83,4 +85,24 @@ final class PreferencesService { defaults.removeObject(forKey: Key.externalUserId) } } + + // MARK: - Identity Verification (demo REST fetch) + + func getUseIdentityVerification() -> Bool { defaults.bool(forKey: Key.useIdentityVerification) } + func setUseIdentityVerification(_ value: Bool) { defaults.set(value, forKey: Key.useIdentityVerification) } + + func getSessionJwtToken() -> String? { + guard let value = defaults.string(forKey: Key.sessionJwtToken), !value.isEmpty else { + return nil + } + return value + } + + func setSessionJwtToken(_ value: String?) { + if let value = value, !value.isEmpty { + defaults.set(value, forKey: Key.sessionJwtToken) + } else { + defaults.removeObject(forKey: Key.sessionJwtToken) + } + } } diff --git a/examples/demo/App/Services/UserFetchService.swift b/examples/demo/App/Services/UserFetchService.swift index 836559eaa..7aeb969c4 100644 --- a/examples/demo/App/Services/UserFetchService.swift +++ b/examples/demo/App/Services/UserFetchService.swift @@ -27,29 +27,66 @@ import Foundation -/// Reads the OneSignal /users API to hydrate aliases / tags / channels in the demo +/// Why a demo /users fetch returned nothing, shown in the User section so a 401 under +/// Identity Verification is not mistaken for an empty user. +enum UserFetchError: Error, CustomStringConvertible { + case badRequest + case http(Int) + case badBody + case transport(Error) + + var description: String { + switch self { + case .badRequest: return "bad request" + case .http(let status): return "HTTP \(status)" + case .badBody: return "unreadable body" + case .transport(let error): return error.localizedDescription + } + } +} + +/// Reads the OneSignal /users API to hydrate aliases / tags / channels in the demo. final class UserFetchService { static let shared = UserFetchService() private init() {} - func fetchUser(appId: String, onesignalId: String) async -> UserData? { - let urlString = "https://api.onesignal.com/apps/\(appId)/users/by/onesignal_id/\(onesignalId)" - guard let url = URL(string: urlString) else { return nil } + /// Fetches by `onesignal_id` (unsigned) or `external_id` (Bearer when `jwt` is set). + func fetchUser( + appId: String, + aliasLabel: String, + aliasValue: String, + jwt: String? = nil + ) async -> Result { + guard !aliasValue.isEmpty else { return .failure(.badRequest) } + + // Path-encode so external_ids with reserved characters don't misroute the GET. + var allowed = CharacterSet.urlPathAllowed + allowed.remove(charactersIn: "/") + guard + let encodedAlias = aliasValue.addingPercentEncoding(withAllowedCharacters: allowed), + let url = URL(string: "https://api.onesignal.com/apps/\(appId)/users/by/\(aliasLabel)/\(encodedAlias)") + else { + return .failure(.badRequest) + } var request = URLRequest(url: url) request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let jwt = jwt { + request.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization") + } do { let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { - return nil + return .failure(.http((response as? HTTPURLResponse)?.statusCode ?? -1)) } guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return nil + return .failure(.badBody) } - return parse(json) + return .success(parse(json)) } catch { - return nil + return .failure(.transport(error)) } } diff --git a/examples/demo/App/ViewModels/OneSignalViewModel.swift b/examples/demo/App/ViewModels/OneSignalViewModel.swift index e158be52d..14fdb8330 100644 --- a/examples/demo/App/ViewModels/OneSignalViewModel.swift +++ b/examples/demo/App/ViewModels/OneSignalViewModel.swift @@ -43,6 +43,11 @@ final class OneSignalViewModel: ObservableObject { @Published var externalUserId: String? @Published var aliases: [KeyValueItem] = [] + @Published var useIdentityVerification: Bool = false + /// External id the SDK is waiting on a JWT for. Drives the banner in `UserSection`. + @Published var jwtAskExternalId: String? + /// Outcome of the last demo /users fetch, "OK" or the failure reason. + @Published var userFetchStatus: String? // MARK: - Push @@ -99,6 +104,7 @@ final class OneSignalViewModel: ObservableObject { self.appId = service.appId self.consentRequired = service.consentRequired self.consentGiven = service.consentGiven + self.useIdentityVerification = service.useIdentityVerification self.externalUserId = service.externalId ?? prefs.getExternalUserId() self.hasNotificationPermission = service.hasNotificationPermission refreshState() @@ -106,7 +112,12 @@ final class OneSignalViewModel: ObservableObject { TooltipService.shared.loadIfNeeded() - if service.onesignalId != nil { + // Demo REST hydrate only — does not call login / updateUserJwt with a cached JWT. + if useIdentityVerification { + if externalUserId != nil || service.externalId != nil { + Task { await fetchUserDataFromApi() } + } + } else if service.onesignalId != nil { Task { await fetchUserDataFromApi() } } } @@ -119,24 +130,49 @@ final class OneSignalViewModel: ObservableObject { isInAppMessagesPaused = service.isInAppMessagesPaused isLocationShared = service.isLocationShared hasNotificationPermission = service.hasNotificationPermission - externalUserId = service.externalId + externalUserId = service.externalId ?? prefs.getExternalUserId() let sdkTags = service.getTags() tags = sdkTags.map { KeyValueItem(key: $0.key, value: $0.value) } } func fetchUserDataFromApi() async { - guard let onesignalId = service.onesignalId else { return } + let aliasLabel: String + let aliasValue: String + let jwt: String? + + if useIdentityVerification { + guard let externalId = externalUserId ?? service.externalId, !externalId.isEmpty else { + return + } + aliasLabel = "external_id" + aliasValue = externalId + jwt = service.sessionJwtToken + } else { + guard let onesignalId = service.onesignalId, !onesignalId.isEmpty else { + return + } + aliasLabel = "onesignal_id" + aliasValue = onesignalId + jwt = nil + } + requestSequence &+= 1 let captured = requestSequence isLoading = true - let userData = await UserFetchService.shared.fetchUser(appId: appId, onesignalId: onesignalId) + let result = await UserFetchService.shared.fetchUser( + appId: appId, + aliasLabel: aliasLabel, + aliasValue: aliasValue, + jwt: jwt + ) // Drop the result if a newer fetch has started while this one was in flight. guard captured == requestSequence else { return } - if let userData = userData { + switch result { + case .success(let userData): aliases = userData.aliases.map { KeyValueItem(key: $0.key, value: $0.value) } tags = userData.tags.map { KeyValueItem(key: $0.key, value: $0.value) } emails = userData.emails @@ -144,6 +180,10 @@ final class OneSignalViewModel: ObservableObject { if let extId = userData.externalId, !extId.isEmpty { externalUserId = extId } + userFetchStatus = "OK" + case .failure(let error): + userFetchStatus = "\(aliasLabel) fetch failed: \(error)" + print("[OneSignal] /users fetch by \(aliasLabel) failed: \(error)") } isLoading = false } @@ -166,21 +206,63 @@ final class OneSignalViewModel: ObservableObject { // MARK: - User - func login(externalId: String) { + func login(externalId: String, jwtToken: String? = nil) { let trimmed = externalId.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } + let sameUser = trimmed == service.externalId isLoading = true - service.login(externalId: trimmed) + service.login(externalId: trimmed, jwtToken: jwtToken) externalUserId = trimmed - clearUserData() + if jwtToken != nil, jwtAskExternalId == trimmed { + jwtAskExternalId = nil + } + if sameUser { + // No user-state event fires for the same user, so refetch here instead. + Task { await fetchUserDataFromApi() } + } else { + clearUserData() + } + } + + func updateUserJwt(externalId: String, token: String) { + let trimmedId = externalId.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedId.isEmpty, !trimmedToken.isEmpty else { return } + // Same condition the service uses to decide whether to store the bearer. + let currentUser = trimmedId == service.externalId + service.updateUserJwt(externalId: trimmedId, token: trimmedToken) + if jwtAskExternalId == trimmedId { + jwtAskExternalId = nil + } + print("[OneSignal] Updated JWT for: \(trimmedId)") + if currentUser { + // The demo now holds a fresh bearer for this user, and no user-state + // event is guaranteed, so refetch instead of leaving the stale failure. + Task { await fetchUserDataFromApi() } + } + } + + /// The SDK parked this user's requests for want of a token. Drop the demo's stale copy and show the ask. + func handleJwtInvalidated(externalId: String) { + service.clearSessionJwtToken() + jwtAskExternalId = externalId + print("[OneSignal] JWT invalidated for externalId: \(externalId)") } func logout() { service.logout() externalUserId = nil + jwtAskExternalId = nil clearUserData() } + func setUseIdentityVerification(_ enabled: Bool) { + useIdentityVerification = enabled + service.useIdentityVerification = enabled + print("[OneSignal] Fetch by external_id \(enabled ? "enabled" : "disabled")") + Task { await fetchUserDataFromApi() } + } + private func clearUserData() { aliases.removeAll() emails.removeAll() @@ -284,6 +366,53 @@ final class OneSignalViewModel: ObservableObject { tags.removeAll { keys.contains($0.key) } } + // MARK: - Location + + func setLocationShared(_ shared: Bool) { + isLocationShared = shared + service.isLocationShared = shared + } + + func promptLocation() { + service.requestLocationPermission() + } + + func checkLocationShared() -> Bool { + let shared = service.isLocationShared + print("[OneSignal] Location shared: \(shared)") + return shared + } + + // MARK: - Tooltips + + func showTooltip(for key: String) { + if let tooltip = TooltipService.shared.tooltip(for: key) { + activeTooltip = tooltip + } + } + + func dismissTooltip() { + activeTooltip = nil + } + + // MARK: - Observers + + private func setupObservers() { + observers.viewModel = self + service.addPushSubscriptionObserver(observers) + service.addUserObserver(observers) + service.addPermissionObserver(observers) + service.addUserJwtInvalidatedListener(observers) + } +} + +// MARK: - Engagement + +/// Outcomes, in-app messages, triggers, custom events, notifications and Live +/// Activities. Split out of the main declaration so the type body stays inside +/// SwiftLint's `type_body_length` limit. +extension OneSignalViewModel { + // MARK: - Outcomes func sendOutcome(_ name: String) { @@ -354,23 +483,6 @@ final class OneSignalViewModel: ObservableObject { print("[OneSignal] Event tracked: \(name)") } - // MARK: - Location - - func setLocationShared(_ shared: Bool) { - isLocationShared = shared - service.isLocationShared = shared - } - - func promptLocation() { - service.requestLocationPermission() - } - - func checkLocationShared() -> Bool { - let shared = service.isLocationShared - print("[OneSignal] Location shared: \(shared)") - return shared - } - // MARK: - Notifications func clearAllNotifications() { @@ -425,32 +537,15 @@ final class OneSignalViewModel: ObservableObject { } } #endif - - // MARK: - Tooltips - - func showTooltip(for key: String) { - if let tooltip = TooltipService.shared.tooltip(for: key) { - activeTooltip = tooltip - } - } - - func dismissTooltip() { - activeTooltip = nil - } - - // MARK: - Observers - - private func setupObservers() { - observers.viewModel = self - service.addPushSubscriptionObserver(observers) - service.addUserObserver(observers) - service.addPermissionObserver(observers) - } } // MARK: - Observer Bridge -private final class Observers: NSObject, OSPushSubscriptionObserver, OSUserStateObserver, OSNotificationPermissionObserver { +private final class Observers: NSObject, + OSPushSubscriptionObserver, + OSUserStateObserver, + OSNotificationPermissionObserver, + OSUserJwtInvalidatedListener { weak var viewModel: OneSignalViewModel? func onPushSubscriptionDidChange(state: OSPushSubscriptionChangedState) { @@ -472,4 +567,10 @@ private final class Observers: NSObject, OSPushSubscriptionObserver, OSUserState viewModel?.isPushEnabled = OneSignal.User.pushSubscription.optedIn } } + + func onUserJwtInvalidated(event: OSUserJwtInvalidatedEvent) { + Task { @MainActor in + viewModel?.handleJwtInvalidated(externalId: event.externalId) + } + } } diff --git a/examples/demo/App/Views/Components/AddItemDialog.swift b/examples/demo/App/Views/Components/AddItemDialog.swift index f6b099578..938a12f5e 100644 --- a/examples/demo/App/Views/Components/AddItemDialog.swift +++ b/examples/demo/App/Views/Components/AddItemDialog.swift @@ -36,6 +36,19 @@ struct AddItemDialog: View { @State private var keyText: String = "" @State private var valueText: String = "" + /// `initialKey` prefills the first field. + init( + itemType: AddItemType, + initialKey: String = "", + onAdd: @escaping (String, String) -> Void, + onCancel: @escaping () -> Void + ) { + self.itemType = itemType + self.onAdd = onAdd + self.onCancel = onCancel + _keyText = State(initialValue: initialKey) + } + var body: some View { OSDialog( title: itemType.title, diff --git a/examples/demo/App/Views/Components/LoginUserDialog.swift b/examples/demo/App/Views/Components/LoginUserDialog.swift new file mode 100644 index 000000000..537470c53 --- /dev/null +++ b/examples/demo/App/Views/Components/LoginUserDialog.swift @@ -0,0 +1,70 @@ +/** + * 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 SwiftUI + +/// Login / switch-user dialog: external id required, JWT optional. +struct LoginUserDialog: View { + let onLogin: (String, String?) -> Void + let onCancel: () -> Void + + @State private var externalId: String = "" + @State private var jwtToken: String = "" + + var body: some View { + OSDialog( + title: "Login User", + confirmLabel: "Login", + isConfirmEnabled: isValid, + confirmAccessibilityID: "singleinput_confirm_button", + cancelAccessibilityID: "singleinput_cancel_button", + onConfirm: { + let id = externalId.trimmingCharacters(in: .whitespacesAndNewlines) + let token = jwtToken.trimmingCharacters(in: .whitespacesAndNewlines) + onLogin(id, token.isEmpty ? nil : token) + }, + onCancel: onCancel + ) { + VStack(spacing: 12) { + OSTextField( + placeholder: "External User Id", + text: $externalId, + accessibilityID: "login_user_id_input" + ) + OSTextField( + placeholder: "JWT Token (optional)", + text: $jwtToken, + accessibilityID: "login_user_jwt_input" + ) + } + } + } + + private var isValid: Bool { + !externalId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} diff --git a/examples/demo/App/Views/Sections/UserSection.swift b/examples/demo/App/Views/Sections/UserSection.swift index 0e0bb5067..2ac39b9bf 100644 --- a/examples/demo/App/Views/Sections/UserSection.swift +++ b/examples/demo/App/Views/Sections/UserSection.swift @@ -27,13 +27,25 @@ import SwiftUI -/// Login/logout + status display, mirroring the Capacitor UserSection +/// Login/logout + JWT / Identity Verification controls for manual testing. struct UserSection: View { @EnvironmentObject var viewModel: OneSignalViewModel + @EnvironmentObject var toast: ToastPresenter @State private var loginOpen = false + @State private var updateJwtOpen = false var body: some View { SectionCard(title: "USER", sectionKey: "user") { + ToggleRow( + label: "Fetch by external_id (JWT)", + description: "Demo REST fetch only. Signs with the stored JWT.", + isOn: Binding( + get: { viewModel.useIdentityVerification }, + set: { viewModel.setUseIdentityVerification($0) } + ), + accessibilityID: "identity_verification_toggle" + ) + ValueCard(rows: [ ValueCard.Row( label: "Status", @@ -45,9 +57,39 @@ struct UserSection: View { value: viewModel.externalUserId ?? "—", valueAccessibilityID: "user_external_id_value", monospaced: true + ), + ValueCard.Row( + label: "REST fetch", + value: viewModel.userFetchStatus ?? "—", + valueAccessibilityID: "user_fetch_status_value" ) ]) + // Shown instead of auto-feeding the stored token, so the SDK's ask is visible. + if let askedId = viewModel.jwtAskExternalId { + VStack(alignment: .leading, spacing: OS.Spacing.cardGap) { + Text("The SDK is waiting for a JWT. Requests for this user are parked until one is supplied.") + .font(OS.Font.bodySmall) + .foregroundColor(OS.Color.bodyText) + Text(askedId) + .font(OS.Font.mono12) + .foregroundColor(OS.Color.bodyText) + .lineLimit(1) + .truncationMode(.middle) + .accessibilityIdentifier("jwt_ask_external_id_value") + ActionButton( + "PROVIDE JWT", + style: .outline, + accessibilityID: "jwt_ask_provide_button" + ) { + updateJwtOpen = true + } + } + .osCard(background: OS.Color.warningBackground) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("jwt_ask_banner") + } + ActionButton( viewModel.loginButtonTitle, accessibilityID: "login_user_button" @@ -64,16 +106,39 @@ struct UserSection: View { viewModel.logout() } } + + ActionButton( + "UPDATE USER JWT", + style: .outline, + accessibilityID: "update_user_jwt_button" + ) { + updateJwtOpen = true + } } .osCenteredDialog(isPresented: $loginOpen) { - AddItemDialog( - itemType: .externalUserId, - onAdd: { _, value in - viewModel.login(externalId: value) + LoginUserDialog( + onLogin: { externalId, jwt in + viewModel.login(externalId: externalId, jwtToken: jwt) loginOpen = false }, onCancel: { loginOpen = false } ) } + .osCenteredDialog(isPresented: $updateJwtOpen) { + AddItemDialog( + itemType: .updateUserJwt, + initialKey: viewModel.jwtAskExternalId ?? viewModel.externalUserId ?? "", + onAdd: { externalId, token in + viewModel.updateUserJwt(externalId: externalId, token: token) + updateJwtOpen = false + }, + onCancel: { updateJwtOpen = false } + ) + } + .onChange(of: viewModel.jwtAskExternalId) { askedId in + if let askedId = askedId { + toast.show("SDK asked for a JWT for \(askedId)") + } + } } } From 9ac6fdd308dd9639973df67d2e70e03db0e28db1 Mon Sep 17 00:00:00 2001 From: Nan Date: Sun, 13 Sep 2026 21:20:08 -0700 Subject: [PATCH 10/25] fix: [SDK-5099] respect REST API-disabled push subscriptions (#1731) (cherry picked from commit 15561f40b428859fc3b7c5e9cd34a3b63df0c8f3) --- .../Source/Executors/OSUserExecutor.swift | 61 ++- .../Source/OSPushSubscriptionImpl.swift | 5 +- .../Source/OSSubscriptionModel.swift | 340 ++++++++++-- .../Source/OneSignalUserManagerImpl.swift | 3 + .../OSRequestUpdateSubscription.swift | 13 +- .../OneSignalUserTests.swift | 483 ++++++++++++++++++ 6 files changed, 824 insertions(+), 81 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift index 66c6450bb..a577b088a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift @@ -624,28 +624,7 @@ extension OSUserExecutor { } } - // TODO: Determine how to hydrate the push subscription, which is still faulty. - // Hydrate by token if sub_id exists? - // Problem: a user can have multiple iOS push subscription, and perhaps missing token - // Ideally we only get push subscription for this device in the response, not others - - // Hydrate the push subscription if we don't already have a subscription ID AND token matches the original request - if OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId == nil, - let subscriptionObject = parseSubscriptionObjectResponse(response) - { - for subModel in subscriptionObject { - if subModel["type"] as? String == "iOSPush", - // response may have "" token or no token - areTokensEqual(tokenA: originalPushToken, tokenB: subModel["token"] as? String) - { - OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.hydrate(subModel) - if addNewRecords, let subId = subModel["id"] as? String { - newRecordsState.add(subId) - } - break - } - } - } + hydratePushSubscription(response: response, originalPushToken: originalPushToken, addNewRecords: addNewRecords) // Hydrate onto the user this response is for // If user has changed, don't hydrate, except for push subscription above @@ -686,6 +665,44 @@ extension OSUserExecutor { } } + /// Hydrates the push subscription from a fetch or create response: the whole object before a + /// subscription ID exists, only the server's remote disable state once one does. + // TODO: Determine how to hydrate the push subscription, which is still faulty. + // Hydrate by token if sub_id exists? + // Problem: a user can have multiple iOS push subscription, and perhaps missing token + // Ideally we only get push subscription for this device in the response, not others + private func hydratePushSubscription(response: [AnyHashable: Any], originalPushToken: String?, addNewRecords: Bool) { + guard let subscriptionObject = parseSubscriptionObjectResponse(response) else { + return + } + // The response's subscription ID is recorded as a new record even when the model is absent. + let pushSubscriptionModel = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel + + // Hydrate the push subscription if we don't already have a subscription ID AND token matches the original request + guard let subscriptionId = pushSubscriptionModel?.subscriptionId else { + for subModel in subscriptionObject { + if subModel["type"] as? String == "iOSPush", + // response may have "" token or no token + areTokensEqual(tokenA: originalPushToken, tokenB: subModel["token"] as? String) + { + pushSubscriptionModel?.hydrate(subModel) + if addNewRecords, let subId = subModel["id"] as? String { + newRecordsState.add(subId) + } + break + } + } + return + } + + // Only the remote disable state hydrates onto an existing push subscription; the device + // owns the rest. Skipping it lets the next subscription payload re-enable a suppressed device. + for subModel in subscriptionObject where subModel["id"] as? String == subscriptionId { + pushSubscriptionModel?.hydrateRemoteDisableState(from: subModel) + break + } + } + /** Returns if 2 tokens are equal. This is needed as a nil token is equal to the empty string "". */ diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPushSubscriptionImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPushSubscriptionImpl.swift index 61e85e755..509cdee9c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPushSubscriptionImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPushSubscriptionImpl.swift @@ -90,7 +90,10 @@ public class OSPushSubscriptionImpl: NSObject, OSPushSubscription { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.optIn") else { return } - pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?._isDisabled = false + let model = pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY) + // Clear first so `remoteDisableClearedByUser` is set before the opt-out flips and its delta goes out. + model?.clearRemoteDisable() + model?._isDisabled = false OSNotificationsManager.requestPermission(nil, fallbackToSettings: true) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift index a12407f7b..bfd609401 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift @@ -94,6 +94,30 @@ enum OSSubscriptionType: String { case sms = "SMS" } +/** + The `notification_types` codes the app owner sets remotely, meaning the server turned the + subscription off rather than the device failing to register. The SDK treats them identically but + records them separately, so outgoing payloads echo back the code the server sent. Every other + negative code is a device or delivery error the device recovers from by re-asserting its own state. + */ +enum OSRemoteDisable { + /// Unsubscribed by hand from the dashboard. + static let manuallyUnsubscribed = -22 + + /// Disabled through the REST API. + static let restApiDisabled = -31 + + private static let allCodes: Set = [manuallyUnsubscribed, restApiDisabled] + + /// True when `notificationTypes` is one of the codes the app owner sets remotely. + static func matches(_ notificationTypes: Int?) -> Bool { + guard let notificationTypes else { + return false + } + return allCodes.contains(notificationTypes) + } +} + /** Internal subscription model. */ @@ -115,6 +139,10 @@ class OSSubscriptionModel: OSModel { var deviceModel: String? var appVersion: String? var netType: Int? + var remoteDisabledReason: Int? + // Not persisted; an optIn() clear outranks stale hydration until the server reports + // the subscription in another state. + var remoteDisableClearedByUser = false } /** @@ -183,11 +211,21 @@ class OSSubscriptionModel: OSModel { return } + // A reset drops the record's disable; the change event still needs the old code for its previous state. + let previousRemoteDisabledReason = remoteDisabledReason + if newValue == nil { + remoteDisabledReason = nil + } + // Cache the subscriptionId to UserDefaults for routine reads, and the OSResilientStorage mirror OneSignalUserDefaults.initShared().saveString(forKey: OSUD_PUSH_SUBSCRIPTION_ID, withValue: newValue) OSResilientStorage.setString(newValue ?? "", forKey: OSResilientStorage.keySubscriptionId) - firePushSubscriptionChanged(.subscriptionId(oldValue)) + // The create request carries the new record's state, so no enabled delta here. + firePushSubscriptionChanged( + .subscriptionId(oldValue, remoteDisabledReason: previousRemoteDisabledReason), + generateEnabledDelta: false + ) } } @@ -195,15 +233,24 @@ class OSSubscriptionModel: OSModel { var enabled: Bool { // Does not consider subscription_id in the calculation get { let state = snapshot() - return calculateIsEnabled(address: state.address, reachable: state.reachable, isDisabled: state.isDisabled) + return calculateIsEnabled( + address: state.address, + reachable: state.reachable, + isDisabled: state.isDisabled, + remoteDisabledReason: state.remoteDisabledReason + ) } } var optedIn: Bool { - // optedIn = permission + userPreference + // optedIn = permission + userPreference + not suppressed by the app owner get { let state = snapshot() - return calculateIsOptedIn(reachable: state.reachable, isDisabled: state.isDisabled) + return calculateIsOptedIn( + reachable: state.reachable, + isDisabled: state.isDisabled, + remoteDisabledReason: state.remoteDisabledReason + ) } } @@ -292,7 +339,8 @@ class OSSubscriptionModel: OSModel { /** The `enabled` and `notification_types` to report, which the server reads as a pair. An internal - disable overrides both. `notificationTypes` is nil when there is no value to send. + disable overrides both; otherwise a recorded remote disable holds `enabled` false and echoes its + code. `notificationTypes` is nil when there is no value to send. Taken from one snapshot so the two cannot disagree, and shared with Update Subscription so a subscription reports the same thing however the Request was built. @@ -305,9 +353,33 @@ class OSSubscriptionModel: OSModel { guard !state.isDisabledInternally else { return (false, -2) } - let enabled = calculateIsEnabled(address: state.address, reachable: state.reachable, isDisabled: state.isDisabled) - // notificationTypes defaults to -1 instead of nil, don't send if it's -1 - return (enabled, state.notificationTypes == -1 ? nil : state.notificationTypes) + let enabled = calculateIsEnabled( + address: state.address, + reachable: state.reachable, + isDisabled: state.isDisabled, + remoteDisabledReason: state.remoteDisabledReason + ) + return (enabled, outboundNotificationTypes(state)) + } + + /** + The server's remote disable code, either -22 (unsubscribed by hand from the dashboard) or -31 + (disabled through the REST API), or nil when the server has not disabled this subscription. The + two codes are kept apart so payloads echo back the one the server sent rather than collapsing + them. Hydrated from responses, never derived from device state, and echoed back in payloads so + routine updates and logins don't re-enable a suppressed subscription. Cleared when a response + reports any other state, when the subscription ID resets, or by `optIn()`. + */ + var remoteDisabledReason: Int? { + get { stateLock.withLock { state.remoteDisabledReason } } + set { + let oldValue = swapValue(\.remoteDisabledReason, to: newValue) + guard newValue != oldValue else { + return + } + // Mirrors server state rather than a local change, so persist without generating a delta. + self.set(property: "remoteDisabledReason", newValue: newValue, preventServerUpdate: true) + } } // Properties for push subscription @@ -421,7 +493,9 @@ class OSSubscriptionModel: OSModel { sdk: ONESIGNAL_VERSION, deviceModel: OSDeviceUtils.getDeviceVariant(), appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String, - netType: OSNetworkingUtils.getNetType() as? Int + netType: OSNetworkingUtils.getNetType() as? Int, + remoteDisabledReason: nil, + remoteDisableClearedByUser: false ) super.init(changeNotifier: changeNotifier) @@ -444,6 +518,7 @@ class OSSubscriptionModel: OSModel { coder.encode(state.deviceModel, forKey: "deviceModel") coder.encode(state.appVersion, forKey: "appVersion") coder.encode(state.netType, forKey: "netType") + coder.encode(state.remoteDisabledReason, forKey: "remoteDisabledReason") } required init?(coder: NSCoder) { @@ -469,7 +544,9 @@ class OSSubscriptionModel: OSModel { sdk: coder.decodeObject(forKey: "sdk") as? String ?? ONESIGNAL_VERSION, deviceModel: coder.decodeObject(forKey: "deviceModel") as? String, appVersion: coder.decodeObject(forKey: "appVersion") as? String, - netType: coder.decodeObject(forKey: "netType") as? Int + netType: coder.decodeObject(forKey: "netType") as? Int, + remoteDisabledReason: coder.decodeObject(forKey: "remoteDisabledReason") as? Int, + remoteDisableClearedByUser: false ) super.init(coder: coder) @@ -490,13 +567,11 @@ class OSSubscriptionModel: OSModel { // self.address = property.value as? String case "enabled": if let enabled = property.value as? Bool { - if self.enabled != enabled { // TODO: Is this right? - _isDisabled = !enabled - } + hydrateEnabled(enabled, response: response) } case "notification_types": if let notificationTypes = property.value as? Int { - self.notificationTypes = notificationTypes + hydrateNotificationTypes(notificationTypes) } default: OneSignalLog.onesignalLog(.LL_DEBUG, message: "Unused property on subscription model") @@ -525,6 +600,7 @@ class OSSubscriptionModel: OSModel { } return json } + } // Push Subscription related @@ -534,26 +610,175 @@ extension OSSubscriptionModel { let state = snapshot() return OSPushSubscriptionState(id: state.subscriptionId, token: state.address, - optedIn: calculateIsOptedIn(reachable: state.reachable, isDisabled: state.isDisabled) + optedIn: calculateIsOptedIn( + reachable: state.reachable, + isDisabled: state.isDisabled, + remoteDisabledReason: state.remoteDisabledReason + ) ) } // Calculates if the device is opted in to push notification. - // Must have permission and not be opted out. - func calculateIsOptedIn(reachable: Bool, isDisabled: Bool) -> Bool { - return reachable && !isDisabled + // Must have permission, not be opted out, and not be disabled remotely by the app owner. A + // remote disable suppresses delivery just as surely as a missing permission or an opt-out, and + // it is the only one of the three the app cannot see any other way. + func calculateIsOptedIn(reachable: Bool, isDisabled: Bool, remoteDisabledReason: Int?) -> Bool { + return reachable && !isDisabled && remoteDisabledReason == nil } // Calculates if push notifications are enabled on the device. // Does not consider the existence of the subscription_id, as we send this in the request to create a push subscription. - func calculateIsEnabled(address: String?, reachable: Bool, isDisabled: Bool) -> Bool { - return address != nil && reachable && !isDisabled + func calculateIsEnabled(address: String?, reachable: Bool, isDisabled: Bool, remoteDisabledReason: Int?) -> Bool { + return address != nil && reachable && !isDisabled && remoteDisabledReason == nil } func updateNotificationTypes() { notificationTypes = Int(OSNotificationsManager.getNotificationTypes(_isDisabled)) } + /// Applies a hydrated `enabled`. A push remote disable is not a user opt-out, so it must not flip + /// `_isDisabled`; `hydrateNotificationTypes` records it instead. + private func hydrateEnabled(_ enabled: Bool, response: [String: Any]) { + guard type != .push || !isRemoteDisable(response) else { + return + } + if self.enabled != enabled { // TODO: Is this right? + _isDisabled = !enabled + } + } + + /// Applies a hydrated notification_types. For push, -22 and -31 are recorded verbatim and any other + /// value clears the record. Email and SMS carry the same codes but have no remote disable state. + private func hydrateNotificationTypes(_ value: Int) { + guard type == .push else { + self.notificationTypes = value + return + } + if OSRemoteDisable.matches(value) { + recordRemoteDisable(value) + } else { + acceptServerNonDisabledState() + self.notificationTypes = value + } + } + + /// True when the response's notification_types carries a remote disable code. + private func isRemoteDisable(_ response: [String: Any]) -> Bool { + return OSRemoteDisable.matches(response["notification_types"] as? Int) + } + + /// Records the server's disable code verbatim, so -22 and -31 stay distinguishable, unless + /// `optIn()` cleared one and the server has not yet reported the subscription in another state; + /// the user's explicit intent wins that race. + private func recordRemoteDisable(_ code: Int) { + let (changed, previousReason) = stateLock.withLock { () -> (Bool, Int?) in + guard !state.remoteDisableClearedByUser, state.remoteDisabledReason != code else { + return (false, nil) + } + let previousReason = state.remoteDisabledReason + state.remoteDisabledReason = code + return (true, previousReason) + } + guard changed else { + return + } + OneSignalLog.onesignalLog( + .LL_DEBUG, + message: "OSSubscriptionModel: recording remote disable \(code) for push subscription \(subscriptionId ?? "nil")" + ) + self.set(property: "remoteDisabledReason", newValue: code, preventServerUpdate: true) + // The disable takes `optedIn` to false, which observers need to hear about. + firePushSubscriptionChanged(.remoteDisabledReason(previousReason), generateEnabledDelta: false) + } + + /// Clears `remoteDisabledReason` and re-arms recording once the server reports a non-disabled state. + private func acceptServerNonDisabledState() { + let clearedReason: Int? = stateLock.withLock { + state.remoteDisableClearedByUser = false + let previous = state.remoteDisabledReason + state.remoteDisabledReason = nil + return previous + } + guard let clearedReason else { + return + } + OneSignalLog.onesignalLog( + .LL_DEBUG, + message: "OSSubscriptionModel: clearing remote disable \(clearedReason) for push subscription \(subscriptionId ?? "nil")" + ) + self.set(property: "remoteDisabledReason", newValue: nil as Int?, preventServerUpdate: true) + // Clearing the disable takes `optedIn` back to true, which observers need to hear about. + firePushSubscriptionChanged(.remoteDisabledReason(clearedReason), generateEnabledDelta: false) + } + + /// notification_types for outgoing payloads: the recorded disable code (the positive device + /// value would re-enable it), else the device value, nil for the -1 default. + private func outboundNotificationTypes(_ state: State) -> Int? { + if let remoteDisabledReason = state.remoteDisabledReason { + return remoteDisabledReason + } + return state.notificationTypes != -1 ? state.notificationTypes : nil + } + + /// The PATCH body for a subscription update, built from one snapshot so a concurrent hydration + /// or `optIn()` can't tear `enabled` away from `notification_types`. + func updateParams() -> [String: Any] { + let state = snapshot() + var params: [String: Any] = [:] + params["token"] = state.address + params["device_os"] = state.deviceOs + params["sdk"] = state.sdk + params["app_version"] = state.appVersion + let enablement = reportedEnablement(from: state) + params["enabled"] = enablement.enabled + if let notificationTypes = enablement.notificationTypes { + params["notification_types"] = notificationTypes + } + return params + } + + /** + Mirrors the server's remote disable state from a fetched subscription object: -22 and -31 are + recorded verbatim, any other reported value clears it. The device stays the source of truth for + the rest of an existing subscription's state, so nothing else is read. + */ + func hydrateRemoteDisableState(from serverSubscription: [String: Any]) { + guard type == .push, let serverTypes = serverSubscription["notification_types"] as? Int else { + return + } + if OSRemoteDisable.matches(serverTypes) { + recordRemoteDisable(serverTypes) + } else { + acceptServerNonDisabledState() + } + } + + /** + Clears a remote disable and enqueues an enabled-change delta so the server re-enables the + subscription. Called from `optIn()` before `_isDisabled` flips, so it still sees the state the + opt-in is changing. + + `remoteDisableClearedByUser` is set only when the opt-in leads to a re-enable: a disable was + recorded here, the user had opted out, or permission is missing and `optIn()` is about to prompt + for it. An opt-in that changes nothing sends nothing, and setting the flag for it would ignore + every later disable until the process died. + */ + func clearRemoteDisable() { + let oldValue: Int? = stateLock.withLock { + let recorded = state.remoteDisabledReason + if recorded != nil || state.isDisabled || !state.reachable { + state.remoteDisableClearedByUser = true + } + state.remoteDisabledReason = nil + return recorded + } + guard let oldValue else { + return + } + self.set(property: "remoteDisabledReason", newValue: nil as Int?, preventServerUpdate: true) + firePushSubscriptionChanged(.remoteDisabledReason(oldValue)) + } + func updateTestType() { let releaseMode: OSUIApplicationReleaseMode = OneSignalMobileProvision.releaseMode() // Workaround to unsure how to extract the Int value in 1 step... @@ -583,44 +808,67 @@ extension OSSubscriptionModel { } enum OSPushPropertyChanged { - case subscriptionId(String?) + case subscriptionId(String?, remoteDisabledReason: Int?) case reachable(Bool) case isDisabled(Bool) case address(String?) - } - - func firePushSubscriptionChanged(_ changedProperty: OSPushPropertyChanged) { - var prevIsOptedIn = true - var prevIsEnabled = true - var prevSubscriptionState = OSPushSubscriptionState(id: "", token: "", optedIn: true) + case remoteDisabledReason(Int?) + } + + /// Notifies push subscription observers of the state after `changedProperty` changed. + /// + /// Pass `generateEnabledDelta: false` when the change came from a server response: the server + /// already holds that state, so enqueueing an `enabled` delta for it would send a request that + /// tells the server what it just told us. + func firePushSubscriptionChanged(_ changedProperty: OSPushPropertyChanged, generateEnabledDelta: Bool = true) { + // The previous state is the current state with only the changed property's old value substituted. + var prevId = subscriptionId + var prevAddress = address + var prevReachable = _reachable + var prevIsDisabled = _isDisabled + var prevRemoteDisabledReason = remoteDisabledReason switch changedProperty { - case .subscriptionId(let oldValue): - prevIsEnabled = calculateIsEnabled(address: address, reachable: _reachable, isDisabled: _isDisabled) - prevIsOptedIn = calculateIsOptedIn(reachable: _reachable, isDisabled: _isDisabled) - prevSubscriptionState = OSPushSubscriptionState(id: oldValue, token: address, optedIn: prevIsOptedIn) - + case .subscriptionId(let oldValue, let oldRemoteDisabledReason): + prevId = oldValue + prevRemoteDisabledReason = oldRemoteDisabledReason case .reachable(let oldValue): - prevIsEnabled = calculateIsEnabled(address: address, reachable: oldValue, isDisabled: _isDisabled) - prevIsOptedIn = calculateIsOptedIn(reachable: oldValue, isDisabled: _isDisabled) - prevSubscriptionState = OSPushSubscriptionState(id: subscriptionId, token: address, optedIn: prevIsOptedIn) - + prevReachable = oldValue case .isDisabled(let oldValue): - prevIsEnabled = calculateIsEnabled(address: address, reachable: _reachable, isDisabled: oldValue) - prevIsOptedIn = calculateIsOptedIn(reachable: _reachable, isDisabled: oldValue) - prevSubscriptionState = OSPushSubscriptionState(id: subscriptionId, token: address, optedIn: prevIsOptedIn) - + prevIsDisabled = oldValue case .address(let oldValue): - prevIsEnabled = calculateIsEnabled(address: oldValue, reachable: _reachable, isDisabled: _isDisabled) - prevIsOptedIn = calculateIsOptedIn(reachable: _reachable, isDisabled: _isDisabled) - prevSubscriptionState = OSPushSubscriptionState(id: subscriptionId, token: oldValue, optedIn: prevIsOptedIn) + prevAddress = oldValue + case .remoteDisabledReason(let oldValue): + prevRemoteDisabledReason = oldValue } - let newIsOptedIn = calculateIsOptedIn(reachable: _reachable, isDisabled: _isDisabled) + let prevIsEnabled = calculateIsEnabled( + address: prevAddress, + reachable: prevReachable, + isDisabled: prevIsDisabled, + remoteDisabledReason: prevRemoteDisabledReason + ) + let prevIsOptedIn = calculateIsOptedIn( + reachable: prevReachable, + isDisabled: prevIsDisabled, + remoteDisabledReason: prevRemoteDisabledReason + ) + let prevSubscriptionState = OSPushSubscriptionState(id: prevId, token: prevAddress, optedIn: prevIsOptedIn) + + let newIsOptedIn = calculateIsOptedIn( + reachable: _reachable, + isDisabled: _isDisabled, + remoteDisabledReason: remoteDisabledReason + ) - let newIsEnabled = calculateIsEnabled(address: address, reachable: _reachable, isDisabled: _isDisabled) + let newIsEnabled = calculateIsEnabled( + address: address, + reachable: _reachable, + isDisabled: _isDisabled, + remoteDisabledReason: remoteDisabledReason + ) - if prevIsEnabled != newIsEnabled { + if generateEnabledDelta && prevIsEnabled != newIsEnabled { self.set(property: "enabled", newValue: newIsEnabled) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index dc1a55db5..d24a010db 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -93,6 +93,9 @@ import OneSignalNotifications @objc public protocol OSPushSubscription { var id: String? { get } var token: String? { get } + /// The user's preference combined with OS permission. This is false while the app owner has the + /// subscription disabled remotely, from the dashboard or the REST API; `optIn()` clears that + /// suppression. var optedIn: Bool { get } func optIn() diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateSubscription.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateSubscription.swift index 55fe178d0..8b0b000d7 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateSubscription.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateSubscription.swift @@ -74,18 +74,7 @@ class OSRequestUpdateSubscription: OneSignalRequest, OSUserRequest { /// Rebuild the PATCH body from the current subscription model. func refreshParametersFromLiveModel() { - var subscriptionParams: [String: Any] = [:] - subscriptionParams["token"] = subscriptionModel.address - subscriptionParams["device_os"] = subscriptionModel.deviceOs - subscriptionParams["sdk"] = subscriptionModel.sdk - subscriptionParams["app_version"] = subscriptionModel.appVersion - - let enablement = subscriptionModel.reportedEnablement() - subscriptionParams["enabled"] = enablement.enabled - if let notificationTypes = enablement.notificationTypes { - subscriptionParams["notification_types"] = notificationTypes - } - self.parameters = ["subscription": subscriptionParams] + self.parameters = ["subscription": subscriptionModel.updateParams()] } init(subscriptionModel: OSSubscriptionModel, identityModel: OSIdentityModel?) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift index 212569198..e422de622 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift @@ -331,4 +331,487 @@ final class OneSignalUserTests: XCTestCase { XCTAssertNil(manager._user) XCTAssertNil(manager.currentUser(matching: identityModel.modelId)) } + + // MARK: - Remotely disabled push subscriptions + + /// The notification_types codes the app owner sets remotely: -22 unsubscribes the subscription + /// by hand from the dashboard, -31 disables it through the REST API. Both mean "the server + /// turned this off" and are treated identically, but each is recorded as itself so outgoing + /// payloads echo back the code the server actually sent. + private static let remoteDisableCodes = [-22, -31] + + /// A push model hydrated with the server's remote disable state for `code`. + private func pushModelWithRemoteDisable(_ code: Int = -31) -> OSSubscriptionModel { + let model = OSSubscriptionModel( + type: .push, + address: "test-token", + subscriptionId: "test-sub-id", + reachable: true, + isDisabled: false, + changeNotifier: OSEventProducer() + ) + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "enabled": false, "notification_types": code]) + return model + } + + func testRemoteDisable_recognizesOnlyTheTwoServerOwnedCodes() { + for code in Self.remoteDisableCodes { + XCTAssertTrue( + OSRemoteDisable.matches(code), + "\(code) is a code the app owner sets remotely and must suppress outgoing payloads" + ) + } + // Every other code describes device or delivery state the device recovers from by + // re-asserting its own truth. Treating one as a remote disable would permanently suppress + // a subscription the device could have re-enabled, so the boundary matters more than the + // list: -21/-23/-24 sit inside the range reserved for other platforms, and -30/-32 + // bracket the REST API code. + for code in [1, 0, -2, -3, -13, -21, -23, -24, -25, -30, -32] { + XCTAssertFalse( + OSRemoteDisable.matches(code), + "\(code) is device-recoverable and must not be recorded as a remote disable" + ) + } + XCTAssertFalse(OSRemoteDisable.matches(nil)) + } + + func testRemoteDisable_overridesOutgoingSubscriptionPayloads() { + for code in Self.remoteDisableCodes { + let model = pushModelWithRemoteDisable(code) + XCTAssertEqual(model.remoteDisabledReason, code) + + // The recorded code round-trips rather than collapsing to the other one. + let json = model.jsonRepresentation() + XCTAssertEqual(json["enabled"] as? Bool, false) + XCTAssertEqual(json["notification_types"] as? Int, code) + + let updateRequest = OSRequestUpdateSubscription(subscriptionModel: model, identityModel: nil) + let params = updateRequest.parameters?["subscription"] as? [String: Any] + XCTAssertEqual(params?["enabled"] as? Bool, false) + XCTAssertEqual(params?["notification_types"] as? Int, code) + } + } + + func testRemoteDisable_survivesDeviceStateRefresh() { + for code in Self.remoteDisableCodes { + let model = pushModelWithRemoteDisable(code) + + // Device-driven recomputes must not clear server-owned disable state + model.updateNotificationTypes() + model.update() + + XCTAssertEqual(model.remoteDisabledReason, code) + XCTAssertEqual(model.jsonRepresentation()["enabled"] as? Bool, false) + } + } + + func testRemoteDisable_survivesArchiving() throws { + for code in Self.remoteDisableCodes { + let model = pushModelWithRemoteDisable(code) + + let data = try NSKeyedArchiver.archivedData(withRootObject: model, requiringSecureCoding: false) + let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) + unarchiver.requiresSecureCoding = false + let restored = try XCTUnwrap(unarchiver.decodeObject(forKey: NSKeyedArchiveRootObjectKey) as? OSSubscriptionModel) + + XCTAssertEqual(restored.remoteDisabledReason, code) + XCTAssertEqual(restored.jsonRepresentation()["enabled"] as? Bool, false) + } + } + + func testRemoteDisable_clearedByOptIn() { + for code in Self.remoteDisableCodes { + let model = pushModelWithRemoteDisable(code) + + model.clearRemoteDisable() + + XCTAssertNil(model.remoteDisabledReason) + let json = model.jsonRepresentation() + XCTAssertEqual(json["enabled"] as? Bool, true) + XCTAssertNotEqual(json["notification_types"] as? Int, code) + } + } + + func testRemoteDisable_mirrorsServerField() { + // Only -22 and -31 are ever recorded; any other reported value is not, and clears an + // existing disable. Each code is recorded as itself, including replacing the other one. + let model = OSSubscriptionModel( + type: .push, + address: "test-token", + subscriptionId: "test-sub-id", + reachable: true, + isDisabled: false, + changeNotifier: OSEventProducer() + ) + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "enabled": false, "notification_types": -2]) + XCTAssertNil(model.remoteDisabledReason) + + for code in Self.remoteDisableCodes { + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "enabled": false, "notification_types": code]) + XCTAssertEqual(model.remoteDisabledReason, code) + + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "enabled": false, "notification_types": -2]) + XCTAssertNil(model.remoteDisabledReason) + XCTAssertEqual(model.jsonRepresentation()["enabled"] as? Bool, true) + } + } + + func testRemoteDisable_switchingBetweenTheTwoCodesRecordsTheLatest() { + // The dashboard and the REST API can both act on the same subscription, so a second + // disable arriving under the other code must replace the recorded one rather than be + // ignored as "already disabled". + let model = pushModelWithRemoteDisable(-22) + XCTAssertEqual(model.remoteDisabledReason, -22) + + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "enabled": false, "notification_types": -31]) + + XCTAssertEqual(model.remoteDisabledReason, -31) + XCTAssertEqual(model.jsonRepresentation()["notification_types"] as? Int, -31) + } + + func testRemoteDisable_clearedWhenSubscriptionIdResets() { + // The disable code describes a specific server record; it must die with the record. + for code in Self.remoteDisableCodes { + let model = pushModelWithRemoteDisable(code) + + model.subscriptionId = nil + + XCTAssertNil(model.remoteDisabledReason) + XCTAssertEqual(model.jsonRepresentation()["enabled"] as? Bool, true) + } + } + + func testRemoteDisable_optInOutranksStaleHydration() { + for code in Self.remoteDisableCodes { + let model = pushModelWithRemoteDisable(code) + + // A stale fetch response landing after optIn() cleared the disable must not re-record it + model.clearRemoteDisable() + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "enabled": false, "notification_types": code]) + XCTAssertNil(model.remoteDisabledReason) + + // Once the server reports another state, recording re-arms for a later operator disable + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "enabled": true, "notification_types": 1]) + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "enabled": false, "notification_types": code]) + XCTAssertEqual(model.remoteDisabledReason, code) + } + } + + func testRemoteDisable_clearedWhenServerReportsEnabled() { + for code in Self.remoteDisableCodes { + let model = pushModelWithRemoteDisable(code) + + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "enabled": true, "notification_types": 1]) + + XCTAssertNil(model.remoteDisabledReason) + } + } + + /** + A push subscription the app owner turned off remotely must stay disabled across a login to a + different external ID. The fetch hydrates the server's disable state onto the existing + subscription, and the login's Create User payload echoes it back. + + Uses -22 (unsubscribed by hand from the dashboard) rather than -31 so this end-to-end path also + proves the exact recorded code survives, instead of every remote disable reporting as -31. + */ + func testLoginToDifferentUser_afterRemoteDisable_sendsDisabledPushSubscription() throws { + /* Setup */ + let client = MockOneSignalClient() + MockUserRequests.setDefaultCreateAnonUserResponses(with: client) + MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: userA_EUID) + MockUserRequests.setDefaultCreateUserResponses(with: client, externalId: userB_EUID) + + // Fetching user A reports the push subscription unsubscribed from the dashboard + var disabledResponse = MockUserRequests.testDefaultFullCreateUserResponse( + onesignalId: anonUserOSID, + externalId: userA_EUID, + subscriptionId: testPushSubId + ) + let disabledSub = MockUserRequests.testDefaultPushSubPayload(id: testPushSubId) + .merging(["enabled": false, "notification_types": -22]) { _, new in new } + disabledResponse["subscriptions"] = [disabledSub] + client.setMockResponseForRequest( + request: "", + response: disabledResponse + ) + OneSignalCoreImpl.setSharedClient(client) + + // 1. Start with an anonymous user and log in to user A; the post-identify fetch + // hydrates the remote disable onto the existing push subscription + OneSignalUserManagerImpl.sharedInstance.start() + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + + OneSignalCoreMocks.waitUntil("Fetch did not hydrate the remote disable") { + OneSignalUserManagerImpl.sharedInstance.user.identityModel.externalId == userA_EUID && + OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.remoteDisabledReason == -22 + } + + /* When */ + + // 2. Log in to user B, which sends a Create User carrying the push subscription + OneSignalUserManagerImpl.sharedInstance.login(externalId: userB_EUID, token: nil) + + func createUserBRequest() -> OSRequestCreateUser? { + return client.executedRequests.compactMap { $0 as? OSRequestCreateUser }.first { + ($0.parameters?["identity"] as? [String: String])?[OS_EXTERNAL_ID] == userB_EUID + } + } + OneSignalCoreMocks.waitUntil("Create User for user B was not sent") { + createUserBRequest() != nil + } + + /* Then */ + + let subscriptions = try XCTUnwrap(createUserBRequest()?.parameters?["subscriptions"] as? [[String: Any]]) + XCTAssertEqual(subscriptions.first?["enabled"] as? Bool, false) + XCTAssertEqual(subscriptions.first?["notification_types"] as? Int, -22) + } +} + + +/** + `optedIn` is the only signal the public API gives an app for "will push reach this device", so a + subscription the app owner turned off remotely has to report false there. Kept in its own class + because these build bare models and never touch the user manager singleton. + */ +final class RemoteDisableOptedInTests: XCTestCase { + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + // Firing a push subscription change reaches the user manager singleton and can enqueue a + // delta, so reset it between tests the way the rest of this suite does. + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + } + + /// The notification_types codes the app owner sets remotely: -22 from the dashboard, -31 through + /// the REST API. Treated identically, recorded separately. + private static let remoteDisableCodes = [-22, -31] + + private func makePushModel() -> OSSubscriptionModel { + return OSSubscriptionModel( + type: .push, + address: "test-token", + subscriptionId: "test-sub-id", + reachable: true, + isDisabled: false, + changeNotifier: OSEventProducer() + ) + } + + private func pushModelWithRemoteDisable(_ code: Int) -> OSSubscriptionModel { + let model = makePushModel() + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "enabled": false, "notification_types": code]) + return model + } + + func testRemoteDisable_reportsOptedInFalse() { + // A remote disable suppresses delivery, so the property clients read to decide whether push + // works must say so. Before this, a preference center showed "subscribed" on a device the + // app owner had turned off, and nothing in the public API revealed why. + for code in Self.remoteDisableCodes { + let model = pushModelWithRemoteDisable(code) + + XCTAssertFalse(model.optedIn, "a \(code) disable must report optedIn false") + // currentPushSubscriptionState builds the observer payload, so it has to agree. + XCTAssertFalse(model.currentPushSubscriptionState.optedIn) + + // The toggle a client drives off is not a dead end: opting in reports true again. + model.clearRemoteDisable() + XCTAssertTrue(model.optedIn) + XCTAssertTrue(model.currentPushSubscriptionState.optedIn) + } + } + + func testRemoteDisable_optedInIgnoresDeviceRecoverableCodes() { + // Only the two server-owned codes reach optedIn, and they arrive through + // remoteDisabledReason rather than notificationTypes. A device-side delivery error is + // recoverable by re-asserting local state, so it must not read as an opt-out to the app. + let model = makePushModel() + + for code in [-2, -13, -25, -30, -32] { + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "notification_types": code]) + XCTAssertTrue(model.optedIn, "\(code) is device-recoverable and must not clear optedIn") + } + } + + func testRemoteDisable_hydrationDoesNotEnqueueAnEnabledDelta() { + // Hydrating the disable flips optedIn, which observers need to hear about, but the server + // is where the state came from. Enqueueing an `enabled` delta for it would send a request + // telling the server what it just told us, so the observer fires without one. + for code in Self.remoteDisableCodes { + let model = makePushModel() + let spy = SpyModelChangedHandler() + model.changeNotifier.subscribe(spy) + + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "notification_types": code]) + XCTAssertEqual(model.remoteDisabledReason, code) + XCTAssertTrue(spy.serverUpdates.isEmpty, "hydrating \(code) must not enqueue a delta") + // The reason was still written, so the assertion above is not passing vacuously. + XCTAssertTrue(spy.hydratedUpdates.contains("remoteDisabledReason")) + + // Clearing it from the server side is the same story. + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "notification_types": -2]) + XCTAssertNil(model.remoteDisabledReason) + XCTAssertFalse(spy.serverUpdates.contains("enabled")) + + // An optIn() clear is the opposite case: the delta is how the server re-enables it. + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "notification_types": code]) + spy.serverUpdates.removeAll() + model.clearRemoteDisable() + XCTAssertTrue(spy.serverUpdates.contains("enabled"), "optIn() must re-enable on the server") + } + } + + func testRemoteDisable_emailAndSmsHydrateWithoutRecordingOrNotifyingPush() { + // The server writes the same codes on email and SMS rows the app owner disables, and those + // models hydrate through the same path. The remote disable state and the push observer it + // drives are push-only, so an email row must not record one or reach push observers with an + // email address as the token. + let observer = SpyPushSubscriptionObserver() + OneSignalUserManagerImpl.sharedInstance.pushSubscriptionImpl.addObserver(observer) + defer { OneSignalUserManagerImpl.sharedInstance.pushSubscriptionImpl.removeObserver(observer) } + + for (type, address) in [(OSSubscriptionType.email, "person@example.com"), (.sms, "+15555550100")] { + for code in Self.remoteDisableCodes { + let model = OSSubscriptionModel( + type: type, + address: address, + subscriptionId: "test-other-sub-id", + reachable: true, + isDisabled: false, + changeNotifier: OSEventProducer() + ) + + model.hydrate([ + "id": "test-other-sub-id", "type": type.rawValue, "token": address, "enabled": false, "notification_types": code + ]) + + XCTAssertNil(model.remoteDisabledReason, "\(type.rawValue) must not record \(code)") + XCTAssertEqual(model.notificationTypes, code) + XCTAssertTrue(model._isDisabled, "\(type.rawValue) keeps the plain enabled mapping") + } + } + + // Observer callbacks hop to the main queue, so let anything queued arrive before asserting nothing did. + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + XCTAssertTrue(observer.changes.isEmpty, "email and SMS hydration must not reach push observers") + } + + func testRemoteDisable_resetEventReportsThePreviousStateAsDisabled() throws { + // Observers see the reset as one change: the disabled record with its ID, then no record and + // no disable. Clearing the code before building the event made the previous state read as + // opted in, so the app never saw the disable end. + for code in Self.remoteDisableCodes { + let model = pushModelWithRemoteDisable(code) + let observer = SpyPushSubscriptionObserver() + OneSignalUserManagerImpl.sharedInstance.pushSubscriptionImpl.addObserver(observer) + defer { OneSignalUserManagerImpl.sharedInstance.pushSubscriptionImpl.removeObserver(observer) } + let spy = SpyModelChangedHandler() + model.changeNotifier.subscribe(spy) + + model.subscriptionId = nil + + OneSignalCoreMocks.waitUntil("the reset did not reach push observers") { !observer.changes.isEmpty } + let change = try XCTUnwrap(observer.changes.last) + XCTAssertEqual(change.previous.id, "test-sub-id") + XCTAssertFalse(change.previous.optedIn, "the record was disabled by \(code) until it went away") + XCTAssertNil(change.current.id) + XCTAssertTrue(change.current.optedIn) + // The new record's state travels with the create request, not as a delta against the dead ID. + XCTAssertFalse(spy.serverUpdates.contains("enabled")) + } + } + + func testRemoteDisable_optInFromOptedOutOutranksStaleHydration() { + // The customer's first disable is the common shape of this race. The customer disables the + // subscription, a fetch goes out that will report it, and an opted-out user opts in before + // that response lands. Nothing is recorded locally yet, but the opt-in changed the device + // state and a re-enable is on its way, so the stale response must not record over it. + for code in Self.remoteDisableCodes { + let model = OSSubscriptionModel( + type: .push, + address: "test-token", + subscriptionId: "test-sub-id", + reachable: true, + isDisabled: true, + changeNotifier: OSEventProducer() + ) + + model.clearRemoteDisable() + + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "enabled": false, "notification_types": code]) + XCTAssertNil(model.remoteDisabledReason, "a fetch predating optIn() must not record \(code)") + } + } + + func testRemoteDisable_optInWithoutPermissionOutranksStaleHydration() { + // A user who never granted permission taps opt-in while a fetch that will report the + // customer's first disable is in flight. Nothing changes locally yet, but optIn() is about + // to prompt for permission, and the grant is the write that re-enables the subscription. + // Without the flag the stale response would record the disable first, and the grant's + // update would carry it instead of enabling the subscription the user just asked for. + for code in Self.remoteDisableCodes { + let model = OSSubscriptionModel( + type: .push, + address: "test-token", + subscriptionId: "test-sub-id", + reachable: false, + isDisabled: false, + changeNotifier: OSEventProducer() + ) + + model.clearRemoteDisable() + + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "enabled": false, "notification_types": code]) + XCTAssertNil(model.remoteDisabledReason, "a fetch predating optIn() must not record \(code)") + } + } + + func testRemoteDisable_optInThatChangesNothingDoesNotOutrankALaterDisable() { + // Many apps call optIn() on every launch. A call that finds the user already opted in with + // nothing recorded sends nothing, so it has no intent to protect. Arming the guard for it + // would make every fetch that reports a disable get ignored until the process died, and the + // next routine update would re-enable the subscription, which is the bug this branch fixes. + for code in Self.remoteDisableCodes { + let model = OSSubscriptionModel( + type: .push, + address: "test-token", + subscriptionId: "test-sub-id", + reachable: true, + isDisabled: false, + changeNotifier: OSEventProducer() + ) + + model.clearRemoteDisable() + + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "enabled": false, "notification_types": code]) + XCTAssertEqual(model.remoteDisabledReason, code, "a disable landing after a no-op optIn() must be recorded") + XCTAssertEqual(model.jsonRepresentation()["enabled"] as? Bool, false) + } + } +} + +/// Records the properties a model reported as changed, split by whether the change was meant to +/// reach the server. `hydrating` is true for writes that only mirror state the server already has. +private class SpyPushSubscriptionObserver: NSObject, OSPushSubscriptionObserver { + var changes: [OSPushSubscriptionChangedState] = [] + + func onPushSubscriptionDidChange(state: OSPushSubscriptionChangedState) { + changes.append(state) + } +} + +private class SpyModelChangedHandler: OSModelChangedHandler { + var serverUpdates: [String] = [] + var hydratedUpdates: [String] = [] + + func onModelUpdated(args: OSModelChangedArgs, hydrating: Bool) { + if hydrating { + hydratedUpdates.append(args.property) + } else { + serverUpdates.append(args.property) + } + } } From 809d6ad2ba218571e6015ee231de9d4a5d9d3e4c Mon Sep 17 00:00:00 2001 From: Fadi George Date: Fri, 18 Sep 2026 11:02:15 -0700 Subject: [PATCH 11/25] fix: [SDK-5202] canonicalize Chinese locales and make setLanguage("") use device default (#1746) Co-authored-by: Cursor (cherry picked from commit f320db9953dbe4745b0b764b8bff400e781bf4ce) --- MIGRATION_GUIDE.md | 2 +- .../demo/App/Services/OneSignalService.swift | 5 + .../App/Services/PreferencesService.swift | 6 + .../App/ViewModels/OneSignalViewModel.swift | 8 ++ .../demo/App/Views/Sections/UserSection.swift | 130 ++++++++++++++++-- .../Controller/OSInAppMessageController.m | 24 ++-- .../IAMIntegrationTests.swift | 31 +++++ ...SignalInAppMessagesTests-Bridging-Header.h | 1 + .../Source/OSPropertiesModel.swift | 69 ++++++++-- .../Source/OneSignalUserManagerImpl.swift | 24 ++-- .../OneSignalUserTests.swift | 36 +++++ 11 files changed, 300 insertions(+), 36 deletions(-) diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 27b27e27a..753c20bef 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -333,7 +333,7 @@ The User name space is accessible via `OneSignal.User` and provides access to us | **Swift** | **Objective-C** | **Description** | | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `OneSignal.User.setLanguage("en")` | `[OneSignal.User setLanguage:@"en"]` | *Set the 2-character language for this user.* | +| `OneSignal.User.setLanguage("en")` | `[OneSignal.User setLanguage:@"en"]` | *Set the language tag for this user. Pass an empty string to restore the device language.* | | `let pushSubscriptionProperty = OneSignal.User.pushSubscription.` | `id pushSubscriptionProperty = OneSignal.User.pushSubscription.` | *The push subscription associated to the current user. Please refer to the Push Subscription Namespace API below for additional details.* | | `let id: String? = OneSignal.User.onesignalId` | `NSString* id = OneSignal.User.onesignalId` | *Returns the nullable OneSignal ID for the current user.* | | `let id: String? = OneSignal.User.externalId` | `NSString* id = OneSignal.User.externalId` | *Returns the nullable external ID for the current user.* | diff --git a/examples/demo/App/Services/OneSignalService.swift b/examples/demo/App/Services/OneSignalService.swift index d55fa0df8..d29757e0e 100644 --- a/examples/demo/App/Services/OneSignalService.swift +++ b/examples/demo/App/Services/OneSignalService.swift @@ -124,6 +124,11 @@ final class OneSignalService { OneSignal.logout() } + func setLanguage(_ language: String) { + prefs.setLanguage(language) + OneSignal.User.setLanguage(language) + } + /// Called when the SDK rejects the token, so the REST fetch stops sending it. func clearSessionJwtToken() { prefs.setSessionJwtToken(nil) diff --git a/examples/demo/App/Services/PreferencesService.swift b/examples/demo/App/Services/PreferencesService.swift index f10e31358..0420121d0 100644 --- a/examples/demo/App/Services/PreferencesService.swift +++ b/examples/demo/App/Services/PreferencesService.swift @@ -49,6 +49,7 @@ final class PreferencesService { static let externalUserId = "onesignal.demo.externalUserId" static let useIdentityVerification = "onesignal.demo.useIdentityVerification" static let sessionJwtToken = "onesignal.demo.sessionJwtToken" + static let language = "onesignal.demo.language" } // MARK: - Consent @@ -105,4 +106,9 @@ final class PreferencesService { defaults.removeObject(forKey: Key.sessionJwtToken) } } + + // MARK: - Language + + func getLanguage() -> String { defaults.string(forKey: Key.language) ?? "" } + func setLanguage(_ value: String) { defaults.set(value, forKey: Key.language) } } diff --git a/examples/demo/App/ViewModels/OneSignalViewModel.swift b/examples/demo/App/ViewModels/OneSignalViewModel.swift index 14fdb8330..0dfc20b5c 100644 --- a/examples/demo/App/ViewModels/OneSignalViewModel.swift +++ b/examples/demo/App/ViewModels/OneSignalViewModel.swift @@ -42,6 +42,7 @@ final class OneSignalViewModel: ObservableObject { // MARK: - Identity @Published var externalUserId: String? + @Published var language: String @Published var aliases: [KeyValueItem] = [] @Published var useIdentityVerification: Bool = false /// External id the SDK is waiting on a JWT for. Drives the banner in `UserSection`. @@ -106,6 +107,7 @@ final class OneSignalViewModel: ObservableObject { self.consentGiven = service.consentGiven self.useIdentityVerification = service.useIdentityVerification self.externalUserId = service.externalId ?? prefs.getExternalUserId() + self.language = prefs.getLanguage() self.hasNotificationPermission = service.hasNotificationPermission refreshState() setupObservers() @@ -253,9 +255,15 @@ final class OneSignalViewModel: ObservableObject { service.logout() externalUserId = nil jwtAskExternalId = nil + setLanguage("") clearUserData() } + func setLanguage(_ language: String) { + service.setLanguage(language) + self.language = language + } + func setUseIdentityVerification(_ enabled: Bool) { useIdentityVerification = enabled service.useIdentityVerification = enabled diff --git a/examples/demo/App/Views/Sections/UserSection.swift b/examples/demo/App/Views/Sections/UserSection.swift index 2ac39b9bf..9eed1b662 100644 --- a/examples/demo/App/Views/Sections/UserSection.swift +++ b/examples/demo/App/Views/Sections/UserSection.swift @@ -27,6 +27,60 @@ import SwiftUI +private struct LanguageOption: Identifiable { + let name: String + let code: String + + var id: String { code } +} + +private let languageOptions = [ + LanguageOption(name: "Device Default", code: ""), + LanguageOption(name: "English", code: "en"), + LanguageOption(name: "Arabic", code: "ar"), + LanguageOption(name: "Azerbaijani", code: "az"), + LanguageOption(name: "Bosnian", code: "bs"), + LanguageOption(name: "Catalan", code: "ca"), + LanguageOption(name: "Chinese (Simplified)", code: "zh-Hans"), + LanguageOption(name: "Chinese (Traditional)", code: "zh-Hant"), + LanguageOption(name: "Croatian", code: "hr"), + LanguageOption(name: "Czech", code: "cs"), + LanguageOption(name: "Danish", code: "da"), + LanguageOption(name: "Dutch", code: "nl"), + LanguageOption(name: "Estonian", code: "et"), + LanguageOption(name: "Finnish", code: "fi"), + LanguageOption(name: "French", code: "fr"), + LanguageOption(name: "Georgian", code: "ka"), + LanguageOption(name: "Bulgarian", code: "bg"), + LanguageOption(name: "German", code: "de"), + LanguageOption(name: "Greek", code: "el"), + LanguageOption(name: "Hindi", code: "hi"), + LanguageOption(name: "Hebrew", code: "he"), + LanguageOption(name: "Hungarian", code: "hu"), + LanguageOption(name: "Indonesian", code: "id"), + LanguageOption(name: "Italian", code: "it"), + LanguageOption(name: "Japanese", code: "ja"), + LanguageOption(name: "Korean", code: "ko"), + LanguageOption(name: "Latvian", code: "lv"), + LanguageOption(name: "Lithuanian", code: "lt"), + LanguageOption(name: "Malay", code: "ms"), + LanguageOption(name: "Norwegian", code: "nb"), + LanguageOption(name: "Persian", code: "fa"), + LanguageOption(name: "Polish", code: "pl"), + LanguageOption(name: "Portuguese", code: "pt"), + LanguageOption(name: "Punjabi", code: "pa"), + LanguageOption(name: "Romanian", code: "ro"), + LanguageOption(name: "Russian", code: "ru"), + LanguageOption(name: "Serbian", code: "sr"), + LanguageOption(name: "Slovak", code: "sk"), + LanguageOption(name: "Spanish", code: "es"), + LanguageOption(name: "Swedish", code: "sv"), + LanguageOption(name: "Thai", code: "th"), + LanguageOption(name: "Turkish", code: "tr"), + LanguageOption(name: "Ukrainian", code: "uk"), + LanguageOption(name: "Vietnamese", code: "vi") +] + /// Login/logout + JWT / Identity Verification controls for manual testing. struct UserSection: View { @EnvironmentObject var viewModel: OneSignalViewModel @@ -46,24 +100,39 @@ struct UserSection: View { accessibilityID: "identity_verification_toggle" ) - ValueCard(rows: [ - ValueCard.Row( + VStack(spacing: 0) { + InfoRow( label: "Status", value: viewModel.isLoggedIn ? "Logged In" : "Anonymous", valueAccessibilityID: "user_status_value" - ), - ValueCard.Row( + ) + .padding(.vertical, 4) + + divider + + InfoRow( label: "External ID", value: viewModel.externalUserId ?? "—", valueAccessibilityID: "user_external_id_value", - monospaced: true - ), - ValueCard.Row( + isMonospaced: true + ) + .padding(.vertical, 4) + + divider + + InfoRow( label: "REST fetch", value: viewModel.userFetchStatus ?? "—", valueAccessibilityID: "user_fetch_status_value" ) - ]) + .padding(.vertical, 4) + + divider + + languageMenu + .padding(.vertical, 4) + } + .osCard() // Shown instead of auto-feeding the stored token, so the SDK's ask is visible. if let askedId = viewModel.jwtAskExternalId { @@ -141,4 +210,49 @@ struct UserSection: View { } } } + + private var divider: some View { + Rectangle() + .fill(OS.Color.divider) + .frame(height: OS.Layout.dividerHeight) + .padding(.vertical, 4) + } + + private var languageMenu: some View { + HStack(alignment: .center, spacing: 12) { + Text("Language") + .font(OS.Font.bodyMedium) + .foregroundColor(OS.Color.bodyText) + + Spacer(minLength: 0) + + Menu { + ForEach(languageOptions) { option in + Button { + viewModel.setLanguage(option.code) + } label: { + if option.code == viewModel.language { + Label(option.name, systemImage: "checkmark") + } else { + Text(option.name) + } + } + } + } label: { + HStack(spacing: 4) { + Text(selectedLanguageName) + .font(OS.Font.bodySmall) + .foregroundColor(OS.Color.bodyText) + Image(systemName: "chevron.down") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(OS.Color.grey600) + } + } + .accessibilityIdentifier("user_language_dropdown") + } + } + + private var selectedLanguageName: String { + languageOptions.first { $0.code == viewModel.language }?.name ?? viewModel.language + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSInAppMessageController.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSInAppMessageController.m index 4609ea4d9..2bbf5a9fe 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSInAppMessageController.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSInAppMessageController.m @@ -69,21 +69,27 @@ variant over lower platforms (ie. 'all') even if they have a matching language. */ - (NSString * _Nullable)variantId { - // we only want the first two characters, ie. "en-US" we want "en" - NSString *userLanguageCode = [OneSignalUserManagerImpl.sharedInstance.language substringToIndex:2]; + NSString *userLanguage = OneSignalUserManagerImpl.sharedInstance.language; + NSString *baseLanguage = [[userLanguage componentsSeparatedByString:@"-"] firstObject]; NSString *variantId; for (NSString *type in PREFERRED_VARIANT_ORDER) { - if (self.variants[type]) { - if (self.variants[type][userLanguageCode]) { - variantId = self.variants[type][userLanguageCode]; - break; + NSDictionary *languageVariants = self.variants[type]; + if (languageVariants) { + if (userLanguage) { + variantId = languageVariants[userLanguage]; + } + + if (!variantId + && baseLanguage + && ![baseLanguage isEqualToString:@"zh"] + && ![baseLanguage isEqualToString:userLanguage]) { + variantId = languageVariants[baseLanguage]; } - if (!variantId && self.variants[type][@"default"]) { - variantId = self.variants[type][@"default"]; - break; + if (!variantId) { + variantId = languageVariants[@"default"]; } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift index 25f2bc264..ff466300f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift @@ -50,6 +50,37 @@ final class IAMIntegrationTests: XCTestCase { override func tearDownWithError() throws { } + func testLanguageVariantSelection() throws { + OneSignalIdentifiers.currentAppId = "test-app-id" + _ = OneSignalUserMocks.setUserManagerInternalUser(onesignalId: testOneSignalId) + + var messageJson = IAMTestHelpers.testDefaultMessageJson() + messageJson["variants"] = [ + "ios": [ + "default": "default-variant", + "en": "english-variant", + "zh-Hans": "simplified-chinese-variant", + "zh-Hant": "traditional-chinese-variant" + ], + "all": [ + "zh-Hans": "lower-priority-variant" + ] + ] + let message = try XCTUnwrap(OSInAppMessageInternal.instance(withJson: messageJson)) + + let testCases = [ + ("zh-Hans", "simplified-chinese-variant"), + ("zh-Hant", "traditional-chinese-variant"), + ("en-US", "english-variant"), + ("kl", "default-variant") + ] + + for (language, expectedVariant) in testCases { + OneSignalUserManagerImpl.sharedInstance.setLanguage(language) + XCTAssertEqual(message.variantId(), expectedVariant, language) + } + } + /** Test IAMs should display even when IAMs are paused. */ diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h index 8f671baae..53adad582 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h @@ -3,6 +3,7 @@ // #import "OSInAppMessageInternal.h" +#import "OSInAppMessageController.h" #import "OSMessagingController.h" #import "OSInAppMessagingRequests.h" diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModel.swift index 0b966f7a3..b22266f92 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModel.swift @@ -29,6 +29,64 @@ import Foundation import OneSignalOSCore import OneSignalCore +final class OSLanguageProviderDevice { + private let localeProvider: () -> Locale + + init(localeProvider: @escaping () -> Locale = { + Locale(identifier: NSLocale.preferredLanguages.first ?? DEFAULT_LANGUAGE) + }) { + self.localeProvider = localeProvider + } + + var language: String { + let locale = localeProvider() + if let chineseLanguage = chineseLanguage(locale) { + return chineseLanguage + } + guard let languageCode = languageCode(locale) else { + return DEFAULT_LANGUAGE + } + return languageCode + } + + private func chineseLanguage(_ locale: Locale) -> String? { + guard languageCode(locale) == "zh" else { + return nil + } + + switch scriptCode(locale) { + case "Hans": + return "zh-Hans" + case "Hant": + return "zh-Hant" + default: + let isTraditionalRegion = regionCode(locale).map { ["HK", "MO", "TW"].contains($0) } ?? false + return isTraditionalRegion ? "zh-Hant" : "zh-Hans" + } + } + + private func languageCode(_ locale: Locale) -> String? { + if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { + return locale.language.languageCode?.identifier + } + return locale.languageCode + } + + private func scriptCode(_ locale: Locale) -> String? { + if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { + return locale.language.script?.identifier + } + return locale.scriptCode + } + + private func regionCode(_ locale: Locale) -> String? { + if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { + return locale.region?.identifier + } + return locale.regionCode + } +} + // Both lat and long must exist to be accepted by the server class OSLocationPoint: NSObject, NSCoding { let lat: Float @@ -80,16 +138,7 @@ class OSPropertiesModel: OSModel { // We seem to lose access to this init() in superclass after adding init?(coder: NSCoder) override init(changeNotifier: OSEventProducer) { super.init(changeNotifier: changeNotifier) - self.language = getPreferredLanguage() - } - - private func getPreferredLanguage() -> String { - let preferredLanguages = NSLocale.preferredLanguages - if !preferredLanguages.isEmpty { - return preferredLanguages[0] - } else { - return DEFAULT_LANGUAGE - } + self.language = OSLanguageProviderDevice().language } override func encode(with coder: NSCoder) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index d24a010db..1db8529d1 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -74,7 +74,13 @@ import OneSignalNotifications // SMS func addSms(_ number: String) func removeSms(_ number: String) - // Language + /** + Sets the language for this user. + + See [Supported Languages](https://documentation.onesignal.com/docs/en/multi-language-messaging#supported-languages). + + - Parameter language: A supported language code, or an empty string to use the device default. + */ func setLanguage(_ language: String) // Events /** @@ -122,7 +128,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { } @objc public var language: String? { - return _user?.propertiesModel.language + return _user?.propertiesModel.language ?? OSLanguageProviderDevice().language } @objc public let pushSubscriptionImpl: OSPushSubscriptionImpl @@ -914,17 +920,19 @@ extension OneSignalUserManagerImpl: OSUser { self.subscriptionModelStore.remove(number) } + /** + Sets the language for this user. + + See [Supported Languages](https://documentation.onesignal.com/docs/en/multi-language-messaging#supported-languages). + + - Parameter language: A supported language code, or an empty string to use the device default. + */ public func setLanguage(_ language: String) { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "setLanguage") else { return } - if language == "" { - OneSignalLog.onesignalLog(.LL_ERROR, message: "OneSignal.User.setLanguage cannot be called with an empty language code.") - return - } - - user.setLanguage(language) + user.setLanguage(language.isEmpty ? OSLanguageProviderDevice().language : language) } public func trackEvent(name: String, properties: [String: Any]?) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift index e422de622..2a4f8ebb8 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift @@ -48,6 +48,42 @@ final class OneSignalUserTests: XCTestCase { override func tearDownWithError() throws { } + func testDeviceLanguageNormalization() { + let testCases = [ + ("zh-Hans-HK", "zh-Hans"), + ("zh-Hant-CN", "zh-Hant"), + ("zh-CN", "zh-Hans"), + ("zh-TW", "zh-Hant"), + ("zh-HK", "zh-Hant"), + ("zh-MO", "zh-Hant"), + ("zh", "zh-Hans"), + ("en-US", "en") + ] + + for (languageTag, expectedLanguage) in testCases { + let provider = OSLanguageProviderDevice { + Locale(identifier: languageTag) + } + XCTAssertEqual(provider.language, expectedLanguage, languageTag) + } + } + + func testEmptyLanguageUsesDeviceLanguage() { + let user = OneSignalUserMocks.setUserManagerInternalUser(onesignalId: "osid-a") + let manager = OneSignalUserManagerImpl.sharedInstance + let deviceLanguage = OSLanguageProviderDevice().language + let customLanguage = deviceLanguage == "de" ? "fr" : "de" + + manager.setLanguage(customLanguage) + XCTAssertEqual(user.propertiesModel.language, customLanguage) + XCTAssertEqual(manager.language, customLanguage) + + manager.setLanguage("") + + XCTAssertEqual(user.propertiesModel.language, deviceLanguage) + XCTAssertEqual(manager.language, deviceLanguage) + } + func testInternalOnesignalIdTracksCurrentUser() { let manager = OneSignalUserManagerImpl.sharedInstance OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") From 7df6890eea9778d5b06ceef6e6b0ef6cae5f9294 Mon Sep 17 00:00:00 2001 From: Nan Date: Fri, 18 Sep 2026 11:05:35 -0700 Subject: [PATCH 12/25] fix: [SDK-5181] accept a create-subscription success with no subscription object (#1748) (cherry picked from commit 90ada2af77638a32a294703cd13a659aeeb06195) --- .../OneSignal.xcodeproj/project.pbxproj | 4 + .../OSSubscriptionOperationExecutor.swift | 21 +- .../SubscriptionCreateResponseTests.swift | 198 ++++++++++++++++++ 3 files changed, 212 insertions(+), 11 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionCreateResponseTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 9c98cfc51..b69de6d9b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -199,6 +199,7 @@ 3CA8B8822BEC2FCB0010ADA1 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C7A39D42B7C18EE0082665E /* XCTest.framework */; }; 3CA8B8832BEC2FCB0010ADA1 /* XCTest.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3C7A39D42B7C18EE0082665E /* XCTest.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3CA93BC4300AEFFA000724B3 /* SubscriptionUpdateRaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA93BC3300AEFFA000724B3 /* SubscriptionUpdateRaceTests.swift */; }; + 3C5181A1B2C3D4E5F6A7B802 /* SubscriptionCreateResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C5181A1B2C3D4E5F6A7B801 /* SubscriptionCreateResponseTests.swift */; }; 3CA93BC7300B0100000724B3 /* SubscriptionModelConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA93BC6300B0100000724B3 /* SubscriptionModelConcurrencyTests.swift */; }; 3CAA4BB72F0BAFBA00A16682 /* TriggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CAA4BB62F0BAFBA00A16682 /* TriggerTests.swift */; }; 3CB331682F281679000E1801 /* CustomEventsIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CB331672F281679000E1801 /* CustomEventsIntegrationTests.swift */; }; @@ -1480,6 +1481,7 @@ 3C9AD6D22B228BB000BC1540 /* OSRequestUpdateProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRequestUpdateProperties.swift; sourceTree = ""; }; 3CA6CE0928E4F19B00CA0585 /* OSUserRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSUserRequest.swift; sourceTree = ""; }; 3CA93BC3300AEFFA000724B3 /* SubscriptionUpdateRaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionUpdateRaceTests.swift; sourceTree = ""; }; + 3C5181A1B2C3D4E5F6A7B801 /* SubscriptionCreateResponseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionCreateResponseTests.swift; sourceTree = ""; }; 3CA93BC6300B0100000724B3 /* SubscriptionModelConcurrencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionModelConcurrencyTests.swift; sourceTree = ""; }; 3CAA4BB62F0BAFBA00A16682 /* TriggerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerTests.swift; sourceTree = ""; }; 3CB331672F281679000E1801 /* CustomEventsIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEventsIntegrationTests.swift; sourceTree = ""; }; @@ -2550,6 +2552,7 @@ children = ( 3CF11E3C2C6D6155002856F5 /* UserExecutorTests.swift */, 3CA93BC3300AEFFA000724B3 /* SubscriptionUpdateRaceTests.swift */, + 3C5181A1B2C3D4E5F6A7B801 /* SubscriptionCreateResponseTests.swift */, 3CB331692F281692000E1801 /* OSCustomEventsExecutorTests.swift */, FE740F9E6B87D215510B5982 /* ExecutorAnonymousPurgeTests.swift */, ); @@ -4711,6 +4714,7 @@ 3CC890352C5BF9A7002CB4CC /* UserConcurrencyTests.swift in Sources */, 3CB3316A2F281692000E1801 /* OSCustomEventsExecutorTests.swift in Sources */, 3CA93BC4300AEFFA000724B3 /* SubscriptionUpdateRaceTests.swift in Sources */, + 3C5181A1B2C3D4E5F6A7B802 /* SubscriptionCreateResponseTests.swift in Sources */, 3CDE664C2BFC2A56006DA114 /* OneSignalUserObjcTests.m in Sources */, D465D9B81F58B242ADF14874 /* OSIdentityModelRepoTests.swift in Sources */, B5FBED8247288744EB484CB5 /* OSIdentityModelTests.swift in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift index 43d3f63b9..81745cbc4 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift @@ -357,18 +357,11 @@ extension OSSubscriptionOperationExecutor { self.addRequestQueue.removeAll(where: { $0 == request}) OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) - guard let response = response?["subscription"] as? [String: Any] else { - OneSignalLog.onesignalLog(.LL_ERROR, message: "Unabled to parse response to create subscription request") - if inBackground { - OSBackgroundTaskManager.endBackgroundTask(backgroundTaskIdentifier) - } - return - } - if let onesignalId = request.identityModel.onesignalId { - if let rywToken = response["ryw_token"] as? String + // ryw_token and ryw_delay are top-level fields, siblings of "subscription". + if let rywToken = response?["ryw_token"] as? String { - let rywDelay = response["ryw_delay"] as? NSNumber + let rywDelay = response?["ryw_delay"] as? NSNumber OSConsistencyManager.shared.setRywTokenAndDelay( id: onesignalId, key: OSIamFetchOffsetKey.subscriptionUpdate, @@ -380,7 +373,13 @@ extension OSSubscriptionOperationExecutor { } } - request.subscriptionModel.hydrate(response) + // A 2xx with no subscription object is the server's no-op for a subscription that already exists on this user. + if let subscription = response?["subscription"] as? [String: Any] { + request.subscriptionModel.hydrate(subscription) + } else { + let type = request.subscriptionModel.type.rawValue + OneSignalLog.onesignalLog(.LL_INFO, message: "Create \(type) subscription response has no subscription object to hydrate") + } if inBackground { OSBackgroundTaskManager.endBackgroundTask(backgroundTaskIdentifier) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionCreateResponseTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionCreateResponseTests.swift new file mode 100644 index 000000000..cf6edcd7a --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionCreateResponseTests.swift @@ -0,0 +1,198 @@ +/* + 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 OneSignalUserMocks +@testable import OneSignalOSCore +@testable import OneSignalUser + +/** + How the subscription executor handles a CreateSubscription success response. + */ +final class SubscriptionCreateResponseTests: XCTestCase { + + private let email = "test@example.com" + private let onesignalId = "test-onesignal-id" + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OSConsistencyManager.shared.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + OneSignalLog.setLogLevel(.LL_VERBOSE) + } + + override func tearDownWithError() throws { } + + /** + The server answers a create for a subscription that already exists on the user with a 2xx and no + subscription object. The request is finished, not retried, the model is left as it was, and a + fetch waiting on this user's read-your-write token is released because none is coming. + */ + func testResponseWithoutSubscription_completesWithoutHydratingAndReleasesWaiters() throws { + let client = MockOneSignalClient() + OneSignalCoreImpl.setSharedClient(client) + client.setMockResponseForRequest(request: createRequestKey, response: [:]) + + let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState(), auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) + let user = OneSignalUserMocks.setUserManagerInternalUser(onesignalId: onesignalId) + let model = makeEmailSubscriptionModel() + + // Only a resolve can release this waiter, so it stays blocked if the response is dropped instead of handled. + let released = expectation(description: "IAM fetch waiter was not released") + DispatchQueue.global().async { + _ = OSConsistencyManager.shared.getRywTokenFromAwaitableCondition(UnmetIamFetchCondition(), forId: self.onesignalId) + released.fulfill() + } + OneSignalCoreMocks.waitUntil("Waiter was not registered") { self.waiterCount(forId: self.onesignalId) == 1 } + + executor.enqueueDelta(addDelta(for: model, identityModel: user.identityModel)) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("Create subscription request did not complete") { + client.hasCompletedRequestOfType(OSRequestCreateSubscription.self) + } + waitForAddRequestQueueToDrain() + + XCTAssertTrue(client.allRequestsHandled) + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestCreateSubscription.self, expectedCount: 1)) + XCTAssertNil(model.subscriptionId) + wait(for: [released], timeout: 3.0) + } + + /** + `ryw_token` and `ryw_delay` are siblings of the subscription object, not fields of it. + */ + func testResponseWithSubscription_hydratesModelAndRecordsTopLevelRywToken() throws { + let client = MockOneSignalClient() + OneSignalCoreImpl.setSharedClient(client) + client.setMockResponseForRequest( + request: createRequestKey, + response: [ + "subscription": ["id": "email-subscription-id", "type": "Email", "token": email], + "ryw_token": "ryw-token-1", + "ryw_delay": 250 + ] + ) + + let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState(), auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) + let user = OneSignalUserMocks.setUserManagerInternalUser(onesignalId: onesignalId) + let model = makeEmailSubscriptionModel() + + executor.enqueueDelta(addDelta(for: model, identityModel: user.identityModel)) + executor.processDeltaQueue(inBackground: false) + // Hydration is the last step of the success handler, after the token is recorded. + OneSignalCoreMocks.waitUntil("Subscription model was not hydrated") { + model.subscriptionId == "email-subscription-id" + } + + XCTAssertTrue(client.allRequestsHandled) + let recorded = OSConsistencyManager.shared.getRywTokenFromAwaitableCondition( + SubscriptionUpdateTokenCondition(id: onesignalId), + forId: onesignalId + ) + XCTAssertEqual(recorded?.rywToken, "ryw-token-1") + XCTAssertEqual(recorded?.rywDelay?.intValue, 250) + } + + // MARK: - Helpers + + private var createRequestKey: String { + "" + } + + private func makeEmailSubscriptionModel() -> OSSubscriptionModel { + OSSubscriptionModel( + type: .email, + address: email, + subscriptionId: nil, + reachable: true, + isDisabled: false, + changeNotifier: OSEventProducer() + ) + } + + private func addDelta(for model: OSSubscriptionModel, identityModel: OSIdentityModel) -> OSDelta { + OSDelta( + name: OS_ADD_SUBSCRIPTION_DELTA, + identityModelId: identityModel.modelId, + externalId: identityModel.externalId, + model: model, + property: model.type.rawValue, + value: model.address ?? "" + ) + } + + private func waiterCount(forId id: String) -> Int { + OSConsistencyManager.shared.queue.sync { + OSConsistencyManager.shared.indexedConditions[id]?.count ?? 0 + } + } + + private func waitForAddRequestQueueToDrain() { + OneSignalCoreMocks.waitUntil("Create subscription request was not removed from the cache") { + let requests = OneSignalUserDefaults.initShared().getSavedCodeableData( + forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, + defaultValue: [] + ) as? [OSRequestCreateSubscription] + return requests?.isEmpty == true + } + } +} + +/// Reads back the subscription-update token recorded for an id without waiting on anything. +private final class SubscriptionUpdateTokenCondition: NSObject, OSCondition { + private let id: String + + init(id: String) { + self.id = id + } + + var conditionId: String { "SubscriptionUpdateTokenCondition" } + + func isMet(indexedTokens: [String: [NSNumber: OSReadYourWriteData]]) -> Bool { + true + } + + func getNewestToken(indexedTokens: [String: [NSNumber: OSReadYourWriteData]]) -> OSReadYourWriteData? { + indexedTokens[id]?[NSNumber(value: OSIamFetchOffsetKey.subscriptionUpdate.rawValue)] + } +} + +/// Never met on its own and carries the IAM fetch condition id, so only the executor's resolve releases it. +private final class UnmetIamFetchCondition: NSObject, OSCondition { + var conditionId: String { OSIamFetchReadyCondition.CONDITIONID } + + func isMet(indexedTokens: [String: [NSNumber: OSReadYourWriteData]]) -> Bool { + false + } + + func getNewestToken(indexedTokens: [String: [NSNumber: OSReadYourWriteData]]) -> OSReadYourWriteData? { + nil + } +} From ceb077a05661c3a3ce647440764200fa633d8bda Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 21 Sep 2026 08:43:32 -0700 Subject: [PATCH 13/25] fix: [SDK-5279] recreate the push subscription when the fetched user has none (#1750) (cherry picked from commit 8cc93a1b50e673466ed9fa698afcf1e78b80d16e) --- .../Source/Executors/OSUserExecutor.swift | 15 +-- .../OneSignalUserMocks.swift | 8 +- .../Executors/UserExecutorTests.swift | 107 +++++++++++++++++- 3 files changed, 117 insertions(+), 13 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift index a577b088a..9688b8809 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift @@ -563,17 +563,12 @@ extension OSUserExecutor { OneSignalUserManagerImpl.sharedInstance.clearUserData(user) self.parseFetchUserResponse(response: response, identityModel: request.identityModel, originalPushToken: OneSignalUserManagerImpl.sharedInstance.pushSubscriptionImpl.token) - // If this is a on-new-session's fetch user call, check that the subscription still exists + // If this is a on-new-session's fetch user call, check that the subscription still exists. + // A user with no subscriptions has no "subscriptions" key at all, so an absent key means none. if request.onNewSession, - let subId = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId, - let subscriptionObjects = self.parseSubscriptionObjectResponse(response) { - var subscriptionExists = false - for subModel in subscriptionObjects { - if subModel["id"] as? String == subId { - subscriptionExists = true - break - } - } + let subId = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId { + let subscriptionObjects = self.parseSubscriptionObjectResponse(response) ?? [] + let subscriptionExists = subscriptionObjects.contains { $0["id"] as? String == subId } if !subscriptionExists { // This subscription probably has been deleted diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift index 031b8ffb8..263be7f5f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift @@ -54,10 +54,14 @@ public class OneSignalUserMocks: NSObject { OneSignalUserManagerImpl.sharedInstance.reset() } - public static func setUserManagerInternalUser(externalId: String = "test-external-id", onesignalId: String?) -> OSUserInternal { + public static func setUserManagerInternalUser( + externalId: String = "test-external-id", + onesignalId: String?, + pushToken: String = "" + ) -> OSUserInternal { let user = OneSignalUserManagerImpl.sharedInstance.setNewInternalUser( externalId: externalId, - pushSubscriptionModel: OSSubscriptionModel(type: .push, address: "", subscriptionId: testPushSubId, reachable: false, isDisabled: false, changeNotifier: OSEventProducer()) + pushSubscriptionModel: OSSubscriptionModel(type: .push, address: pushToken, subscriptionId: testPushSubId, reachable: false, isDisabled: false, changeNotifier: OSEventProducer()) ) if let onesignalId = onesignalId { user.identityModel.addAliases([OS_ONESIGNAL_ID: onesignalId]) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift index 3f85a3774..27e5a82de 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift @@ -52,10 +52,22 @@ private class Mocks { let pushModel = OSSubscriptionModel(type: .push, address: "", subscriptionId: nil, reachable: false, isDisabled: false, changeNotifier: OSEventProducer()) return OSUserInternalImpl(identityModel: identityModel, propertiesModel: propertiesModel, pushSubscriptionModel: pushModel) } + + /// The on-new-session self-heal sends its Create through the user manager's subscription executor. + func installSubscriptionExecutor() -> OSSubscriptionOperationExecutor { + let executor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState, auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) + OneSignalUserManagerImpl.sharedInstance.subscriptionExecutor = executor + return executor + } } +private let recreatedPushSubId = "recreated-push-sub-id" + final class UserExecutorTests: XCTestCase { + /// Whatever executor the shared manager had before a test installed its own, restored in tearDown. + private var previousSubscriptionExecutor: OSSubscriptionOperationExecutor? + override func setUpWithError() throws { OneSignalCoreMocks.clearUserDefaults() OneSignalUserMocks.reset() @@ -63,9 +75,12 @@ final class UserExecutorTests: XCTestCase { OneSignalIdentifiers.currentAppId = "test-app-id" // Temp. logging to help debug during testing OneSignalLog.setLogLevel(.LL_VERBOSE) + previousSubscriptionExecutor = OneSignalUserManagerImpl.sharedInstance.subscriptionExecutor } - override func tearDownWithError() throws { } + override func tearDownWithError() throws { + OneSignalUserManagerImpl.sharedInstance.subscriptionExecutor = previousSubscriptionExecutor + } func testCreateUser_withPushSubscription_addsToNewRecords() { /* Setup */ @@ -324,6 +339,96 @@ final class UserExecutorTests: XCTestCase { XCTAssertEqual(currentUser.identityModel.externalId, userA_EUID) } + // MARK: - On-new-session push subscription self-heal + + /// Installs a user whose push subscription already has a server id, as after an earlier session. + private func setUpUserWithPushSubscription() -> OSUserInternal { + return OneSignalUserMocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID, pushToken: "push-token") + } + + private func stubRecreatedPushSubscription(_ mocks: Mocks) { + mocks.client.setMockResponseForRequest( + request: "", + response: ["subscription": MockUserRequests.testDefaultPushSubPayload(id: recreatedPushSubId)] + ) + } + + private func fetchUserOnNewSession(_ mocks: Mocks, user: OSUserInternal, response: [String: Any]) { + mocks.client.setMockResponseForRequest( + request: "", + response: response + ) + mocks.userExecutor.fetchUser(aliasLabel: OS_ONESIGNAL_ID, aliasId: userA_OSID, identityModel: user.identityModel, onNewSession: true) + OneSignalCoreMocks.waitUntil("Fetch user request did not complete") { + mocks.client.hasCompletedRequestOfType(OSRequestFetchUser.self) + } + } + + /** + A user whose only subscription was deleted server-side comes back with no "subscriptions" key at all. + The self-heal must still notice this device's push subscription is gone and re-create it. + */ + func testFetchUser_onNewSession_recreatesPushSubscription_whenResponseHasNoSubscriptionsKey() { + /* Setup */ + let mocks = Mocks() + let subscriptionExecutor = mocks.installSubscriptionExecutor() + let user = setUpUserWithPushSubscription() + stubRecreatedPushSubscription(mocks) + + /* When */ + fetchUserOnNewSession(mocks, user: user, response: MockUserRequests.testIdentityPayload(onesignalId: userA_OSID, externalId: userA_EUID)) + subscriptionExecutor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("Push subscription was not re-created") { + user.pushSubscriptionModel.subscriptionId == recreatedPushSubId + } + + /* Then */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCreateSubscription.self, expectedCount: 1)) + XCTAssertEqual(user.pushSubscriptionModel.subscriptionId, recreatedPushSubId) + } + + /** + The response lists other subscriptions but not this device's push subscription, so it is re-created. + */ + func testFetchUser_onNewSession_recreatesPushSubscription_whenResponseOmitsThisDevice() { + /* Setup */ + let mocks = Mocks() + let subscriptionExecutor = mocks.installSubscriptionExecutor() + let user = setUpUserWithPushSubscription() + stubRecreatedPushSubscription(mocks) + var response: [String: Any] = MockUserRequests.testIdentityPayload(onesignalId: userA_OSID, externalId: userA_EUID) + response["subscriptions"] = [["type": "Email", "id": "remote_email_id", "token": "remote_email@example.com"]] + + /* When */ + fetchUserOnNewSession(mocks, user: user, response: response) + subscriptionExecutor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("Push subscription was not re-created") { + user.pushSubscriptionModel.subscriptionId == recreatedPushSubId + } + + /* Then */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCreateSubscription.self, expectedCount: 1)) + XCTAssertEqual(user.pushSubscriptionModel.subscriptionId, recreatedPushSubId) + } + + /** + The response still lists this device's push subscription, so nothing is re-created. + */ + func testFetchUser_onNewSession_keepsPushSubscription_whenResponseContainsIt() { + /* Setup */ + let mocks = Mocks() + let user = setUpUserWithPushSubscription() + var response: [String: Any] = MockUserRequests.testIdentityPayload(onesignalId: userA_OSID, externalId: userA_EUID) + response["subscriptions"] = [MockUserRequests.testDefaultPushSubPayload(id: testPushSubId)] + + /* When */ + fetchUserOnNewSession(mocks, user: user, response: response) + + /* Then */ + // The self-heal clears the id before queuing its Create, so an unchanged id proves it did not run. + XCTAssertEqual(user.pushSubscriptionModel.subscriptionId, testPushSubId) + } + // MARK: - Identity Verification /// Cached Create User with no `external_id` must not go out once Identity Verification is required. From 836b340c4eb6246bfbc65ea18a56ad002b23e2f4 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 21 Sep 2026 10:55:11 -0700 Subject: [PATCH 14/25] chore: [SDK-5282] keep the JWT out of the log pipeline (#1747) --- .../Source/API/OneSignalClient.m | 18 +++++- .../OneSignalCoreTests/LoggingTests.swift | 56 +++++++++++++++++++ .../OneSignalCoreTests-Bridging-Header.h | 7 +++ .../Source/OneSignalUserManagerImpl+Jwt.swift | 6 +- .../OneSignalUserTests/UserJwtApiTests.swift | 28 ++++++++++ 5 files changed, 111 insertions(+), 4 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/API/OneSignalClient.m b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/API/OneSignalClient.m index 1733ecc3f..5cfee3305 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/API/OneSignalClient.m +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/API/OneSignalClient.m @@ -175,6 +175,20 @@ - (double)calculateReattemptDelay:(int)reattemptCount { return REATTEMPT_DELAY * pow(3, reattemptCount); } +- (NSDictionary *)loggableHeadersOfRequest:(OneSignalRequest *)request { + NSDictionary *headers = request.additionalHeaders; + if (!headers) { + return nil; + } + NSMutableDictionary *loggable = [headers mutableCopy]; + for (NSString *name in headers) { + if ([name caseInsensitiveCompare:@"Authorization"] == NSOrderedSame) { + loggable[name] = @""; + } + } + return loggable; +} + - (void)prettyPrintDebugStatementWithRequest:(OneSignalRequest *)request { if (![NSJSONSerialization isValidJSONObject:request.parameters]) return; @@ -190,7 +204,7 @@ - (void)prettyPrintDebugStatementWithRequest:(OneSignalRequest *)request { NSString *jsonString = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; - [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"HTTP Request (%@) with URL: %@, with parameters: %@ and headers: %@", NSStringFromClass([request class]), request.urlRequest.URL.absoluteString, jsonString, request.additionalHeaders]]; + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"HTTP Request (%@) with URL: %@, with parameters: %@ and headers: %@", NSStringFromClass([request class]), request.urlRequest.URL.absoluteString, jsonString, [self loggableHeadersOfRequest:request]]]; } - (void)handleJSONNSURLResponse:(NSURLResponse*)response data:(NSData*)data error:(NSError*)error isAsync:(BOOL)async withRequest:(OneSignalRequest *)request onSuccess:(OSResultSuccessBlock)successBlock onFailure:(OSClientFailureBlock)failureBlock { @@ -206,7 +220,7 @@ - (void)handleJSONNSURLResponse:(NSURLResponse*)response data:(NSData*)data erro innerJson[@"httpStatusCode"] = [NSNumber numberWithLong:statusCode]; innerJson[@"headers"] = headers; - [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"network request (%@) with URL %@ and headers: %@", NSStringFromClass([request class]), request.urlRequest.URL.absoluteString, request.additionalHeaders]]; + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"network request (%@) with URL %@ and headers: %@", NSStringFromClass([request class]), request.urlRequest.URL.absoluteString, [self loggableHeadersOfRequest:request]]]; [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"network response (%@) with URL %@: %@", NSStringFromClass([request class]), request.urlRequest.URL.absoluteString, innerJson]]; if (jsonError) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalCoreTests/LoggingTests.swift b/iOS_SDK/OneSignalSDK/OneSignalCoreTests/LoggingTests.swift index b0a2cf8fe..6a63f245a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCoreTests/LoggingTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalCoreTests/LoggingTests.swift @@ -169,4 +169,60 @@ final class LoggingTests: XCTestCase { XCTAssertEqual(sink.levels, [.LL_WARN]) XCTAssertEqual(sink.messages, ["raw message"]) } + + // MARK: - Credential redaction + + private let bearer = "jwt-a1b2c3" + + /// Headers as `OSRequestAuth` and the User module set them on a signed request. + private func signedRequest() -> OneSignalRequest { + let request = OneSignalRequest() + request.method = GET + request.path = "apps/test-app-id/users/by/external_id/user-a" + request.parameters = ["app_id": "test-app-id"] + request.additionalHeaders = ["Authorization": "Bearer \(bearer)", "OneSignal-Subscription-Id": "sub-a"] + return request + } + + /// The send log masks the bearer and nothing else. + func testSendingASignedRequestLogsTheHeadersWithTheBearerMasked() throws { + let listener = TestLogListener() + OneSignalLog.debug().__add(listener) + defer { OneSignalLog.debug().__remove(listener) } + let request = signedRequest() + + XCTAssertTrue(OneSignalClient.shared().validRequest(request)) + + let entry = try XCTUnwrap(listener.calls.first { $0.contains("HTTP Request") }) + XCTAssertFalse(entry.contains(bearer), entry) + XCTAssertTrue(entry.contains("Authorization = \"\""), entry) + XCTAssertTrue(entry.contains("sub-a"), entry) + XCTAssertFalse(listener.calls.contains { $0.contains(bearer) }, "\(listener.calls)") + XCTAssertEqual(request.urlRequest().value(forHTTPHeaderField: "Authorization"), "Bearer \(bearer)") + } + + /// The response log masks the bearer too. + func testHandlingAResponseLogsTheHeadersWithTheBearerMasked() throws { + let listener = TestLogListener() + OneSignalLog.debug().__add(listener) + defer { OneSignalLog.debug().__remove(listener) } + let request = signedRequest() + let url = try XCTUnwrap(request.urlRequest().url) + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil) + let body = try JSONSerialization.data(withJSONObject: ["onesignal_id": "osid-a"]) + var result: [AnyHashable: Any]? + + OneSignalClient.shared().handleJSONNSURLResponse( + response, data: body, error: nil, isAsync: false, with: request, + onSuccess: { result = $0 }, + onFailure: { XCTFail("\($0)") } + ) + + XCTAssertEqual(result?["onesignal_id"] as? String, "osid-a") + let entry = try XCTUnwrap(listener.calls.first { $0.contains("network request") }) + XCTAssertFalse(entry.contains(bearer), entry) + XCTAssertTrue(entry.contains("Authorization = \"\""), entry) + XCTAssertFalse(listener.calls.contains { $0.contains(bearer) }, "\(listener.calls)") + XCTAssertEqual(request.urlRequest().value(forHTTPHeaderField: "Authorization"), "Bearer \(bearer)") + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests-Bridging-Header.h b/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests-Bridging-Header.h index 1b2cb5d6d..f83146626 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests-Bridging-Header.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests-Bridging-Header.h @@ -2,3 +2,10 @@ // Use this file to import your target's public headers that you would like to expose to Swift. // +#import + +/// The client's log sites, exposed so tests can read what they print without a network round trip. +@interface OneSignalClient (LoggingTests) +- (BOOL)validRequest:(OneSignalRequest *)request; +- (void)handleJSONNSURLResponse:(NSURLResponse *)response data:(NSData *)data error:(NSError *)error isAsync:(BOOL)async withRequest:(OneSignalRequest *)request onSuccess:(OSResultSuccessBlock)successBlock onFailure:(OSClientFailureBlock)failureBlock; +@end diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift index 5a424932f..b99b855d5 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift @@ -103,8 +103,10 @@ extension OneSignalUserManagerImpl { OneSignalLog.onesignalLog(.LL_ERROR, message: "OneSignal.updateUserJwt called with empty externalId or an unusable token. This is not allowed.") return } - // TODO: omit the token from this log before shipping — keep for testing. - OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignal.updateUserJwt called for externalId: \(externalId) with token: \(token)") + OneSignalLog.onesignalLog( + .LL_VERBOSE, + message: "OneSignal.updateUserJwt called for externalId: \(externalId) with token: ...\(token.suffix(8))" + ) storeJwt(externalId: externalId, token: token) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtApiTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtApiTests.swift index f24b44667..6755b732d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtApiTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtApiTests.swift @@ -32,6 +32,19 @@ import OneSignalUserMocks @testable import OneSignalOSCore @testable import OneSignalUser +private final class CapturingLogListener: NSObject, OSLogListener { + private let lock = NSLock() + private var captured: [String] = [] + + var entries: [String] { + lock.withLock { captured } + } + + func onLogEvent(_ event: OneSignalLogEvent) { + lock.withLock { captured.append(event.entry) } + } +} + /** Public JWT surface on the User Manager: store a token, and tell the app when it stopped being accepted. Goes through `sharedInstance` because the JWT config and IV service are shared. @@ -113,6 +126,21 @@ final class UserJwtApiTests: XCTestCase { XCTAssertNil(user.identityModel.jwtBearerToken) } + /// The entry names the user and the token's last 8 characters, never more. + func testUpdateUserJwtLogsTheCallWithOnlyTheTokensTail() { + _ = OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") + let token = "eyJhbGciOi.eyJzdWIiOi.signature-a1b2c3d4" + let log = CapturingLogListener() + OneSignalLog.debug().__add(log) + defer { OneSignalLog.debug().__remove(log) } + + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: "user-a", token: token) + + let entry = log.entries.first { $0.contains("updateUserJwt") } + XCTAssertEqual(entry, "VERBOSE: OneSignal.updateUserJwt called for externalId: user-a with token: ...a1b2c3d4") + XCTAssertFalse(log.entries.contains { $0.contains("signature-a1b2c3d4") }, "\(log.entries)") + } + // MARK: - Invalidated listener func testInvalidatingAJwtParksTheTokenAndNotifiesTheApp() { From c2fb2725d5fb3f9921bda6fdaac8cf113d153f29 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 21 Sep 2026 13:47:53 -0700 Subject: [PATCH 15/25] chore: move OSSubscriptionModel device metadata properties into an extension The class body had grown to 362 lines, past SwiftLint's 350-line type_body_length error threshold, which fails the Swift Lint job on every pull request targeting 5.8-main. The six device and SDK metadata properties are plain passthroughs over the guarded state, so moving them to an extension in the same file drops the class body to 302 lines with no behavior change. Co-authored-by: Cursor --- .../Source/OSSubscriptionModel.swift | 136 +++++++++--------- 1 file changed, 69 insertions(+), 67 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift index bfd609401..26f829c11 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift @@ -382,73 +382,6 @@ class OSSubscriptionModel: OSModel { } } - // Properties for push subscription - var testType: Int? { - get { stateLock.withLock { state.testType } } - set { - let oldValue = swapValue(\.testType, to: newValue) - guard newValue != oldValue else { - return - } - self.set(property: "testType", newValue: newValue) - } - } - - var deviceOs: String { - get { stateLock.withLock { state.deviceOs } } - set { - let oldValue = swapValue(\.deviceOs, to: newValue) - guard newValue != oldValue else { - return - } - self.set(property: "deviceOs", newValue: newValue) - } - } - - var sdk: String { - get { stateLock.withLock { state.sdk } } - set { - let oldValue = swapValue(\.sdk, to: newValue) - guard newValue != oldValue else { - return - } - self.set(property: "sdk", newValue: newValue) - } - } - - var deviceModel: String? { - get { stateLock.withLock { state.deviceModel } } - set { - let oldValue = swapValue(\.deviceModel, to: newValue) - guard newValue != oldValue else { - return - } - self.set(property: "deviceModel", newValue: newValue) - } - } - - var appVersion: String? { - get { stateLock.withLock { state.appVersion } } - set { - let oldValue = swapValue(\.appVersion, to: newValue) - guard newValue != oldValue else { - return - } - self.set(property: "appVersion", newValue: newValue) - } - } - - var netType: Int? { - get { stateLock.withLock { state.netType } } - set { - let oldValue = swapValue(\.netType, to: newValue) - guard newValue != oldValue else { - return - } - self.set(property: "netType", newValue: newValue) - } - } - // When a Subscription is initialized, it may not have a subscriptionId until a request to the backend is made. init(type: OSSubscriptionType, address: String?, @@ -603,6 +536,75 @@ class OSSubscriptionModel: OSModel { } +// Device and SDK metadata reported alongside the subscription. +extension OSSubscriptionModel { + var testType: Int? { + get { stateLock.withLock { state.testType } } + set { + let oldValue = swapValue(\.testType, to: newValue) + guard newValue != oldValue else { + return + } + self.set(property: "testType", newValue: newValue) + } + } + + var deviceOs: String { + get { stateLock.withLock { state.deviceOs } } + set { + let oldValue = swapValue(\.deviceOs, to: newValue) + guard newValue != oldValue else { + return + } + self.set(property: "deviceOs", newValue: newValue) + } + } + + var sdk: String { + get { stateLock.withLock { state.sdk } } + set { + let oldValue = swapValue(\.sdk, to: newValue) + guard newValue != oldValue else { + return + } + self.set(property: "sdk", newValue: newValue) + } + } + + var deviceModel: String? { + get { stateLock.withLock { state.deviceModel } } + set { + let oldValue = swapValue(\.deviceModel, to: newValue) + guard newValue != oldValue else { + return + } + self.set(property: "deviceModel", newValue: newValue) + } + } + + var appVersion: String? { + get { stateLock.withLock { state.appVersion } } + set { + let oldValue = swapValue(\.appVersion, to: newValue) + guard newValue != oldValue else { + return + } + self.set(property: "appVersion", newValue: newValue) + } + } + + var netType: Int? { + get { stateLock.withLock { state.netType } } + set { + let oldValue = swapValue(\.netType, to: newValue) + guard newValue != oldValue else { + return + } + self.set(property: "netType", newValue: newValue) + } + } +} + // Push Subscription related extension OSSubscriptionModel { // Only used for the push subscription model From a317781a3578f8667788ca256cf763e3d0367bc7 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 22 Sep 2026 08:18:46 -0700 Subject: [PATCH 16/25] fix: [SDK-5137] Identity Verification config and token request follow-ups from the JWT review (#1743) --- .../OneSignal.xcodeproj/project.pbxproj | 4 + .../Source/Jwt/OSUserJwtConfig.swift | 32 +++- .../Jwt/OSUserJwtConfigTests.swift | 23 +++ .../Source/Executors/OSUserExecutor.swift | 5 +- .../OneSignalUser/Source/OSRequestAuth.swift | 5 +- .../OneSignalUser/Source/OSUserJwtRepo.swift | 16 +- .../Source/OneSignalUserManagerImpl+Jwt.swift | 64 ++++++- .../Source/OneSignalUserManagerImpl.swift | 30 ++-- .../OSUserJwtRepoTests.swift | 12 ++ .../OneSignalUserTests/UserJwtAskTests.swift | 167 ++++++++++++++++++ .../OneSignalSDK/Source/OneSignalFramework.h | 1 + .../Source/OneSignalSwiftInterface.swift | 1 + 12 files changed, 319 insertions(+), 41 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 b69de6d9b..4d5e52700 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -362,6 +362,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 */; }; @@ -1597,6 +1598,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 = ""; }; @@ -2535,6 +2537,7 @@ 047D8F5E1095A20C9C54FD33 /* OSRequestAuthTests.swift */, 3016921C1F6B7B7793F67567 /* RequestPathEncodingTests.swift */, 5BFE2F960129386AFA6D5F41 /* UserJwtLifecycleTests.swift */, + 3567F486AA4335CCA79D659B /* UserJwtAskTests.swift */, ); path = OneSignalUserTests; sourceTree = ""; @@ -4726,6 +4729,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/OneSignalOSCore/Source/Jwt/OSUserJwtConfig.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSUserJwtConfig.swift index c4ad37f66..c50b75a88 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)") } @@ -100,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 9688b8809..46365cae7 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 { 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 8adf8f164..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,11 +105,20 @@ 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) } } + /** + 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+Jwt.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift index b99b855d5..7f6c55729 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift @@ -28,6 +28,44 @@ 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) { + // 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) { + 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. @@ -51,28 +89,40 @@ 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) } /** 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) { - self.userJwtInvalidatedObserver.addObserver(listener) + self.userJwtInvalidatedListeners.add(listener) let pending = userJwtRepo.pendingTokenAsks() guard !pending.isEmpty else { return @@ -91,7 +141,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 1db8529d1..652c7836e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -189,17 +189,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 - } + // 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 userJwtInvalidatedListeners: OSUserJwtInvalidatedListeners // Model Stores let identityModelStore = OSModelStore(changeSubscription: OSEventProducer(), storeKey: OS_IDENTITY_MODEL_STORE_KEY).registerAsUserObserver() @@ -228,12 +221,13 @@ 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 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.sharedInstance.userJwtInvalidatedObserver.notifyChange(OSUserJwtInvalidatedEvent(externalId: externalId)) + userJwtInvalidatedListeners.notify(externalId: externalId) } self.identityVerificationService = identityVerificationService + self.userJwtInvalidatedListeners = userJwtInvalidatedListeners self.userJwtRepo = userJwtRepo self.requestAuth = OSRequestAuth(identityVerificationService: identityVerificationService, jwt: userJwtRepo) self.operationRepo = operationRepo @@ -456,8 +450,8 @@ 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 { + storeJwtOrRearmAsk(externalId: externalId, token: token) } userExecutor!.createUser(newUser) return newUser @@ -484,9 +478,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { let newUser = setNewInternalUser(externalId: externalId, pushSubscriptionModel: pushSubscriptionModel) // 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/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 new file mode 100644 index 000000000..e0f73016a --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtAskTests.swift @@ -0,0 +1,167 @@ +/* + 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 + +/// 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) { + 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 } } + } +} + +/** + 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() + + 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 { + // 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 + } + + // MARK: - an ask nobody hears + + /// 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()) + } + + listeners.notify(externalId: userA_EUID) + + 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 log = LogCapture() + OneSignalLog.debug().__add(log) + defer { OneSignalLog.debug().__remove(log) } + let listener = MockUserJwtInvalidatedListener() + let listeners = OSUserJwtInvalidatedListeners() + listeners.add(listener) + + 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) + XCTAssertTrue(warnings.isEmpty, "\(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] + } + } + + /// 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] + } + } +} 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 1a6720081a2392156508822e7e162226250ab302 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 22 Sep 2026 08:42:16 -0700 Subject: [PATCH 17/25] fix: [SDK-5137] in-app message fetch wait follow-ups from the JWT review (#1744) --- .../OneSignal.xcodeproj/project.pbxproj | 4 + .../Consistency/OSConsistencyManager.swift | 41 ++-- .../ConsistencyManagerTestHelpers.swift | 9 +- .../OSConsistencyManagerTests.swift | 43 ++-- .../OSIamFetchReadyConditionTests.swift | 97 ++++++++-- .../OSPropertyOperationExecutor.swift | 20 +- .../OSSubscriptionOperationExecutor.swift | 39 ++-- .../Source/Executors/OSUserExecutor.swift | 19 +- .../ExecutorReadYourWriteTests.swift | 183 ++++++++++++++++++ .../SubscriptionCreateResponseTests.swift | 33 +++- 10 files changed, 377 insertions(+), 111 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorReadYourWriteTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 4d5e52700..be8173de6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -199,6 +199,7 @@ 3CA8B8822BEC2FCB0010ADA1 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C7A39D42B7C18EE0082665E /* XCTest.framework */; }; 3CA8B8832BEC2FCB0010ADA1 /* XCTest.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3C7A39D42B7C18EE0082665E /* XCTest.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3CA93BC4300AEFFA000724B3 /* SubscriptionUpdateRaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA93BC3300AEFFA000724B3 /* SubscriptionUpdateRaceTests.swift */; }; + E85C1C500D87196C5E20144F /* ExecutorReadYourWriteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C95B2AD05FFF9FC12C9D5878 /* ExecutorReadYourWriteTests.swift */; }; 3C5181A1B2C3D4E5F6A7B802 /* SubscriptionCreateResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C5181A1B2C3D4E5F6A7B801 /* SubscriptionCreateResponseTests.swift */; }; 3CA93BC7300B0100000724B3 /* SubscriptionModelConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA93BC6300B0100000724B3 /* SubscriptionModelConcurrencyTests.swift */; }; 3CAA4BB72F0BAFBA00A16682 /* TriggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CAA4BB62F0BAFBA00A16682 /* TriggerTests.swift */; }; @@ -1482,6 +1483,7 @@ 3C9AD6D22B228BB000BC1540 /* OSRequestUpdateProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRequestUpdateProperties.swift; sourceTree = ""; }; 3CA6CE0928E4F19B00CA0585 /* OSUserRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSUserRequest.swift; sourceTree = ""; }; 3CA93BC3300AEFFA000724B3 /* SubscriptionUpdateRaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionUpdateRaceTests.swift; sourceTree = ""; }; + C95B2AD05FFF9FC12C9D5878 /* ExecutorReadYourWriteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecutorReadYourWriteTests.swift; sourceTree = ""; }; 3C5181A1B2C3D4E5F6A7B801 /* SubscriptionCreateResponseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionCreateResponseTests.swift; sourceTree = ""; }; 3CA93BC6300B0100000724B3 /* SubscriptionModelConcurrencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionModelConcurrencyTests.swift; sourceTree = ""; }; 3CAA4BB62F0BAFBA00A16682 /* TriggerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerTests.swift; sourceTree = ""; }; @@ -2555,6 +2557,7 @@ children = ( 3CF11E3C2C6D6155002856F5 /* UserExecutorTests.swift */, 3CA93BC3300AEFFA000724B3 /* SubscriptionUpdateRaceTests.swift */, + C95B2AD05FFF9FC12C9D5878 /* ExecutorReadYourWriteTests.swift */, 3C5181A1B2C3D4E5F6A7B801 /* SubscriptionCreateResponseTests.swift */, 3CB331692F281692000E1801 /* OSCustomEventsExecutorTests.swift */, FE740F9E6B87D215510B5982 /* ExecutorAnonymousPurgeTests.swift */, @@ -4717,6 +4720,7 @@ 3CC890352C5BF9A7002CB4CC /* UserConcurrencyTests.swift in Sources */, 3CB3316A2F281692000E1801 /* OSCustomEventsExecutorTests.swift in Sources */, 3CA93BC4300AEFFA000724B3 /* SubscriptionUpdateRaceTests.swift in Sources */, + E85C1C500D87196C5E20144F /* ExecutorReadYourWriteTests.swift in Sources */, 3C5181A1B2C3D4E5F6A7B802 /* SubscriptionCreateResponseTests.swift in Sources */, 3CDE664C2BFC2A56006DA114 /* OneSignalUserObjcTests.m in Sources */, D465D9B81F58B242ADF14874 /* OSIdentityModelRepoTests.swift in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift index 50de9c001..6e071ad5f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift @@ -33,10 +33,10 @@ import OneSignalCore @objc public static let shared = OSConsistencyManager() // Serial, and the only place `indexedTokens` and `indexedConditions` may be touched. - // Non-private so test helpers can synchronize with it. + // Neither is private, so test helpers can read them through it. let queue = DispatchQueue(label: "com.consistencyManager.queue") - private var indexedTokens: [String: [NSNumber: OSReadYourWriteData]] = [:] - // Waiters, indexed by the id passed to getRywTokenFromAwaitableCondition. Non-private for tests. + var indexedTokens: [String: [NSNumber: OSReadYourWriteData]] = [:] + // Waiters, indexed by the id passed to getRywTokenFromAwaitableCondition. var indexedConditions: [String: [(OSCondition, DispatchSemaphore)]] = [:] /** @@ -58,7 +58,12 @@ import OneSignalCore } } - // Function to set the token in a thread-safe manner + /** + Records the outcome of a write for `id` under the caller's key, and releases any waiter it satisfies. + A response with no `ryw_token` is filed as a blank entry: the write still completed, and a condition + that only needs it to have completed is met by the entry being there. A fetch that registers later + finds it on file, instead of waiting the full timeout for a token that is never coming. + */ public func setRywTokenAndDelay(id: String, key: any OSConsistencyKeyEnum, value: OSReadYourWriteData) { queue.sync { let nsKey = NSNumber(value: key.rawValue) @@ -73,21 +78,27 @@ import OneSignalCore /// Blocks the caller until the condition is met or `waitTimeout` elapses, then returns the newest /// token the condition accepts, which is nil when it was released without one. @objc public func getRywTokenFromAwaitableCondition(_ condition: OSCondition, forId id: String) -> OSReadYourWriteData? { + // The timeout in force when the waiter registers is the one it waits. + let timeout = OSConsistencyManager.waitTimeout let semaphore = DispatchSemaphore(value: 0) queue.sync { self.indexedConditions[id, default: []].append((condition, semaphore)) self.checkConditionsAndComplete(forId: id) } - if semaphore.wait(timeout: .now() + OSConsistencyManager.waitTimeout) == .timedOut { + if semaphore.wait(timeout: .now() + timeout) == .timedOut { OneSignalLog.onesignalLog(.LL_WARN, message: "OSConsistencyManager timed out waiting on \(condition.conditionId) for id: \(id)") queue.sync { // Skip if a met-path release already removed this waiter. guard self.indexedConditions[id]?.contains(where: { $0.1 === semaphore }) == true else { return } + // Deregister first, so the re-check below cannot release this waiter a second time. + self.indexedConditions[id]?.removeAll { $0.1 === semaphore } // Clear so later fetches for this id are not held to a subscription token that never arrives. condition.onConditionSatisfied?() - self.indexedConditions[id]?.removeAll { $0.1 === semaphore } + // The lowered bar can be all a sibling waiter on this id was missing, so measure the rest + // against it now instead of leaving them to sit out their own timeouts. + self.checkConditionsAndComplete(forId: id) } } return queue.sync { @@ -95,24 +106,6 @@ import OneSignalCore } } - /** - Releases waiters on `conditionId` registered under `id` (e.g. onesignalId). Used when that user's - response carried no `ryw_token`, so those waiters have nothing left to wait for. - */ - @objc(resolveConditionsWithConditionId:forId:) - public func resolveConditions(conditionId: String, forId id: String) { - queue.sync { - guard let waiters = self.indexedConditions[id] else { - return - } - for (condition, semaphore) in waiters where condition.conditionId == conditionId { - OneSignalLog.onesignalLog(.LL_INFO, message: "Condition \(conditionId) resolved for id: \(id)") - self.release(condition, semaphore) - } - self.indexedConditions[id] = waiters.filter { $0.0.conditionId != conditionId } - } - } - // Private method to check conditions for a specific id (unique ID like onesignalId) private func checkConditionsAndComplete(forId id: String) { guard let waiters = indexedConditions[id] else { return } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/ConsistencyManagerTestHelpers.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/ConsistencyManagerTestHelpers.swift index 8649a1a07..23a3037ab 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/ConsistencyManagerTestHelpers.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/ConsistencyManagerTestHelpers.swift @@ -25,7 +25,7 @@ THE SOFTWARE. */ -import OneSignalOSCore +@testable import OneSignalOSCore public class ConsistencyManagerTestHelpers { /// Clears both halves of the read-your-write state: the manager's tokens and waiters, and the @@ -44,3 +44,10 @@ public class ConsistencyManagerTestHelpers { OSConsistencyManager.shared.setRywTokenAndDelay(id: id, key: key, value: rywData) } } + +extension OSConsistencyManager { + /// The tokens on file right now, read through the manager's own queue. + public func snapshotTokens() -> [String: [NSNumber: OSReadYourWriteData]] { + return queue.sync { indexedTokens } + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift index e422a0e65..ba23f19c0 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift @@ -281,42 +281,51 @@ class OSConsistencyManagerTests: XCTestCase { XCTAssertEqual(rywData?.rywToken, "456") } - // MARK: - Releasing waiters + // MARK: - Writes that came back without a token - /// A response with no `ryw_token` releases waiters for that user only. - func testResolvingByConditionIdReleasesWaitersForThatId() { + private let blank = OSReadYourWriteData(rywToken: nil, rywDelay: nil) + + /// A write that completed with no `ryw_token` is filed like any other, so a waiter that registers + /// afterwards has nothing left to wait for. + func testAWaiterRegisteredAfterATokenlessWriteIsReleasedAtOnce() { + let id = "onesignal-id" + let key = NSNumber(value: OSIamFetchOffsetKey.userUpdate.rawValue) + consistencyManager.setRywTokenAndDelay(id: id, key: OSIamFetchOffsetKey.userUpdate, value: blank) + + let condition = TestMetCondition(expectedTokens: [id: [key: blank]]) let returned = expectation(description: "waiter returned") DispatchQueue.global().async { - _ = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: "onesignal-id") + _ = self.consistencyManager.getRywTokenFromAwaitableCondition(condition, forId: id) returned.fulfill() } - OneSignalCoreMocks.waitUntil("waiter registered") { self.consistencyManager.waiterCount == 1 } - - consistencyManager.resolveConditions(conditionId: TestUnmetCondition.CONDITIONID, forId: "onesignal-id") + // The timeout is the full 30 seconds here, so only a release brings the waiter back in time. wait(for: [returned], timeout: 2.0) XCTAssertEqual(consistencyManager.waiterCount, 0) } - /// Resolving user B leaves user A's waiter registered. - func testResolvingOneIdLeavesAnotherIdsWaiterWaiting() { + /// The entry is filed under its id, so user B's tokenless write does not release user A. + func testATokenlessWriteForOneIdLeavesAnotherIdsWaiterWaiting() { OSConsistencyManager.waitTimeout = .milliseconds(200) + let key = NSNumber(value: OSIamFetchOffsetKey.userUpdate.rawValue) let userA = "onesignal-id-a" let userB = "onesignal-id-b" + let conditionA = TestMetCondition(expectedTokens: [userA: [key: blank]]) + let conditionB = TestMetCondition(expectedTokens: [userB: [key: blank]]) let aReturned = expectation(description: "user A waiter returned") let bReturned = expectation(description: "user B waiter returned") DispatchQueue.global().async { - _ = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: userA) + _ = self.consistencyManager.getRywTokenFromAwaitableCondition(conditionA, forId: userA) aReturned.fulfill() } DispatchQueue.global().async { - _ = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: userB) + _ = self.consistencyManager.getRywTokenFromAwaitableCondition(conditionB, forId: userB) bReturned.fulfill() } OneSignalCoreMocks.waitUntil("both waiters registered") { self.consistencyManager.waiterCount == 2 } - consistencyManager.resolveConditions(conditionId: TestUnmetCondition.CONDITIONID, forId: userB) + consistencyManager.setRywTokenAndDelay(id: userB, key: OSIamFetchOffsetKey.userUpdate, value: blank) wait(for: [bReturned], timeout: 2.0) XCTAssertEqual(consistencyManager.waiterCount, 1, "user A's waiter must still be registered") @@ -324,16 +333,20 @@ class OSConsistencyManagerTests: XCTestCase { wait(for: [aReturned], timeout: 2.0) } - func testResolvingADifferentConditionLeavesTheWaiterWaiting() { + /// The entry is filed under the writer's key, so a waiter that needs a different write keeps waiting. + func testATokenlessWriteUnderAnotherKeyLeavesTheWaiterWaiting() { OSConsistencyManager.waitTimeout = .milliseconds(200) + let id = "onesignal-id" + let subscriptionKey = NSNumber(value: OSIamFetchOffsetKey.subscriptionUpdate.rawValue) + let condition = TestMetCondition(expectedTokens: [id: [subscriptionKey: blank]]) let returned = expectation(description: "waiter returned") DispatchQueue.global().async { - _ = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: "onesignal-id") + _ = self.consistencyManager.getRywTokenFromAwaitableCondition(condition, forId: id) returned.fulfill() } OneSignalCoreMocks.waitUntil("waiter registered") { self.consistencyManager.waiterCount == 1 } - consistencyManager.resolveConditions(conditionId: "SomeOtherCondition", forId: "onesignal-id") + consistencyManager.setRywTokenAndDelay(id: id, key: OSIamFetchOffsetKey.userUpdate, value: blank) XCTAssertEqual(consistencyManager.waiterCount, 1) // Drain the timeout so the thread is not left blocked after the test. diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift index f1c122a23..72cbe63e8 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift @@ -101,11 +101,12 @@ final class OSIamFetchReadyConditionTests: XCTestCase { func testTheManagerLowersTheBarWhenItReleasesTheWaiter() { let manager = OSConsistencyManager.shared - OSIamFetchReadyCondition.sharedInstance(withId: userA).setSubscriptionUpdatePending(value: true) + let condition = OSIamFetchReadyCondition.sharedInstance(withId: userA) + condition.setSubscriptionUpdatePending(value: true) let firstReturned = expectation(description: "first fetch released") DispatchQueue.global().async { - _ = manager.getRywTokenFromAwaitableCondition(OSIamFetchReadyCondition.sharedInstance(withId: self.userA), forId: self.userA) + _ = manager.getRywTokenFromAwaitableCondition(condition, forId: self.userA) firstReturned.fulfill() } OneSignalCoreMocks.waitUntil("first fetch waiting") { manager.waiterCount == 1 } @@ -115,33 +116,61 @@ final class OSIamFetchReadyConditionTests: XCTestCase { manager.setRywTokenAndDelay(id: userA, key: OSIamFetchOffsetKey.subscriptionUpdate, value: token("200")) wait(for: [firstReturned], timeout: 2.0) + XCTAssertTrue(condition.isMet(indexedTokens: [userA: userUpdateToken()]), "the release has to lower the bar") - // The next fetch has no subscription update behind it, so the user token alone releases it. + // Forget the tokens, so the subscription token still on file cannot be what releases the next fetch. + manager.reset() let secondReturned = expectation(description: "second fetch released") DispatchQueue.global().async { - _ = manager.getRywTokenFromAwaitableCondition(OSIamFetchReadyCondition.sharedInstance(withId: self.userA), forId: self.userA) + _ = manager.getRywTokenFromAwaitableCondition(condition, forId: self.userA) secondReturned.fulfill() } + OneSignalCoreMocks.waitUntil("second fetch waiting") { manager.waiterCount == 1 } + + // The next fetch has no subscription update behind it, so the user token alone releases it. + manager.setRywTokenAndDelay(id: userA, key: OSIamFetchOffsetKey.userUpdate, value: token("300")) wait(for: [secondReturned], timeout: 2.0) } - /// The `ryw_token`-missing fallback the executors call for that user. - func testResolvingTheConditionReleasesTheFetch() { + /// A write that came back with no `ryw_token` is filed as a blank entry, and the condition only asks + /// whether the entry is there, so the fetch goes out without a token rather than waiting for one. + func testAWriteWithNoTokenReleasesTheFetch() { let manager = OSConsistencyManager.shared + let condition = OSIamFetchReadyCondition.sharedInstance(withId: userA) let returned = expectation(description: "fetch released") + var rywData: OSReadYourWriteData? DispatchQueue.global().async { - _ = manager.getRywTokenFromAwaitableCondition(OSIamFetchReadyCondition.sharedInstance(withId: self.userA), forId: self.userA) + rywData = manager.getRywTokenFromAwaitableCondition(condition, forId: self.userA) returned.fulfill() } OneSignalCoreMocks.waitUntil("fetch waiting") { manager.waiterCount == 1 } - manager.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: userA) + manager.setRywTokenAndDelay(id: userA, key: OSIamFetchOffsetKey.userUpdate, value: blank()) wait(for: [returned], timeout: 2.0) + XCTAssertNil(rywData?.rywToken) } - /// Resolving user B leaves user A's subscription bar raised. - func testResolvingOneUserDoesNotLowerAnotherUsersSubscriptionBar() { + /// The entry stays on file, so a fetch that registers after the tokenless write is released at once + /// instead of waiting the full timeout for a token that is never coming. + func testAFetchThatRegistersAfterATokenlessWriteDoesNotWait() { + let manager = OSConsistencyManager.shared + let condition = OSIamFetchReadyCondition.sharedInstance(withId: userA) + manager.setRywTokenAndDelay(id: userA, key: OSIamFetchOffsetKey.userUpdate, value: blank()) + + let returned = expectation(description: "fetch released") + DispatchQueue.global().async { + _ = manager.getRywTokenFromAwaitableCondition(condition, forId: self.userA) + returned.fulfill() + } + + // The timeout is the full 30 seconds here, so only a release brings the fetch back in time. + wait(for: [returned], timeout: 2.0) + XCTAssertEqual(manager.waiterCount, 0) + } + + /// User B's tokenless write leaves user A's subscription bar raised. + func testATokenlessWriteForOneUserDoesNotLowerAnotherUsersSubscriptionBar() { let manager = OSConsistencyManager.shared let conditionA = OSIamFetchReadyCondition.sharedInstance(withId: userA) let conditionB = OSIamFetchReadyCondition.sharedInstance(withId: userB) @@ -155,13 +184,14 @@ final class OSIamFetchReadyConditionTests: XCTestCase { } OneSignalCoreMocks.waitUntil("user B waiting") { manager.waiterCount == 1 } - manager.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: userB) + // A completed Create User is enough on its own, bar or no bar. + manager.setRywTokenAndDelay(id: userB, key: OSIamFetchOffsetKey.userCreate, value: blank()) wait(for: [bReturned], timeout: 2.0) XCTAssertFalse(conditionA.isMet(indexedTokens: [userA: userUpdateToken()]), "user A's subscription bar must still be up") XCTAssertTrue(conditionB.isMet(indexedTokens: [userB: userUpdateToken()]), - "user B's bar comes down with its own resolve") + "user B's bar comes down with its own release") } /// After a timeout, a later fetch for the same id is released by the user token alone. @@ -177,7 +207,10 @@ final class OSIamFetchReadyConditionTests: XCTestCase { firstReturned.fulfill() } wait(for: [firstReturned], timeout: 2.0) + XCTAssertTrue(condition.isMet(indexedTokens: [userA: userUpdateToken()]), "the timeout has to lower the bar") + // Back to the full timeout, so the second fetch can only return by being released. + OSConsistencyManager.waitTimeout = .seconds(30) manager.setRywTokenAndDelay(id: userA, key: OSIamFetchOffsetKey.userUpdate, value: token("100")) let secondReturned = expectation(description: "second fetch released by user token alone") @@ -188,12 +221,52 @@ final class OSIamFetchReadyConditionTests: XCTestCase { wait(for: [secondReturned], timeout: 2.0) } + /// The bar is shared by every fetch waiting on the id, so lowering it on one timeout can be all a sibling + /// was missing. That sibling goes out with the user token it already had, not after a timeout of its own. + func testATimeoutReleasesASiblingThatAlreadyHasItsUserToken() { + let manager = OSConsistencyManager.shared + let condition = OSIamFetchReadyCondition.sharedInstance(withId: userA) + condition.setSubscriptionUpdatePending(value: true) + + OSConsistencyManager.waitTimeout = .seconds(1) + let firstReturned = expectation(description: "first fetch timed out") + DispatchQueue.global().async { + _ = manager.getRywTokenFromAwaitableCondition(condition, forId: self.userA) + firstReturned.fulfill() + } + OneSignalCoreMocks.waitUntil("first fetch waiting") { manager.waiterCount == 1 } + + // Left to itself, the sibling would sit out the full timeout. + OSConsistencyManager.waitTimeout = .seconds(30) + let siblingReturned = expectation(description: "sibling fetch released") + var siblingData: OSReadYourWriteData? + DispatchQueue.global().async { + siblingData = manager.getRywTokenFromAwaitableCondition(condition, forId: self.userA) + siblingReturned.fulfill() + } + OneSignalCoreMocks.waitUntil("sibling fetch waiting") { manager.waiterCount == 2 } + + // A user token alone satisfies neither while the bar is up. + manager.setRywTokenAndDelay(id: userA, key: OSIamFetchOffsetKey.userUpdate, value: token("100")) + XCTAssertEqual(manager.waiterCount, 2) + + wait(for: [firstReturned], timeout: 2.0) + wait(for: [siblingReturned], timeout: 2.0) + XCTAssertEqual(siblingData?.rywToken, "100", "the sibling proceeds with the token it already had") + XCTAssertEqual(manager.waiterCount, 0) + } + // MARK: - Helpers private func token(_ value: String) -> OSReadYourWriteData { return OSReadYourWriteData(rywToken: value, rywDelay: 0) } + /// What a write that came back with no `ryw_token` files. + private func blank() -> OSReadYourWriteData { + return OSReadYourWriteData(rywToken: nil, rywDelay: nil) + } + private func userCreateToken() -> [NSNumber: OSReadYourWriteData] { return [NSNumber(value: OSIamFetchOffsetKey.userCreate.rawValue): token("create")] } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift index c91eb50fe..4dbb9e25c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift @@ -292,19 +292,13 @@ class OSPropertyOperationExecutor: OSOperationExecutor { } if let onesignalId = request.identityModel.onesignalId { - if let rywToken = response?["ryw_token"] as? String - { - let rywDelay = response?["ryw_delay"] as? NSNumber - - OSConsistencyManager.shared.setRywTokenAndDelay( - id: onesignalId, - key: OSIamFetchOffsetKey.userUpdate, - value: OSReadYourWriteData(rywToken: rywToken, rywDelay: rywDelay) - ) - } else { - // handle a potential regression where ryw_token is no longer returned by API - OSConsistencyManager.shared.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: onesignalId) - } + // Filed even when the response has no ryw_token, so a fetch that registers after this write + // is not held for a token that never comes. + let rywData = OSReadYourWriteData( + rywToken: response?["ryw_token"] as? String, + rywDelay: response?["ryw_delay"] as? NSNumber + ) + OSConsistencyManager.shared.setRywTokenAndDelay(id: onesignalId, key: OSIamFetchOffsetKey.userUpdate, value: rywData) } } onFailure: { error in OneSignalLog.onesignalLog(.LL_ERROR, message: "OSPropertyOperationExecutor update properties request failed with error: \(error.debugDescription)") diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift index 81745cbc4..78fb01c8d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift @@ -358,19 +358,13 @@ extension OSSubscriptionOperationExecutor { OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) if let onesignalId = request.identityModel.onesignalId { - // ryw_token and ryw_delay are top-level fields, siblings of "subscription". - if let rywToken = response?["ryw_token"] as? String - { - let rywDelay = response?["ryw_delay"] as? NSNumber - OSConsistencyManager.shared.setRywTokenAndDelay( - id: onesignalId, - key: OSIamFetchOffsetKey.subscriptionUpdate, - value: OSReadYourWriteData(rywToken: rywToken, rywDelay: rywDelay) - ) - } else { - // handle a potential regression where ryw_token is no longer returned by API - OSConsistencyManager.shared.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: onesignalId) - } + // Filed even when the response has no ryw_token, so a fetch that registers after this write + // is not held for a token that never comes. The token sits beside `subscription`, not inside it. + let rywData = OSReadYourWriteData( + rywToken: response?["ryw_token"] as? String, + rywDelay: response?["ryw_delay"] as? NSNumber + ) + OSConsistencyManager.shared.setRywTokenAndDelay(id: onesignalId, key: OSIamFetchOffsetKey.subscriptionUpdate, value: rywData) } // A 2xx with no subscription object is the server's no-op for a subscription that already exists on this user. @@ -489,18 +483,13 @@ extension OSSubscriptionOperationExecutor { OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: self.updateRequestQueue) if let onesignalId = request.identityModel?.onesignalId { - if let rywToken = response?["ryw_token"] as? String - { - let rywDelay = response?["ryw_delay"] as? NSNumber - OSConsistencyManager.shared.setRywTokenAndDelay( - id: onesignalId, - key: OSIamFetchOffsetKey.subscriptionUpdate, - value: OSReadYourWriteData(rywToken: rywToken, rywDelay: rywDelay) - ) - } else { - // handle a potential regression where ryw_token is no longer returned by API - OSConsistencyManager.shared.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: onesignalId) - } + // Filed even when the response has no ryw_token, so a fetch that registers after this write + // is not held for a token that never comes. + let rywData = OSReadYourWriteData( + rywToken: response?["ryw_token"] as? String, + rywDelay: response?["ryw_delay"] as? NSNumber + ) + OSConsistencyManager.shared.setRywTokenAndDelay(id: onesignalId, key: OSIamFetchOffsetKey.subscriptionUpdate, value: rywData) } self.executeNextPendingUpdateSubscription(for: modelId, inBackground: inBackground) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift index 46365cae7..60c516520 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift @@ -363,18 +363,13 @@ extension OSUserExecutor { } if let onesignalId = request.identityModel.onesignalId { - if let rywToken = response["ryw_token"] as? String - { - let rywDelay = response["ryw_delay"] as? NSNumber - OSConsistencyManager.shared.setRywTokenAndDelay( - id: onesignalId, - key: OSIamFetchOffsetKey.userCreate, - value: OSReadYourWriteData(rywToken: rywToken, rywDelay: rywDelay) - ) - } else { - // handle a potential regression where ryw_token is no longer returned by API - OSConsistencyManager.shared.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: onesignalId) - } + // Filed even when the response has no ryw_token, so a fetch that registers after this write + // is not held for a token that never comes. + let rywData = OSReadYourWriteData( + rywToken: response["ryw_token"] as? String, + rywDelay: response["ryw_delay"] as? NSNumber + ) + OSConsistencyManager.shared.setRywTokenAndDelay(id: onesignalId, key: OSIamFetchOffsetKey.userCreate, value: rywData) } } OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = false diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorReadYourWriteTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorReadYourWriteTests.swift new file mode 100644 index 000000000..de5b34644 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorReadYourWriteTests.swift @@ -0,0 +1,183 @@ +/* + 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 OneSignalOSCore +import OneSignalCoreMocks +import OneSignalOSCoreMocks +import OneSignalUserMocks +@testable import OneSignalUser + +/** + What each executor records for the in-app message fetch, with and without a `ryw_token` in the response. + + The fetch waits on `OSIamFetchReadyCondition`, which only asks whether the writes it cares about have + completed. A response with no token still completes the write, so the executor files an entry with no + token under its own key. A fetch that registers afterwards finds the entry and goes out, instead of + waiting the full timeout for a token that is never coming. + */ +final class ExecutorReadYourWriteTests: XCTestCase { + private var client = MockOneSignalClient() + private var newRecordsState = MockNewRecordsState() + private var user = OSIdentityModel(aliases: nil, changeNotifier: OSEventProducer()) + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + ConsistencyManagerTestHelpers.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + + client = MockOneSignalClient() + client.fireSuccessForAllRequests = true + OneSignalCoreImpl.setSharedClient(client) + newRecordsState = MockNewRecordsState() + user = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: userA_OSID, OS_EXTERNAL_ID: userA_EUID], changeNotifier: OSEventProducer()) + OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(user) + } + + override func tearDownWithError() throws { + ConsistencyManagerTestHelpers.reset() + OneSignalCoreMocks.clearUserDefaults() + } + + // MARK: - Tests + + func testACreateUserResponseWithNoTokenStillReadiesTheFetch() { + MockUserRequests.setDefaultCreateUserResponses(with: client, externalId: userA_EUID) + let executor = OSUserExecutor( + newRecordsState: newRecordsState, + identityVerificationService: OneSignalUserManagerImpl.sharedInstance.identityVerificationService, + auth: auth + ) + let identity = OSIdentityModel(aliases: [OS_EXTERNAL_ID: userA_EUID], changeNotifier: OSEventProducer()) + + executor.createUser(aliasLabel: OS_EXTERNAL_ID, aliasId: userA_EUID, identityModel: identity) + + OneSignalCoreMocks.waitUntil("A tokenless Create User left the fetch waiting") { self.fetchIsReady(for: userA_OSID) } + } + + func testAnUpdatePropertiesResponseWithNoTokenStillReadiesTheFetch() { + let executor = OSPropertyOperationExecutor(newRecordsState: newRecordsState, auth: auth) + executor.enqueueDelta(OSDelta( + name: OS_UPDATE_PROPERTIES_DELTA, + identityModelId: user.modelId, + externalId: user.externalId, + model: OSModel(changeNotifier: OSEventProducer()), + property: "language", + value: "en" + )) + + executor.processDeltaQueue(inBackground: false) + + OneSignalCoreMocks.waitUntil("A tokenless Update Properties left the fetch waiting") { self.fetchIsReady(for: userA_OSID) } + } + + func testACreateSubscriptionResponseWithNoTokenStillReadiesAFetchHeldForIt() { + holdTheFetchForASubscriptionUpdate() + let email = "a@example.com" + MockUserRequests.setAddEmailResponse(with: client, email: email) + let executor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState, auth: auth) + executor.enqueueDelta(subscriptionDelta(OS_ADD_SUBSCRIPTION_DELTA, model: subscription(type: .email, address: email, id: nil))) + + executor.processDeltaQueue(inBackground: false) + + OneSignalCoreMocks.waitUntil("A tokenless Create Subscription left the fetch waiting") { self.fetchIsReady(for: userA_OSID) } + } + + /// The create response carries `ryw_token` beside `subscription`, and it has to be read from there. + func testACreateSubscriptionResponseWithATokenFilesItForTheFetch() { + let email = "a@example.com" + client.setMockResponseForRequest( + request: "", + response: [ + "subscription": ["id": "\(email)_id", "type": "Email", "token": email], + "ryw_token": "0900", + "ryw_delay": 250 + ] + ) + let executor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState, auth: auth) + executor.enqueueDelta(subscriptionDelta(OS_ADD_SUBSCRIPTION_DELTA, model: subscription(type: .email, address: email, id: nil))) + + executor.processDeltaQueue(inBackground: false) + + OneSignalCoreMocks.waitUntil("The Create Subscription token was not filed") { + self.filedEntry(for: userA_OSID, key: .subscriptionUpdate)?.rywToken == "0900" + } + XCTAssertEqual(filedEntry(for: userA_OSID, key: .subscriptionUpdate)?.rywDelay?.intValue, 250) + } + + func testAnUpdateSubscriptionResponseWithNoTokenStillReadiesAFetchHeldForIt() { + holdTheFetchForASubscriptionUpdate() + let executor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState, auth: auth) + let push = subscription(type: .push, address: "push-token", id: "push-sub-id") + executor.enqueueDelta(subscriptionDelta(OS_UPDATE_SUBSCRIPTION_DELTA, model: push)) + + executor.processDeltaQueue(inBackground: false) + + OneSignalCoreMocks.waitUntil("A tokenless Update Subscription left the fetch waiting") { self.fetchIsReady(for: userA_OSID) } + } + + // MARK: - Helpers + + private var auth: OSRequestAuthorizing { + return OneSignalUserManagerImpl.sharedInstance.requestAuth + } + + /// Whether a fetch for the user would go out now rather than wait. + private func fetchIsReady(for onesignalId: String) -> Bool { + return OSIamFetchReadyCondition.sharedInstance(withId: onesignalId) + .isMet(indexedTokens: OSConsistencyManager.shared.snapshotTokens()) + } + + /// What is on file for the user under one key, or nil when nothing has been filed there. + private func filedEntry(for onesignalId: String, key: OSIamFetchOffsetKey) -> OSReadYourWriteData? { + return OSConsistencyManager.shared.snapshotTokens()[onesignalId]?[NSNumber(value: key.rawValue)] + } + + /// A subscription update in flight holds the fetch until that write completes, even with a user token on file. + private func holdTheFetchForASubscriptionUpdate() { + OSIamFetchReadyCondition.sharedInstance(withId: userA_OSID).setSubscriptionUpdatePending(value: true) + ConsistencyManagerTestHelpers.setDefaultRywToken(id: userA_OSID) + XCTAssertFalse(fetchIsReady(for: userA_OSID), "the fetch has to be held before the write completes") + } + + private func subscription(type: OSSubscriptionType, address: String, id: String?) -> OSSubscriptionModel { + return OSSubscriptionModel( + type: type, + address: address, + subscriptionId: id, + reachable: true, + isDisabled: false, + changeNotifier: OSEventProducer() + ) + } + + private func subscriptionDelta(_ name: String, model: OSSubscriptionModel) -> OSDelta { + return OSDelta(name: name, identityModelId: user.modelId, externalId: user.externalId, model: model, property: "optedIn", value: true) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionCreateResponseTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionCreateResponseTests.swift index cf6edcd7a..a1b9851a3 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionCreateResponseTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionCreateResponseTests.swift @@ -52,8 +52,8 @@ final class SubscriptionCreateResponseTests: XCTestCase { /** The server answers a create for a subscription that already exists on the user with a 2xx and no - subscription object. The request is finished, not retried, the model is left as it was, and a - fetch waiting on this user's read-your-write token is released because none is coming. + subscription object. The request is finished, not retried, the model is left as it was, and the + write is filed with no token, so a fetch held for it is released instead of waiting the timeout out. */ func testResponseWithoutSubscription_completesWithoutHydratingAndReleasesWaiters() throws { let client = MockOneSignalClient() @@ -64,10 +64,10 @@ final class SubscriptionCreateResponseTests: XCTestCase { let user = OneSignalUserMocks.setUserManagerInternalUser(onesignalId: onesignalId) let model = makeEmailSubscriptionModel() - // Only a resolve can release this waiter, so it stays blocked if the response is dropped instead of handled. + // Only the executor filing this write can release the waiter, so it stays blocked if the response is dropped instead of handled. let released = expectation(description: "IAM fetch waiter was not released") DispatchQueue.global().async { - _ = OSConsistencyManager.shared.getRywTokenFromAwaitableCondition(UnmetIamFetchCondition(), forId: self.onesignalId) + _ = OSConsistencyManager.shared.getRywTokenFromAwaitableCondition(SubscriptionWriteFiledCondition(id: self.onesignalId), forId: self.onesignalId) released.fulfill() } OneSignalCoreMocks.waitUntil("Waiter was not registered") { self.waiterCount(forId: self.onesignalId) == 1 } @@ -83,6 +83,9 @@ final class SubscriptionCreateResponseTests: XCTestCase { XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestCreateSubscription.self, expectedCount: 1)) XCTAssertNil(model.subscriptionId) wait(for: [released], timeout: 3.0) + let filed = filedSubscriptionEntry(forId: onesignalId) + XCTAssertNotNil(filed, "the tokenless write was not filed for the fetch") + XCTAssertNil(filed?.rywToken) } /** @@ -154,6 +157,12 @@ final class SubscriptionCreateResponseTests: XCTestCase { } } + private func filedSubscriptionEntry(forId id: String) -> OSReadYourWriteData? { + OSConsistencyManager.shared.queue.sync { + OSConsistencyManager.shared.indexedTokens[id]?[NSNumber(value: OSIamFetchOffsetKey.subscriptionUpdate.rawValue)] + } + } + private func waitForAddRequestQueueToDrain() { OneSignalCoreMocks.waitUntil("Create subscription request was not removed from the cache") { let requests = OneSignalUserDefaults.initShared().getSavedCodeableData( @@ -184,15 +193,21 @@ private final class SubscriptionUpdateTokenCondition: NSObject, OSCondition { } } -/// Never met on its own and carries the IAM fetch condition id, so only the executor's resolve releases it. -private final class UnmetIamFetchCondition: NSObject, OSCondition { - var conditionId: String { OSIamFetchReadyCondition.CONDITIONID } +/// Met once a subscription write is on file for the id, with or without a token, so only the executor filing that write releases it. +private final class SubscriptionWriteFiledCondition: NSObject, OSCondition { + private let id: String + + init(id: String) { + self.id = id + } + + var conditionId: String { "SubscriptionWriteFiledCondition" } func isMet(indexedTokens: [String: [NSNumber: OSReadYourWriteData]]) -> Bool { - false + indexedTokens[id]?[NSNumber(value: OSIamFetchOffsetKey.subscriptionUpdate.rawValue)] != nil } func getNewestToken(indexedTokens: [String: [NSNumber: OSReadYourWriteData]]) -> OSReadYourWriteData? { - nil + indexedTokens[id]?[NSNumber(value: OSIamFetchOffsetKey.subscriptionUpdate.rawValue)] } } From 56af6f665f4118f9e3978871fcf773eef8ac87a0 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 22 Sep 2026 09:58:54 -0700 Subject: [PATCH 18/25] fix: [SDK-5137] user request queue and identity model follow-ups from the JWT review (#1740) --- .../OneSignal.xcodeproj/project.pbxproj | 8 + .../MockOneSignalClient.swift | 5 + .../OneSignalOSCoreMocks/OSCoreMocks.swift | 8 + .../OSOperationRepoFlushTests.swift | 1 + ...erationRepoIdentityVerificationTests.swift | 1 + .../OSOperationRepoTestSupport.swift | 10 - .../OSSubscriptionOperationExecutor.swift | 5 +- .../Source/Executors/OSUserExecutor.swift | 78 ++++++- .../Source/OSIdentityModel.swift | 33 ++- .../OneSignalUser/Source/OSRequestAuth.swift | 49 ++-- .../OSRequestDeleteSubscription.swift | 6 +- .../Source/Requests/OSUserRequest.swift | 19 +- .../DeltaOwnershipTests.swift | 3 +- .../ExecutorAnonymousPurgeTests.swift | 20 ++ .../Executors/UserExecutorRetryTests.swift | 91 ++++++++ .../Executors/UserExecutorTests.swift | 125 +++++++++++ .../OSIdentityModelTests.swift | 33 ++- .../OSRequestAuthTests.swift | 56 +++++ .../UserJwtLifecycleTests.swift | 2 +- .../UserStateReportingTests.swift | 211 ++++++++++++++++++ 20 files changed, 706 insertions(+), 58 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorRetryTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/UserStateReportingTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index be8173de6..200aef9f2 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -253,6 +253,7 @@ 3CEE93572B7C78FD008440BD /* OneSignalCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE7D17E627026B95002D3A5D /* OneSignalCore.framework */; }; 3CEE93582B7C78FE008440BD /* OneSignalCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = DE7D17E627026B95002D3A5D /* OneSignalCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3CF11E3D2C6D6155002856F5 /* UserExecutorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CF11E3C2C6D6155002856F5 /* UserExecutorTests.swift */; }; + 354E0C59BA9B18437C36215B /* UserExecutorRetryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C310B6C569E96C204F8CD68 /* UserExecutorRetryTests.swift */; }; 3CF11E402C6E6DE2002856F5 /* MockNewRecordsState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CF11E3F2C6E6DE2002856F5 /* MockNewRecordsState.swift */; }; 3CF1A5632C669EA40056B3AA /* OSNewRecordsState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CF1A5622C669EA40056B3AA /* OSNewRecordsState.swift */; }; 3CF8629E28A183F900776CA4 /* OSIdentityModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CF8629D28A183F900776CA4 /* OSIdentityModel.swift */; }; @@ -363,6 +364,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 */; }; + E42087CB1AB15481E55D34FE /* UserStateReportingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA6355B75EDC9A3100B98F5 /* UserStateReportingTests.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 */; }; @@ -1524,6 +1526,7 @@ 3CEE90A62BFE6ABD00B0FB5B /* OSPropertiesSupportedProperty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSPropertiesSupportedProperty.swift; sourceTree = ""; }; 3CEE90A82C000BD500B0FB5B /* OneSignalRequest+UnitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OneSignalRequest+UnitTests.swift"; sourceTree = ""; }; 3CF11E3C2C6D6155002856F5 /* UserExecutorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserExecutorTests.swift; sourceTree = ""; }; + 6C310B6C569E96C204F8CD68 /* UserExecutorRetryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserExecutorRetryTests.swift; sourceTree = ""; }; 3CF11E3F2C6E6DE2002856F5 /* MockNewRecordsState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNewRecordsState.swift; sourceTree = ""; }; 3CF1A5622C669EA40056B3AA /* OSNewRecordsState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSNewRecordsState.swift; sourceTree = ""; }; 3CF8629D28A183F900776CA4 /* OSIdentityModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSIdentityModel.swift; sourceTree = ""; }; @@ -1600,6 +1603,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 = ""; }; + FDA6355B75EDC9A3100B98F5 /* UserStateReportingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserStateReportingTests.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 = ""; }; @@ -2539,6 +2543,7 @@ 047D8F5E1095A20C9C54FD33 /* OSRequestAuthTests.swift */, 3016921C1F6B7B7793F67567 /* RequestPathEncodingTests.swift */, 5BFE2F960129386AFA6D5F41 /* UserJwtLifecycleTests.swift */, + FDA6355B75EDC9A3100B98F5 /* UserStateReportingTests.swift */, 3567F486AA4335CCA79D659B /* UserJwtAskTests.swift */, ); path = OneSignalUserTests; @@ -2556,6 +2561,7 @@ isa = PBXGroup; children = ( 3CF11E3C2C6D6155002856F5 /* UserExecutorTests.swift */, + 6C310B6C569E96C204F8CD68 /* UserExecutorRetryTests.swift */, 3CA93BC3300AEFFA000724B3 /* SubscriptionUpdateRaceTests.swift */, C95B2AD05FFF9FC12C9D5878 /* ExecutorReadYourWriteTests.swift */, 3C5181A1B2C3D4E5F6A7B801 /* SubscriptionCreateResponseTests.swift */, @@ -4714,6 +4720,7 @@ files = ( 3CB331682F281679000E1801 /* CustomEventsIntegrationTests.swift in Sources */, 3CF11E3D2C6D6155002856F5 /* UserExecutorTests.swift in Sources */, + 354E0C59BA9B18437C36215B /* UserExecutorRetryTests.swift in Sources */, 3C67F77A2BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift in Sources */, 3CC063EE2B6D7FE8002BB07F /* OneSignalUserTests.swift in Sources */, 3CA93BC7300B0100000724B3 /* SubscriptionModelConcurrencyTests.swift in Sources */, @@ -4733,6 +4740,7 @@ AAFA2D46E6C5FD3D14D39F27 /* OSRequestAuthTests.swift in Sources */, 23D66BEB40CE76DFF89744A3 /* RequestPathEncodingTests.swift in Sources */, 7EB69F3B404D0AEF46EC1536 /* UserJwtLifecycleTests.swift in Sources */, + E42087CB1AB15481E55D34FE /* UserStateReportingTests.swift in Sources */, 9345FA2E8878EA1B0216A49A /* UserJwtAskTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift b/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift index 1b5288797..1c865af66 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift @@ -283,4 +283,9 @@ extension MockOneSignalClient { request.isKind(of: type) }.count } + + /// Held requests count as in flight. Read under the lock, so safe from the test thread. + public var isIdle: Bool { + return lock.withLock { startedRequests.count == completedRequests.count } + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift index 4dae89435..31a65c3ea 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift @@ -55,4 +55,12 @@ extension OSOperationRepo { } paused = false } + + /** + The queue as of right now. Tests poll it while the repo appends on its own queue, so reading + `deltaQueue` directly is a data race even when only the count is wanted. + */ + public func snapshotDeltaQueue() -> [OSDelta] { + return dispatchQueue.sync { deltaQueue } + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift index 2e01ca899..52cc1c79d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift @@ -29,6 +29,7 @@ import Foundation import XCTest import OneSignalCore import OneSignalCoreMocks +import OneSignalOSCoreMocks @testable import OneSignalOSCore /// Covers `flushDeltaQueue` routing: matched deltas go to executors and leave the repo diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoIdentityVerificationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoIdentityVerificationTests.swift index 47ad71e0d..7b3e405c3 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoIdentityVerificationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoIdentityVerificationTests.swift @@ -29,6 +29,7 @@ import Foundation import XCTest import OneSignalCore import OneSignalCoreMocks +import OneSignalOSCoreMocks import OneSignalKMP @testable import OneSignalOSCore diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift index fc84a9fba..bb84d4ec3 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift @@ -71,16 +71,6 @@ enum OSOperationRepoTestEnvironment { } } -extension OSOperationRepo { - /** - The queue as of right now. Tests poll it while the repo appends on its own queue, so reading - `deltaQueue` directly is a data race even when only the count is wanted. - */ - func snapshotDeltaQueue() -> [OSDelta] { - return dispatchQueue.sync { deltaQueue } - } -} - /// Records what the Operation Repo hands it, so tests can assert on routing rather than on requests. final class MockOperationExecutor: OSOperationExecutor { let supportedDeltas: [String] diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift index 78fb01c8d..aeee2c099 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift @@ -440,9 +440,8 @@ extension OSSubscriptionOperationExecutor { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionOperationExecutor delete subscription request failed with error: \(error.debugDescription)") self.dispatchQueue.async { let responseType = OSNetworkingUtils.getResponseStatusType(error.code) - if responseType == .unauthorized, self.auth.handleUnauthorized(request) { - OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSubscriptionOperationExecutor holding \(request) for a new token") - } else if responseType != .retryable { + // No token handling: the delete is never signed, so a 401 here is not about the user's JWT. + if responseType != .retryable { // Fail, no retry, remove from cache and queue // If this request returns a missing status, that is ok as this is a delete request self.removeRequestQueue.removeAll(where: { $0 == request}) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift index 60c516520..392fb3408 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift @@ -68,9 +68,13 @@ class OSUserExecutor { 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. + + With the requirement off there is nothing to reshape, but a login kept at start while the requirement + was still unknown may never become sendable; see `dropIdentifyUsersThatCanNeverPrepare`. */ private func reshapeInvalidRequests() { guard identityVerificationService.ivBehaviorActive else { + dropIdentifyUsersThatCanNeverPrepare() return } @@ -121,6 +125,43 @@ class OSUserExecutor { return request is OSRequestFetchIdentityBySubscription } + /** + An Identify User whose user has no `onesignal_id`, and no queued Create User or Fetch Identity By + Subscription to supply one, can never prepare, so drop it rather than let it block the logins behind it. + Runs on every send, so a handler that supplies an id must hydrate before it removes its Request from + the queue; a pass between the two would see no supplier and drop the login. + */ + private func dropIdentifyUsersThatCanNeverPrepare() { + guard identityVerificationService.requirement == .off else { + return + } + let modelIdsAwaitingAnId = Set(userRequestQueue.compactMap { request -> String? in + if let createUser = request as? OSRequestCreateUser { + return createUser.identityModel.modelId + } + if let fetchIdentity = request as? OSRequestFetchIdentityBySubscription { + return fetchIdentity.identityModel.modelId + } + return nil + }) + let kept = userRequestQueue.filter { request in + guard let identifyUser = request as? OSRequestIdentifyUser, + identifyUser.identityModelToIdentify.onesignalId == nil, + !modelIdsAwaitingAnId.contains(identifyUser.identityModelToIdentify.modelId) + else { + return true + } + let reason = "its user never received an onesignal_id and nothing queued can supply one" + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSUserExecutor dropped \(identifyUser), \(reason)") + return false + } + guard kept.count != userRequestQueue.count else { + return + } + userRequestQueue = kept + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, withValue: userRequestQueue) + } + /// Read in requests from the cache, do not read in FetchUser requests as this is not needed. private func uncacheUserRequests() { var userRequestQueue: [OSUserRequest] = [] @@ -160,9 +201,15 @@ class OSUserExecutor { req.identityModelToUpdate = updateInRepo } - // `prepareForExecution` is false under IV so `reshapeInvalidRequests` can promote - // this login; do not treat that as a permanent drop. - if auth.ivBehaviorActive || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + // Keep the login when the user it identifies is known to the repo: a restored Create User + // ahead of it put the model there, and that response supplies the `onesignal_id` prepare + // needs. Keep it too while the requirement is not known to be off, since + // `reshapeInvalidRequests` decides what a login made under Identity Verification becomes + // once the requirement is known. Otherwise only a Request that can be sent as is stays: + // one that can never prepare would hold the queue and block the logins behind it. + if identifyInRepo != nil + || identityVerificationService.requirement != .off + || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { if identifyInRepo == nil { addIdentityModel(req.identityModelToIdentify) } @@ -253,7 +300,9 @@ class OSUserExecutor { else { // Only the app can end this wait (`updateUserJwt` → `storeJwt`); do not poll for it. // A login for another user behind this one must not be stranded, so step over it. - if self.auth.awaitsToken(request) { + // Anything else that stops a prepare, the cool-down or an id still to arrive, resolves + // on its own, and the delayed retry below is what picks it up. + if self.auth.parkedForToken(request) { awaitingToken = true continue } @@ -339,8 +388,6 @@ extension OSUserExecutor { request.sentToClient = true OneSignalCoreImpl.sharedClient().execute(request) { response in - self.removeFromQueue(request) - // Create User's response won't send us the user's complete info if this user already exists if let response = response { // Parse the response for any data we need to update @@ -350,6 +397,8 @@ extension OSUserExecutor { originalPushToken: request.originalPushToken, addNewRecords: request.addsNewRecords ) + // Must follow the hydrate; see `dropIdentifyUsersThatCanNeverPrepare`. + self.removeFromQueue(request) // If this user already exists and we logged into an external_id, fetch the user data // Fetch the user only if its the current user and non-anonymous @@ -371,6 +420,8 @@ extension OSUserExecutor { ) OSConsistencyManager.shared.setRywTokenAndDelay(id: onesignalId, key: OSIamFetchOffsetKey.userCreate, value: rywData) } + } else { + self.removeFromQueue(request) } OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = false } onFailure: { error in @@ -417,11 +468,12 @@ extension OSUserExecutor { request.sentToClient = true OneSignalCoreImpl.sharedClient().execute(request) { response in - self.removeFromQueue(request) - if let identityObject = self.parseIdentityObjectResponse(response), let onesignalId = identityObject[OS_ONESIGNAL_ID] { request.identityModel.hydrate(identityObject) + OSUserStateSnapshot.fireUserStateChangedIfCurrent(request.identityModel) + // Must follow the hydrate; see `dropIdentifyUsersThatCanNeverPrepare`. + self.removeFromQueue(request) // Fetch this user's data if it is the current user guard OneSignalUserManagerImpl.sharedInstance.currentUser(matching: request.identityModel.modelId) != nil @@ -431,6 +483,8 @@ extension OSUserExecutor { } self.fetchUser(aliasLabel: OS_ONESIGNAL_ID, aliasId: onesignalId, identityModel: request.identityModel) + } else { + self.removeFromQueue(request) } } onFailure: { error in OneSignalLog.onesignalLog(.LL_ERROR, message: "OSUserExecutor executeFetchIdentityBySubscriptionRequest failed with error: \(error.debugDescription)") @@ -470,10 +524,9 @@ extension OSUserExecutor { request.sentToClient = true OneSignalCoreImpl.sharedClient().execute(request) { _ in - self.removeFromQueue(request) - guard let onesignalId = request.identityModelToIdentify.onesignalId else { OneSignalLog.onesignalLog(.LL_ERROR, message: "executeIdentifyUserRequest succeeded but is now missing OneSignal ID!") + self.removeFromQueue(request) self.executePendingRequests() return } @@ -484,6 +537,9 @@ extension OSUserExecutor { request.aliasLabel: request.aliasId ] request.identityModelToUpdate.hydrate(aliases) + OSUserStateSnapshot.fireUserStateChangedIfCurrent(request.identityModelToUpdate) + // Must follow the hydrate; see `dropIdentifyUsersThatCanNeverPrepare`. + self.removeFromQueue(request) // the anonymous user has been identified, still need to Fetch User as we cleared local data if OneSignalUserManagerImpl.sharedInstance.currentUser(matching: request.identityModelToUpdate.modelId) != nil { @@ -608,8 +664,10 @@ extension OSUserExecutor { // If this was a create user, it hydrates the onesignal_id of the request's identityModel // The model in the store may be different, and it may be waiting on the onesignal_id of this previous model + // Only a current user is reported to the app; a parked Create User can complete after a switch. if let identityObject = parseIdentityObjectResponse(response) { identityModel.hydrate(identityObject) + OSUserStateSnapshot.fireUserStateChangedIfCurrent(identityModel) if addNewRecords, let onesignalId = identityObject[OS_ONESIGNAL_ID] { newRecordsState.add(onesignalId) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift index 6df132faf..06a362887 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift @@ -133,11 +133,15 @@ class OSIdentityModel: OSModel { } /** - Called to clear the model's data in preparation for hydration via a fetch user call. + Keeps `onesignal_id` and `external_id` and drops every other alias, ahead of the Fetch User response + that fills the model back in. The fetch is addressed by one of those two, so neither can change, and + work built on another queue before the response lands has to keep reading this user as created and + identified. A response that omits `external_id` therefore leaves it in place; a same-user `login` is + a no-op, so only a login as someone else replaces it. */ func clearData() { lock.withLock { - self.aliases = [:] + self.aliases = self.aliases.filter { $0.key == OS_ONESIGNAL_ID || $0.key == OS_EXTERNAL_ID } } } @@ -161,21 +165,34 @@ class OSIdentityModel: OSModel { } OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSIdentityModel hydrateModel with aliases: \(remoteAliases)") - let newOnesignalId = remoteAliases[OS_ONESIGNAL_ID] - let newExternalId = remoteAliases[OS_EXTERNAL_ID] - + // Reporting the user to the app is the executor's call, since only a current user may be reported. internalAddAliases(remoteAliases) - OSUserStateSnapshot.fireUserStateChanged(newOnesignalId: newOnesignalId, newExternalId: newExternalId) } } /** Owns the last user state the app was told about, so the observer only hears real changes. - Hydration is the usual source, but `logout` under Identity Verification also reports here: it creates - no user on the server, so there is no hydration to carry the news that nobody is signed in. + The User executor reports a hydrated current user through `fireUserStateChangedIfCurrent`, and `logout` + under Identity Verification also reports here: it creates no user on the server, so there is no + hydration to carry the news that nobody is signed in. */ enum OSUserStateSnapshot { + /** + Reports the hydrated user to the app, but only while that user is still current. A Create User or + Identify User for a user the app has since switched away from still hydrates its model, since the + Requests queued behind it need the `onesignal_id`, but the app must not hear that user as signed in, + and the persisted pair must keep naming the current user, or the current user's real state would + later read as unchanged and go unreported. + */ + static func fireUserStateChangedIfCurrent(_ identityModel: OSIdentityModel) { + guard OneSignalUserManagerImpl.sharedInstance.currentUser(matching: identityModel.modelId) != nil else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSUserStateSnapshot not reporting a hydrated user who is no longer current") + return + } + fireUserStateChanged(newOnesignalId: identityModel.onesignalId, newExternalId: identityModel.externalId) + } + /// Fires the user observer if `onesignal_id` OR `external_id` differs from the last reported pair. static func fireUserStateChanged(newOnesignalId: String?, newExternalId: String?) { let prevOnesignalId = OneSignalUserDefaults.initShared().getSavedString(forKey: OS_SNAPSHOT_ONESIGNAL_ID, defaultValue: nil) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift index 7aba9299c..42386565b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift @@ -53,17 +53,19 @@ protocol OSRequestAuthorizing: AnyObject { /// The same decision for endpoints that take a token but no alias, because their path names a /// subscription or the app. Returns `false` under the same conditions as `authorizeUserScoped`, - /// except that a `sendsUnsigned` Request with no owner is allowed through. + /// except that a `sendsUnsigned` Request always goes through with no header, owner or not. func authorize(_ request: OSUserRequest) -> Bool /** - Returns `true` if the Request's owner has no token to sign with, which is why the two methods above - parked it. Reads only: it does not ask the app for a token, so call it after one of them has. + Whether the last authorization of this Request parked it, because its owner has no token to sign + with. Consumes the answer, so ask once per `prepareForExecution`, right after it returns false. Lets a caller that stops at its first unsendable Request tell "nothing can send until the app hands - over a token for this user" from "not addressable yet", which resolves on its own. + over a token for this user" from "not addressable yet", which resolves on its own: an app id that has + not arrived, a user still inside the new-records cool-down, or an `onesignal_id` a queued Create User + has yet to supply. Those fail before the two methods above run, so only a park may skip the retry. */ - func awaitsToken(_ request: OSUserRequest) -> Bool + func parkedForToken(_ request: OSUserRequest) -> Bool /** Parks the token an unauthorized response rejected and clears `sentToClient` so the Request is @@ -119,6 +121,15 @@ final class OSRequestAuth: OSRequestAuthorizing { private let identityVerificationService: OSIdentityVerificationService private let jwt: OSUserJwtProviding + /** + Requests whose last authorization `park` held. Weak, so a Request that leaves its queue leaves this + too. Every authorization starts by forgetting the Request, so the entry reflects the latest attempt, + and `parkedForToken` removes it, so a prepare that fails before authorizing reads as not parked. + Shared by every executor's queue, hence the lock. + */ + private let parkedRequests = NSHashTable.weakObjects() + private let parkedRequestsLock = NSLock() + var ivBehaviorActive: Bool { return identityVerificationService.ivBehaviorActive } @@ -129,6 +140,7 @@ final class OSRequestAuth: OSRequestAuthorizing { } func authorizeUserScoped(_ request: OSUserRequest, legacyAlias: OSAliasPair) -> OSAliasPair? { + forgetPark(of: request) guard ivBehaviorActive else { return legacyAlias } @@ -148,16 +160,15 @@ final class OSRequestAuth: OSRequestAuthorizing { } func authorize(_ request: OSUserRequest) -> Bool { - guard ivBehaviorActive else { + forgetPark(of: request) + // An exempt Request may carry an owner for the purge; it still goes out unsigned. + guard ivBehaviorActive, !request.sendsUnsigned else { return true } guard let externalId = request.ownerExternalId else { // Anything not exempt is a leftover the purge has yet to clear, and unsendable until it does. - guard request.sendsUnsigned else { - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSRequestAuth: refusing \(request), it has no owner under Identity Verification") - return false - } - return true + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSRequestAuth: refusing \(request), it has no owner under Identity Verification") + return false } guard let token = jwt.validJwt(externalId: externalId) else { park(request, ownedBy: externalId) @@ -167,11 +178,18 @@ final class OSRequestAuth: OSRequestAuthorizing { return true } - func awaitsToken(_ request: OSUserRequest) -> Bool { - guard ivBehaviorActive, let externalId = request.ownerExternalId else { - return false + func parkedForToken(_ request: OSUserRequest) -> Bool { + return parkedRequestsLock.withLock { + guard parkedRequests.contains(request) else { + return false + } + parkedRequests.remove(request) + return true } - return jwt.validJwt(externalId: externalId) == nil + } + + private func forgetPark(of request: OSUserRequest) { + parkedRequestsLock.withLock { parkedRequests.remove(request) } } /** @@ -181,6 +199,7 @@ final class OSRequestAuth: OSRequestAuthorizing { is stored or a `login` as that user rearms it. */ private func park(_ request: OSUserRequest, ownedBy externalId: String) { + parkedRequestsLock.withLock { parkedRequests.add(request) } // Log only on the ask that reaches the app; later prepareForExecution retries stay quiet. if jwt.askForToken(externalId: externalId) { OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSRequestAuth: holding \(request) until \(externalId) has a token") diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestDeleteSubscription.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestDeleteSubscription.swift index cf162e809..c67e0aa8e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestDeleteSubscription.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestDeleteSubscription.swift @@ -45,10 +45,14 @@ class OSRequestDeleteSubscription: OneSignalRequest, OSUserRequest { /** See the ownership convention in `OSUserRequest.swift`. Removing an email or SMS subscription is a deliberate action on one user, so an anonymous one is dropped under Identity Verification even - though the path addresses a subscription rather than a user. + though the path addresses a subscription rather than a user. The owner serves that purge only; + see `sendsUnsigned`. */ let ownerExternalId: String? + /// The endpoint takes no user JWT, so waiting for a token would only delay the unsubscribe, forever after a logout. + var sendsUnsigned: Bool { return true } + // Need the subscription_id func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { if let subscriptionId = subscriptionModel.subscriptionId, diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift index 7842b4708..ac02149fe 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift @@ -34,9 +34,9 @@ protocol OSUserRequest: OneSignalRequest, NSCoding { /// The user this Request belongs to; also selects its token. See the ownership convention below. var ownerExternalId: String? { get } - /// Whether this Request may still be sent with no `Authorization` header once Identity Verification - /// is in effect. Only Update Subscription may: its path names a subscription rather than a user, so - /// there is no user for the server to authorize. Everything else with no owner is refused. + /// Whether this Request goes out with no `Authorization` header even under Identity Verification: + /// Update Subscription and Delete Subscription, whose endpoints are addressed by subscription ID and + /// take no user JWT. Everything else with no owner is refused. var sendsUnsigned: Bool { get } /// Builds the path and resolves authorization. `false` leaves the Request queued, whether it is @@ -55,10 +55,11 @@ extension OSUserRequest { owner's `external_id` as of when the Request was built, and both the purge and the token lookup judge it by that rather than by its `identityModel`. - The live model cannot answer the question. `clearUserData` empties an Identity Model's aliases before - a fetch response hydrates them, so for that window an identified user reads as anonymous and a purge - running alongside it would delete signed work. The stamp also matches how `OSDelta` carries - `externalId`, which keeps a Delta and the Request built from it judged the same way. + The live model is not the record of who the work was for. Its aliases are cleared and hydrated again + around every fetch (only the two ids survive the clear, see `OSIdentityModel.clearData`), and the + owner has to be what it was when the work was built, not what the model reads later. The stamp also + matches how `OSDelta` carries `externalId`, which keeps a Delta and the Request built from it judged + the same way. nil means anonymous, including for caches written before ownership was stamped. @@ -68,7 +69,9 @@ extension OSUserRequest { Three Requests are nil by construction and so are never signed: Identify User and Fetch Identity By Subscription both address a user that has no `external_id` yet, and Update Subscription is the - device's own push subscription. Each says why at its declaration. + device's own push subscription. Delete Subscription carries an owner for the purge but declares + `sendsUnsigned`, since its endpoint is addressed by subscription ID and takes no user JWT. Each says + why at its declaration. */ internal extension OneSignalRequest { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift index f798e56ee..f4c901715 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift @@ -28,6 +28,7 @@ import XCTest import OneSignalCore import OneSignalCoreMocks +import OneSignalOSCoreMocks import OneSignalUserMocks @testable import OneSignalOSCore @testable import OneSignalUser @@ -298,6 +299,6 @@ final class DeltaOwnershipTests: XCTestCase { } private func queuedDelta(named name: String, property: String) -> OSDelta? { - return OneSignalUserManagerImpl.sharedInstance.operationRepo.deltaQueue.first { $0.name == name && $0.property == property } + return OneSignalUserManagerImpl.sharedInstance.operationRepo.snapshotDeltaQueue().first { $0.name == name && $0.property == property } } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift index ff103ab63..6509b847b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift @@ -349,6 +349,26 @@ final class ExecutorAnonymousPurgeTests: XCTestCase { XCTAssertEqual(cachedRequestOwners(OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, of: OSRequestDeleteSubscription.self), [userA_EUID]) } + /// The delete endpoint is addressed by subscription ID and takes no user JWT, so an owned delete goes + /// out unsigned even when its owner has no token, and nobody is asked for one. Parking it would hold + /// an unsubscribe on a credential the server does not read. + func testTheSubscriptionExecutorSendsAnOwnedDeleteUnsignedWithoutAskingForAToken() { + let tokenless = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: userB_OSID, OS_EXTERNAL_ID: userB_EUID], changeNotifier: OSEventProducer()) + OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(tokenless) + let executor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState, auth: auth) + + let removal = subscriptionDelta(OS_REMOVE_SUBSCRIPTION_DELTA, for: tokenless, subscription: subscription(id: "tokenless-subscription-id")) + executor.enqueueDelta(removal) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("The owned delete was not sent") { + self.client.hasExecutedRequestOfType(OSRequestDeleteSubscription.self, expectedCount: 1) + } + + XCTAssertNil(client.executedRequests.first?.additionalHeaders?["Authorization"]) + XCTAssertTrue(OneSignalUserManagerImpl.sharedInstance.userJwtRepo.pendingTokenAsks().isEmpty, + "nobody may be asked for a token the endpoint does not read") + } + /// An Update Subscription is addressed by subscription ID and never signed, so it has no owner to be /// judged by and the purge has to leave that queue alone: `logout()`'s unsubscribe travels in it. func testTheSubscriptionExecutorKeepsEveryUpdateRequest() { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorRetryTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorRetryTests.swift new file mode 100644 index 000000000..a621edb58 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorRetryTests.swift @@ -0,0 +1,91 @@ +/* + 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 OneSignalOSCore +import OneSignalCoreMocks +import OneSignalOSCoreMocks +import OneSignalUserMocks +@testable import OneSignalUser + +/** + What the User executor retries on its own, and what it waits on the app for. A parked Request waits + for a token that only `updateUserJwt` can supply; anything else that stops a prepare resolves on its + own and has to be retried, or a login sits in the queue until the next launch. + */ +final class UserExecutorRetryTests: XCTestCase { + private var client = MockOneSignalClient() + private var newRecordsState = MockNewRecordsState() + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + + client = MockOneSignalClient() + OneSignalCoreImpl.setSharedClient(client) + newRecordsState = MockNewRecordsState() + // Presence is the hold: the production timer is a no-op under TEST. + newRecordsState.holdWhilePresent = true + } + + override func tearDownWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + } + + private func makeExecutor() -> OSUserExecutor { + return OSUserExecutor( + newRecordsState: newRecordsState, + identityVerificationService: OneSignalUserManagerImpl.sharedInstance.identityVerificationService, + auth: OneSignalUserManagerImpl.sharedInstance.requestAuth + ) + } + + /** + A Create User held by the new-records cool-down on its push subscription fails to prepare before + the auth layer can park it, so nobody is asked for a token. It has to be retried once the cool-down + passes, at which point it parks and asks, rather than be stepped over as if it were already parked. + */ + func testACreateUserHeldByTheCoolDownIsRetriedUntilItCanParkAndAsk() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + let jwtRepo = OneSignalUserManagerImpl.sharedInstance.userJwtRepo + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: nil) + newRecordsState.add(testPushSubId) + let executor = makeExecutor() + + executor.createUser(user) + allowAsyncWorkToRun() + XCTAssertFalse(jwtRepo.pendingTokenAsks().contains(userA_EUID), "held by the cool-down, so nobody may be asked yet") + + newRecordsState.holdWhilePresent = false + OneSignalCoreMocks.waitUntil("The app was not asked for a token once the cool-down passed") { + jwtRepo.pendingTokenAsks().contains(userA_EUID) + } + XCTAssertFalse(client.hasExecutedRequestOfType(OSRequestCreateUser.self), "nothing signs a Create User whose owner has no token") + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift index 27e5a82de..e313a233f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift @@ -612,6 +612,131 @@ final class UserExecutorTests: XCTestCase { } } +/// Logins restored from the archive of a launch that ended before its requests went out. Split from +/// `UserExecutorTests` at SwiftLint's type body limit. +final class UserExecutorRestoredLoginTests: XCTestCase { + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + } + + /// The archive an offline first launch with a `login` leaves behind: the anonymous Create User has not + /// been sent, so its user has no `onesignal_id` yet. The Identify User behind it has to wait for that + /// response rather than be dropped at start. + func testRestoredIdentifyUserBehindItsUnsentCreateUserIsSentOnceTheCreateUserCompletes() { + /* Setup */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: nil) + let createUser = makeUnsentAnonymousCreateUserRequest() + cacheUserRequests([createUser, makeIdentifyUserRequest(identifying: createUser.identityModel, updating: user.identityModel)]) + + /* When */ + let mocks = Mocks { + MockUserRequests.setDefaultCreateAnonUserResponses(with: $0) + MockUserRequests.setDefaultIdentifyUserResponses(with: $0, externalId: userA_EUID, conflicted: false) + } + OneSignalCoreMocks.waitUntil("Restored Identify User was not sent after its Create User") { + mocks.client.hasExecutedRequestOfType(OSRequestIdentifyUser.self) + } + + /* Then */ + XCTAssertTrue(mocks.client.executedRequests.first is OSRequestCreateUser, "the Create User has to go out first") + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self, expectedCount: 1)) + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestIdentifyUser.self, expectedCount: 1)) + } + + /// Same archive with the requirement unknown at start. Once auth turns out to be required, reshape + /// drops the anonymous Create User and promotes the Identify User into the Create User `login` would + /// have made. + func testRestoredIdentifyUserBehindItsUnsentCreateUserBecomesACreateUserWhenAuthIsRequired() { + /* Setup */ + makeRequirementUnknown() + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: nil) + user.identityModel.jwtBearerToken = "token-a" + let createUser = makeUnsentAnonymousCreateUserRequest() + cacheUserRequests([createUser, makeIdentifyUserRequest(identifying: createUser.identityModel, updating: user.identityModel)]) + let mocks = Mocks { MockUserRequests.setDefaultCreateUserResponses(with: $0, externalId: userA_EUID) } + allowAsyncWorkToRun() + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self), + "nothing may be sent while the requirement is unknown") + + /* When */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + OneSignalCoreMocks.waitUntil("Restored Identify User was not reshaped into a Create User") { + mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self) + } + + /* Then */ + let createUsers = mocks.client.executedRequests.compactMap { $0 as? OSRequestCreateUser } + XCTAssertEqual(createUsers.map { $0.identityModel.externalId }, [userA_EUID], "only the promoted Create User for A may go out") + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + } + + /// A login kept at start while the requirement was unknown, whose user never received an + /// `onesignal_id` and has no Create User left to supply one, can never prepare once auth is known to + /// be off. It has to be dropped rather than hold the queue, or every login behind it is stranded. + func testRestoredIdentifyUserThatCanNeverPrepareIsDroppedOnceAuthIsKnownToBeOff() { + /* Setup */ + makeRequirementUnknown() + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: nil) + let neverCreated = OSIdentityModel(aliases: nil, changeNotifier: OSEventProducer()) + cacheUserRequests([makeIdentifyUserRequest(identifying: neverCreated, updating: user.identityModel)]) + let mocks = Mocks { MockUserRequests.setDefaultCreateUserResponses(with: $0, externalId: userB_EUID) } + + /* When */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + // A login behind the dead Identify User has to go out. + let userB = mocks.createUserInstance(externalId: userB_EUID) + OneSignalUserManagerImpl.sharedInstance._user = userB + mocks.userExecutor.createUser(userB) + OneSignalCoreMocks.waitUntil("Create User behind the dead Identify User was not sent") { + mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self) + } + + /* Then */ + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self, expectedCount: 1)) + } + + private func cacheUserRequests(_ requests: [OSUserRequest]) { + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, withValue: requests) + } + + private func makeIdentifyUserRequest( + identifying identityModelToIdentify: OSIdentityModel, + updating identityModelToUpdate: OSIdentityModel + ) -> OSRequestIdentifyUser { + return OSRequestIdentifyUser( + aliasLabel: OS_EXTERNAL_ID, + aliasId: userA_EUID, + identityModelToIdentify: identityModelToIdentify, + identityModelToUpdate: identityModelToUpdate + ) + } + + /// A Create User for an anonymous user that has not been sent, so its user has no `onesignal_id`. + private func makeUnsentAnonymousCreateUserRequest() -> OSRequestCreateUser { + let pushModel = OSSubscriptionModel( + type: .push, address: nil, subscriptionId: nil, reachable: false, isDisabled: false, changeNotifier: OSEventProducer() + ) + return OSRequestCreateUser( + identityModel: OSIdentityModel(aliases: nil, changeNotifier: OSEventProducer()), + propertiesModel: OSPropertiesModel(changeNotifier: OSEventProducer()), + pushSubscriptionModel: pushModel, + originalPushToken: nil + ) + } + + /// `OneSignalUserMocks.reset()` hydrates the requirement off for non-IV tests, so a test that starts + /// unknown has to clear both the shared config and its cache. + private func makeRequirementUnknown() { + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_USE_IDENTITY_VERIFICATION) + OSCoreMocks.resetSharedJwtConfig() + } +} + /// Upgrade decode of `addsNewRecords` on a cached Create User. final class OSRequestCreateUserArchiveTests: XCTestCase { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift index a29434e21..01dbf284d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift @@ -33,7 +33,7 @@ import OneSignalUserMocks @testable import OneSignalUser /// Covers the JWT bearer token on `OSIdentityModel`: which tokens count as usable, the -/// compare-and-set on invalidation, and what survives an archive round trip. +/// compare-and-set on invalidation, and what survives an archive round trip. Also what `clearData` keeps. final class OSIdentityModelTests: XCTestCase { override func setUpWithError() throws { @@ -59,6 +59,37 @@ final class OSIdentityModelTests: XCTestCase { return try XCTUnwrap(unarchiver.decodeObject(forKey: NSKeyedArchiveRootObjectKey) as? OSIdentityModel) } + // MARK: - clearData() + + /// Work built before the fetch response lands must still read this user as created and identified. + func testClearDataKeepsBothIdsAndDropsEveryOtherAlias() { + let model = OSIdentityModel( + aliases: [OS_ONESIGNAL_ID: userA_OSID, OS_EXTERNAL_ID: userA_EUID, "stale_label": "stale_value"], + changeNotifier: OSEventProducer() + ) + + model.clearData() + + XCTAssertEqual(model.onesignalId, userA_OSID) + XCTAssertEqual(model.externalId, userA_EUID) + XCTAssertNil(model.aliases["stale_label"]) + } + + /// The fetch response merges into what the clear kept, so one without `external_id` leaves the user + /// identified. + func testHydrateAfterClearDataMergesIntoTheKeptExternalId() { + let model = OSIdentityModel( + aliases: [OS_ONESIGNAL_ID: userA_OSID, OS_EXTERNAL_ID: userA_EUID], + changeNotifier: OSEventProducer() + ) + model.clearData() + + model.hydrate([OS_ONESIGNAL_ID: userA_OSID]) + + XCTAssertEqual(model.onesignalId, userA_OSID) + XCTAssertEqual(model.externalId, userA_EUID) + } + // MARK: - getValidJwt() func testGetValidJwtReturnsNilWhenTokenIsNil() { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSRequestAuthTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSRequestAuthTests.swift index 62455a1d7..fe73e7e25 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSRequestAuthTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSRequestAuthTests.swift @@ -238,6 +238,27 @@ final class OSRequestAuthTests: XCTestCase { XCTAssertNil(request.authorizationHeader) } + /// Delete Subscription keeps an owner for the purge; a missing token must not park it or ask the app. + func testAuthorizeSendsAnOwnedExemptRequestUnsignedWithoutAsking() { + let auth = makeAuth(requiresUserAuth: true) + let request = StubUserRequest(ownerExternalId: "user-a", sendsUnsigned: true) + + XCTAssertTrue(auth.authorize(request)) + XCTAssertNil(request.authorizationHeader) + XCTAssertTrue(jwt.askedFor.isEmpty) + XCTAssertFalse(auth.parkedForToken(request)) + } + + /// Not signed even with a token on hand; the endpoint ignores the header. + func testAuthorizeDoesNotSignAnOwnedExemptRequestThatHasAToken() { + let auth = makeAuth(requiresUserAuth: true) + jwt.tokens["user-a"] = "token-a" + let request = StubUserRequest(ownerExternalId: "user-a", sendsUnsigned: true) + + XCTAssertTrue(auth.authorize(request)) + XCTAssertNil(request.authorizationHeader) + } + /// Everything else with no owner is a leftover the purge has yet to clear, and must not go out unsigned. func testAuthorizeRefusesAnUnownedRequestThatIsNotExempt() { let auth = makeAuth(requiresUserAuth: true) @@ -255,6 +276,41 @@ final class OSRequestAuthTests: XCTestCase { XCTAssertNil(request.authorizationHeader) } + // MARK: - parkedForToken + + /// The answer is consumed, so the executor asks once per failed prepare and sees a fresh answer next time. + func testParkedForTokenIsTrueOnceAfterAPark() { + let auth = makeAuth(requiresUserAuth: true) + let request = StubUserRequest(ownerExternalId: "user-a") + + XCTAssertFalse(auth.parkedForToken(request), "nothing has parked it yet") + XCTAssertFalse(auth.authorize(request)) + XCTAssertTrue(auth.parkedForToken(request)) + XCTAssertFalse(auth.parkedForToken(request), "consumed by the read before") + } + + /// A signed authorization is not a park, so a prepare that fails after it failed for some other reason. + func testParkedForTokenIsFalseAfterASignedAuthorization() { + let auth = makeAuth(requiresUserAuth: true) + jwt.tokens["user-a"] = "token-a" + let request = StubUserRequest(ownerExternalId: "user-a") + + XCTAssertTrue(auth.authorize(request)) + XCTAssertFalse(auth.parkedForToken(request)) + } + + /// The entry reflects the latest authorization: a park does not outlive a later signed attempt. + func testASignedAuthorizationClearsAnEarlierPark() { + let auth = makeAuth(requiresUserAuth: true) + let request = StubUserRequest(ownerExternalId: "user-a") + XCTAssertFalse(auth.authorize(request)) + + jwt.tokens["user-a"] = "token-a" + XCTAssertTrue(auth.authorizeUserScoped(request, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, "osid")) != nil) + + XCTAssertFalse(auth.parkedForToken(request)) + } + // MARK: - handleUnauthorized func testHandleUnauthorizedInvalidatesTheSignedTokenAndRequeuesTheRequest() { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtLifecycleTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtLifecycleTests.swift index 58514b954..01401ee1b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtLifecycleTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtLifecycleTests.swift @@ -109,7 +109,7 @@ final class UserJwtLifecycleTests: XCTestCase { /// The Delta `logout()` produces by silencing the push subscription. private func silencingDelta() -> OSDelta? { - return OneSignalUserManagerImpl.sharedInstance.operationRepo.deltaQueue.first { + return OneSignalUserManagerImpl.sharedInstance.operationRepo.snapshotDeltaQueue().first { $0.name == OS_UPDATE_SUBSCRIPTION_DELTA && $0.property == "isDisabledInternally" } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserStateReportingTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserStateReportingTests.swift new file mode 100644 index 000000000..1af0598be --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserStateReportingTests.swift @@ -0,0 +1,211 @@ +/* + 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 class MockUserStateObserver: NSObject, OSUserStateObserver { + private let lock = NSLock() + private var reported: [OSUserState] = [] + + /// Read on the test thread while the SDK reports from its response threads. + var states: [OSUserState] { + return lock.withLock { reported } + } + + func onUserStateDidChange(state: OSUserChangedState) { + lock.withLock { reported.append(state.current) } + } +} + +/** + What the app's `OSUserStateObserver` hears, and what the persisted snapshot names, once a Request + completes for a user who is no longer current. One test per executor site that hydrates an identity model. + */ +final class UserStateReportingTests: XCTestCase { + private var client = MockOneSignalClient() + private var observer = MockUserStateObserver() + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + + client = MockOneSignalClient() + MockUserRequests.setDefaultCreateUserResponses(with: client, externalId: userA_EUID) + MockUserRequests.setDefaultCreateUserResponses(with: client, externalId: userB_EUID) + OneSignalCoreImpl.setSharedClient(client) + + // Held strongly for the test's lifetime: OSObservable keeps observers weakly. + observer = MockUserStateObserver() + OneSignalUserManagerImpl.sharedInstance.addObserver(observer) + } + + override func tearDownWithError() throws { + // A Request still in flight would land mid-next-test and hydrate the shared models under it. + OneSignalCoreMocks.waitUntil("A Request was still in flight at teardown") { self.client.isIdle } + OneSignalUserManagerImpl.sharedInstance.removeObserver(observer) + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = false + OneSignalCoreMocks.clearUserDefaults() + } + + /** + Stepping over a parked Create User lets a later login proceed, so the parked one can complete after + another user is current. Its model still hydrates, since Requests queued behind it need the + `onesignal_id`, but the app has to keep hearing the current user, and the persisted pair has to keep + naming the current user, or the app is told the wrong user is signed in. + */ + func testAParkedCreateUserThatCompletesAfterAUserSwitchDoesNotReportThatUser() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + + // Parks for want of a token, which asks the app. + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + allowAsyncWorkToRun() + // Steps over the parked Create User and becomes the current, reported user. + OneSignalUserManagerImpl.sharedInstance.login(externalId: userB_EUID, token: "token-b") + waitForTheLoginToSettle() + XCTAssertEqual(observer.states.last?.externalId, userB_EUID) + let reportsBeforeA = observer.states.count + + // Answers the ask, so A's Create User goes out while B is current. + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitUntil("A's parked Create User was not sent") { + self.client.executedRequests.contains { ($0 as? OSRequestCreateUser)?.identityModel.externalId == userA_EUID } + } + // The report fires inside the response block, before the mock counts it complete, so idle means decided. + OneSignalCoreMocks.waitUntil("A's Create User was still in flight") { self.client.isIdle } + // Drains the executor queue before teardown. + allowAsyncWorkToRun(seconds: 0.1) + + // Hydrated, so anything queued for A has its onesignal_id. + XCTAssertEqual(OneSignalUserManagerImpl.sharedInstance.identityModelRepo.get(externalId: userA_EUID)?.onesignalId, userA_OSID) + // Not reported: the app hears nothing new, and the persisted pair still names B. + XCTAssertEqual(observer.states.count, reportsBeforeA, "the app must not hear about A: \(observer.states)") + XCTAssertEqual(observer.states.last?.externalId, userB_EUID) + assertPersistedSnapshotNames(externalId: userB_EUID, onesignalId: userB_OSID) + } + + /// A second `login` before the first one's Identify User returns makes another user current. The + /// response still hydrates the first user's model, but the app must not hear that user. + func testAnIdentifyUserThatCompletesAfterAUserSwitchDoesNotReportThatUser() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + MockUserRequests.setDefaultCreateAnonUserResponses(with: client) + MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: userA_EUID) + // The Identify User addresses the anonymous user by onesignal_id, so let its Create User finish. + OneSignalUserManagerImpl.sharedInstance.start() + OneSignalCoreMocks.waitUntil("The anonymous user was not created") { + OneSignalUserManagerImpl.sharedInstance.user.identityModel.onesignalId == anonUserOSID && self.client.isIdle + } + + client.holdResponses = true + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalCoreMocks.waitUntil("The Identify User was not started") { + self.client.startedRequestCount(ofType: OSRequestIdentifyUser.self) == 1 + } + // B becomes current while A's Identify User is in flight. + OneSignalUserManagerImpl.sharedInstance.login(externalId: userB_EUID, token: nil) + client.releaseHeldResponses() + waitForTheLoginToSettle() + + // Still hydrated, for anything queued behind it. + XCTAssertEqual(OneSignalUserManagerImpl.sharedInstance.identityModelRepo.get(externalId: userA_EUID)?.onesignalId, anonUserOSID) + XCTAssertFalse(observer.states.contains { $0.externalId == userA_EUID }, "the app must not hear about A: \(observer.states)") + XCTAssertEqual(observer.states.last?.externalId, userB_EUID) + assertPersistedSnapshotNames(externalId: userB_EUID, onesignalId: userB_OSID) + } + + /// The 3.x upgrade path. A `login` before the legacy player's fetch returns makes an identified user + /// current; the response still hydrates the anonymous model, but the app must not hear that user. + func testAFetchIdentityBySubscriptionThatCompletesAfterALoginDoesNotReportTheAnonymousUser() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + let legacyPlayerId = "legacy_player_id" + let legacyOnesignalId = "legacy_player_onesignal_id" + OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: legacyPlayerId) + client.setMockResponseForRequest( + request: "OSRequestFetchIdentityBySubscription with subscriptionId: \(legacyPlayerId)", + response: MockUserRequests.testIdentityPayload(onesignalId: legacyOnesignalId, externalId: nil) + ) + client.setMockResponseForRequest( + request: "", + response: MockUserRequests.testIdentityPayload(onesignalId: legacyOnesignalId, externalId: userA_EUID) + ) + client.setMockResponseForRequest( + request: "", + response: MockUserRequests.testIdentityPayload(onesignalId: legacyOnesignalId, externalId: userA_EUID) + ) + client.holdResponses = true + + // Migrates the legacy player; its identity fetch is held. + OneSignalUserManagerImpl.sharedInstance.start() + let anonymousModel = OneSignalUserManagerImpl.sharedInstance.user.identityModel + XCTAssertNil(anonymousModel.onesignalId) + OneSignalCoreMocks.waitUntil("The Fetch Identity By Subscription was not started") { + self.client.startedRequestCount(ofType: OSRequestFetchIdentityBySubscription.self) == 1 + } + // A becomes current while the fetch is in flight. + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + client.releaseHeldResponses() + waitForTheLoginToSettle() + + // Still hydrated, for the Identify User behind it. + XCTAssertEqual(anonymousModel.onesignalId, legacyOnesignalId) + XCTAssertFalse(observer.states.contains { $0.externalId == nil }, "the app must not hear the anonymous user: \(observer.states)") + XCTAssertEqual(observer.states.last?.externalId, userA_EUID) + assertPersistedSnapshotNames(externalId: userA_EUID, onesignalId: legacyOnesignalId) + } + + // MARK: - Helpers + + private func assertPersistedSnapshotNames( + externalId: String, onesignalId: String, file: StaticString = #filePath, line: UInt = #line + ) { + let defaults = OneSignalUserDefaults.initShared() + XCTAssertEqual(defaults.getSavedString(forKey: OS_SNAPSHOT_EXTERNAL_ID, defaultValue: nil), externalId, file: file, line: line) + XCTAssertEqual(defaults.getSavedString(forKey: OS_SNAPSHOT_ONESIGNAL_ID, defaultValue: nil), onesignalId, file: file, line: line) + } + + /** + Outlasts every effect of an identified login, including the Fetch User its Create User or Identify + User starts. A fetch landing later would re-report the current user and hide a wrong report made in + between. + */ + private func waitForTheLoginToSettle() { + OneSignalCoreMocks.waitUntil("The login did not reach a reported user") { + OneSignalUserManagerImpl.sharedInstance.user.identityModel.onesignalId != nil + && self.observer.states.contains { $0.onesignalId != nil } + && self.client.hasCompletedRequestOfType(OSRequestFetchUser.self) + && self.client.isIdle + } + allowAsyncWorkToRun(seconds: 0.1) + OneSignalCoreMocks.waitUntil("The login left a Request in flight") { self.client.isIdle } + } +} From 65ae0d318801951bf003f927d31df8e292ec59a8 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 22 Sep 2026 10:42:46 -0700 Subject: [PATCH 19/25] chore: [SDK-5280] raise SDK deployment target to iOS 15 (#1749) (cherry picked from commit 168bf135ce9c4d205869ec9dbf35f3a33f9abea9) --- .github/ISSUE_TEMPLATE/bug-report.yml | 5 +- MIGRATION_GUIDE.md | 2 +- OneSignal.podspec | 2 +- OneSignalXCFramework.podspec | 2 +- Package.swift | 2 +- README.md | 2 +- .../project.pbxproj | 12 +- .../OneSignal.xcodeproj/project.pbxproj | 138 +++++++++--------- 8 files changed, 81 insertions(+), 84 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index f3e5b351a..e9fee10f0 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -56,9 +56,6 @@ body: - "17" - "16" - "15" - - "14" - - "13" - - "12" validations: required: true - type: textarea @@ -69,7 +66,7 @@ body: placeholder: | * iOS 17.2 * iOS 15.4 - * iOS 12.0 + * iOS 15.0 render: Markdown - type: textarea diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 753c20bef..12253fa78 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -48,7 +48,7 @@ OneSignal uses a built-in **alias label** called `external_id` which supports ex As mentioned above, the iOS SDK is making the jump from `v3` to `v5`, in order to align across OneSignal’s suite of client SDKs. ## Requirements -- Minimum deployment target of iOS 11 +- Minimum deployment target of iOS 15 - Requires Xcode 14 - If you are using CocoaPods, please use version `1.11.3+` and Ruby version `2.7.5+`. diff --git a/OneSignal.podspec b/OneSignal.podspec index 7e1124197..e5af52897 100755 --- a/OneSignal.podspec +++ b/OneSignal.podspec @@ -7,7 +7,7 @@ Pod::Spec.new do |s| s.author = { "Joseph Kalash" => "joseph@onesignal.com", "Josh Kasten" => "josh@onesignal.com" , "Brad Hesse" => "brad@onesignal.com"} s.source = { :git => "https://github.com/OneSignal/OneSignal-iOS-SDK.git", :tag => s.version.to_s } - s.platform = :ios, "12.0" + s.platform = :ios, "15.0" s.pod_target_xcconfig = { 'IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]' => '14.0' } diff --git a/OneSignalXCFramework.podspec b/OneSignalXCFramework.podspec index f9c016afc..72e2c72bd 100644 --- a/OneSignalXCFramework.podspec +++ b/OneSignalXCFramework.podspec @@ -7,7 +7,7 @@ Pod::Spec.new do |s| s.author = { "Joseph Kalash" => "joseph@onesignal.com", "Josh Kasten" => "josh@onesignal.com" , "Brad Hesse" => "brad@onesignal.com"} s.source = { :git => "https://github.com/OneSignal/OneSignal-iOS-SDK.git", :tag => s.version.to_s } - s.platform = :ios, '12.0' + s.platform = :ios, '15.0' s.pod_target_xcconfig = { 'IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]' => '14.0' } diff --git a/Package.swift b/Package.swift index 669d5d5b8..456a093b4 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "OneSignalFramework", // Package name MUST be on line 7 for release automation platforms: [ - .iOS(.v12), + .iOS(.v15), .macCatalyst(.v14), ], products: [ diff --git a/README.md b/README.md index 8c2c639bc..8025ac6a8 100644 --- a/README.md +++ b/README.md @@ -32,4 +32,4 @@ For account issues and support please contact OneSignal support from the [OneSig #### Supports: * Swift and Objective-C Projects -* Supports iOS 12 to iOS 26 +* Supports iOS 15 to iOS 26 diff --git a/iOS_SDK/OneSignalDevApp/OneSignalExample.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalDevApp/OneSignalExample.xcodeproj/project.pbxproj index 72bc0a65a..b8a442548 100644 --- a/iOS_SDK/OneSignalDevApp/OneSignalExample.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalDevApp/OneSignalExample.xcodeproj/project.pbxproj @@ -828,7 +828,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = OneSignalExample; @@ -871,7 +871,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; PRODUCT_NAME = OneSignalExample; SDKROOT = iphoneos; @@ -896,7 +896,7 @@ "$(PROJECT_DIR)", ); INFOPLIST_FILE = OneSignalDevApp/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -928,7 +928,7 @@ "$(PROJECT_DIR)", ); INFOPLIST_FILE = OneSignalDevApp/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1123,7 +1123,7 @@ OS_APP_CLIP, ); INFOPLIST_FILE = OneSignalDevAppClip/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1162,7 +1162,7 @@ GCC_C_LANGUAGE_STANDARD = gnu11; GCC_PREPROCESSOR_DEFINITIONS = OS_APP_CLIP; INFOPLIST_FILE = OneSignalDevAppClip/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 200aef9f2..defac0464 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -5544,7 +5544,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = NO; @@ -5597,7 +5597,7 @@ "$(inherited)", ); GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; @@ -5646,7 +5646,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = NO; @@ -5711,7 +5711,7 @@ INFOPLIST_FILE = OneSignalOSCoreFramework/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -5791,7 +5791,7 @@ INFOPLIST_FILE = OneSignalOSCoreFramework/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -5853,7 +5853,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5919,7 +5919,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5981,7 +5981,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -6041,7 +6041,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -6108,7 +6108,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -6171,7 +6171,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -6232,7 +6232,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -6299,7 +6299,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -6362,7 +6362,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -6419,7 +6419,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = NO; @@ -6473,7 +6473,7 @@ "$(inherited)", ); GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; @@ -6524,7 +6524,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = NO; @@ -6577,7 +6577,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -6644,7 +6644,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -6707,7 +6707,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -6763,7 +6763,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = NO; @@ -6816,7 +6816,7 @@ "$(inherited)", ); GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; @@ -6866,7 +6866,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = NO; @@ -6914,7 +6914,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = NO; @@ -6966,7 +6966,7 @@ "$(inherited)", ); GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; @@ -7015,7 +7015,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = NO; @@ -7070,7 +7070,7 @@ INFOPLIST_FILE = OneSignalLiveActivitiesFramework/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -7144,7 +7144,7 @@ INFOPLIST_FILE = OneSignalLiveActivitiesFramework/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -7214,7 +7214,7 @@ INFOPLIST_FILE = OneSignalLiveActivitiesFramework/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -7280,7 +7280,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = NO; @@ -7340,7 +7340,7 @@ "$(inherited)", ); GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; @@ -7396,7 +7396,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = NO; @@ -7447,7 +7447,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; GENERATE_MASTER_OBJECT_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "-all_load", @@ -7478,7 +7478,7 @@ "$(inherited)", "$(PROJECT_DIR)", ); - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -7522,7 +7522,7 @@ GCC_WARN_64_TO_32_BIT_CONVERSION = NO; INFOPLIST_FILE = OneSignalFramework/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -7572,7 +7572,7 @@ GCC_PREPROCESSOR_DEFINITIONS = OS_TEST; HEADER_SEARCH_PATHS = $CONFIGURATION_TEMP_DIR/UnitTests.build/DerivedSources; INFOPLIST_FILE = UnitTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -7652,7 +7652,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; GENERATE_MASTER_OBJECT_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "-all_load", @@ -7683,7 +7683,7 @@ "$(inherited)", "$(PROJECT_DIR)", ); - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -7728,7 +7728,7 @@ GCC_WARN_64_TO_32_BIT_CONVERSION = NO; INFOPLIST_FILE = OneSignalFramework/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -7779,7 +7779,7 @@ GCC_PREPROCESSOR_DEFINITIONS = OS_TEST; HEADER_SEARCH_PATHS = $CONFIGURATION_TEMP_DIR/UnitTests.build/DerivedSources; INFOPLIST_FILE = UnitTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -7859,7 +7859,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; GENERATE_MASTER_OBJECT_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "-all_load", @@ -7890,7 +7890,7 @@ "$(inherited)", "$(PROJECT_DIR)", ); - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -7935,7 +7935,7 @@ GCC_WARN_64_TO_32_BIT_CONVERSION = NO; INFOPLIST_FILE = OneSignalFramework/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -7986,7 +7986,7 @@ GCC_PREPROCESSOR_DEFINITIONS = OS_TEST; HEADER_SEARCH_PATHS = $CONFIGURATION_TEMP_DIR/UnitTests.build/DerivedSources; INFOPLIST_FILE = UnitTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -8078,7 +8078,7 @@ GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = OS_TEST; INFOPLIST_FILE = UnitTestApp/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 13.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -8150,7 +8150,7 @@ INFOPLIST_FILE = OneSignalCoreFramework/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2021 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -8230,7 +8230,7 @@ INFOPLIST_FILE = OneSignalExtensionFramework/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2021 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -8295,7 +8295,7 @@ INFOPLIST_FILE = OneSignalOutcomesFramework/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2021 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -8358,7 +8358,7 @@ INFOPLIST_FILE = OneSignalUserFramework/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -8420,7 +8420,7 @@ INFOPLIST_FILE = OneSignalUserFramework/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -8487,7 +8487,7 @@ INFOPLIST_FILE = OneSignalUserFramework/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -8560,7 +8560,7 @@ INFOPLIST_FILE = OneSignalCoreFramework/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2021 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -8653,7 +8653,7 @@ INFOPLIST_FILE = OneSignalCoreFramework/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2021 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -8729,7 +8729,7 @@ INFOPLIST_FILE = OneSignalExtensionFramework/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2021 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -8797,7 +8797,7 @@ INFOPLIST_FILE = OneSignalExtensionFramework/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2021 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -8858,7 +8858,7 @@ INFOPLIST_FILE = OneSignalOutcomesFramework/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2021 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -8925,7 +8925,7 @@ INFOPLIST_FILE = OneSignalOutcomesFramework/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2021 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -9131,7 +9131,7 @@ INFOPLIST_FILE = OneSignalLocationFramework/Info.plist; INFOPLIST_KEY_NSPrincipalClass = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -9199,7 +9199,7 @@ INFOPLIST_FILE = OneSignalLocationFramework/Info.plist; INFOPLIST_KEY_NSPrincipalClass = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -9263,7 +9263,7 @@ INFOPLIST_FILE = OneSignalLocationFramework/Info.plist; INFOPLIST_KEY_NSPrincipalClass = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -9324,7 +9324,7 @@ INFOPLIST_FILE = OneSignalInAppMessagesFramework/Info.plist; INFOPLIST_KEY_NSPrincipalClass = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -9392,7 +9392,7 @@ INFOPLIST_FILE = OneSignalInAppMessagesFramework/Info.plist; INFOPLIST_KEY_NSPrincipalClass = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -9456,7 +9456,7 @@ INFOPLIST_FILE = OneSignalInAppMessagesFramework/Info.plist; INFOPLIST_KEY_NSPrincipalClass = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -9535,7 +9535,7 @@ GCC_PREPROCESSOR_DEFINITIONS = OS_TEST; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OneSignalOSCoreFramework/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -9585,7 +9585,7 @@ GCC_C_LANGUAGE_STANDARD = gnu11; GCC_PREPROCESSOR_DEFINITIONS = OS_TEST; INFOPLIST_FILE = UnitTestApp/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 13.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -9639,7 +9639,7 @@ GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = OS_TEST; INFOPLIST_FILE = UnitTestApp/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 13.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -9697,7 +9697,7 @@ INFOPLIST_FILE = OneSignalNotificationsFramework/Info.plist; INFOPLIST_KEY_NSPrincipalClass = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -9764,7 +9764,7 @@ INFOPLIST_FILE = OneSignalNotificationsFramework/Info.plist; INFOPLIST_KEY_NSPrincipalClass = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -9826,7 +9826,7 @@ INFOPLIST_FILE = OneSignalNotificationsFramework/Info.plist; INFOPLIST_KEY_NSPrincipalClass = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", From a260de09d87e8433767e4f04e502b318ef5e6bdf Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 28 Sep 2026 09:19:32 -0700 Subject: [PATCH 20/25] chore: [SDK-5346] raise Mac Catalyst deployment target to 15 (#1757) (cherry picked from commit 1798e57e3a8eed4a88a187c6c92341b15268c862) --- .github/workflows/ci.yml | 2 +- OneSignal.podspec | 3 -- OneSignalXCFramework.podspec | 3 -- Package.swift | 2 +- .../OneSignal.xcodeproj/project.pbxproj | 33 ------------------- 5 files changed, 2 insertions(+), 41 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f08eea3e..deef85e1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -192,7 +192,7 @@ jobs: frameworks="$RUNNER_TEMP/OneSignalFramework-Catalyst.xcarchive/Products/Library/Frameworks" host="$RUNNER_TEMP/CatalystLoggerHost" xcrun --sdk macosx swiftc \ - -target "$(uname -m)-apple-ios14.0-macabi" \ + -target "$(uname -m)-apple-ios15.0-macabi" \ -F "$frameworks" \ -framework OneSignalOSCore \ -framework OneSignalCore \ diff --git a/OneSignal.podspec b/OneSignal.podspec index e5af52897..f42a2ac7c 100755 --- a/OneSignal.podspec +++ b/OneSignal.podspec @@ -8,9 +8,6 @@ Pod::Spec.new do |s| s.source = { :git => "https://github.com/OneSignal/OneSignal-iOS-SDK.git", :tag => s.version.to_s } s.platform = :ios, "15.0" - s.pod_target_xcconfig = { - 'IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]' => '14.0' - } s.requires_arc = true s.default_subspec = "OneSignalComplete" diff --git a/OneSignalXCFramework.podspec b/OneSignalXCFramework.podspec index 72e2c72bd..ac3839c0e 100644 --- a/OneSignalXCFramework.podspec +++ b/OneSignalXCFramework.podspec @@ -8,9 +8,6 @@ Pod::Spec.new do |s| s.source = { :git => "https://github.com/OneSignal/OneSignal-iOS-SDK.git", :tag => s.version.to_s } s.platform = :ios, '15.0' - s.pod_target_xcconfig = { - 'IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]' => '14.0' - } s.requires_arc = true s.default_subspec = "OneSignalComplete" diff --git a/Package.swift b/Package.swift index 456a093b4..c5af78657 100644 --- a/Package.swift +++ b/Package.swift @@ -7,7 +7,7 @@ let package = Package( name: "OneSignalFramework", // Package name MUST be on line 7 for release automation platforms: [ .iOS(.v15), - .macCatalyst(.v14), + .macCatalyst(.v15), ], products: [ .library( diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index defac0464..488cc6a76 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -5712,7 +5712,6 @@ INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5792,7 +5791,6 @@ INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -7071,7 +7069,6 @@ INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -7145,7 +7142,6 @@ INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -7215,7 +7211,6 @@ INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -7523,7 +7518,6 @@ INFOPLIST_FILE = OneSignalFramework/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -7729,7 +7723,6 @@ INFOPLIST_FILE = OneSignalFramework/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -7936,7 +7929,6 @@ INFOPLIST_FILE = OneSignalFramework/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -8079,7 +8071,6 @@ GCC_PREPROCESSOR_DEFINITIONS = OS_TEST; INFOPLIST_FILE = UnitTestApp/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 13.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -8151,7 +8142,6 @@ INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2021 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -8231,7 +8221,6 @@ INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2021 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -8296,7 +8285,6 @@ INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2021 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -8359,7 +8347,6 @@ INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -8421,7 +8408,6 @@ INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -8488,7 +8474,6 @@ INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -8561,7 +8546,6 @@ INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2021 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -8654,7 +8638,6 @@ INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2021 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -8730,7 +8713,6 @@ INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2021 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -8798,7 +8780,6 @@ INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2021 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -8859,7 +8840,6 @@ INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2021 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -8926,7 +8906,6 @@ INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2021 Hiptic. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -9132,7 +9111,6 @@ INFOPLIST_KEY_NSPrincipalClass = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -9200,7 +9178,6 @@ INFOPLIST_KEY_NSPrincipalClass = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -9264,7 +9241,6 @@ INFOPLIST_KEY_NSPrincipalClass = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -9325,7 +9301,6 @@ INFOPLIST_KEY_NSPrincipalClass = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -9393,7 +9368,6 @@ INFOPLIST_KEY_NSPrincipalClass = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -9457,7 +9431,6 @@ INFOPLIST_KEY_NSPrincipalClass = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -9536,7 +9509,6 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OneSignalOSCoreFramework/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -9586,7 +9558,6 @@ GCC_PREPROCESSOR_DEFINITIONS = OS_TEST; INFOPLIST_FILE = UnitTestApp/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 13.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -9640,7 +9611,6 @@ GCC_PREPROCESSOR_DEFINITIONS = OS_TEST; INFOPLIST_FILE = UnitTestApp/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 13.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -9698,7 +9668,6 @@ INFOPLIST_KEY_NSPrincipalClass = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -9765,7 +9734,6 @@ INFOPLIST_KEY_NSPrincipalClass = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -9827,7 +9795,6 @@ INFOPLIST_KEY_NSPrincipalClass = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", From 8f412f6ef6fce3c4c0d307e1277b76c8d42c04b2 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 28 Sep 2026 11:54:07 -0700 Subject: [PATCH 21/25] chore: [SDK-5307] remove stale compatibility code for iOS below 15 (#1756) (cherry picked from commit 513c0aec6fde61180f43d6c593371afd4a36b3a3) --- .../OneSignalDevApp/AppDelegate.m | 10 +-- .../LiveActivityController.swift | 3 - .../OneSignalDevApp/ViewController.m | 22 +++--- .../OneSignal.xcodeproj/project.pbxproj | 4 + .../OneSignalCore/Source/OSBundleUtils.m | 5 +- .../OneSignalCoreMocks.swift | 20 ++--- .../OneSignalAttachmentHandler.m | 79 +++---------------- .../Controller/OSMessagingController.m | 8 +- .../UIApplication+OneSignal.m | 9 +-- ...otificationCenter+OneSignalNotifications.m | 6 +- .../OneSignalNotificationSettings.m | 8 +- .../OSNotificationsManager.m | 6 +- .../Source/OSRemoteLoggingController.swift | 6 +- .../Source/OneSignalLifecycleObserver.m | 10 +-- .../OSRemoteLoggingControllerTests.swift | 2 +- .../OneSignalAttachmentHandlerTests.m | 73 +++++++++++++++++ 16 files changed, 125 insertions(+), 146 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/UnitTests/OneSignalAttachmentHandlerTests.m diff --git a/iOS_SDK/OneSignalDevApp/OneSignalDevApp/AppDelegate.m b/iOS_SDK/OneSignalDevApp/OneSignalDevApp/AppDelegate.m index 8c2daa5dd..7fecdd48d 100644 --- a/iOS_SDK/OneSignalDevApp/OneSignalDevApp/AppDelegate.m +++ b/iOS_SDK/OneSignalDevApp/OneSignalDevApp/AppDelegate.m @@ -237,13 +237,9 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotificationWithPayload:notification.request.content.userInfo completion:^(OSNotification *notif) { if (notif) { - if (@available(iOS 14.0, *)) { - completionHandler(UNNotificationPresentationOptionBanner | - UNNotificationPresentationOptionList | - UNNotificationPresentationOptionSound); - } else { - completionHandler(UNNotificationPresentationOptionAlert | UNNotificationPresentationOptionSound); - } + completionHandler(UNNotificationPresentationOptionBanner | + UNNotificationPresentationOptionList | + UNNotificationPresentationOptionSound); } else { completionHandler(UNNotificationPresentationOptionNone); } diff --git a/iOS_SDK/OneSignalDevApp/OneSignalDevApp/LiveActivityController.swift b/iOS_SDK/OneSignalDevApp/OneSignalDevApp/LiveActivityController.swift index f61eab7c1..a6708669e 100644 --- a/iOS_SDK/OneSignalDevApp/OneSignalDevApp/LiveActivityController.swift +++ b/iOS_SDK/OneSignalDevApp/OneSignalDevApp/LiveActivityController.swift @@ -81,7 +81,6 @@ class LiveActivityController: NSObject { An example of starting a Live Activity whose attributes are "OneSignal SDK aware". The SDK will handle listening for update tokens on behalf of the app. */ static var counter1 = 0 - @available(iOS 13.0, *) @objc static func createOneSignalAwareActivity(activityId: String) { if #available(iOS 16.1, *) { @@ -103,7 +102,6 @@ class LiveActivityController: NSObject { /** An example of starting a Live Activity using the DefaultLiveActivityAttributes. The SDK will handle listening for update tokens on behalf of the app. */ - @available(iOS 13.0, *) @objc static func createDefaultActivity(activityId: String) { if #available(iOS 16.1, *) { @@ -118,7 +116,6 @@ class LiveActivityController: NSObject { An example of starting a Live Activity whose attributes are **not** "OneSignal SDK aware". The app must handle listening for update tokens and notify the OneSignal SDK. */ static var counter2 = 0 - @available(iOS 13.0, *) @objc static func createActivity(activityId: String) async { if #available(iOS 16.1, *) { diff --git a/iOS_SDK/OneSignalDevApp/OneSignalDevApp/ViewController.m b/iOS_SDK/OneSignalDevApp/OneSignalDevApp/ViewController.m index 083b34a67..e0c0a2961 100644 --- a/iOS_SDK/OneSignalDevApp/OneSignalDevApp/ViewController.m +++ b/iOS_SDK/OneSignalDevApp/OneSignalDevApp/ViewController.m @@ -238,19 +238,15 @@ - (IBAction)sendUniqueOutcomeEvent:(id)sender { - (IBAction)startAndEnterLiveActivity:(id)sender { #if TARGET_OS_MACCATALYST #else - if (@available(iOS 13.0, *)) { - NSString *activityId = [self.activityId text]; - // Will not make a live activity if activityId is empty - if (activityId && activityId.length) { - // 1. Create a Default activity - // [LiveActivityController createDefaultActivityWithActivityId:activityId ]; - // 2. Create non-OneSignal-aware activity - // [LiveActivityController createActivityWithActivityId:activityId completionHandler:^(void) {} ]; - // 3. Create OneSignal-aware activity - [LiveActivityController createOneSignalAwareActivityWithActivityId:activityId]; - } - } else { - NSLog(@"Must use iOS 13 or later for swift concurrency which is required for [LiveActivityController createActivityWithCompletionHandler..."); + NSString *activityId = [self.activityId text]; + // Will not make a live activity if activityId is empty + if (activityId && activityId.length) { + // 1. Create a Default activity + // [LiveActivityController createDefaultActivityWithActivityId:activityId ]; + // 2. Create non-OneSignal-aware activity + // [LiveActivityController createActivityWithActivityId:activityId completionHandler:^(void) {} ]; + // 3. Create OneSignal-aware activity + [LiveActivityController createOneSignalAwareActivityWithActivityId:activityId]; } #endif } diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 488cc6a76..4a68492ba 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -302,6 +302,7 @@ 473542672B8F93830016DB4C /* OneSignalUserMocks.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3CC063DD2B6D7F2A002BB07F /* OneSignalUserMocks.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 4746E2A72B86B64100D6324C /* LiveActivitiesSwiftTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4746E2A62B86B64100D6324C /* LiveActivitiesSwiftTests.swift */; }; 4746E2AB2B8775C400D6324C /* LiveActivitiesObjcTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 4746E2AA2B8775C400D6324C /* LiveActivitiesObjcTests.m */; }; + 53075307A11C000000000A02 /* OneSignalAttachmentHandlerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 53075307A11C000000000A01 /* OneSignalAttachmentHandlerTests.m */; }; 475F47212B8E398E00EC05B3 /* OneSignalLiveActivities.h in Headers */ = {isa = PBXBuildFile; fileRef = 475F47202B8E398E00EC05B3 /* OneSignalLiveActivities.h */; settings = {ATTRIBUTES = (Public, ); }; }; 475F47242B8E398E00EC05B3 /* OneSignalLiveActivities.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 475F471E2B8E398D00EC05B3 /* OneSignalLiveActivities.framework */; }; 475F47252B8E398E00EC05B3 /* OneSignalLiveActivities.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 475F471E2B8E398D00EC05B3 /* OneSignalLiveActivities.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -1585,6 +1586,7 @@ 4735424C2B8F93340016DB4C /* OSLiveActivitiesExecutorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLiveActivitiesExecutorTests.swift; sourceTree = ""; }; 4746E2A62B86B64100D6324C /* LiveActivitiesSwiftTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitiesSwiftTests.swift; sourceTree = ""; }; 4746E2AA2B8775C400D6324C /* LiveActivitiesObjcTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = LiveActivitiesObjcTests.m; sourceTree = ""; }; + 53075307A11C000000000A01 /* OneSignalAttachmentHandlerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalAttachmentHandlerTests.m; sourceTree = ""; }; 475F471E2B8E398D00EC05B3 /* OneSignalLiveActivities.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = OneSignalLiveActivities.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 475F47202B8E398E00EC05B3 /* OneSignalLiveActivities.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalLiveActivities.h; sourceTree = ""; }; 475F47482B8E3A4400EC05B3 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -2839,6 +2841,7 @@ 3C2C7DC2288E007E0020F9AE /* UnitTests-Bridging-Header.h */, 4746E2A62B86B64100D6324C /* LiveActivitiesSwiftTests.swift */, 4746E2AA2B8775C400D6324C /* LiveActivitiesObjcTests.m */, + 53075307A11C000000000A01 /* OneSignalAttachmentHandlerTests.m */, 3C4319082F4CE9D90075492D /* SessionEndOutcomesRequestTests.swift */, 658E6E9E6BC6BBF702BCBD33 /* OSRemoteLoggingControllerTests.swift */, ); @@ -4911,6 +4914,7 @@ DE5EFECA24D8DBF70032632D /* OSInAppMessageViewControllerOverrider.m in Sources */, DE7D18E12703B49B002D3A5D /* OSFocusRequests.m in Sources */, 4746E2AB2B8775C400D6324C /* LiveActivitiesObjcTests.m in Sources */, + 53075307A11C000000000A02 /* OneSignalAttachmentHandlerTests.m in Sources */, 03CCCC832835D90F004BF794 /* OneSignalUNUserNotificationCenterHelper.m in Sources */, 03866CC12378A67B0009C1D8 /* RestClientAsserts.m in Sources */, 7ADF891C230DB5BD0054E0D6 /* UnitTestAppDelegate.m in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OSBundleUtils.m b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OSBundleUtils.m index dfd407799..63e6e8cec 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OSBundleUtils.m +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OSBundleUtils.m @@ -25,10 +25,7 @@ of this software and associated documentation files (the "Software"), to deal @implementation OSBundleUtils + (BOOL)isAppUsingUIScene { - if (@available(iOS 13.0, *)) { - return [[NSBundle mainBundle] objectForInfoDictionaryKey:@"UIApplicationSceneManifest"] != nil; - } - return NO; + return [[NSBundle mainBundle] objectForInfoDictionaryKey:@"UIApplicationSceneManifest"] != nil; } @end diff --git a/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/OneSignalCoreMocks.swift b/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/OneSignalCoreMocks.swift index 20cddf0cd..a05160c99 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/OneSignalCoreMocks.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/OneSignalCoreMocks.swift @@ -71,10 +71,8 @@ public class OneSignalCoreMocks: NSObject { @objc public static func backgroundApp() { if OSBundleUtils.isAppUsingUIScene() { - if #available(iOS 13.0, *) { - NotificationCenter.default.post(name: UIScene.willDeactivateNotification, object: nil) - NotificationCenter.default.post(name: UIScene.didEnterBackgroundNotification, object: nil) - } + NotificationCenter.default.post(name: UIScene.willDeactivateNotification, object: nil) + NotificationCenter.default.post(name: UIScene.didEnterBackgroundNotification, object: nil) } else { NotificationCenter.default.post(name: UIApplication.willResignActiveNotification, object: nil) NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) @@ -83,10 +81,8 @@ public class OneSignalCoreMocks: NSObject { @objc public static func foregroundApp() { if OSBundleUtils.isAppUsingUIScene() { - if #available(iOS 13.0, *) { - NotificationCenter.default.post(name: UIScene.willEnterForegroundNotification, object: nil) - NotificationCenter.default.post(name: UIScene.didActivateNotification, object: nil) - } + NotificationCenter.default.post(name: UIScene.willEnterForegroundNotification, object: nil) + NotificationCenter.default.post(name: UIScene.didActivateNotification, object: nil) } else { NotificationCenter.default.post(name: UIApplication.willEnterForegroundNotification, object: nil) NotificationCenter.default.post(name: UIApplication.didBecomeActiveNotification, object: nil) @@ -95,9 +91,7 @@ public class OneSignalCoreMocks: NSObject { @objc public static func resignActive() { if OSBundleUtils.isAppUsingUIScene() { - if #available(iOS 13.0, *) { - NotificationCenter.default.post(name: UIScene.willDeactivateNotification, object: nil) - } + NotificationCenter.default.post(name: UIScene.willDeactivateNotification, object: nil) } else { NotificationCenter.default.post(name: UIApplication.willResignActiveNotification, object: nil) } @@ -105,9 +99,7 @@ public class OneSignalCoreMocks: NSObject { @objc public static func becomeActive() { if OSBundleUtils.isAppUsingUIScene() { - if #available(iOS 13.0, *) { - NotificationCenter.default.post(name: UIScene.didActivateNotification, object: nil) - } + NotificationCenter.default.post(name: UIScene.didActivateNotification, object: nil) } else { NotificationCenter.default.post(name: UIApplication.didBecomeActiveNotification, object: nil) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalExtension/OneSignalAttachmentHandler.m b/iOS_SDK/OneSignalSDK/OneSignalExtension/OneSignalAttachmentHandler.m index e955a408e..c3209c6bb 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalExtension/OneSignalAttachmentHandler.m +++ b/iOS_SDK/OneSignalSDK/OneSignalExtension/OneSignalAttachmentHandler.m @@ -47,35 +47,8 @@ @implementation DirectDownloadDelegate @synthesize error, response, done; -(void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data { - NSError * __autoreleasing fileHandleError; - NSError * __autoreleasing *fileHandleErrorPointer = &fileHandleError; - if (@available(iOS 13.0, *)) { - // We need to use NSInvocation for reflection because performSelector cannot take pointer parameters - SEL writeDataSelector = NSSelectorFromString(@"writeData:error:"); - NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:[NSFileHandle instanceMethodSignatureForSelector:writeDataSelector]]; - [invocation setTarget:outputHandle]; - [invocation setSelector:writeDataSelector]; - /* - From Apple's Documentation on NSInvocation: - Indices 0 and 1 indicate the hidden arguments self and _cmd, respectively; - you should set these values directly with the target and selector properties. - Use indices 2 and greater for the arguments normally passed in a message. - */ - [invocation setArgument:&data atIndex:2]; - [invocation setArgument:&fileHandleErrorPointer atIndex:3]; - [invocation invoke]; - } else { - @try { - [outputHandle writeData:data]; - } @catch (NSException *e) { - NSDictionary *userInfo = @{ - NSLocalizedDescriptionKey : @"Failed to write attachment data to filehandle", - }; - fileHandleError = [NSError errorWithDomain:@"com.onesignal.download" code:0 userInfo:userInfo]; - } - } - - if (fileHandleError != nil) { + NSError *fileHandleError; + if (![outputHandle writeData:data error:&fileHandleError]) { [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignal Error encountered while downloading attachment: %@", fileHandleError]]; } } @@ -265,46 +238,16 @@ + (void)addAttachments:(OSNotification*)notification + (UNNotificationAction *)createActionForButton:(NSDictionary *)button { NSString *buttonId = button[@"id"]; NSString *buttonText = button[@"text"]; - - if (@available(iOS 15.0, *)) { - // Using reflection for Xcode versions lower than 13 - id icon; // UNNotificationActionIcon - let UNNotificationActionIconClass = NSClassFromString(@"UNNotificationActionIcon"); - if (UNNotificationActionIconClass) { - if (button[@"systemIcon"]) { - icon = [UNNotificationActionIconClass performSelector:@selector(iconWithSystemImageName:) - withObject:button[@"systemIcon"]]; - } else if (button[@"templateIcon"]) { - icon = [UNNotificationActionIconClass performSelector:@selector(iconWithTemplateImageName:) - withObject:button[@"templateIcon"]]; - } - } - - // We need to use NSInvocation because performSelector only allows up to 2 arguments - SEL actionSelector = NSSelectorFromString(@"actionWithIdentifier:title:options:icon:"); - UNNotificationAction * __unsafe_unretained action; - NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:[UNNotificationAction methodSignatureForSelector:actionSelector]]; - [invocation setTarget:[UNNotificationAction class]]; - [invocation setSelector:actionSelector]; - /* - From Apple's Documentation on NSInvocation: - Indices 0 and 1 indicate the hidden arguments self and _cmd, respectively; - you should set these values directly with the target and selector properties. - Use indices 2 and greater for the arguments normally passed in a message. - */ - NSUInteger actionOption = UNNotificationActionOptionForeground; - [invocation setArgument:&buttonId atIndex:2]; - [invocation setArgument:&buttonText atIndex:3]; - [invocation setArgument:&actionOption atIndex:4]; - [invocation setArgument:&icon atIndex:5]; - [invocation invoke]; - [invocation getReturnValue:&action]; - return action; - } else { - return [UNNotificationAction actionWithIdentifier:buttonId - title:buttonText - options:UNNotificationActionOptionForeground]; + UNNotificationActionIcon *icon; + if (button[@"systemIcon"]) { + icon = [UNNotificationActionIcon iconWithSystemImageName:button[@"systemIcon"]]; + } else if (button[@"templateIcon"]) { + icon = [UNNotificationActionIcon iconWithTemplateImageName:button[@"templateIcon"]]; } + return [UNNotificationAction actionWithIdentifier:buttonId + title:buttonText + options:UNNotificationActionOptionForeground + icon:icon]; } /* diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m index 20d7e0655..7215af95c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m @@ -1370,13 +1370,7 @@ - (void)webViewContentFinishedLoading:(OSInAppMessageInternal *)message { // Required to display if the app is using a Scene // See https://github.com/OneSignal/OneSignal-iOS-SDK/issues/648 - (void)addKeySceneToWindow:(UIWindow*)window { - if (@available(iOS 13.0, *)) { - // The below lines can be replace with this single line once Xcode 10 support is dropped - // window.windowScene = UIApplication.sharedApplication.keyWindow.windowScene; - UIWindow *keyWindow = UIApplication.sharedApplication.keyWindow; - id windowScene = [keyWindow performSelector:@selector(windowScene)]; - [window performSelector:@selector(setWindowScene:) withObject:windowScene]; - } + window.windowScene = UIApplication.sharedApplication.keyWindow.windowScene; } - (void)dynamicTriggerCompleted:(NSString *)triggerId { diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/UIApplication+OneSignal.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/UIApplication+OneSignal.m index d48950e0d..9ffec9c07 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/UIApplication+OneSignal.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/UIApplication+OneSignal.m @@ -33,13 +33,8 @@ @implementation UIApplication (OneSignal) + (BOOL)applicationIsActive { if ([OSBundleUtils isAppUsingUIScene] && [NSThread isMainThread]) { - if (@available(iOS 13.0, *)) { - UIWindow *keyWindow = UIApplication.sharedApplication.keyWindow; - id windowScene = [keyWindow performSelector:@selector(windowScene)]; - id session = [windowScene performSelector:@selector(session)]; - id scene = [session performSelector:@selector(scene)]; - return [scene performSelector:@selector(activationState)] == 0; - } + UIWindow *keyWindow = UIApplication.sharedApplication.keyWindow; + return keyWindow.windowScene.activationState == UISceneActivationStateForegroundActive; } return [[UIApplication sharedApplication] applicationState] == UIApplicationStateActive; } diff --git a/iOS_SDK/OneSignalSDK/OneSignalNotifications/Categories/UNUserNotificationCenter+OneSignalNotifications.m b/iOS_SDK/OneSignalSDK/OneSignalNotifications/Categories/UNUserNotificationCenter+OneSignalNotifications.m index 24cca02dd..58c11d141 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalNotifications/Categories/UNUserNotificationCenter+OneSignalNotifications.m +++ b/iOS_SDK/OneSignalSDK/OneSignalNotifications/Categories/UNUserNotificationCenter+OneSignalNotifications.m @@ -345,8 +345,8 @@ + (void)forwardReceivedNotificationResponseWithCenter:(UNUserNotificationCenter // Or call a legacy AppDelegate selector // - If not a dismiss event as their isn't a iOS 9 selector for it. else if (![OneSignalNotificationsUNUserNotificationCenter isDismissEvent:response]) { - BOOL isTextReply = [response isKindOfClass:NSClassFromString(@"UNTextInputNotificationResponse")]; - NSString* userText = isTextReply ? [response valueForKey:@"userText"] : nil; + BOOL isTextReply = [response isKindOfClass:[UNTextInputNotificationResponse class]]; + NSString* userText = isTextReply ? ((UNTextInputNotificationResponse *)response).userText : nil; [OneSignalNotificationsUNUserNotificationCenter callLegacyAppDeletegateSelector:response.notification isTextReply:isTextReply actionIdentifier:response.actionIdentifier @@ -406,7 +406,7 @@ The iOS SDK used to call some local notification selectors (such as didReceiveLo the SDK will now print warnings when a developer's app implements these selectors */ BOOL isCustomAction = actionIdentifier && ![@"com.apple.UNNotificationDefaultActionIdentifier" isEqualToString:actionIdentifier]; - BOOL isRemote = [notification.request.trigger isKindOfClass:NSClassFromString(@"UNPushNotificationTrigger")]; + BOOL isRemote = [notification.request.trigger isKindOfClass:[UNPushNotificationTrigger class]]; if (isRemote) { NSDictionary* remoteUserInfo = notification.request.content.userInfo; diff --git a/iOS_SDK/OneSignalSDK/OneSignalNotifications/NotificationSettings/OneSignalNotificationSettings.m b/iOS_SDK/OneSignalSDK/OneSignalNotifications/NotificationSettings/OneSignalNotificationSettings.m index 86bafd846..5f80874fd 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalNotifications/NotificationSettings/OneSignalNotificationSettings.m +++ b/iOS_SDK/OneSignalSDK/OneSignalNotifications/NotificationSettings/OneSignalNotificationSettings.m @@ -74,12 +74,8 @@ - (void)getNotificationPermissionState:(void (^)(OSPermissionStateInternal *subs status.answeredPrompt = settings.authorizationStatus != UNAuthorizationStatusNotDetermined && settings.authorizationStatus != provisionalStatus; status.provisional = (settings.authorizationStatus == 3); status.accepted = settings.authorizationStatus == UNAuthorizationStatusAuthorized && !status.provisional; - if (@available(iOS 14.0, *)) { - status.ephemeral = (settings.authorizationStatus == AUTH_STATUS_EPHEMERAL); - status.accepted = status.accepted || status.ephemeral; - } else { - status.ephemeral = false; - } + status.ephemeral = (settings.authorizationStatus == AUTH_STATUS_EPHEMERAL); + status.accepted = status.accepted || status.ephemeral; status.notificationTypes = (settings.badgeSetting == UNNotificationSettingEnabled ? 1 : 0) + (settings.soundSetting == UNNotificationSettingEnabled ? 2 : 0) diff --git a/iOS_SDK/OneSignalSDK/OneSignalNotifications/OSNotificationsManager.m b/iOS_SDK/OneSignalSDK/OneSignalNotifications/OSNotificationsManager.m index 6e70adddf..d855d0c7a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalNotifications/OSNotificationsManager.m +++ b/iOS_SDK/OneSignalSDK/OneSignalNotifications/OSNotificationsManager.m @@ -264,10 +264,8 @@ + (void)registerLifecycleObserver { } + (void)registerLifecycleObserverAsUIScene { - if (@available(iOS 13.0, *)) { - [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"OSNotificationManager registering for Scene Lifecycle notifications"]; - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(willEnterForeground) name:@"UISceneWillEnterForegroundNotification" object:nil]; - } + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"OSNotificationManager registering for Scene Lifecycle notifications"]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(willEnterForeground) name:UISceneWillEnterForegroundNotification object:nil]; } + (void)registerLifecycleObserverAsUIApplication { diff --git a/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift b/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift index b60a1ad77..a957bdcd4 100644 --- a/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift +++ b/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift @@ -261,9 +261,9 @@ final class OSRemoteLoggingController: NSObject, OSInternalLogSink { private extension OSRemoteLoggingController { func registerLifecycleObservers() { if usesScenes() { - observe(Notification.Name("UISceneDidActivateNotification"), appState: "foreground") - observe(Notification.Name("UISceneWillDeactivateNotification"), appState: "unknown") - observe(Notification.Name("UISceneDidEnterBackgroundNotification"), appState: "background", flush: true) + observe(UIScene.didActivateNotification, appState: "foreground") + observe(UIScene.willDeactivateNotification, appState: "unknown") + observe(UIScene.didEnterBackgroundNotification, appState: "background", flush: true) } else { observe(UIApplication.didBecomeActiveNotification, appState: "foreground") observe(UIApplication.willResignActiveNotification, appState: "unknown") diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignalLifecycleObserver.m b/iOS_SDK/OneSignalSDK/Source/OneSignalLifecycleObserver.m index bd20945c2..a2e9a9f08 100644 --- a/iOS_SDK/OneSignalSDK/Source/OneSignalLifecycleObserver.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignalLifecycleObserver.m @@ -58,12 +58,10 @@ + (void)registerLifecycleObserver { } + (void)registerLifecycleObserverAsUIScene { - if (@available(iOS 13.0, *)) { - [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"registering for Scene Lifecycle notifications"]; - [[NSNotificationCenter defaultCenter] addObserver:[OneSignalLifecycleObserver sharedInstance] selector:@selector(didEnterBackground) name:@"UISceneDidEnterBackgroundNotification" object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:[OneSignalLifecycleObserver sharedInstance] selector:@selector(didBecomeActive) name:@"UISceneDidActivateNotification" object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:[OneSignalLifecycleObserver sharedInstance] selector:@selector(willResignActive) name:@"UISceneWillDeactivateNotification" object:nil]; - } + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"registering for Scene Lifecycle notifications"]; + [[NSNotificationCenter defaultCenter] addObserver:[OneSignalLifecycleObserver sharedInstance] selector:@selector(didEnterBackground) name:UISceneDidEnterBackgroundNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:[OneSignalLifecycleObserver sharedInstance] selector:@selector(didBecomeActive) name:UISceneDidActivateNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:[OneSignalLifecycleObserver sharedInstance] selector:@selector(willResignActive) name:UISceneWillDeactivateNotification object:nil]; } + (void)registerLifecycleObserverAsUIApplication { diff --git a/iOS_SDK/OneSignalSDK/UnitTests/OSRemoteLoggingControllerTests.swift b/iOS_SDK/OneSignalSDK/UnitTests/OSRemoteLoggingControllerTests.swift index 7ba395a26..4fbe32a0a 100644 --- a/iOS_SDK/OneSignalSDK/UnitTests/OSRemoteLoggingControllerTests.swift +++ b/iOS_SDK/OneSignalSDK/UnitTests/OSRemoteLoggingControllerTests.swift @@ -217,7 +217,7 @@ final class OSRemoteLoggingControllerTests: XCTestCase { wait(for: [telemetry.flushExpectation!], timeout: 0.2) telemetry.flushExpectation = expectation(description: "flushes on scene background") - notificationCenter.post(name: Notification.Name("UISceneDidEnterBackgroundNotification"), object: nil) + notificationCenter.post(name: UIScene.didEnterBackgroundNotification, object: nil) wait(for: [telemetry.flushExpectation!], timeout: 2) } diff --git a/iOS_SDK/OneSignalSDK/UnitTests/OneSignalAttachmentHandlerTests.m b/iOS_SDK/OneSignalSDK/UnitTests/OneSignalAttachmentHandlerTests.m new file mode 100644 index 000000000..9a0bb9bfc --- /dev/null +++ b/iOS_SDK/OneSignalSDK/UnitTests/OneSignalAttachmentHandlerTests.m @@ -0,0 +1,73 @@ +/* + 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 +#import +#import + +@interface OneSignalAttachmentHandlerTests : XCTestCase +@end + +@implementation OneSignalAttachmentHandlerTests + +- (void)testCreateActionForButtonWithSystemIcon { + UNNotificationAction *action = [OneSignalAttachmentHandler createActionForButton:@{ + @"id": @"id1", + @"text": @"text1", + @"systemIcon": @"hand.thumbsup" + }]; + + XCTAssertEqualObjects(action.identifier, @"id1"); + XCTAssertEqualObjects(action.title, @"text1"); + XCTAssertEqual(action.options, UNNotificationActionOptionForeground); + XCTAssertNotNil(action.icon); +} + +- (void)testCreateActionForButtonWithTemplateIcon { + UNNotificationAction *action = [OneSignalAttachmentHandler createActionForButton:@{ + @"id": @"id1", + @"text": @"text1", + @"templateIcon": @"myImage/thumbsup" + }]; + + XCTAssertEqualObjects(action.identifier, @"id1"); + XCTAssertNotNil(action.icon); +} + +- (void)testCreateActionForButtonWithoutIcon { + UNNotificationAction *action = [OneSignalAttachmentHandler createActionForButton:@{ + @"id": @"id1", + @"text": @"text1" + }]; + + XCTAssertEqualObjects(action.identifier, @"id1"); + XCTAssertEqualObjects(action.title, @"text1"); + XCTAssertEqual(action.options, UNNotificationActionOptionForeground); + XCTAssertNil(action.icon); +} + +@end From 775f27f743dddc4030bd679a4f8378dab2021e79 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 5 Oct 2026 14:20:10 -0700 Subject: [PATCH 22/25] fix: [SDK-5313] run in-app message trigger and pause changes on the main queue (#1755) (cherry picked from commit 3f367c796d2d7ffc51cdcceb3382afd8065d8dd1) --- .../OneSignal.xcodeproj/project.pbxproj | 4 + .../Controller/OSMessagingController.m | 64 ++++-- .../IAMIntegrationTests.swift | 12 +- .../TriggerThreadingTests.swift | 183 ++++++++++++++++++ 4 files changed, 243 insertions(+), 20 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/TriggerThreadingTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 4a68492ba..1649f95a7 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -114,6 +114,7 @@ 3C2DB2F12DE6CB5E0006B905 /* OneSignalBadgeHelpers.h in Headers */ = {isa = PBXBuildFile; fileRef = 3C2DB2EF2DE6CB5E0006B905 /* OneSignalBadgeHelpers.h */; settings = {ATTRIBUTES = (Public, ); }; }; 3C2DB2F22DE6CB5E0006B905 /* OneSignalBadgeHelpers.m in Sources */ = {isa = PBXBuildFile; fileRef = 3C2DB2F02DE6CB5E0006B905 /* OneSignalBadgeHelpers.m */; }; 3C30FE362F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C30FE352F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift */; }; + 3C8B41D82F7E9A6C001B9C25 /* TriggerThreadingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8B41D72F7E9A6C001B9C25 /* TriggerThreadingTests.swift */; }; 3C3D34E92E95EAA5006A2924 /* LiveActivityConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3D34E82E95EAA5006A2924 /* LiveActivityConstants.swift */; }; 3C3D8D782E92DB7500C3E977 /* OSLiveActivityViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3D8D772E92DB7500C3E977 /* OSLiveActivityViewExtensions.swift */; }; 3C427AC9301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C427AC8301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift */; }; @@ -1429,6 +1430,7 @@ 3C2DB2EF2DE6CB5E0006B905 /* OneSignalBadgeHelpers.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalBadgeHelpers.h; sourceTree = ""; }; 3C2DB2F02DE6CB5E0006B905 /* OneSignalBadgeHelpers.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalBadgeHelpers.m; sourceTree = ""; }; 3C30FE352F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EarlyTriggerTrackingTests.swift; sourceTree = ""; }; + 3C8B41D72F7E9A6C001B9C25 /* TriggerThreadingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerThreadingTests.swift; sourceTree = ""; }; 3C3D34E82E95EAA5006A2924 /* LiveActivityConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityConstants.swift; sourceTree = ""; }; 3C3D8D772E92DB7500C3E977 /* OSLiveActivityViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLiveActivityViewExtensions.swift; sourceTree = ""; }; 3C427AC8301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSOperationRepoFlushTests.swift; sourceTree = ""; }; @@ -2354,6 +2356,7 @@ 3C7021E82ECF0CF4001768C6 /* IAMIntegrationTests.swift */, 3CAA4BB62F0BAFBA00A16682 /* TriggerTests.swift */, 3C30FE352F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift */, + 3C8B41D72F7E9A6C001B9C25 /* TriggerThreadingTests.swift */, 3CB35FCA2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift */, 3C7021E72ECF0CF3001768C6 /* OneSignalInAppMessagesTests-Bridging-Header.h */, 67ECA2928D863073B785F93F /* IamFetchIdentityVerificationTests.swift */, @@ -4609,6 +4612,7 @@ buildActionMask = 2147483647; files = ( 3C30FE362F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift in Sources */, + 3C8B41D82F7E9A6C001B9C25 /* TriggerThreadingTests.swift in Sources */, 3CAA4BB72F0BAFBA00A16682 /* TriggerTests.swift in Sources */, 3C7021E92ECF0CF4001768C6 /* IAMIntegrationTests.swift in Sources */, 3C01519C2C2E29F90079E076 /* IAMRequestTests.m in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m index 7215af95c..704316abb 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m @@ -198,16 +198,35 @@ + (void)start { - (BOOL)isInAppMessagingPaused { return _isInAppMessagingPaused; } + +/// The message collections and trigger evaluation belong to the main queue. Entry points that can +/// arrive on any thread go through here, inline on the main thread and dispatched asynchronously from +/// anywhere else. +- (void)runOnMainQueue:(void (^)(void))block { + if ([NSThread isMainThread]) { + block(); + } else { + dispatch_async(dispatch_get_main_queue(), block); + } +} + - (void)setInAppMessagingPaused:(BOOL)pause { + // Written on the calling thread so a read right after this call sees the new value. _isInAppMessagingPaused = pause; // If IAM are not paused, try to evaluate and show IAMs if (!pause) { - [self evaluateMessages]; - } else if (self.isInAppMessageShowing) { - dispatch_async(dispatch_get_main_queue(), ^{ - [self.viewController dismissCurrentInAppMessage]; - }); + [self runOnMainQueue:^{ + [self evaluateMessages]; + }]; + } else { + [self runOnMainQueue:^{ + if (self.isInAppMessageShowing) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self.viewController dismissCurrentInAppMessage]; + }); + } + }]; } } @@ -1025,25 +1044,34 @@ - (void)evaluateRedisplayedInAppMessages:(NSArray *)newTriggersKeys #pragma mark Trigger Methods - (void)addTriggers:(NSDictionary *)triggers { - [self evaluateRedisplayedInAppMessages:triggers.allKeys]; - - // Track triggers added early on cold start (before first fetch completes) for redisplay logic - if (!self.hasCompletedFirstFetch) { - [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"Tracking triggers added early on cold start: %@", triggers]]; - [self.earlySessionTriggers addObjectsFromArray:triggers.allKeys]; - } + NSDictionary *newTriggers = [triggers copy]; + [self runOnMainQueue:^{ + [self evaluateRedisplayedInAppMessages:newTriggers.allKeys]; + + // Track triggers added early on cold start (before first fetch completes) for redisplay logic + if (!self.hasCompletedFirstFetch) { + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"Tracking triggers added early on cold start: %@", newTriggers]]; + [self.earlySessionTriggers addObjectsFromArray:newTriggers.allKeys]; + } - [self.triggerController addTriggers:triggers]; + [self.triggerController addTriggers:newTriggers]; + }]; } - (void)removeTriggersForKeys:(NSArray *)keys { - [self evaluateRedisplayedInAppMessages:keys]; - [self.triggerController removeTriggersForKeys:keys]; + NSArray *removedKeys = [keys copy]; + [self runOnMainQueue:^{ + [self evaluateRedisplayedInAppMessages:removedKeys]; + [self.triggerController removeTriggersForKeys:removedKeys]; + }]; } - (void)clearTriggers { - NSDictionary *allTriggers = [self getTriggers]; - [self removeTriggersForKeys:allTriggers.allKeys]; + [self runOnMainQueue:^{ + // Read the keys on the main queue so a trigger queued before this call is cleared too. + NSDictionary *allTriggers = [self getTriggers]; + [self removeTriggersForKeys:allTriggers.allKeys]; + }]; } - (NSDictionary *)getTriggers { @@ -1241,8 +1269,8 @@ - (void)messageViewDidDisplayPage:(OSInAppMessageInternal *)message withPageId:( } - (void)messageIsNotActive:(OSInAppMessageInternal *)message { - [self deleteInactiveMessage:message]; dispatch_async(dispatch_get_main_queue(), ^{ + [self deleteInactiveMessage:message]; [self cleanUpInAppWindow]; }); } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift index ff466300f..ccffb9fdd 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift @@ -44,11 +44,14 @@ final class IAMIntegrationTests: XCTestCase { OneSignalCoreMocks.clearUserDefaults() OneSignalUserMocks.reset() ConsistencyManagerTestHelpers.reset() + OSMessagingController.removeInstance() // Temp. logging to help debug during testing OneSignalLog.setLogLevel(.LL_VERBOSE) } - override func tearDownWithError() throws { } + override func tearDownWithError() throws { + OSMessagingController.removeInstance() + } func testLanguageVariantSelection() throws { OneSignalIdentifiers.currentAppId = "test-app-id" @@ -101,7 +104,12 @@ final class IAMIntegrationTests: XCTestCase { // 3. Present the preview message OSMessagingController.sharedInstance().present(inAppPreviewMessage: message) - // 4. Verify that the preview IAM is showing even when paused + // 4. Let anything the pause queued on the main queue run first + let drained = expectation(description: "main queue drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 5) + + // 5. Verify that the preview IAM is showing even when paused XCTAssertTrue(OSMessagingController.sharedInstance().isInAppMessageShowing) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/TriggerThreadingTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/TriggerThreadingTests.swift new file mode 100644 index 000000000..c9f6e91c1 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/TriggerThreadingTests.swift @@ -0,0 +1,183 @@ +/* + 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 OneSignalOSCore +import OneSignalCoreMocks +import OneSignalOSCoreMocks +import OneSignalUserMocks +import OneSignalInAppMessagesMocks + +/** + The trigger and pause entry points and the inactive-message callback accept calls from any thread, + while the controller's message state lives on the main queue. Each test drives an entry point from a background queue with the + main thread held, then lets the main queue drain and checks where the work ran. + */ +final class TriggerThreadingTests: XCTestCase { + + /// Records the thread each call arrives on. Matching and sharing always fail so evaluation stops there. + private final class RecordingTriggerController: OSTriggerController { + var addCallsOnMain: [Bool] = [] + var removeCallsOnMain: [Bool] = [] + var matchCallsOnMain: [Bool] = [] + var sharedTriggerCallsOnMain: [Bool] = [] + + override func addTriggers(_ triggers: [String: Any]) { + addCallsOnMain.append(Thread.isMainThread) + super.addTriggers(triggers) + } + + override func removeTriggers(forKeys keys: [String]) { + removeCallsOnMain.append(Thread.isMainThread) + super.removeTriggers(forKeys: keys) + } + + override func messageMatchesTriggers(_ message: OSInAppMessageInternal) -> Bool { + matchCallsOnMain.append(Thread.isMainThread) + return false + } + + override func hasSharedTriggers(_ message: OSInAppMessageInternal, newTriggersKeys: [String]) -> Bool { + sharedTriggerCallsOnMain.append(Thread.isMainThread) + return false + } + } + + private var controller: OSMessagingController! + private var triggerController: RecordingTriggerController! + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OSMessagingController.removeInstance() + OneSignalIdentifiers.currentAppId = "test-app-id" + + controller = OSMessagingController.sharedInstance() + triggerController = RecordingTriggerController() + triggerController.delegate = controller + controller.triggerController = triggerController + + // One message with redisplay state so trigger changes reach the redisplay check + let message = OSInAppMessageInternal.instance(withJson: IAMTestHelpers.testDefaultMessageJson())! + controller.messages = NSMutableArray(array: [message]) + controller.redisplayedInAppMessages[message.messageId] = message + } + + override func tearDownWithError() throws { + OSMessagingController.removeInstance() + } + + /// Runs `work` on a background queue while the main thread waits, so nothing handed to the + /// main queue can run until `drainMainQueue` is called. + private func runOffMain(_ work: @escaping () -> Void) { + let finished = DispatchSemaphore(value: 0) + DispatchQueue.global().async { + work() + finished.signal() + } + XCTAssertEqual(finished.wait(timeout: .now() + 5), .success, "background call did not return") + } + + private func drainMainQueue() { + let drained = expectation(description: "main queue drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 5) + } + + func testAddTriggersOffMainRunsOnMainQueue() { + runOffMain { self.controller.addTriggers(["key": "value"]) } + + XCTAssertEqual(triggerController.sharedTriggerCallsOnMain, [], "redisplay state was read on the calling thread") + XCTAssertEqual(triggerController.addCallsOnMain, [], "trigger state was touched on the calling thread") + + drainMainQueue() + + XCTAssertEqual(triggerController.sharedTriggerCallsOnMain, [true]) + XCTAssertEqual(triggerController.addCallsOnMain, [true]) + XCTAssertEqual(triggerController.getTriggers()["key"] as? String, "value") + XCTAssertTrue(controller.earlySessionTriggers.contains("key")) + } + + func testRemoveTriggersOffMainRunsOnMainQueue() { + controller.addTriggers(["key": "value"]) + triggerController.sharedTriggerCallsOnMain.removeAll() + + runOffMain { self.controller.removeTriggers(forKeys: ["key"]) } + + XCTAssertEqual(triggerController.sharedTriggerCallsOnMain, [], "redisplay state was read on the calling thread") + XCTAssertEqual(triggerController.removeCallsOnMain, [], "trigger state was touched on the calling thread") + + drainMainQueue() + + XCTAssertEqual(triggerController.sharedTriggerCallsOnMain, [true]) + XCTAssertEqual(triggerController.removeCallsOnMain, [true]) + XCTAssertNil(triggerController.getTriggers()["key"]) + } + + func testClearTriggersOffMainRunsOnMainQueueAndClearsATriggerQueuedBefore() { + runOffMain { + self.controller.addTriggers(["key": "value"]) + self.controller.clearTriggers() + } + + XCTAssertEqual(triggerController.removeCallsOnMain, [], "trigger state was touched on the calling thread") + + drainMainQueue() + + XCTAssertEqual(triggerController.removeCallsOnMain, [true]) + XCTAssertTrue(triggerController.getTriggers().isEmpty) + } + + func testUnpausingOffMainEvaluatesOnMainQueueAndFlagIsImmediate() { + var pausedSeenByCaller = false + + runOffMain { + self.controller.setInAppMessagingPaused(true) + pausedSeenByCaller = self.controller.isInAppMessagingPaused() + self.controller.setInAppMessagingPaused(false) + } + + XCTAssertTrue(pausedSeenByCaller) + XCTAssertEqual(triggerController.matchCallsOnMain, [], "messages were evaluated on the calling thread") + + drainMainQueue() + + XCTAssertEqual(triggerController.matchCallsOnMain, [true]) + } + + func testInactiveMessageOffMainIsDeletedOnMainQueue() { + let message = controller.messages.firstObject as! OSInAppMessageInternal + + runOffMain { self.controller.messageIsNotActive(message) } + + XCTAssertEqual(controller.messages.count, 1, "messages were replaced on the calling thread") + + drainMainQueue() + + XCTAssertEqual(controller.messages.count, 0) + } +} From 630b355dee88b6e3784f2e9f7b20e5ed5e6c3253 Mon Sep 17 00:00:00 2001 From: abdulraqeeb33 Date: Tue, 6 Oct 2026 14:26:57 -0500 Subject: [PATCH 23/25] fix: [SDK-5426] reject blank identity strings (#1760) Co-authored-by: AR Abdul Azeez Co-authored-by: Cursor (cherry picked from commit a040da8b82ec430aa7648ab9c9a3672e47b64f66) --- .../OneSignal.xcodeproj/project.pbxproj | 12 ++ .../OneSignalInAppMessages.m | 17 +- .../IAMIntegrationTests.swift | 17 ++ .../IAMRequestTests.m | 20 +++ .../OneSignalOSCore/Source/OSInputGuard.swift | 98 ++++++++++++ .../OSInputGuardTests.swift | 66 ++++++++ .../Source/OneSignalUserManagerImpl.swift | 18 ++- .../InputGuardUserTests.swift | 147 ++++++++++++++++++ iOS_SDK/OneSignalSDK/Source/OneSignal.m | 11 +- 9 files changed, 395 insertions(+), 11 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSInputGuard.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSInputGuardTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/InputGuardUserTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 1649f95a7..a374eb980 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -90,6 +90,8 @@ A5048F01A1B2C3D4E5F6000A /* OSFeatureFlagsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */; }; A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */; }; A5062F01A1B2C3D4E5F60002 /* OSDeviceGestureDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */; }; + A5426001A1B2C3D4E5F60002 /* OSInputGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5426001A1B2C3D4E5F60001 /* OSInputGuard.swift */; }; + A5426001A1B2C3D4E5F60004 /* OSInputGuardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5426001A1B2C3D4E5F60003 /* OSInputGuardTests.swift */; }; A5062F01A1B2C3D4E5F60004 /* OSDeviceGestureDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */; }; C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */; }; 32D3A6EA8AD44274B5CE378A /* FileLogStoreRetentionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */; }; @@ -219,6 +221,7 @@ 3CC063E02B6D7F2A002BB07F /* OneSignalUserMocks.h in Headers */ = {isa = PBXBuildFile; fileRef = 3CC063DF2B6D7F2A002BB07F /* OneSignalUserMocks.h */; settings = {ATTRIBUTES = (Public, ); }; }; 3CC063E62B6D7F96002BB07F /* OneSignalUserMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC063E52B6D7F96002BB07F /* OneSignalUserMocks.swift */; }; 3CC063EE2B6D7FE8002BB07F /* OneSignalUserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC063ED2B6D7FE8002BB07F /* OneSignalUserTests.swift */; }; + A5426001A1B2C3D4E5F60006 /* InputGuardUserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5426001A1B2C3D4E5F60005 /* InputGuardUserTests.swift */; }; 3CC063EF2B6D7FE8002BB07F /* OneSignalUser.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE69E19B282ED8060090BB3D /* OneSignalUser.framework */; }; 3CC890352C5BF9A7002CB4CC /* UserConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC890342C5BF9A7002CB4CC /* UserConcurrencyTests.swift */; }; 3CC9A6342AFA1FDE008F68FD /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3CC9A6332AFA1FDD008F68FD /* PrivacyInfo.xcprivacy */; }; @@ -1508,6 +1511,7 @@ 3CC063E52B6D7F96002BB07F /* OneSignalUserMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalUserMocks.swift; sourceTree = ""; }; 3CC063EB2B6D7FE8002BB07F /* OneSignalUserTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OneSignalUserTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3CC063ED2B6D7FE8002BB07F /* OneSignalUserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalUserTests.swift; sourceTree = ""; }; + A5426001A1B2C3D4E5F60005 /* InputGuardUserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputGuardUserTests.swift; sourceTree = ""; }; 3CC890342C5BF9A7002CB4CC /* UserConcurrencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserConcurrencyTests.swift; sourceTree = ""; }; 3CC9A6332AFA1FDD008F68FD /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 3CC9A6352AFA26E7008F68FD /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; @@ -1893,6 +1897,8 @@ A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsTests.swift; sourceTree = ""; }; A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsRefreshServiceTests.swift; sourceTree = ""; }; A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDeviceGestureDetector.swift; sourceTree = ""; }; + A5426001A1B2C3D4E5F60001 /* OSInputGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSInputGuard.swift; sourceTree = ""; }; + A5426001A1B2C3D4E5F60003 /* OSInputGuardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSInputGuardTests.swift; sourceTree = ""; }; A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDeviceGestureDetectorTests.swift; sourceTree = ""; }; 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLogCrashHandlerTests.swift; sourceTree = ""; }; 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogStoreRetentionTests.swift; sourceTree = ""; }; @@ -2397,6 +2403,7 @@ 3C448BA12936B474002F96BC /* OSBackgroundTaskManager.swift */, 4710EA522B8FCFB200435356 /* OSDispatchQueue.swift */, A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */, + A5426001A1B2C3D4E5F60001 /* OSInputGuard.swift */, DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */, DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */, A843B922174496E99F2D00A8 /* Jwt */, @@ -2534,6 +2541,7 @@ 3CDE664A2BFC2A55006DA114 /* OneSignalUserTests-Bridging-Header.h */, 3CF11E3E2C6D61AC002856F5 /* Executors */, 3CC063ED2B6D7FE8002BB07F /* OneSignalUserTests.swift */, + A5426001A1B2C3D4E5F60005 /* InputGuardUserTests.swift */, 3CA93BC6300B0100000724B3 /* SubscriptionModelConcurrencyTests.swift */, 3CC890342C5BF9A7002CB4CC /* UserConcurrencyTests.swift */, 3CB331672F281679000E1801 /* CustomEventsIntegrationTests.swift */, @@ -2720,6 +2728,7 @@ A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */, A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */, A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */, + A5426001A1B2C3D4E5F60003 /* OSInputGuardTests.swift */, 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */, 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */, 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */, @@ -4636,6 +4645,7 @@ A5048F01A1B2C3D4E5F6000D /* OSFeatureManagerImpl.swift in Sources */, A5048F01A1B2C3D4E5F60008 /* OSFeatureFlagsRefreshService.swift in Sources */, A5062F01A1B2C3D4E5F60002 /* OSDeviceGestureDetector.swift in Sources */, + A5426001A1B2C3D4E5F60002 /* OSInputGuard.swift in Sources */, 3C4F9E4428A4466C009F453A /* OSOperationRepo.swift in Sources */, 3C14E3B02FAE54C006ED053 /* IOSLogger.swift in Sources */, 698F58A488FCE503DFD5247F /* OSLogCrashHandler.swift in Sources */, @@ -4730,6 +4740,7 @@ 354E0C59BA9B18437C36215B /* UserExecutorRetryTests.swift in Sources */, 3C67F77A2BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift in Sources */, 3CC063EE2B6D7FE8002BB07F /* OneSignalUserTests.swift in Sources */, + A5426001A1B2C3D4E5F60006 /* InputGuardUserTests.swift in Sources */, 3CA93BC7300B0100000724B3 /* SubscriptionModelConcurrencyTests.swift in Sources */, 3CC890352C5BF9A7002CB4CC /* UserConcurrencyTests.swift in Sources */, 3CB3316A2F281692000E1801 /* OSCustomEventsExecutorTests.swift in Sources */, @@ -4827,6 +4838,7 @@ A5048F01A1B2C3D4E5F6000A /* OSFeatureFlagsTests.swift in Sources */, A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */, A5062F01A1B2C3D4E5F60004 /* OSDeviceGestureDetectorTests.swift in Sources */, + A5426001A1B2C3D4E5F60004 /* OSInputGuardTests.swift in Sources */, C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */, 32D3A6EA8AD44274B5CE378A /* FileLogStoreRetentionTests.swift in Sources */, 3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/OneSignalInAppMessages.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/OneSignalInAppMessages.m index f9540813c..223a173f7 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/OneSignalInAppMessages.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/OneSignalInAppMessages.m @@ -28,6 +28,7 @@ of this software and associated documentation files (the "Software"), to deal #import "OneSignalInAppMessages.h" #import "OSMessagingController.h" #import "OSInAppMessageMigrationController.h" +#import @implementation OneSignalInAppMessages @@ -77,10 +78,10 @@ + (void)addTrigger:(NSString * _Nonnull)key withValue:(NSString * _Nonnull)value if ([OSPrivacyConsentController shouldLogMissingPrivacyConsentErrorWithMethodName:@"addTrigger:withValue:"]) return; - if (!key) { - [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:@"Attempted to set a trigger with a nil key."]; + if ([OSInputGuard isMissing:key api:@"addTrigger: key"]) + return; + if (!value && [OSInputGuard isMissing:nil api:@"addTrigger: value"]) return; - } [OSMessagingController.sharedInstance addTriggers:@{key : value}]; } @@ -90,6 +91,9 @@ + (void)addTriggers:(NSDictionary * _Nonnull)triggers { if ([OSPrivacyConsentController shouldLogMissingPrivacyConsentErrorWithMethodName:@"addTriggers:"]) return; + if ([OSInputGuard hasMissingEntries:triggers api:@"addTriggers" allowEmptyValue:YES]) + return; + [OSMessagingController.sharedInstance addTriggers:triggers]; } @@ -98,10 +102,8 @@ + (void)removeTrigger:(NSString * _Nonnull)key { if ([OSPrivacyConsentController shouldLogMissingPrivacyConsentErrorWithMethodName:@"removeTriggerForKey:"]) return; - if (!key) { - [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:@"Attempted to remove a trigger with a nil key."]; + if ([OSInputGuard isMissing:key api:@"removeTrigger: key"]) return; - } [OSMessagingController.sharedInstance removeTriggersForKeys:@[key]]; } @@ -111,6 +113,9 @@ + (void)removeTriggers:(NSArray * _Nonnull)keys { if ([OSPrivacyConsentController shouldLogMissingPrivacyConsentErrorWithMethodName:@"removeTriggerForKey:"]) return; + if ([OSInputGuard isMissingAny:keys api:@"removeTriggers: key"]) + return; + [OSMessagingController.sharedInstance removeTriggersForKeys:keys]; } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift index ccffb9fdd..2e495d971 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift @@ -159,4 +159,21 @@ final class IAMIntegrationTests: XCTestCase { XCTAssertFalse(OSMessagingController.sharedInstance().isInAppMessageShowing) XCTAssertEqual(OSMessagingController.sharedInstance().messageDisplayQueue.count, 0) } + + func testBlankTriggerKeysLeaveExistingTriggers() { + let controller = OSMessagingController.sharedInstance() + OneSignalInAppMessages.addTrigger("guard-kept", withValue: "yes") + OneSignalInAppMessages.addTrigger("", withValue: "no") + OneSignalInAppMessages.addTriggers(["": "y", "blank-batch": "x"]) + OneSignalInAppMessages.removeTrigger("") + OneSignalInAppMessages.removeTriggers(["guard-kept", ""]) + + let triggers = controller.triggerController.getTriggers() + XCTAssertEqual(triggers["guard-kept"] as? String, "yes") + XCTAssertNil(triggers[""]) + XCTAssertNil(triggers["blank-batch"]) + + OneSignalInAppMessages.removeTrigger("guard-kept") + controller.earlySessionTriggers.remove("guard-kept") + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMRequestTests.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMRequestTests.m index 6d5eb4509..36e910d56 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMRequestTests.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMRequestTests.m @@ -28,6 +28,14 @@ of this software and associated documentation files (the "Software"), to deal #import #import "OSInAppMessageBridgeEvent.h" #import "OSInAppMessagingRequests.h" +#import "OneSignalInAppMessages.h" +#import "OSMessagingController.h" +#import "OSTriggerController.h" + +@interface OSMessagingController (InputGuardTests) +@property (strong, nonatomic, nonnull) NSMutableSet *earlySessionTriggers; +@property (strong, nonatomic, nonnull) OSTriggerController *triggerController; +@end @interface IAMRequestTests : XCTestCase @@ -179,4 +187,16 @@ - (void)testInAppMessageClicked_withNilArguments { })); } +- (void)testNilTriggerValueIsNotStored { + [OneSignalInAppMessages addTrigger:@"nil-kept" withValue:@"yes"]; + [OneSignalInAppMessages addTrigger:@"nil-value" withValue:nil]; + + NSDictionary *triggers = [OSMessagingController.sharedInstance.triggerController getTriggers]; + XCTAssertEqualObjects(triggers[@"nil-kept"], @"yes"); + XCTAssertNil(triggers[@"nil-value"]); + + [OneSignalInAppMessages removeTrigger:@"nil-kept"]; + [OSMessagingController.sharedInstance.earlySessionTriggers removeObject:@"nil-kept"]; +} + @end diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSInputGuard.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSInputGuard.swift new file mode 100644 index 000000000..20a2efa34 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSInputGuard.swift @@ -0,0 +1,98 @@ +/* + 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 Foundation +import OneSignalCore + +@objc(OSInputGuard) +public final class OSInputGuard: NSObject { + @objc(isMissing:api:) + public static func isMissing(_ value: String?, _ api: String) -> Bool { + // A NUL cannot be stored in a text column, so it is never a usable value. + if let value, value.contains("\u{0000}") { + OneSignalLog.onesignalLog(.LL_ERROR, message: "\(api) contains a null byte") + return true + } + if let value, !value.isEmpty { + return false + } + OneSignalLog.onesignalLog(.LL_ERROR, message: "\(api) is required") + return true + } + + @objc(isMissingAny:api:) + public static func isMissingAny(_ values: NSArray?, _ api: String) -> Bool { + guard let values else { + return isMissing(nil, api) + } + for item in values where isMissing(item as? String, api) { + return true + } + return false + } + + /// `allowEmptyValue` keeps "" and a value containing a null byte. A null value is still rejected. + @objc(hasMissingEntries:api:allowEmptyValue:) + public static func hasMissingEntries( + _ values: NSDictionary?, + _ api: String, + allowEmptyValue: Bool + ) -> Bool { + guard let values else { + return isMissing(nil, api) + } + for (key, item) in values { + if isMissing(key as? String, "\(api): key") { + return true + } + if allowEmptyValue { + if item is NSNull { + return isMissing(nil, "\(api): value") + } + continue + } + if isMissing(item as? String, "\(api): value") { + return true + } + } + return false + } + + @nonobjc + public static func isMissingAny(_ values: [String]?, _ api: String) -> Bool { + isMissingAny(values as NSArray?, api) + } + + @nonobjc + public static func hasMissingEntries( + _ values: [String: String]?, + _ api: String, + allowEmptyValue: Bool + ) -> Bool { + hasMissingEntries(values as NSDictionary?, api, allowEmptyValue: allowEmptyValue) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSInputGuardTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSInputGuardTests.swift new file mode 100644 index 000000000..2f610d093 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSInputGuardTests.swift @@ -0,0 +1,66 @@ +/* + 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 +@testable import OneSignalOSCore + +final class OSInputGuardTests: XCTestCase { + func testIsMissingRejectsNilAndEmptyAndKeepsWhitespace() { + XCTAssertTrue(OSInputGuard.isMissing(nil, "login: externalId")) + XCTAssertTrue(OSInputGuard.isMissing("", "login: externalId")) + XCTAssertFalse(OSInputGuard.isMissing(" ", "login: externalId")) + XCTAssertFalse(OSInputGuard.isMissing("user", "login: externalId")) + XCTAssertTrue(OSInputGuard.isMissing("\u{0000}: 1", "login: externalId")) + XCTAssertTrue(OSInputGuard.isMissing("abc\u{0000}", "addAlias: id")) + } + + func testIsMissingAnyRejectsABlankEntryAndANilCollection() { + XCTAssertTrue(OSInputGuard.isMissingAny(nil as NSArray?, "removeTags: key")) + XCTAssertTrue(OSInputGuard.isMissingAny(nil as [String]?, "removeTags: key")) + XCTAssertTrue(OSInputGuard.isMissingAny(["ok", ""], "removeTags: key")) + XCTAssertFalse(OSInputGuard.isMissingAny(["ok", " "], "removeTags: key")) + } + + func testHasMissingEntriesRejectsBlankKeysAndBlankAliasIds() { + XCTAssertTrue(OSInputGuard.hasMissingEntries(nil as [String: String]?, "addAliases", allowEmptyValue: false)) + XCTAssertTrue(OSInputGuard.hasMissingEntries(["": "id"], "addAliases", allowEmptyValue: false)) + XCTAssertTrue(OSInputGuard.hasMissingEntries(["label": ""], "addAliases", allowEmptyValue: false)) + XCTAssertFalse(OSInputGuard.hasMissingEntries(["label": "id"], "addAliases", allowEmptyValue: false)) + XCTAssertFalse(OSInputGuard.hasMissingEntries(["label": " "], "addAliases", allowEmptyValue: false)) + XCTAssertTrue(OSInputGuard.hasMissingEntries(["external_id": "\u{0000}: 1"], "addAliases", allowEmptyValue: false)) + } + + func testHasMissingEntriesAllowsAnEmptyTagValueAndRejectsNull() { + XCTAssertFalse(OSInputGuard.hasMissingEntries(["key": ""], "addTags", allowEmptyValue: true)) + XCTAssertTrue(OSInputGuard.hasMissingEntries(["": "value"], "addTags", allowEmptyValue: true)) + let tags = NSMutableDictionary() + tags["key"] = NSNull() + XCTAssertTrue(OSInputGuard.hasMissingEntries(tags, "addTags", allowEmptyValue: true)) + XCTAssertFalse(OSInputGuard.hasMissingEntries(["key": "a\u{0000}b"], "addTags", allowEmptyValue: true)) + XCTAssertTrue(OSInputGuard.hasMissingEntries(["a\u{0000}": "value"], "addTags", allowEmptyValue: true)) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 652c7836e..2524098de 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -378,10 +378,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { return } start() - guard externalId != "" else { - OneSignalLog.onesignalLog(.LL_ERROR, message: "OneSignal.User login called with empty externalId. This is not allowed.") - return - } + if OSInputGuard.isMissing(externalId, "login: externalId") { return } OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignal.User login called with externalId: \(externalId)") // Ungated: a subscription internally disabled by a previous logout has to come back even if @@ -789,6 +786,7 @@ extension OneSignalUserManagerImpl: OSUser { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "addAlias") else { return } + if OSInputGuard.isMissing(label, "addAlias: label") || OSInputGuard.isMissing(id, "addAlias: id") { return } user.addAliases([label: id]) } @@ -796,6 +794,7 @@ extension OneSignalUserManagerImpl: OSUser { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "addAliases") else { return } + if OSInputGuard.hasMissingEntries(aliases, "addAliases", allowEmptyValue: false) { return } user.addAliases(aliases) } @@ -803,6 +802,7 @@ extension OneSignalUserManagerImpl: OSUser { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "removeAlias") else { return } + if OSInputGuard.isMissing(label, "removeAlias: label") { return } user.removeAliases([label]) } @@ -810,6 +810,7 @@ extension OneSignalUserManagerImpl: OSUser { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "removeAliases") else { return } + if OSInputGuard.isMissingAny(labels, "removeAliases: label") { return } user.removeAliases(labels) } @@ -817,6 +818,7 @@ extension OneSignalUserManagerImpl: OSUser { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "addTag") else { return } + if OSInputGuard.isMissing(key, "addTag: key") { return } user.addTags([key: value]) } @@ -824,6 +826,7 @@ extension OneSignalUserManagerImpl: OSUser { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "addTags") else { return } + if OSInputGuard.hasMissingEntries(tags, "addTags", allowEmptyValue: true) { return } user.addTags(tags) } @@ -831,6 +834,7 @@ extension OneSignalUserManagerImpl: OSUser { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "removeTag") else { return } + if OSInputGuard.isMissing(tag, "removeTag: key") { return } user.removeTags([tag]) } @@ -838,6 +842,7 @@ extension OneSignalUserManagerImpl: OSUser { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "removeTags") else { return } + if OSInputGuard.isMissingAny(tags, "removeTags: key") { return } user.removeTags(tags) } @@ -852,6 +857,7 @@ extension OneSignalUserManagerImpl: OSUser { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "addEmail") else { return } + if OSInputGuard.isMissing(email, "addEmail: email") { return } // Check if is valid email? // Check if this email already exists on this User? createUserIfNil() @@ -875,6 +881,7 @@ extension OneSignalUserManagerImpl: OSUser { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "removeEmail") else { return } + if OSInputGuard.isMissing(email, "removeEmail: email") { return } // Check if is valid email? createUserIfNil() self.subscriptionModelStore.remove(email) @@ -884,6 +891,7 @@ extension OneSignalUserManagerImpl: OSUser { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "addSmsNumber") else { return } + if OSInputGuard.isMissing(number, "addSms: smsNumber") { return } // Check if is valid SMS? // Check if this SMS already exists on this User? createUserIfNil() @@ -907,6 +915,7 @@ extension OneSignalUserManagerImpl: OSUser { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "removeSmsNumber") else { return } + if OSInputGuard.isMissing(number, "removeSms: smsNumber") { return } // Check if is valid SMS? createUserIfNil() self.subscriptionModelStore.remove(number) @@ -931,6 +940,7 @@ extension OneSignalUserManagerImpl: OSUser { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "trackEvent") else { return } + if OSInputGuard.isMissing(name, "trackEvent: name") { return } let processedProperties = properties ?? [:] diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/InputGuardUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/InputGuardUserTests.swift new file mode 100644 index 000000000..a0552ccfa --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/InputGuardUserTests.swift @@ -0,0 +1,147 @@ +/* + 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 OneSignalCore +import OneSignalCoreMocks +import OneSignalUserMocks +import XCTest +@testable import OneSignalOSCore +@_spi(OneSignalInternal) @testable import OneSignalUser + +final class InputGuardUserTests: XCTestCase { + private var previousAppId: String? + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + previousAppId = OneSignalIdentifiers.currentAppId + OneSignalIdentifiers.currentAppId = "b2f7f966-d8cc-11e4-bed1-df8f05be55ba" + OneSignalCoreImpl.setSharedClient(MockOneSignalClient()) + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true + } + + override func tearDownWithError() throws { + OneSignalIdentifiers.currentAppId = previousAppId + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + } + + func testBlankAliasLabelIsNotStoredAndEmptyRemoveLeavesIt() { + let manager = OneSignalUserManagerImpl.sharedInstance + manager.user.addAliases(["": "legacy-id"]) + manager.addAlias(label: "", id: "alias-id") + manager.addAlias(label: "kept-label", id: "real-id") + manager.addAlias(label: "kept-label", id: "") + manager.addAliases(["blank-label": "x", "": "y"]) + + XCTAssertEqual(manager.user.identityModel.aliases[""], "legacy-id") + XCTAssertEqual(manager.user.identityModel.aliases["kept-label"], "real-id") + XCTAssertNil(manager.user.identityModel.aliases["blank-label"]) + + manager.removeAlias("") + manager.removeAliases([""]) + XCTAssertEqual(manager.user.identityModel.aliases[""], "legacy-id") + + manager.addAlias(label: " ", id: "space-id") + XCTAssertEqual(manager.user.identityModel.aliases[" "], "space-id") + manager.removeAlias(" ") + XCTAssertNil(manager.user.identityModel.aliases[" "]) + } + + func testBlankTagKeyRejectsTheBatchAndEmptyRemoveLeavesIt() { + let manager = OneSignalUserManagerImpl.sharedInstance + manager.user.addTags(["": "legacy"]) + manager.addTags(["": "nope", "blank-batch": "nope"]) + manager.addTag(key: "empty-value", value: "") + + XCTAssertEqual(manager.getTags()[""], "legacy") + XCTAssertNil(manager.getTags()["blank-batch"]) + XCTAssertEqual(manager.getTags()["empty-value"], "") + + manager.removeTag("") + manager.removeTags([""]) + XCTAssertEqual(manager.getTags()[""], "legacy") + manager.removeTag("empty-value") + XCTAssertNil(manager.getTags()["empty-value"]) + } + + func testEmptyEmailAndSmsAreNotStored() { + let manager = OneSignalUserManagerImpl.sharedInstance + manager.addEmail("") + XCTAssertNil(manager.subscriptionModelStore.getModel(key: "")) + manager.addSms("") + XCTAssertNil(manager.subscriptionModelStore.getModel(key: "")) + + manager.subscriptionModelStore.add( + id: "", + model: OSSubscriptionModel( + type: .email, + address: "", + subscriptionId: nil, + reachable: true, + isDisabled: false, + changeNotifier: OSEventProducer() + ), + hydrating: false + ) + manager.removeEmail("") + XCTAssertNotNil(manager.subscriptionModelStore.getModel(key: "")) + manager.removeSms("") + XCTAssertNotNil(manager.subscriptionModelStore.getModel(key: "")) + } + + func testNullByteIsNotStoredExceptAsAnEmptyTagValue() { + let manager = OneSignalUserManagerImpl.sharedInstance + manager.login(externalId: "\u{0000}: 1", token: nil) + XCTAssertNil(manager.externalId) + + manager.addAlias(label: "nul-id", id: "\u{0000}: 1") + manager.addAlias(label: "\u{0000}", id: "1") + XCTAssertNil(manager.user.identityModel.aliases["nul-id"]) + XCTAssertNil(manager.user.identityModel.aliases["\u{0000}"]) + + manager.addTag(key: "nul-value", value: "a\u{0000}b") + XCTAssertEqual(manager.getTags()["nul-value"], "a\u{0000}b") + manager.addTags(["\u{0000}": "nope", "sibling": "nope"]) + XCTAssertNil(manager.getTags()["sibling"]) + manager.removeTag("nul-value") + } + + func testBlankEventNameIsNotEnqueued() { + let manager = OneSignalUserManagerImpl.sharedInstance + let repo = OneSignalUserManagerImpl.sharedInstance.operationRepo + manager.trackEvent(name: "kept-event", properties: nil) + repo.flushAndWait() + let before = repo.deltaQueue.filter { $0.name == OS_CUSTOM_EVENT_DELTA }.count + + manager.trackEvent(name: "", properties: nil) + repo.flushAndWait() + let after = repo.deltaQueue.filter { $0.name == OS_CUSTOM_EVENT_DELTA }.count + XCTAssertEqual(after, before) + XCTAssertGreaterThan(before, 0) + } +} diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index c4400d757..195723348 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -266,6 +266,10 @@ + (void)logout { Note: wrappers may call this method with a null appId. */ + (void)initialize:(nonnull NSString*)newAppId withLaunchOptions:(nullable NSDictionary*)launchOptions { + // "" is rejected. nil still means the app id has not been provided. + if (newAppId != nil && [OSInputGuard isMissing:newAppId api:@"initialize: appId"]) { + return; + } [OSRemoteLoggingController configureFromCacheForAppId:newAppId ?: OneSignalIdentifiers.storedAppId]; [self setAppId:newAppId]; [self setLaunchOptions:launchOptions]; @@ -281,7 +285,12 @@ + (void)initialize:(nonnull NSString*)newAppId withLaunchOptions:(nullable NSDic + (void)setAppId:(nullable NSString*)newAppId { [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"setAppId called with appId: %@!", newAppId]]; - if (!newAppId || newAppId.length == 0) { + // "" is rejected. nil still falls back to the cached app id. + if (newAppId != nil && [OSInputGuard isMissing:newAppId api:@"initialize: appId"]) { + return; + } + + if (!newAppId) { NSString* cachedAppId = OneSignalIdentifiers.storedAppId; if (cachedAppId) { [OneSignalLog onesignalLog:ONE_S_LL_INFO message:[NSString stringWithFormat:@"Initializing OneSignal with cached appId: '%@'.", cachedAppId]]; From f4e0153e36d570ba1d4fc8a9f96cf5166c6f8a60 Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 7 Oct 2026 09:23:21 -0700 Subject: [PATCH 24/25] fix: [SDK-5314] JWT - drop the Identity Verification beta queues and limit cached queue (#1758) --- .../OneSignal.xcodeproj/project.pbxproj | 4 + .../Source/OneSignalCommonDefines.h | 11 + .../Source/OneSignalUserDefaults.h | 4 + .../Source/OneSignalUserDefaults.m | 63 +++++- .../OneSignalCoreTests.swift | 95 +++++++++ .../Source/OSOperationRepo.swift | 2 +- .../Executors/OSCustomEventsExecutor.swift | 4 +- .../OSIdentityOperationExecutor.swift | 6 +- .../OSPropertyOperationExecutor.swift | 4 +- .../OSSubscriptionOperationExecutor.swift | 8 +- .../Source/Executors/OSUserExecutor.swift | 47 ++++- .../Source/OneSignalUserManagerImpl.swift | 18 ++ .../CachedUserWithoutAnIdTests.swift | 191 ++++++++++++++++++ .../OneSignalUserTests.swift | 54 ++++- 14 files changed, 491 insertions(+), 20 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/CachedUserWithoutAnIdTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index a374eb980..e4d704f58 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -371,6 +371,7 @@ 7EB69F3B404D0AEF46EC1536 /* UserJwtLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BFE2F960129386AFA6D5F41 /* UserJwtLifecycleTests.swift */; }; E42087CB1AB15481E55D34FE /* UserStateReportingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA6355B75EDC9A3100B98F5 /* UserStateReportingTests.swift */; }; 9345FA2E8878EA1B0216A49A /* UserJwtAskTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3567F486AA4335CCA79D659B /* UserJwtAskTests.swift */; }; + 59611FB5A88143468425CCB3 /* CachedUserWithoutAnIdTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5249D3FD71F02D4BCAF0995B /* CachedUserWithoutAnIdTests.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 */; }; @@ -1613,6 +1614,7 @@ 5BFE2F960129386AFA6D5F41 /* UserJwtLifecycleTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserJwtLifecycleTests.swift; sourceTree = ""; }; FDA6355B75EDC9A3100B98F5 /* UserStateReportingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserStateReportingTests.swift; sourceTree = ""; }; 3567F486AA4335CCA79D659B /* UserJwtAskTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserJwtAskTests.swift; sourceTree = ""; }; + 5249D3FD71F02D4BCAF0995B /* CachedUserWithoutAnIdTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CachedUserWithoutAnIdTests.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 = ""; }; @@ -2558,6 +2560,7 @@ 5BFE2F960129386AFA6D5F41 /* UserJwtLifecycleTests.swift */, FDA6355B75EDC9A3100B98F5 /* UserStateReportingTests.swift */, 3567F486AA4335CCA79D659B /* UserJwtAskTests.swift */, + 5249D3FD71F02D4BCAF0995B /* CachedUserWithoutAnIdTests.swift */, ); path = OneSignalUserTests; sourceTree = ""; @@ -4760,6 +4763,7 @@ 7EB69F3B404D0AEF46EC1536 /* UserJwtLifecycleTests.swift in Sources */, E42087CB1AB15481E55D34FE /* UserStateReportingTests.swift in Sources */, 9345FA2E8878EA1B0216A49A /* UserJwtAskTests.swift in Sources */, + 59611FB5A88143468425CCB3 /* CachedUserWithoutAnIdTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h index 0c3b6cd96..3c54ee549 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h @@ -378,6 +378,17 @@ typedef enum {GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT, TRACE, PATCH} HTTP #define OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY @"OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY" #define OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY @"OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY" +// A codeable value larger than this is not written, and a cached queue blob larger than it is dropped without +// decoding it. Real queues are a few KB. CFPreferences rejects a value of 4 MB or more on iOS. +#define OS_USER_DEFAULTS_MAX_VALUE_BYTES 1048576 + +// Written only by the 5.3.0-beta Identity Verification builds. Nothing reads them anymore. +#define OS_IV_BETA_USER_EXECUTOR_PENDING_QUEUE_KEY @"OS_USER_EXECUTOR_PENDING_QUEUE_KEY" +#define OS_IV_BETA_IDENTITY_EXECUTOR_PENDING_QUEUE_KEY @"OS_IDENTITY_EXECUTOR_PENDING_QUEUE_KEY" +#define OS_IV_BETA_PROPERTIES_EXECUTOR_PENDING_QUEUE_KEY @"OS_PROPERTIES_EXECUTOR_PENDING_QUEUE_KEY" +#define OS_IV_BETA_SUBSCRIPTION_EXECUTOR_PENDING_QUEUE_KEY @"OS_SUBSCRIPTION_EXECUTOR_PENDING_QUEUE_KEY" +#define OS_IV_BETA_CUSTOM_EVENTS_EXECUTOR_PENDING_QUEUE_KEY @"OS_CUSTOM_EVENTS_EXECUTOR_PENDING_QUEUE_KEY" + // Live Activies Executor #define OS_LIVE_ACTIVITIES_EXECUTOR_UPDATE_TOKENS_KEY @"OS_LIVE_ACTIVITIES_EXECUTOR_UPDATE_TOKENS_KEY" #define OS_LIVE_ACTIVITIES_EXECUTOR_START_TOKENS_KEY @"OS_LIVE_ACTIVITIES_EXECUTOR_START_TOKENS_KEY" diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.h index 89a03fe3e..ed3dc6477 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.h @@ -69,7 +69,11 @@ - (void)saveObjectForKey:(NSString * _Nonnull)key withValue:(id _Nullable)value; // NSUserDefaults for storing and getting saved codeable data (custom objects) +/// A blob that is not archived data or cannot be decoded is logged, left in place and `value` returned. - (id _Nullable)getSavedCodeableDataForKey:(NSString * _Nonnull)key defaultValue:(id _Nullable)value; +/// Same, except that a blob larger than `maxBytes`, not archived data or undecodable is removed. 0 means no limit, and nothing is removed. +- (id _Nullable)getSavedCodeableDataForKey:(NSString * _Nonnull)key defaultValue:(id _Nullable)value maxBytes:(NSUInteger)maxBytes; +/// A value whose archive is larger than `OS_USER_DEFAULTS_MAX_VALUE_BYTES` is not written and the key is removed. - (void)saveCodeableDataForKey:(NSString * _Nonnull)key withValue:(id _Nullable)value; @end diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m index fb48bacf5..2f4eaf12e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalUserDefaults.m @@ -28,6 +28,7 @@ #import #import "OneSignalUserDefaults.h" #import "OneSignalCommonDefines.h" +#import "OneSignalLog.h" @implementation OneSignalUserDefaults : NSObject @@ -152,15 +153,65 @@ - (void)saveObjectForKey:(NSString * _Nonnull)key withValue:(id _Nullable)object } - (id _Nullable)getSavedCodeableDataForKey:(NSString * _Nonnull)key defaultValue:(id _Nullable)value { - if ([self keyExists:key]) - return [NSKeyedUnarchiver unarchiveObjectWithData:[self.userDefaults objectForKey:key]]; - - return value; + return [self getSavedCodeableDataForKey:key defaultValue:value maxBytes:0]; +} + +- (id _Nullable)getSavedCodeableDataForKey:(NSString * _Nonnull)key defaultValue:(id _Nullable)value maxBytes:(NSUInteger)maxBytes { + if (![self keyExists:key]) + return value; + + id stored = [self.userDefaults objectForKey:key]; + if (![stored isKindOfClass:[NSData class]]) { + [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults cannot decode %@: not archived data", key]]; + [self removeUndecodableValueForKey:key maxBytes:maxBytes]; + return value; + } + NSData *data = stored; + if (maxBytes > 0 && data.length > maxBytes) { + [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults dropping %@: %lu bytes, limit %lu", key, (unsigned long)data.length, (unsigned long)maxBytes]]; + [self removeValueForKey:key]; + return value; + } + @try { + // A blob that decodes to nil is treated like a missing key. + return [NSKeyedUnarchiver unarchiveObjectWithData:data] ?: value; + } @catch (NSException *exception) { + [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults cannot decode %@: %@", key, exception.reason]]; + [self removeUndecodableValueForKey:key maxBytes:maxBytes]; + return value; + } +} + +/// Only a capped read, a queue of this SDK's own Requests, heals by deletion. An uncapped read leaves the blob to +/// whichever module wrote it; a model store, for one, is overwritten by the user the SDK creates in its place. +- (void)removeUndecodableValueForKey:(NSString *)key maxBytes:(NSUInteger)maxBytes { + if (maxBytes > 0) { + [self removeValueForKey:key]; + } } - (void)saveCodeableDataForKey:(NSString * _Nonnull)key withValue:(id _Nullable)value { - [self.userDefaults setObject:[NSKeyedArchiver archivedDataWithRootObject:value] forKey:key]; - [self.userDefaults synchronize]; + NSData *data; + @try { + data = [NSKeyedArchiver archivedDataWithRootObject:value]; + } @catch (NSException *exception) { + [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults could not archive %@: %@", key, exception.reason]]; + return; + } + // Nothing the SDK caches is legitimately this large: a queue that reached it is the bloat the read cap drops, + // so the previous blob goes too rather than resend the Requests it held. CFPreferences refuses a value of 4 MB + // or more and can stop persisting the suite afterwards. + if (data.length > OS_USER_DEFAULTS_MAX_VALUE_BYTES) { + [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults not caching %@: %lu bytes", key, (unsigned long)data.length]]; + [self removeValueForKey:key]; + return; + } + @try { + [self.userDefaults setObject:data forKey:key]; + [self.userDefaults synchronize]; + } @catch (NSException *exception) { + [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:[NSString stringWithFormat:@"OneSignalUserDefaults could not cache %@: %@", key, exception.reason]]; + } } //gets the NSBundle of the primary application - NOT the app extension diff --git a/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests.swift b/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests.swift index 7187b5f0c..e3d387366 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests.swift @@ -73,4 +73,99 @@ final class OneSignalCoreTests: XCTestCase { XCTAssertEqual(templateId, "templateId123") XCTAssertEqual(templateName, "Template name") } + + // MARK: - Codeable data guards + + private let guardKey = "OS_CORE_TESTS_CODEABLE_GUARD" + + func testOversizedCodeableDataIsDroppedNotDecoded() { + let defaults = OneSignalUserDefaults.initShared() + // A real archive over the cap; without the cap it would decode. + defaults.saveCodeableData(forKey: guardKey, withValue: [String(repeating: "x", count: 2048)]) + + let restored = defaults.getSavedCodeableData(forKey: guardKey, defaultValue: ["default"], maxBytes: 1024) as? [String] + + XCTAssertEqual(restored, ["default"]) + XCTAssertFalse(defaults.keyExists(guardKey)) + } + + func testCodeableDataWithinTheLimitIsDecoded() { + let defaults = OneSignalUserDefaults.initShared() + defaults.saveCodeableData(forKey: guardKey, withValue: ["cached"]) + + let restored = defaults.getSavedCodeableData(forKey: guardKey, defaultValue: [], maxBytes: 1024) as? [String] + + XCTAssertEqual(restored, ["cached"]) + defaults.removeValue(forKey: guardKey) + } + + func testUnreadableCodeableDataReturnsTheDefaultWithoutThrowing() { + let defaults = OneSignalUserDefaults.initShared() + defaults.saveObject(forKey: guardKey, withValue: Data("not an archive".utf8)) + + let restored = defaults.getSavedCodeableData(forKey: guardKey, defaultValue: ["default"]) as? [String] + + XCTAssertEqual(restored, ["default"]) + defaults.removeValue(forKey: guardKey) + } + + func testUnarchivableValueLeavesTheCachedValueInPlace() { + let defaults = OneSignalUserDefaults.initShared() + defaults.saveCodeableData(forKey: guardKey, withValue: ["cached"]) + + // NSObject does not adopt NSCoding, so archiving it raises. + defaults.saveCodeableData(forKey: guardKey, withValue: NSObject()) + + XCTAssertEqual(defaults.getSavedCodeableData(forKey: guardKey, defaultValue: nil) as? [String], ["cached"]) + defaults.removeValue(forKey: guardKey) + } + + func testValueOverTheLimitIsNotCachedAndThePreviousBlobGoesWithIt() { + let defaults = OneSignalUserDefaults.initShared() + defaults.saveCodeableData(forKey: guardKey, withValue: ["cached"]) + + defaults.saveCodeableData(forKey: guardKey, withValue: Data(count: Int(OS_USER_DEFAULTS_MAX_VALUE_BYTES))) + + XCTAssertFalse(defaults.keyExists(guardKey)) + } + + func testBlobThatIsNotArchivedDataIsRemovedOnlyByACappedRead() { + let defaults = OneSignalUserDefaults.initShared() + defaults.saveObject(forKey: guardKey, withValue: "not data") + + XCTAssertEqual(defaults.getSavedCodeableData(forKey: guardKey, defaultValue: ["default"]) as? [String], ["default"]) + XCTAssertTrue(defaults.keyExists(guardKey)) + + XCTAssertEqual(defaults.getSavedCodeableData(forKey: guardKey, defaultValue: ["default"], maxBytes: 1024) as? [String], ["default"]) + XCTAssertFalse(defaults.keyExists(guardKey)) + } + + func testUndecodableBlobIsRemovedOnlyByACappedRead() throws { + let defaults = OneSignalUserDefaults.initShared() + defaults.saveObject(forKey: guardKey, withValue: try archiveNamingAnUnknownClass()) + + XCTAssertEqual(defaults.getSavedCodeableData(forKey: guardKey, defaultValue: ["default"]) as? [String], ["default"]) + XCTAssertTrue(defaults.keyExists(guardKey)) + + XCTAssertEqual(defaults.getSavedCodeableData(forKey: guardKey, defaultValue: ["default"], maxBytes: 1024) as? [String], ["default"]) + XCTAssertFalse(defaults.keyExists(guardKey)) + } + + /// A keyed archive whose root is of a class this process does not have. Decoding it raises, as a queue written + /// by a newer build does. + private func archiveNamingAnUnknownClass() throws -> Data { + let archive = try NSKeyedArchiver.archivedData(withRootObject: ["cached"], requiringSecureCoding: false) + var plist = try XCTUnwrap(try PropertyListSerialization.propertyList(from: archive, format: nil) as? [String: Any]) + var objects = try XCTUnwrap(plist["$objects"] as? [Any]) + for (index, object) in objects.enumerated() { + guard var descriptor = object as? [String: Any], descriptor["$classname"] != nil else { + continue + } + descriptor["$classname"] = "OSNoSuchClass" + descriptor["$classes"] = ["OSNoSuchClass"] + objects[index] = descriptor + } + plist["$objects"] = objects + return try PropertyListSerialization.data(fromPropertyList: plist, format: .binary, options: 0) + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift index d1b3a36e8..c1274ef84 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift @@ -78,7 +78,7 @@ public class OSOperationRepo: NSObject { } private func uncacheDeltaQueue() { - guard let cached = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY, defaultValue: []) as? [OSDelta] else { + guard let cached = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSDelta] else { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSOperationRepo is unable to uncache the OSDelta queue.") return } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift index b6571ac08..dc948efb7 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift @@ -63,7 +63,7 @@ class OSCustomEventsExecutor: OSOperationExecutor { } private func uncacheDeltas() { - if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: []) as? [OSDelta] { + if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSDelta] { for (index, delta) in deltaQueue.enumerated().reversed() { if OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) == nil { // The identity model does not exist, drop this Delta @@ -81,7 +81,7 @@ class OSCustomEventsExecutor: OSOperationExecutor { } private func uncacheRequests() { - if var requestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestCustomEvents] { + if var requestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestCustomEvents] { // Hook each uncached Request to the model in the store for (index, request) in requestQueue.enumerated().reversed() { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift index c3e1825bc..93a15057d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift @@ -50,7 +50,7 @@ class OSIdentityOperationExecutor: OSOperationExecutor { } private func uncacheDeltas() { - if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: []) as? [OSDelta] { + if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSDelta] { // Hook each uncached Delta to the model in the store for (index, delta) in deltaQueue.enumerated().reversed() { if let modelInStore = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.model.modelId) { @@ -70,7 +70,7 @@ class OSIdentityOperationExecutor: OSOperationExecutor { } private func uncacheAddAliasRequests() { - if var addRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestAddAliases] { + if var addRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestAddAliases] { // Hook each uncached Request to the model in the store for (index, request) in addRequestQueue.enumerated().reversed() { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { @@ -93,7 +93,7 @@ class OSIdentityOperationExecutor: OSOperationExecutor { } private func uncacheRemoveAliasRequests() { - if var removeRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestRemoveAlias] { + if var removeRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestRemoveAlias] { // Hook each uncached Request to the model in the store for (index, request) in removeRequestQueue.enumerated().reversed() { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift index 4dbb9e25c..2189459f5 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift @@ -85,7 +85,7 @@ class OSPropertyOperationExecutor: OSOperationExecutor { } private func uncacheDeltas() { - if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_PROPERTIES_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: []) as? [OSDelta] { + if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_PROPERTIES_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSDelta] { for (index, delta) in deltaQueue.enumerated().reversed() { if OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) == nil { // The identity model does not exist, drop this Delta @@ -101,7 +101,7 @@ class OSPropertyOperationExecutor: OSOperationExecutor { } private func uncacheUpdateRequests() { - if var updateRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestUpdateProperties] { + if var updateRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestUpdateProperties] { // Hook each uncached Request to the model in the store for (index, request) in updateRequestQueue.enumerated().reversed() { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift index aeee2c099..9b4c805f7 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift @@ -53,7 +53,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } private func uncacheDeltas() { - if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: []) as? [OSDelta] { + if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSDelta] { // Hook each uncached Delta to the model in the store for (index, delta) in deltaQueue.enumerated().reversed() { if let modelInStore = getSubscriptionModelFromStores(modelId: delta.model.modelId) { @@ -75,7 +75,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { private func uncacheCreateSubscriptionRequests() { var requestQueue: [OSRequestCreateSubscription] = [] - if let cachedAddRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestCreateSubscription] { + if let cachedAddRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestCreateSubscription] { // Hook each uncached Request to the model in the store for request in cachedAddRequestQueue { // 1. Hook up the subscription model @@ -111,7 +111,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } private func uncacheDeleteSubscriptionRequests() { - if var removeRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestDeleteSubscription] { + if var removeRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestDeleteSubscription] { // Hook each uncached Request to the model in the store for (index, request) in removeRequestQueue.enumerated().reversed() { if let subscriptionModel = getSubscriptionModelFromStores(modelId: request.subscriptionModel.modelId) { @@ -136,7 +136,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } private func uncacheUpdateSubscriptionRequests() { - if var updateRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestUpdateSubscription] { + if var updateRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestUpdateSubscription] { // Hook each uncached Request to the model in the store for (index, request) in updateRequestQueue.enumerated().reversed() { if let subscriptionModel = getSubscriptionModelFromStores(modelId: request.subscriptionModel.modelId) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift index 392fb3408..da751dd62 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift @@ -50,6 +50,7 @@ class OSUserExecutor { self.auth = auth uncacheUserRequests() migrateTransferSubscriptionRequests() + createUserIfNeverCreated(OneSignalUserManagerImpl.sharedInstance._user) identityVerificationService.addOnJwtConfigHydratedHandler(for: .userExecutor) { [weak self] _ in // Including an unchanged value: Requests held while `requirement` was unknown wait on this. @@ -167,7 +168,7 @@ class OSUserExecutor { var userRequestQueue: [OSUserRequest] = [] // Read unfinished Create User + Identify User + Get Identity By Subscription requests from cache, if any... - if let cachedRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSUserRequest] { + if let cachedRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSUserRequest] { // Hook each uncached Request to the right model reference for request in cachedRequestQueue { if request.isKind(of: OSRequestFetchIdentityBySubscription.self), let req = request as? OSRequestFetchIdentityBySubscription { @@ -244,6 +245,50 @@ class OSUserExecutor { } } + /// A cached user with no `onesignal_id` never reached the server, and only a Create User gets it there. + /// Queue one unless a queued Request already exists. Skip an anonymous user under Identity + /// Verification, where `reshapeInvalidRequests` would drop the Create User anyway. `cachedUser` is the + /// user `start()` loaded from the cache, read in `init` before Path 2 or 3 can replace it. The check runs + /// on the executor queue so it sees what `migrateTransferSubscriptionRequests` appended. + private func createUserIfNeverCreated(_ cachedUser: OSUserInternal?) { + guard let user = cachedUser else { + return + } + dispatchQueue.async { + guard user.identityModel.onesignalId == nil, + user.identityModel.externalId != nil || !self.identityVerificationService.ivBehaviorActive, + !self.queueSuppliesAnId(for: user.identityModel.modelId) + else { + return + } + OneSignalLog.onesignalLog(.LL_WARN, message: "OSUserExecutor queued a Create User for the cached user") + let request = OSRequestCreateUser( + identityModel: user.identityModel, + propertiesModel: user.propertiesModel, + pushSubscriptionModel: user.pushSubscriptionModel, + originalPushToken: user.pushSubscriptionModel.address + ) + self.userRequestQueue.append(request) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, withValue: self.userRequestQueue) + } + } + + /// Whether a queued Request gives the identity model an `onesignal_id` when it completes. + private func queueSuppliesAnId(for modelId: String) -> Bool { + return userRequestQueue.contains { request in + switch request { + case let createUser as OSRequestCreateUser: + return createUser.identityModel.modelId == modelId + case let fetchIdentity as OSRequestFetchIdentityBySubscription: + return fetchIdentity.identityModel.modelId == modelId + case let identifyUser as OSRequestIdentifyUser: + return identifyUser.identityModelToUpdate.modelId == modelId + default: + return false + } + } + } + private func getIdentityModel(_ modelId: String) -> OSIdentityModel? { return OneSignalUserManagerImpl.sharedInstance.getIdentityModel(modelId) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 2524098de..0247e8dc3 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -238,6 +238,23 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { self.pushSubscriptionImpl = OSPushSubscriptionImpl(pushSubscriptionModelStore: pushSubscriptionModelStore) } + /// The 5.3.0-beta builds parked Requests awaiting a JWT under these keys and never trimmed them, so an + /// upgrading device can carry a large blob nothing reads anymore. + private func removeIdentityVerificationBetaCaches() { + let defaults = OneSignalUserDefaults.initShared() + let keys = [ + OS_IV_BETA_USER_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_IDENTITY_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_PROPERTIES_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_SUBSCRIPTION_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_CUSTOM_EVENTS_EXECUTOR_PENDING_QUEUE_KEY + ] + for key in keys where defaults.keyExists(key) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OneSignalUserManager removing the Identity Verification beta cache \(key)") + defaults.removeValue(forKey: key) + } + } + @objc public func start() { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else { @@ -251,6 +268,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { } OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignalUserManager calling start") + removeIdentityVerificationBetaCaches() // The model stores load their in-memory `models` dict once in their initializer. // If the singleton was first touched while protected data was unavailable (iOS app diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/CachedUserWithoutAnIdTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/CachedUserWithoutAnIdTests.swift new file mode 100644 index 000000000..c64e2aa6a --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/CachedUserWithoutAnIdTests.swift @@ -0,0 +1,191 @@ +/* + 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 + +/** + A cached user with no `onesignal_id` was never created on the server. Only its Create User does that, and a + launch can lose the Request: the queue it sat in was dropped as oversized or unreadable, or a 5.3.0-beta build + parked it under a key `start()` removes. `start()` queues it again rather than leave the device unreachable. + */ +final class CachedUserWithoutAnIdTests: XCTestCase { + private var client = MockOneSignalClient() + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + + client = MockOneSignalClient() + MockUserRequests.setDefaultCreateAnonUserResponses(with: client) + MockUserRequests.setDefaultCreateUserResponses(with: client, externalId: userA_EUID) + OneSignalCoreImpl.setSharedClient(client) + } + + override func tearDownWithError() throws { + // The mock answers 50ms late, so a Request still in flight would otherwise land mid-next-test. + OneSignalCoreMocks.waitUntil("A Request was still in flight at teardown") { self.client.isIdle } + OneSignalCoreMocks.clearUserDefaults() + } + + /// The models a launch leaves behind when it is killed before its Create User is sent, with the queue empty. + @discardableResult + private func cacheUser(externalId: String?) -> OSUserInternal { + return OneSignalUserManagerImpl.sharedInstance.setNewInternalUser(externalId: externalId, pushSubscriptionModel: nil) + } + + private func sentCreateUsers() -> [OSRequestCreateUser] { + return client.executedRequests.compactMap { $0 as? OSRequestCreateUser } + } + + private func externalId(of request: OSRequestCreateUser) -> String? { + return (request.parameters?["identity"] as? [String: String])?[OS_EXTERNAL_ID] + } + + func testStartCreatesACachedIdentifiedUserWithNoOneSignalId() { + let user = cacheUser(externalId: userA_EUID) + + OneSignalUserManagerImpl.sharedInstance.start() + + OneSignalCoreMocks.waitUntil("The cached user was not created") { + self.client.hasCompletedRequestOfType(OSRequestCreateUser.self) + } + XCTAssertEqual(sentCreateUsers().map(externalId(of:)), [userA_EUID]) + OneSignalCoreMocks.waitUntil("The response did not hydrate the cached user") { user.identityModel.onesignalId != nil } + } + + func testStartCreatesACachedAnonymousUserWithNoOneSignalId() { + cacheUser(externalId: nil) + + OneSignalUserManagerImpl.sharedInstance.start() + + OneSignalCoreMocks.waitUntil("The cached user was not created") { + self.client.hasCompletedRequestOfType(OSRequestCreateUser.self) + } + XCTAssertEqual(sentCreateUsers().count, 1) + XCTAssertNil(sentCreateUsers().first.flatMap(externalId(of:))) + } + + func testStartSendsACachedCreateUserOnceWhenItIsStillQueued() { + let user = cacheUser(externalId: userA_EUID) + let queued = OSRequestCreateUser( + identityModel: user.identityModel, + propertiesModel: user.propertiesModel, + pushSubscriptionModel: user.pushSubscriptionModel, + originalPushToken: nil + ) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, withValue: [queued]) + + OneSignalUserManagerImpl.sharedInstance.start() + + OneSignalCoreMocks.waitUntil("The cached Create User did not complete") { + self.client.hasCompletedRequestOfType(OSRequestCreateUser.self) + } + // A second one would still be queued here: it goes out only after the post-create delay. + OneSignalCoreMocks.waitUntil("A Create User is still queued") { + OneSignalUserManagerImpl.sharedInstance.userExecutor?.userRequestQueue.contains { $0 is OSRequestCreateUser } == false + } + XCTAssertEqual(sentCreateUsers().count, 1) + } + + func testStartAsksForATokenForACachedIdentifiedUserUnderIdentityVerification() { + 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) } + cacheUser(externalId: userA_EUID) + + OneSignalUserManagerImpl.sharedInstance.start() + + OneSignalCoreMocks.waitUntil("The app was not asked for a token") { listener.invalidatedExternalIds == [userA_EUID] } + XCTAssertTrue(sentCreateUsers().isEmpty) + + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userA_EUID, token: "a-token") + + OneSignalCoreMocks.waitUntil("The Create User did not go out with the token") { + self.client.hasCompletedRequestOfType(OSRequestCreateUser.self) + } + XCTAssertEqual(sentCreateUsers().map(externalId(of:)), [userA_EUID]) + } + + /// Path 3 creates the user after the executor exists. The recovery must not see that user. + func testFreshStartSendsOneCreateUser() { + OneSignalUserManagerImpl.sharedInstance.start() + + OneSignalCoreMocks.waitUntil("The anonymous user was not created") { + self.client.hasCompletedRequestOfType(OSRequestCreateUser.self) + } + OneSignalCoreMocks.waitUntil("A Create User is still queued") { + OneSignalUserManagerImpl.sharedInstance.userExecutor?.userRequestQueue.contains { $0 is OSRequestCreateUser } == false + } + XCTAssertEqual(sentCreateUsers().count, 1) + } + + /// Path 2 creates the user after the executor exists too, and a Create User ahead of its Fetch Identity + /// By Subscription would make a new user instead of adopting the 3.x player. + func testLegacyPlayerStartQueuesNoCreateUser() { + let legacyPlayerId = "legacy_player_id" + let legacyOnesignalId = "legacy_player_onesignal_id" + OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: legacyPlayerId) + client.setMockResponseForRequest( + request: "OSRequestFetchIdentityBySubscription with subscriptionId: \(legacyPlayerId)", + response: MockUserRequests.testIdentityPayload(onesignalId: legacyOnesignalId, externalId: nil) + ) + client.setMockResponseForRequest( + request: "", + response: MockUserRequests.testIdentityPayload(onesignalId: legacyOnesignalId, externalId: nil) + ) + + OneSignalUserManagerImpl.sharedInstance.start() + + OneSignalCoreMocks.waitUntil("The legacy player's identity was not fetched") { + self.client.hasCompletedRequestOfType(OSRequestFetchIdentityBySubscription.self) + } + XCTAssertTrue(sentCreateUsers().isEmpty) + XCTAssertFalse(OneSignalUserManagerImpl.sharedInstance.userExecutor?.userRequestQueue.contains { $0 is OSRequestCreateUser } ?? true) + XCTAssertEqual(OneSignalUserManagerImpl.sharedInstance._user?.identityModel.onesignalId, legacyOnesignalId) + } + + func testStartLeavesACachedAnonymousUserAloneUnderIdentityVerification() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + cacheUser(externalId: nil) + + OneSignalUserManagerImpl.sharedInstance.start() + + // Nothing positive to wait for: give the executor's queue a turn, then look. + Thread.sleep(forTimeInterval: 0.2) + XCTAssertTrue(sentCreateUsers().isEmpty) + XCTAssertEqual(OneSignalUserManagerImpl.sharedInstance.userExecutor?.userRequestQueue.isEmpty, true) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift index 2a4f8ebb8..42b18a85e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift @@ -605,7 +605,6 @@ final class OneSignalUserTests: XCTestCase { } } - /** `optedIn` is the only signal the public API gives an app for "will push reach this device", so a subscription the app owner turned off remotely has to report false there. Kept in its own class @@ -829,6 +828,59 @@ final class RemoteDisableOptedInTests: XCTestCase { } } +/// The 5.3.0-beta line left queues on disk that this SDK does not read. `start()` clears them and +/// refuses to decode an oversized queue it does read. +final class IdentityVerificationBetaCacheTests: XCTestCase { + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + } + + override func tearDownWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + } + + func testStartRemovesIdentityVerificationBetaCachesAndAnOversizedQueue() throws { + let client = MockOneSignalClient() + client.executeInstantaneously = true + MockUserRequests.setDefaultCreateAnonUserResponses(with: client) + OneSignalCoreImpl.setSharedClient(client) + + let defaults = OneSignalUserDefaults.initShared() + let betaKeys = [ + OS_IV_BETA_USER_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_IDENTITY_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_PROPERTIES_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_SUBSCRIPTION_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_CUSTOM_EVENTS_EXECUTOR_PENDING_QUEUE_KEY + ] + for key in betaKeys { + defaults.saveCodeableData(forKey: key, withValue: ["nan01": []]) + } + // A real archive over the cap. Without the cap it decodes to strings, which fails the cast and leaves the key as is. + // Distinct strings: the archiver stores a repeated object once, which would keep this under the cap. + let oversized = (0..<(Int(OS_USER_DEFAULTS_MAX_VALUE_BYTES) / 1024 + 64)).map { String(repeating: "x", count: 1024) + String($0) } + // Written directly: `saveCodeableData` refuses a value this large. The builds that wrote these blobs did not. + defaults.saveObject( + forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, + withValue: try NSKeyedArchiver.archivedData(withRootObject: oversized, requiringSecureCoding: false) + ) + + OneSignalUserManagerImpl.sharedInstance.start() + OneSignalCoreMocks.waitUntil("Anonymous user creation did not complete") { + client.hasCompletedRequestOfType(OSRequestCreateUser.self) + } + + for key in betaKeys { + XCTAssertFalse(defaults.keyExists(key), "\(key) survived start()") + } + // The property executor replaced the blob with its empty queue rather than decoding it. + let queue = defaults.getSavedCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, defaultValue: nil) as? [Any] + XCTAssertEqual(queue?.count, 0) + } +} + /// Records the properties a model reported as changed, split by whether the change was meant to /// reach the server. `hydrating` is true for writes that only mirror state the server already has. private class SpyPushSubscriptionObserver: NSObject, OSPushSubscriptionObserver { From 11849c94fc9c23985067e87aabc84cda0856a44f Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 7 Oct 2026 11:20:03 -0700 Subject: [PATCH 25/25] feat: [SDK-5345] age out stale queued Requests to prevent bloat (#1759) --- .../OneSignal.xcodeproj/project.pbxproj | 8 + .../Executors/OSCustomEventsExecutor.swift | 48 +- .../OSIdentityOperationExecutor.swift | 25 +- .../OSPropertyOperationExecutor.swift | 20 +- .../OneSignalUser/Source/OSRequestAging.swift | 96 ++++ .../Source/OneSignalUserManagerImpl+Jwt.swift | 17 + .../Source/OneSignalUserManagerImpl.swift | 17 - .../Executors/ExecutorRequestAgingTests.swift | 420 ++++++++++++++++++ 8 files changed, 629 insertions(+), 22 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAging.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorRequestAgingTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index e4d704f58..a641076e0 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -53,11 +53,13 @@ 03E56DD328405F4A006AA1DA /* OneSignalAppDelegateOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 03E56DD228405F4A006AA1DA /* OneSignalAppDelegateOverrider.m */; }; 16664C4C25DDB195003B8A14 /* NSTimeZoneOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 16664C4B25DDB195003B8A14 /* NSTimeZoneOverrider.m */; }; 1B7E5A0AEB23050C398E6111 /* ExecutorAnonymousPurgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE740F9E6B87D215510B5982 /* ExecutorAnonymousPurgeTests.swift */; }; + D9F1B3E5A7C2495FC1E6B8D3 /* ExecutorRequestAgingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E5A1D7F2B9461EB8D4A6C2 /* ExecutorRequestAgingTests.swift */; }; 23D66BEB40CE76DFF89744A3 /* RequestPathEncodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3016921C1F6B7B7793F67567 /* RequestPathEncodingTests.swift */; }; 257E219608960B8545199057 /* OneSignalUserManagerImpl+Jwt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */; }; 2DB99C76F3532383C3B81D09 /* OSUserJwtRepoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5809B47EB4F2478099697CE /* OSUserJwtRepoTests.swift */; }; 322B62F85070DCB5C5599D6D /* OSOperationRepoIdentityVerificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFBB451F692A15639A1A08AE /* OSOperationRepoIdentityVerificationTests.swift */; }; 32601EF1960CD92605D1ABF9 /* OSRequestAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BF72AAEB5284C97B864A1A8 /* OSRequestAuth.swift */; }; + B7D2A9C4E6F1483DA2C5E9F1 /* OSRequestAging.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C4E7F2B9D3468CA5E1F0B2 /* OSRequestAging.swift */; }; 37E6B2BB19D9CAF300D0C601 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37E6B2BA19D9CAF300D0C601 /* UIKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 3C0151922C2E298F0079E076 /* OneSignalInAppMessages.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DEBAAE282A4211D900BF2C1C /* OneSignalInAppMessages.framework */; }; 3C01519C2C2E29F90079E076 /* IAMRequestTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3C01519B2C2E29F90079E076 /* IAMRequestTests.m */; }; @@ -1719,6 +1721,7 @@ 91F60F7B1E80E49A00706E60 /* UncaughtExceptionHandler.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = UncaughtExceptionHandler.h; sourceTree = ""; }; 91F60F7C1E80E4E400706E60 /* UncaughtExceptionHandler.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = UncaughtExceptionHandler.m; sourceTree = ""; }; 9BF72AAEB5284C97B864A1A8 /* OSRequestAuth.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSRequestAuth.swift; sourceTree = ""; }; + A1C4E7F2B9D3468CA5E1F0B2 /* OSRequestAging.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSRequestAging.swift; sourceTree = ""; }; 9D1BD95D237663BF00A064F7 /* OSInfluenceDataDefines.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSInfluenceDataDefines.h; sourceTree = ""; }; 9D1BD95E2379E7A900A064F7 /* OSOutcomeEvent.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSOutcomeEvent.h; sourceTree = ""; }; 9D1BD95F2379E7C300A064F7 /* OSOutcomeEvent.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OSOutcomeEvent.m; sourceTree = ""; }; @@ -1961,6 +1964,7 @@ F4855B81F170253FB0C1749D /* OSIdentityVerificationServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityVerificationServiceTests.swift; sourceTree = ""; }; F83E7BF2B518EA8B0B51B276 /* OSAliasPair.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSAliasPair.swift; sourceTree = ""; }; FE740F9E6B87D215510B5982 /* ExecutorAnonymousPurgeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExecutorAnonymousPurgeTests.swift; sourceTree = ""; }; + C3E5A1D7F2B9461EB8D4A6C2 /* ExecutorRequestAgingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExecutorRequestAgingTests.swift; sourceTree = ""; }; FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIamFetchReadyConditionTests.swift; sourceTree = ""; }; FFBB451F692A15639A1A08AE /* OSOperationRepoIdentityVerificationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSOperationRepoIdentityVerificationTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -2583,6 +2587,7 @@ 3C5181A1B2C3D4E5F6A7B801 /* SubscriptionCreateResponseTests.swift */, 3CB331692F281692000E1801 /* OSCustomEventsExecutorTests.swift */, FE740F9E6B87D215510B5982 /* ExecutorAnonymousPurgeTests.swift */, + C3E5A1D7F2B9461EB8D4A6C2 /* ExecutorRequestAgingTests.swift */, ); path = Executors; sourceTree = ""; @@ -2965,6 +2970,7 @@ 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */, E78603AF16454A648A96DF7B /* OSPushSubscriptionImpl.swift */, 9BF72AAEB5284C97B864A1A8 /* OSRequestAuth.swift */, + A1C4E7F2B9D3468CA5E1F0B2 /* OSRequestAging.swift */, ); path = Source; sourceTree = ""; @@ -4758,6 +4764,7 @@ AE95DD5B72F1D102F6D6062A /* XCTestCase+AsyncWork.swift in Sources */, 99B1615D0132AFAA981A9AD3 /* DeltaOwnershipTests.swift in Sources */, 1B7E5A0AEB23050C398E6111 /* ExecutorAnonymousPurgeTests.swift in Sources */, + D9F1B3E5A7C2495FC1E6B8D3 /* ExecutorRequestAgingTests.swift in Sources */, AAFA2D46E6C5FD3D14D39F27 /* OSRequestAuthTests.swift in Sources */, 23D66BEB40CE76DFF89744A3 /* RequestPathEncodingTests.swift in Sources */, 7EB69F3B404D0AEF46EC1536 /* UserJwtLifecycleTests.swift in Sources */, @@ -4982,6 +4989,7 @@ 257E219608960B8545199057 /* OneSignalUserManagerImpl+Jwt.swift in Sources */, DDE652EF2123473B808F8CCA /* OSPushSubscriptionImpl.swift in Sources */, 32601EF1960CD92605D1ABF9 /* OSRequestAuth.swift in Sources */, + B7D2A9C4E6F1483DA2C5E9F1 /* OSRequestAging.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift index dc948efb7..57162d51d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift @@ -50,13 +50,15 @@ class OSCustomEventsExecutor: OSOperationExecutor { private var requestQueue: [OSRequestCustomEvents] = [] private let newRecordsState: OSNewRecordsState private let auth: OSRequestAuthorizing + private let nowProvider: () -> Date // The executor dispatch queue, serial. This synchronizes access to `deltaQueue` and `requestQueue`. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSCustomEventsExecutor", target: .global()) - init(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) { + init(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing, nowProvider: @escaping () -> Date = { Date() }) { self.newRecordsState = newRecordsState self.auth = auth + self.nowProvider = nowProvider // Read unfinished deltas and requests from cache, if any... uncacheDeltas() uncacheRequests() @@ -64,6 +66,7 @@ class OSCustomEventsExecutor: OSOperationExecutor { private func uncacheDeltas() { if var deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSDelta] { + _ = removeStaleDeltas(from: &deltaQueue) for (index, delta) in deltaQueue.enumerated().reversed() { if OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) == nil { // The identity model does not exist, drop this Delta @@ -82,6 +85,8 @@ class OSCustomEventsExecutor: OSOperationExecutor { private func uncacheRequests() { if var requestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestCustomEvents] { + // Before the hook-up, so it does not restore an identity model for a Request that is dropped anyway. + _ = removeStaleRequests(from: &requestQueue) // Hook each uncached Request to the model in the store for (index, request) in requestQueue.enumerated().reversed() { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { @@ -140,6 +145,9 @@ class OSCustomEventsExecutor: OSOperationExecutor { /// This method will be used in an upcoming release that combine multiple events. func processDeltaQueueWithBatching(inBackground: Bool) { self.dispatchQueue.async { + if self.removeStaleDeltas(from: &self.deltaQueue) { + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY, withValue: self.deltaQueue) + } if self.deltaQueue.isEmpty { // Delta queue is empty but there may be pending requests self.processRequestQueue(inBackground: inBackground) @@ -148,7 +156,7 @@ class OSCustomEventsExecutor: OSOperationExecutor { OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSCustomEventsExecutor processDeltaQueue with queue: \(self.deltaQueue)") // Holds mapping of identity model ID to the events for it, with the owner the Deltas stamped - var combinedEvents: [String: (events: [[String: Any]], ownerExternalId: String?)] = [:] + var combinedEvents: [String: (events: [[String: Any]], ownerExternalId: String?, timestamp: Date)] = [:] // 1. Combine the events for every distinct user for (index, delta) in self.deltaQueue.enumerated().reversed() { @@ -174,7 +182,10 @@ class OSCustomEventsExecutor: OSOperationExecutor { EventConstants.payload: self.addSdkMetadata(properties: properties) ] - combinedEvents[identityModel.modelId, default: ([], delta.externalId)].events.append(event) + var combined = combinedEvents[identityModel.modelId, default: ([], delta.externalId, delta.timestamp)] + combined.events.append(event) + combined.timestamp = min(combined.timestamp, delta.timestamp) + combinedEvents[identityModel.modelId] = combined self.deltaQueue.remove(at: index) } @@ -190,6 +201,8 @@ class OSCustomEventsExecutor: OSOperationExecutor { identityModel: identityModel, ownerExternalId: combined.ownerExternalId ) + // Aged from the oldest event, not from when its user got an onesignal_id. + request.timestamp = combined.timestamp self.requestQueue.append(request) } @@ -203,6 +216,9 @@ class OSCustomEventsExecutor: OSOperationExecutor { func processDeltaQueue(inBackground: Bool) { self.dispatchQueue.async { + if self.removeStaleDeltas(from: &self.deltaQueue) { + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY, withValue: self.deltaQueue) + } if self.deltaQueue.isEmpty { // Delta queue is empty but there may be pending requests self.processRequestQueue(inBackground: inBackground) @@ -240,6 +256,8 @@ class OSCustomEventsExecutor: OSOperationExecutor { identityModel: identityModel, ownerExternalId: delta.externalId ) + // Aged from the event, not from when its user got an onesignal_id. + request.timestamp = delta.timestamp self.requestQueue.append(request) } @@ -271,6 +289,10 @@ class OSCustomEventsExecutor: OSOperationExecutor { /// This method is called by `processDeltaQueue` only and does not need to be added to the dispatchQueue. private func processRequestQueue(inBackground: Bool) { + if removeStaleRequests(from: &requestQueue) { + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, withValue: requestQueue) + } + if requestQueue.isEmpty { return } @@ -280,6 +302,26 @@ class OSCustomEventsExecutor: OSOperationExecutor { } } + /// See `OSRequestAging`. Deltas wait here until their user has an `onesignal_id`, so they are aged too. + private func removeStaleDeltas(from queue: inout [OSDelta]) -> Bool { + return queue.removeStaleDeltas( + typeLimit: OSRequestAging.customEventRequestMaxAge, + now: nowProvider(), + currentExternalId: OSRequestAging.currentExternalId, + executor: "OSCustomEventsExecutor" + ) + } + + /// See `OSRequestAging`. Returns whether the queue changed, so the caller can rewrite its cache entry. + private func removeStaleRequests(from queue: inout [OSRequestCustomEvents]) -> Bool { + return queue.removeStaleRequests( + typeLimit: OSRequestAging.customEventRequestMaxAge, + now: nowProvider(), + currentExternalId: OSRequestAging.currentExternalId, + executor: "OSCustomEventsExecutor" + ) + } + private func executeRequest(_ request: OSRequestCustomEvents, inBackground: Bool) { guard !request.sentToClient else { return diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift index 93a15057d..ecf130098 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift @@ -36,13 +36,15 @@ class OSIdentityOperationExecutor: OSOperationExecutor { private var removeRequestQueue: [OSRequestRemoveAlias] = [] private let newRecordsState: OSNewRecordsState private let auth: OSRequestAuthorizing + private let nowProvider: () -> Date // The Identity executor dispatch queue, serial. This synchronizes access to the delta and request queues. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSIdentityOperationExecutor", target: .global()) - init(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) { + init(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing, nowProvider: @escaping () -> Date = { Date() }) { self.newRecordsState = newRecordsState self.auth = auth + self.nowProvider = nowProvider // Read unfinished deltas and requests from cache, if any... uncacheDeltas() uncacheAddAliasRequests() @@ -71,6 +73,8 @@ class OSIdentityOperationExecutor: OSOperationExecutor { private func uncacheAddAliasRequests() { if var addRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestAddAliases] { + // Before the hook-up, so it does not restore an identity model for a Request that is dropped anyway. + _ = removeStaleRequests(from: &addRequestQueue) // Hook each uncached Request to the model in the store for (index, request) in addRequestQueue.enumerated().reversed() { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { @@ -94,6 +98,8 @@ class OSIdentityOperationExecutor: OSOperationExecutor { private func uncacheRemoveAliasRequests() { if var removeRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestRemoveAlias] { + // Before the hook-up, so it does not restore an identity model for a Request that is dropped anyway. + _ = removeStaleRequests(from: &removeRequestQueue) // Hook each uncached Request to the model in the store for (index, request) in removeRequestQueue.enumerated().reversed() { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { @@ -196,6 +202,13 @@ class OSIdentityOperationExecutor: OSOperationExecutor { /// This method is called by `processDeltaQueue` only and does not need to be added to the dispatchQueue. private func processRequestQueue(inBackground: Bool) { + if removeStaleRequests(from: &addRequestQueue) { + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: addRequestQueue) + } + if removeStaleRequests(from: &removeRequestQueue) { + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, withValue: removeRequestQueue) + } + let requestQueue: [OneSignalRequest] = addRequestQueue + removeRequestQueue if requestQueue.isEmpty { @@ -216,6 +229,16 @@ class OSIdentityOperationExecutor: OSOperationExecutor { } } + /// See `OSRequestAging`. Only the owner rule applies, since an alias change has no limit of its own. + private func removeStaleRequests(from queue: inout [T]) -> Bool { + return queue.removeStaleRequests( + typeLimit: nil, + now: nowProvider(), + currentExternalId: OSRequestAging.currentExternalId, + executor: "OSIdentityOperationExecutor" + ) + } + func executeAddAliasesRequest(_ request: OSRequestAddAliases, inBackground: Bool) { guard !request.sentToClient else { return diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift index 2189459f5..41b773f17 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift @@ -71,13 +71,15 @@ class OSPropertyOperationExecutor: OSOperationExecutor { private var updateRequestQueue: [OSRequestUpdateProperties] = [] private let newRecordsState: OSNewRecordsState private let auth: OSRequestAuthorizing + private let nowProvider: () -> Date // The property executor dispatch queue, serial. This synchronizes access to `deltaQueue` and `updateRequestQueue`. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSPropertyOperationExecutor", target: .global()) - init(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) { + init(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing, nowProvider: @escaping () -> Date = { Date() }) { self.newRecordsState = newRecordsState self.auth = auth + self.nowProvider = nowProvider // Read unfinished deltas and requests from cache, if any... // Note that we should only have deltas for the current user as old ones are flushed.. uncacheDeltas() @@ -102,6 +104,8 @@ class OSPropertyOperationExecutor: OSOperationExecutor { private func uncacheUpdateRequests() { if var updateRequestQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, defaultValue: [], maxBytes: UInt(OS_USER_DEFAULTS_MAX_VALUE_BYTES)) as? [OSRequestUpdateProperties] { + // Before the hook-up, so it does not restore an identity model for a Request that is dropped anyway. + _ = removeStaleRequests(from: &updateRequestQueue) // Hook each uncached Request to the model in the store for (index, request) in updateRequestQueue.enumerated().reversed() { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { @@ -249,6 +253,10 @@ class OSPropertyOperationExecutor: OSOperationExecutor { /// This method is called by `processDeltaQueue` only and does not need to be added to the dispatchQueue. private func processRequestQueue(inBackground: Bool) { + if removeStaleRequests(from: &updateRequestQueue) { + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: updateRequestQueue) + } + if updateRequestQueue.isEmpty { return } @@ -258,6 +266,16 @@ class OSPropertyOperationExecutor: OSOperationExecutor { } } + /// See `OSRequestAging`. Returns whether the queue changed, so the caller can rewrite its cache entry. + private func removeStaleRequests(from queue: inout [OSRequestUpdateProperties]) -> Bool { + return queue.removeStaleRequests( + typeLimit: OSRequestAging.propertyRequestMaxAge, + now: nowProvider(), + currentExternalId: OSRequestAging.currentExternalId, + executor: "OSPropertyOperationExecutor" + ) + } + func executeUpdatePropertiesRequest(_ request: OSRequestUpdateProperties, inBackground: Bool) { guard !request.sentToClient else { return diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAging.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAging.swift new file mode 100644 index 000000000..835acd0a0 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAging.swift @@ -0,0 +1,96 @@ +/* + 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 Foundation +import OneSignalCore +import OneSignalOSCore + +/// Age limits for queued user Requests. Each executor applies them at uncache and at the start of a +/// flush pass, before `prepareForExecution`, so a stale Request is dropped even if it could send by then. +enum OSRequestAging { + static let propertyRequestMaxAge: TimeInterval = 60 * 60 * 24 * 90 // 90 days + static let nonCurrentUserRequestMaxAge: TimeInterval = 60 * 60 * 24 * 30 // 30 days + static let customEventRequestMaxAge: TimeInterval = 60 * 60 * 24 * 30 // 30 days + + /// The `external_id` of the current user, nil while there is none. Reads `_user`, since `user` calls + /// `start()` and would deadlock at uncache. + static var currentExternalId: String? { + return OneSignalUserManagerImpl.sharedInstance._user?.identityModel.externalId + } + + /// The limit for work owned by `owner`, nil when it never ages out. + static func maxAge(owner: String?, typeLimit: TimeInterval?, currentExternalId: String?) -> TimeInterval? { + var limit = typeLimit + if let owner = owner, owner != currentExternalId { + limit = min(limit ?? .infinity, nonCurrentUserRequestMaxAge) + } + return limit + } + + /// The age at which work with `timestamp` is dropped, nil to keep it. A timestamp ahead of the clock has no age. + static func staleAge(timestamp: Date, owner: String?, typeLimit: TimeInterval?, now: Date, currentExternalId: String?) -> TimeInterval? { + guard let limit = maxAge(owner: owner, typeLimit: typeLimit, currentExternalId: currentExternalId) else { + return nil + } + let age = now.timeIntervalSince(timestamp) + return age > limit ? age : nil + } + + static func logDrop(of item: String, owner: String?, age: TimeInterval, executor: String) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "\(executor) dropped \(item) owned by \(owner ?? "nil"), \(Int(age / 86_400)) days old") + } +} + +extension Array where Element: OSUserRequest { + /// Removes every Request past its age limit. Returns whether any were dropped, so the caller can rewrite its cache entry. + mutating func removeStaleRequests(typeLimit: TimeInterval?, now: Date, currentExternalId: String?, executor: String) -> Bool { + let countBefore = count + removeAll { request in + guard let age = OSRequestAging.staleAge(timestamp: request.timestamp, owner: request.ownerExternalId, typeLimit: typeLimit, now: now, currentExternalId: currentExternalId) else { + return false + } + OSRequestAging.logDrop(of: "\(type(of: request))", owner: request.ownerExternalId, age: age, executor: executor) + return true + } + return count != countBefore + } +} + +extension Array where Element == OSDelta { + /// The same for Deltas, which the custom events executor holds until their user has an `onesignal_id`. + mutating func removeStaleDeltas(typeLimit: TimeInterval?, now: Date, currentExternalId: String?, executor: String) -> Bool { + let countBefore = count + removeAll { delta in + guard let age = OSRequestAging.staleAge(timestamp: delta.timestamp, owner: delta.externalId, typeLimit: typeLimit, now: now, currentExternalId: currentExternalId) else { + return false + } + OSRequestAging.logDrop(of: "\(delta.name) Delta", owner: delta.externalId, age: age, executor: executor) + return true + } + return count != countBefore + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift index 7f6c55729..078e2835f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift @@ -184,4 +184,21 @@ extension OneSignalUserManagerImpl { } return requestAuth.authorization(onesignalId: identityModel.onesignalId, externalId: identityModel.externalId) } + + /// The 5.3.0-beta builds parked Requests awaiting a JWT under these keys and never trimmed them, so an + /// upgrading device can carry a large blob nothing reads anymore. + func removeIdentityVerificationBetaCaches() { + let defaults = OneSignalUserDefaults.initShared() + let keys = [ + OS_IV_BETA_USER_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_IDENTITY_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_PROPERTIES_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_SUBSCRIPTION_EXECUTOR_PENDING_QUEUE_KEY, + OS_IV_BETA_CUSTOM_EVENTS_EXECUTOR_PENDING_QUEUE_KEY + ] + for key in keys where defaults.keyExists(key) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OneSignalUserManager removing the Identity Verification beta cache \(key)") + defaults.removeValue(forKey: key) + } + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 0247e8dc3..1e5dc6a11 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -238,23 +238,6 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { self.pushSubscriptionImpl = OSPushSubscriptionImpl(pushSubscriptionModelStore: pushSubscriptionModelStore) } - /// The 5.3.0-beta builds parked Requests awaiting a JWT under these keys and never trimmed them, so an - /// upgrading device can carry a large blob nothing reads anymore. - private func removeIdentityVerificationBetaCaches() { - let defaults = OneSignalUserDefaults.initShared() - let keys = [ - OS_IV_BETA_USER_EXECUTOR_PENDING_QUEUE_KEY, - OS_IV_BETA_IDENTITY_EXECUTOR_PENDING_QUEUE_KEY, - OS_IV_BETA_PROPERTIES_EXECUTOR_PENDING_QUEUE_KEY, - OS_IV_BETA_SUBSCRIPTION_EXECUTOR_PENDING_QUEUE_KEY, - OS_IV_BETA_CUSTOM_EVENTS_EXECUTOR_PENDING_QUEUE_KEY - ] - for key in keys where defaults.keyExists(key) { - OneSignalLog.onesignalLog(.LL_DEBUG, message: "OneSignalUserManager removing the Identity Verification beta cache \(key)") - defaults.removeValue(forKey: key) - } - } - @objc public func start() { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorRequestAgingTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorRequestAgingTests.swift new file mode 100644 index 000000000..bc8295e45 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorRequestAgingTests.swift @@ -0,0 +1,420 @@ +/* + 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 OneSignalOSCore +import OneSignalCoreMocks +import OneSignalOSCoreMocks +import OneSignalUserMocks +@testable import OneSignalUser + +/** + What each executor drops from its queues on age alone. See `OSRequestAging` for the limits. + + A Request is aged from the `timestamp` its cache entry keeps, so these tests write Requests with old + timestamps straight into the cache and read back what the executor kept. User A is the current user and + user B is not. Both hold a token, so nothing here is dropped for being unsendable. + */ +final class ExecutorRequestAgingTests: XCTestCase { + private let day: TimeInterval = 86_400 + private let propertiesKey = OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY + private let addAliasesKey = OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY + private let removeAliasKey = OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY + private let customEventsKey = OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY + + private var client = MockOneSignalClient() + private var newRecordsState = MockNewRecordsState() + private var current = OSIdentityModel(aliases: nil, changeNotifier: OSEventProducer()) + private var other = OSIdentityModel(aliases: nil, changeNotifier: OSEventProducer()) + /// The clock the executors under test read. + private var now = Date() + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + + client = MockOneSignalClient() + client.fireSuccessForAllRequests = true + OneSignalCoreImpl.setSharedClient(client) + newRecordsState = MockNewRecordsState() + newRecordsState.holdWhilePresent = true + now = Date() + + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + current = OneSignalUserMocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID).identityModel + current.jwtBearerToken = "token-a" + other = addUserToRepo(externalId: userB_EUID, onesignalId: userB_OSID, token: "token-b") + } + + override func tearDownWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + } + + // MARK: - Setup helpers + + private func addUserToRepo(externalId: String, onesignalId: String?, token: String) -> OSIdentityModel { + var aliases = [OS_EXTERNAL_ID: externalId] + if let onesignalId = onesignalId { + aliases[OS_ONESIGNAL_ID] = onesignalId + } + let model = OSIdentityModel(aliases: aliases, changeNotifier: OSEventProducer()) + model.jwtBearerToken = token + OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(model) + return model + } + + private var auth: OSRequestAuthorizing { + return OneSignalUserManagerImpl.sharedInstance.requestAuth + } + + private func propertyExecutor() -> OSPropertyOperationExecutor { + return OSPropertyOperationExecutor(newRecordsState: newRecordsState, auth: auth, nowProvider: { self.now }) + } + + private func identityExecutor() -> OSIdentityOperationExecutor { + return OSIdentityOperationExecutor(newRecordsState: newRecordsState, auth: auth, nowProvider: { self.now }) + } + + private func customEventsExecutor() -> OSCustomEventsExecutor { + return OSCustomEventsExecutor(newRecordsState: newRecordsState, auth: auth, nowProvider: { self.now }) + } + + /// A negative `daysOld` puts the timestamp ahead of the clock. + private func aged(_ request: T, daysOld: Double) -> T { + request.timestamp = now.addingTimeInterval(-daysOld * day) + return request + } + + private func propertyUpdate(for identityModel: OSIdentityModel, daysOld: Double) -> OSRequestUpdateProperties { + let request = OSRequestUpdateProperties(params: ["properties": ["language": "en"]], identityModel: identityModel, ownerExternalId: identityModel.externalId) + return aged(request, daysOld: daysOld) + } + + private func addAliases(for identityModel: OSIdentityModel, daysOld: Double) -> OSRequestAddAliases { + let request = OSRequestAddAliases(aliases: ["test_alias_label": "test-alias-id"], identityModel: identityModel, ownerExternalId: identityModel.externalId) + return aged(request, daysOld: daysOld) + } + + private func removeAlias(for identityModel: OSIdentityModel, daysOld: Double) -> OSRequestRemoveAlias { + let request = OSRequestRemoveAlias(labelToRemove: "test_alias_label", identityModel: identityModel, ownerExternalId: identityModel.externalId) + return aged(request, daysOld: daysOld) + } + + private func customEvents(for identityModel: OSIdentityModel, daysOld: Double) -> OSRequestCustomEvents { + let event: [String: Any] = [ + "name": "test_event", + "onesignal_id": identityModel.onesignalId ?? "", + "timestamp": "2026-01-01T00:00:00Z", + "payload": ["test_property": "test-value"] + ] + let request = OSRequestCustomEvents(events: [event], identityModel: identityModel, ownerExternalId: identityModel.externalId) + return aged(request, daysOld: daysOld) + } + + private func cache(_ requests: [T], under key: String) { + OneSignalUserDefaults.initShared().saveCodeableData(forKey: key, withValue: requests) + } + + private func customEventDelta(for identityModel: OSIdentityModel) -> OSDelta { + return OSDelta( + name: OS_CUSTOM_EVENT_DELTA, + identityModelId: identityModel.modelId, + externalId: identityModel.externalId, + model: OSModel(changeNotifier: OSEventProducer()), + property: "test_event", + value: ["test_property": "test-value"] + ) + } + + // MARK: - Assertion helpers + + private func cachedOwners(_ key: String, of type: T.Type) -> [String?] { + let requests = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: key, defaultValue: []) as? [T] ?? [] + return requests.map { $0.ownerExternalId } + } + + private func cachedCustomEventDeltaCount() -> Int { + let deltas = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY, defaultValue: []) as? [OSDelta] ?? [] + return deltas.count + } + + // MARK: - Property updates + + func testAPropertyUpdate91DaysOldIsDroppedAtUncache() { + cache([propertyUpdate(for: current, daysOld: 91)], under: propertiesKey) + + _ = propertyExecutor() + + XCTAssertEqual(cachedOwners(propertiesKey, of: OSRequestUpdateProperties.self), []) + } + + func testAPropertyUpdate89DaysOldIsKeptAtUncache() { + cache([propertyUpdate(for: current, daysOld: 89)], under: propertiesKey) + + _ = propertyExecutor() + + XCTAssertEqual(cachedOwners(propertiesKey, of: OSRequestUpdateProperties.self), [userA_EUID]) + } + + /// The check runs before `prepareForExecution`, so the Request is dropped on the flush that finds it + /// stale even though the hold that stopped the first flush has lifted by then. + func testAPropertyUpdateThatCrosses90DaysBetweenFlushesIsDroppedAtTheSecondFlush() { + newRecordsState.add(userA_OSID) + cache([propertyUpdate(for: current, daysOld: 89)], under: propertiesKey) + let executor = propertyExecutor() + + executor.processDeltaQueue(inBackground: false) + allowAsyncWorkToRun() + XCTAssertEqual(client.executedRequests.count, 0) + XCTAssertEqual(cachedOwners(propertiesKey, of: OSRequestUpdateProperties.self), [userA_EUID]) + + newRecordsState.holdWhilePresent = false + now = now.addingTimeInterval(2 * day) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("The stale Update Properties was not dropped from the cache") { + self.cachedOwners(self.propertiesKey, of: OSRequestUpdateProperties.self).isEmpty + } + + XCTAssertEqual(client.executedRequests.count, 0) + } + + /// The premise of the test above: once the hold lifts, a Request still under its limit is sent. + func testAPropertyUpdateStillUnderItsLimitIsSentOnceTheHoldLifts() { + newRecordsState.add(userA_OSID) + cache([propertyUpdate(for: current, daysOld: 89)], under: propertiesKey) + let executor = propertyExecutor() + + executor.processDeltaQueue(inBackground: false) + allowAsyncWorkToRun() + XCTAssertEqual(client.executedRequests.count, 0) + + newRecordsState.holdWhilePresent = false + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("The Update Properties was not sent") { + self.client.hasExecutedRequestOfType(OSRequestUpdateProperties.self, expectedCount: 1) + } + + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestUpdateProperties.self, expectedCount: 1)) + } + + /// The rule judges the owner at each flush, so a switch makes the outgoing user's older Requests non-current. + func testARequestOwnedByAUserWhoIsNoLongerCurrentIsDroppedAtTheNextFlush() { + newRecordsState.add(userA_OSID) + cache([propertyUpdate(for: current, daysOld: 45)], under: propertiesKey) + let executor = propertyExecutor() + + executor.processDeltaQueue(inBackground: false) + allowAsyncWorkToRun() + XCTAssertEqual(cachedOwners(propertiesKey, of: OSRequestUpdateProperties.self), [userA_EUID]) + + _ = OneSignalUserMocks.setUserManagerInternalUser(externalId: userB_EUID, onesignalId: userB_OSID) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("The outgoing user's Update Properties was not dropped") { + self.cachedOwners(self.propertiesKey, of: OSRequestUpdateProperties.self).isEmpty + } + + XCTAssertEqual(client.executedRequests.count, 0) + } + + // MARK: - Non-current users + + func testRequestsOwnedByANonCurrentUserAreDroppedAt31Days() { + cache([propertyUpdate(for: other, daysOld: 31)], under: propertiesKey) + cache([addAliases(for: other, daysOld: 31)], under: addAliasesKey) + cache([removeAlias(for: other, daysOld: 31)], under: removeAliasKey) + cache([customEvents(for: other, daysOld: 31)], under: customEventsKey) + + _ = propertyExecutor() + _ = identityExecutor() + _ = customEventsExecutor() + + XCTAssertEqual(cachedOwners(propertiesKey, of: OSRequestUpdateProperties.self), []) + XCTAssertEqual(cachedOwners(addAliasesKey, of: OSRequestAddAliases.self), []) + XCTAssertEqual(cachedOwners(removeAliasKey, of: OSRequestRemoveAlias.self), []) + XCTAssertEqual(cachedOwners(customEventsKey, of: OSRequestCustomEvents.self), []) + } + + func testRequestsOwnedByANonCurrentUserAreKeptAt29Days() { + cache([propertyUpdate(for: other, daysOld: 29)], under: propertiesKey) + cache([addAliases(for: other, daysOld: 29)], under: addAliasesKey) + cache([removeAlias(for: other, daysOld: 29)], under: removeAliasKey) + cache([customEvents(for: other, daysOld: 29)], under: customEventsKey) + + _ = propertyExecutor() + _ = identityExecutor() + _ = customEventsExecutor() + + XCTAssertEqual(cachedOwners(propertiesKey, of: OSRequestUpdateProperties.self), [userB_EUID]) + XCTAssertEqual(cachedOwners(addAliasesKey, of: OSRequestAddAliases.self), [userB_EUID]) + XCTAssertEqual(cachedOwners(removeAliasKey, of: OSRequestRemoveAlias.self), [userB_EUID]) + XCTAssertEqual(cachedOwners(customEventsKey, of: OSRequestCustomEvents.self), [userB_EUID]) + } + + func testAPropertyUpdateOwnedByTheCurrentUserIsKeptAt31Days() { + cache([propertyUpdate(for: current, daysOld: 31)], under: propertiesKey) + + _ = propertyExecutor() + + XCTAssertEqual(cachedOwners(propertiesKey, of: OSRequestUpdateProperties.self), [userA_EUID]) + } + + // MARK: - Aliases + + /// Both queues are aged at flush, and each rewrites its own cache entry. + func testAnIdentityFlushDropsStaleAliasRequestsFromBothQueues() { + newRecordsState.add(userA_OSID) + cache([addAliases(for: other, daysOld: 29)], under: addAliasesKey) + cache([removeAlias(for: other, daysOld: 29), removeAlias(for: current, daysOld: 29)], under: removeAliasKey) + let executor = identityExecutor() + + now = now.addingTimeInterval(2 * day) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("The stale alias Requests were not dropped from both cache entries") { + self.cachedOwners(self.addAliasesKey, of: OSRequestAddAliases.self).isEmpty + && self.cachedOwners(self.removeAliasKey, of: OSRequestRemoveAlias.self) == [userA_EUID] + } + + XCTAssertEqual(client.executedRequests.count, 0) + } + + // MARK: - Custom events + + func testCustomEventsOwnedByTheCurrentUserAreDroppedAt31Days() { + cache([customEvents(for: current, daysOld: 31)], under: customEventsKey) + + _ = customEventsExecutor() + + XCTAssertEqual(cachedOwners(customEventsKey, of: OSRequestCustomEvents.self), []) + } + + func testACustomEventsFlushKeepsAt29DaysAndDropsAt31Days() { + newRecordsState.add(userA_OSID) + cache([customEvents(for: current, daysOld: 29)], under: customEventsKey) + let executor = customEventsExecutor() + + executor.processDeltaQueue(inBackground: false) + allowAsyncWorkToRun() + XCTAssertEqual(cachedOwners(customEventsKey, of: OSRequestCustomEvents.self), [userA_EUID]) + + now = now.addingTimeInterval(2 * day) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("The stale Custom Events was not dropped from the cache") { + self.cachedOwners(self.customEventsKey, of: OSRequestCustomEvents.self).isEmpty + } + + XCTAssertEqual(client.executedRequests.count, 0) + } + + /// A custom event stays a Delta until its user has an `onesignal_id`, so it is aged where it waits. + func testACustomEventDeltaWaitingForAnOnesignalIdIsDroppedAt31Days() { + let pending = addUserToRepo(externalId: "test_user_c_external_id", onesignalId: nil, token: "token-c") + let executor = customEventsExecutor() + executor.enqueueDelta(customEventDelta(for: pending)) + + now = now.addingTimeInterval(29 * day) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("The waiting Delta was not cached") { + self.cachedCustomEventDeltaCount() == 1 + } + + now = now.addingTimeInterval(2 * day) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("The stale Delta was not dropped from the cache") { + self.cachedCustomEventDeltaCount() == 0 + } + + XCTAssertEqual(cachedOwners(customEventsKey, of: OSRequestCustomEvents.self), []) + XCTAssertEqual(client.executedRequests.count, 0) + } + + /// The Request built once the id arrives keeps the Delta's timestamp, so its 30 days count from the event. + func testACustomEventRequestIsAgedFromItsDelta() { + let pendingOSID = "test_user_c_onesignal_id" + let pending = addUserToRepo(externalId: "test_user_c_external_id", onesignalId: nil, token: "token-c") + newRecordsState.add(pendingOSID) + let executor = customEventsExecutor() + executor.enqueueDelta(customEventDelta(for: pending)) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("The waiting Delta was not cached") { + self.cachedCustomEventDeltaCount() == 1 + } + + now = now.addingTimeInterval(20 * day) + pending.addAliases([OS_ONESIGNAL_ID: pendingOSID]) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("The Delta did not become a Request once its user had an onesignal_id") { + self.cachedOwners(self.customEventsKey, of: OSRequestCustomEvents.self) == ["test_user_c_external_id"] + } + + now = now.addingTimeInterval(11 * day) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("The Request was not aged from its Delta") { + self.cachedOwners(self.customEventsKey, of: OSRequestCustomEvents.self).isEmpty + } + + XCTAssertEqual(client.executedRequests.count, 0) + } + + // MARK: - Requests with no age limit + + func testACreateUser91DaysOldIsKeptAtUncache() throws { + let user = try XCTUnwrap(OneSignalUserManagerImpl.sharedInstance._user) + // Held so the User executor cannot send it. The assertion is that uncache kept it. + newRecordsState.add(testPushSubId) + let request = OSRequestCreateUser(identityModel: current, propertiesModel: user.propertiesModel, pushSubscriptionModel: user.pushSubscriptionModel, originalPushToken: nil) + cache([aged(request, daysOld: 91)], under: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY) + + _ = OSUserExecutor(newRecordsState: newRecordsState, identityVerificationService: OneSignalUserManagerImpl.sharedInstance.identityVerificationService, auth: auth) + allowAsyncWorkToRun() + + XCTAssertEqual(cachedOwners(OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, of: OSRequestCreateUser.self), [userA_EUID]) + XCTAssertEqual(client.executedRequests.count, 0) + } + + func testACreateSubscription91DaysOldIsKeptAtUncache() { + let subscription = OSSubscriptionModel(type: .email, address: "a@example.com", subscriptionId: "test-email-subscription-id", reachable: true, isDisabled: false, changeNotifier: OSEventProducer()) + let request = OSRequestCreateSubscription(subscriptionModel: subscription, identityModel: current, ownerExternalId: userA_EUID) + cache([aged(request, daysOld: 91)], under: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY) + + _ = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState, auth: auth) + + XCTAssertEqual(cachedOwners(OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, of: OSRequestCreateSubscription.self), [userA_EUID]) + } + + // MARK: - Clock + + /// Owned by the other user, so both limits would apply if it had any age. + func testARequestWithATimestampAheadOfTheClockIsKept() { + cache([propertyUpdate(for: other, daysOld: -365)], under: propertiesKey) + + _ = propertyExecutor() + + XCTAssertEqual(cachedOwners(propertiesKey, of: OSRequestUpdateProperties.self), [userB_EUID]) + } +}