Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d75bdf5
fix: [PR2] read-your-write consistency defects
nan-li Aug 11, 2026
53642ec
fix: scope RYW resolve to one user and lower the bar on timeout
nan-li Aug 12, 2026
ead7217
chore: tighten RYW comments to iOS comment style
nan-li Aug 12, 2026
5ac1967
chore(tests): [PR2] reuse the shared test helpers added in #1719
nan-li Aug 21, 2026
407bcab
feat: [PR3] feature flags, JWT config, and the Identity Verification …
nan-li Aug 11, 2026
859489e
fix: [PR3] tighten params retry class and hydration handler race
nan-li Aug 12, 2026
b784b28
refactor: [PR3] adopt the KMP-backed feature manager from #1723
nan-li Aug 27, 2026
bbb8151
fix: [PR4] declare the OneSignalUserMocks dependency on OneSignalOSCo…
nan-li Aug 21, 2026
e5ca7ee
feat: [PR4] identity model JWT, the JWT repo, and the public API
nan-li Aug 11, 2026
ccefb84
fix: [PR4] mark JWT bearer updates as local-only
nan-li Aug 12, 2026
7b27c60
refactor: [PR4] drop the feature-manager refresh removed in PR3
nan-li Aug 27, 2026
e86fa70
feat: [PR5] bind each Delta to the user that owns it
nan-li Aug 11, 2026
b7f4bc2
fix: [PR5] drop stale properties/subscription deltas instead of mis-s…
nan-li Aug 12, 2026
22a6c77
feat: [PR6] Identity Verification for the request pipeline
nan-li Aug 11, 2026
d16bdde
fix: [PR6] hold new-record IDs in purge tests under TEST delay
nan-li Aug 12, 2026
2c2e1e2
chore: [PR6] split types to clear SwiftLint length errors
nan-li Aug 12, 2026
fb2ad17
fix: [PR6] keep restored Identify under IV so reshape can promote it
nan-li Aug 13, 2026
76bde7c
fix: [PR6] default missing addsNewRecords to true on Create User decode
nan-li Aug 13, 2026
135250d
fix: [PR6] keep uncached owned requests only while Identity Verificat…
nan-li Aug 13, 2026
9474f56
fix: [PR6] flush combined property deltas before the 5.6-main waitUntil
nan-li Aug 20, 2026
3ca42d2
fix: [PR6] create the anonymous user before combining property deltas
nan-li Aug 20, 2026
c296d59
refactor: [PR6] adapt the IV gating tests to the KMP-backed feature m…
nan-li Aug 28, 2026
b92b237
feat: [PR7] in-app messages under Identity Verification
nan-li Aug 11, 2026
8ac966d
fix: [PR7] reset in-app messages on user change only while newCodePat…
nan-li Aug 13, 2026
4e3602d
fix: [PR7] refetch IAMs on 401 when a replacement token already landed
nan-li Aug 13, 2026
e45f735
refactor: [PR7] drive the IAM rollout-flag tests through OSFeatureFla…
nan-li Aug 28, 2026
3db98a5
feat: [PR8] demo app Identity Verification UI
nan-li Aug 11, 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dev-only, but this still writes a bearer to UserDefaults.standard (backups, anyone dumping defaults). It also teaches an unsafe pattern.

I’d keep it in memory for the process, or Keychain. The update path already prints Updated JWT for: without the token, which is the right instinct.

} 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