Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions OptableSDK.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions Source/Core/LocalStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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? {
Expand All @@ -47,18 +52,26 @@ 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
}

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(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)
Expand All @@ -68,5 +81,18 @@ final class LocalStorage: NSObject {
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
}
}
4 changes: 3 additions & 1 deletion Source/OptableSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
19 changes: 18 additions & 1 deletion Source/Public/OptableConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -123,5 +139,6 @@ public class OptableConfig: NSObject {
self.apiKey = apiKey
self.customUserAgent = customUserAgent
self.skipAdvertisingIdDetection = skipAdvertisingIdDetection
self.cacheTTL = cacheTTL
}
}
115 changes: 115 additions & 0 deletions Tests/Unit/LocalStorageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,121 @@ 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: 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 = [
Expand Down
32 changes: 32 additions & 0 deletions Tests/Unit/OptableConfigTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
14 changes: 14 additions & 0 deletions docs/usage-objc.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 13 additions & 0 deletions docs/usage-swift.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading