diff --git a/OptableSDK.podspec b/OptableSDK.podspec index aa2b380..a42262f 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/OptableSDK.xcodeproj/project.pbxproj b/OptableSDK.xcodeproj/project.pbxproj index 12615ab..0efcc66 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; @@ -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", @@ -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; @@ -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", @@ -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/Package.swift b/Package.swift index b51a145..413dbe1 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 @@ -29,5 +29,6 @@ let package = Package( name: "OptableSDKTests", dependencies: ["OptableSDK"], path: "Tests"), - ] + ], + swiftLanguageModes: [.v6] ) diff --git a/Source/Core/EdgeAPI.swift b/Source/Core/EdgeAPI.swift index b0d5900..4e01d3b 100644 --- a/Source/Core/EdgeAPI.swift +++ b/Source/Core/EdgeAPI.swift @@ -16,25 +16,34 @@ import WebKit [](https://docs.optable.co/optable-documentation/guides/real-time-api-integrations-guide) */ -final class EdgeAPI { - private let kPassportHeader: String = "X-Optable-Visitor" +final class EdgeAPI: Sendable { + private static let kPassportHeader: String = "X-Optable-Visitor" - var storage: LocalStorage - var config: OptableConfig + let storage: LocalStorage + let 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() + private let 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 } } @@ -108,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 @@ -118,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) } @@ -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 { @@ -172,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..7f719f2 100644 --- a/Source/Core/LocalStorage.swift +++ b/Source/Core/LocalStorage.swift @@ -10,20 +10,21 @@ 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. + + `Sendable`: every stored property is immutable, `UserDefaults` is thread-safe, and targeting reads and writes + are serialized by `lock`. */ -final class LocalStorage: NSObject { +final class LocalStorage: NSObject, 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 - - private let config: OptableConfig - + let passportKey: String + let targetingKey: String + let targetingStoredAtKey: String + private let lock = NSLock() init(_ config: OptableConfig) { @@ -33,8 +34,6 @@ final class LocalStorage: NSObject { .data(using: .utf8)? .base64EncodedString() - self.config = config - self.passportKey = self.keyPfx + "_PASS_" + (base64Key ?? "UNKNOWN") self.targetingKey = self.keyPfx + "_TGT_" + (base64Key ?? "UNKNOWN") @@ -53,13 +52,15 @@ final class LocalStorage: NSObject { 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 } @@ -105,8 +106,8 @@ final class LocalStorage: NSObject { 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 @@ -114,6 +115,6 @@ final class LocalStorage: NSObject { let age = Date().timeIntervalSince1970 - storedAt - return age >= 0 && age < config.cacheTTL + return age >= 0 && age < cacheTTL } } diff --git a/Source/Misc/AppTrackingTransparency.swift b/Source/Misc/AppTrackingTransparency.swift index 433b73d..6467b0b 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: 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 { 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 {} diff --git a/Source/OptableSDK.swift b/Source/OptableSDK.swift index 23b315d..4262047 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,7 +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. */ -@objc +@preconcurrency @MainActor @objc public protocol OptableDelegate { func identifyOk(_ result: HTTPURLResponse) func identifyErr(_ error: NSError) @@ -45,8 +46,8 @@ 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 { - @objc +public final class OptableSDK: NSObject { + @preconcurrency @MainActor @objc public var delegate: OptableDelegate? let config: OptableConfig @@ -72,6 +73,9 @@ public class OptableSDK: NSObject { } } +// MARK: - Sendable +extension OptableSDK: Sendable {} + // MARK: - Identify public extension OptableSDK { /** @@ -91,7 +95,8 @@ public extension OptableSDK { } ``` */ - func identify(_ ids: [OptableIdentifier], completion: @escaping (Result) -> Void) throws { + @preconcurrency + func identify(_ ids: [OptableIdentifier], completion: @escaping @Sendable (Result) -> Void) throws { try _identify(ids, completion: completion) } @@ -137,7 +142,8 @@ 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 { + @preconcurrency + func targeting(_ ids: [OptableIdentifier]? = nil, hids: [OptableIdentifier]? = nil, completion: @escaping @Sendable (Result) -> Void) throws { try _targeting(ids: ids, hids: hids, completion: completion) } @@ -146,7 +152,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. @@ -185,7 +191,8 @@ public extension OptableSDK { - .success(HTTPURLResponse) on success - .failure(Error) on failure */ - func witness(event: String, properties: [String: Any], _ completion: @escaping (Result) -> Void) throws { + @preconcurrency + func witness(event: String, properties: [String: Any], _ completion: @escaping @Sendable (Result) -> Void) throws { try _witness(event: event, properties: properties, completion: completion) } @@ -214,11 +221,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) + } } } } @@ -236,7 +245,8 @@ 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 { + @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) } @@ -265,11 +275,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) + } } }) } @@ -298,7 +310,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 +337,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 +377,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 +400,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") } 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) + } } }) } 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 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 diff --git a/Source/Public/OptableTargeting.swift b/Source/Public/OptableTargeting.swift index f342c6c..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? @@ -33,6 +33,9 @@ public class OptableTargeting: NSObject { } } +// MARK: - Sendable +extension OptableTargeting: @unchecked Sendable {} + // MARK: - Helpers extension OptableTargeting { diff --git a/Tests/Integration/OptableSDKTests.swift b/Tests/Integration/OptableSDKTests.swift index fbcfce5..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 } @@ -99,6 +100,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..0176710 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( @@ -245,7 +237,7 @@ class LocalStorageTests: XCTestCase { } } -private let kOptableTargeting: NSDictionary = [ +private var kOptableTargeting: NSDictionary { [ "user": [ ], "resolved_ids": [ @@ -365,10 +357,10 @@ private let kOptableTargeting: NSDictionary = [ ], ], ], -] +] } -private let kGamTargetingKeywords: NSDictionary = [ +private var kGamTargetingKeywords: NSDictionary { [ "optable-test": "082793f9", -] +] } private let kORTB2: String = "ortb2_data" 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