@advenue/react-native 0.9.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -7
- package/android/src/main/java/expo/modules/advenue/AdvenueAndroidModule.kt +117 -368
- package/android/src/main/kotlin/io/advenue/Advenue.kt +574 -0
- package/android/src/main/kotlin/io/advenue/AdvenueConfig.kt +141 -0
- package/android/src/main/kotlin/io/advenue/core/Backoff.kt +28 -0
- package/android/src/main/kotlin/io/advenue/core/ClientEvent.kt +136 -0
- package/android/src/main/kotlin/io/advenue/core/CommandPipe.kt +193 -0
- package/android/src/main/kotlin/io/advenue/core/Consent.kt +52 -0
- package/android/src/main/kotlin/io/advenue/core/Contracts.kt +80 -0
- package/android/src/main/kotlin/io/advenue/core/Conversion.kt +69 -0
- package/android/src/main/kotlin/io/advenue/core/Engine.kt +436 -0
- package/android/src/main/kotlin/io/advenue/core/EventQueue.kt +89 -0
- package/android/src/main/kotlin/io/advenue/core/HmacSigner.kt +31 -0
- package/android/src/main/kotlin/io/advenue/core/InstallReferrer.kt +146 -0
- package/android/src/main/kotlin/io/advenue/core/Json.kt +272 -0
- package/android/src/main/kotlin/io/advenue/core/Limits.kt +50 -0
- package/android/src/main/kotlin/io/advenue/core/MetaReferrer.kt +80 -0
- package/android/src/main/kotlin/io/advenue/core/PiiScrub.kt +77 -0
- package/android/src/main/kotlin/io/advenue/core/SessionTracker.kt +189 -0
- package/android/src/main/kotlin/io/advenue/core/SystemServices.kt +58 -0
- package/android/src/main/kotlin/io/advenue/core/Tcf.kt +48 -0
- package/android/src/main/kotlin/io/advenue/core/Time.kt +56 -0
- package/android/src/main/kotlin/io/advenue/platform/Collectors.kt +163 -0
- package/android/src/main/kotlin/io/advenue/platform/CompositeStore.kt +33 -0
- package/android/src/main/kotlin/io/advenue/platform/ConversionFetcher.kt +68 -0
- package/android/src/main/kotlin/io/advenue/platform/ForegroundTracker.kt +90 -0
- package/android/src/main/kotlin/io/advenue/platform/HttpUrlTransport.kt +101 -0
- package/android/src/main/kotlin/io/advenue/platform/Identity.kt +79 -0
- package/android/src/main/kotlin/io/advenue/platform/InstallEnrichment.kt +197 -0
- package/android/src/main/kotlin/io/advenue/platform/InstallScopedStore.kt +96 -0
- package/android/src/main/kotlin/io/advenue/platform/LifecycleBridge.kt +72 -0
- package/android/src/main/kotlin/io/advenue/platform/PreferencesStore.kt +32 -0
- package/android/src/main/kotlin/io/advenue/plugin/Contracts.kt +67 -0
- package/android/src/main/kotlin/io/advenue/plugin/FirebaseAppInstanceIdSource.kt +73 -0
- package/android/src/main/kotlin/io/advenue/plugin/PlayAdvertisingIdSource.kt +47 -0
- package/android/src/main/kotlin/io/advenue/plugin/PlayInstallReferrerSource.kt +85 -0
- package/android/src/main/kotlin/io/advenue/plugin/PlayIntegritySource.kt +86 -0
- package/android/src/main/kotlin/io/advenue/plugin/PluginRegistry.kt +61 -0
- package/dist/index.cjs +188 -675
- package/dist/index.d.cts +203 -336
- package/dist/index.d.ts +203 -336
- package/dist/index.js +187 -681
- package/ios/AdvenueIosModule.swift +143 -446
- package/ios/vendor/Advenue/Advenue.swift +632 -0
- package/ios/vendor/Advenue/AdvenueConfig.swift +89 -0
- package/ios/vendor/AdvenueCore/AdvenueValue.swift +103 -0
- package/ios/vendor/AdvenueCore/Attestation.swift +52 -0
- package/ios/vendor/AdvenueCore/Backoff.swift +28 -0
- package/ios/vendor/AdvenueCore/ClientEvent.swift +154 -0
- package/ios/vendor/AdvenueCore/Consent.swift +40 -0
- package/ios/vendor/AdvenueCore/Contracts.swift +59 -0
- package/ios/vendor/AdvenueCore/Conversion.swift +74 -0
- package/ios/vendor/AdvenueCore/ConversionValue.swift +217 -0
- package/ios/vendor/AdvenueCore/Engine.swift +607 -0
- package/ios/vendor/AdvenueCore/EventQueue.swift +89 -0
- package/ios/vendor/AdvenueCore/Limits.swift +41 -0
- package/ios/vendor/AdvenueCore/PIIScrub.swift +102 -0
- package/ios/vendor/AdvenueCore/SessionTracker.swift +139 -0
- package/ios/vendor/AdvenueCore/SkanConfig.swift +76 -0
- package/ios/vendor/AdvenueCore/SkanReporter.swift +25 -0
- package/ios/vendor/AdvenueCore/SkanState.swift +258 -0
- package/ios/vendor/AdvenueCore/Tcf.swift +48 -0
- package/ios/vendor/AdvenueCore/Transport.swift +14 -0
- package/ios/vendor/AdvenueFirebase/FirebaseAppInstanceId.swift +33 -0
- package/ios/vendor/AdvenuePlatform/AdvertisingIdentity.swift +87 -0
- package/ios/vendor/AdvenuePlatform/CacheFileStore.swift +90 -0
- package/ios/vendor/AdvenuePlatform/ChallengeFetcher.swift +42 -0
- package/ios/vendor/AdvenuePlatform/ConversionFetcher.swift +63 -0
- package/ios/vendor/AdvenuePlatform/CryptoKitSigner.swift +19 -0
- package/ios/vendor/AdvenuePlatform/DeviceCheckAttestation.swift +91 -0
- package/ios/vendor/AdvenuePlatform/DeviceInfo.swift +77 -0
- package/ios/vendor/AdvenuePlatform/ForegroundTracker.swift +55 -0
- package/ios/vendor/AdvenuePlatform/HttpTransport.swift +96 -0
- package/ios/vendor/AdvenuePlatform/Identity.swift +52 -0
- package/ios/vendor/AdvenuePlatform/InstallEnrichment.swift +155 -0
- package/ios/vendor/AdvenuePlatform/KeychainStore.swift +95 -0
- package/ios/vendor/AdvenuePlatform/SearchAdsToken.swift +76 -0
- package/ios/vendor/AdvenuePlatform/SkanConfigFetcher.swift +70 -0
- package/ios/vendor/AdvenuePlatform/StoreKitSkanReporter.swift +140 -0
- package/ios/vendor/AdvenuePlatform/SystemServices.swift +55 -0
- package/ios/vendor/AdvenuePlatform/TcfReader.swift +18 -0
- package/ios/vendor/AdvenuePlatform/UserDefaultsStore.swift +26 -0
- package/package.json +13 -15
- package/scripts/check-dist.mjs +17 -0
- package/scripts/check-vendored-swift.mjs +148 -0
- package/scripts/vendor-natives.mjs +149 -0
- package/scripts/vendor-natives.test.mjs +112 -0
- package/src/deep-links.ts +19 -1
- package/src/index.ts +273 -830
- package/src/native-types.ts +73 -181
- package/src/native.ts +0 -23
- package/src/types.ts +86 -0
- package/src/aem.ts +0 -33
- package/src/mmkv-storage.ts +0 -21
- package/src/native-storage.ts +0 -63
- package/src/secure-store.ts +0 -26
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// What the SDK POSTs. `apiKey` travels in the body, matching
|
|
4
|
+
/// `eventBatchSchema`.
|
|
5
|
+
struct EventBatch: Encodable {
|
|
6
|
+
let apiKey: String
|
|
7
|
+
let events: [ClientEvent]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
public let DEFAULT_ENDPOINT = "https://ingest.advenue.io"
|
|
11
|
+
/// The schema caps a batch at 100 events.
|
|
12
|
+
|
|
13
|
+
/// Batched ingest over URLSession.
|
|
14
|
+
///
|
|
15
|
+
/// Throws `IngestError` on a non-2xx so the caller can distinguish a transient
|
|
16
|
+
/// failure from a poison payload; a transport failure maps to 408, matching
|
|
17
|
+
/// sdk-core so both SDKs retry exactly the same cases.
|
|
18
|
+
public struct HttpTransport: EventTransport, Sendable {
|
|
19
|
+
private let endpoint: String
|
|
20
|
+
private let apiKey: String
|
|
21
|
+
private let signingSecret: String?
|
|
22
|
+
private let signer: any Signer
|
|
23
|
+
private let clock: any Clock
|
|
24
|
+
private let session: URLSession
|
|
25
|
+
/// Receives the app the server reported for an ACCEPTED batch. Purely
|
|
26
|
+
/// diagnostic: it runs after the batch is already accepted, so nothing it
|
|
27
|
+
/// does can turn a successful ingest into a failure.
|
|
28
|
+
private let onAccepted: @Sendable (String) -> Void
|
|
29
|
+
|
|
30
|
+
public init(
|
|
31
|
+
endpoint: String = DEFAULT_ENDPOINT,
|
|
32
|
+
apiKey: String,
|
|
33
|
+
signingSecret: String? = nil,
|
|
34
|
+
signer: any Signer = CryptoKitSigner(),
|
|
35
|
+
clock: any Clock = SystemClock(),
|
|
36
|
+
session: URLSession = .shared,
|
|
37
|
+
onAccepted: @escaping @Sendable (String) -> Void = { _ in }
|
|
38
|
+
) {
|
|
39
|
+
self.onAccepted = onAccepted
|
|
40
|
+
self.endpoint = endpoint
|
|
41
|
+
self.apiKey = apiKey
|
|
42
|
+
self.signingSecret = signingSecret
|
|
43
|
+
self.signer = signer
|
|
44
|
+
self.clock = clock
|
|
45
|
+
self.session = session
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// The exact request this transport would send. Public so a test can assert
|
|
49
|
+
/// the wire shape — endpoint, headers, body, signature — without a network
|
|
50
|
+
/// round trip, which is the part that has to be right.
|
|
51
|
+
public func buildRequest(_ events: [ClientEvent]) throws -> URLRequest {
|
|
52
|
+
let body = try EventEncoding.canonicalEncoder()
|
|
53
|
+
.encode(EventBatch(apiKey: apiKey, events: events))
|
|
54
|
+
var request = URLRequest(url: URL(string: "\(endpoint)/v1/events")!)
|
|
55
|
+
request.httpMethod = "POST"
|
|
56
|
+
request.setValue("application/json", forHTTPHeaderField: "content-type")
|
|
57
|
+
request.httpBody = body
|
|
58
|
+
request.timeoutInterval = 15
|
|
59
|
+
|
|
60
|
+
if let secret = signingSecret {
|
|
61
|
+
let timestamp = String(clock.nowMs())
|
|
62
|
+
request.setValue(timestamp, forHTTPHeaderField: "X-Advenue-Timestamp")
|
|
63
|
+
request.setValue(
|
|
64
|
+
signRequest(
|
|
65
|
+
signer, secret: secret, timestamp: timestamp,
|
|
66
|
+
body: String(decoding: body, as: UTF8.self)),
|
|
67
|
+
forHTTPHeaderField: "X-Advenue-Signature")
|
|
68
|
+
}
|
|
69
|
+
return request
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
public func send(_ events: [ClientEvent]) async throws {
|
|
73
|
+
let request = try buildRequest(events)
|
|
74
|
+
let response: URLResponse
|
|
75
|
+
let payload: Data
|
|
76
|
+
do {
|
|
77
|
+
(payload, response) = try await session.data(for: request)
|
|
78
|
+
} catch {
|
|
79
|
+
// No status to reason about. 408 marks it retryable, which is what
|
|
80
|
+
// sdk-core does for the same case.
|
|
81
|
+
throw IngestError(status: 408)
|
|
82
|
+
}
|
|
83
|
+
guard let http = response as? HTTPURLResponse else { throw IngestError(status: 408) }
|
|
84
|
+
guard (200..<300).contains(http.statusCode) else {
|
|
85
|
+
throw IngestError(status: http.statusCode)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Diagnostics only — the batch is already accepted at this point, so a
|
|
89
|
+
// missing or unparseable body must change nothing.
|
|
90
|
+
if let body = try? JSONSerialization.jsonObject(with: payload) as? [String: Any],
|
|
91
|
+
let appId = body["appId"] as? String, !appId.isEmpty
|
|
92
|
+
{
|
|
93
|
+
onAccepted(appId)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Survives uninstall — Keychain.
|
|
4
|
+
public let DEVICE_ID_KEY = "advenue.device_id"
|
|
5
|
+
/// Dies with the app — UserDefaults.
|
|
6
|
+
public let INSTALLATION_ID_KEY = "advenue.installation_id"
|
|
7
|
+
|
|
8
|
+
public enum IdentityResolution: Equatable, Sendable {
|
|
9
|
+
case resolved(deviceId: String, installationId: String)
|
|
10
|
+
/// The Keychain could not be read. The caller waits for
|
|
11
|
+
/// `protectedDataDidBecomeAvailable` rather than inventing an identity.
|
|
12
|
+
case deferred
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/// Resolves the pair the SDK stamps on every event.
|
|
16
|
+
///
|
|
17
|
+
/// The asymmetry is the design: `device_id` lives in the Keychain and survives
|
|
18
|
+
/// uninstall, `installation_id` lives in UserDefaults and does not. Reinstall
|
|
19
|
+
/// detection rests entirely on that difference, so neither half may quietly
|
|
20
|
+
/// move to the other store.
|
|
21
|
+
///
|
|
22
|
+
/// A locked Keychain returns `.deferred` and mints nothing. Treating it as
|
|
23
|
+
/// absent is what would stamp a whole background launch with a device id
|
|
24
|
+
/// belonging to no real device — reachable since #26 introduced silent push,
|
|
25
|
+
/// which can start the app before the first unlock after a reboot.
|
|
26
|
+
public func resolveIdentity(
|
|
27
|
+
secure: any SecureStore,
|
|
28
|
+
store: any KeyValueStore,
|
|
29
|
+
uuid: any UUIDSource
|
|
30
|
+
) -> IdentityResolution {
|
|
31
|
+
let deviceId: String
|
|
32
|
+
switch secure.read(DEVICE_ID_KEY) {
|
|
33
|
+
case .found(let existing):
|
|
34
|
+
deviceId = existing
|
|
35
|
+
case .unavailable:
|
|
36
|
+
return .deferred
|
|
37
|
+
case .absent:
|
|
38
|
+
let minted = uuid.next()
|
|
39
|
+
secure.write(minted, forKey: DEVICE_ID_KEY)
|
|
40
|
+
deviceId = minted
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let installationId: String
|
|
44
|
+
if let existing = store.string(forKey: INSTALLATION_ID_KEY) {
|
|
45
|
+
installationId = existing
|
|
46
|
+
} else {
|
|
47
|
+
installationId = uuid.next()
|
|
48
|
+
store.set(installationId, forKey: INSTALLATION_ID_KEY)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return .resolved(deviceId: deviceId, installationId: installationId)
|
|
52
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import CryptoKit
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// Spec §5: the install waits this long for enrichment and no longer. Matches
|
|
5
|
+
/// the deadlines the RN SDK already uses (SKAN config 3000 ms).
|
|
6
|
+
public let INSTALL_WINDOW_MS = 3_000
|
|
7
|
+
|
|
8
|
+
/// What the install event carries beyond the envelope every event has.
|
|
9
|
+
public struct Enrichment: Sendable, Equatable {
|
|
10
|
+
public var attestation: AttestationResult?
|
|
11
|
+
public var attestationChallenge: String?
|
|
12
|
+
public var idfa: String?
|
|
13
|
+
public var vendorId: String?
|
|
14
|
+
public var appInstanceId: String?
|
|
15
|
+
public var adservicesToken: String?
|
|
16
|
+
/// B2: reklam takibi sınırlama bayrağı — düşürülüyordu, artık olaya işlenir.
|
|
17
|
+
public var limitAdTracking: Bool
|
|
18
|
+
|
|
19
|
+
public init(
|
|
20
|
+
idfa: String? = nil, vendorId: String? = nil,
|
|
21
|
+
appInstanceId: String? = nil, adservicesToken: String? = nil,
|
|
22
|
+
attestation: AttestationResult? = nil, attestationChallenge: String? = nil,
|
|
23
|
+
limitAdTracking: Bool = false
|
|
24
|
+
) {
|
|
25
|
+
self.attestation = attestation
|
|
26
|
+
self.attestationChallenge = attestationChallenge
|
|
27
|
+
self.idfa = idfa
|
|
28
|
+
self.vendorId = vendorId
|
|
29
|
+
self.appInstanceId = appInstanceId
|
|
30
|
+
self.adservicesToken = adservicesToken
|
|
31
|
+
self.limitAdTracking = limitAdTracking
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// The capabilities the collector draws on, injected so the deadline behaviour
|
|
36
|
+
/// is testable without ATT, AdServices or Firebase — none of which a simulator
|
|
37
|
+
/// can exercise.
|
|
38
|
+
public struct EnrichmentSources: Sendable {
|
|
39
|
+
public var searchAdsToken: @Sendable () async -> String?
|
|
40
|
+
public var advertisingId: @Sendable () -> (idfa: String?, vendorId: String?)
|
|
41
|
+
/// B2: ATT sınırlama bayrağı. Ayrı kapanış — advertisingId demetini
|
|
42
|
+
/// değiştirmek mevcut sahte kaynakları kırardı.
|
|
43
|
+
public var limitAdTracking: @Sendable () -> Bool
|
|
44
|
+
public var appInstanceId: @Sendable () async -> String?
|
|
45
|
+
/// Returns the attested challenge and its result, or nil. Every failure path
|
|
46
|
+
/// — unsupported device, challenge fetch, attest — yields nil, because an
|
|
47
|
+
/// install held for attestation is an install lost.
|
|
48
|
+
public var attestation: @Sendable () async -> (challenge: String, result: AttestationResult)?
|
|
49
|
+
|
|
50
|
+
public init(
|
|
51
|
+
searchAdsToken: @escaping @Sendable () async -> String?,
|
|
52
|
+
advertisingId: @escaping @Sendable () -> (idfa: String?, vendorId: String?),
|
|
53
|
+
appInstanceId: @escaping @Sendable () async -> String?,
|
|
54
|
+
attestation: @escaping @Sendable () async -> (challenge: String, result: AttestationResult)? = {
|
|
55
|
+
nil
|
|
56
|
+
},
|
|
57
|
+
limitAdTracking: @escaping @Sendable () -> Bool = { false }
|
|
58
|
+
) {
|
|
59
|
+
self.searchAdsToken = searchAdsToken
|
|
60
|
+
self.advertisingId = advertisingId
|
|
61
|
+
self.appInstanceId = appInstanceId
|
|
62
|
+
self.attestation = attestation
|
|
63
|
+
self.limitAdTracking = limitAdTracking
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// Production wiring. `AdvertisingIdentity.advertisingId` already returns nil
|
|
67
|
+
/// unless ATT is authorized, so the SDK never ships the zero UUID — which
|
|
68
|
+
/// would be worse than nothing, because it looks like an identifier.
|
|
69
|
+
public static func system(
|
|
70
|
+
appInstanceIdProvider: (@Sendable () async -> String?)? = nil,
|
|
71
|
+
attestation: (@Sendable () async -> (challenge: String, result: AttestationResult)?)? = nil
|
|
72
|
+
) -> EnrichmentSources {
|
|
73
|
+
EnrichmentSources(
|
|
74
|
+
searchAdsToken: { await SearchAdsTokenFetcher().token() },
|
|
75
|
+
advertisingId: {
|
|
76
|
+
let identity = AdvertisingIdentity()
|
|
77
|
+
return (idfa: identity.advertisingId, vendorId: identity.vendorId)
|
|
78
|
+
},
|
|
79
|
+
appInstanceId: { await appInstanceIdProvider?() },
|
|
80
|
+
attestation: { await attestation?() },
|
|
81
|
+
limitAdTracking: { AdvertisingIdentity().limitAdTracking })
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// Collects what is ready before `deadlineMs` and returns; a source still
|
|
86
|
+
/// running when the deadline expires contributes nothing.
|
|
87
|
+
///
|
|
88
|
+
/// The deadline is hard by design (spec §5). The AdServices retry can still be
|
|
89
|
+
/// in flight when it expires, and the install is sent regardless: a token that
|
|
90
|
+
/// arrives afterwards rides the next event, whereas a delayed install shifts
|
|
91
|
+
/// every attribution window behind it.
|
|
92
|
+
public func collectEnrichment(
|
|
93
|
+
_ sources: EnrichmentSources,
|
|
94
|
+
deadlineMs: Int
|
|
95
|
+
) async -> Enrichment {
|
|
96
|
+
// Synchronous reads; no reason to race them.
|
|
97
|
+
let ids = sources.advertisingId()
|
|
98
|
+
var result = Enrichment(
|
|
99
|
+
idfa: ids.idfa, vendorId: ids.vendorId,
|
|
100
|
+
limitAdTracking: sources.limitAdTracking())
|
|
101
|
+
|
|
102
|
+
await withTaskGroup(of: (String, String?).self) { group in
|
|
103
|
+
group.addTask { ("adservices", await sources.searchAdsToken()) }
|
|
104
|
+
group.addTask { ("appInstance", await sources.appInstanceId()) }
|
|
105
|
+
group.addTask {
|
|
106
|
+
try? await Task.sleep(nanoseconds: UInt64(deadlineMs) * 1_000_000)
|
|
107
|
+
return ("deadline", nil)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
var settled = 0
|
|
111
|
+
for await (key, value) in group {
|
|
112
|
+
if key == "deadline" { break }
|
|
113
|
+
if key == "adservices" { result.adservicesToken = value }
|
|
114
|
+
if key == "appInstance" { result.appInstanceId = value }
|
|
115
|
+
settled += 1
|
|
116
|
+
if settled == 2 { break }
|
|
117
|
+
}
|
|
118
|
+
group.cancelAll()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Attestation is two round trips — challenge, then attest — and is raced by
|
|
122
|
+
// the same deadline as everything else. An `async let` awaited here would
|
|
123
|
+
// NOT be bounded: it would hold the install for as long as Apple took, which
|
|
124
|
+
// is the one thing enrichment must never do.
|
|
125
|
+
if let attested = await withDeadline(ms: deadlineMs, { await sources.attestation() }) {
|
|
126
|
+
result.attestation = attested?.result
|
|
127
|
+
result.attestationChallenge = attested?.challenge
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return result
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/// Runs `work` and gives up at the deadline. Returns nil when the deadline won,
|
|
134
|
+
/// which the caller treats the same as "the source had nothing".
|
|
135
|
+
func withDeadline<T: Sendable>(
|
|
136
|
+
ms: Int, _ work: @escaping @Sendable () async -> T
|
|
137
|
+
) async -> T? {
|
|
138
|
+
await withTaskGroup(of: Optional<T>.self) { group in
|
|
139
|
+
group.addTask { await work() }
|
|
140
|
+
group.addTask {
|
|
141
|
+
try? await Task.sleep(nanoseconds: UInt64(ms) * 1_000_000)
|
|
142
|
+
return nil
|
|
143
|
+
}
|
|
144
|
+
let first = await group.next() ?? nil
|
|
145
|
+
group.cancelAll()
|
|
146
|
+
return first
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
/// Lowercase hex SHA-256. Used for the Meta AEM `sourceUrlHash`, which both the
|
|
152
|
+
/// SDK and the server dedup on, so the two must agree byte for byte.
|
|
153
|
+
public func sha256Hex(_ value: String) -> String {
|
|
154
|
+
SHA256.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined()
|
|
155
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Security
|
|
3
|
+
|
|
4
|
+
/// The answer to a secure read. The three cases are genuinely different, and
|
|
5
|
+
/// collapsing `unavailable` into `absent` is the bug this type exists to
|
|
6
|
+
/// prevent.
|
|
7
|
+
public enum SecureReadResult: Equatable, Sendable {
|
|
8
|
+
/// A value is stored and readable.
|
|
9
|
+
case found(String)
|
|
10
|
+
/// No such item. A caller may mint a new identity.
|
|
11
|
+
case absent
|
|
12
|
+
/// The item exists or may exist, but it cannot be read —
|
|
13
|
+
/// `errSecInteractionNotAllowed` on an `AfterFirstUnlock` item before the
|
|
14
|
+
/// first unlock after a reboot. A caller MUST NOT mint a new identity:
|
|
15
|
+
/// silent push can background-launch the app in exactly this window, and a
|
|
16
|
+
/// fresh device id there means a phantom device and a phantom install.
|
|
17
|
+
case unavailable
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/// Storage that survives app uninstall. `device_id` lives here; the
|
|
21
|
+
/// installation id and install flag live in UserDefaults precisely because
|
|
22
|
+
/// they must NOT survive — reinstall detection rests on that asymmetry.
|
|
23
|
+
public protocol SecureStore: Sendable {
|
|
24
|
+
func read(_ key: String) -> SecureReadResult
|
|
25
|
+
func write(_ value: String, forKey key: String)
|
|
26
|
+
func delete(_ key: String)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public struct KeychainStore: SecureStore {
|
|
30
|
+
/// Shared with the App Attest key id under a different account, matching the
|
|
31
|
+
/// RN module. Same service, distinct accounts, so neither can read the
|
|
32
|
+
/// other's entry.
|
|
33
|
+
public static let service = "io.advenue.sdk"
|
|
34
|
+
|
|
35
|
+
public init() {}
|
|
36
|
+
|
|
37
|
+
private func baseQuery(_ key: String) -> [String: Any] {
|
|
38
|
+
[
|
|
39
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
40
|
+
kSecAttrService as String: Self.service,
|
|
41
|
+
kSecAttrAccount as String: key,
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// Maps a Keychain result to the three answers the SDK distinguishes.
|
|
46
|
+
///
|
|
47
|
+
/// Split out as a pure function because it is the part that decides whether
|
|
48
|
+
/// a device id gets invented, and it is testable without Keychain access —
|
|
49
|
+
/// which an unsigned XCTest bundle in the simulator does not have
|
|
50
|
+
/// (`errSecMissingEntitlement`). The round-trip needs a signed host app or a
|
|
51
|
+
/// real device; this decision does not, and it is where the bug was.
|
|
52
|
+
public static func interpret(status: OSStatus, data: Data?) -> SecureReadResult {
|
|
53
|
+
switch status {
|
|
54
|
+
case errSecSuccess:
|
|
55
|
+
guard let data, let value = String(data: data, encoding: .utf8) else { return .absent }
|
|
56
|
+
return .found(value)
|
|
57
|
+
case errSecItemNotFound:
|
|
58
|
+
return .absent
|
|
59
|
+
default:
|
|
60
|
+
// errSecInteractionNotAllowed (locked), errSecMissingEntitlement, and
|
|
61
|
+
// every other error land here. Treating any of them as "no value" is
|
|
62
|
+
// what would invent a device.
|
|
63
|
+
return .unavailable
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
public func read(_ key: String) -> SecureReadResult {
|
|
68
|
+
var query = baseQuery(key)
|
|
69
|
+
query[kSecReturnData as String] = true
|
|
70
|
+
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
71
|
+
var result: AnyObject?
|
|
72
|
+
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
73
|
+
return Self.interpret(status: status, data: result as? Data)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
public func write(_ value: String, forKey key: String) {
|
|
77
|
+
guard let data = value.data(using: .utf8) else { return }
|
|
78
|
+
let base = baseQuery(key)
|
|
79
|
+
let status = SecItemUpdate(
|
|
80
|
+
base as CFDictionary, [kSecValueData as String: data] as CFDictionary)
|
|
81
|
+
if status == errSecItemNotFound {
|
|
82
|
+
var add = base
|
|
83
|
+
add[kSecValueData as String] = data
|
|
84
|
+
// AfterFirstUnlock so a background launch after reboot can read it;
|
|
85
|
+
// ThisDeviceOnly so an iCloud restore cannot put one device's identity
|
|
86
|
+
// on another, which would merge two devices into one.
|
|
87
|
+
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
|
88
|
+
SecItemAdd(add as CFDictionary, nil)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
public func delete(_ key: String) {
|
|
93
|
+
SecItemDelete(baseQuery(key) as CFDictionary)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
#if canImport(AdServices)
|
|
4
|
+
import AdServices
|
|
5
|
+
#endif
|
|
6
|
+
|
|
7
|
+
public enum SearchAdsError: Error, Sendable {
|
|
8
|
+
/// Retrying may succeed — the token is often unavailable for a few seconds
|
|
9
|
+
/// after install.
|
|
10
|
+
case transient
|
|
11
|
+
/// Retrying cannot help.
|
|
12
|
+
case unsupported
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/// Fetches the Apple Search Ads attribution token, retrying transient
|
|
16
|
+
/// failures.
|
|
17
|
+
///
|
|
18
|
+
/// `AAAttribution.attributionToken()` can fail immediately after install;
|
|
19
|
+
/// field reports put the transient internal error at roughly 8% of users. The
|
|
20
|
+
/// RN module calls it once and rejects on any throw, so those installs
|
|
21
|
+
/// silently become organic and Apple Search Ads spend goes unattributed.
|
|
22
|
+
///
|
|
23
|
+
/// The retry deliberately outlives the caller's first-open deadline: the
|
|
24
|
+
/// install is never held for it, and a token obtained on a later attempt
|
|
25
|
+
/// travels on the next event. Holding the install instead would shift
|
|
26
|
+
/// attribution windows, which is worse than a less enriched install.
|
|
27
|
+
public struct SearchAdsTokenFetcher: Sendable {
|
|
28
|
+
private let attempts: Int
|
|
29
|
+
private let delayMs: Int
|
|
30
|
+
private let fetch: @Sendable () throws -> String
|
|
31
|
+
|
|
32
|
+
public init(
|
|
33
|
+
attempts: Int = 3,
|
|
34
|
+
delayMs: Int = 1000,
|
|
35
|
+
fetch: (@Sendable () throws -> String)? = nil
|
|
36
|
+
) {
|
|
37
|
+
self.attempts = attempts
|
|
38
|
+
self.delayMs = delayMs
|
|
39
|
+
self.fetch = fetch ?? Self.systemFetch
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@Sendable
|
|
43
|
+
private static func systemFetch() throws -> String {
|
|
44
|
+
#if canImport(AdServices)
|
|
45
|
+
if #available(iOS 14.3, *) {
|
|
46
|
+
do {
|
|
47
|
+
return try AAAttribution.attributionToken()
|
|
48
|
+
} catch {
|
|
49
|
+
throw SearchAdsError.transient
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
throw SearchAdsError.unsupported
|
|
53
|
+
#else
|
|
54
|
+
throw SearchAdsError.unsupported
|
|
55
|
+
#endif
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
public func token() async -> String? {
|
|
59
|
+
for attempt in 1...attempts {
|
|
60
|
+
do {
|
|
61
|
+
return try fetch()
|
|
62
|
+
} catch SearchAdsError.unsupported {
|
|
63
|
+
return nil
|
|
64
|
+
} catch {
|
|
65
|
+
guard attempt < attempts else { return nil }
|
|
66
|
+
// 1 s, 2 s, 4 s — long enough for the system to settle, bounded so a
|
|
67
|
+
// permanently broken device does not retry forever.
|
|
68
|
+
let backoff = delayMs * (1 << (attempt - 1))
|
|
69
|
+
if backoff > 0 {
|
|
70
|
+
try? await Task.sleep(nanoseconds: UInt64(backoff) * 1_000_000)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return nil
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Matches the deadline the RN SDK uses. The install does not wait for this
|
|
4
|
+
/// beyond it — a config that arrives late applies to the next event.
|
|
5
|
+
public let SKAN_CONFIG_TIMEOUT_MS = 3_000
|
|
6
|
+
|
|
7
|
+
/// Fetches the conversion-value config the dashboard serves.
|
|
8
|
+
///
|
|
9
|
+
/// This is what makes SKAN configurable at all: a conversion schema is tuned
|
|
10
|
+
/// constantly and an app release cycle is weeks, so a config that can only be
|
|
11
|
+
/// changed by shipping a new binary is a config nobody changes.
|
|
12
|
+
public struct HttpSkanConfigFetcher: SkanConfigSource {
|
|
13
|
+
private let endpoint: String
|
|
14
|
+
private let apiKey: String
|
|
15
|
+
private let session: URLSession
|
|
16
|
+
|
|
17
|
+
public init(
|
|
18
|
+
endpoint: String = DEFAULT_ENDPOINT, apiKey: String, session: URLSession = .shared
|
|
19
|
+
) {
|
|
20
|
+
self.endpoint = endpoint
|
|
21
|
+
self.apiKey = apiKey
|
|
22
|
+
self.session = session
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
public func fetch(etag: String?) async throws -> SkanConfigResult {
|
|
26
|
+
var request = URLRequest(url: URL(string: "\(endpoint)/v1/sdk-config")!)
|
|
27
|
+
request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
|
|
28
|
+
if let etag { request.setValue(etag, forHTTPHeaderField: "if-none-match") }
|
|
29
|
+
request.timeoutInterval = Double(SKAN_CONFIG_TIMEOUT_MS) / 1000
|
|
30
|
+
|
|
31
|
+
let (payload, response) = try await session.data(for: request)
|
|
32
|
+
guard let http = response as? HTTPURLResponse else { throw IngestError(status: 408) }
|
|
33
|
+
if http.statusCode == 304 { return .notModified }
|
|
34
|
+
guard (200..<300).contains(http.statusCode) else {
|
|
35
|
+
throw IngestError(status: http.statusCode)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
guard let body = try? JSONSerialization.jsonObject(with: payload) as? [String: Any],
|
|
39
|
+
let version = body["version"] as? Int, version >= 0,
|
|
40
|
+
let skan = body["skan"] as? [String: Any]
|
|
41
|
+
else { throw IngestError(status: 422) }
|
|
42
|
+
|
|
43
|
+
return .fetched(
|
|
44
|
+
RemoteSkanConfig(
|
|
45
|
+
version: version,
|
|
46
|
+
rules: try decodeRules(skan),
|
|
47
|
+
etag: http.value(forHTTPHeaderField: "etag")))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// Decoded by hand rather than through Codable: the server's shape is a JSON
|
|
51
|
+
/// object the dashboard writes, and a decoding failure must be an ordinary
|
|
52
|
+
/// rejected config rather than an exception nobody catches.
|
|
53
|
+
private func decodeRules(_ raw: [String: Any]) throws -> ConversionValueConfig {
|
|
54
|
+
let rawRules = raw["rules"] as? [[String: Any]] ?? []
|
|
55
|
+
let rules: [ConversionValueRule] = rawRules.compactMap { rule in
|
|
56
|
+
guard let fine = rule["fineValue"] as? Int else { return nil }
|
|
57
|
+
return ConversionValueRule(
|
|
58
|
+
fineValue: fine,
|
|
59
|
+
coarseValue: (rule["coarseValue"] as? String).flatMap(CoarseValue.init(rawValue:)),
|
|
60
|
+
minRevenueMicros: rule["minRevenueMicros"] as? String,
|
|
61
|
+
events: rule["events"] as? [String])
|
|
62
|
+
}
|
|
63
|
+
guard rules.count == rawRules.count else { throw IngestError(status: 422) }
|
|
64
|
+
|
|
65
|
+
return ConversionValueConfig(
|
|
66
|
+
rules: rules,
|
|
67
|
+
defaultCoarse: (raw["defaultCoarse"] as? String).flatMap(CoarseValue.init(rawValue:)),
|
|
68
|
+
revenueCurrency: raw["revenueCurrency"] as? String)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
#if os(iOS)
|
|
4
|
+
import StoreKit
|
|
5
|
+
|
|
6
|
+
#if canImport(AdAttributionKit)
|
|
7
|
+
import AdAttributionKit
|
|
8
|
+
#endif
|
|
9
|
+
#endif
|
|
10
|
+
|
|
11
|
+
/// SKAdNetwork through StoreKit.
|
|
12
|
+
///
|
|
13
|
+
/// Three API generations, and an OS with none of them must do nothing rather
|
|
14
|
+
/// than trap: an SDK that crashes on an old device takes the host app down with
|
|
15
|
+
/// it, and SKAN is the least important thing it does.
|
|
16
|
+
///
|
|
17
|
+
/// On macOS — where the host test suite runs — SKAdNetwork does not exist at
|
|
18
|
+
/// all, so this degrades to nothing. `canImport(StoreKit)` is the wrong gate
|
|
19
|
+
/// and compiles happily before failing on the symbol: StoreKit is there, SKAN
|
|
20
|
+
/// is not.
|
|
21
|
+
///
|
|
22
|
+
/// **What a simulator does here, measured rather than assumed:** it does NOT
|
|
23
|
+
/// silently succeed. `updatePostbackConversionValue` answers
|
|
24
|
+
/// `SKANErrorDomain 10` when there is no ad impression to attach a value to,
|
|
25
|
+
/// which is also what a real device with no impression does. So the simulator
|
|
26
|
+
/// exercises the SDK's degradation path for real — the caller abandons the
|
|
27
|
+
/// value and reports it — and that is worth more than the no-op this comment
|
|
28
|
+
/// originally claimed.
|
|
29
|
+
///
|
|
30
|
+
/// What it still cannot settle is the success path: that a postback arrives
|
|
31
|
+
/// carrying the value we sent needs a device with a signed impression, and it
|
|
32
|
+
/// is on the checklist rather than implied here.
|
|
33
|
+
public struct StoreKitSkanReporter: SkanReporter {
|
|
34
|
+
private let onError: @Sendable (String, any Error) -> Void
|
|
35
|
+
private let adAttributionUpdate: @Sendable (Int, CoarseValue, Bool) async throws -> Void
|
|
36
|
+
|
|
37
|
+
public init(
|
|
38
|
+
onError: @escaping @Sendable (String, any Error) -> Void = { _, _ in },
|
|
39
|
+
adAttributionUpdate: (@Sendable (Int, CoarseValue, Bool) async throws -> Void)? = nil
|
|
40
|
+
) {
|
|
41
|
+
self.onError = onError
|
|
42
|
+
self.adAttributionUpdate = adAttributionUpdate ?? Self.systemAdAttributionUpdate
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// AdAttributionKit, iOS 17.4+. An impression may be signed for either rail,
|
|
46
|
+
/// so both are updated rather than one chosen.
|
|
47
|
+
///
|
|
48
|
+
/// Injectable because the invariant that matters cannot otherwise be tested:
|
|
49
|
+
/// a failure here is **expected** whenever no AAK-signed impression exists,
|
|
50
|
+
/// and it must not fail the SKAN update. If it did, a device with only a
|
|
51
|
+
/// SKAN-signed impression would never confirm its value and that window would
|
|
52
|
+
/// report nothing for the rest of its life.
|
|
53
|
+
@Sendable
|
|
54
|
+
private static func systemAdAttributionUpdate(
|
|
55
|
+
fine: Int, coarse: CoarseValue, lockWindow: Bool
|
|
56
|
+
) async throws {
|
|
57
|
+
#if os(iOS) && canImport(AdAttributionKit)
|
|
58
|
+
if #available(iOS 17.4, *) {
|
|
59
|
+
try await Postback.updateConversionValue(
|
|
60
|
+
fine, coarseConversionValue: coarse.adAttributionValue,
|
|
61
|
+
lockPostback: lockWindow)
|
|
62
|
+
}
|
|
63
|
+
#endif
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
public func register() {
|
|
67
|
+
#if os(iOS)
|
|
68
|
+
if #available(iOS 16.1, *) {
|
|
69
|
+
// The modern form registers implicitly on the first conversion update,
|
|
70
|
+
// but calling it explicitly is what starts the window for an install
|
|
71
|
+
// that never converts — which is most of them.
|
|
72
|
+
SKAdNetwork.updatePostbackConversionValue(0) { error in
|
|
73
|
+
if let error { onError("skan.register", error) }
|
|
74
|
+
}
|
|
75
|
+
} else if #available(iOS 14.0, *) {
|
|
76
|
+
SKAdNetwork.registerAppForAdNetworkAttribution()
|
|
77
|
+
}
|
|
78
|
+
#endif
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
public func update(fine: Int, coarse: CoarseValue, lockWindow: Bool) async throws {
|
|
82
|
+
// AdAttributionKit first, and its failure is swallowed on purpose: it is
|
|
83
|
+
// the expected answer when no AAK-signed impression exists. Letting it
|
|
84
|
+
// propagate would abandon a perfectly good SKAN value.
|
|
85
|
+
do {
|
|
86
|
+
try await adAttributionUpdate(fine, coarse, lockWindow)
|
|
87
|
+
} catch {
|
|
88
|
+
onError("skan.adAttributionKit", error)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#if os(iOS)
|
|
92
|
+
if #available(iOS 16.1, *) {
|
|
93
|
+
try await SKAdNetwork.updatePostbackConversionValue(
|
|
94
|
+
fine, coarseValue: coarse.storeKitValue, lockWindow: lockWindow)
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
if #available(iOS 15.4, *) {
|
|
98
|
+
// No coarse value before 16.1. Sending the fine value alone is still
|
|
99
|
+
// worth doing: v3-signed ads read it.
|
|
100
|
+
try await SKAdNetwork.updatePostbackConversionValue(fine)
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
if #available(iOS 14.0, *) {
|
|
104
|
+
SKAdNetwork.updateConversionValue(fine)
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
#endif
|
|
108
|
+
// No SKAN on this OS. Not an error worth surfacing — there is nothing the
|
|
109
|
+
// app or the SDK could do about it.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#if os(iOS) && canImport(AdAttributionKit)
|
|
114
|
+
extension CoarseValue {
|
|
115
|
+
/// AdAttributionKit's own enum, distinct from StoreKit's.
|
|
116
|
+
@available(iOS 17.4, *)
|
|
117
|
+
var adAttributionValue: AdAttributionKit.CoarseConversionValue {
|
|
118
|
+
switch self {
|
|
119
|
+
case .low: return .low
|
|
120
|
+
case .medium: return .medium
|
|
121
|
+
case .high: return .high
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
#endif
|
|
126
|
+
|
|
127
|
+
#if os(iOS)
|
|
128
|
+
extension CoarseValue {
|
|
129
|
+
/// Apple's own enum. Mapping this wrong silently files every postback in
|
|
130
|
+
/// the wrong bucket, and nothing downstream can detect it.
|
|
131
|
+
@available(iOS 16.1, *)
|
|
132
|
+
var storeKitValue: SKAdNetwork.CoarseConversionValue {
|
|
133
|
+
switch self {
|
|
134
|
+
case .low: return .low
|
|
135
|
+
case .medium: return .medium
|
|
136
|
+
case .high: return .high
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
#endif
|