diff --git a/OptableSDK.xcodeproj/project.pbxproj b/OptableSDK.xcodeproj/project.pbxproj index fab643e..686c972 100644 --- a/OptableSDK.xcodeproj/project.pbxproj +++ b/OptableSDK.xcodeproj/project.pbxproj @@ -45,6 +45,7 @@ Misc/Constants.swift, Unit/EdgeAPITests.swift, Unit/LocalStorageTests.swift, + Unit/OptableConfigTests.swift, Unit/OptableIdentifierEncoderTests.swift, Unit/OptableIdentifiersTests.swift, Unit/OptableSDKHelpersIdentifiersEnrichmentTests.swift, diff --git a/Source/Core/LocalStorage.swift b/Source/Core/LocalStorage.swift index 2b3016a..5ba0d73 100644 --- a/Source/Core/LocalStorage.swift +++ b/Source/Core/LocalStorage.swift @@ -20,6 +20,11 @@ final class LocalStorage: NSObject { let keyPfx: String = "OPTABLE" var passportKey: String var targetingKey: String + var targetingStoredAtKey: String + + private let config: OptableConfig + + private let lock = NSLock() init(_ config: OptableConfig) { // The key used for storage should be unique to the host+app that this instance was initialized with: @@ -28,6 +33,8 @@ 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") @@ -35,6 +42,7 @@ final class LocalStorage: NSObject { self.gamTargetingKeywordsKey = targetingKey + "_gamTargetingKeywords" self.ortb2Key = targetingKey + "_ortb2" self.id5SignatureKey = targetingKey + "_id5Signature" + self.targetingStoredAtKey = targetingKey + "_storedAt" } func getPassport() -> String? { @@ -46,38 +54,66 @@ final class LocalStorage: NSObject { } func getTargeting() -> OptableTargeting? { - guard let targetingData = UserDefaults.standard.object(forKey: targetingDataKey) as? [String: Any] else { - return nil + lock.synchronized { + guard let targetingData = UserDefaults.standard.object(forKey: targetingDataKey) as? [String: Any] else { + return nil + } + + guard isTargetingFresh() else { + removeTargetingEntry() + return nil + } + + return OptableTargeting( + optableTargeting: targetingData, + gamTargetingKeywords: UserDefaults.standard.object(forKey: gamTargetingKeywordsKey) as? [String: Any], + ortb2: UserDefaults.standard.string(forKey: ortb2Key) + ) } - let optableTargeting = OptableTargeting( - optableTargeting: targetingData, - gamTargetingKeywords: UserDefaults.standard.object(forKey: gamTargetingKeywordsKey) as? [String: Any], - ortb2: UserDefaults.standard.string(forKey: ortb2Key) - ) - return optableTargeting } func setTargeting(_ targeting: OptableTargeting) { - // Decompose object explicitly - // Because Codable/NSSecureCoding does not support heterogeneous containers such as NSDictionary([String: Any]) - // However UserDefaults does support - UserDefaults.standard.setValue(targeting.targetingData, forKey: targetingDataKey) - UserDefaults.standard.setValue(targeting.gamTargetingKeywords, forKey: gamTargetingKeywordsKey) - UserDefaults.standard.setValue(targeting.ortb2, forKey: ortb2Key) + lock.synchronized { + // Decompose object explicitly + // Because Codable/NSSecureCoding does not support heterogeneous containers such as NSDictionary([String: Any]) + // However UserDefaults does support + UserDefaults.standard.setValue(Date().timeIntervalSince1970, forKey: targetingStoredAtKey) + UserDefaults.standard.setValue(targeting.targetingData, forKey: targetingDataKey) + UserDefaults.standard.setValue(targeting.gamTargetingKeywords, forKey: gamTargetingKeywordsKey) + UserDefaults.standard.setValue(targeting.ortb2, forKey: ortb2Key) + } } func clearTargeting() { - UserDefaults.standard.removeObject(forKey: targetingDataKey) - UserDefaults.standard.removeObject(forKey: gamTargetingKeywordsKey) - UserDefaults.standard.removeObject(forKey: ortb2Key) - UserDefaults.standard.removeObject(forKey: id5SignatureKey) + lock.synchronized { removeTargetingEntry() } } func getID5Signature() -> String? { - return UserDefaults.standard.string(forKey: id5SignatureKey) + UserDefaults.standard.string(forKey: id5SignatureKey) } func setID5Signature(_ signature: String?) { UserDefaults.standard.set(signature, forKey: id5SignatureKey) } + + /// Removes every key of the targeting entry. + private func removeTargetingEntry() { + UserDefaults.standard.removeObject(forKey: targetingDataKey) + UserDefaults.standard.removeObject(forKey: gamTargetingKeywordsKey) + UserDefaults.standard.removeObject(forKey: ortb2Key) + UserDefaults.standard.removeObject(forKey: id5SignatureKey) + 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 { + // 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 + } + + let age = Date().timeIntervalSince1970 - storedAt + + return age >= 0 && age < config.cacheTTL + } } diff --git a/Source/Misc/NSLock++.swift b/Source/Misc/NSLock++.swift new file mode 100644 index 0000000..9c1a350 --- /dev/null +++ b/Source/Misc/NSLock++.swift @@ -0,0 +1,18 @@ +// +// NSLock++.swift +// OptableSDK +// +// Copyright © 2026 Optable Technologies, Inc. All rights reserved. +// + +import Foundation + +extension NSLock { + /// Runs `body` while holding the lock and returns its result. + /// Stands in for `NSLock.withLock(_:)`, which requires iOS 16. + func synchronized(_ body: () throws -> T) rethrows -> T { + lock() + defer { unlock() } + return try body() + } +} diff --git a/Source/OptableSDK.swift b/Source/OptableSDK.swift index c8625c5..308213f 100644 --- a/Source/OptableSDK.swift +++ b/Source/OptableSDK.swift @@ -142,7 +142,9 @@ public extension OptableSDK { try _targeting(ids: ids, hids: hids, completion: completion) } - /// targetingFromCache() returns the previously cached targeting data, if any. + /// Returns the previously cached targeting data, if any. + /// Cached data expires after `OptableConfig.cacheTTL` (24 hours by default). An expired entry is + /// reported as absent and is cleared from storage. @objc func targetingFromCache() -> OptableTargeting? { return self.api.storage.getTargeting() diff --git a/Source/Public/OptableConfig.swift b/Source/Public/OptableConfig.swift index 0d5a783..65b829e 100644 --- a/Source/Public/OptableConfig.swift +++ b/Source/Public/OptableConfig.swift @@ -10,6 +10,11 @@ import Foundation @objc public class OptableConfig: NSObject { + // MARK: Constants + /// The default lifetime of cached targeting data: 24 hours. + @objc + public static let defaultCacheTTL: TimeInterval = 24 * 60 * 60 + // MARK: Required /// The tenant name associated with the configuration. E.g. `acmeco.optable.co` => `acmeco`. @objc @@ -50,6 +55,15 @@ public class OptableConfig: NSObject { @objc public var skipAdvertisingIdDetection: Bool = false + /** + How long, in seconds, targeting data cached by the `targeting` API stays valid. Default is `defaultCacheTTL` (24 hours). + + Once a cached entry is older than this, `targetingFromCache()` reports it as absent and drops it from storage. + A value of `0` therefore disables caching entirely. + */ + @objc + public var cacheTTL: TimeInterval = 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. @@ -111,6 +125,7 @@ public class OptableConfig: NSObject { - customUserAgent: An optional custom user agent string for network requests. - origin: 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`. No header is sent when nil. Unrelated to `originSlug`. - skipAdvertisingIdDetection: Boolean flag to skip the detection of advertising IDs. Default is false. + - cacheTTL: How long, in seconds, cached targeting data stays valid. Default is `defaultCacheTTL` (24 hours). */ public init( tenant: String, @@ -121,7 +136,8 @@ public class OptableConfig: NSObject { apiKey: String? = nil, customUserAgent: String? = nil, origin: String? = nil, - skipAdvertisingIdDetection: Bool = false + skipAdvertisingIdDetection: Bool = false, + cacheTTL: TimeInterval = OptableConfig.defaultCacheTTL ) { self.tenant = tenant self.originSlug = originSlug @@ -132,5 +148,6 @@ public class OptableConfig: NSObject { self.customUserAgent = customUserAgent self.origin = origin self.skipAdvertisingIdDetection = skipAdvertisingIdDetection + self.cacheTTL = cacheTTL } } diff --git a/Tests/Unit/LocalStorageTests.swift b/Tests/Unit/LocalStorageTests.swift index f8e89ba..9f87e1d 100644 --- a/Tests/Unit/LocalStorageTests.swift +++ b/Tests/Unit/LocalStorageTests.swift @@ -97,6 +97,152 @@ class LocalStorageTests: XCTestCase { XCTAssert(localStorage.getTargeting() == nil) } + + // MARK: - Cache TTL + func testTargetingIsReturnedWithinTTL() { + let storage = makeStorageWithStoredTargeting(cacheTTL: 60) + + setStoredAge(storage, to: 30) + + XCTAssertNotNil(storage.getTargeting()) + } + + func testTargetingIsNilPastTTL() { + let storage = makeStorageWithStoredTargeting(cacheTTL: 60) + + setStoredAge(storage, to: 61) + + XCTAssertNil(storage.getTargeting()) + } + + func testTargetingExpiresAtExactlyTTL() { + let storage = makeStorageWithStoredTargeting(cacheTTL: 60) + + setStoredAge(storage, to: 60) + + XCTAssertNil(storage.getTargeting()) + } + + func testZeroTTLDisablesCaching() { + let storage = makeStorageWithStoredTargeting(cacheTTL: 0) + + setStoredAge(storage, to: 0) + + XCTAssertNil(storage.getTargeting()) + } + + func testExpiredTargetingIsClearedFromStorage() { + let storage = makeStorageWithStoredTargeting(cacheTTL: 60) + + setStoredAge(storage, to: 61) + XCTAssertNil(storage.getTargeting()) + + setStoredAge(storage, to: 0) + XCTAssertNil(storage.getTargeting()) + } + + func testTargetingWithoutStoredTimestampIsTreatedAsExpired() { + let storage = makeStorageWithStoredTargeting(cacheTTL: 60) + + UserDefaults.standard.removeObject(forKey: storage.targetingStoredAtKey) + + XCTAssertNil(storage.getTargeting()) + } + + func testTargetingIsNilWhenStoredInTheFuture() { + let storage = makeStorageWithStoredTargeting(cacheTTL: 60) + + setStoredAge(storage, to: -30) + + XCTAssertNil(storage.getTargeting()) + } + + func testDefaultTTLKeepsTargetingFreshJustUnderTwentyFourHours() { + let storage = makeStorageWithStoredTargeting(cacheTTL: nil) + + setStoredAge(storage, to: 24 * 60 * 60 - 60) + + XCTAssertNotNil(storage.getTargeting()) + } + + func testDefaultTTLExpiresTargetingPastTwentyFourHours() { + let storage = makeStorageWithStoredTargeting(cacheTTL: nil) + + setStoredAge(storage, to: 24 * 60 * 60 + 60) + + XCTAssertNil(storage.getTargeting()) + } + + // 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 + `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 freshTargeting = OptableTargeting( + optableTargeting: kOptableTargeting as! [String: Any], + gamTargetingKeywords: kGamTargetingKeywords as? [String: Any], + ortb2: kORTB2 + ) + + for iteration in 0..<500 { + storage.setTargeting(freshTargeting) + setStoredAge(storage, to: 61) + + let group = DispatchGroup() + let queue = DispatchQueue.global(qos: .userInitiated) + queue.async(group: group) { _ = storage.getTargeting() } + 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 } + } + } + + // MARK: Helpers + /** + 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 { + let config = OptableConfig(tenant: "tenant-\(function)", originSlug: "slug") + if let cacheTTL { + config.cacheTTL = cacheTTL + } + + let storage = LocalStorage(config) + storage.setTargeting( + OptableTargeting( + optableTargeting: kOptableTargeting as! [String: Any], + gamTargetingKeywords: kGamTargetingKeywords as? [String: Any], + ortb2: kORTB2 + ) + ) + + return storage + } + + /** + Backdates the stored entry so that it reads as `age` seconds old, standing in for the passage of time. + + A negative age places the timestamp in the future. + */ + private func setStoredAge(_ storage: LocalStorage, to age: TimeInterval) { + UserDefaults.standard.setValue( + Date().timeIntervalSince1970 - age, + forKey: storage.targetingStoredAtKey + ) + } } private let kOptableTargeting: NSDictionary = [ diff --git a/Tests/Unit/OptableConfigTests.swift b/Tests/Unit/OptableConfigTests.swift new file mode 100644 index 0000000..9bedd73 --- /dev/null +++ b/Tests/Unit/OptableConfigTests.swift @@ -0,0 +1,32 @@ +// +// OptableConfigTests.swift +// OptableSDK +// +// Copyright © 2026 Optable Technologies, Inc. All rights reserved. +// + +@testable import OptableSDK +import XCTest + +// MARK: - OptableConfigTests +class OptableConfigTests: XCTestCase { + func testDefaultCacheTTLIsTwentyFourHours() { + XCTAssertEqual(OptableConfig.defaultCacheTTL, 24 * 60 * 60) + } + + func testCacheTTLDefaultsToDefaultCacheTTL() { + let objcInit = OptableConfig(tenant: "tenant", originSlug: "slug") + XCTAssertEqual(objcInit.cacheTTL, OptableConfig.defaultCacheTTL) + + let swiftInit = OptableConfig(tenant: "tenant", originSlug: "slug", host: "host") + XCTAssertEqual(swiftInit.cacheTTL, OptableConfig.defaultCacheTTL) + } + + func testCacheTTLIsConfigurable() { + let config = OptableConfig(tenant: "tenant", originSlug: "slug", cacheTTL: 60) + XCTAssertEqual(config.cacheTTL, 60) + + config.cacheTTL = 120 + XCTAssertEqual(config.cacheTTL, 120) + } +} diff --git a/docs/usage-objc.md b/docs/usage-objc.md index 9ca87a9..4262d33 100644 --- a/docs/usage-objc.md +++ b/docs/usage-objc.md @@ -181,6 +181,20 @@ You can also clear the locally cached targeting data: Note that both `targetingFromCache` and `targetingClearCache` are synchronous. +##### Cache Expiry + +Cached targeting data expires 24 hours after it was fetched. Once an entry has expired, `targetingFromCache` reports it as absent by returning `nil`, and clears it from client storage. Call the targeting API again to refresh it. + +You can change the lifetime by setting the `cacheTTL` property, expressed in seconds: + +```objective-c +@import OptableSDK; +... +config.cacheTTL = 60 * 60; // expire cached targeting data after one hour +``` + +The default is `OptableConfig.defaultCacheTTL`, which is 24 hours. Setting `cacheTTL` to `0` effectively disables the cache, since every entry is then already expired by the time it is read. + ### Witness API To send real-time event data from the user's device to the DCN for eventual audience assembly, you can call the witness API as follows: diff --git a/docs/usage-swift.md b/docs/usage-swift.md index a66a225..2a66bbe 100644 --- a/docs/usage-swift.md +++ b/docs/usage-swift.md @@ -195,6 +195,19 @@ OPTABLE!.targetingClearCache() Note that both `targetingFromCache()` and `targetingClearCache()` are synchronous. +##### Cache Expiry + +Cached targeting data expires 24 hours after it was fetched. Once an entry has expired, `targetingFromCache()` reports it as absent by returning `nil`, and clears it from client storage. Call `targeting()` again to refresh it. + +You can change the lifetime with the optional `cacheTTL` parameter, expressed in seconds: + +```swift +let config = OptableConfig(..., cacheTTL: 60 * 60) // expire cached targeting data after one hour +OPTABLE = OptableSDK(config: config) +``` + +The default is `OptableConfig.defaultCacheTTL`, which is 24 hours. Setting `cacheTTL` to `0` effectively disables the cache, since every entry is then already expired by the time it is read. + ### Witness API > :information_source: For more info check: