From 20d6bd3f7a3e91cf1fe8fda89fa5b8625701e4dc Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 11 Aug 2026 16:57:51 -0700 Subject: [PATCH 1/7] feat: [PR8] demo app Identity Verification UI Exercises the Identity Verification surface end to end: log in with a token, watch a token be rejected and supply a replacement through the invalidated listener, and see how the SDK behaves while the requirement is still unknown. Co-authored-by: Cursor --- examples/demo/App.xcodeproj/project.pbxproj | 4 ++ examples/demo/App/Models/AppModels.swift | 9 ++- .../demo/App/Services/OneSignalService.swift | 35 +++++++++- .../App/Services/PreferencesService.swift | 28 +++++++- .../demo/App/Services/UserFetchService.swift | 27 +++++-- .../App/ViewModels/OneSignalViewModel.swift | 68 ++++++++++++++++-- .../Views/Components/LoginUserDialog.swift | 70 +++++++++++++++++++ .../demo/App/Views/Sections/UserSection.swift | 38 ++++++++-- 8 files changed, 257 insertions(+), 22 deletions(-) create mode 100644 examples/demo/App/Views/Components/LoginUserDialog.swift diff --git a/examples/demo/App.xcodeproj/project.pbxproj b/examples/demo/App.xcodeproj/project.pbxproj index ae17cd23e..04b561eb5 100644 --- a/examples/demo/App.xcodeproj/project.pbxproj +++ b/examples/demo/App.xcodeproj/project.pbxproj @@ -42,6 +42,7 @@ 5B959D44AB09CB821C00AFBF /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35F726E64F9B6817F917227C /* ContentView.swift */; }; 5F4B7EC8437D1A8D80DF7674 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20B46F63592FC67B655BEB8 /* NotificationService.swift */; }; 638B81D9DA5FD8636BB038B0 /* OneSignalUser.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EF49509A218369322ECFA3B9 /* OneSignalUser.framework */; }; + 673E0146B4B27EB81FCF50DA /* LoginUserDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DD26AED9EA84B0308C1BFE8 /* LoginUserDialog.swift */; }; 674995A7A55C13341317E19B /* OSDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = D261D46C404E325CBA87A9E0 /* OSDialog.swift */; }; 68BC99D15FDCB26EB35EBB07 /* OneSignalLocation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FE9834773C437CC373607693 /* OneSignalLocation.framework */; }; 6E3E040FD8A750248E70E46F /* AppModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EA9D80191548D49F09D30B3 /* AppModels.swift */; }; @@ -324,6 +325,7 @@ 38138523A8A81A60A77800CA /* NotificationSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSender.swift; sourceTree = ""; }; 3A654457BF0A55B54220E669 /* RemoveMultiDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoveMultiDialog.swift; sourceTree = ""; }; 3B81D7E1A7EB9BB4466C768F /* SendPushSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendPushSection.swift; sourceTree = ""; }; + 3DD26AED9EA84B0308C1BFE8 /* LoginUserDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginUserDialog.swift; sourceTree = ""; }; 3FD8258E807E6672642A32E6 /* UserFetchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserFetchService.swift; sourceTree = ""; }; 4072202943CA64D2CBC38CB5 /* MultiPairInputDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiPairInputDialog.swift; sourceTree = ""; }; 4078B5F096680AFA83D1CB85 /* OutcomesSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutcomesSection.swift; sourceTree = ""; }; @@ -512,6 +514,7 @@ 2D959B1636916DAEE5FE6278 /* CustomNotificationDialog.swift */, 5984B93007C6B85AFE09045A /* KeyValueRow.swift */, 280B23B41935EAB89C8C6FCB /* ListWidgets.swift */, + 3DD26AED9EA84B0308C1BFE8 /* LoginUserDialog.swift */, 4072202943CA64D2CBC38CB5 /* MultiPairInputDialog.swift */, D261D46C404E325CBA87A9E0 /* OSDialog.swift */, D54B9DAAEDBE67B73893C522 /* OutcomeDialog.swift */, @@ -828,6 +831,7 @@ 7D2BA9022E77B00205453467 /* LiveActivityController.swift in Sources */, DBDA727D6317A3CCC73A1699 /* LiveActivitySection.swift in Sources */, B3AE701075398C6A369DBBE0 /* LocationSection.swift in Sources */, + 673E0146B4B27EB81FCF50DA /* LoginUserDialog.swift in Sources */, E3725231A3FD5F5A88BAA758 /* MultiPairInputDialog.swift in Sources */, 4C18E3D284BB28BD846162F3 /* NotificationSender.swift in Sources */, 674995A7A55C13341317E19B /* OSDialog.swift in Sources */, diff --git a/examples/demo/App/Models/AppModels.swift b/examples/demo/App/Models/AppModels.swift index 70ccd615a..d98591df0 100644 --- a/examples/demo/App/Models/AppModels.swift +++ b/examples/demo/App/Models/AppModels.swift @@ -80,6 +80,7 @@ enum AddItemType { case tag case trigger case externalUserId + case updateUserJwt var title: String { switch self { @@ -89,12 +90,13 @@ enum AddItemType { case .tag: return "Add Tag" case .trigger: return "Add Trigger" case .externalUserId: return "Login User" + case .updateUserJwt: return "Update User JWT" } } var requiresKeyValue: Bool { switch self { - case .alias, .tag, .trigger: return true + case .alias, .tag, .trigger, .updateUserJwt: return true case .email, .sms, .externalUserId: return false } } @@ -103,6 +105,7 @@ enum AddItemType { switch self { case .alias: return "Label" case .tag, .trigger: return "Key" + case .updateUserJwt: return "External User Id" default: return "Key" } } @@ -114,6 +117,7 @@ enum AddItemType { case .sms: return "Phone Number" case .tag, .trigger: return "Value" case .externalUserId: return "External User Id" + case .updateUserJwt: return "JWT Token" } } @@ -141,6 +145,7 @@ enum AddItemType { case .tag: return "tag" case .trigger: return "trigger" case .externalUserId: return "login_user_id" + case .updateUserJwt: return "update_jwt" } } @@ -152,6 +157,7 @@ enum AddItemType { case .alias: return "alias_label_input" case .tag: return "tag_key_input" case .trigger: return "trigger_key_input" + case .updateUserJwt: return "update_jwt_external_id_input" default: return "\(accessibilityKey)_key_input" } } @@ -165,6 +171,7 @@ enum AddItemType { case .alias: return "alias_id_input" case .tag: return "tag_value_input" case .trigger: return "trigger_value_input" + case .updateUserJwt: return "update_jwt_token_input" default: return "\(accessibilityKey)_input" } } diff --git a/examples/demo/App/Services/OneSignalService.swift b/examples/demo/App/Services/OneSignalService.swift index 598189ea0..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 e158be52d..bcba7d280 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() @@ -445,12 +490,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) { @@ -472,4 +522,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 } + ) + } } } From 11dd0d491f530d9fc10feb678aea4796db860b65 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 10 Sep 2026 11:46:12 -0700 Subject: [PATCH 2/7] fix: [JWT-8] surface the SDK's JWT ask in the demo instead of re-logging in at launch The demo called login(storedExternalId) on every cold start. The SDK already restores the user from its own cache, so that call was a no-op, and passing the stored token there would have hidden the path a customer's app hits on relaunch, where the SDK either signs with the token it archived or asks for one through OSUserJwtInvalidatedListener. Drop the cold-start login. When the SDK asks for a token, clear the demo's stale copy so the REST fetch stops sending a rejected bearer, show a banner with the external id and a PROVIDE JWT button, toast the ask, and prefill the UPDATE USER JWT dialog with that id. --- .../demo/App/Services/OneSignalService.swift | 24 ++++++++------ .../App/ViewModels/OneSignalViewModel.swift | 20 +++++++++++- .../App/Views/Components/AddItemDialog.swift | 13 ++++++++ .../demo/App/Views/Sections/UserSection.swift | 32 +++++++++++++++++++ 4 files changed, 78 insertions(+), 11 deletions(-) diff --git a/examples/demo/App/Services/OneSignalService.swift b/examples/demo/App/Services/OneSignalService.swift index 7b1b45af3..c29414c3c 100644 --- a/examples/demo/App/Services/OneSignalService.swift +++ b/examples/demo/App/Services/OneSignalService.swift @@ -49,10 +49,13 @@ final class OneSignalService { // MARK: - Initialization /// Mirrors the Capacitor demo's `useOneSignal` init order: feed cached - /// consent into the SDK BEFORE `initialize`, then restore IAM-paused, - /// location-shared, and a previously-logged-in external user id once the - /// SDK is ready. Without this, toggles flip back to defaults on every - /// cold launch. + /// consent into the SDK BEFORE `initialize`, then restore IAM-paused and + /// location-shared once the SDK is ready. Without this, toggles flip back + /// to defaults on every cold launch. + /// + /// No login here. The SDK restores the user from its own cache, and the stored JWT + /// stays with the demo so the SDK's own ask through `OSUserJwtInvalidatedListener` + /// stays observable on cold start. func initialize(launchOptions: [UIApplication.LaunchOptionsKey: Any]?) { OneSignal.Debug.setLogLevel(.LL_VERBOSE) @@ -63,10 +66,6 @@ final class OneSignalService { OneSignal.InAppMessages.paused = prefs.getIamPaused() OneSignal.Location.isShared = prefs.getLocationShared() - - if let storedExternalId = prefs.getExternalUserId() { - OneSignal.login(storedExternalId) - } } // MARK: - Identity @@ -98,7 +97,7 @@ final class OneSignalService { func login(externalId: String, jwtToken: String? = nil) { prefs.setExternalUserId(externalId) - // Persist for the demo REST fetch only; cold start does not call login/updateUserJwt with it. + // Demo REST fetch only. Not replayed to the SDK at launch. prefs.setSessionJwtToken(jwtToken) if let jwtToken = jwtToken { OneSignal.login(externalId: externalId, token: jwtToken) @@ -118,6 +117,11 @@ final class OneSignalService { OneSignal.logout() } + /// Called when the SDK rejects the token, so the REST fetch stops sending it. + func clearSessionJwtToken() { + prefs.setSessionJwtToken(nil) + } + // MARK: - Identity Verification (demo REST fetch) /// Demo toggle for addressing the REST user fetch by `external_id`. Persisted across launches. @@ -126,7 +130,7 @@ final class OneSignalService { 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. + /// Token from the last login / updateUserJwt, for the demo REST fetch only. Cleared when the SDK rejects it. var sessionJwtToken: String? { prefs.getSessionJwtToken() } // MARK: - Aliases diff --git a/examples/demo/App/ViewModels/OneSignalViewModel.swift b/examples/demo/App/ViewModels/OneSignalViewModel.swift index bcba7d280..8c0b31c70 100644 --- a/examples/demo/App/ViewModels/OneSignalViewModel.swift +++ b/examples/demo/App/ViewModels/OneSignalViewModel.swift @@ -44,6 +44,8 @@ final class OneSignalViewModel: ObservableObject { @Published var externalUserId: String? @Published var aliases: [KeyValueItem] = [] @Published var useIdentityVerification: Bool = false + /// External id the SDK is waiting on a JWT for. Drives the banner in `UserSection`. + @Published var jwtAskExternalId: String? // MARK: - Push @@ -203,6 +205,9 @@ final class OneSignalViewModel: ObservableObject { isLoading = true service.login(externalId: trimmed, jwtToken: jwtToken) externalUserId = trimmed + if jwtToken != nil, jwtAskExternalId == trimmed { + jwtAskExternalId = nil + } clearUserData() } @@ -211,12 +216,23 @@ final class OneSignalViewModel: ObservableObject { let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedId.isEmpty, !trimmedToken.isEmpty else { return } service.updateUserJwt(externalId: trimmedId, token: trimmedToken) + if jwtAskExternalId == trimmedId { + jwtAskExternalId = nil + } print("[OneSignal] Updated JWT for: \(trimmedId)") } + /// The SDK parked this user's requests for want of a token. Drop the demo's stale copy and show the ask. + func handleJwtInvalidated(externalId: String) { + service.clearSessionJwtToken() + jwtAskExternalId = externalId + print("[OneSignal] JWT invalidated for externalId: \(externalId)") + } + func logout() { service.logout() externalUserId = nil + jwtAskExternalId = nil clearUserData() } @@ -524,6 +540,8 @@ private final class Observers: NSObject, } func onUserJwtInvalidated(event: OSUserJwtInvalidatedEvent) { - print("[OneSignal] JWT invalidated for externalId: \(event.externalId)") + Task { @MainActor in + viewModel?.handleJwtInvalidated(externalId: event.externalId) + } } } diff --git a/examples/demo/App/Views/Components/AddItemDialog.swift b/examples/demo/App/Views/Components/AddItemDialog.swift index f6b099578..938a12f5e 100644 --- a/examples/demo/App/Views/Components/AddItemDialog.swift +++ b/examples/demo/App/Views/Components/AddItemDialog.swift @@ -36,6 +36,19 @@ struct AddItemDialog: View { @State private var keyText: String = "" @State private var valueText: String = "" + /// `initialKey` prefills the first field. + init( + itemType: AddItemType, + initialKey: String = "", + onAdd: @escaping (String, String) -> Void, + onCancel: @escaping () -> Void + ) { + self.itemType = itemType + self.onAdd = onAdd + self.onCancel = onCancel + _keyText = State(initialValue: initialKey) + } + var body: some View { OSDialog( title: itemType.title, diff --git a/examples/demo/App/Views/Sections/UserSection.swift b/examples/demo/App/Views/Sections/UserSection.swift index b6dca78a1..2d10beeb6 100644 --- a/examples/demo/App/Views/Sections/UserSection.swift +++ b/examples/demo/App/Views/Sections/UserSection.swift @@ -30,6 +30,7 @@ import SwiftUI /// Login/logout + JWT / Identity Verification controls for manual testing. struct UserSection: View { @EnvironmentObject var viewModel: OneSignalViewModel + @EnvironmentObject var toast: ToastPresenter @State private var loginOpen = false @State private var updateJwtOpen = false @@ -59,6 +60,31 @@ struct UserSection: View { ) ]) + // Shown instead of auto-feeding the stored token, so the SDK's ask is visible. + if let askedId = viewModel.jwtAskExternalId { + VStack(alignment: .leading, spacing: OS.Spacing.cardGap) { + Text("The SDK is waiting for a JWT. Requests for this user are parked until one is supplied.") + .font(OS.Font.bodySmall) + .foregroundColor(OS.Color.bodyText) + Text(askedId) + .font(OS.Font.mono12) + .foregroundColor(OS.Color.bodyText) + .lineLimit(1) + .truncationMode(.middle) + .accessibilityIdentifier("jwt_ask_external_id_value") + ActionButton( + "PROVIDE JWT", + style: .outline, + accessibilityID: "jwt_ask_provide_button" + ) { + updateJwtOpen = true + } + } + .osCard(background: OS.Color.warningBackground) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("jwt_ask_banner") + } + ActionButton( viewModel.loginButtonTitle, accessibilityID: "login_user_button" @@ -96,6 +122,7 @@ struct UserSection: View { .osCenteredDialog(isPresented: $updateJwtOpen) { AddItemDialog( itemType: .updateUserJwt, + initialKey: viewModel.jwtAskExternalId ?? viewModel.externalUserId ?? "", onAdd: { externalId, token in viewModel.updateUserJwt(externalId: externalId, token: token) updateJwtOpen = false @@ -103,5 +130,10 @@ struct UserSection: View { onCancel: { updateJwtOpen = false } ) } + .onChange(of: viewModel.jwtAskExternalId) { askedId in + if let askedId = askedId { + toast.show("SDK asked for a JWT for \(askedId)") + } + } } } From 6db4e7d34b42f461c0040f0e10d327e983b67afe Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 10 Sep 2026 11:55:03 -0700 Subject: [PATCH 3/7] fix: [JWT-8] only save the demo bearer when the JWT is for the current user UPDATE USER JWT accepts any external id, and the SDK ignores a token for an id it has no identity model for. The demo saved the token as its REST bearer before the SDK decided, so a mismatched id left the demo signing the current user's fetch with someone else's token. Only write prefs when the id is the current user. --- examples/demo/App/Services/OneSignalService.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/examples/demo/App/Services/OneSignalService.swift b/examples/demo/App/Services/OneSignalService.swift index c29414c3c..073e7c5ff 100644 --- a/examples/demo/App/Services/OneSignalService.swift +++ b/examples/demo/App/Services/OneSignalService.swift @@ -107,7 +107,11 @@ final class OneSignalService { } func updateUserJwt(externalId: String, token: String) { - prefs.setSessionJwtToken(token) + // The demo's REST bearer belongs to the current user. A token for anyone else + // still goes to the SDK, which owns the parked work for that id. + if externalId == OneSignal.User.externalId { + prefs.setSessionJwtToken(token) + } OneSignal.updateUserJwt(externalId: externalId, token: token) } From 1f5f7a092b3875226dc84f0df2788d7366c08424 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 10 Sep 2026 11:57:03 -0700 Subject: [PATCH 4/7] fix: [JWT-8] keep the demo bearer and refetch on a same-user login A same-user login with an empty JWT field cleared the demo's stored token while the SDK kept its own, so the REST fetch lost its bearer for no reason. Only overwrite the stored token when one was supplied or the user changed. A same-user login also wiped the hydrated display and waited for a user-state event that never fires for the same user. Refetch directly in that case. --- examples/demo/App/Services/OneSignalService.swift | 7 +++++-- examples/demo/App/ViewModels/OneSignalViewModel.swift | 8 +++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/examples/demo/App/Services/OneSignalService.swift b/examples/demo/App/Services/OneSignalService.swift index 073e7c5ff..d55fa0df8 100644 --- a/examples/demo/App/Services/OneSignalService.swift +++ b/examples/demo/App/Services/OneSignalService.swift @@ -96,9 +96,12 @@ final class OneSignalService { // MARK: - User func login(externalId: String, jwtToken: String? = nil) { + // Demo REST fetch only. Not replayed to the SDK at launch. A same-user login + // without a token keeps the stored one, matching the SDK, which keeps its own. + if jwtToken != nil || externalId != OneSignal.User.externalId { + prefs.setSessionJwtToken(jwtToken) + } prefs.setExternalUserId(externalId) - // Demo REST fetch only. Not replayed to the SDK at launch. - prefs.setSessionJwtToken(jwtToken) if let jwtToken = jwtToken { OneSignal.login(externalId: externalId, token: jwtToken) } else { diff --git a/examples/demo/App/ViewModels/OneSignalViewModel.swift b/examples/demo/App/ViewModels/OneSignalViewModel.swift index 8c0b31c70..7d47dadfb 100644 --- a/examples/demo/App/ViewModels/OneSignalViewModel.swift +++ b/examples/demo/App/ViewModels/OneSignalViewModel.swift @@ -202,13 +202,19 @@ final class OneSignalViewModel: ObservableObject { func login(externalId: String, jwtToken: String? = nil) { let trimmed = externalId.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } + let sameUser = trimmed == service.externalId isLoading = true service.login(externalId: trimmed, jwtToken: jwtToken) externalUserId = trimmed if jwtToken != nil, jwtAskExternalId == trimmed { jwtAskExternalId = nil } - clearUserData() + if sameUser { + // No user-state event fires for the same user, so refetch here instead. + Task { await fetchUserDataFromApi() } + } else { + clearUserData() + } } func updateUserJwt(externalId: String, token: String) { From 4ea6caf6af689dbb29d52450822d577e4bd9f20d Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 10 Sep 2026 12:00:07 -0700 Subject: [PATCH 5/7] fix: [JWT-8] relabel the fetch toggle, refetch on flip, and show the /users result The toggle read as a client-side jwt_required switch. It only changes how the demo's own /users fetch is addressed, so name it that way and refetch when it flips instead of waiting for the next login. A failed fetch collapsed to nil and left the last hydrated state on screen with no indication, so a 401 under Identity Verification looked like nothing happened. The fetch now returns a Result, and the User section shows OK or the failure reason. --- .../demo/App/Services/UserFetchService.swift | 32 +++++++++++++++---- .../App/ViewModels/OneSignalViewModel.swift | 14 ++++++-- .../demo/App/Views/Sections/UserSection.swift | 9 ++++-- 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/examples/demo/App/Services/UserFetchService.swift b/examples/demo/App/Services/UserFetchService.swift index 8e258884c..7aeb969c4 100644 --- a/examples/demo/App/Services/UserFetchService.swift +++ b/examples/demo/App/Services/UserFetchService.swift @@ -27,6 +27,24 @@ import Foundation +/// Why a demo /users fetch returned nothing, shown in the User section so a 401 under +/// Identity Verification is not mistaken for an empty user. +enum UserFetchError: Error, CustomStringConvertible { + case badRequest + case http(Int) + case badBody + case transport(Error) + + var description: String { + switch self { + case .badRequest: return "bad request" + case .http(let status): return "HTTP \(status)" + case .badBody: return "unreadable body" + case .transport(let error): return error.localizedDescription + } + } +} + /// Reads the OneSignal /users API to hydrate aliases / tags / channels in the demo. final class UserFetchService { static let shared = UserFetchService() @@ -38,8 +56,8 @@ final class UserFetchService { aliasLabel: String, aliasValue: String, jwt: String? = nil - ) async -> UserData? { - guard !aliasValue.isEmpty else { return nil } + ) async -> Result { + guard !aliasValue.isEmpty else { return .failure(.badRequest) } // Path-encode so external_ids with reserved characters don't misroute the GET. var allowed = CharacterSet.urlPathAllowed @@ -48,7 +66,7 @@ final class UserFetchService { let encodedAlias = aliasValue.addingPercentEncoding(withAllowedCharacters: allowed), let url = URL(string: "https://api.onesignal.com/apps/\(appId)/users/by/\(aliasLabel)/\(encodedAlias)") else { - return nil + return .failure(.badRequest) } var request = URLRequest(url: url) @@ -61,14 +79,14 @@ final class UserFetchService { do { let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { - return nil + return .failure(.http((response as? HTTPURLResponse)?.statusCode ?? -1)) } guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return nil + return .failure(.badBody) } - return parse(json) + return .success(parse(json)) } catch { - return nil + return .failure(.transport(error)) } } diff --git a/examples/demo/App/ViewModels/OneSignalViewModel.swift b/examples/demo/App/ViewModels/OneSignalViewModel.swift index 7d47dadfb..980048fc4 100644 --- a/examples/demo/App/ViewModels/OneSignalViewModel.swift +++ b/examples/demo/App/ViewModels/OneSignalViewModel.swift @@ -46,6 +46,8 @@ final class OneSignalViewModel: ObservableObject { @Published var useIdentityVerification: Bool = false /// External id the SDK is waiting on a JWT for. Drives the banner in `UserSection`. @Published var jwtAskExternalId: String? + /// Outcome of the last demo /users fetch, "OK" or the failure reason. + @Published var userFetchStatus: String? // MARK: - Push @@ -159,7 +161,7 @@ final class OneSignalViewModel: ObservableObject { let captured = requestSequence isLoading = true - let userData = await UserFetchService.shared.fetchUser( + let result = await UserFetchService.shared.fetchUser( appId: appId, aliasLabel: aliasLabel, aliasValue: aliasValue, @@ -169,7 +171,8 @@ final class OneSignalViewModel: ObservableObject { // Drop the result if a newer fetch has started while this one was in flight. guard captured == requestSequence else { return } - if let userData = userData { + switch result { + case .success(let userData): aliases = userData.aliases.map { KeyValueItem(key: $0.key, value: $0.value) } tags = userData.tags.map { KeyValueItem(key: $0.key, value: $0.value) } emails = userData.emails @@ -177,6 +180,10 @@ final class OneSignalViewModel: ObservableObject { if let extId = userData.externalId, !extId.isEmpty { externalUserId = extId } + userFetchStatus = "OK" + case .failure(let error): + userFetchStatus = "\(aliasLabel) fetch failed: \(error)" + print("[OneSignal] /users fetch by \(aliasLabel) failed: \(error)") } isLoading = false } @@ -245,7 +252,8 @@ final class OneSignalViewModel: ObservableObject { func setUseIdentityVerification(_ enabled: Bool) { useIdentityVerification = enabled service.useIdentityVerification = enabled - print("[OneSignal] Identity verification \(enabled ? "enabled" : "disabled")") + print("[OneSignal] Fetch by external_id \(enabled ? "enabled" : "disabled")") + Task { await fetchUserDataFromApi() } } private func clearUserData() { diff --git a/examples/demo/App/Views/Sections/UserSection.swift b/examples/demo/App/Views/Sections/UserSection.swift index 2d10beeb6..2ac39b9bf 100644 --- a/examples/demo/App/Views/Sections/UserSection.swift +++ b/examples/demo/App/Views/Sections/UserSection.swift @@ -37,8 +37,8 @@ struct UserSection: View { var body: some View { SectionCard(title: "USER", sectionKey: "user") { ToggleRow( - label: "Identity Verification", - description: "Use external_id for API calls", + label: "Fetch by external_id (JWT)", + description: "Demo REST fetch only. Signs with the stored JWT.", isOn: Binding( get: { viewModel.useIdentityVerification }, set: { viewModel.setUseIdentityVerification($0) } @@ -57,6 +57,11 @@ struct UserSection: View { value: viewModel.externalUserId ?? "—", valueAccessibilityID: "user_external_id_value", monospaced: true + ), + ValueCard.Row( + label: "REST fetch", + value: viewModel.userFetchStatus ?? "—", + valueAccessibilityID: "user_fetch_status_value" ) ]) From 70d545028bfff350756b281bd7ff8c7def22214f Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 10 Sep 2026 16:52:42 -0700 Subject: [PATCH 6/7] style: [JWT-8] split the demo view model to clear the SwiftLint error The JWT UI work pushed OneSignalViewModel's class body to 360 lines, past SwiftLint's 350-line type_body_length error threshold, which failed the Swift Lint job. Moved outcomes, in-app messages, triggers, custom events, notifications and Live Activities into an extension in the same file. The rule only counts the primary declaration, so the body drops to 264 lines and leaves room for the next demo change. Pure code movement, no logic change. --- .../App/ViewModels/OneSignalViewModel.swift | 86 ++++++++++--------- 1 file changed, 47 insertions(+), 39 deletions(-) diff --git a/examples/demo/App/ViewModels/OneSignalViewModel.swift b/examples/demo/App/ViewModels/OneSignalViewModel.swift index 980048fc4..d435b6d89 100644 --- a/examples/demo/App/ViewModels/OneSignalViewModel.swift +++ b/examples/demo/App/ViewModels/OneSignalViewModel.swift @@ -359,6 +359,53 @@ final class OneSignalViewModel: ObservableObject { tags.removeAll { keys.contains($0.key) } } + // MARK: - Location + + func setLocationShared(_ shared: Bool) { + isLocationShared = shared + service.isLocationShared = shared + } + + func promptLocation() { + service.requestLocationPermission() + } + + func checkLocationShared() -> Bool { + let shared = service.isLocationShared + print("[OneSignal] Location shared: \(shared)") + return shared + } + + // MARK: - Tooltips + + func showTooltip(for key: String) { + if let tooltip = TooltipService.shared.tooltip(for: key) { + activeTooltip = tooltip + } + } + + func dismissTooltip() { + activeTooltip = nil + } + + // MARK: - Observers + + private func setupObservers() { + observers.viewModel = self + service.addPushSubscriptionObserver(observers) + service.addUserObserver(observers) + service.addPermissionObserver(observers) + service.addUserJwtInvalidatedListener(observers) + } +} + +// MARK: - Engagement + +/// Outcomes, in-app messages, triggers, custom events, notifications and Live +/// Activities. Split out of the main declaration so the type body stays inside +/// SwiftLint's `type_body_length` limit. +extension OneSignalViewModel { + // MARK: - Outcomes func sendOutcome(_ name: String) { @@ -429,23 +476,6 @@ final class OneSignalViewModel: ObservableObject { print("[OneSignal] Event tracked: \(name)") } - // MARK: - Location - - func setLocationShared(_ shared: Bool) { - isLocationShared = shared - service.isLocationShared = shared - } - - func promptLocation() { - service.requestLocationPermission() - } - - func checkLocationShared() -> Bool { - let shared = service.isLocationShared - print("[OneSignal] Location shared: \(shared)") - return shared - } - // MARK: - Notifications func clearAllNotifications() { @@ -500,28 +530,6 @@ final class OneSignalViewModel: ObservableObject { } } #endif - - // MARK: - Tooltips - - func showTooltip(for key: String) { - if let tooltip = TooltipService.shared.tooltip(for: key) { - activeTooltip = tooltip - } - } - - func dismissTooltip() { - activeTooltip = nil - } - - // MARK: - Observers - - private func setupObservers() { - observers.viewModel = self - service.addPushSubscriptionObserver(observers) - service.addUserObserver(observers) - service.addPermissionObserver(observers) - service.addUserJwtInvalidatedListener(observers) - } } // MARK: - Observer Bridge From 5edbe374ebe1b5368dbfa05b17edad310743d9d6 Mon Sep 17 00:00:00 2001 From: Nan Date: Fri, 11 Sep 2026 10:33:40 -0700 Subject: [PATCH 7/7] fix: [JWT-8] refetch the demo /users call after PROVIDE JWT updateUserJwt stored a fresh bearer but never refetched, so the REST fetch row kept showing the 401 that prompted the ask until a user-state event or a toggle flip happened to fire. Refetch when the token is for the current user, the same condition the service uses to decide whether to store the bearer, matching what a same-user login already does. --- examples/demo/App/ViewModels/OneSignalViewModel.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/examples/demo/App/ViewModels/OneSignalViewModel.swift b/examples/demo/App/ViewModels/OneSignalViewModel.swift index d435b6d89..14fdb8330 100644 --- a/examples/demo/App/ViewModels/OneSignalViewModel.swift +++ b/examples/demo/App/ViewModels/OneSignalViewModel.swift @@ -228,11 +228,18 @@ final class OneSignalViewModel: ObservableObject { let trimmedId = externalId.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedId.isEmpty, !trimmedToken.isEmpty else { return } + // Same condition the service uses to decide whether to store the bearer. + let currentUser = trimmedId == service.externalId service.updateUserJwt(externalId: trimmedId, token: trimmedToken) if jwtAskExternalId == trimmedId { jwtAskExternalId = nil } print("[OneSignal] Updated JWT for: \(trimmedId)") + if currentUser { + // The demo now holds a fresh bearer for this user, and no user-state + // event is guaranteed, so refetch instead of leaving the stale failure. + Task { await fetchUserDataFromApi() } + } } /// The SDK parked this user's requests for want of a token. Drop the demo's stale copy and show the ask.