diff --git a/examples/demo/App.xcodeproj/project.pbxproj b/examples/demo/App.xcodeproj/project.pbxproj index c2eb9fe65..23fca3d88 100644 --- a/examples/demo/App.xcodeproj/project.pbxproj +++ b/examples/demo/App.xcodeproj/project.pbxproj @@ -41,6 +41,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 */; }; @@ -321,6 +322,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 = ""; }; @@ -358,7 +360,7 @@ E194A3F19072CB17A8F1A12E /* SmsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmsSection.swift; sourceTree = ""; }; ECAC7EF0B67920F9FEC4F129 /* TagsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsSection.swift; sourceTree = ""; }; F46DFACB9F304B9374F3C570 /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = ""; }; - "TEMP_E243B27C-2274-4671-9C94-8B86EB8D4EFA" /* Build.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Build.xcconfig; sourceTree = ""; }; + "TEMP_4292FBC8-5C5B-4FEC-8FC6-E00954AA81D6" /* Build.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Build.xcconfig; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -504,6 +506,7 @@ 2D959B1636916DAEE5FE6278 /* CustomNotificationDialog.swift */, 5984B93007C6B85AFE09045A /* KeyValueRow.swift */, 280B23B41935EAB89C8C6FCB /* ListWidgets.swift */, + 3DD26AED9EA84B0308C1BFE8 /* LoginUserDialog.swift */, 4072202943CA64D2CBC38CB5 /* MultiPairInputDialog.swift */, D261D46C404E325CBA87A9E0 /* OSDialog.swift */, D54B9DAAEDBE67B73893C522 /* OutcomeDialog.swift */, @@ -564,10 +567,10 @@ path = Views; sourceTree = ""; }; - "TEMP_9E448277-C21D-48E5-98E2-992293BCF68A" /* demo */ = { + "TEMP_22A9BEEB-F02D-4CD8-B729-B06597DB45B7" /* demo */ = { isa = PBXGroup; children = ( - "TEMP_E243B27C-2274-4671-9C94-8B86EB8D4EFA" /* Build.xcconfig */, + "TEMP_4292FBC8-5C5B-4FEC-8FC6-E00954AA81D6" /* Build.xcconfig */, ); path = demo; sourceTree = ""; @@ -810,6 +813,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 */, @@ -936,7 +940,7 @@ /* Begin XCBuildConfiguration section */ 0D2EF3911CA89837C30DB0D1 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = "TEMP_E243B27C-2274-4671-9C94-8B86EB8D4EFA" /* Build.xcconfig */; + baseConfigurationReference = "TEMP_4292FBC8-5C5B-4FEC-8FC6-E00954AA81D6" /* Build.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; @@ -954,7 +958,7 @@ }; 4A0C935808978B5A7673E412 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = "TEMP_E243B27C-2274-4671-9C94-8B86EB8D4EFA" /* Build.xcconfig */; + baseConfigurationReference = "TEMP_4292FBC8-5C5B-4FEC-8FC6-E00954AA81D6" /* Build.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App.entitlements; @@ -1034,7 +1038,7 @@ }; D0E56A85F1C385808720F94B /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = "TEMP_E243B27C-2274-4671-9C94-8B86EB8D4EFA" /* Build.xcconfig */; + baseConfigurationReference = "TEMP_4292FBC8-5C5B-4FEC-8FC6-E00954AA81D6" /* Build.xcconfig */; buildSettings = { CODE_SIGN_ENTITLEMENTS = OneSignalNotificationServiceExtension/OneSignalNotificationServiceExtension.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; @@ -1056,7 +1060,7 @@ }; EB1CC3A930E09FEBECF9195D /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = "TEMP_E243B27C-2274-4671-9C94-8B86EB8D4EFA" /* Build.xcconfig */; + baseConfigurationReference = "TEMP_4292FBC8-5C5B-4FEC-8FC6-E00954AA81D6" /* Build.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App.entitlements; @@ -1079,7 +1083,7 @@ }; F305A3E63851EE49DA2D190E /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = "TEMP_E243B27C-2274-4671-9C94-8B86EB8D4EFA" /* Build.xcconfig */; + baseConfigurationReference = "TEMP_4292FBC8-5C5B-4FEC-8FC6-E00954AA81D6" /* Build.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; @@ -1102,7 +1106,7 @@ }; F5FD25168D9B32A08A468069 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = "TEMP_E243B27C-2274-4671-9C94-8B86EB8D4EFA" /* Build.xcconfig */; + baseConfigurationReference = "TEMP_4292FBC8-5C5B-4FEC-8FC6-E00954AA81D6" /* Build.xcconfig */; buildSettings = { CODE_SIGN_ENTITLEMENTS = OneSignalNotificationServiceExtension/OneSignalNotificationServiceExtension.entitlements; DEVELOPMENT_TEAM = 99SW8E36CT; 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..7b1b45af3 100644 --- a/examples/demo/App/Services/OneSignalService.swift +++ b/examples/demo/App/Services/OneSignalService.swift @@ -96,16 +96,39 @@ final class OneSignalService { // MARK: - User - func login(externalId: String) { + func login(externalId: String, jwtToken: String? = nil) { prefs.setExternalUserId(externalId) - OneSignal.login(externalId) + // Persist for the demo REST fetch only; cold start does not call login/updateUserJwt with it. + prefs.setSessionJwtToken(jwtToken) + if let jwtToken = jwtToken { + OneSignal.login(externalId: externalId, token: jwtToken) + } else { + OneSignal.login(externalId) + } + } + + func updateUserJwt(externalId: String, token: String) { + prefs.setSessionJwtToken(token) + OneSignal.updateUserJwt(externalId: externalId, token: token) } func logout() { prefs.setExternalUserId(nil) + prefs.setSessionJwtToken(nil) OneSignal.logout() } + // 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) } + } + + /// JWT from the last login / updateUserJwt. Used by the demo REST fetch only β€” not auto-fed to the SDK on cold start. + var sessionJwtToken: String? { prefs.getSessionJwtToken() } + // MARK: - Aliases func addAlias(label: String, id: String) { OneSignal.User.addAlias(label: label, id: id) } @@ -222,6 +245,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..8e258884c 100644 --- a/examples/demo/App/Services/UserFetchService.swift +++ b/examples/demo/App/Services/UserFetchService.swift @@ -27,17 +27,36 @@ import Foundation -/// Reads the OneSignal /users API to hydrate aliases / tags / channels in the demo +/// 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 -> UserData? { + guard !aliasValue.isEmpty else { return nil } + + // 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 nil + } 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) diff --git a/examples/demo/App/ViewModels/OneSignalViewModel.swift b/examples/demo/App/ViewModels/OneSignalViewModel.swift index dfa23553a..a60511018 100644 --- a/examples/demo/App/ViewModels/OneSignalViewModel.swift +++ b/examples/demo/App/ViewModels/OneSignalViewModel.swift @@ -43,6 +43,7 @@ final class OneSignalViewModel: ObservableObject { @Published var externalUserId: String? @Published var aliases: [KeyValueItem] = [] + @Published var useIdentityVerification: Bool = false // MARK: - Push @@ -99,6 +100,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 +108,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,19 +126,43 @@ 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 userData = 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 } @@ -166,21 +197,35 @@ 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 } isLoading = true - service.login(externalId: trimmed) + service.login(externalId: trimmed, jwtToken: jwtToken) externalUserId = trimmed 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 } + service.updateUserJwt(externalId: trimmedId, token: trimmedToken) + print("[OneSignal] Updated JWT for: \(trimmedId)") + } + func logout() { service.logout() externalUserId = nil clearUserData() } + func setUseIdentityVerification(_ enabled: Bool) { + useIdentityVerification = enabled + service.useIdentityVerification = enabled + print("[OneSignal] Identity verification \(enabled ? "enabled" : "disabled")") + } + private func clearUserData() { aliases.removeAll() emails.removeAll() @@ -443,12 +488,17 @@ final class OneSignalViewModel: ObservableObject { service.addPushSubscriptionObserver(observers) service.addUserObserver(observers) service.addPermissionObserver(observers) + service.addUserJwtInvalidatedListener(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) { @@ -470,4 +520,8 @@ private final class Observers: NSObject, OSPushSubscriptionObserver, OSUserState viewModel?.isPushEnabled = OneSignal.User.pushSubscription.optedIn } } + + func onUserJwtInvalidated(event: OSUserJwtInvalidatedEvent) { + print("[OneSignal] JWT invalidated for externalId: \(event.externalId)") + } } 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..b6dca78a1 100644 --- a/examples/demo/App/Views/Sections/UserSection.swift +++ b/examples/demo/App/Views/Sections/UserSection.swift @@ -27,13 +27,24 @@ 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 @State private var loginOpen = false + @State private var updateJwtOpen = false var body: some View { SectionCard(title: "USER", sectionKey: "user") { + ToggleRow( + label: "Identity Verification", + description: "Use external_id for API calls", + isOn: Binding( + get: { viewModel.useIdentityVerification }, + set: { viewModel.setUseIdentityVerification($0) } + ), + accessibilityID: "identity_verification_toggle" + ) + ValueCard(rows: [ ValueCard.Row( label: "Status", @@ -64,16 +75,33 @@ 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, + onAdd: { externalId, token in + viewModel.updateUserJwt(externalId: externalId, token: token) + updateJwtOpen = false + }, + onCancel: { updateJwtOpen = false } + ) + } } } diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 10f512fa6..264c2e9cd 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -51,7 +51,15 @@ 03CCCC832835D90F004BF794 /* OneSignalUNUserNotificationCenterHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = 03CCCC812835D90F004BF794 /* OneSignalUNUserNotificationCenterHelper.m */; }; 03CCCC852835F291004BF794 /* UIApplicationDelegateSwizzlingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 03CCCC842835F291004BF794 /* UIApplicationDelegateSwizzlingTests.m */; }; 03E56DD328405F4A006AA1DA /* OneSignalAppDelegateOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 03E56DD228405F4A006AA1DA /* OneSignalAppDelegateOverrider.m */; }; + 0AA11438FBF3A82D13824467 /* OSFeatureManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DA9EEED644F1160CAD9A38 /* OSFeatureManager.swift */; }; 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 */; }; + 2F32272222E88DF0C2C18B53 /* OSFeatureManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42E4A83C6C0D0DF28CDECA90 /* OSFeatureManagerTests.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 */; }; @@ -279,6 +287,8 @@ 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 */; }; + 50C4F7CFEC46836131011004 /* XCTestCase+WaitUntil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A81CCA8DFA43C8AEA738B8 /* XCTestCase+WaitUntil.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 */; }; @@ -288,6 +298,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 */; }; @@ -330,6 +341,9 @@ 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 */; }; 911E2CC61E398B97003112A4 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37E6B2BA19D9CAF300D0C601 /* UIKit.framework */; }; @@ -360,13 +374,21 @@ 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 */; }; + 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 */; }; 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, ); }; }; + 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 */; }; CA1A6E6920DC2E31001C41B9 /* OneSignalDialogController.h in Headers */ = {isa = PBXBuildFile; fileRef = CA1A6E6720DC2E31001C41B9 /* OneSignalDialogController.h */; }; CA1A6E6A20DC2E31001C41B9 /* OneSignalDialogController.m in Sources */ = {isa = PBXBuildFile; fileRef = CA1A6E6820DC2E31001C41B9 /* OneSignalDialogController.m */; }; @@ -394,6 +416,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 */; }; @@ -650,6 +677,8 @@ 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 */; }; + FD1F1FCA05D555623DD53B54 /* OSUserJwtRepo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8BBA843AFC81A4940CF7CC /* OSUserJwtRepo.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1319,18 +1348,25 @@ 03866CBC2378A33B0009C1D8 /* OutcomeIntegrationTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OutcomeIntegrationTests.m; sourceTree = ""; }; 03866CBF2378A67B0009C1D8 /* RestClientAsserts.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RestClientAsserts.h; sourceTree = ""; }; 03866CC02378A67B0009C1D8 /* RestClientAsserts.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RestClientAsserts.m; sourceTree = ""; }; + 03A81CCA8DFA43C8AEA738B8 /* XCTestCase+WaitUntil.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "XCTestCase+WaitUntil.swift"; sourceTree = ""; }; 03CCCC7D2835D8CC004BF794 /* OneSignalUNUserNotificationCenterSwizzlingTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OneSignalUNUserNotificationCenterSwizzlingTest.m; sourceTree = ""; }; 03CCCC812835D90F004BF794 /* OneSignalUNUserNotificationCenterHelper.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OneSignalUNUserNotificationCenterHelper.m; sourceTree = ""; }; 03CCCC822835D90F004BF794 /* OneSignalUNUserNotificationCenterHelper.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OneSignalUNUserNotificationCenterHelper.h; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; + 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 = ""; }; 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 = ""; }; @@ -1344,6 +1380,12 @@ 3C11518C289AF5E800565C41 /* OSModelChangedHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSModelChangedHandler.swift; sourceTree = ""; }; 3C14E39E2AFAE39B006ED053 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 3C14E3A02AFAE461006ED053 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + 3C14E3A92FAE54C006ED053 /* IOSLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSLogger.swift; sourceTree = ""; }; + 3C14E3AA2FAE54C006ED053 /* OneSignalLogHttpSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalLogHttpSender.swift; sourceTree = ""; }; + 3C14E3AB2FAE54C006ED053 /* FileLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogStore.swift; sourceTree = ""; }; + 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 = ""; }; 3C19C6312E919F0C00D6731E /* OSRequestLiveActivityClicked.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRequestLiveActivityClicked.swift; sourceTree = ""; }; 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalIdentifiersFallbackTests.swift; sourceTree = ""; }; 3C23A21C2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSModelStoreRefreshTests.swift; sourceTree = ""; }; @@ -1474,6 +1516,7 @@ 3E2400381D4FFC31008BDE70 /* OneSignalFramework.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = OneSignalFramework.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3E24003B1D4FFC31008BDE70 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 3E464ED91D88EE6A00DCF7E9 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; }; + 42E4A83C6C0D0DF28CDECA90 /* OSFeatureManagerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSFeatureManagerTests.swift; sourceTree = ""; }; 4529DED01FA81EA800CEAB1D /* NSObjectOverrider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NSObjectOverrider.h; sourceTree = ""; }; 4529DED11FA81EA800CEAB1D /* NSObjectOverrider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = NSObjectOverrider.m; sourceTree = ""; }; 4529DED31FA823B900CEAB1D /* TestHelperFunctions.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = TestHelperFunctions.h; sourceTree = ""; }; @@ -1514,6 +1557,10 @@ 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 = ""; }; + 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; }; 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 = ""; }; @@ -1524,6 +1571,10 @@ 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 = ""; }; + 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 = ""; }; 7A12EBD623060A6F005C4FA5 /* OSSessionManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OSSessionManager.h; sourceTree = ""; }; @@ -1597,6 +1648,9 @@ 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 = ""; }; + 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 = ""; }; 911E2CBE1E398AB3003112A4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -1620,6 +1674,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 = ""; }; @@ -1630,6 +1685,8 @@ 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 = ""; }; + C7DA9EEED644F1160CAD9A38 /* OSFeatureManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSFeatureManager.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 = ""; }; @@ -1783,12 +1840,6 @@ DEBAAEB62A4381AE00BF2C1C /* OSInAppMessageMigrationController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSInAppMessageMigrationController.h; sourceTree = ""; }; DEBAAEB72A4381AE00BF2C1C /* OSInAppMessageMigrationController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OSInAppMessageMigrationController.m; sourceTree = ""; }; DEC08AFF2947D4E900C81DA3 /* OneSignalSwiftInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalSwiftInterface.swift; sourceTree = ""; }; - 3C14E3A92FAE54C006ED053 /* IOSLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSLogger.swift; sourceTree = ""; }; - 3C14E3AA2FAE54C006ED053 /* OneSignalLogHttpSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalLogHttpSender.swift; sourceTree = ""; }; - 3C14E3AB2FAE54C006ED053 /* FileLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogStore.swift; sourceTree = ""; }; - 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 = ""; }; 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 = ""; }; DEF5CCF42539321A0003E9CC /* AppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; @@ -1834,6 +1885,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 */ @@ -2236,6 +2294,7 @@ 3C30FE352F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift */, 3CB35FCA2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift */, 3C7021E72ECF0CF3001768C6 /* OneSignalInAppMessagesTests-Bridging-Header.h */, + 67ECA2928D863073B785F93F /* IamFetchIdentityVerificationTests.swift */, ); path = OneSignalInAppMessagesTests; sourceTree = ""; @@ -2273,6 +2332,9 @@ 4710EA522B8FCFB200435356 /* OSDispatchQueue.swift */, DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */, DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */, + A843B922174496E99F2D00A8 /* Jwt */, + C7DA9EEED644F1160CAD9A38 /* OSFeatureManager.swift */, + 8596A3E728FB90691E43BCA0 /* OSUrlPath.swift */, ); path = Source; sourceTree = ""; @@ -2380,6 +2442,7 @@ 3C87066F2BDE0957000D8CD2 /* MockUserRequests.swift */, 3C8706712BDEE076000D8CD2 /* MockUserDefines.swift */, 3CC063E52B6D7F96002BB07F /* OneSignalUserMocks.swift */, + 1F214EE6C5FE133672D6622F /* MockUserJwtInvalidatedListener.swift */, ); path = OneSignalUserMocks; sourceTree = ""; @@ -2395,6 +2458,14 @@ 3CB331672F281679000E1801 /* CustomEventsIntegrationTests.swift */, 3C67F7792BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift */, 3CDE664B2BFC2A56006DA114 /* OneSignalUserObjcTests.m */, + 89DE5BB0EDD3964C20C5169F /* OSIdentityModelRepoTests.swift */, + 5953656ACCC21358BC0CF2F0 /* OSIdentityModelTests.swift */, + B5809B47EB4F2478099697CE /* OSUserJwtRepoTests.swift */, + 80DC5517E6EB5B26CF980CC5 /* UserJwtApiTests.swift */, + 3881024646E7F0DE05158442 /* DeltaOwnershipTests.swift */, + 047D8F5E1095A20C9C54FD33 /* OSRequestAuthTests.swift */, + 3016921C1F6B7B7793F67567 /* RequestPathEncodingTests.swift */, + 5BFE2F960129386AFA6D5F41 /* UserJwtLifecycleTests.swift */, ); path = OneSignalUserTests; sourceTree = ""; @@ -2413,6 +2484,7 @@ 3CF11E3C2C6D6155002856F5 /* UserExecutorTests.swift */, 3CA93BC3300AEFFA000724B3 /* SubscriptionUpdateRaceTests.swift */, 3CB331692F281692000E1801 /* OSCustomEventsExecutorTests.swift */, + FE740F9E6B87D215510B5982 /* ExecutorAnonymousPurgeTests.swift */, ); path = Executors; sourceTree = ""; @@ -2551,6 +2623,15 @@ path = OneSignalLiveActivitiesFramework; sourceTree = ""; }; + 4795885CE6CFFB1998AC7D09 /* Feature */ = { + isa = PBXGroup; + children = ( + 42E4A83C6C0D0DF28CDECA90 /* OSFeatureManagerTests.swift */, + ); + name = Feature; + path = Feature; + sourceTree = ""; + }; 5B053FB92CAE07EB002F30C4 /* OneSignalOSCoreTests */ = { isa = PBXGroup; children = ( @@ -2560,10 +2641,27 @@ 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */, 3C23A21E2FCE0AA1001D32E3 /* OSResilientStorageTests.swift */, 3C23A21C2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift */, + FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */, + 03A81CCA8DFA43C8AEA738B8 /* XCTestCase+WaitUntil.swift */, + 4795885CE6CFFB1998AC7D09 /* Feature */, + 5B47CE0CE255AC7128442FFF /* Jwt */, + 557653D2007BFF86EA8342E4 /* OSDeltaTests.swift */, + FFBB451F692A15639A1A08AE /* OSOperationRepoIdentityVerificationTests.swift */, + 5221EEDBA5A74BD565490D52 /* OSOperationRepoTestSupport.swift */, ); path = OneSignalOSCoreTests; sourceTree = ""; }; + 5B47CE0CE255AC7128442FFF /* Jwt */ = { + isa = PBXGroup; + children = ( + F4855B81F170253FB0C1749D /* OSIdentityVerificationServiceTests.swift */, + E9376A4957E9090C748BCB18 /* OSUserJwtConfigTests.swift */, + ); + name = Jwt; + path = Jwt; + sourceTree = ""; + }; 5BC1DE652C90BC9F00CA8807 /* Consistency */ = { isa = PBXGroup; children = ( @@ -2715,6 +2813,17 @@ name = Categories; sourceTree = ""; }; + A843B922174496E99F2D00A8 /* Jwt */ = { + isa = PBXGroup; + children = ( + 1C4EAEA1BC62D8FC57927511 /* OSIdentityVerificationService.swift */, + 6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */, + F83E7BF2B518EA8B0B51B276 /* OSAliasPair.swift */, + ); + name = Jwt; + path = Jwt; + sourceTree = ""; + }; DE51DDE2294262670073D5C4 /* RemoteParameters */ = { isa = PBXGroup; children = ( @@ -2752,6 +2861,11 @@ 3CF8629F28A1964F00776CA4 /* OSPropertiesModel.swift */, 3CE795F828DB99B500736BD4 /* OSSubscriptionModelStoreListener.swift */, 3C5117162B15C31E00563465 /* OSUserState.swift */, + 2F2FC6C922FF8104F3197DD4 /* OSUserJwtInvalidatedEvent.swift */, + 6A8BBA843AFC81A4940CF7CC /* OSUserJwtRepo.swift */, + 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */, + E78603AF16454A648A96DF7B /* OSPushSubscriptionImpl.swift */, + 9BF72AAEB5284C97B864A1A8 /* OSRequestAuth.swift */, ); path = Source; sourceTree = ""; @@ -4410,6 +4524,7 @@ 3C7021E92ECF0CF4001768C6 /* IAMIntegrationTests.swift in Sources */, 3C01519C2C2E29F90079E076 /* IAMRequestTests.m in Sources */, 3CB35FCB2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift in Sources */, + 6F894909CB6D09258DCCA1C9 /* IamFetchIdentityVerificationTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4446,6 +4561,11 @@ 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 */, + 0AA11438FBF3A82D13824467 /* OSFeatureManager.swift in Sources */, + D14FEB74CBF964F5BF0FD615 /* OSUrlPath.swift in Sources */, + ABAFB38CC150EE24ED595EF7 /* OSAliasPair.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4496,6 +4616,7 @@ 3C8706702BDE0957000D8CD2 /* MockUserRequests.swift in Sources */, 3C8706722BDEE076000D8CD2 /* MockUserDefines.swift in Sources */, 3CC063E62B6D7F96002BB07F /* OneSignalUserMocks.swift in Sources */, + 94D9F5AFE86B16453A36C9A2 /* MockUserJwtInvalidatedListener.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4512,6 +4633,15 @@ 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 */, + 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; }; @@ -4585,6 +4715,14 @@ 3C427AC9301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift in Sources */, 3C14E3B52FAE54C006ED053 /* OSLoggerAdaptersTests.swift in Sources */, 3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */, + DD2A89A8052E2D1912B0038B /* OSIamFetchReadyConditionTests.swift in Sources */, + 50C4F7CFEC46836131011004 /* XCTestCase+WaitUntil.swift in Sources */, + 2F32272222E88DF0C2C18B53 /* OSFeatureManagerTests.swift in Sources */, + 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; }; @@ -4706,6 +4844,11 @@ 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 */, + DDE652EF2123473B808F8CCA /* OSPushSubscriptionImpl.swift in Sources */, + 32601EF1960CD92605D1ABF9 /* OSRequestAuth.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5410,14 +5553,6 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; BUILD_LIBRARY_FOR_DISTRIBUTION = YES; - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; @@ -5445,6 +5580,14 @@ DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OneSignalOSCoreFramework/Info.plist; @@ -5478,14 +5621,6 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; BUILD_LIBRARY_FOR_DISTRIBUTION = YES; - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; @@ -5514,6 +5649,14 @@ DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_TESTABILITY = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_OPTIMIZATION_LEVEL = 0; @@ -6976,14 +7119,6 @@ buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; BUNDLE_LOADER = "$(TEST_HOST)"; - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -7008,6 +7143,14 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 99SW8E36CT; ENABLE_USER_SCRIPT_SANDBOXING = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 11.0; @@ -7030,14 +7173,6 @@ buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; BUNDLE_LOADER = "$(TEST_HOST)"; - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -7062,6 +7197,14 @@ DEVELOPMENT_TEAM = 99SW8E36CT; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_C_LANGUAGE_STANDARD = gnu17; GCC_DYNAMIC_NO_PIC = NO; GCC_OPTIMIZATION_LEVEL = 0; @@ -7092,14 +7235,6 @@ buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; BUNDLE_LOADER = "$(TEST_HOST)"; - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -7124,6 +7259,14 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 99SW8E36CT; ENABLE_USER_SCRIPT_SANDBOXING = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 11.0; @@ -7191,14 +7334,6 @@ CA2951B72167F4120064227A /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); CLANG_ANALYZER_GCD_PERFORMANCE = YES; CLANG_ANALYZER_SECURITY_FLOATLOOPCOUNTER = YES; CLANG_ENABLE_CODE_COVERAGE = NO; @@ -7208,6 +7343,14 @@ "$(inherited)", "$(PROJECT_DIR)", ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); IPHONEOS_DEPLOYMENT_TARGET = 9.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -7225,14 +7368,6 @@ CA2951B82167F4120064227A /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ENABLE_CODE_COVERAGE = NO; @@ -7249,6 +7384,14 @@ DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_WARN_64_TO_32_BIT_CONVERSION = NO; INFOPLIST_FILE = OneSignalFramework/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -7278,14 +7421,6 @@ CA2951B92167F4120064227A /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NONNULL = YES; CLANG_ENABLE_MODULES = YES; @@ -7297,6 +7432,14 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 99SW8E36CT; ENABLE_TESTABILITY = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = OS_TEST; HEADER_SEARCH_PATHS = $CONFIGURATION_TEMP_DIR/UnitTests.build/DerivedSources; @@ -7395,14 +7538,6 @@ CA2951C32167FB950064227A /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); CLANG_ANALYZER_GCD_PERFORMANCE = YES; CLANG_ANALYZER_SECURITY_FLOATLOOPCOUNTER = YES; CLANG_ENABLE_CODE_COVERAGE = NO; @@ -7412,6 +7547,14 @@ "$(inherited)", "$(PROJECT_DIR)", ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); IPHONEOS_DEPLOYMENT_TARGET = 9.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -7429,14 +7572,6 @@ CA2951C42167FB950064227A /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ENABLE_CODE_COVERAGE = YES; @@ -7454,6 +7589,14 @@ DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_TESTABILITY = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_WARN_64_TO_32_BIT_CONVERSION = NO; INFOPLIST_FILE = OneSignalFramework/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -7484,14 +7627,6 @@ CA2951C52167FB950064227A /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NONNULL = YES; CLANG_ENABLE_MODULES = YES; @@ -7503,6 +7638,14 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 99SW8E36CT; ENABLE_TESTABILITY = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = OS_TEST; HEADER_SEARCH_PATHS = $CONFIGURATION_TEMP_DIR/UnitTests.build/DerivedSources; @@ -7601,14 +7744,6 @@ DE3D8F3928C15839008C2BBF /* Test */ = { isa = XCBuildConfiguration; buildSettings = { - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); CLANG_ANALYZER_GCD_PERFORMANCE = YES; CLANG_ANALYZER_SECURITY_FLOATLOOPCOUNTER = YES; CLANG_ENABLE_CODE_COVERAGE = NO; @@ -7618,6 +7753,14 @@ "$(inherited)", "$(PROJECT_DIR)", ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); IPHONEOS_DEPLOYMENT_TARGET = 9.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -7635,14 +7778,6 @@ DE3D8F3A28C15839008C2BBF /* Test */ = { isa = XCBuildConfiguration; buildSettings = { - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ENABLE_CODE_COVERAGE = YES; @@ -7660,6 +7795,14 @@ DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_TESTABILITY = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_WARN_64_TO_32_BIT_CONVERSION = NO; INFOPLIST_FILE = OneSignalFramework/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -7690,14 +7833,6 @@ DE3D8F3B28C15839008C2BBF /* Test */ = { isa = XCBuildConfiguration; buildSettings = { - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NONNULL = YES; CLANG_ENABLE_MODULES = YES; @@ -7709,6 +7844,14 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 99SW8E36CT; ENABLE_TESTABILITY = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = OS_TEST; HEADER_SEARCH_PATHS = $CONFIGURATION_TEMP_DIR/UnitTests.build/DerivedSources; @@ -9205,14 +9348,6 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; BUILD_LIBRARY_FOR_DISTRIBUTION = YES; - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); CLANG_ENABLE_MODULES = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; @@ -9222,6 +9357,14 @@ DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_TESTABILITY = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_PREPROCESSOR_DEFINITIONS = OS_TEST; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OneSignalOSCoreFramework/Info.plist; diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h index 6b2c48a24..3dc2fab3e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h @@ -77,6 +77,8 @@ // Remote Params #define OSUD_LOCATION_ENABLED @"OSUD_LOCATION_ENABLED" #define OSUD_REQUIRES_USER_PRIVACY_CONSENT @"OSUD_REQUIRES_USER_PRIVACY_CONSENT" +#define OSUD_USE_IDENTITY_VERIFICATION @"OSUD_USE_IDENTITY_VERIFICATION" +#define OSUD_SDK_FEATURE_FLAGS @"OSUD_SDK_FEATURE_FLAGS" // Remote Params - Receive Receipts #define OSUD_RECEIVE_RECEIPTS_ENABLED @"OS_ENABLE_RECEIVE_RECEIPTS" // * OSUD_RECEIVE_RECEIPTS_ENABLED // Outcomes @@ -135,7 +137,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" @@ -324,6 +326,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/OneSignalCoreMocks/MockOneSignalClient.swift b/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift index a388fc6f9..d85ffbaa3 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift @@ -197,12 +197,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/OneSignalInAppMessages/Controller/OSMessagingController.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m index 067787d89..dc7cf17ac 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; @@ -242,6 +250,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; @@ -255,7 +268,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"]; @@ -269,7 +290,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; } @@ -290,22 +316,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]; @@ -316,6 +476,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]; @@ -335,7 +499,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; @@ -350,11 +519,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"]; } @@ -365,21 +537,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 @@ -388,6 +575,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]; @@ -404,6 +595,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]; + } }]; } @@ -1237,11 +1434,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/EarlyTriggerTrackingTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift index bedfe909e..d916c1f49 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift @@ -49,7 +49,7 @@ final class EarlyTriggerTrackingTests: XCTestCase { override func setUpWithError() throws { OneSignalCoreMocks.clearUserDefaults() OneSignalUserMocks.reset() - OSConsistencyManager.shared.reset() + ConsistencyManagerTestHelpers.reset() OSMessagingController.removeInstance() // Set up basic configuration diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift index 9efcb6f51..f21259a50 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift @@ -41,7 +41,7 @@ final class IAMIntegrationTests: XCTestCase { override func setUpWithError() throws { OneSignalCoreMocks.clearUserDefaults() OneSignalUserMocks.reset() - OSConsistencyManager.shared.reset() + ConsistencyManagerTestHelpers.reset() // Temp. logging to help debug during testing OneSignalLog.setLogLevel(.LL_VERBOSE) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IamFetchIdentityVerificationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IamFetchIdentityVerificationTests.swift new file mode 100644 index 000000000..52031a37a --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IamFetchIdentityVerificationTests.swift @@ -0,0 +1,309 @@ +/* + 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 +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() + OSFeatureManager.shared.setEnabledFeatureKeys([]) + 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() { + OSFeatureManager.shared.setEnabledFeatureKeys([OSFeatureFlag.identityVerification.rawValue]) + } + + /// 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.waitForBackgroundThreads(seconds: 0.5) + } + + private func login(token: String?) { + ConsistencyManagerTestHelpers.setDefaultRywToken(id: userA_OSID) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: token) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + } + + private func fetch() { + OneSignalInAppMessages.getFromServer(testPushSubId) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + } + + // 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 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() + + fetch() + + XCTAssertEqual(executedFetches().count, fetchesBefore) + XCTAssertEqual(deferredSubscriptionId(), testPushSubId) + + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + 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 + + fetch() + + XCTAssertEqual(executedFetches().count, fetchesBefore) + XCTAssertEqual(deferredSubscriptionId(), testPushSubId) + + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userA_EUID, token: "token-b") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + 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.waitForBackgroundThreads(seconds: 0.5) + + 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.waitForBackgroundThreads(seconds: 0.5) + + 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.waitForBackgroundThreads(seconds: 0.5) + + 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 8d7ada7cb..54155d657 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift @@ -50,7 +50,7 @@ final class OSMessagingControllerUserStateTests: XCTestCase { override func setUpWithError() throws { OneSignalCoreMocks.clearUserDefaults() OneSignalUserMocks.reset() - OSConsistencyManager.shared.reset() + ConsistencyManagerTestHelpers.reset() OSMessagingController.removeInstance() // Set up basic configuration @@ -59,6 +59,7 @@ final class OSMessagingControllerUserStateTests: XCTestCase { } override func tearDownWithError() throws { + OSFeatureManager.shared.setEnabledFeatureKeys([]) OSMessagingController.removeInstance() } @@ -71,7 +72,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 { @@ -87,15 +88,14 @@ final class OSMessagingControllerUserStateTests: XCTestCase { /* 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 @@ -104,7 +104,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 */ @@ -129,7 +129,7 @@ final class OSMessagingControllerUserStateTests: XCTestCase { OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) // 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,22 +143,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 */ + OSFeatureManager.shared.setEnabledFeatureKeys([OSFeatureFlag.identityVerification.rawValue]) let client = MockOneSignalClient() OneSignalCoreImpl.setSharedClient(client) OneSignalInAppMessages.start() @@ -177,16 +179,41 @@ 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.waitForBackgroundThreads(seconds: 0.5) /* 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.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1)) + + MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: testExternalId) + OneSignalUserManagerImpl.sharedInstance.login(externalId: testExternalId, token: nil) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + 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/OneSignalOSCore/Source/Consistency/IamFetch/OSIamFetchReadyCondition.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/IamFetch/OSIamFetchReadyCondition.swift index 66751e3a0..3075e9cbb 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/IamFetch/OSIamFetchReadyCondition.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/IamFetch/OSIamFetchReadyCondition.swift @@ -28,17 +28,31 @@ @objc public class OSIamFetchReadyCondition: NSObject, OSCondition { // the id used to index the token map (e.g. onesignalId) private let id: String + + private let stateLock = NSLock() private var hasSubscriptionUpdatePending: Bool = false - // Singleton shared instance initialized with default empty id - private static var instance: OSIamFetchReadyCondition? + private static let instancesLock = NSLock() + private static var instances: [String: OSIamFetchReadyCondition] = [:] - // Method to get or initialize the shared instance + /** + One condition per id, so a fetch waits on the same object the subscription listener armed, and a + fetch for a user who just switched in is not answered by the previous user's tokens. + */ @objc public static func sharedInstance(withId id: String) -> OSIamFetchReadyCondition { - if instance == nil { - instance = OSIamFetchReadyCondition(id: id) + return instancesLock.withLock { + if let existing = instances[id] { + return existing + } + let condition = OSIamFetchReadyCondition(id: id) + instances[id] = condition + return condition } - return instance! + } + + /// Test seam; the instances otherwise live as long as the process. + @objc public static func reset() { + instancesLock.withLock { instances = [:] } } // Private initializer to prevent external instantiation @@ -53,8 +67,16 @@ return OSIamFetchReadyCondition.CONDITIONID } + /// Raises the bar for the next fetch: an in-session subscription change is only readable once its + /// own token arrives, so waiting on the user token alone would fetch before the server can see it. public func setSubscriptionUpdatePending(value: Bool) { - hasSubscriptionUpdatePending = value + stateLock.withLock { hasSubscriptionUpdatePending = value } + } + + /// The fetch this was raised for has been released, so later fetches stop waiting on a subscription + /// token that has no update behind it. + @objc public func onConditionSatisfied() { + setSubscriptionUpdatePending(value: false) } public func isMet(indexedTokens: [String: [NSNumber: OSReadYourWriteData]]) -> Bool { @@ -71,7 +93,7 @@ return true } - if hasSubscriptionUpdatePending { + if stateLock.withLock({ hasSubscriptionUpdatePending }) { return userUpdateTokenSet && subscriptionTokenSet } return userUpdateTokenSet diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSCondition.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSCondition.swift index c63807819..ffef386ee 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSCondition.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSCondition.swift @@ -32,4 +32,8 @@ import Foundation var conditionId: String { get } func isMet(indexedTokens: [String: [NSNumber: OSReadYourWriteData]]) -> Bool func getNewestToken(indexedTokens: [String: [NSNumber: OSReadYourWriteData]]) -> OSReadYourWriteData? + + /// Called once a waiter on this condition has been released, so a condition that raised its own bar + /// for that wait can lower it again instead of holding every later waiter to it. + @objc optional func onConditionSatisfied() } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift index ac7129de8..50de9c001 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift @@ -32,17 +32,30 @@ import OneSignalCore // Singleton instance @objc public static let shared = OSConsistencyManager() - private let queue = DispatchQueue(label: "com.consistencyManager.queue") + // Serial, and the only place `indexedTokens` and `indexedConditions` may be touched. + // Non-private so test helpers can synchronize with it. + let queue = DispatchQueue(label: "com.consistencyManager.queue") private var indexedTokens: [String: [NSNumber: OSReadYourWriteData]] = [:] - private var indexedConditions: [String: [(OSCondition, DispatchSemaphore)]] = [:] // Index conditions by condition id + // Waiters, indexed by the id passed to getRywTokenFromAwaitableCondition. Non-private for tests. + var indexedConditions: [String: [(OSCondition, DispatchSemaphore)]] = [:] + + /** + How long a waiter blocks before proceeding with whatever token it has. A response that never arrives β€” + the device is offline, or the endpoint stopped returning `ryw_token` and has no call that resolves the + condition β€” would otherwise hold the calling thread for the life of the process. + Non-private so tests can shorten it. + */ + static var waitTimeout: DispatchTimeInterval = .seconds(30) // Private initializer to prevent multiple instances private override init() {} // Used for testing public func reset() { - indexedTokens = [:] - indexedConditions = [:] + queue.sync { + self.indexedTokens = [:] + self.indexedConditions = [:] + } } // Function to set the token in a thread-safe manner @@ -57,52 +70,67 @@ import OneSignalCore } } - // Register a condition and block the caller until the condition is met + /// 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? { let semaphore = DispatchSemaphore(value: 0) queue.sync { - if self.indexedConditions[id] == nil { - self.indexedConditions[id] = [] - } - self.indexedConditions[id]?.append((condition, semaphore)) + self.indexedConditions[id, default: []].append((condition, semaphore)) self.checkConditionsAndComplete(forId: id) } - semaphore.wait() // Block until the condition is met + if semaphore.wait(timeout: .now() + OSConsistencyManager.waitTimeout) == .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 + } + // 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 } + } + } return queue.sync { return condition.getNewestToken(indexedTokens: self.indexedTokens) } } - // Method to resolve conditions by condition ID (e.g. OSIamFetchReadyCondition.ID) - @objc public func resolveConditionsWithID(id: String) { - guard let conditionList = indexedConditions[id] else { return } - var completedConditions: [(OSCondition, DispatchSemaphore)] = [] - for (condition, semaphore) in conditionList { - if condition.conditionId == id { - semaphore.signal() - completedConditions.append((condition, semaphore)) + /** + 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 } - } - indexedConditions[id]?.removeAll { condition, semaphore in - completedConditions.contains(where: { $0.0 === condition && $0.1 == semaphore }) + 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 conditionList = indexedConditions[id] else { return } - var completedConditions: [(OSCondition, DispatchSemaphore)] = [] - for (condition, semaphore) in conditionList { + guard let waiters = indexedConditions[id] else { return } + var stillWaiting: [(OSCondition, DispatchSemaphore)] = [] + for (condition, semaphore) in waiters { if condition.isMet(indexedTokens: indexedTokens) { OneSignalLog.onesignalLog(.LL_INFO, message: "Condition met for id: \(id)") - semaphore.signal() - completedConditions.append((condition, semaphore)) + release(condition, semaphore) } else { OneSignalLog.onesignalLog(.LL_INFO, message: "Condition not met for id: \(id)") + stillWaiting.append((condition, semaphore)) } } - indexedConditions[id]?.removeAll { condition, semaphore in - completedConditions.contains(where: { $0.0 === condition && $0.1 == semaphore }) - } + indexedConditions[id] = stillWaiting + } + + private func release(_ condition: OSCondition, _ semaphore: DispatchSemaphore) { + condition.onConditionSatisfied?() + semaphore.signal() } } 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/Jwt/OSIdentityVerificationService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSIdentityVerificationService.swift new file mode 100644 index 000000000..d649c342c --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSIdentityVerificationService.swift @@ -0,0 +1,115 @@ +/* + 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 + +/// 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: OSFeatureManagerProtocol + 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(.identityVerification) || ivBehaviorActive + } + + public init(featureManager: OSFeatureManagerProtocol, 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/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/OSFeatureManager.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSFeatureManager.swift new file mode 100644 index 000000000..1721f2d8a --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSFeatureManager.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 Foundation +import OneSignalCore + +/// Backend-driven feature switches keyed by remote-config identifiers. +public enum OSFeatureFlag: String { + case identityVerification = "sdk_identity_verification" +} + +public protocol OSFeatureManagerProtocol: AnyObject { + func isEnabled(_ feature: OSFeatureFlag) -> Bool +} + +/** + Resolves which features are enabled for this run. + Flags take effect as soon as they are set so a kill switch does not need a cold start; the set is + cached so a launch that has not yet fetched remote config keeps the last known values. + */ +public final class OSFeatureManager: OSFeatureManagerProtocol { + /// Shared for the same reason as `OSUserJwtConfig.shared`, and under the same rule: only + /// `OneSignalUserManagerImpl` references it today, and `OneSignal.m` will once remote params + /// deliver flag keys. Everything below them is handed the feature manager when it is created. + public static let shared = OSFeatureManager() + + /** + Local-only test hook for forcing features ON without backend config. + Add keys here while testing locally, e.g. `[OSFeatureFlag.identityVerification.rawValue]`. + */ + private static let localFeatureOverrides: Set = [] + // private static let localFeatureOverrides: Set = [ + // OSFeatureFlag.identityVerification.rawValue + // ] + + private let lock = NSLock() + private var enabledKeys: Set + + public init() { + enabledKeys = OSFeatureManager.cachedKeys() + OSFeatureManager.warnIfLocalOverrides() + } + + /// Bypasses the cache so tests can force flags on. + public init(enabledKeys: Set) { + self.enabledKeys = Set(enabledKeys.map(OSFeatureManager.canonicalize)) + OSFeatureManager.warnIfLocalOverrides() + } + + public func isEnabled(_ feature: OSFeatureFlag) -> Bool { + return lock.withLock { + enabledKeys.contains(feature.rawValue) + || OSFeatureManager.localFeatureOverrides.contains(feature.rawValue) + } + } + + public func setEnabledFeatureKeys(_ keys: [String]) { + let canonical = Set(keys.map(OSFeatureManager.canonicalize)) + lock.withLock { + enabledKeys = canonical + OneSignalUserDefaults.initShared().saveObject(forKey: OSUD_SDK_FEATURE_FLAGS, withValue: Array(canonical)) + } + } + + /** + Re-reads the cached keys while none are known, closing the same prewarm gap as + `OSUserJwtConfig.refreshIfUnknown`. An app with every flag off reads the cache a second time, which + is cheaper than keeping enough state to tell that case apart from a read that came back empty. + */ + public func refreshIfEmpty() { + lock.withLock { + guard enabledKeys.isEmpty else { + return + } + enabledKeys = OSFeatureManager.cachedKeys() + } + } + + // Fold case so a differently cased remote key still matches a flag. + private static func canonicalize(_ key: String) -> String { + return key.lowercased() + } + + private static func cachedKeys() -> Set { + guard let cached = OneSignalUserDefaults.initShared().getSavedObject(forKey: OSUD_SDK_FEATURE_FLAGS, defaultValue: nil) as? [String] else { + return [] + } + return Set(cached.map(canonicalize)) + } + + private static func warnIfLocalOverrides() { + let overrides = Set(localFeatureOverrides.map(canonicalize)) + guard !overrides.isEmpty else { + return + } + OneSignalLog.onesignalLog( + .LL_WARN, + message: "OSFeatureManager: local feature override enabled for testing only: \(overrides)" + ) + } +} 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/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 223080697..a38b449ac 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 { @@ -131,6 +211,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") @@ -141,29 +241,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/ConsistencyManagerTestHelpers.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/ConsistencyManagerTestHelpers.swift index 53d105943..8649a1a07 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/ConsistencyManagerTestHelpers.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/ConsistencyManagerTestHelpers.swift @@ -28,6 +28,13 @@ import OneSignalOSCore public class ConsistencyManagerTestHelpers { + /// Clears both halves of the read-your-write state: the manager's tokens and waiters, and the + /// per-id conditions, which otherwise carry a raised subscription bar into the next test. + public static func reset() { + OSConsistencyManager.shared.reset() + OSIamFetchReadyCondition.reset() + } + /// Unblocks the Consistency Manager, which allows fetching of IAMs for example. public static func setDefaultRywToken(id: String) { let key = OSIamFetchOffsetKey.userUpdate diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/MockNewRecordsState.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/MockNewRecordsState.swift index 25a6444f7..b489cdeac 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/MockNewRecordsState.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/MockNewRecordsState.swift @@ -35,6 +35,13 @@ public class MockNewRecordsState: OSNewRecordsState { public var records: [MockNewRecord] = [] + /** + 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) records.append(record) @@ -42,6 +49,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 6a144eef7..4dae89435 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift @@ -31,20 +31,28 @@ 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() + } + + /// 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 { /** - 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/Feature/OSFeatureManagerTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Feature/OSFeatureManagerTests.swift new file mode 100644 index 000000000..c469749e8 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Feature/OSFeatureManagerTests.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 XCTest +import OneSignalCore +@testable import OneSignalOSCore + +final class OSFeatureManagerTests: XCTestCase { + + override func setUp() { + super.setUp() + clearCachedKeys() + } + + override func tearDown() { + clearCachedKeys() + super.tearDown() + } + + private func clearCachedKeys() { + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_FEATURE_FLAGS) + } + + func testFlagsAreOffWithoutRemoteConfig() { + XCTAssertFalse(OSFeatureManager().isEnabled(.identityVerification)) + } + + func testSettingTheKeyEnablesTheFlag() { + let featureManager = OSFeatureManager() + featureManager.setEnabledFeatureKeys([OSFeatureFlag.identityVerification.rawValue]) + + XCTAssertTrue(featureManager.isEnabled(.identityVerification)) + } + + func testEnabledKeysAreReadBackOnTheNextLaunch() { + OSFeatureManager().setEnabledFeatureKeys([OSFeatureFlag.identityVerification.rawValue]) + + XCTAssertTrue(OSFeatureManager().isEnabled(.identityVerification)) + } + + func testUnrecognizedKeysDontEnableAnything() { + let featureManager = OSFeatureManager() + featureManager.setEnabledFeatureKeys(["sdk_some_future_feature"]) + + XCTAssertFalse(featureManager.isEnabled(.identityVerification)) + } + + func testKeysMatchRegardlessOfCase() { + let featureManager = OSFeatureManager() + featureManager.setEnabledFeatureKeys(["SDK_Identity_Verification"]) + + XCTAssertTrue(featureManager.isEnabled(.identityVerification)) + } + + func testDroppingTheKeyTurnsTheFlagOffWithoutARelaunch() { + let featureManager = OSFeatureManager() + featureManager.setEnabledFeatureKeys([OSFeatureFlag.identityVerification.rawValue]) + + featureManager.setEnabledFeatureKeys([]) + + // The kill switch has to land in the current run, and it can't come back on the next one + XCTAssertFalse(featureManager.isEnabled(.identityVerification)) + XCTAssertFalse(OSFeatureManager().isEnabled(.identityVerification)) + } + + func testRefreshAdoptsCachedKeysThatTheInitialReadMissed() { + let featureManager = OSFeatureManager() + OSFeatureManager().setEnabledFeatureKeys([OSFeatureFlag.identityVerification.rawValue]) + + featureManager.refreshIfEmpty() + + XCTAssertTrue(featureManager.isEnabled(.identityVerification)) + } + + func testRefreshLeavesKeysThatAreAlreadyKnownAlone() { + let featureManager = OSFeatureManager(enabledKeys: [OSFeatureFlag.identityVerification.rawValue]) + OSFeatureManager().setEnabledFeatureKeys([]) + + featureManager.refreshIfEmpty() + + XCTAssertTrue(featureManager.isEnabled(.identityVerification)) + } + + func testKeysPassedToTheInitializerBypassTheCache() { + OSFeatureManager().setEnabledFeatureKeys([]) + + let featureManager = OSFeatureManager(enabledKeys: [OSFeatureFlag.identityVerification.rawValue]) + + XCTAssertTrue(featureManager.isEnabled(.identityVerification)) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSIdentityVerificationServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSIdentityVerificationServiceTests.swift new file mode 100644 index 000000000..1e9bc5a31 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSIdentityVerificationServiceTests.swift @@ -0,0 +1,245 @@ +/* + 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 OSIdentityVerificationServiceTests: XCTestCase { + + private var jwtConfig = OSUserJwtConfig() + private var featureManager = OSFeatureManager(enabledKeys: []) + + override func setUp() { + super.setUp() + clearCache() + jwtConfig = OSUserJwtConfig() + featureManager = OSFeatureManager(enabledKeys: []) + } + + override func tearDown() { + clearCache() + super.tearDown() + } + + private func clearCache() { + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_USE_IDENTITY_VERIFICATION) + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_FEATURE_FLAGS) + } + + 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() { + featureManager = OSFeatureManager(enabledKeys: [OSFeatureFlag.identityVerification.rawValue]) + 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() { + featureManager = OSFeatureManager(enabledKeys: [OSFeatureFlag.identityVerification.rawValue]) + 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 realFeatureManager = OSFeatureManager() + let service = OSIdentityVerificationService(featureManager: realFeatureManager, jwtConfig: jwtConfig) + jwtConfig.hydrate(requiresUserAuth: false) + + realFeatureManager.setEnabledFeatureKeys([OSFeatureFlag.identityVerification.rawValue]) + XCTAssertTrue(service.newCodePathsRun) + + realFeatureManager.setEnabledFeatureKeys([]) + 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/OneSignalOSCoreTests/OSConsistencyManagerTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift index 9a686d644..2db1a1378 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift @@ -8,19 +8,23 @@ import Foundation import XCTest -import OneSignalOSCore +@testable import OneSignalOSCore class OSConsistencyManagerTests: XCTestCase { var consistencyManager: OSConsistencyManager! + private var defaultWaitTimeout: DispatchTimeInterval! override func setUp() { super.setUp() // Use the shared instance of OSConsistencyManager consistencyManager = OSConsistencyManager.shared + defaultWaitTimeout = OSConsistencyManager.waitTimeout } override func tearDown() { + OSConsistencyManager.waitTimeout = defaultWaitTimeout consistencyManager.reset() + OSIamFetchReadyCondition.reset() super.tearDown() } @@ -92,35 +96,28 @@ class OSConsistencyManagerTests: XCTestCase { // Test: registerCondition does not complete when condition is not met func testRegisterConditionDoesNotCompleteWhenConditionIsNotMet() { - // Given a condition that will never be met - let condition = TestUnmetCondition() + OSConsistencyManager.waitTimeout = .milliseconds(500) let id = "test_id" - let rywDelay = 500 as NSNumber - - // Start on a background queue to simulate async behavior - DispatchQueue.global().async { - // Register the condition asynchronously - let rywData = self.consistencyManager.getRywTokenFromAwaitableCondition(condition, forId: id) + let returned = expectation(description: "waiter returned") + var rywData: OSReadYourWriteData? - // Since the condition will never be met, rywToken should remain nil - XCTAssertNil(rywData) + DispatchQueue.global().async { + rywData = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: id) + returned.fulfill() + } + waitUntil("waiter registered") { self.consistencyManager.waiterCount == 1 } - // Set an unrelated token to verify that the unmet condition still doesn't complete - self.consistencyManager.setRywTokenAndDelay( + // A token for another id must not release this waiter + consistencyManager.setRywTokenAndDelay( id: "unrelated_id", key: OSIamFetchOffsetKey.userUpdate, - value: OSReadYourWriteData(rywToken: "unrelated", rywDelay: rywDelay) - ) - - // newest token should still be nil as the condition is not met - XCTAssertNil(rywData) - } + value: OSReadYourWriteData(rywToken: "unrelated", rywDelay: 500) + ) + XCTAssertEqual(consistencyManager.waiterCount, 1) - // Use a short delay to let the async behavior complete without waiting indefinitely - DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) { - XCTAssertTrue(true) // Simulate some async action completing without hanging - } - } + wait(for: [returned], timeout: 2.0) + XCTAssertNil(rywData) + } func testSetRywTokenWithoutAnyCondition() { // Given @@ -292,6 +289,87 @@ class OSConsistencyManagerTests: XCTestCase { waitForExpectations(timeout: 2.0, handler: nil) } + + // MARK: - Releasing waiters + + /// A response with no `ryw_token` releases waiters for that user only. + func testResolvingByConditionIdReleasesWaitersForThatId() { + let returned = expectation(description: "waiter returned") + DispatchQueue.global().async { + _ = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: "onesignal-id") + returned.fulfill() + } + waitUntil("waiter registered") { self.consistencyManager.waiterCount == 1 } + + consistencyManager.resolveConditions(conditionId: TestUnmetCondition.CONDITIONID, forId: "onesignal-id") + + wait(for: [returned], timeout: 2.0) + XCTAssertEqual(consistencyManager.waiterCount, 0) + } + + /// Resolving user B leaves user A's waiter registered. + func testResolvingOneIdLeavesAnotherIdsWaiterWaiting() { + OSConsistencyManager.waitTimeout = .milliseconds(200) + let userA = "onesignal-id-a" + let userB = "onesignal-id-b" + + 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) + aReturned.fulfill() + } + DispatchQueue.global().async { + _ = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: userB) + bReturned.fulfill() + } + waitUntil("both waiters registered") { self.consistencyManager.waiterCount == 2 } + + consistencyManager.resolveConditions(conditionId: TestUnmetCondition.CONDITIONID, forId: userB) + + wait(for: [bReturned], timeout: 2.0) + XCTAssertEqual(consistencyManager.waiterCount, 1, "user A's waiter must still be registered") + // Drain A's timeout so the thread is not left blocked after the test. + wait(for: [aReturned], timeout: 2.0) + } + + func testResolvingADifferentConditionLeavesTheWaiterWaiting() { + OSConsistencyManager.waitTimeout = .milliseconds(200) + let returned = expectation(description: "waiter returned") + DispatchQueue.global().async { + _ = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: "onesignal-id") + returned.fulfill() + } + waitUntil("waiter registered") { self.consistencyManager.waiterCount == 1 } + + consistencyManager.resolveConditions(conditionId: "SomeOtherCondition", forId: "onesignal-id") + + XCTAssertEqual(consistencyManager.waiterCount, 1) + // Drain the timeout so the thread is not left blocked after the test. + wait(for: [returned], timeout: 2.0) + } + + /// A condition nothing ever meets must not hold its thread for the life of the process. + func testAWaiterGivesUpWhenItsConditionIsNeverMet() { + OSConsistencyManager.waitTimeout = .milliseconds(200) + let returned = expectation(description: "waiter returned") + var rywData: OSReadYourWriteData? + DispatchQueue.global().async { + rywData = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: "onesignal-id") + returned.fulfill() + } + + wait(for: [returned], timeout: 2.0) + XCTAssertNil(rywData) + XCTAssertEqual(consistencyManager.waiterCount, 0, "a waiter that gave up has to deregister") + } +} + +extension OSConsistencyManager { + /// Waiters registered right now. Tests poll it while the manager mutates on its own queue. + var waiterCount: Int { + return queue.sync { indexedConditions.values.reduce(0) { $0 + $1.count } } + } } // Mock implementation of OSCondition that simulates a condition that isn't met 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/OSIamFetchReadyConditionTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift new file mode 100644 index 000000000..68ea0b19e --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift @@ -0,0 +1,208 @@ +/* + 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 +@testable import OneSignalOSCore + +/// Covers what an IAM fetch waits for, and for whom. +final class OSIamFetchReadyConditionTests: XCTestCase { + + private let userA = "onesignal-id-a" + private let userB = "onesignal-id-b" + private var defaultWaitTimeout: DispatchTimeInterval! + + override func setUp() { + super.setUp() + defaultWaitTimeout = OSConsistencyManager.waitTimeout + OSConsistencyManager.shared.reset() + OSIamFetchReadyCondition.reset() + } + + override func tearDown() { + OSConsistencyManager.waitTimeout = defaultWaitTimeout + OSConsistencyManager.shared.reset() + OSIamFetchReadyCondition.reset() + super.tearDown() + } + + // MARK: - One condition per id + + func testTheSameIdGetsTheSameCondition() { + let first = OSIamFetchReadyCondition.sharedInstance(withId: userA) + let second = OSIamFetchReadyCondition.sharedInstance(withId: userA) + + XCTAssertTrue(first === second, "the fetch has to wait on the object the listener armed") + } + + /// A user switch must not be answered by the previous user's condition, which reads their tokens. + func testADifferentIdGetsItsOwnCondition() { + let forUserA = OSIamFetchReadyCondition.sharedInstance(withId: userA) + let forUserB = OSIamFetchReadyCondition.sharedInstance(withId: userB) + + XCTAssertFalse(forUserA === forUserB) + XCTAssertTrue(forUserA.isMet(indexedTokens: [userA: userCreateToken()])) + XCTAssertFalse(forUserB.isMet(indexedTokens: [userA: userCreateToken()]), + "the new user's fetch must not be released by the previous user's token") + } + + // MARK: - What the condition waits for + + func testAUserUpdateTokenIsEnoughWithNoSubscriptionUpdatePending() { + let condition = OSIamFetchReadyCondition.sharedInstance(withId: userA) + + XCTAssertTrue(condition.isMet(indexedTokens: [userA: userUpdateToken()])) + } + + func testAPendingSubscriptionUpdateAlsoWaitsForItsToken() { + let condition = OSIamFetchReadyCondition.sharedInstance(withId: userA) + condition.setSubscriptionUpdatePending(value: true) + + XCTAssertFalse(condition.isMet(indexedTokens: [userA: userUpdateToken()])) + XCTAssertTrue(condition.isMet(indexedTokens: [userA: userUpdateToken().merging(subscriptionToken()) { current, _ in current }])) + } + + /// The raised bar belongs to the fetch it was raised for; leaving it up makes every later fetch + /// wait on a subscription token that has no update behind it. + func testTheSubscriptionBarComesBackDownOnceTheFetchIsReleased() { + let condition = OSIamFetchReadyCondition.sharedInstance(withId: userA) + condition.setSubscriptionUpdatePending(value: true) + + condition.onConditionSatisfied() + + XCTAssertTrue(condition.isMet(indexedTokens: [userA: userUpdateToken()])) + } + + // MARK: - Through the Consistency Manager + + func testTheManagerLowersTheBarWhenItReleasesTheWaiter() { + let manager = OSConsistencyManager.shared + OSIamFetchReadyCondition.sharedInstance(withId: userA).setSubscriptionUpdatePending(value: true) + + let firstReturned = expectation(description: "first fetch released") + DispatchQueue.global().async { + _ = manager.getRywTokenFromAwaitableCondition(OSIamFetchReadyCondition.sharedInstance(withId: self.userA), forId: self.userA) + firstReturned.fulfill() + } + waitUntil("first fetch waiting") { manager.waiterCount == 1 } + + manager.setRywTokenAndDelay(id: userA, key: OSIamFetchOffsetKey.userUpdate, value: token("100")) + XCTAssertEqual(manager.waiterCount, 1, "a pending subscription update still owes a token") + + manager.setRywTokenAndDelay(id: userA, key: OSIamFetchOffsetKey.subscriptionUpdate, value: token("200")) + wait(for: [firstReturned], timeout: 2.0) + + // The next fetch has no subscription update behind it, so the user token alone releases it. + let secondReturned = expectation(description: "second fetch released") + DispatchQueue.global().async { + _ = manager.getRywTokenFromAwaitableCondition(OSIamFetchReadyCondition.sharedInstance(withId: self.userA), forId: self.userA) + secondReturned.fulfill() + } + wait(for: [secondReturned], timeout: 2.0) + } + + /// The `ryw_token`-missing fallback the executors call for that user. + func testResolvingTheConditionReleasesTheFetch() { + let manager = OSConsistencyManager.shared + let returned = expectation(description: "fetch released") + DispatchQueue.global().async { + _ = manager.getRywTokenFromAwaitableCondition(OSIamFetchReadyCondition.sharedInstance(withId: self.userA), forId: self.userA) + returned.fulfill() + } + waitUntil("fetch waiting") { manager.waiterCount == 1 } + + manager.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: userA) + + wait(for: [returned], timeout: 2.0) + } + + /// Resolving user B leaves user A's subscription bar raised. + func testResolvingOneUserDoesNotLowerAnotherUsersSubscriptionBar() { + let manager = OSConsistencyManager.shared + let conditionA = OSIamFetchReadyCondition.sharedInstance(withId: userA) + let conditionB = OSIamFetchReadyCondition.sharedInstance(withId: userB) + conditionA.setSubscriptionUpdatePending(value: true) + conditionB.setSubscriptionUpdatePending(value: true) + + let bReturned = expectation(description: "user B fetch released") + DispatchQueue.global().async { + _ = manager.getRywTokenFromAwaitableCondition(conditionB, forId: self.userB) + bReturned.fulfill() + } + waitUntil("user B waiting") { manager.waiterCount == 1 } + + manager.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: userB) + 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") + } + + /// After a timeout, a later fetch for the same id is released by the user token alone. + func testTimingOutLowersTheSubscriptionBar() { + OSConsistencyManager.waitTimeout = .milliseconds(200) + let manager = OSConsistencyManager.shared + let condition = OSIamFetchReadyCondition.sharedInstance(withId: userA) + condition.setSubscriptionUpdatePending(value: true) + + let firstReturned = expectation(description: "first fetch timed out") + DispatchQueue.global().async { + _ = manager.getRywTokenFromAwaitableCondition(condition, forId: self.userA) + firstReturned.fulfill() + } + wait(for: [firstReturned], timeout: 2.0) + + manager.setRywTokenAndDelay(id: userA, key: OSIamFetchOffsetKey.userUpdate, value: token("100")) + + let secondReturned = expectation(description: "second fetch released by user token alone") + DispatchQueue.global().async { + _ = manager.getRywTokenFromAwaitableCondition(condition, forId: self.userA) + secondReturned.fulfill() + } + wait(for: [secondReturned], timeout: 2.0) + } + + // MARK: - Helpers + + private func token(_ value: String) -> OSReadYourWriteData { + return OSReadYourWriteData(rywToken: value, rywDelay: 0) + } + + private func userCreateToken() -> [NSNumber: OSReadYourWriteData] { + return [NSNumber(value: OSIamFetchOffsetKey.userCreate.rawValue): token("create")] + } + + private func userUpdateToken() -> [NSNumber: OSReadYourWriteData] { + return [NSNumber(value: OSIamFetchOffsetKey.userUpdate.rawValue): token("update")] + } + + private func subscriptionToken() -> [NSNumber: OSReadYourWriteData] { + return [NSNumber(value: OSIamFetchOffsetKey.subscriptionUpdate.rawValue): token("subscription")] + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift index e8519205d..0257b9260 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift @@ -1,7 +1,7 @@ /* Modified MIT License - Copyright 2026 OneSignal + Copyright 2025 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 @@ -37,17 +37,26 @@ 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" - 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() { - resetOperationRepo() + OSOperationRepoTestEnvironment.clearCache() super.tearDown() } @@ -56,21 +65,18 @@ final class OSOperationRepoFlushTests: XCTestCase { let processExpectation = expectation(description: "processDeltaQueue") executor.onProcessDeltaQueue = { processExpectation.fulfill() } - 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) - waitUntil("both deltas enqueued") { repo.deltaQueue.count == 2 } + repo.enqueueDelta(makeDelta(name: knownDelta, property: "a")) + repo.enqueueDelta(makeDelta(name: knownDelta, property: "b")) + waitUntil("both deltas enqueued") { self.repo.snapshotDeltaQueue().count == 2 } repo.paused = false repo.addFlushDeltaQueueToDispatchQueue() wait(for: [processExpectation], timeout: 2.0) XCTAssertEqual(executor.enqueued.map(\.property), ["a", "b"]) - XCTAssertTrue(repo.deltaQueue.isEmpty) + XCTAssertTrue(repo.snapshotDeltaQueue().isEmpty) } func testFlush_keepsUnmatchedDeltasInRepoQueue() { @@ -78,21 +84,18 @@ final class OSOperationRepoFlushTests: XCTestCase { let processExpectation = expectation(description: "processDeltaQueue") executor.onProcessDeltaQueue = { processExpectation.fulfill() } - 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) - waitUntil("both deltas enqueued") { repo.deltaQueue.count == 2 } + repo.enqueueDelta(makeDelta(name: unknownDelta, property: "a")) + repo.enqueueDelta(makeDelta(name: unknownDelta, property: "b")) + waitUntil("both deltas enqueued") { self.repo.snapshotDeltaQueue().count == 2 } repo.paused = false repo.addFlushDeltaQueueToDispatchQueue() wait(for: [processExpectation], timeout: 2.0) XCTAssertTrue(executor.enqueued.isEmpty) - XCTAssertEqual(repo.deltaQueue.map(\.property), ["a", "b"]) + XCTAssertEqual(repo.snapshotDeltaQueue().map(\.property), ["a", "b"]) } func testFlush_routesMatchedAndPreservesUnmatchedOrder() { @@ -100,7 +103,6 @@ final class OSOperationRepoFlushTests: XCTestCase { let processExpectation = expectation(description: "processDeltaQueue") executor.onProcessDeltaQueue = { processExpectation.fulfill() } - let repo = OSOperationRepo.sharedInstance repo.addExecutor(executor) // Interleaved matched/unmatched: assert dispatch order and retained queue order. @@ -109,72 +111,44 @@ 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")) - waitUntil("all deltas enqueued") { repo.deltaQueue.count == 5 } + waitUntil("all deltas enqueued") { self.repo.snapshotDeltaQueue().count == 5 } repo.paused = false repo.addFlushDeltaQueueToDispatchQueue() wait(for: [processExpectation], timeout: 2.0) XCTAssertEqual(executor.enqueued.map(\.property), ["known-1", "known-2", "known-3"]) - XCTAssertEqual(repo.deltaQueue.map(\.property), ["unknown-1", "unknown-2"]) - } - - // MARK: - Helpers - - private func resetOperationRepo() { - let repo = OSOperationRepo.sharedInstance - repo.deltaQueue.removeAll() - repo.executors.removeAll() - repo.deltasToExecutorMap.removeAll() - repo.paused = false + XCTAssertEqual(repo.snapshotDeltaQueue().map(\.property), ["unknown-1", "unknown-2"]) } - private func makeDelta(name: String, property: String) -> OSDelta { - OSDelta( - name: name, - identityModelId: UUID().uuidString, - model: OSModel(changeNotifier: OSEventProducer()), - property: property, - value: property - ) - } - - private func waitUntil( - _ description: String, - timeout: TimeInterval = 2.0, - file: StaticString = #filePath, - line: UInt = #line, - _ condition: @escaping () -> Bool - ) { - let exp = expectation(description: description) - let timer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in - if condition() { - timer.invalidate() - exp.fulfill() - } + /** + 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]) } + + DispatchQueue.concurrentPerform(iterations: executors.count) { index in + self.repo.addExecutor(executors[index]) } - let result = XCTWaiter.wait(for: [exp], timeout: timeout) - timer.invalidate() - XCTAssertEqual(result, .completed, "Timed out waiting for: \(description)", file: file, line: line) - } -} -private final class MockOperationExecutor: OSOperationExecutor { - let supportedDeltas: [String] - private(set) var enqueued: [OSDelta] = [] - var onProcessDeltaQueue: (() -> Void)? + for name in names { + repo.enqueueDelta(makeDelta(name: name, property: name)) + } + waitUntil("all deltas enqueued") { self.repo.snapshotDeltaQueue().count == names.count } - init(supportedDeltas: [String]) { - self.supportedDeltas = supportedDeltas - } + repo.paused = false + repo.addFlushDeltaQueueToDispatchQueue() - func enqueueDelta(_ delta: OSDelta) { - enqueued.append(delta) + waitUntil("all deltas routed") { self.repo.snapshotDeltaQueue().isEmpty } + XCTAssertEqual(executors.map { $0.enqueued.map(\.property) }, names.map { [$0] }) } - func cacheDeltaQueue() {} + // MARK: - Helpers - func processDeltaQueue(inBackground: Bool) { - onProcessDeltaQueue?() + 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..8e748b0ef --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoIdentityVerificationTests.swift @@ -0,0 +1,266 @@ +/* + Modified MIT License + + Copyright 2025 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 + +/// 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 featureManager = OSFeatureManager(enabledKeys: []) + + override func setUp() { + super.setUp() + OneSignalIdentifiers.currentAppId = "test-app-id" + OSOperationRepoTestEnvironment.clearCache() + jwtConfig = OSUserJwtConfig() + featureManager = OSFeatureManager(enabledKeys: []) + } + + 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")) + 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")) + 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")) + 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. + 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. + 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() { + featureManager = OSFeatureManager(enabledKeys: [OSFeatureFlag.identityVerification.rawValue]) + + let repo = makeRepo() + let executor = MockOperationExecutor(supportedDeltas: [deltaName]) + let processed = flushExpectation(on: executor) + repo.addExecutor(executor) + + repo.enqueueDelta(makeDelta(externalId: nil, property: "anonymous")) + 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) + + 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..c4f95de6f --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift @@ -0,0 +1,107 @@ +/* + Modified MIT License + + Copyright 2025 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_FEATURE_FLAGS) + } + + 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(enabledKeys: [])) -> 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/OneSignalOSCoreTests/XCTestCase+WaitUntil.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/XCTestCase+WaitUntil.swift new file mode 100644 index 000000000..629c7d95e --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/XCTestCase+WaitUntil.swift @@ -0,0 +1,51 @@ +/* + Modified MIT License + + Copyright 2025 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 + +extension XCTestCase { + /// Polls: the work runs on a private queue with no completion to hook. + func waitUntil( + _ description: String, + timeout: TimeInterval = 2.0, + file: StaticString = #filePath, + line: UInt = #line, + _ condition: @escaping () -> Bool + ) { + let exp = expectation(description: description) + let timer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in + if condition() { + timer.invalidate() + exp.fulfill() + } + } + let result = XCTWaiter.wait(for: [exp], timeout: timeout) + timer.invalidate() + XCTAssertEqual(result, .completed, "Timed out waiting for: \(description)", file: file, line: line) + } +} 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 88f29af73..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 @@ -276,7 +303,7 @@ class OSPropertyOperationExecutor: OSOperationExecutor { ) } else { // handle a potential regression where ryw_token is no longer returned by API - OSConsistencyManager.shared.resolveConditionsWithID(id: OSIamFetchReadyCondition.CONDITIONID) + OSConsistencyManager.shared.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: onesignalId) } } } onFailure: { error in @@ -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 f100bbfd9..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 @@ -311,7 +376,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { ) } else { // handle a potential regression where ryw_token is no longer returned by API - OSConsistencyManager.shared.resolveConditionsWithID(id: OSIamFetchReadyCondition.CONDITIONID) + OSConsistencyManager.shared.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: onesignalId) } } @@ -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 @@ -431,7 +500,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { ) } else { // handle a potential regression where ryw_token is no longer returned by API - OSConsistencyManager.shared.resolveConditionsWithID(id: OSIamFetchReadyCondition.CONDITIONID) + OSConsistencyManager.shared.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: onesignalId) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift index 1b8d0e5b3..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 @@ -271,19 +372,26 @@ extension OSUserExecutor { ) } else { // handle a potential regression where ryw_token is no longer returned by API - OSConsistencyManager.shared.resolveConditionsWithID(id: OSIamFetchReadyCondition.CONDITIONID) + OSConsistencyManager.shared.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: onesignalId) } } } - 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/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/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/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/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/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..5a424932f --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift @@ -0,0 +1,135 @@ +/* + 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 { + /** + 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() + NotificationCenter.default.post(name: Notification.Name(OS_ON_USER_JWT_UPDATED), object: nil) + } + + /** + 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) + } + + /// 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 eb7608b2d..222137988 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() + // Shared instances: remote params hydrate them before this class is started, and 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,9 @@ 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 requestAuth: OSRequestAuthorizing + let operationRepo: OSOperationRepo let identityModelStoreListener: OSIdentityModelStoreListener let propertiesModelStoreListener: OSPropertiesModelStoreListener let subscriptionModelStoreListener: OSSubscriptionModelStoreListener @@ -203,10 +217,21 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { var customEventsExecutor: OSCustomEventsExecutor? private override init() { - self.identityModelStoreListener = OSIdentityModelStoreListener(store: identityModelStore) - self.propertiesModelStoreListener = OSPropertiesModelStoreListener(store: propertiesModelStore) - self.subscriptionModelStoreListener = OSSubscriptionModelStoreListener(store: subscriptionModelStore) - self.pushSubscriptionModelStoreListener = OSSubscriptionModelStoreListener(store: pushSubscriptionModelStore) + 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 + OneSignalUserManagerImpl.sharedInstance.userJwtInvalidatedObserver.notifyChange(OSUserJwtInvalidatedEvent(externalId: externalId)) + } + 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) + self.pushSubscriptionModelStoreListener = OSSubscriptionModelStoreListener(store: pushSubscriptionModelStore, operationRepo: operationRepo) self.pushSubscriptionImpl = OSPushSubscriptionImpl(pushSubscriptionModelStore: pushSubscriptionModelStore) } @@ -233,6 +258,10 @@ 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() + featureManager.refreshIfEmpty() + operationRepo.refreshIfEmpty() OSNotificationsManager.delegate = self @@ -256,24 +285,47 @@ 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 + // 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 + } + 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) { @@ -330,10 +382,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) @@ -376,6 +431,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 } } @@ -388,7 +448,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 } @@ -400,7 +462,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 } @@ -412,6 +474,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( @@ -459,7 +526,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() { @@ -467,7 +557,11 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { _user = nil createUserIfNil() } +} +// MARK: - User setup helpers + +extension OneSignalUserManagerImpl { @objc public func clearAllModelsFromStores() { prepareForNewUser() @@ -578,18 +672,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 @@ -604,7 +686,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 @@ -638,11 +720,12 @@ extension OneSignalUserManagerImpl { let delta = OSDelta( name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, + externalId: identityModel.externalId, model: propertiesModel, 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. @@ -660,15 +743,10 @@ extension OneSignalUserManagerImpl { */ @objc public func runBackgroundTasks() { - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue(inBackground: true) + operationRepo.addFlushDeltaQueueToDispatchQueue(inBackground: true) } } - extension OneSignalUserManagerImpl: OSUser { - public func onJwtExpired(expiredHandler: @escaping OSJwtExpiredHandler) { - jwtExpiredHandler = expiredHandler - } - public var User: OSUser { start() return self @@ -861,82 +939,12 @@ extension OneSignalUserManagerImpl: OSUser { let delta = OSDelta( name: OS_CUSTOM_EVENT_DELTA, identityModelId: identityModel.modelId, + externalId: identityModel.externalId, model: identityModel, 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/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 da94af841..430d25b0f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift @@ -37,7 +37,13 @@ public class OneSignalUserMocks: NSObject { // TODO: create mocked server responses to user requests @objc public static func reset() { - 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) OneSignalUserManagerImpl.sharedInstance.reset() } @@ -59,6 +65,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. @@ -67,6 +81,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/CustomEventsIntegrationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/CustomEventsIntegrationTests.swift index e64b057dc..67eebcc75 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.waitForBackgroundThreads(seconds: 0.5) /* Then */ @@ -80,7 +80,7 @@ final class CustomEventsIntegrationTests: XCTestCase { /* When */ userManager.trackEvent(name: "test_event", properties: nil) - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) /* Then */ @@ -98,7 +98,7 @@ final class CustomEventsIntegrationTests: XCTestCase { /* When */ userManager.trackEvent(name: "test_event", properties: [:]) - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) /* Then */ @@ -119,7 +119,7 @@ final class CustomEventsIntegrationTests: XCTestCase { /* When */ userManager.trackEvent(name: "test_event", properties: invalidProperties) - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) /* Then - No request should be made */ @@ -154,7 +154,7 @@ final class CustomEventsIntegrationTests: XCTestCase { /* When */ userManager.trackEvent(name: "complex_event", properties: complexProperties) - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) /* Then */ @@ -220,7 +220,7 @@ final class CustomEventsIntegrationTests: XCTestCase { /* When */ userManager.trackEvent(name: "array_event", properties: properties) - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) /* Then */ diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift new file mode 100644 index 000000000..24f8fc772 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift @@ -0,0 +1,285 @@ +/* + 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) + 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.waitForBackgroundThreads(seconds: 0.5) + + 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) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + 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: - 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.waitForBackgroundThreads(seconds: 0.5) + + 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.waitForBackgroundThreads(seconds: 0.5) + + let request = try XCTUnwrap(client.executedRequests.compactMap { $0 as? OSRequestUpdateSubscription }.first) + XCTAssertNil(request.identityModel) + } + + // 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 executingClient() -> MockOneSignalClient { + let client = MockOneSignalClient() + client.fireSuccessForAllRequests = true + OneSignalCoreImpl.setSharedClient(client) + return client + } + + private func emailSubscriptionModel() -> OSSubscriptionModel { + return OSSubscriptionModel( + type: .email, + address: emailAddress, + subscriptionId: "test-subscription-id", + reachable: true, + isDisabled: false, + changeNotifier: OSEventProducer() + ) + } + + 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..defb4c1a6 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift @@ -0,0 +1,401 @@ +/* + 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.waitForBackgroundThreads(seconds: 0.5) + + 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) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + XCTAssertEqual(client.executedRequests.count, 0) + + executor.removeOperationsWithoutExternalId() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + 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() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + // 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.waitForBackgroundThreads(seconds: 0.5) + + 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.waitForBackgroundThreads(seconds: 0.5) + + // 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) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + XCTAssertEqual(client.executedRequests.count, 0) + + executor.removeOperationsWithoutExternalId() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + 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.waitForBackgroundThreads(seconds: 0.5) + + 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) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + XCTAssertEqual(client.executedRequests.count, 0) + + executor.removeOperationsWithoutExternalId() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + 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.waitForBackgroundThreads(seconds: 0.5) + + 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) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + XCTAssertEqual(client.executedRequests.count, 0) + + executor.removeOperationsWithoutExternalId() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + 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.waitForBackgroundThreads(seconds: 0.5) + + executor.removeOperationsWithoutExternalId() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + 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 3ea47ff03..f3972f321 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) } } @@ -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 43c1746e9..9fe199759 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 @@ -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 @@ -110,6 +111,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { executor.enqueueDelta(OSDelta( name: OS_UPDATE_SUBSCRIPTION_DELTA, identityModelId: identityModelId, + externalId: nil, model: model, property: "notificationTypes", value: subscribedNotificationTypes @@ -143,13 +145,14 @@ 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 executor.enqueueDelta(OSDelta( name: OS_UPDATE_SUBSCRIPTION_DELTA, identityModelId: identityModelId, + externalId: nil, model: model, property: "notificationTypes", value: promptedNeverAnswered @@ -171,6 +174,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { executor.enqueueDelta(OSDelta( name: OS_UPDATE_SUBSCRIPTION_DELTA, identityModelId: identityModelId, + externalId: nil, model: model, property: "notificationTypes", value: subscribedNotificationTypes @@ -200,7 +204,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 @@ -214,6 +218,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { executor.enqueueDelta(OSDelta( name: OS_UPDATE_SUBSCRIPTION_DELTA, identityModelId: identityModelId, + externalId: nil, model: model, property: "notificationTypes", value: promptedNeverAnswered @@ -231,6 +236,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/Executors/UserExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift index 703079453..25b0e1d14 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.waitForBackgroundThreads(seconds: 0.5) /* Then */ @@ -79,6 +84,58 @@ 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) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* 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() @@ -244,4 +301,240 @@ 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() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* 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.waitForBackgroundThreads(seconds: 0.5) + + /* 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)) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + + /* When the requirement arrives */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* 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) } + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* 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.waitForBackgroundThreads(seconds: 0.5) + + /* 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.waitForBackgroundThreads(seconds: 0.5) + + /* 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.waitForBackgroundThreads(seconds: 0.5) + + /* 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) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* 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/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/OSRequestAuthTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSRequestAuthTests.swift new file mode 100644 index 000000000..b7574e446 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSRequestAuthTests.swift @@ -0,0 +1,356 @@ +/* + 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 + +/// 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() + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_USE_IDENTITY_VERIFICATION) + jwtConfig = OSUserJwtConfig() + jwt = StubJwtProvider() + } + + override func tearDown() { + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_USE_IDENTITY_VERIFICATION) + super.tearDown() + } + + 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 service = OSIdentityVerificationService(featureManager: OSFeatureManager(enabledKeys: enabledKeys), 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: [OSFeatureFlag.identityVerification.rawValue]) + 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/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/OneSignalUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift index bfe416b46..1b87cdf31 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift @@ -118,7 +118,7 @@ final class OneSignalUserTests: XCTestCase { OneSignalCoreImpl.setSharedClient(client) // Increase flush interval to allow all the updates to batch - OSOperationRepo.sharedInstance.pollIntervalMilliseconds = 300 + OneSignalUserManagerImpl.sharedInstance.operationRepo.pollIntervalMilliseconds = 300 // Wait to let any pending flushes in the Operation Repo to run OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.1) 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 2c598b6bb..88c14d1b8 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift @@ -319,7 +319,7 @@ final class SwitchUserIntegrationTests: XCTestCase { OneSignalCoreImpl.setSharedClient(client) // Increase flush interval to allow all the updates to batch - OSOperationRepo.sharedInstance.pollIntervalMilliseconds = 300 + OneSignalUserManagerImpl.sharedInstance.operationRepo.pollIntervalMilliseconds = 300 // Wait to let any pending flushes in the Operation Repo to run OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.3) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift index 94d3f4388..81af4ed71 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift @@ -68,7 +68,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 +92,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, 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")) + 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) } // 4. Run background threads @@ -131,22 +131,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, 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)) + 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) } // 4. Run background threads @@ -172,21 +172,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, 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)) + 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) } // 4. Run background threads @@ -213,7 +213,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/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/OneSignalUserTests/UserJwtLifecycleTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtLifecycleTests.swift new file mode 100644 index 000000000..46e34ab01 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtLifecycleTests.swift @@ -0,0 +1,450 @@ +/* + 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 { + OneSignalUserManagerImpl.sharedInstance.removeObserver(observer) + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = false + OneSignalCoreMocks.clearUserDefaults() + OSFeatureManager.shared.setEnabledFeatureKeys([]) + } + + /// 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: - login + + func testLoginFromAnonymousPromotesTheUserWhileIdentityVerificationIsOff() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + _ = OneSignalUserManagerImpl.sharedInstance.user // anonymous user first + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + 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.waitForBackgroundThreads(seconds: 0.5) + + 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() { + OSFeatureManager.shared.setEnabledFeatureKeys([OSFeatureFlag.identityVerification.rawValue]) + makeRequirementUnknown() + _ = OneSignalUserManagerImpl.sharedInstance.user + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertTrue(queuedUserRequests().contains { $0 is OSRequestIdentifyUser }) + XCTAssertFalse(client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + XCTAssertFalse(client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + // 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() { + OSFeatureManager.shared.setEnabledFeatureKeys([OSFeatureFlag.identityVerification.rawValue]) + makeRequirementUnknown() + _ = OneSignalUserManagerImpl.sharedInstance.user + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + 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.waitForBackgroundThreads(seconds: 0.5) + + 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") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + 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. + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + 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") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + 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") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + 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.waitForBackgroundThreads(seconds: 0.5) + 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") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + // 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.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertTrue(OneSignalUserManagerImpl.sharedInstance.operationRepo.paused) + } + + // MARK: - logout + + func testLogoutUnderIdentityVerificationSilencesThePushSubscriptionAndReportsNoUser() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + optInPushSubscription() + observer.states.removeAll() + + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + 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") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + optInPushSubscription() + // Deltas have to stay in the repo queue long enough to be inspected. + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true + + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + 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") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + let pushSubscription = optInPushSubscription() + + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertFalse(pushSubscription._isDisabled) + XCTAssertEqual(pushSubscription.notificationTypes, optedInNotificationTypes) + XCTAssertTrue(pushSubscription.optedIn) + } + + func testLogoutWhileIdentityVerificationIsOffLeavesThePushSubscriptionReporting() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + optInPushSubscription() + + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + 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) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + optInPushSubscription() + observer.states.removeAll() + makeRequirementUnknown() + + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + 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) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + optInPushSubscription() + makeRequirementUnknown() + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + 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") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + optInPushSubscription() + + OneSignalUserManagerImpl.sharedInstance._logout() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(pushSubscriptionPayload()["notification_types"] as? Int, optedInNotificationTypes) + } + + func testLoggingBackInRestoresThePushSubscription() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + optInPushSubscription() + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + 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") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + optInPushSubscription() + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(pushSubscriptionPayload()["notification_types"] as? Int, optedInNotificationTypes) + } +} diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 7a128f170..437aa2550 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -127,6 +127,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) @@ -147,7 +157,8 @@ + (void)clearStatics { _downloadedParameters = false; _didCallDownloadParameters = false; - + _downloadParametersAttempts = 0; + // sessionLaunchTime = [NSDate date]; [OSOutcomes clearStatics]; @@ -179,6 +190,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]; } @@ -359,6 +382,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]; @@ -654,6 +683,7 @@ + (void)handleAppIdChange:(NSString*)appId { initDone = false; _downloadedParameters = false; _didCallDownloadParameters = false; + _downloadParametersAttempts = 0; let sharedUserDefaults = OneSignalUserDefaults.initShared; @@ -665,6 +695,9 @@ + (void)handleAppIdChange:(NSString*)appId { [sharedUserDefaults removeValueForKey:OSUD_RECEIVE_RECEIPTS_ENABLED]; [sharedUserDefaults removeValueForKey:OS_PUSH_SUBSCRIPTION_MODEL_STORE_KEY]; + [sharedUserDefaults removeValueForKey:OSUD_USE_IDENTITY_VERIFICATION]; + [sharedUserDefaults removeValueForKey:OSUD_SDK_FEATURE_FLAGS]; + // Drop cached identifiers β€” a real app-id change invalidates them. [OSResilientStorage setStrings:@{ OSResilientStorage.keySubscriptionId: @"", @@ -710,6 +743,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; @@ -717,8 +751,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]) { @@ -759,9 +797,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 { diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignalFramework.h b/iOS_SDK/OneSignalSDK/Source/OneSignalFramework.h index 90b8b0d8a..59c5d71c2 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() }