From dfa279230c049d090bb48e41c38159d05d89af3f Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Wed, 30 Sep 2026 20:26:12 +0300 Subject: [PATCH 01/23] Raise minimum iOS deployment target to 15.0 --- OptableSDK.podspec | 2 +- OptableSDK.xcodeproj/project.pbxproj | 12 ++++++------ Package.swift | 4 ++-- .../demo-ios-objc.xcodeproj/project.pbxproj | 4 ++-- .../demo-ios-swift.xcodeproj/project.pbxproj | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/OptableSDK.podspec b/OptableSDK.podspec index 3c6f246..6584541 100644 --- a/OptableSDK.podspec +++ b/OptableSDK.podspec @@ -14,7 +14,7 @@ Pod::Spec.new do |spec| spec.author = { "Optable Technologies Inc" => "support@optable.co" } spec.platform = :ios - spec.ios.deployment_target = "12.0" + spec.ios.deployment_target = "15.0" spec.swift_version = "5.0" spec.source = { :git => "https://github.com/Optable/optable-ios-sdk.git", :tag => "#{spec.version}" } diff --git a/OptableSDK.xcodeproj/project.pbxproj b/OptableSDK.xcodeproj/project.pbxproj index 686c972..12615ab 100644 --- a/OptableSDK.xcodeproj/project.pbxproj +++ b/OptableSDK.xcodeproj/project.pbxproj @@ -286,7 +286,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -347,7 +347,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -374,7 +374,7 @@ ENABLE_MODULE_VERIFIER = YES; INFOPLIST_FILE = Source/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -407,7 +407,7 @@ ENABLE_MODULE_VERIFIER = YES; INFOPLIST_FILE = Source/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -431,7 +431,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Tests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -452,7 +452,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Tests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Package.swift b/Package.swift index 9a158da..3fbbd15 100644 --- a/Package.swift +++ b/Package.swift @@ -1,11 +1,11 @@ -// swift-tools-version:5.3 +// swift-tools-version:5.5 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "OptableSDK", - platforms: [.iOS(.v12)], + platforms: [.iOS(.v15)], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( diff --git a/demo-ios-objc/demo-ios-objc.xcodeproj/project.pbxproj b/demo-ios-objc/demo-ios-objc.xcodeproj/project.pbxproj index 67e4701..3af9d3b 100644 --- a/demo-ios-objc/demo-ios-objc.xcodeproj/project.pbxproj +++ b/demo-ios-objc/demo-ios-objc.xcodeproj/project.pbxproj @@ -360,7 +360,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -413,7 +413,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; diff --git a/demo-ios-swift/demo-ios-swift.xcodeproj/project.pbxproj b/demo-ios-swift/demo-ios-swift.xcodeproj/project.pbxproj index cf29466..e5a10f0 100644 --- a/demo-ios-swift/demo-ios-swift.xcodeproj/project.pbxproj +++ b/demo-ios-swift/demo-ios-swift.xcodeproj/project.pbxproj @@ -348,7 +348,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -402,7 +402,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; From eedee52a0658dad83333c415408a3e85742ac2ba Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Wed, 30 Sep 2026 20:26:12 +0300 Subject: [PATCH 02/23] Fix deprecation warnings under iOS 15 target --- Source/Core/EdgeAPI.swift | 4 +++- Source/Misc/AppTrackingTransparency.swift | 25 ++++------------------- 2 files changed, 7 insertions(+), 22 deletions(-) diff --git a/Source/Core/EdgeAPI.swift b/Source/Core/EdgeAPI.swift index 4d62e22..d3da536 100644 --- a/Source/Core/EdgeAPI.swift +++ b/Source/Core/EdgeAPI.swift @@ -148,7 +148,9 @@ extension EdgeAPI { private func resolveUserAgent(callback: @escaping (_ useragent: String) -> Void) { var wkUserAgent = "" let myGroup = DispatchGroup() - let window = UIApplication.shared.keyWindow + let window = UIApplication.shared.connectedScenes + .compactMap { ($0 as? UIWindowScene)?.keyWindow } + .first let webView = WKWebView(frame: UIScreen.main.bounds) webView.isHidden = true diff --git a/Source/Misc/AppTrackingTransparency.swift b/Source/Misc/AppTrackingTransparency.swift index 5139c9d..c9d18ac 100644 --- a/Source/Misc/AppTrackingTransparency.swift +++ b/Source/Misc/AppTrackingTransparency.swift @@ -29,21 +29,6 @@ } #endif - // MARK: isAdvertisingTrackingEnabled - - #if DEBUG - @available(iOS, introduced: 6, deprecated: 14, - message: "Replaced by ATTrackingManager in AppTrackingTransparency.") - static var isAdvertisingTrackingEnabled_DebugOverride: Bool? - static var isAdvertisingTrackingEnabled: Bool { - isAdvertisingTrackingEnabled_DebugOverride ?? ASIdentifierManager.shared().isAdvertisingTrackingEnabled - } - #else - static var isAdvertisingTrackingEnabled: Bool { - ASIdentifierManager.shared().isAdvertisingTrackingEnabled - } - #endif - // MARK: advertisingIdentifierAvailable #if DEBUG @@ -57,14 +42,12 @@ } #endif + // The SDK requires iOS 15+, where AppTrackingTransparency is always available, so the + // pre-iOS 14 `ASIdentifierManager.isAdvertisingTrackingEnabled` fallback no longer applies. #if canImport(AppTrackingTransparency) - if #available(iOS 14, *) { - return trackingStatus == .authorized - } else { - return isAdvertisingTrackingEnabled - } + return trackingStatus == .authorized #else - return isAdvertisingTrackingEnabled + return false #endif } From 21d10953b89ca61f889c80e0a136ad7b6d9ca827 Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Wed, 30 Sep 2026 20:26:12 +0300 Subject: [PATCH 03/23] Raise demo pod targets to iOS 15, update pods --- demo-ios-objc/Podfile | 13 +++++++++++++ demo-ios-objc/Podfile.lock | 16 ++++++++-------- demo-ios-swift/Podfile | 13 +++++++++++++ demo-ios-swift/Podfile.lock | 16 ++++++++-------- 4 files changed, 42 insertions(+), 16 deletions(-) diff --git a/demo-ios-objc/Podfile b/demo-ios-objc/Podfile index db4bb9b..72b1e52 100644 --- a/demo-ios-objc/Podfile +++ b/demo-ios-objc/Podfile @@ -11,3 +11,16 @@ target 'demo-ios-objc' do pod 'PrebidMobile' end + +# Xcode 27 refuses to build targets whose deployment target is below iOS 15. Some third-party pods +# (Google-Mobile-Ads-SDK, GoogleUserMessagingPlatform, PrebidMobile) still declare older minimums, +# so raise them to the platform version declared above. +post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + if config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f < 15.0 + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0' + end + end + end +end diff --git a/demo-ios-objc/Podfile.lock b/demo-ios-objc/Podfile.lock index 8e0556a..44d9e11 100644 --- a/demo-ios-objc/Podfile.lock +++ b/demo-ios-objc/Podfile.lock @@ -1,11 +1,11 @@ PODS: - - Google-Mobile-Ads-SDK (13.7.0): + - Google-Mobile-Ads-SDK (13.11.0): - GoogleUserMessagingPlatform (>= 1.1) - GoogleUserMessagingPlatform (3.1.0) - OptableSDK (1.0.0) - - PrebidMobile (3.3.3): - - PrebidMobile/core (= 3.3.3) - - PrebidMobile/core (3.3.3) + - PrebidMobile (3.3.4): + - PrebidMobile/core (= 3.3.4) + - PrebidMobile/core (3.3.4) DEPENDENCIES: - Google-Mobile-Ads-SDK @@ -23,11 +23,11 @@ EXTERNAL SOURCES: :path: "../" SPEC CHECKSUMS: - Google-Mobile-Ads-SDK: dd0ded6717d122c6a32c69dc4afcace7d276e4e4 + Google-Mobile-Ads-SDK: 09d0868abe43b9f851b7b340a245679f0eafc96c GoogleUserMessagingPlatform: befe603da6501006420c206222acd449bba45a9c - OptableSDK: d7cb5f7a211f1bac2a0c25677f4c4b31ded8ed4e - PrebidMobile: 5ded0a10d6da41d4b140c1c8b86b42c7629133fa + OptableSDK: 127f749734ded99deff764f29bc1342b20153b2b + PrebidMobile: ce3afbb56904a5e8b3f5f5aa0696474d2786a96b -PODFILE CHECKSUM: 84321d4bbdf19f72ce3dfa6f4cb2f0a9869574ad +PODFILE CHECKSUM: 788f5abee95f41b7e281003e2cab30df58eb8547 COCOAPODS: 1.16.2 diff --git a/demo-ios-swift/Podfile b/demo-ios-swift/Podfile index 5e9d12e..7dfa9a5 100644 --- a/demo-ios-swift/Podfile +++ b/demo-ios-swift/Podfile @@ -11,3 +11,16 @@ target 'demo-ios-swift' do pod 'PrebidMobile' end + +# Xcode 27 refuses to build targets whose deployment target is below iOS 15. Some third-party pods +# (Google-Mobile-Ads-SDK, GoogleUserMessagingPlatform, PrebidMobile) still declare older minimums, +# so raise them to the platform version declared above. +post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + if config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f < 15.0 + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0' + end + end + end +end diff --git a/demo-ios-swift/Podfile.lock b/demo-ios-swift/Podfile.lock index 59ae941..cd81e9f 100644 --- a/demo-ios-swift/Podfile.lock +++ b/demo-ios-swift/Podfile.lock @@ -1,11 +1,11 @@ PODS: - - Google-Mobile-Ads-SDK (13.7.0): + - Google-Mobile-Ads-SDK (13.11.0): - GoogleUserMessagingPlatform (>= 1.1) - GoogleUserMessagingPlatform (3.1.0) - OptableSDK (1.0.0) - - PrebidMobile (3.3.3): - - PrebidMobile/core (= 3.3.3) - - PrebidMobile/core (3.3.3) + - PrebidMobile (3.3.4): + - PrebidMobile/core (= 3.3.4) + - PrebidMobile/core (3.3.4) DEPENDENCIES: - Google-Mobile-Ads-SDK @@ -23,11 +23,11 @@ EXTERNAL SOURCES: :path: "../" SPEC CHECKSUMS: - Google-Mobile-Ads-SDK: dd0ded6717d122c6a32c69dc4afcace7d276e4e4 + Google-Mobile-Ads-SDK: 09d0868abe43b9f851b7b340a245679f0eafc96c GoogleUserMessagingPlatform: befe603da6501006420c206222acd449bba45a9c - OptableSDK: d7cb5f7a211f1bac2a0c25677f4c4b31ded8ed4e - PrebidMobile: 5ded0a10d6da41d4b140c1c8b86b42c7629133fa + OptableSDK: 127f749734ded99deff764f29bc1342b20153b2b + PrebidMobile: ce3afbb56904a5e8b3f5f5aa0696474d2786a96b -PODFILE CHECKSUM: aca8225c99e7af1a76b3862a46118652667f5944 +PODFILE CHECKSUM: ae16575987318eda44e4e135cd54cad3debe5193 COCOAPODS: 1.16.2 From 92f42302b449d1a3f7b847e2b47d831c1ea23d17 Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Wed, 30 Sep 2026 20:26:12 +0300 Subject: [PATCH 04/23] Run CI on xcode-27 runner --- .github/workflows/ios-sdk-ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ios-sdk-ci.yml b/.github/workflows/ios-sdk-ci.yml index 251beb7..6f0d999 100644 --- a/.github/workflows/ios-sdk-ci.yml +++ b/.github/workflows/ios-sdk-ci.yml @@ -10,7 +10,7 @@ on: jobs: sdk-tests: name: "SDK Tests" - runs-on: macos-15 + runs-on: xcode-27 steps: - name: Checkout Code uses: actions/checkout@v5 @@ -30,7 +30,7 @@ jobs: build-demo-swift: name: "Build Demo Swift" - runs-on: macos-15 + runs-on: xcode-27 needs: [sdk-tests] steps: - name: Checkout Code @@ -58,7 +58,7 @@ jobs: build-demo-objc: name: "Build Demo Objective-C" - runs-on: macos-15 + runs-on: xcode-27 env: FL_OUTPUT_DIR: output needs: [sdk-tests] From 84c0c12c45c6381a6752335906b0b95fdd06679b Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Wed, 30 Sep 2026 20:26:12 +0300 Subject: [PATCH 05/23] Fix objc result check in Slack steps --- .github/workflows/ios-sdk-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ios-sdk-ci.yml b/.github/workflows/ios-sdk-ci.yml index 6f0d999..ceb2bdd 100644 --- a/.github/workflows/ios-sdk-ci.yml +++ b/.github/workflows/ios-sdk-ci.yml @@ -92,7 +92,7 @@ jobs: steps: - name: Successful Release Post to Slack uses: slackapi/slack-github-action@v2.1.1 - if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && needs.build-demo-swift.result == 'success' && needs.build-demo-swift.result == 'success' }} + if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && needs.build-demo-swift.result == 'success' && needs.build-demo-objc.result == 'success' }} with: method: chat.postMessage token: "${{ secrets.SLACK_MESSENGER_APP_TOKEN }}" @@ -117,7 +117,7 @@ jobs: text: ":white_check_mark: *iOS SDK release:*\n${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" - name: Build failure Post to Slack uses: slackapi/slack-github-action@v2.1.1 - if: ${{ github.event_name == 'pull_request' && (needs.build-demo-swift.result != 'success' || needs.build-demo-swift.result != 'success')}} + if: ${{ github.event_name == 'pull_request' && (needs.build-demo-swift.result != 'success' || needs.build-demo-objc.result != 'success')}} with: method: chat.postMessage token: "${{ secrets.SLACK_MESSENGER_APP_TOKEN }}" From 6648cfd19152f97a808e2e8d4f6d708d57e5c861 Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Wed, 30 Sep 2026 20:33:09 +0300 Subject: [PATCH 06/23] Clean up resundant comments --- Source/Misc/AppTrackingTransparency.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Source/Misc/AppTrackingTransparency.swift b/Source/Misc/AppTrackingTransparency.swift index c9d18ac..9d1bc87 100644 --- a/Source/Misc/AppTrackingTransparency.swift +++ b/Source/Misc/AppTrackingTransparency.swift @@ -42,8 +42,6 @@ } #endif - // The SDK requires iOS 15+, where AppTrackingTransparency is always available, so the - // pre-iOS 14 `ASIdentifierManager.isAdvertisingTrackingEnabled` fallback no longer applies. #if canImport(AppTrackingTransparency) return trackingStatus == .authorized #else From 0b4b4e176fb027ac57432dda4af7dddb3a3bf9e1 Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Wed, 30 Sep 2026 20:48:24 +0300 Subject: [PATCH 07/23] Remove availability checks below iOS 15 --- Source/Core/EdgeAPI.swift | 21 +--- Source/Core/OptableIdentifierEncoder.swift | 29 +---- Source/Misc/AppTrackingTransparency.swift | 119 +++++++----------- .../RangeReplaceableCollection+Compat.swift | 22 ---- Source/OptableSDK.swift | 19 +-- Tests/Integration/OptableSDKTests.swift | 4 - ...SDKHelpersIdentifiersEnrichmentTests.swift | 2 - 7 files changed, 58 insertions(+), 158 deletions(-) delete mode 100644 Source/Misc/RangeReplaceableCollection+Compat.swift diff --git a/Source/Core/EdgeAPI.swift b/Source/Core/EdgeAPI.swift index d3da536..b0d5900 100644 --- a/Source/Core/EdgeAPI.swift +++ b/Source/Core/EdgeAPI.swift @@ -118,25 +118,8 @@ extension EdgeAPI { completionHandler(data, response, error) return } - if #available(iOS 13.0, *) { - if let passport = res.value(forHTTPHeaderField: self.kPassportHeader) { - self.storage.setPassport(passport) - } - } else { - // In older versions of iOS, we have to resort searching through headers via res.allHeaderFields - // Unlike res.value(forHTTPHeaderField:...) which was introduced in iOS 13.0, allHeaderFields is - // case-sensitive, so we need to take special care to perform a case-INsensitive search: - for (key, value) in res.allHeaderFields { - if let header = key as? String { - let result: ComparisonResult = header.compare(self.kPassportHeader, options: NSString.CompareOptions.caseInsensitive) - if result == .orderedSame { - if let pp = value as? String { - self.storage.setPassport(pp) - break - } - } - } - } + if let passport = res.value(forHTTPHeaderField: self.kPassportHeader) { + self.storage.setPassport(passport) } completionHandler(data, response, error) } diff --git a/Source/Core/OptableIdentifierEncoder.swift b/Source/Core/OptableIdentifierEncoder.swift index 0590761..8a65694 100644 --- a/Source/Core/OptableIdentifierEncoder.swift +++ b/Source/Core/OptableIdentifierEncoder.swift @@ -5,11 +5,8 @@ // Copyright © 2026 Optable Technologies, Inc. All rights reserved. // -import CommonCrypto +import CryptoKit import Foundation -#if canImport(CryptoKit) - import CryptoKit -#endif // MARK: - OptableIdentifierEncoder enum OptableIdentifierEncoder { @@ -196,25 +193,9 @@ enum OptableIdentifierEncoder { } private static func sha256(data: Data) -> String { - #if canImport(CryptoKit) - if #available(iOS 13.0, *) { - return SHA256 - .hash(data: data) - .compactMap({ String(format: "%02x", $0) }) - .joined() - } - #endif - - return cchash(data) - } - - private static func cchash(_ input: Data) -> String { - var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) - input.withUnsafeBytes { bytes in - _ = CC_SHA256(bytes.baseAddress, CC_LONG(input.count), &digest) - } - return digest.makeIterator().compactMap { - String(format: "%02x", $0) - }.joined() + SHA256 + .hash(data: data) + .compactMap({ String(format: "%02x", $0) }) + .joined() } } diff --git a/Source/Misc/AppTrackingTransparency.swift b/Source/Misc/AppTrackingTransparency.swift index 9d1bc87..0410f5e 100644 --- a/Source/Misc/AppTrackingTransparency.swift +++ b/Source/Misc/AppTrackingTransparency.swift @@ -8,11 +8,7 @@ #if canImport(AdSupport) import AdSupport - - #if canImport(AppTrackingTransparency) - import AppTrackingTransparency - #endif - + import AppTrackingTransparency import Foundation enum ATT { @@ -42,11 +38,7 @@ } #endif - #if canImport(AppTrackingTransparency) - return trackingStatus == .authorized - #else - return false - #endif + return trackingStatus == .authorized } // MARK: attAvailable @@ -62,86 +54,69 @@ } #endif - if #available(iOS 14, *) { - return true - } else { - return false - } + return true } - #if canImport(AppTrackingTransparency) + // MARK: canAuthorize - // MARK: canAuthorize + #if DEBUG + static var canAuthorize_DebugOverride: Bool? + #endif + static var canAuthorize: Bool { #if DEBUG - @available(iOS 14, *) - static var canAuthorize_DebugOverride: Bool? + if let override = canAuthorize_DebugOverride { + return override + } #endif - static var canAuthorize: Bool { - if #available(iOS 14, *) { - #if DEBUG - if let override = canAuthorize_DebugOverride { - return override - } - #endif - - return ATTrackingManager.trackingAuthorizationStatus == .notDetermined - } else { - return false - } - } + return ATTrackingManager.trackingAuthorizationStatus == .notDetermined + } + + // MARK: trackingStatus - // MARK: trackingStatus + #if DEBUG + static var trackingStatus_DebugOverride: ATTrackingManager.AuthorizationStatus? + #endif + static var trackingStatus: ATTrackingManager.AuthorizationStatus { #if DEBUG - @available(iOS 14, *) - static var trackingStatus_DebugOverride: ATTrackingManager.AuthorizationStatus? + return trackingStatus_DebugOverride ?? ATTrackingManager.trackingAuthorizationStatus + #else + return ATTrackingManager.trackingAuthorizationStatus #endif + } - @available(iOS 14, *) - static var trackingStatus: ATTrackingManager.AuthorizationStatus { - #if DEBUG - return trackingStatus_DebugOverride ?? ATTrackingManager.trackingAuthorizationStatus - #else - return ATTrackingManager.trackingAuthorizationStatus - #endif - } + // MARK: RequestAuthorization - // MARK: RequestAuthorization - - @available(iOS 14, *) - static func requestATTAuthorization(completion: ((Bool) -> Void)? = nil) { - #if DEBUG - if let override = trackingStatus_DebugOverride { - completion?(override == .authorized) - return - } - #endif - - ATTrackingManager.requestTrackingAuthorization { status in - switch status { - case .authorized: - completion?(true) - case .denied, .notDetermined, .restricted: - completion?(false) - @unknown default: - completion?(true) - } + static func requestATTAuthorization(completion: ((Bool) -> Void)? = nil) { + #if DEBUG + if let override = trackingStatus_DebugOverride { + completion?(override == .authorized) + return } - } + #endif - @available(iOS 14, *) - @discardableResult - static func requestATTAuthorization() async -> Bool { - await withCheckedContinuation { continuation in - requestATTAuthorization { isAuthorized in - continuation.resume(returning: isAuthorized) - } + ATTrackingManager.requestTrackingAuthorization { status in + switch status { + case .authorized: + completion?(true) + case .denied, .notDetermined, .restricted: + completion?(false) + @unknown default: + completion?(true) } } + } - #endif + @discardableResult + static func requestATTAuthorization() async -> Bool { + await withCheckedContinuation { continuation in + requestATTAuthorization { isAuthorized in + continuation.resume(returning: isAuthorized) + } + } + } } #endif diff --git a/Source/Misc/RangeReplaceableCollection+Compat.swift b/Source/Misc/RangeReplaceableCollection+Compat.swift deleted file mode 100644 index 329a242..0000000 --- a/Source/Misc/RangeReplaceableCollection+Compat.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// RangeReplaceableCollection+Compat.swift -// OptableSDK -// -// Copyright © 2026 Optable Technologies, Inc. All rights reserved. -// - -import Foundation -import SwiftUI - -extension RangeReplaceableCollection where Self: MutableCollection, Index == Int { - mutating func removeCompat(atOffsets offsets: IndexSet) { - if #available(iOS 13.0, *) { - remove(atOffsets: offsets) - } else { - // Remove from highest index to lowest to avoid shifting issues - for index in offsets.sorted(by: >) { - remove(at: index) - } - } - } -} diff --git a/Source/OptableSDK.swift b/Source/OptableSDK.swift index 308213f..23b315d 100644 --- a/Source/OptableSDK.swift +++ b/Source/OptableSDK.swift @@ -101,7 +101,6 @@ public extension OptableSDK { Instead of completion callbacks, results are returned via async/await. */ - @available(iOS 13.0, *) func identify(_ ids: [OptableIdentifier]) async throws -> HTTPURLResponse { return try await withCheckedThrowingContinuation({ [unowned self] continuation in do { @@ -164,7 +163,6 @@ public extension OptableSDK { Instead of completion callbacks, results are returned via async/await. */ - @available(iOS 13.0, *) func targeting(_ ids: [OptableIdentifier]? = nil, hids: [OptableIdentifier]? = nil) async throws -> OptableTargeting { return try await withCheckedThrowingContinuation({ [unowned self] continuation in do { @@ -197,7 +195,6 @@ public extension OptableSDK { Instead of completion callbacks, results are returned via async/await. */ - @available(iOS 13.0, *) func witness(event: String, properties: [String: Any]) async throws -> HTTPURLResponse { return try await withCheckedThrowingContinuation({ [unowned self] continuation in do { @@ -249,7 +246,6 @@ public extension OptableSDK { Instead of completion callbacks, results are returned via async/await. */ - @available(iOS 13.0, *) func profile(traits: [String: Any], id: String? = nil, neighbors: [String]? = nil) async throws -> OptableTargeting { return try await withCheckedThrowingContinuation({ [unowned self] continuation in do { @@ -432,18 +428,11 @@ extension OptableSDK { ATT.advertisingIdentifier != UUID(uuid: uuid_t(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) { let systemIDFA = ATT.advertisingIdentifier.uuidString - var idfaMatchingSystemIdxs: [Int] = [] - - for idx in ids.indices { - if case let .appleIDFA(value) = ids[idx] { - if value == systemIDFA { - idfaMatchingSystemIdxs.append(idx) - } - } - } - // Remove all matching systemIDFA (deduplicate) - ids.removeCompat(atOffsets: IndexSet(idfaMatchingSystemIdxs)) + ids.removeAll { id in + if case let .appleIDFA(value) = id { return value == systemIDFA } + return false + } // Prepend all identifiers with systemIDFA ids.insert(.appleIDFA(systemIDFA), at: ids.startIndex) diff --git a/Tests/Integration/OptableSDKTests.swift b/Tests/Integration/OptableSDKTests.swift index 6dcb627..fbcfce5 100644 --- a/Tests/Integration/OptableSDKTests.swift +++ b/Tests/Integration/OptableSDKTests.swift @@ -24,7 +24,6 @@ class OptableSDKTests: XCTestCase { } // MARK: Identify - @available(iOS 13.0, *) func test_identify_async() async throws { let response = try await sdk.identify([.emailAddress("test@test.com")]) XCTAssert(response.allHeaderFields.keys.contains("x-optable-visitor")) @@ -52,7 +51,6 @@ class OptableSDKTests: XCTestCase { } // MARK: Target - @available(iOS 13.0, *) func test_target_async() async throws { let response = try await sdk.targeting([.emailAddress("test@test.com")]) XCTAssert(response.targetingData.keys.isEmpty == false) @@ -102,7 +100,6 @@ class OptableSDKTests: XCTestCase { } // MARK: Witness - @available(iOS 13.0, *) func test_witness_async() async throws { let response: HTTPURLResponse = try await sdk.witness(event: "test", properties: ["integration-test-witness": "integration-test-witness-value"]) XCTAssert(response.allHeaderFields.keys.contains("x-optable-visitor")) @@ -130,7 +127,6 @@ class OptableSDKTests: XCTestCase { } // MARK: Profile - @available(iOS 13.0, *) func test_profile_async() async throws { let response = try await sdk.profile(traits: ["integration-test-profile": "integration-test-profile-value"]) XCTAssert(response.targetingData.keys.isEmpty == false) diff --git a/Tests/Unit/OptableSDKHelpersIdentifiersEnrichmentTests.swift b/Tests/Unit/OptableSDKHelpersIdentifiersEnrichmentTests.swift index 085cb4a..ea53d53 100644 --- a/Tests/Unit/OptableSDKHelpersIdentifiersEnrichmentTests.swift +++ b/Tests/Unit/OptableSDKHelpersIdentifiersEnrichmentTests.swift @@ -63,7 +63,6 @@ class OptableSDKHelpersIdentifiersEnrichmentTests: XCTestCase { } } - @available(iOS 14, *) func test_idfa_detection_enabled_enrich_system_idfa_same_as_user_idfa_prepend() { ATT.advertisingIdentifierAvailable_DebugOverride = true ATT.advertisingIdentifier_DebugOverride = UUID(uuidString: systemIDFA) @@ -94,7 +93,6 @@ class OptableSDKHelpersIdentifiersEnrichmentTests: XCTestCase { } } - @available(iOS 14, *) func test_idfa_detection_enabled_enrich_system_idfa_user_idfa_persist() { ATT.advertisingIdentifierAvailable_DebugOverride = true ATT.advertisingIdentifier_DebugOverride = UUID(uuidString: systemIDFA) From fa89ca153a2e3d971658b83ff2729e17232ad57f Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Thu, 1 Oct 2026 09:57:13 +0300 Subject: [PATCH 08/23] Add Locked box and use it for ATT debug overrides --- Source/Misc/AppTrackingTransparency.swift | 35 +++++++++++++++++++---- Source/Misc/Locked.swift | 28 ++++++++++++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 Source/Misc/Locked.swift diff --git a/Source/Misc/AppTrackingTransparency.swift b/Source/Misc/AppTrackingTransparency.swift index 0410f5e..f87ffc6 100644 --- a/Source/Misc/AppTrackingTransparency.swift +++ b/Source/Misc/AppTrackingTransparency.swift @@ -11,11 +11,18 @@ import AppTrackingTransparency import Foundation + /// The `*_DebugOverride` knobs are test-only hooks. Each one is backed by a `Locked` box, which keeps + /// the static accessors concurrency-safe under Swift 6 (plain `static var`s are rejected there). enum ATT { // MARK: advertisingIdentifier #if DEBUG - static var advertisingIdentifier_DebugOverride: UUID? + private static let _advertisingIdentifier_DebugOverride = Locked(nil) + static var advertisingIdentifier_DebugOverride: UUID? { + get { _advertisingIdentifier_DebugOverride.withLock { $0 } } + set { _advertisingIdentifier_DebugOverride.withLock { $0 = newValue } } + } + static var advertisingIdentifier: UUID { advertisingIdentifier_DebugOverride ?? ASIdentifierManager.shared().advertisingIdentifier } @@ -28,7 +35,11 @@ // MARK: advertisingIdentifierAvailable #if DEBUG - static var advertisingIdentifierAvailable_DebugOverride: Bool? + private static let _advertisingIdentifierAvailable_DebugOverride = Locked(nil) + static var advertisingIdentifierAvailable_DebugOverride: Bool? { + get { _advertisingIdentifierAvailable_DebugOverride.withLock { $0 } } + set { _advertisingIdentifierAvailable_DebugOverride.withLock { $0 = newValue } } + } #endif static var advertisingIdentifierAvailable: Bool { @@ -44,7 +55,11 @@ // MARK: attAvailable #if DEBUG - static var attAvailable_DebugOverride: Bool? + private static let _attAvailable_DebugOverride = Locked(nil) + static var attAvailable_DebugOverride: Bool? { + get { _attAvailable_DebugOverride.withLock { $0 } } + set { _attAvailable_DebugOverride.withLock { $0 = newValue } } + } #endif static var attAvailable: Bool { @@ -60,7 +75,11 @@ // MARK: canAuthorize #if DEBUG - static var canAuthorize_DebugOverride: Bool? + private static let _canAuthorize_DebugOverride = Locked(nil) + static var canAuthorize_DebugOverride: Bool? { + get { _canAuthorize_DebugOverride.withLock { $0 } } + set { _canAuthorize_DebugOverride.withLock { $0 = newValue } } + } #endif static var canAuthorize: Bool { @@ -76,7 +95,11 @@ // MARK: trackingStatus #if DEBUG - static var trackingStatus_DebugOverride: ATTrackingManager.AuthorizationStatus? + private static let _trackingStatus_DebugOverride = Locked(nil) + static var trackingStatus_DebugOverride: ATTrackingManager.AuthorizationStatus? { + get { _trackingStatus_DebugOverride.withLock { $0 } } + set { _trackingStatus_DebugOverride.withLock { $0 = newValue } } + } #endif static var trackingStatus: ATTrackingManager.AuthorizationStatus { @@ -89,7 +112,7 @@ // MARK: RequestAuthorization - static func requestATTAuthorization(completion: ((Bool) -> Void)? = nil) { + static func requestATTAuthorization(completion: (@Sendable (Bool) -> Void)? = nil) { #if DEBUG if let override = trackingStatus_DebugOverride { completion?(override == .authorized) diff --git a/Source/Misc/Locked.swift b/Source/Misc/Locked.swift new file mode 100644 index 0000000..13c34e3 --- /dev/null +++ b/Source/Misc/Locked.swift @@ -0,0 +1,28 @@ +// +// Locked.swift +// OptableSDK +// +// Copyright © 2026 Optable Technologies, Inc. All rights reserved. +// + +import Foundation + +/// A value guarded by a lock, so it can be shared across threads under Swift 6 strict concurrency. +/// Stands in for `Synchronization.Mutex` (iOS 18) and `OSAllocatedUnfairLock` (iOS 16). +final class Locked { + private let lock = NSLock() + private var value: Value + + init(_ value: Value) { + self.value = value + } + + /// Runs `body` with exclusive access to the wrapped value and returns its result. + func withLock(_ body: (inout Value) throws -> T) rethrows -> T { + lock.lock() + defer { lock.unlock() } + return try body(&value) + } +} + +extension Locked: @unchecked Sendable where Value: Sendable {} From 914a97b1ed0773cb61e225dedbc73f39a376bcd5 Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Thu, 1 Oct 2026 09:57:13 +0300 Subject: [PATCH 09/23] Resolve WebView user agent on the main actor --- Source/Core/EdgeAPI.swift | 43 ++++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/Source/Core/EdgeAPI.swift b/Source/Core/EdgeAPI.swift index b0d5900..9e3246b 100644 --- a/Source/Core/EdgeAPI.swift +++ b/Source/Core/EdgeAPI.swift @@ -22,19 +22,28 @@ final class EdgeAPI { var storage: LocalStorage var config: OptableConfig - var userAgent: String? + /// The `User-Agent` sent with every request: `config.customUserAgent` when set, otherwise the WebView's + /// user agent, resolved asynchronously on the main actor at init (nil until it arrives). + var userAgent: String? { + get { userAgentStore.withLock { $0 } } + set { userAgentStore.withLock { $0 = newValue } } + } + + private let userAgentStore: Locked private lazy var jsonEncoder = JSONEncoder() init(_ config: OptableConfig) { self.config = config self.storage = LocalStorage(config) + self.userAgentStore = Locked(config.customUserAgent) + if config.customUserAgent == nil { - self.resolveUserAgent { realUserAgent in - self.userAgent = realUserAgent + let userAgentStore = self.userAgentStore + Task { @MainActor in + let realUserAgent = await EdgeAPI.resolveWebViewUserAgent() + userAgentStore.withLock { $0 = realUserAgent } } - } else { - self.userAgent = config.customUserAgent } } @@ -128,9 +137,10 @@ extension EdgeAPI { // MARK: - Private extension EdgeAPI { - private func resolveUserAgent(callback: @escaping (_ useragent: String) -> Void) { - var wkUserAgent = "" - let myGroup = DispatchGroup() + /// Reads `navigator.userAgent` from a hidden, throwaway WKWebView attached to the key window. + /// Returns an empty string when the WebView cannot provide one. + @MainActor + private static func resolveWebViewUserAgent() async -> String { let window = UIApplication.shared.connectedScenes .compactMap { ($0 as? UIWindowScene)?.keyWindow } .first @@ -138,20 +148,15 @@ extension EdgeAPI { webView.isHidden = true window?.addSubview(webView) - myGroup.enter() - - webView.loadHTMLString("", baseURL: nil) - webView.evaluateJavaScript("navigator.userAgent", completionHandler: { (userAgent: Any?, error: Error?) in - if let userAgent = userAgent as? String { - wkUserAgent = userAgent - } + defer { webView.stopLoading() webView.removeFromSuperview() - myGroup.leave() - }) - myGroup.notify(queue: .main) { - callback(wkUserAgent) } + + webView.loadHTMLString("", baseURL: nil) + + let userAgent = try? await webView.evaluateJavaScript("navigator.userAgent") + return (userAgent as? String) ?? "" } func resolveHeaders() -> HTTPHeaders { From 1088c1d24c2b286de8d50d6bfe92f874917c1b5e Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Thu, 1 Oct 2026 09:57:13 +0300 Subject: [PATCH 10/23] Enable Swift 6 mode, mark OptableTargeting Sendable --- OptableSDK.xcodeproj/project.pbxproj | 4 ++-- Source/Public/OptableTargeting.swift | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/OptableSDK.xcodeproj/project.pbxproj b/OptableSDK.xcodeproj/project.pbxproj index 12615ab..b676310 100644 --- a/OptableSDK.xcodeproj/project.pbxproj +++ b/OptableSDK.xcodeproj/project.pbxproj @@ -388,7 +388,7 @@ SUPPORTS_MACCATALYST = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -419,7 +419,7 @@ PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; diff --git a/Source/Public/OptableTargeting.swift b/Source/Public/OptableTargeting.swift index f342c6c..e47ca1c 100644 --- a/Source/Public/OptableTargeting.swift +++ b/Source/Public/OptableTargeting.swift @@ -33,6 +33,11 @@ public class OptableTargeting: NSObject { } } +// MARK: - Sendable +/// Every stored property is a `let`, and the dictionaries only hold the immutable Foundation values +/// produced by `JSONSerialization`, so instances can safely cross threads (e.g. resume a continuation). +extension OptableTargeting: @unchecked Sendable {} + // MARK: - Helpers extension OptableTargeting { From df4dc824e4acbfe97a68ccabd9614ffc279f008c Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Thu, 1 Oct 2026 10:50:45 +0300 Subject: [PATCH 11/23] Make completion handlers Sendable, lock-guard delegate and storage --- Source/Core/EdgeAPI.swift | 18 +++++++++--------- Source/Core/LocalStorage.swift | 11 +++++++---- Source/OptableSDK.swift | 28 +++++++++++++++++++--------- 3 files changed, 35 insertions(+), 22 deletions(-) diff --git a/Source/Core/EdgeAPI.swift b/Source/Core/EdgeAPI.swift index 9e3246b..2f3a19b 100644 --- a/Source/Core/EdgeAPI.swift +++ b/Source/Core/EdgeAPI.swift @@ -17,10 +17,10 @@ import WebKit */ final class EdgeAPI { - private let kPassportHeader: String = "X-Optable-Visitor" + private static let kPassportHeader: String = "X-Optable-Visitor" - var storage: LocalStorage - var config: OptableConfig + let storage: LocalStorage + let config: OptableConfig /// The `User-Agent` sent with every request: `config.customUserAgent` when set, otherwise the WebView's /// user agent, resolved asynchronously on the main actor at init (nil until it arrives). @@ -31,7 +31,7 @@ final class EdgeAPI { private let userAgentStore: Locked - private lazy var jsonEncoder = JSONEncoder() + private let jsonEncoder = JSONEncoder() init(_ config: OptableConfig) { self.config = config @@ -117,8 +117,8 @@ final class EdgeAPI { // MARK: - Dispatch extension EdgeAPI { - func dispatch(request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask { - return URLSession.shared.dataTask(with: request) { data, response, error in + func dispatch(request: URLRequest, completionHandler: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask { + return URLSession.shared.dataTask(with: request) { [storage] data, response, error in guard let res = response as? HTTPURLResponse, error == nil else { completionHandler(data, response, error) return @@ -127,8 +127,8 @@ extension EdgeAPI { completionHandler(data, response, error) return } - if let passport = res.value(forHTTPHeaderField: self.kPassportHeader) { - self.storage.setPassport(passport) + if let passport = res.value(forHTTPHeaderField: Self.kPassportHeader) { + storage.setPassport(passport) } completionHandler(data, response, error) } @@ -177,7 +177,7 @@ extension EdgeAPI { } if let passport: String = storage.getPassport() { - headers[kPassportHeader] = passport + headers[Self.kPassportHeader] = passport } return headers diff --git a/Source/Core/LocalStorage.swift b/Source/Core/LocalStorage.swift index 5ba0d73..81fe848 100644 --- a/Source/Core/LocalStorage.swift +++ b/Source/Core/LocalStorage.swift @@ -10,17 +10,20 @@ import Foundation /** The OptableSDK keeps some state in UserDefaults (https://developer.apple.com/documentation/foundation/userdefaults), a key/value store persisted across launches of the app. The state is therefore unique to the app+device, and not globally unique to the app across devices. + + `@unchecked Sendable`: `UserDefaults` is thread-safe, targeting reads and writes are serialized by `lock`, + and the remaining state is immutable (`config` is only read). */ -final class LocalStorage: NSObject { +final class LocalStorage: NSObject, @unchecked Sendable { private let targetingDataKey: String private let gamTargetingKeywordsKey: String private let ortb2Key: String private let id5SignatureKey: String let keyPfx: String = "OPTABLE" - var passportKey: String - var targetingKey: String - var targetingStoredAtKey: String + let passportKey: String + let targetingKey: String + let targetingStoredAtKey: String private let config: OptableConfig diff --git a/Source/OptableSDK.swift b/Source/OptableSDK.swift index 23b315d..e2627cd 100644 --- a/Source/OptableSDK.swift +++ b/Source/OptableSDK.swift @@ -47,7 +47,12 @@ public protocol OptableDelegate { @objc public class OptableSDK: NSObject { @objc - public var delegate: OptableDelegate? + public var delegate: OptableDelegate? { + get { delegateStore.withLock { $0 } } + set { delegateStore.withLock { $0 = newValue } } + } + + private let delegateStore = Locked(nil) let config: OptableConfig let api: EdgeAPI @@ -72,6 +77,11 @@ public class OptableSDK: NSObject { } } +// MARK: - Sendable +/// `@unchecked`: `delegate` is lock-guarded, `api` holds no mutable state beyond its lock-guarded user agent, +/// and `config` is the integrator's object, which the SDK only reads. +extension OptableSDK: @unchecked Sendable {} + // MARK: - Identify public extension OptableSDK { /** @@ -91,7 +101,7 @@ public extension OptableSDK { } ``` */ - func identify(_ ids: [OptableIdentifier], completion: @escaping (Result) -> Void) throws { + func identify(_ ids: [OptableIdentifier], completion: @escaping @Sendable (Result) -> Void) throws { try _identify(ids, completion: completion) } @@ -137,7 +147,7 @@ public extension OptableSDK { On success, the result is cached in client storage. You can read it using targetingFromCache() and clear it using targetingClearCache(). */ - func targeting(_ ids: [OptableIdentifier]? = nil, hids: [OptableIdentifier]? = nil, completion: @escaping (Result) -> Void) throws { + func targeting(_ ids: [OptableIdentifier]? = nil, hids: [OptableIdentifier]? = nil, completion: @escaping @Sendable (Result) -> Void) throws { try _targeting(ids: ids, hids: hids, completion: completion) } @@ -185,7 +195,7 @@ public extension OptableSDK { - .success(HTTPURLResponse) on success - .failure(Error) on failure */ - func witness(event: String, properties: [String: Any], _ completion: @escaping (Result) -> Void) throws { + func witness(event: String, properties: [String: Any], _ completion: @escaping @Sendable (Result) -> Void) throws { try _witness(event: event, properties: properties, completion: completion) } @@ -236,7 +246,7 @@ public extension OptableSDK { The resulting OptableTargeting is also cached for targetingFromCache(). */ - func profile(traits: [String: Any], id: String? = nil, neighbors: [String]? = nil, _ completion: @escaping (Result) -> Void) throws { + func profile(traits: [String: Any], id: String? = nil, neighbors: [String]? = nil, _ completion: @escaping @Sendable (Result) -> Void) throws { try _profile(traits: traits, id: id, neighbors: neighbors, completion: completion) } @@ -298,7 +308,7 @@ public extension OptableSDK { // MARK: - Internal extension OptableSDK { - func _identify(_ ids: [OptableIdentifier], completion: @escaping (Result) -> Void) throws { + func _identify(_ ids: [OptableIdentifier], completion: @escaping @Sendable (Result) -> Void) throws { var ids = ids enrichIfNeeded(ids: &ids) @@ -325,7 +335,7 @@ extension OptableSDK { }).resume() } - func _targeting(ids: [OptableIdentifier]?, hids: [OptableIdentifier]?, completion: @escaping (Result) -> Void) throws { + func _targeting(ids: [OptableIdentifier]?, hids: [OptableIdentifier]?, completion: @escaping @Sendable (Result) -> Void) throws { var ids = ids ?? [] var hids = hids ?? [] @@ -365,7 +375,7 @@ extension OptableSDK { }).resume() } - func _witness(event: String, properties: [String: Any], completion: @escaping (Result) -> Void) throws { + func _witness(event: String, properties: [String: Any], completion: @escaping @Sendable (Result) -> Void) throws { guard let request = try api.witness(event: event, properties: properties) else { throw OptableError.witness("Failed to create witness request") } @@ -388,7 +398,7 @@ extension OptableSDK { }).resume() } - func _profile(traits: [String: Any], id: String?, neighbors: [String]?, completion: @escaping (Result) -> Void) throws { + func _profile(traits: [String: Any], id: String?, neighbors: [String]?, completion: @escaping @Sendable (Result) -> Void) throws { guard let request = try api.profile(traits: traits, id: id, neighbors: neighbors) else { throw OptableError.profile("Failed to create profile request") } From 1b090ffd1857a54834e8b3775d87677c5500ed13 Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Thu, 1 Oct 2026 11:01:12 +0300 Subject: [PATCH 12/23] Make LocalStorage checked Sendable, pass cacheTTL into getTargeting --- Source/Core/LocalStorage.swift | 24 +++++---- Source/OptableSDK.swift | 2 +- Tests/Integration/OptableSDKTests.swift | 17 +++++++ Tests/Unit/LocalStorageTests.swift | 66 +++++++++++-------------- 4 files changed, 58 insertions(+), 51 deletions(-) diff --git a/Source/Core/LocalStorage.swift b/Source/Core/LocalStorage.swift index 81fe848..7f719f2 100644 --- a/Source/Core/LocalStorage.swift +++ b/Source/Core/LocalStorage.swift @@ -11,10 +11,10 @@ import Foundation /** The OptableSDK keeps some state in UserDefaults (https://developer.apple.com/documentation/foundation/userdefaults), a key/value store persisted across launches of the app. The state is therefore unique to the app+device, and not globally unique to the app across devices. - `@unchecked Sendable`: `UserDefaults` is thread-safe, targeting reads and writes are serialized by `lock`, - and the remaining state is immutable (`config` is only read). + `Sendable`: every stored property is immutable, `UserDefaults` is thread-safe, and targeting reads and writes + are serialized by `lock`. */ -final class LocalStorage: NSObject, @unchecked Sendable { +final class LocalStorage: NSObject, Sendable { private let targetingDataKey: String private let gamTargetingKeywordsKey: String private let ortb2Key: String @@ -24,9 +24,7 @@ final class LocalStorage: NSObject, @unchecked Sendable { let passportKey: String let targetingKey: String let targetingStoredAtKey: String - - private let config: OptableConfig - + private let lock = NSLock() init(_ config: OptableConfig) { @@ -36,8 +34,6 @@ final class LocalStorage: NSObject, @unchecked Sendable { .data(using: .utf8)? .base64EncodedString() - self.config = config - self.passportKey = self.keyPfx + "_PASS_" + (base64Key ?? "UNKNOWN") self.targetingKey = self.keyPfx + "_TGT_" + (base64Key ?? "UNKNOWN") @@ -56,13 +52,15 @@ final class LocalStorage: NSObject, @unchecked Sendable { UserDefaults.standard.set(passport, forKey: passportKey) } - func getTargeting() -> OptableTargeting? { + /// Returns the stored targeting entry while it is younger than `cacheTTL`. An expired entry is removed + /// from storage and reported as absent. + func getTargeting(cacheTTL: TimeInterval) -> OptableTargeting? { lock.synchronized { guard let targetingData = UserDefaults.standard.object(forKey: targetingDataKey) as? [String: Any] else { return nil } - guard isTargetingFresh() else { + guard isTargetingFresh(cacheTTL: cacheTTL) else { removeTargetingEntry() return nil } @@ -108,8 +106,8 @@ final class LocalStorage: NSObject, @unchecked Sendable { UserDefaults.standard.removeObject(forKey: targetingStoredAtKey) } - /// Whether the stored targeting entry was fetched recently enough to still be served, per `config.cacheTTL`. The caller must hold `lock`. - private func isTargetingFresh() -> Bool { + /// Whether the stored targeting entry was fetched recently enough to still be served, per `cacheTTL`. The caller must hold `lock`. + private func isTargetingFresh(cacheTTL: TimeInterval) -> Bool { // NOTE: A missing timestamp means the entry predates cache expiry support, so its age is unknowable - treat it as expired. guard let storedAt = UserDefaults.standard.object(forKey: targetingStoredAtKey) as? TimeInterval else { return false @@ -117,6 +115,6 @@ final class LocalStorage: NSObject, @unchecked Sendable { let age = Date().timeIntervalSince1970 - storedAt - return age >= 0 && age < config.cacheTTL + return age >= 0 && age < cacheTTL } } diff --git a/Source/OptableSDK.swift b/Source/OptableSDK.swift index e2627cd..de1a907 100644 --- a/Source/OptableSDK.swift +++ b/Source/OptableSDK.swift @@ -156,7 +156,7 @@ public extension OptableSDK { /// reported as absent and is cleared from storage. @objc func targetingFromCache() -> OptableTargeting? { - return self.api.storage.getTargeting() + return self.api.storage.getTargeting(cacheTTL: config.cacheTTL) } /// targetingClearCache() clears any previously cached targeting data. diff --git a/Tests/Integration/OptableSDKTests.swift b/Tests/Integration/OptableSDKTests.swift index fbcfce5..f54a184 100644 --- a/Tests/Integration/OptableSDKTests.swift +++ b/Tests/Integration/OptableSDKTests.swift @@ -99,6 +99,23 @@ class OptableSDKTests: XCTestCase { XCTAssertNil(sdk.targetingFromCache()) } + func test_targetingFromCache_usesCurrentConfigCacheTTL() { + let config = OptableConfig(tenant: T.api.tenant.prebidtest, originSlug: T.api.slug.iosSDK, cacheTTL: 60) + let sdk = OptableSDK(config: config) + + // Seed storage with an entry that is 90 seconds old + sdk.api.storage.setTargeting(OptableTargeting(optableTargeting: ["foo": "bar"])) + UserDefaults.standard.setValue(Date().timeIntervalSince1970 - 90, forKey: sdk.api.storage.targetingStoredAtKey) + + // Raising the TTL after init keeps the entry readable + config.cacheTTL = 120 + XCTAssertNotNil(sdk.targetingFromCache()) + + // Lowering it below the entry's age expires it + config.cacheTTL = 60 + XCTAssertNil(sdk.targetingFromCache()) + } + // MARK: Witness func test_witness_async() async throws { let response: HTTPURLResponse = try await sdk.witness(event: "test", properties: ["integration-test-witness": "integration-test-witness-value"]) diff --git a/Tests/Unit/LocalStorageTests.swift b/Tests/Unit/LocalStorageTests.swift index 9f87e1d..61c8215 100644 --- a/Tests/Unit/LocalStorageTests.swift +++ b/Tests/Unit/LocalStorageTests.swift @@ -29,7 +29,7 @@ class LocalStorageTests: XCTestCase { localStorage.setTargeting(optableTargetingFull) - let readTargeting = localStorage.getTargeting() + let readTargeting = localStorage.getTargeting(cacheTTL: OptableConfig.defaultCacheTTL) XCTAssert(readTargeting != nil) XCTAssert(readTargeting!.targetingData as NSDictionary == kOptableTargeting) XCTAssert(readTargeting!.gamTargetingKeywords as? NSDictionary == kGamTargetingKeywords) @@ -45,7 +45,7 @@ class LocalStorageTests: XCTestCase { localStorage.setTargeting(optableTargetingFull) - let readTargeting = localStorage.getTargeting() + let readTargeting = localStorage.getTargeting(cacheTTL: OptableConfig.defaultCacheTTL) XCTAssert(readTargeting != nil) XCTAssert(readTargeting!.targetingData as NSDictionary == kOptableTargeting) XCTAssert(readTargeting!.gamTargetingKeywords as? NSDictionary == nil) @@ -61,7 +61,7 @@ class LocalStorageTests: XCTestCase { localStorage.setTargeting(optableTargetingFull) - let readTargeting = localStorage.getTargeting() + let readTargeting = localStorage.getTargeting(cacheTTL: OptableConfig.defaultCacheTTL) XCTAssert(readTargeting != nil) XCTAssert(readTargeting!.targetingData as NSDictionary == kOptableTargeting) XCTAssert(readTargeting!.gamTargetingKeywords as? NSDictionary == kGamTargetingKeywords) @@ -95,94 +95,94 @@ class LocalStorageTests: XCTestCase { localStorage.clearTargeting() - XCTAssert(localStorage.getTargeting() == nil) + XCTAssert(localStorage.getTargeting(cacheTTL: OptableConfig.defaultCacheTTL) == nil) } // MARK: - Cache TTL func testTargetingIsReturnedWithinTTL() { - let storage = makeStorageWithStoredTargeting(cacheTTL: 60) + let storage = makeStorageWithStoredTargeting() setStoredAge(storage, to: 30) - XCTAssertNotNil(storage.getTargeting()) + XCTAssertNotNil(storage.getTargeting(cacheTTL: 60)) } func testTargetingIsNilPastTTL() { - let storage = makeStorageWithStoredTargeting(cacheTTL: 60) + let storage = makeStorageWithStoredTargeting() setStoredAge(storage, to: 61) - XCTAssertNil(storage.getTargeting()) + XCTAssertNil(storage.getTargeting(cacheTTL: 60)) } func testTargetingExpiresAtExactlyTTL() { - let storage = makeStorageWithStoredTargeting(cacheTTL: 60) + let storage = makeStorageWithStoredTargeting() setStoredAge(storage, to: 60) - XCTAssertNil(storage.getTargeting()) + XCTAssertNil(storage.getTargeting(cacheTTL: 60)) } func testZeroTTLDisablesCaching() { - let storage = makeStorageWithStoredTargeting(cacheTTL: 0) + let storage = makeStorageWithStoredTargeting() setStoredAge(storage, to: 0) - XCTAssertNil(storage.getTargeting()) + XCTAssertNil(storage.getTargeting(cacheTTL: 0)) } func testExpiredTargetingIsClearedFromStorage() { - let storage = makeStorageWithStoredTargeting(cacheTTL: 60) + let storage = makeStorageWithStoredTargeting() setStoredAge(storage, to: 61) - XCTAssertNil(storage.getTargeting()) + XCTAssertNil(storage.getTargeting(cacheTTL: 60)) setStoredAge(storage, to: 0) - XCTAssertNil(storage.getTargeting()) + XCTAssertNil(storage.getTargeting(cacheTTL: 60)) } func testTargetingWithoutStoredTimestampIsTreatedAsExpired() { - let storage = makeStorageWithStoredTargeting(cacheTTL: 60) + let storage = makeStorageWithStoredTargeting() UserDefaults.standard.removeObject(forKey: storage.targetingStoredAtKey) - XCTAssertNil(storage.getTargeting()) + XCTAssertNil(storage.getTargeting(cacheTTL: 60)) } func testTargetingIsNilWhenStoredInTheFuture() { - let storage = makeStorageWithStoredTargeting(cacheTTL: 60) + let storage = makeStorageWithStoredTargeting() setStoredAge(storage, to: -30) - XCTAssertNil(storage.getTargeting()) + XCTAssertNil(storage.getTargeting(cacheTTL: 60)) } func testDefaultTTLKeepsTargetingFreshJustUnderTwentyFourHours() { - let storage = makeStorageWithStoredTargeting(cacheTTL: nil) + let storage = makeStorageWithStoredTargeting() setStoredAge(storage, to: 24 * 60 * 60 - 60) - XCTAssertNotNil(storage.getTargeting()) + XCTAssertNotNil(storage.getTargeting(cacheTTL: OptableConfig.defaultCacheTTL)) } func testDefaultTTLExpiresTargetingPastTwentyFourHours() { - let storage = makeStorageWithStoredTargeting(cacheTTL: nil) + let storage = makeStorageWithStoredTargeting() setStoredAge(storage, to: 24 * 60 * 60 + 60) - XCTAssertNil(storage.getTargeting()) + XCTAssertNil(storage.getTargeting(cacheTTL: OptableConfig.defaultCacheTTL)) } // MARK: - Thread safety /** A stale read must never wipe an entry that was stored concurrently. - Without mutual exclusion, `getTargeting()` can judge the old entry expired, then lose the CPU to a + Without mutual exclusion, `getTargeting(cacheTTL:)` can judge the old entry expired, then lose the CPU to a `setTargeting(_:)` that stores a fresh one, then resume and clear it. Whichever order the two calls serialize in, a fresh entry must be readable afterwards. */ func testConcurrentReadOfExpiredEntryDoesNotWipeFreshWrite() { - let storage = makeStorageWithStoredTargeting(cacheTTL: 60) + let storage = makeStorageWithStoredTargeting() let freshTargeting = OptableTargeting( optableTargeting: kOptableTargeting as! [String: Any], gamTargetingKeywords: kGamTargetingKeywords as? [String: Any], @@ -195,12 +195,12 @@ class LocalStorageTests: XCTestCase { let group = DispatchGroup() let queue = DispatchQueue.global(qos: .userInitiated) - queue.async(group: group) { _ = storage.getTargeting() } + queue.async(group: group) { _ = storage.getTargeting(cacheTTL: 60) } queue.async(group: group) { storage.setTargeting(freshTargeting) } group.wait() - XCTAssertNotNil(storage.getTargeting(), "fresh entry was wiped by a concurrent stale read on iteration \(iteration)") - if storage.getTargeting() == nil { break } + XCTAssertNotNil(storage.getTargeting(cacheTTL: 60), "fresh entry was wiped by a concurrent stale read on iteration \(iteration)") + if storage.getTargeting(cacheTTL: 60) == nil { break } } } @@ -209,17 +209,9 @@ class LocalStorageTests: XCTestCase { Builds a LocalStorage with targeting already stored in it. Each call uses a unique tenant so that tests never share UserDefaults keys. - Passing a nil `cacheTTL` leaves the config default in place. */ - private func makeStorageWithStoredTargeting( - cacheTTL: TimeInterval?, - function: String = #function - ) -> LocalStorage { + private func makeStorageWithStoredTargeting(function: String = #function) -> LocalStorage { let config = OptableConfig(tenant: "tenant-\(function)", originSlug: "slug") - if let cacheTTL { - config.cacheTTL = cacheTTL - } - let storage = LocalStorage(config) storage.setTargeting( OptableTargeting( From 7c46c57dc1164cd3ff0f6d591427f45f6b1397f1 Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Thu, 1 Oct 2026 11:29:14 +0300 Subject: [PATCH 13/23] Declare Swift 6 in Package.swift and podspec --- OptableSDK.podspec | 2 +- Package.swift | 5 +++-- demo-ios-objc/Podfile.lock | 2 +- demo-ios-swift/Podfile.lock | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/OptableSDK.podspec b/OptableSDK.podspec index 6584541..f695841 100644 --- a/OptableSDK.podspec +++ b/OptableSDK.podspec @@ -15,7 +15,7 @@ Pod::Spec.new do |spec| spec.platform = :ios spec.ios.deployment_target = "15.0" - spec.swift_version = "5.0" + spec.swift_version = "6.0" spec.source = { :git => "https://github.com/Optable/optable-ios-sdk.git", :tag => "#{spec.version}" } spec.source_files = "Source/**/*.{h,m,swift}" diff --git a/Package.swift b/Package.swift index 3fbbd15..e0f16d0 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.5 +// swift-tools-version:6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -28,5 +28,6 @@ let package = Package( name: "OptableSDKTests", dependencies: ["OptableSDK"], path: "Tests"), - ] + ], + swiftLanguageModes: [.v6] ) diff --git a/demo-ios-objc/Podfile.lock b/demo-ios-objc/Podfile.lock index 44d9e11..2e99555 100644 --- a/demo-ios-objc/Podfile.lock +++ b/demo-ios-objc/Podfile.lock @@ -25,7 +25,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: Google-Mobile-Ads-SDK: 09d0868abe43b9f851b7b340a245679f0eafc96c GoogleUserMessagingPlatform: befe603da6501006420c206222acd449bba45a9c - OptableSDK: 127f749734ded99deff764f29bc1342b20153b2b + OptableSDK: d96e3936148f2e1c305573b628efc8299e47c097 PrebidMobile: ce3afbb56904a5e8b3f5f5aa0696474d2786a96b PODFILE CHECKSUM: 788f5abee95f41b7e281003e2cab30df58eb8547 diff --git a/demo-ios-swift/Podfile.lock b/demo-ios-swift/Podfile.lock index cd81e9f..56c0d8d 100644 --- a/demo-ios-swift/Podfile.lock +++ b/demo-ios-swift/Podfile.lock @@ -25,7 +25,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: Google-Mobile-Ads-SDK: 09d0868abe43b9f851b7b340a245679f0eafc96c GoogleUserMessagingPlatform: befe603da6501006420c206222acd449bba45a9c - OptableSDK: 127f749734ded99deff764f29bc1342b20153b2b + OptableSDK: d96e3936148f2e1c305573b628efc8299e47c097 PrebidMobile: ce3afbb56904a5e8b3f5f5aa0696474d2786a96b PODFILE CHECKSUM: ae16575987318eda44e4e135cd54cad3debe5193 From b76da88b5cd8ed853f079c7390206a2d829b9cf7 Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Thu, 1 Oct 2026 11:31:10 +0300 Subject: [PATCH 14/23] Mark OptableIdentifier Sendable --- Source/Public/OptableIdentifier.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Public/OptableIdentifier.swift b/Source/Public/OptableIdentifier.swift index 4bc5e51..2748120 100644 --- a/Source/Public/OptableIdentifier.swift +++ b/Source/Public/OptableIdentifier.swift @@ -15,7 +15,7 @@ import Foundation [](https://docs.optable.co/optable-documentation/getting-started/reference/identifier-types) */ -public enum OptableIdentifier { +public enum OptableIdentifier: Sendable { // Personal identifiers, case emailAddress(String) // e case phoneNumber(String) // p From 0d62ac4afaa408fc5c805d103a54934ead506dac Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Thu, 1 Oct 2026 11:37:59 +0300 Subject: [PATCH 15/23] Build the test target in Swift 6 mode --- OptableSDK.xcodeproj/project.pbxproj | 4 ++-- Tests/Unit/LocalStorageTests.swift | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/OptableSDK.xcodeproj/project.pbxproj b/OptableSDK.xcodeproj/project.pbxproj index b676310..2937569 100644 --- a/OptableSDK.xcodeproj/project.pbxproj +++ b/OptableSDK.xcodeproj/project.pbxproj @@ -440,7 +440,7 @@ PRODUCT_BUNDLE_IDENTIFIER = co.optable.OptableSDKTests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -461,7 +461,7 @@ PRODUCT_BUNDLE_IDENTIFIER = co.optable.OptableSDKTests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; diff --git a/Tests/Unit/LocalStorageTests.swift b/Tests/Unit/LocalStorageTests.swift index 61c8215..0176710 100644 --- a/Tests/Unit/LocalStorageTests.swift +++ b/Tests/Unit/LocalStorageTests.swift @@ -237,7 +237,7 @@ class LocalStorageTests: XCTestCase { } } -private let kOptableTargeting: NSDictionary = [ +private var kOptableTargeting: NSDictionary { [ "user": [ ], "resolved_ids": [ @@ -357,10 +357,10 @@ private let kOptableTargeting: NSDictionary = [ ], ], ], -] +] } -private let kGamTargetingKeywords: NSDictionary = [ +private var kGamTargetingKeywords: NSDictionary { [ "optable-test": "082793f9", -] +] } private let kORTB2: String = "ortb2_data" From 2fb8b853cf7fd894919e9e9a1cba3bdcfaee87c9 Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Thu, 1 Oct 2026 12:16:20 +0300 Subject: [PATCH 16/23] Make OptableConfig thread-safe and Sendable --- Source/OptableSDK.swift | 3 +- Source/Public/OptableConfig.swift | 107 ++++++++++++++++++++++++------ 2 files changed, 89 insertions(+), 21 deletions(-) diff --git a/Source/OptableSDK.swift b/Source/OptableSDK.swift index de1a907..b3aa88f 100644 --- a/Source/OptableSDK.swift +++ b/Source/OptableSDK.swift @@ -79,7 +79,8 @@ public class OptableSDK: NSObject { // MARK: - Sendable /// `@unchecked`: `delegate` is lock-guarded, `api` holds no mutable state beyond its lock-guarded user agent, -/// and `config` is the integrator's object, which the SDK only reads. +/// and `config` is `Sendable`. Unchecked only because `OptableDelegate` is an `@objc` protocol whose conformers +/// cannot be required to be `Sendable`. extension OptableSDK: @unchecked Sendable {} // MARK: - Identify diff --git a/Source/Public/OptableConfig.swift b/Source/Public/OptableConfig.swift index 65b829e..9585bc8 100644 --- a/Source/Public/OptableConfig.swift +++ b/Source/Public/OptableConfig.swift @@ -8,8 +8,14 @@ import Foundation +/** + The configuration of an `OptableSDK` instance. + + Every property is backed by its own lock, so the integrator may read and write it from any thread while the SDK + reads it. + */ @objc -public class OptableConfig: NSObject { +public final class OptableConfig: NSObject, Sendable { // MARK: Constants /// The default lifetime of cached targeting data: 24 hours. @objc @@ -18,42 +24,78 @@ public class OptableConfig: NSObject { // MARK: Required /// The tenant name associated with the configuration. E.g. `acmeco.optable.co` => `acmeco`. @objc - public var tenant: String + public var tenant: String { + get { tenantStore.withLock { $0 } } + set { tenantStore.withLock { $0 = newValue } } + } + private let tenantStore: Locked /// The DCN's Source Slug. E.g. `acmeco-sdk`. @objc - public var originSlug: String + public var originSlug: String { + get { originSlugStore.withLock { $0 } } + set { originSlugStore.withLock { $0 = newValue } } + } + private let originSlugStore: Locked // MARK: Optional /// The hostname of the Optable endpoint. Default value is "na.edge.optable.co". @objc - public var host: String = "na.edge.optable.co" + public var host: String { + get { hostStore.withLock { $0 } } + set { hostStore.withLock { $0 = newValue } } + } + private let hostStore = Locked("na.edge.optable.co") /// The API path to be appended to the host. Default value is "v2". @objc - public var path: String = "v2" + public var path: String { + get { pathStore.withLock { $0 } } + set { pathStore.withLock { $0 = newValue } } + } + private let pathStore = Locked("v2") /// Boolean flag that determines if insecure HTTP should be used instead of HTTPS. Default is false. @objc - public var insecure: Bool = false + public var insecure: Bool { + get { insecureStore.withLock { $0 } } + set { insecureStore.withLock { $0 = newValue } } + } + private let insecureStore = Locked(false) /// An optional API key for authentication. If the API Endpoint is enabled as private, a Service Account API key will be required. @objc - public var apiKey: String? + public var apiKey: String? { + get { apiKeyStore.withLock { $0 } } + set { apiKeyStore.withLock { $0 = newValue } } + } + private let apiKeyStore = Locked(nil) /// An optional custom user agent string for network requests. @objc - public var customUserAgent: String? + public var customUserAgent: String? { + get { customUserAgentStore.withLock { $0 } } + set { customUserAgentStore.withLock { $0 = newValue } } + } + private let customUserAgentStore = Locked(nil) /// An optional value sent as the `Origin` HTTP header on every Optable API request, /// identifying the origin you want your mobile traffic attributed to. E.g. `https://www.acmeco.com`. /// When `nil` (the default), no `Origin` header is sent. Unrelated to `originSlug`. @objc - public var origin: String? + public var origin: String? { + get { originStore.withLock { $0 } } + set { originStore.withLock { $0 = newValue } } + } + private let originStore = Locked(nil) /// Boolean flag to skip the detection of advertising IDs. Default is false. @objc - public var skipAdvertisingIdDetection: Bool = false + public var skipAdvertisingIdDetection: Bool { + get { skipAdvertisingIdDetectionStore.withLock { $0 } } + set { skipAdvertisingIdDetectionStore.withLock { $0 = newValue } } + } + private let skipAdvertisingIdDetectionStore = Locked(false) /** How long, in seconds, targeting data cached by the `targeting` API stays valid. Default is `defaultCacheTTL` (24 hours). @@ -62,14 +104,22 @@ public class OptableConfig: NSObject { A value of `0` therefore disables caching entirely. */ @objc - public var cacheTTL: TimeInterval = OptableConfig.defaultCacheTTL + public var cacheTTL: TimeInterval { + get { cacheTTLStore.withLock { $0 } } + set { cacheTTLStore.withLock { $0 = newValue } } + } + private let cacheTTLStore = Locked(OptableConfig.defaultCacheTTL) // MARK: Privacy Regulations /** Optable privacy regulation override, which can be one of: gdpr, can, us, or null and will override all other privacy regulations when present. */ @objc - public var reg: String? + public var reg: String? { + get { regStore.withLock { $0 } } + set { regStore.withLock { $0 = newValue } } + } + private let regStore = Locked(nil) /** TCF EU v2 consent string. @@ -77,7 +127,11 @@ public class OptableConfig: NSObject { > If not set, SDK will try to fetch data from UserDefaults => `IABTCF_TCString`, as stated in [](https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/TCFv2/IAB%20Tech%20Lab%20-%20CMP%20API%20v2.md#in-app-details) */ @objc - public var gdprConsent: String? + public var gdprConsent: String? { + get { gdprConsentStore.withLock { $0 } } + set { gdprConsentStore.withLock { $0 = newValue } } + } + private let gdprConsentStore = Locked(nil) /** A boolean indicating whether GDPR applies, represented as a integer (0 when it does not apply, 1 when it does). This value should be present when gdpr_consent is supplied. @@ -85,7 +139,11 @@ public class OptableConfig: NSObject { > If not set, SDK will try to fetch data from UserDefaults => `IABTCF_gdprApplies`, as stated in [](https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/TCFv2/IAB%20Tech%20Lab%20-%20CMP%20API%20v2.md#in-app-details) */ @objc - public var gdpr: NSNumber? + public var gdpr: NSNumber? { + get { gdprStore.withLock { $0 } } + set { gdprStore.withLock { $0 = newValue } } + } + private let gdprStore = Locked(nil) /** GPP privacy string. @@ -93,13 +151,21 @@ public class OptableConfig: NSObject { > If not set, SDK will try to fetch data from UserDefaults => `IABGPP_2_TCString`, as stated in [](https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/TCFv2/IAB%20Tech%20Lab%20-%20CMP%20API%20v2.md#in-app-details) */ @objc - public var gpp: String? + public var gpp: String? { + get { gppStore.withLock { $0 } } + set { gppStore.withLock { $0 = newValue } } + } + private let gppStore = Locked(nil) /** A comma-separated list of up to two sections applicable in a given GPP privacy string. This value is required when gpp is present. */ @objc - public var gppSid: String? + public var gppSid: String? { + get { gppSidStore.withLock { $0 } } + set { gppSidStore.withLock { $0 = newValue } } + } + private let gppSidStore = Locked(nil) // MARK: Inits /** @@ -109,8 +175,8 @@ public class OptableConfig: NSObject { */ @objc public init(tenant: String, originSlug: String) { - self.tenant = tenant - self.originSlug = originSlug + self.tenantStore = Locked(tenant) + self.originSlugStore = Locked(originSlug) super.init() } @@ -139,8 +205,9 @@ public class OptableConfig: NSObject { skipAdvertisingIdDetection: Bool = false, cacheTTL: TimeInterval = OptableConfig.defaultCacheTTL ) { - self.tenant = tenant - self.originSlug = originSlug + self.tenantStore = Locked(tenant) + self.originSlugStore = Locked(originSlug) + super.init() self.host = host self.path = path self.insecure = insecure From c4f64966f40be5d003e3f40b76237843a462df55 Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Thu, 1 Oct 2026 12:52:08 +0300 Subject: [PATCH 17/23] Deliver OptableDelegate callbacks on the main actor --- Source/OptableSDK.swift | 26 ++++++++++++------- .../Public/ObjCSupport/OptableSDK+ObjC.swift | 24 ++++++++++------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/Source/OptableSDK.swift b/Source/OptableSDK.swift index b3aa88f..d6a8dc5 100644 --- a/Source/OptableSDK.swift +++ b/Source/OptableSDK.swift @@ -11,6 +11,7 @@ import Foundation // MARK: - OptableDelegate /** OptableDelegate enables Objective-C and Swift apps to receive results via delegate callbacks. + Every callback is delivered on the main actor (the main thread). - Ok callbacks: - identifyOk and witnessOk receive an HTTPURLResponse on success. @@ -22,6 +23,7 @@ import Foundation If a synchronous error occurs while preparing a request, the method sets the passed NSError** and does not invoke the delegate callbacks. */ +@MainActor @objc public protocol OptableDelegate { func identifyOk(_ result: HTTPURLResponse) @@ -225,11 +227,13 @@ public extension OptableSDK { @objc func witness(event: String, properties: [String: Any]) throws { try self.witness(event: event, properties: properties) { result in - switch result { - case let .success(response): - self.delegate?.witnessOk(response) - case let .failure(error as NSError): - self.delegate?.witnessErr(error) + Task { @MainActor in + switch result { + case let .success(response): + self.delegate?.witnessOk(response) + case let .failure(error as NSError): + self.delegate?.witnessErr(error) + } } } } @@ -276,11 +280,13 @@ public extension OptableSDK { @objc func profile(traits: [String: Any], id: String? = nil, neighbors: [String]? = nil) throws { try _profile(traits: traits, id: id, neighbors: neighbors, completion: { result in - switch result { - case let .success(response): - self.delegate?.profileOk(response) - case let .failure(error as NSError): - self.delegate?.profileErr(error) + Task { @MainActor in + switch result { + case let .success(response): + self.delegate?.profileOk(response) + case let .failure(error as NSError): + self.delegate?.profileErr(error) + } } }) } diff --git a/Source/Public/ObjCSupport/OptableSDK+ObjC.swift b/Source/Public/ObjCSupport/OptableSDK+ObjC.swift index 14ca848..b21899c 100644 --- a/Source/Public/ObjCSupport/OptableSDK+ObjC.swift +++ b/Source/Public/ObjCSupport/OptableSDK+ObjC.swift @@ -20,11 +20,13 @@ public extension OptableSDK { func identify(_ ids: [OptableSDKIdentifier]) throws { let bridgedIds = ids.compactMap({ OptableIdentifier(objc: $0) }) try self._identify(bridgedIds) { result in - switch result { - case let .success(response): - self.delegate?.identifyOk(response) - case let .failure(error as NSError): - self.delegate?.identifyErr(error) + Task { @MainActor in + switch result { + case let .success(response): + self.delegate?.identifyOk(response) + case let .failure(error as NSError): + self.delegate?.identifyErr(error) + } } } } @@ -54,11 +56,13 @@ public extension OptableSDK { let bridgedHIds = hids.compactMap({ OptableIdentifier(objc: $0) }) try self._targeting(ids: bridgedIds, hids: bridgedHIds, completion: { result in - switch result { - case let .success(optableTargeting): - self.delegate?.targetingOk(optableTargeting) - case let .failure(error as NSError): - self.delegate?.targetingErr(error) + Task { @MainActor in + switch result { + case let .success(optableTargeting): + self.delegate?.targetingOk(optableTargeting) + case let .failure(error as NSError): + self.delegate?.targetingErr(error) + } } }) } From af482615f43695b655afaafa9d3e79441d41b96d Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Fri, 2 Oct 2026 10:26:52 +0300 Subject: [PATCH 18/23] Add preconcurrency to OptableDelegate --- Source/OptableSDK.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Source/OptableSDK.swift b/Source/OptableSDK.swift index d6a8dc5..0cc3758 100644 --- a/Source/OptableSDK.swift +++ b/Source/OptableSDK.swift @@ -23,8 +23,7 @@ import Foundation If a synchronous error occurs while preparing a request, the method sets the passed NSError** and does not invoke the delegate callbacks. */ -@MainActor -@objc +@preconcurrency @MainActor @objc public protocol OptableDelegate { func identifyOk(_ result: HTTPURLResponse) func identifyErr(_ error: NSError) From 389d26f047571ebe4d9ffcf133530ed180acfe83 Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Fri, 2 Oct 2026 10:26:52 +0300 Subject: [PATCH 19/23] Isolate delegate to the main actor --- Source/OptableSDK.swift | 12 ++---------- Tests/Integration/OptableSDKTests.swift | 3 ++- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/Source/OptableSDK.swift b/Source/OptableSDK.swift index 0cc3758..9b36aca 100644 --- a/Source/OptableSDK.swift +++ b/Source/OptableSDK.swift @@ -47,13 +47,8 @@ public protocol OptableDelegate { */ @objc public class OptableSDK: NSObject { - @objc - public var delegate: OptableDelegate? { - get { delegateStore.withLock { $0 } } - set { delegateStore.withLock { $0 = newValue } } - } - - private let delegateStore = Locked(nil) + @preconcurrency @MainActor @objc + public var delegate: OptableDelegate? let config: OptableConfig let api: EdgeAPI @@ -79,9 +74,6 @@ public class OptableSDK: NSObject { } // MARK: - Sendable -/// `@unchecked`: `delegate` is lock-guarded, `api` holds no mutable state beyond its lock-guarded user agent, -/// and `config` is `Sendable`. Unchecked only because `OptableDelegate` is an `@objc` protocol whose conformers -/// cannot be required to be `Sendable`. extension OptableSDK: @unchecked Sendable {} // MARK: - Identify diff --git a/Tests/Integration/OptableSDKTests.swift b/Tests/Integration/OptableSDKTests.swift index f54a184..73130c7 100644 --- a/Tests/Integration/OptableSDKTests.swift +++ b/Tests/Integration/OptableSDKTests.swift @@ -19,7 +19,8 @@ class OptableSDKTests: XCTestCase { lazy var witnessExpectation = expectation(description: "witness-delegate-expectation") lazy var profileExpectation = expectation(description: "profile-delegate-expectation") - override func setUpWithError() throws { + @MainActor + override func setUp() async throws { sdk.delegate = self } From 024c2f52311c299f9bde695b83aabb130fe0208a Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Fri, 2 Oct 2026 10:26:53 +0300 Subject: [PATCH 20/23] Add preconcurrency to completion-based APIs --- Source/OptableSDK.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Source/OptableSDK.swift b/Source/OptableSDK.swift index 9b36aca..6a760fe 100644 --- a/Source/OptableSDK.swift +++ b/Source/OptableSDK.swift @@ -95,6 +95,7 @@ public extension OptableSDK { } ``` */ + @preconcurrency func identify(_ ids: [OptableIdentifier], completion: @escaping @Sendable (Result) -> Void) throws { try _identify(ids, completion: completion) } @@ -141,6 +142,7 @@ public extension OptableSDK { On success, the result is cached in client storage. You can read it using targetingFromCache() and clear it using targetingClearCache(). */ + @preconcurrency func targeting(_ ids: [OptableIdentifier]? = nil, hids: [OptableIdentifier]? = nil, completion: @escaping @Sendable (Result) -> Void) throws { try _targeting(ids: ids, hids: hids, completion: completion) } @@ -189,6 +191,7 @@ public extension OptableSDK { - .success(HTTPURLResponse) on success - .failure(Error) on failure */ + @preconcurrency func witness(event: String, properties: [String: Any], _ completion: @escaping @Sendable (Result) -> Void) throws { try _witness(event: event, properties: properties, completion: completion) } @@ -242,6 +245,7 @@ public extension OptableSDK { The resulting OptableTargeting is also cached for targetingFromCache(). */ + @preconcurrency func profile(traits: [String: Any], id: String? = nil, neighbors: [String]? = nil, _ completion: @escaping @Sendable (Result) -> Void) throws { try _profile(traits: traits, id: id, neighbors: neighbors, completion: completion) } From 3b9a8f13ff91670f9d5db774abc63815b3950023 Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Fri, 2 Oct 2026 10:26:53 +0300 Subject: [PATCH 21/23] Raise test target deployment target to 17.0 --- OptableSDK.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/OptableSDK.xcodeproj/project.pbxproj b/OptableSDK.xcodeproj/project.pbxproj index 2937569..0efcc66 100644 --- a/OptableSDK.xcodeproj/project.pbxproj +++ b/OptableSDK.xcodeproj/project.pbxproj @@ -431,7 +431,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Tests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -452,7 +452,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Tests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", From 07799b75270f64c51604f2626018801df72ef46e Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Fri, 2 Oct 2026 10:26:53 +0300 Subject: [PATCH 22/23] Remove OptableTargeting Sendable comment --- Source/Public/OptableTargeting.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Source/Public/OptableTargeting.swift b/Source/Public/OptableTargeting.swift index e47ca1c..03f5286 100644 --- a/Source/Public/OptableTargeting.swift +++ b/Source/Public/OptableTargeting.swift @@ -34,8 +34,6 @@ public class OptableTargeting: NSObject { } // MARK: - Sendable -/// Every stored property is a `let`, and the dictionaries only hold the immutable Foundation values -/// produced by `JSONSerialization`, so instances can safely cross threads (e.g. resume a continuation). extension OptableTargeting: @unchecked Sendable {} // MARK: - Helpers From 79657a57d6d031e5b315bd20455c5f1b8e246d85 Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Fri, 2 Oct 2026 11:52:28 +0300 Subject: [PATCH 23/23] Make EdgeAPI, OptableSDK Sendable --- Source/Core/EdgeAPI.swift | 2 +- Source/OptableSDK.swift | 4 ++-- Source/Public/OptableTargeting.swift | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Source/Core/EdgeAPI.swift b/Source/Core/EdgeAPI.swift index 2f3a19b..4e01d3b 100644 --- a/Source/Core/EdgeAPI.swift +++ b/Source/Core/EdgeAPI.swift @@ -16,7 +16,7 @@ import WebKit [](https://docs.optable.co/optable-documentation/guides/real-time-api-integrations-guide) */ -final class EdgeAPI { +final class EdgeAPI: Sendable { private static let kPassportHeader: String = "X-Optable-Visitor" let storage: LocalStorage diff --git a/Source/OptableSDK.swift b/Source/OptableSDK.swift index 6a760fe..4262047 100644 --- a/Source/OptableSDK.swift +++ b/Source/OptableSDK.swift @@ -46,7 +46,7 @@ public protocol OptableDelegate { The OptableSDK keeps some state in [UserDefaults](https://developer.apple.com/documentation/foundation/userdefaults), a key/value store persisted across launches of the app. The state is therefore unique to the app+device, and not globally unique to the app across devices. */ @objc -public class OptableSDK: NSObject { +public final class OptableSDK: NSObject { @preconcurrency @MainActor @objc public var delegate: OptableDelegate? @@ -74,7 +74,7 @@ public class OptableSDK: NSObject { } // MARK: - Sendable -extension OptableSDK: @unchecked Sendable {} +extension OptableSDK: Sendable {} // MARK: - Identify public extension OptableSDK { diff --git a/Source/Public/OptableTargeting.swift b/Source/Public/OptableTargeting.swift index 03f5286..739fd4b 100644 --- a/Source/Public/OptableTargeting.swift +++ b/Source/Public/OptableTargeting.swift @@ -8,7 +8,7 @@ import Foundation @objcMembers -public class OptableTargeting: NSObject { +public final class OptableTargeting: NSObject { public let targetingData: [String: Any] public let gamTargetingKeywords: [String: Any]? public let ortb2: String?