Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2905f69
fix: [PR2] read-your-write consistency defects
nan-li Aug 11, 2026
69069ef
fix: scope RYW resolve to one user and lower the bar on timeout
nan-li Aug 12, 2026
58cbdcb
chore: tighten RYW comments to iOS comment style
nan-li Aug 12, 2026
df77117
feat: [PR3] feature flags, JWT config, and the Identity Verification …
nan-li Aug 11, 2026
1630188
fix: [PR3] tighten params retry class and hydration handler race
nan-li Aug 12, 2026
34c1df2
feat: [PR4] identity model JWT, the JWT repo, and the public API
nan-li Aug 11, 2026
b1336f6
fix: [PR4] mark JWT bearer updates as local-only
nan-li Aug 12, 2026
775dd39
feat: [PR5] bind each Delta to the user that owns it
nan-li Aug 11, 2026
630bb94
fix: [PR5] drop stale properties/subscription deltas instead of mis-s…
nan-li Aug 12, 2026
e717a33
feat: [PR6] Identity Verification for the request pipeline
nan-li Aug 11, 2026
fa0ffa6
fix: [PR6] hold new-record IDs in purge tests under TEST delay
nan-li Aug 12, 2026
cce88bd
chore: [PR6] split types to clear SwiftLint length errors
nan-li Aug 12, 2026
da82803
fix: [PR6] keep restored Identify under IV so reshape can promote it
nan-li Aug 13, 2026
b97b2c1
fix: [PR6] default missing addsNewRecords to true on Create User decode
nan-li Aug 13, 2026
d38174e
fix: [PR6] keep uncached owned requests only while Identity Verificat…
nan-li Aug 13, 2026
f9e43ee
fix: [PR6] restore waitForBackgroundThreads after the 5.6-main helper…
nan-li Aug 20, 2026
0e97e3b
fix: [PR6] flush combined property deltas before the 5.6-main waitUntil
nan-li Aug 20, 2026
b838268
fix: [PR6] create the anonymous user before combining property deltas
nan-li Aug 20, 2026
91115f9
feat: [PR7] in-app messages under Identity Verification
nan-li Aug 11, 2026
8dcb1a4
fix: [PR7] reset in-app messages on user change only while newCodePat…
nan-li Aug 13, 2026
0aeade0
fix: [PR7] refetch IAMs on 401 when a replacement token already landed
nan-li Aug 13, 2026
915470f
fix: [PR7] wait on deferredFetchSubscriptionId after the 5.6-main hel…
nan-li Aug 20, 2026
98a90af
feat: [PR8] demo app Identity Verification UI
nan-li Aug 11, 2026
6280471
chore: [PR8] (will revert) add local feature-flag override for testing
nan-li Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions examples/demo/App.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -324,6 +325,7 @@
38138523A8A81A60A77800CA /* NotificationSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSender.swift; sourceTree = "<group>"; };
3A654457BF0A55B54220E669 /* RemoveMultiDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoveMultiDialog.swift; sourceTree = "<group>"; };
3B81D7E1A7EB9BB4466C768F /* SendPushSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendPushSection.swift; sourceTree = "<group>"; };
3DD26AED9EA84B0308C1BFE8 /* LoginUserDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginUserDialog.swift; sourceTree = "<group>"; };
3FD8258E807E6672642A32E6 /* UserFetchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserFetchService.swift; sourceTree = "<group>"; };
4072202943CA64D2CBC38CB5 /* MultiPairInputDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiPairInputDialog.swift; sourceTree = "<group>"; };
4078B5F096680AFA83D1CB85 /* OutcomesSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutcomesSection.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
9 changes: 8 additions & 1 deletion examples/demo/App/Models/AppModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ enum AddItemType {
case tag
case trigger
case externalUserId
case updateUserJwt

var title: String {
switch self {
Expand All @@ -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
}
}
Expand All @@ -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"
}
}
Expand All @@ -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"
}
}

Expand Down Expand Up @@ -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"
}
}

Expand All @@ -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"
}
}
Expand All @@ -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"
}
}
Expand Down
35 changes: 33 additions & 2 deletions examples/demo/App/Services/OneSignalService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down Expand Up @@ -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)
}
Expand Down
28 changes: 25 additions & 3 deletions examples/demo/App/Services/PreferencesService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}
}
27 changes: 23 additions & 4 deletions examples/demo/App/Services/UserFetchService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
68 changes: 61 additions & 7 deletions examples/demo/App/ViewModels/OneSignalViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ final class OneSignalViewModel: ObservableObject {

@Published var externalUserId: String?
@Published var aliases: [KeyValueItem] = []
@Published var useIdentityVerification: Bool = false

// MARK: - Push

Expand Down Expand Up @@ -99,14 +100,20 @@ 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()
setupObservers()

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() }
}
}
Expand All @@ -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 }
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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) {
Expand All @@ -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)")
}
}
Loading
Loading