diff --git a/examples/demo/App/App.swift b/examples/demo/App/App.swift index 7e6d6fd2c..456a112ed 100644 --- a/examples/demo/App/App.swift +++ b/examples/demo/App/App.swift @@ -54,6 +54,10 @@ class AppDelegate: NSObject, UIApplicationDelegate { // Initialize OneSignal OneSignalService.shared.initialize(launchOptions: launchOptions) + // Here rather than in the view model. The SDK doesn't replay this event, so a + // listener added later misses a 401 at launch. + OneSignalService.shared.addUserJwtInvalidatedListener(JwtInvalidatedHandler.shared) + // Set up notification lifecycle listeners setupNotificationListeners() @@ -114,6 +118,20 @@ class NotificationClickHandler: NSObject, OSNotificationClickListener { } } +// MARK: - JWT Handler + +class JwtInvalidatedHandler: NSObject, OSUserJwtInvalidatedListener { + static let shared = JwtInvalidatedHandler() + + func onUserJwtInvalidated(event: OSUserJwtInvalidatedEvent) { + let externalId = event.externalId + LogService.info("JWT invalidated for externalId: \(externalId)") + Task { @MainActor in + JwtAskStore.shared.invalidated(externalId: externalId) + } + } +} + // MARK: - In-App Message Handlers class InAppMessageLifecycleHandler: NSObject, OSInAppMessageLifecycleListener { diff --git a/examples/demo/App/Models/AppModels.swift b/examples/demo/App/Models/AppModels.swift index d98591df0..70ccd615a 100644 --- a/examples/demo/App/Models/AppModels.swift +++ b/examples/demo/App/Models/AppModels.swift @@ -80,7 +80,6 @@ enum AddItemType { case tag case trigger case externalUserId - case updateUserJwt var title: String { switch self { @@ -90,13 +89,12 @@ 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, .updateUserJwt: return true + case .alias, .tag, .trigger: return true case .email, .sms, .externalUserId: return false } } @@ -105,7 +103,6 @@ enum AddItemType { switch self { case .alias: return "Label" case .tag, .trigger: return "Key" - case .updateUserJwt: return "External User Id" default: return "Key" } } @@ -117,7 +114,6 @@ enum AddItemType { case .sms: return "Phone Number" case .tag, .trigger: return "Value" case .externalUserId: return "External User Id" - case .updateUserJwt: return "JWT Token" } } @@ -145,7 +141,6 @@ enum AddItemType { case .tag: return "tag" case .trigger: return "trigger" case .externalUserId: return "login_user_id" - case .updateUserJwt: return "update_jwt" } } @@ -157,7 +152,6 @@ 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" } } @@ -171,7 +165,6 @@ 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 11d70d913..a2af062d2 100644 --- a/examples/demo/App/Services/OneSignalService.swift +++ b/examples/demo/App/Services/OneSignalService.swift @@ -53,8 +53,8 @@ final class OneSignalService { /// 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` + /// No login here. The SDK restores the user from its own cache, and the stored JWTs + /// stay 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) @@ -96,10 +96,9 @@ 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) + // A blank token field leaves any token already stored for this user. + if let jwtToken = jwtToken { + prefs.setJwtToken(jwtToken, externalId: externalId) } prefs.setExternalUserId(externalId) if let jwtToken = jwtToken { @@ -110,17 +109,15 @@ final class OneSignalService { } func updateUserJwt(externalId: String, token: String) { - // The demo's REST bearer belongs to the current user. A token for anyone else - // still goes to the SDK, which owns the parked work for that id. - if externalId == OneSignal.User.externalId { - prefs.setSessionJwtToken(token) - } + // Stored whoever it's for. The SDK can hold parked requests for a user who isn't the + // current one, including one who has logged out. + prefs.setJwtToken(token, externalId: externalId) OneSignal.updateUserJwt(externalId: externalId, token: token) } func logout() { prefs.setExternalUserId(nil) - prefs.setSessionJwtToken(nil) + prefs.clearJwtTokens() OneSignal.logout() } @@ -129,11 +126,6 @@ final class OneSignalService { OneSignal.User.setLanguage(language) } - /// 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. @@ -142,8 +134,8 @@ final class OneSignalService { set { prefs.setUseIdentityVerification(newValue) } } - /// Token from the last login / updateUserJwt, for the demo REST fetch only. Cleared when the SDK rejects it. - var sessionJwtToken: String? { prefs.getSessionJwtToken() } + /// The last token handed to the SDK for this user, for the demo REST fetch only. + func jwtToken(for externalId: String) -> String? { prefs.getJwtToken(externalId: externalId) } // MARK: - Aliases @@ -286,3 +278,37 @@ final class OneSignalService { OneSignal.InAppMessages.addLifecycleListener(listener) } } + +// MARK: - JWT ask + +/// The SDK's pending ask for a JWT. App-scoped so an event caught by the listener `AppDelegate` +/// registers at launch still reaches the UI, however late the view model starts. The SDK doesn't +/// replay the event. +@MainActor +final class JwtAskStore: ObservableObject { + + static let shared = JwtAskStore() + + /// External id the SDK is waiting on a JWT for, or nil. + @Published private(set) var externalId: String? + + private init() {} + + /// Replaces any earlier ask. The stored token stays. The SDK also fires this event when + /// requests park without a token, so it doesn't prove the stored token is bad. A token that + /// is bad shows up as a 401 in the REST fetch row. + func invalidated(externalId: String) { + self.externalId = externalId + } + + /// A token for `externalId` answers the ask if it is for that user. + func answered(externalId: String) { + if self.externalId == externalId { + self.externalId = nil + } + } + + func clear() { + externalId = nil + } +} diff --git a/examples/demo/App/Services/PreferencesService.swift b/examples/demo/App/Services/PreferencesService.swift index 0420121d0..9f658d2ab 100644 --- a/examples/demo/App/Services/PreferencesService.swift +++ b/examples/demo/App/Services/PreferencesService.swift @@ -29,8 +29,8 @@ import Foundation /// `UserDefaults`-backed cache for state the demo restores across cold launches: /// 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`. +/// the Identity Verification toggle, and the demo's JWTs. The JWTs are only for the +/// demo's `/users` fetch. The demo never passes them to `login` or `updateUserJwt` on its own. final class PreferencesService { static let shared = PreferencesService() @@ -48,7 +48,9 @@ final class PreferencesService { static let locationShared = "onesignal.demo.locationShared" static let externalUserId = "onesignal.demo.externalUserId" static let useIdentityVerification = "onesignal.demo.useIdentityVerification" - static let sessionJwtToken = "onesignal.demo.sessionJwtToken" + static let jwtTokens = "onesignal.demo.jwtTokens" + /// The single token the demo kept before `jwtTokens`. Deleted on first read. + static let legacySessionJwtToken = "onesignal.demo.sessionJwtToken" static let language = "onesignal.demo.language" } @@ -92,19 +94,29 @@ final class PreferencesService { 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 { + // Every JWT the tester handed the SDK, keyed by external id. Plaintext UserDefaults is fine + // for a demo and not a pattern for real apps. + + func getJwtToken(externalId: String) -> String? { + guard let value = jwtTokens()[externalId], !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) - } + func setJwtToken(_ token: String, externalId: String) { + var tokens = jwtTokens() + tokens[externalId] = token + defaults.set(tokens, forKey: Key.jwtTokens) + } + + func clearJwtTokens() { + defaults.removeObject(forKey: Key.jwtTokens) + } + + private func jwtTokens() -> [String: String] { + defaults.removeObject(forKey: Key.legacySessionJwtToken) + return defaults.dictionary(forKey: Key.jwtTokens) as? [String: String] ?? [:] } // MARK: - Language diff --git a/examples/demo/App/ViewModels/OneSignalViewModel.swift b/examples/demo/App/ViewModels/OneSignalViewModel.swift index 4cd1cc611..1e6013a6e 100644 --- a/examples/demo/App/ViewModels/OneSignalViewModel.swift +++ b/examples/demo/App/ViewModels/OneSignalViewModel.swift @@ -46,7 +46,8 @@ final class OneSignalViewModel: ObservableObject { @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? + /// Mirrors `JwtAskStore`, which the listener registered in `AppDelegate` fills. + @Published private(set) var jwtAskExternalId: String? /// Outcome of the last demo /users fetch, "OK" or the failure reason. @Published var userFetchStatus: String? @@ -97,9 +98,10 @@ final class OneSignalViewModel: ObservableObject { private let prefs: PreferencesService private var observers = Observers() - /// Monotonically incremented on every `fetchUserDataFromApi` call. The - /// value captured at entry guards the post-await write so a slow fetch - /// for an old `onesignalId` cannot overwrite a newer fetch's results. + /// Monotonically incremented on every `fetchUserDataFromApi` call, and by + /// `resetUserFetch` on a user switch or logout. The value captured at entry + /// guards the post-await write so a slow fetch for an old user cannot + /// overwrite newer state. private var requestSequence: UInt64 = 0 // MARK: - Init @@ -116,6 +118,7 @@ final class OneSignalViewModel: ObservableObject { self.hasNotificationPermission = service.hasNotificationPermission refreshState() setupObservers() + JwtAskStore.shared.$externalId.assign(to: &$jwtAskExternalId) TooltipService.shared.loadIfNeeded() @@ -151,13 +154,15 @@ final class OneSignalViewModel: ObservableObject { if useIdentityVerification { guard let externalId = externalUserId ?? service.externalId, !externalId.isEmpty else { + userFetchStatus = nil return } aliasLabel = "external_id" aliasValue = externalId - jwt = service.sessionJwtToken + jwt = service.jwtToken(for: externalId) } else { guard let onesignalId = service.onesignalId, !onesignalId.isEmpty else { + userFetchStatus = nil return } aliasLabel = "onesignal_id" @@ -222,13 +227,14 @@ final class OneSignalViewModel: ObservableObject { service.login(externalId: trimmed, jwtToken: jwtToken) externalUserId = trimmed resetLanguagePicker() - if jwtToken != nil, jwtAskExternalId == trimmed { - jwtAskExternalId = nil + if jwtToken != nil { + JwtAskStore.shared.answered(externalId: trimmed) } if sameUser { // No user-state event fires for the same user, so refetch here instead. Task { await fetchUserDataFromApi() } } else { + resetUserFetch() clearUserData() } } @@ -237,35 +243,33 @@ 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 - } + JwtAskStore.shared.answered(externalId: trimmedId) LogService.info("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. + if trimmedId == service.externalId { + // updateUserJwt may not fire a user-state event, so refetch instead of leaving + // the last failure on screen. Task { await fetchUserDataFromApi() } } } - /// The SDK parked this user's requests for want of a token. Drop the demo's stale copy and show the ask. - func handleJwtInvalidated(externalId: String) { - service.clearSessionJwtToken() - jwtAskExternalId = externalId - LogService.info("JWT invalidated for externalId: \(externalId)") - } - func logout() { service.logout() externalUserId = nil - jwtAskExternalId = nil + JwtAskStore.shared.clear() + resetUserFetch() resetLanguagePicker() clearUserData() } + /// A fetch still in flight belongs to the previous user, so drop its result. Clear the row + /// too, because a new user's create can wait on a JWT, and then no user-state event comes + /// to refetch. + private func resetUserFetch() { + requestSequence &+= 1 + userFetchStatus = nil + } + func setLanguage(_ language: String) { service.setLanguage(language) self.language = language @@ -424,7 +428,6 @@ final class OneSignalViewModel: ObservableObject { service.addPushSubscriptionObserver(observers) service.addUserObserver(observers) service.addPermissionObserver(observers) - service.addUserJwtInvalidatedListener(observers) } } @@ -566,8 +569,7 @@ extension OneSignalViewModel { private final class Observers: NSObject, OSPushSubscriptionObserver, OSUserStateObserver, - OSNotificationPermissionObserver, - OSUserJwtInvalidatedListener { + OSNotificationPermissionObserver { weak var viewModel: OneSignalViewModel? func onPushSubscriptionDidChange(state: OSPushSubscriptionChangedState) { @@ -603,12 +605,6 @@ private final class Observers: NSObject, } } - func onUserJwtInvalidated(event: OSUserJwtInvalidatedEvent) { - Task { @MainActor in - viewModel?.handleJwtInvalidated(externalId: event.externalId) - } - } - private static func truncatedToken(_ token: String?) -> String { guard let token, !token.isEmpty else { return "null" } return "\(token.prefix(8))…" diff --git a/examples/demo/App/Views/Components/AddItemDialog.swift b/examples/demo/App/Views/Components/AddItemDialog.swift index 938a12f5e..f6b099578 100644 --- a/examples/demo/App/Views/Components/AddItemDialog.swift +++ b/examples/demo/App/Views/Components/AddItemDialog.swift @@ -36,19 +36,6 @@ struct AddItemDialog: View { @State private var keyText: String = "" @State private var valueText: String = "" - /// `initialKey` prefills the first field. - init( - itemType: AddItemType, - initialKey: String = "", - onAdd: @escaping (String, String) -> Void, - onCancel: @escaping () -> Void - ) { - self.itemType = itemType - self.onAdd = onAdd - self.onCancel = onCancel - _keyText = State(initialValue: initialKey) - } - var body: some View { OSDialog( title: itemType.title, diff --git a/examples/demo/App/Views/Components/LoginUserDialog.swift b/examples/demo/App/Views/Components/LoginUserDialog.swift index 537470c53..9a8afadb6 100644 --- a/examples/demo/App/Views/Components/LoginUserDialog.swift +++ b/examples/demo/App/Views/Components/LoginUserDialog.swift @@ -68,3 +68,58 @@ struct LoginUserDialog: View { !externalId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } } + +/// Update User JWT dialog. Fields are stacked full width like `LoginUserDialog` because a JWT +/// runs to several hundred characters. `initialExternalId` prefills the id and stays editable. +struct UpdateJwtDialog: View { + let onUpdate: (String, String) -> Void + let onCancel: () -> Void + + @State private var externalId: String + @State private var jwtToken: String = "" + + init( + initialExternalId: String, + onUpdate: @escaping (String, String) -> Void, + onCancel: @escaping () -> Void + ) { + self.onUpdate = onUpdate + self.onCancel = onCancel + _externalId = State(initialValue: initialExternalId) + } + + var body: some View { + OSDialog( + title: "Update User JWT", + confirmLabel: "Update", + isConfirmEnabled: isValid, + confirmAccessibilityID: "singlepair_confirm_button", + cancelAccessibilityID: "singlepair_cancel_button", + onConfirm: { + onUpdate( + externalId.trimmingCharacters(in: .whitespacesAndNewlines), + jwtToken.trimmingCharacters(in: .whitespacesAndNewlines) + ) + }, + onCancel: onCancel + ) { + VStack(spacing: 12) { + OSTextField( + placeholder: "External User Id", + text: $externalId, + accessibilityID: "update_jwt_external_id_input" + ) + OSTextField( + placeholder: "JWT Token", + text: $jwtToken, + accessibilityID: "update_jwt_token_input" + ) + } + } + } + + private var isValid: Bool { + !externalId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !jwtToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} diff --git a/examples/demo/App/Views/Sections/UserSection.swift b/examples/demo/App/Views/Sections/UserSection.swift index 9eed1b662..3e6fae648 100644 --- a/examples/demo/App/Views/Sections/UserSection.swift +++ b/examples/demo/App/Views/Sections/UserSection.swift @@ -194,10 +194,9 @@ struct UserSection: View { ) } .osCenteredDialog(isPresented: $updateJwtOpen) { - AddItemDialog( - itemType: .updateUserJwt, - initialKey: viewModel.jwtAskExternalId ?? viewModel.externalUserId ?? "", - onAdd: { externalId, token in + UpdateJwtDialog( + initialExternalId: viewModel.jwtAskExternalId ?? viewModel.externalUserId ?? "", + onUpdate: { externalId, token in viewModel.updateUserJwt(externalId: externalId, token: token) updateJwtOpen = false }, diff --git a/examples/demo/build.md b/examples/demo/build.md index d3277eee3..500d8d64f 100644 --- a/examples/demo/build.md +++ b/examples/demo/build.md @@ -125,7 +125,7 @@ Push subscription id, opt-in, notification permission, and the live `OneSignal.U - Sections declare `@State` booleans for their action dialogs (`@State private var addOpen = false`, `@State private var loginOpen = false`, ...) and attach `.osCenteredDialog(isPresented: $addOpen) { AddItemDialog(...) }` on the section view. Dialog confirm handlers call ViewModel SDK methods and (where applicable) `toast.show(...)`. - `OneSignalViewModel` must not hold any action dialog visibility flags or dialog input drafts. - `osCenteredDialog` (in `App/Views/Components/OSDialog.swift`) is implemented on top of `.fullScreenCover` with a `ClearBackgroundView` (`UIViewRepresentable`) so the dialog presents at the window level instead of being clipped to the section's frame inside `ScrollView`. The default slide-up animation is suppressed via `.transaction { $0.disablesAnimations = true }` so the dialog's own fade-in is preserved. -- Shared dialog primitives live in `App/Views/Components/`: `AddItemDialog` (typed via `AddItemType` -- single-field and pair layouts both flow through it), `MultiPairInputDialog`, `RemoveMultiDialog`, `OutcomeDialog`, `CustomNotificationDialog`, `TrackEventDialog`, `TooltipDialog`. Sections import and compose them locally. +- Shared dialog primitives live in `App/Views/Components/`: `AddItemDialog` (typed via `AddItemType` -- single-field and pair layouts both flow through it), `MultiPairInputDialog`, `RemoveMultiDialog`, `OutcomeDialog`, `CustomNotificationDialog`, `TrackEventDialog`, `TooltipDialog`, plus `LoginUserDialog` and `UpdateJwtDialog` in `LoginUserDialog.swift`. Sections import and compose them locally. ### Accessibility (Appium) @@ -136,6 +136,23 @@ XCUITest does NOT inherit identifiers from `Button(role:)` automatically -- set - `ContentView` anchors `accessibilityIdentifier("main_scroll_view")` on the SwiftUI `ScrollView` itself (not the inner `VStack`) so XCUITest exposes it as `XCUIElementTypeScrollView` with the visible viewport's rect. The shared Appium swipe workaround on iOS depends on this anchoring -- attaching the id to the inner stack reports the full content rect (multiple screens tall) and WDIO `swipe` then computes gestures outside the viewport, which iOS clips to the visible region and registers as taps on whatever button sits there. - `ContentView` runs the auto push-permission prompt via `.task { viewModel.promptPushPermission() }` on mount. It races the OneSignal iOS-params response, so the standard alert can show before the SDK registers provisional auth (which would otherwise silently grant permission and skip the prompt entirely). +### Identity Verification controls + +The User section's JWT controls match the Android demo in labels, test ids, and behavior. Change both demos together. + +- **Fetch by external_id (JWT) toggle** (`identity_verification_toggle`). `PreferencesService` persists it. When ON, `fetchUserDataFromApi` fetches by the current external_id with that user's stored JWT as bearer, or unsigned when there is none. That includes the fetch at launch, even before the user has a onesignal_id. When OFF it fetches by onesignal_id, unsigned, which an app with Identity Verification on rejects with a 401. Flipping it refetches. It only drives the demo's own REST GET and does not turn Identity Verification on for the app. +- **REST fetch row** (`user_fetch_status_value`). Reads `—`, `OK`, or the failure reason, for example `external_id fetch failed: HTTP 401`, so a rejected fetch under Identity Verification doesn't look like an empty user. A failed fetch doesn't change the lists. A user switch, a logout, or a fetch skipped for lack of an id resets the row to `—`. A switch or logout also bumps `requestSequence`, which drops any fetch still in flight for the previous user. +- **Stored tokens.** `PreferencesService` keeps every JWT the tester hands the SDK, keyed by external id, as a dictionary under `onesignal.demo.jwtTokens`. A login with a token or an `updateUserJwt` stores it for that id, whoever it is for. A login with the field blank changes nothing, and neither does an invalidation event. Logout clears them all. Plaintext UserDefaults is fine for a demo and not a pattern for real apps. +- **JWT field in `LoginUserDialog`** (`login_user_jwt_input`). Optional. A same-user login refetches, because the SDK fires no user-state event for it. +- **UPDATE USER JWT button** (`update_user_jwt_button`). Always enabled. Opens `UpdateJwtDialog`, which stacks the External User Id and JWT Token fields full width, prefills the id from the pending ask and then the current user, and confirms with `Update`. Calls `OneSignal.updateUserJwt` and refetches when the id is the current user's. +- **JWT ask banner** (`jwt_ask_banner`, with `jwt_ask_external_id_value` and `jwt_ask_provide_button`). `AppDelegate` registers `JwtInvalidatedHandler` right after init, because the SDK doesn't replay the event. The handler hands the asked id to `JwtAskStore`. It leaves the stored token alone, because the SDK also fires this event when requests park without a token, so the event doesn't prove the token is bad. A bad token shows up as a 401 in the REST fetch row. `OneSignalViewModel.jwtAskExternalId` mirrors it, and `UserSection` shows the banner plus a toast once per ask. A token for that id, from `updateUserJwt` or a login, clears the ask, and so does logout. The demo never answers the ask with a stored token on its own. PROVIDE JWT is always enabled. + +Some Identity Verification behavior still differs between the demos because the SDKs differ: + +- A login without a token parks that user's requests. The iOS SDK asks for a token, so the banner shows. The Android SDK fires nothing until SDK-5476. +- After a relaunch with a token still owed, the iOS SDK asks again on the first parked request. The Android SDK doesn't, until SDK-5476. +- If a 401 for an old token lands after PROVIDE JWT, the Android SDK deletes the new token and asks again until SDK-5474. The iOS SDK keeps the new token. + ### Branding assets `App/Assets.xcassets/` ships three branded asset folders alongside the standard `AppIcon` / `AccentColor`: