From 9f6ec4b562d55d5af7f8d8f7e3f9d531f0dda8e9 Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Mon, 27 Jul 2026 17:50:09 +0300 Subject: [PATCH 1/3] feat: configurable ttl for targeting cache --- OptableSDK.xcodeproj/project.pbxproj | 1 + Source/Core/LocalStorage.swift | 26 ++++++++ Source/OptableSDK.swift | 4 +- Source/Public/OptableConfig.swift | 19 +++++- Tests/Unit/LocalStorageTests.swift | 99 ++++++++++++++++++++++++++++ Tests/Unit/OptableConfigTests.swift | 32 +++++++++ docs/usage-objc.md | 14 ++++ docs/usage-swift.md | 13 ++++ 8 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 Tests/Unit/OptableConfigTests.swift diff --git a/OptableSDK.xcodeproj/project.pbxproj b/OptableSDK.xcodeproj/project.pbxproj index a05613f..2f1cffb 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 84ff17f..ab772c4 100644 --- a/Source/Core/LocalStorage.swift +++ b/Source/Core/LocalStorage.swift @@ -15,10 +15,12 @@ final class LocalStorage: NSObject { private let targetingDataKey: String private let gamTargetingKeywordsKey: String private let ortb2Key: String + private let config: OptableConfig let keyPfx: String = "OPTABLE" var passportKey: String var targetingKey: String + var targetingStoredAtKey: String init(_ config: OptableConfig) { // The key used for storage should be unique to the host+app that this instance was initialized with: @@ -27,12 +29,15 @@ 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") self.targetingDataKey = targetingKey + "_targetingData" self.gamTargetingKeywordsKey = targetingKey + "_gamTargetingKeywords" self.ortb2Key = targetingKey + "_ortb2" + self.targetingStoredAtKey = targetingKey + "_storedAt" } func getPassport() -> String? { @@ -47,11 +52,18 @@ final class LocalStorage: NSObject { guard let targetingData = UserDefaults.standard.object(forKey: targetingDataKey) as? [String: Any] else { return nil } + + guard isTargetingFresh() else { + clearTargeting() + return nil + } + let optableTargeting = OptableTargeting( optableTargeting: targetingData, gamTargetingKeywords: UserDefaults.standard.object(forKey: gamTargetingKeywordsKey) as? [String: Any], ortb2: UserDefaults.standard.string(forKey: ortb2Key) ) + return optableTargeting } @@ -62,11 +74,25 @@ final class LocalStorage: NSObject { UserDefaults.standard.setValue(targeting.targetingData, forKey: targetingDataKey) UserDefaults.standard.setValue(targeting.gamTargetingKeywords, forKey: gamTargetingKeywordsKey) UserDefaults.standard.setValue(targeting.ortb2, forKey: ortb2Key) + UserDefaults.standard.setValue(Date().timeIntervalSince1970, forKey: targetingStoredAtKey) } func clearTargeting() { UserDefaults.standard.removeObject(forKey: targetingDataKey) UserDefaults.standard.removeObject(forKey: gamTargetingKeywordsKey) UserDefaults.standard.removeObject(forKey: ortb2Key) + UserDefaults.standard.removeObject(forKey: targetingStoredAtKey) + } + + /// Whether the stored targeting entry was fetched recently enough to still be served, per `config.cacheTTL`. + 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/OptableSDK.swift b/Source/OptableSDK.swift index 5ea6aeb..d846752 100644 --- a/Source/OptableSDK.swift +++ b/Source/OptableSDK.swift @@ -130,7 +130,9 @@ public extension OptableSDK { try _targeting(ids: ids, 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 1a7e23f..5712b94 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 @@ -44,6 +49,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. @@ -104,6 +118,7 @@ public class OptableConfig: NSObject { - apiKey: An optional API key for authentication. If the API Endpoint is enabled as private, a Service Account API key will be required. - customUserAgent: An optional custom user agent string for network requests. - 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, @@ -113,7 +128,8 @@ public class OptableConfig: NSObject { insecure: Bool = false, apiKey: String? = nil, customUserAgent: String? = nil, - skipAdvertisingIdDetection: Bool = false + skipAdvertisingIdDetection: Bool = false, + cacheTTL: TimeInterval = OptableConfig.defaultCacheTTL ) { self.tenant = tenant self.originSlug = originSlug @@ -123,5 +139,6 @@ public class OptableConfig: NSObject { self.apiKey = apiKey self.customUserAgent = customUserAgent self.skipAdvertisingIdDetection = skipAdvertisingIdDetection + self.cacheTTL = cacheTTL } } diff --git a/Tests/Unit/LocalStorageTests.swift b/Tests/Unit/LocalStorageTests.swift index 28b8aa3..2212efc 100644 --- a/Tests/Unit/LocalStorageTests.swift +++ b/Tests/Unit/LocalStorageTests.swift @@ -81,6 +81,105 @@ 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 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: 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 5ed211b..0c086db 100644 --- a/docs/usage-objc.md +++ b/docs/usage-objc.md @@ -145,6 +145,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 715e10f..a989238 100644 --- a/docs/usage-swift.md +++ b/docs/usage-swift.md @@ -157,6 +157,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: From a58444539b470bfca841f9ffccad68337a44045f Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Fri, 31 Jul 2026 14:23:43 +0300 Subject: [PATCH 2/3] fix: store timestamp first --- Source/Core/LocalStorage.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Core/LocalStorage.swift b/Source/Core/LocalStorage.swift index ab772c4..2ef7a90 100644 --- a/Source/Core/LocalStorage.swift +++ b/Source/Core/LocalStorage.swift @@ -71,10 +71,10 @@ final class LocalStorage: NSObject { // 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) - UserDefaults.standard.setValue(Date().timeIntervalSince1970, forKey: targetingStoredAtKey) } func clearTargeting() { From 93bf6537d2f7e3a8beb38ea89563514246a19175 Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Fri, 31 Jul 2026 15:45:12 +0300 Subject: [PATCH 3/3] fix: cache expiry boundary consistency --- Source/Core/LocalStorage.swift | 2 +- Tests/Unit/LocalStorageTests.swift | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/Source/Core/LocalStorage.swift b/Source/Core/LocalStorage.swift index 2ef7a90..2b6602b 100644 --- a/Source/Core/LocalStorage.swift +++ b/Source/Core/LocalStorage.swift @@ -93,6 +93,6 @@ final class LocalStorage: NSObject { let age = Date().timeIntervalSince1970 - storedAt - return age >= 0 && age <= config.cacheTTL + return age >= 0 && age < config.cacheTTL } } diff --git a/Tests/Unit/LocalStorageTests.swift b/Tests/Unit/LocalStorageTests.swift index 2212efc..51f94f5 100644 --- a/Tests/Unit/LocalStorageTests.swift +++ b/Tests/Unit/LocalStorageTests.swift @@ -99,6 +99,22 @@ class LocalStorageTests: XCTestCase { 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)