Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).
## [Unreleased]
### Added
- Added at least once delivery for JSON-only in-app messages through `IterableInAppDelegate.onJsonOnlyMessageAvailable(message:)` and `iterableJsonOnlyInAppMessageAvailable` (Objective-C: `IterableAPI.jsonOnlyInAppMessageAvailableNotification`). Messages are saved to local storage before signaling and replay on foreground until acknowledged. Unhandled messages remain available through `IterableAPI.getUnhandledJsonOnlyMessages()` until acknowledged with `markJsonOnlyMessageHandled(messageId:)`; acknowledgement records a payload fingerprint (bounded to the latest 100 per user) so the same message ID with a changed payload is delivered again. Callbacks are invoked without SDK locks held; see the API documentation for the identity overlap contract.

### Fixed
- Public `inAppConsume` APIs now remove messages locally and post `iterableInboxChanged` after the local state is updated. The notification fires only when an inbox message changes, so removing popups or JSON-only messages no longer announces an inbox change.
- In-app fetch, delivery, and merge are now scoped to the user identity that started them, so a login or logout during processing can no longer deliver or persist the previous user's messages.
- Fixed offline-queued requests replaying an expired JWT forever. Tasks persisted while the token was expired kept the stale token in their payload, so they failed with a 401 on every retry even after a successful refresh, and could block the rest of the offline queue. The task processor now stamps the current auth token at execution time, matching online behavior, which also heals tasks already stuck in the queue.

## [6.7.4]
Expand Down
1 change: 1 addition & 0 deletions swift-sdk/Core/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ enum Const {
static let visitorConsentTimestamp = "itbl_visitor_consent_timestamp"
static let isNotificationsEnabled = "itbl_isNotificationsEnabled"
static let hasStoredNotificationSetting = "itbl_hasStoredNotificationSetting"
static let jsonOnlyMessageQueue = "itbl_json_only_message_queue"

static let attributionInfoExpiration = 24
}
Expand Down
79 changes: 79 additions & 0 deletions swift-sdk/Internal/Auth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,85 @@ protocol AuthProvider: AnyObject {
var auth: Auth { get }
}

struct UserIdentityContext: Equatable {
let identity: UserIdentitySnapshot?
let generation: UInt64
}

final class IdentityCoordinator {
func capture(identityProvider: () -> UserIdentitySnapshot?) -> UserIdentityContext {
withCriticalSection {
UserIdentityContext(identity: identityProvider(), generation: generation)
}
}

func isCurrent(_ context: UserIdentityContext,
identityProvider: () -> UserIdentitySnapshot?) -> Bool {
withCriticalSection {
context.generation == generation &&
context.identity == identityProvider() &&
!hasPendingPublication
}
}

@discardableResult
func performIfCurrent(_ context: UserIdentityContext,
identityProvider: () -> UserIdentitySnapshot?,
_ block: () -> Void) -> Bool {
withCriticalSection {
guard context.generation == generation,
context.identity == identityProvider(),
!hasPendingPublication else {
return false
}
block()
return context.generation == generation &&
context.identity == identityProvider() &&
!hasPendingPublication
}
}

func publish(_ block: () -> Void) {
beginPublication()
withCriticalSection {
block()
generation &+= 1
endPublication()
}
}

func beginPublication() {
pendingPublicationLock.lock()
pendingPublicationCount += 1
pendingPublicationLock.unlock()
}

func endPublication() {
pendingPublicationLock.lock()
pendingPublicationCount -= 1
pendingPublicationLock.unlock()
}

func withCriticalSection<T>(_ block: () -> T) -> T {
lock.lock()
defer { lock.unlock() }
return block()
}

private var hasPendingPublication: Bool {
pendingPublicationLock.lock()
defer { pendingPublicationLock.unlock() }
return pendingPublicationCount > 0
}

// Lock order is manager queue, identity section, then JSON store queue. Identity
// holders must not call customer code or synchronously wait on manager queues.
private let lock = NSRecursiveLock()
private let pendingPublicationLock = NSLock()
private var generation: UInt64 = 0
private var pendingPublicationCount = 0
}

struct Auth {
let userId: String?
let email: String?
Expand Down
10 changes: 10 additions & 0 deletions swift-sdk/Internal/EmptyInAppManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ class EmptyInAppManager: IterableInternalInAppManagerProtocol {
func start() -> Pending<Bool, Error> {
Fulfill<Bool, Error>(value: true)
}

func getUnhandledJsonOnlyMessages() -> [IterableInAppMessage] {
[]
}

func markJsonOnlyMessageHandled(messageId _: String) -> Bool {
false
}

func clearUnhandledJsonOnlyMessages() {}

func handleClick(clickedUrl _: URL?, forMessage _: IterableInAppMessage, location _: InAppLocation, inboxSessionId _: String?) {}

Expand Down
87 changes: 62 additions & 25 deletions swift-sdk/Internal/InternalIterableAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider {

var email: String? {
get {
_email
identityValues().email
} set {
setEmail(newValue)
}
}

var userId: String? {
get {
_userId
identityValues().userId
} set {
setUserId(newValue)
}
Expand Down Expand Up @@ -78,7 +78,11 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider {
}

var auth: Auth {
Auth(userId: userId, email: email, authToken: authManager.getAuthToken(), userIdUnknownUser: localStorage.userIdUnknownUser)
let identity = identityValues()
return Auth(userId: identity.userId,
email: identity.email,
authToken: authManager.getAuthToken(),
userIdUnknownUser: localStorage.userIdUnknownUser)
}

var dependencyContainer: DependencyContainerProtocol
Expand All @@ -88,6 +92,8 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider {
apiClient: self.apiClient,
requestHandler: self.requestHandler,
deviceMetadata: deviceMetadata,
authProvider: self,
identityCoordinator: self.identityCoordinator,
authManager: self.authManager)
}()

Expand Down Expand Up @@ -145,21 +151,25 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider {
}

func setEmail(_ email: String?, authToken: String? = nil, successHandler: OnSuccessHandler? = nil, failureHandler: OnFailureHandler? = nil, identityResolution: IterableIdentityResolution? = nil) {
identityCoordinator.beginPublication()

ITBInfo()
if self._email == email && email != nil {
let currentEmail = identityValues().email
if currentEmail == email && email != nil {
identityCoordinator.endPublication()
self.checkAndUpdateAuthToken(authToken)
return
}

if self._email == email {
if currentEmail == email {
identityCoordinator.endPublication()
return
}

self.logoutPreviousUser()

self._email = email
self._userId = nil
setIdentity(email: email, userId: nil)
identityCoordinator.endPublication()

self.onLogin(authToken) { [weak self] in
guard let config = self?.config else {
Expand Down Expand Up @@ -194,21 +204,26 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider {
}

func setUserId(_ userId: String?, authToken: String? = nil, successHandler: OnSuccessHandler? = nil, failureHandler: OnFailureHandler? = nil, isUnknownUser: Bool = false, identityResolution: IterableIdentityResolution? = nil) {
identityCoordinator.beginPublication()

ITBInfo()

if self._userId == userId && userId != nil {
let currentUserId = identityValues().userId
if currentUserId == userId && userId != nil {
identityCoordinator.endPublication()
self.checkAndUpdateAuthToken(authToken)
return
}

if self._userId == userId {
if currentUserId == userId {
identityCoordinator.endPublication()
return
}

self.logoutPreviousUser()

self._email = nil
self._userId = userId
setIdentity(email: nil, userId: userId)
identityCoordinator.endPublication()

self.onLogin(authToken) { [weak self] in
guard let config = self?.config else {
Expand Down Expand Up @@ -253,9 +268,12 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider {

func logoutUser(withOnSuccess onSuccess: OnSuccessHandler?,
onFailure: OnFailureHandler?) {
identityCoordinator.beginPublication()

ITBInfo()

guard isSDKInitialized() else {
identityCoordinator.endPublication()
onFailure?("Iterable SDK is not initialized", nil)
return
}
Expand All @@ -264,8 +282,10 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider {
disableDeviceForCurrentUser(withOnSuccess: onSuccess, onFailure: onFailure)
}

_email = nil
_userId = nil
setIdentity(email: nil, userId: nil)
identityCoordinator.endPublication()

inAppManager.clearUnhandledJsonOnlyMessages()

storeIdentifierData()

Expand Down Expand Up @@ -748,6 +768,14 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider {
onSuccess: onSuccess,
onFailure: onFailure)
}

func getUnhandledJsonOnlyMessages() -> [IterableInAppMessage] {
inAppManager.getUnhandledJsonOnlyMessages()
}

func markJsonOnlyMessageHandled(messageId: String) -> Bool {
inAppManager.markJsonOnlyMessageHandled(messageId: messageId)
}

@discardableResult
func track(embeddedMessageReceived message: IterableEmbeddedMessage,
Expand Down Expand Up @@ -799,6 +827,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider {
private var _email: String?
private var _payloadData: [AnyHashable: Any]?
private var _userId: String?
private let identityCoordinator = IdentityCoordinator()
private var _successCallback: OnSuccessHandler? = nil
private var _failureCallback: OnFailureHandler? = nil

Expand Down Expand Up @@ -863,29 +892,28 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider {
}

public func isEitherUserIdOrEmailSet() -> Bool {
IterableUtil.isNotNullOrEmpty(string: _email) || IterableUtil.isNotNullOrEmpty(string: _userId)
let identity = identityValues()
return IterableUtil.isNotNullOrEmpty(string: identity.email) || IterableUtil.isNotNullOrEmpty(string: identity.userId)
}

public func noUserLoggedIn() -> Bool {
IterableUtil.isNullOrEmpty(string: _email) && IterableUtil.isNullOrEmpty(string: _userId)
let identity = identityValues()
return IterableUtil.isNullOrEmpty(string: identity.email) && IterableUtil.isNullOrEmpty(string: identity.userId)
}

public func isUnknownUserSet() -> Bool {
IterableUtil.isNotNullOrEmpty(string: localStorage.userIdUnknownUser)
}

private func logoutPreviousUser() {
// Delegates to logoutUser(withOnSuccess:onFailure:) so the logout cleanup
// sequence has a single source of truth. The user-switch paths (setEmail/
// setUserId) pass no handlers: a nil onFailure keeps the not-initialized
// guard a silent no-op, and a nil onSuccess makes the auto-push-off
// completion a no-op, matching this method's previous behavior.
// Preserve the existing no-handler behavior for internal user switches.
logoutUser(withOnSuccess: nil, onFailure: nil)
}

private func storeIdentifierData() {
localStorage.email = _email
localStorage.userId = _userId
let identity = identityValues()
localStorage.email = identity.email
localStorage.userId = identity.userId
}

private func onLogin(_ authToken: String? = nil, onloginSuccess onloginSuccessCallBack: (()->())? = nil) {
Expand Down Expand Up @@ -941,8 +969,18 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider {
}

private func retrieveIdentifierData() {
_email = localStorage.email
_userId = localStorage.userId
setIdentity(email: localStorage.email, userId: localStorage.userId)
}

private func identityValues() -> (email: String?, userId: String?) {
identityCoordinator.withCriticalSection { (_email, _userId) }
}
Comment on lines +978 to +980

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is the re-entry point that closes the deadlock loop: identityValues() takes the same identity lock, so any identity read (email, auth, and track via auth) called from a callback-spawned thread while the JSON-only callback holds the lock on main will block here. See the callback-under-lock comment in InAppManager.swift:754.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Resolved by the same change (6200375): callbacks no longer hold the identity lock, so this re-entry can no longer close a deadlock loop.


private func setIdentity(email: String?, userId: String?) {
identityCoordinator.publish {
_email = email
_userId = userId
}
}

private func save(pushPayload payload: [AnyHashable: Any]) {
Expand Down Expand Up @@ -1182,4 +1220,3 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider {

}


9 changes: 9 additions & 0 deletions swift-sdk/Internal/IterableUserDefaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,14 @@ class IterableUserDefaults {
save(bool: newValue, withKey: .hasStoredNotificationSetting)
}
}

var jsonOnlyMessageQueueData: Data? {
get {
userDefaults.data(forKey: UserDefaultsKey.jsonOnlyMessageQueue.value)
} set {
userDefaults.set(newValue, forKey: UserDefaultsKey.jsonOnlyMessageQueue.value)
}
}

func getAttributionInfo(currentDate: Date) -> IterableAttributionInfo? {
(try? codable(withKey: .attributionInfo, currentDate: currentDate)) ?? nil
Expand Down Expand Up @@ -389,6 +397,7 @@ class IterableUserDefaults {

static let isNotificationsEnabled = UserDefaultsKey(value: Const.UserDefault.isNotificationsEnabled)
static let hasStoredNotificationSetting = UserDefaultsKey(value: Const.UserDefault.hasStoredNotificationSetting)
static let jsonOnlyMessageQueue = UserDefaultsKey(value: Const.UserDefault.jsonOnlyMessageQueue)
}
private struct Envelope: Codable {
let payload: Data
Expand Down
Loading
Loading