Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
32 changes: 20 additions & 12 deletions iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ typedef enum {ATTRIBUTED, NOT_ATTRIBUTED} FocusAttributionState;
#define PROPERTIES_EXECUTOR_BACKGROUND_TASK @"PROPERTIES_EXECUTOR_BACKGROUND_TASK_"
#define SUBSCRIPTION_EXECUTOR_BACKGROUND_TASK @"SUBSCRIPTION_EXECUTOR_BACKGROUND_TASK_"
#define CUSTOM_EVENTS_EXECUTOR_BACKGROUND_TASK @"CUSTOM_EVENTS_EXECUTOR_BACKGROUND_TASK_"
#define SESSION_EXECUTOR_BACKGROUND_TASK @"SESSION_EXECUTOR_BACKGROUND_TASK_"

// OneSignal constants
#define OS_PUSH @"push"
Expand Down Expand Up @@ -353,6 +354,9 @@ typedef enum {GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT, TRACE, PATCH} HTTP

#define OS_CUSTOM_EVENT_DELTA @"OS_CUSTOM_EVENT_DELTA"

#define OS_CREATE_SESSION_DELTA @"OS_CREATE_SESSION_DELTA"
#define OS_UPDATE_SESSION_DELTA @"OS_UPDATE_SESSION_DELTA"

// Operation Repo
#define OS_OPERATION_REPO_DELTA_QUEUE_KEY @"OS_OPERATION_REPO_DELTA_QUEUE_KEY"

Expand All @@ -379,6 +383,10 @@ typedef enum {GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT, TRACE, PATCH} HTTP
#define OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY @"OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY"
#define OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY @"OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY"

// Session Executor
#define OS_SESSION_EXECUTOR_DELTA_QUEUE_KEY @"OS_SESSION_EXECUTOR_DELTA_QUEUE_KEY"
#define OS_SESSION_EXECUTOR_REQUEST_QUEUE_KEY @"OS_SESSION_EXECUTOR_REQUEST_QUEUE_KEY"

// A codeable value larger than this is not written, and a cached queue blob larger than it is dropped without
// decoding it. Real queues are a few KB. CFPreferences rejects a value of 4 MB or more on iOS.
#define OS_USER_DEFAULTS_MAX_VALUE_BYTES 1048576
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,12 @@ public protocol OSOperationExecutor {
so the policy stays there; only the storage is per-executor.
*/
func removeOperationsWithoutExternalId()

/// End any failure backoff early, still waiting out a backend `Retry-After`. Driven by
/// `OSOperationRepo.retryNow`, which flushes right after.
func retryNow()
}

public extension OSOperationExecutor {
func retryNow() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,17 @@ public class OSOperationRepo: NSObject {
var pollIntervalMilliseconds = Int(POLL_INTERVAL_MS)
public var paused = false

/// Driven by app focus. Calls `retryNow` on foreground and when the network returns.
public let retryTrigger: OSOperationRetryTrigger

// Uncache in init so an enqueue before start cannot persist over a previous session's queue.
public init(identityVerificationService: OSIdentityVerificationService) {
self.identityVerificationService = identityVerificationService
self.retryTrigger = OSOperationRetryTrigger()
super.init()
retryTrigger.onRetry = { [weak self] in
self?.retryNow()
}
uncacheDeltaQueue()
}

Expand Down Expand Up @@ -205,6 +212,19 @@ public class OSOperationRepo: NSObject {
}
}

/// Ends executors' failure backoff early and flushes. A backend `Retry-After` is still waited out.
public func retryNow() {
guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else {
return
}
dispatchQueue.async {
for executor in self.executors {
executor.retryNow()
}
self.flushDeltaQueue()
}
}

@objc public func addFlushDeltaQueueToDispatchQueue(inBackground: Bool = false) {
self.dispatchQueue.async {
self.flushDeltaQueue(inBackground: inBackground)
Expand Down Expand Up @@ -232,11 +252,20 @@ public class OSOperationRepo: NSObject {
added and removed, never updated. Should an update path for them appear, this has to narrow to the
push type, which the repo cannot see from here: `OSSubscriptionModel` lives in OneSignalUser, so
the Delta would have to carry the distinction the way it carries `externalId`.

Session Deltas are exempt too: the sessions API takes no user JWT, so Identity Verification does
not apply to it.
*/
private func shouldDropAnonymousDelta(_ delta: OSDelta, ivActive: Bool) -> Bool {
return ivActive && delta.externalId == nil && delta.name != OS_UPDATE_SUBSCRIPTION_DELTA
return ivActive && delta.externalId == nil && !Self.deltasExemptFromIdentityVerification.contains(delta.name)
}

private static let deltasExemptFromIdentityVerification: Set<String> = [
OS_UPDATE_SUBSCRIPTION_DELTA,
OS_CREATE_SESSION_DELTA,
OS_UPDATE_SESSION_DELTA
]

private func flushDeltaQueue(inBackground: Bool = false) {
guard !paused else {
OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSOperationRepo not flushing queue due to being paused")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
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 Foundation
import Network

protocol OSNetworkPathMonitoring: AnyObject {
/// Calls `onAvailable` each time the network becomes available after being unavailable.
func start(onAvailable: @escaping () -> Void)
}

final class OSNetworkPathMonitor: OSNetworkPathMonitoring {
private let monitor = NWPathMonitor()

func start(onAvailable: @escaping () -> Void) {
var wasSatisfied: Bool?
monitor.pathUpdateHandler = { path in
let isSatisfied = path.status == .satisfied
defer { wasSatisfied = isSatisfied }
if isSatisfied, wasSatisfied == false {
onAvailable()
}
}
monitor.start(queue: DispatchQueue(label: "OneSignal.OSNetworkPathMonitor"))
}

deinit {
monitor.cancel()
}
}

/**
Wakes failed operations early: calls `onRetry` when the app comes to the foreground, and when a
network becomes available while it is in the foreground. Network changes in the background are
ignored, since a suspended app could not finish the attempt.
*/
public final class OSOperationRetryTrigger {
private let pathMonitor: OSNetworkPathMonitoring
/// Set once by the owner, before the trigger is handed out.
var onRetry: () -> Void = {}
private let lock = NSLock()
private var isForeground = false
private var isMonitoring = false

init(pathMonitor: OSNetworkPathMonitoring = OSNetworkPathMonitor()) {
self.pathMonitor = pathMonitor
}

public func onFocus() {
let shouldStartMonitoring = lock.withLock {
isForeground = true
defer { isMonitoring = true }
return !isMonitoring
}
if shouldStartMonitoring {
pathMonitor.start { [weak self] in
self?.onNetworkAvailable()
}
}
onRetry()
}

public func onUnfocus() {
lock.withLock { isForeground = false }
}

private func onNetworkAvailable() {
guard lock.withLock({ isForeground }) else {
return
}
onRetry()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ public protocol OSSessionUserProvider: AnyObject {
/// Taken from one read of the current user, so a concurrent login cannot mix two users.
var sessionCurrentUser: OSSessionUser { get }
func sessionOnesignalId(identityModelId: String) -> String?
/// Queues the sessions API create for a session.
func enqueueSessionCreate(_ record: OSSessionRecord, directAttributionId: String?)
/// Queues a session's cumulative duration, ending the session when `endTime` (seconds since
/// 1970) is set.
func enqueueSessionUpdate(_ record: OSSessionRecord, endTime: TimeInterval?)
}

/// New fields must be Optional. A record stored by an earlier version lacks them, and synthesized
Expand All @@ -61,7 +66,7 @@ public struct OSSessionRecord: Codable, Equatable {
/// Fixed for the whole session so it never mixes paths. Read this rather than the feature
/// manager, whose value can change mid-session.
public let usesSessionsApi: Bool
let identityModelId: String?
public let identityModelId: String?
/// Pinned at session start, so it stays the same after a login or user switch.
public internal(set) var onesignalId: String?
public internal(set) var subscriptionId: String?
Expand Down Expand Up @@ -159,6 +164,8 @@ public final class OSSessionService: NSObject {
/// Leaves any open foreground interval running: the tracker reports focus before the session
/// starts asynchronously, and that visit belongs to the new session. A launch in the
/// background has no interval open, so nothing counts until the app becomes active.
/// With the sessions API, the create is enqueued here. The executor holds it until the
/// session's IDs are assigned.
public func startNewSession(userProvider: OSSessionUserProvider) {
let user = userProvider.sessionCurrentUser
let newRecord = OSSessionRecord(
Expand All @@ -177,6 +184,9 @@ public final class OSSessionService: NSObject {
record = newRecord
persist()
}
if newRecord.usesSessionsApi {
userProvider.enqueueSessionCreate(newRecord, directAttributionId: nil)

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.

directAttributionId is always nil. Fine for this PR. Android #2783 attaches a notification open, and there is no iOS PR for that yet.

}
}

func onFocus() {
Expand Down Expand Up @@ -248,6 +258,18 @@ public final class OSSessionService: NSObject {
persist()
}

/// Set once the sessions API creates the session, if it is still the current one.
public func setServerSessionId(_ serverSessionId: String, forSessionId sessionId: String) {
stateLock.withLock {
loadIfNeeded()
guard record?.sessionId == sessionId, record?.serverSessionId == nil else {
return
}
record?.serverSessionId = serverSessionId
persist()
}
}

private func loadIfNeeded() {
guard !loaded else {
return
Expand Down
Loading
Loading