@advenue/react-native 0.8.0 → 1.0.0
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 +118 -347
- package/android/src/main/kotlin/io/advenue/Advenue.kt +549 -0
- package/android/src/main/kotlin/io/advenue/AdvenueConfig.kt +114 -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 +186 -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 +414 -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 +47 -0
- package/android/src/main/kotlin/io/advenue/core/MetaReferrer.kt +80 -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 +149 -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 +91 -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 +183 -655
- package/dist/index.d.cts +199 -331
- package/dist/index.d.ts +199 -331
- package/dist/index.js +182 -661
- package/ios/AdvenueIosModule.swift +144 -432
- package/ios/vendor/Advenue/Advenue.swift +596 -0
- package/ios/vendor/Advenue/AdvenueConfig.swift +77 -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 +587 -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/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 +65 -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 +143 -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 +9 -11
- 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 +267 -803
- package/src/native-types.ts +74 -171
- package/src/native.ts +0 -23
- package/src/types.ts +81 -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,143 @@
|
|
|
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
|
+
|
|
17
|
+
public init(
|
|
18
|
+
idfa: String? = nil, vendorId: String? = nil,
|
|
19
|
+
appInstanceId: String? = nil, adservicesToken: String? = nil,
|
|
20
|
+
attestation: AttestationResult? = nil, attestationChallenge: String? = nil
|
|
21
|
+
) {
|
|
22
|
+
self.attestation = attestation
|
|
23
|
+
self.attestationChallenge = attestationChallenge
|
|
24
|
+
self.idfa = idfa
|
|
25
|
+
self.vendorId = vendorId
|
|
26
|
+
self.appInstanceId = appInstanceId
|
|
27
|
+
self.adservicesToken = adservicesToken
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// The capabilities the collector draws on, injected so the deadline behaviour
|
|
32
|
+
/// is testable without ATT, AdServices or Firebase — none of which a simulator
|
|
33
|
+
/// can exercise.
|
|
34
|
+
public struct EnrichmentSources: Sendable {
|
|
35
|
+
public var searchAdsToken: @Sendable () async -> String?
|
|
36
|
+
public var advertisingId: @Sendable () -> (idfa: String?, vendorId: String?)
|
|
37
|
+
public var appInstanceId: @Sendable () async -> String?
|
|
38
|
+
/// Returns the attested challenge and its result, or nil. Every failure path
|
|
39
|
+
/// — unsupported device, challenge fetch, attest — yields nil, because an
|
|
40
|
+
/// install held for attestation is an install lost.
|
|
41
|
+
public var attestation: @Sendable () async -> (challenge: String, result: AttestationResult)?
|
|
42
|
+
|
|
43
|
+
public init(
|
|
44
|
+
searchAdsToken: @escaping @Sendable () async -> String?,
|
|
45
|
+
advertisingId: @escaping @Sendable () -> (idfa: String?, vendorId: String?),
|
|
46
|
+
appInstanceId: @escaping @Sendable () async -> String?,
|
|
47
|
+
attestation: @escaping @Sendable () async -> (challenge: String, result: AttestationResult)? = {
|
|
48
|
+
nil
|
|
49
|
+
}
|
|
50
|
+
) {
|
|
51
|
+
self.searchAdsToken = searchAdsToken
|
|
52
|
+
self.advertisingId = advertisingId
|
|
53
|
+
self.appInstanceId = appInstanceId
|
|
54
|
+
self.attestation = attestation
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// Production wiring. `AdvertisingIdentity.advertisingId` already returns nil
|
|
58
|
+
/// unless ATT is authorized, so the SDK never ships the zero UUID — which
|
|
59
|
+
/// would be worse than nothing, because it looks like an identifier.
|
|
60
|
+
public static func system(
|
|
61
|
+
appInstanceIdProvider: (@Sendable () async -> String?)? = nil,
|
|
62
|
+
attestation: (@Sendable () async -> (challenge: String, result: AttestationResult)?)? = nil
|
|
63
|
+
) -> EnrichmentSources {
|
|
64
|
+
EnrichmentSources(
|
|
65
|
+
searchAdsToken: { await SearchAdsTokenFetcher().token() },
|
|
66
|
+
advertisingId: {
|
|
67
|
+
let identity = AdvertisingIdentity()
|
|
68
|
+
return (idfa: identity.advertisingId, vendorId: identity.vendorId)
|
|
69
|
+
},
|
|
70
|
+
appInstanceId: { await appInstanceIdProvider?() },
|
|
71
|
+
attestation: { await attestation?() })
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// Collects what is ready before `deadlineMs` and returns; a source still
|
|
76
|
+
/// running when the deadline expires contributes nothing.
|
|
77
|
+
///
|
|
78
|
+
/// The deadline is hard by design (spec §5). The AdServices retry can still be
|
|
79
|
+
/// in flight when it expires, and the install is sent regardless: a token that
|
|
80
|
+
/// arrives afterwards rides the next event, whereas a delayed install shifts
|
|
81
|
+
/// every attribution window behind it.
|
|
82
|
+
public func collectEnrichment(
|
|
83
|
+
_ sources: EnrichmentSources,
|
|
84
|
+
deadlineMs: Int
|
|
85
|
+
) async -> Enrichment {
|
|
86
|
+
// Synchronous reads; no reason to race them.
|
|
87
|
+
let ids = sources.advertisingId()
|
|
88
|
+
var result = Enrichment(idfa: ids.idfa, vendorId: ids.vendorId)
|
|
89
|
+
|
|
90
|
+
await withTaskGroup(of: (String, String?).self) { group in
|
|
91
|
+
group.addTask { ("adservices", await sources.searchAdsToken()) }
|
|
92
|
+
group.addTask { ("appInstance", await sources.appInstanceId()) }
|
|
93
|
+
group.addTask {
|
|
94
|
+
try? await Task.sleep(nanoseconds: UInt64(deadlineMs) * 1_000_000)
|
|
95
|
+
return ("deadline", nil)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
var settled = 0
|
|
99
|
+
for await (key, value) in group {
|
|
100
|
+
if key == "deadline" { break }
|
|
101
|
+
if key == "adservices" { result.adservicesToken = value }
|
|
102
|
+
if key == "appInstance" { result.appInstanceId = value }
|
|
103
|
+
settled += 1
|
|
104
|
+
if settled == 2 { break }
|
|
105
|
+
}
|
|
106
|
+
group.cancelAll()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Attestation is two round trips — challenge, then attest — and is raced by
|
|
110
|
+
// the same deadline as everything else. An `async let` awaited here would
|
|
111
|
+
// NOT be bounded: it would hold the install for as long as Apple took, which
|
|
112
|
+
// is the one thing enrichment must never do.
|
|
113
|
+
if let attested = await withDeadline(ms: deadlineMs, { await sources.attestation() }) {
|
|
114
|
+
result.attestation = attested?.result
|
|
115
|
+
result.attestationChallenge = attested?.challenge
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return result
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/// Runs `work` and gives up at the deadline. Returns nil when the deadline won,
|
|
122
|
+
/// which the caller treats the same as "the source had nothing".
|
|
123
|
+
func withDeadline<T: Sendable>(
|
|
124
|
+
ms: Int, _ work: @escaping @Sendable () async -> T
|
|
125
|
+
) async -> T? {
|
|
126
|
+
await withTaskGroup(of: Optional<T>.self) { group in
|
|
127
|
+
group.addTask { await work() }
|
|
128
|
+
group.addTask {
|
|
129
|
+
try? await Task.sleep(nanoseconds: UInt64(ms) * 1_000_000)
|
|
130
|
+
return nil
|
|
131
|
+
}
|
|
132
|
+
let first = await group.next() ?? nil
|
|
133
|
+
group.cancelAll()
|
|
134
|
+
return first
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
/// Lowercase hex SHA-256. Used for the Meta AEM `sourceUrlHash`, which both the
|
|
140
|
+
/// SDK and the server dedup on, so the two must agree byte for byte.
|
|
141
|
+
public func sha256Hex(_ value: String) -> String {
|
|
142
|
+
SHA256.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined()
|
|
143
|
+
}
|
|
@@ -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
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
public struct SystemClock: Clock {
|
|
4
|
+
public init() {}
|
|
5
|
+
public func nowMs() -> Int64 { Int64(Date().timeIntervalSince1970 * 1000) }
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
public struct SystemUUIDs: UUIDSource {
|
|
9
|
+
public init() {}
|
|
10
|
+
public func next() -> String { UUID().uuidString.lowercased() }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/// Real deferred execution.
|
|
14
|
+
///
|
|
15
|
+
/// Satisfies `Scheduler`'s isolation contract by construction: work is
|
|
16
|
+
/// dispatched onto one serial queue, so every scheduled callback runs in the
|
|
17
|
+
/// same domain as every other. The queue's persist debounce is the only user.
|
|
18
|
+
public final class TimerScheduler: Scheduler, @unchecked Sendable {
|
|
19
|
+
private let queue = DispatchQueue(label: "io.advenue.sdk.scheduler")
|
|
20
|
+
private var items: [CancelToken: DispatchWorkItem] = [:]
|
|
21
|
+
private var next: CancelToken = 1
|
|
22
|
+
private let lock = NSLock()
|
|
23
|
+
|
|
24
|
+
public init() {}
|
|
25
|
+
|
|
26
|
+
public func schedule(afterMs: Int, _ work: @escaping () -> Void) -> CancelToken {
|
|
27
|
+
lock.lock()
|
|
28
|
+
let token = next
|
|
29
|
+
next += 1
|
|
30
|
+
lock.unlock()
|
|
31
|
+
|
|
32
|
+
let item = DispatchWorkItem { [weak self] in
|
|
33
|
+
self?.forget(token)
|
|
34
|
+
work()
|
|
35
|
+
}
|
|
36
|
+
lock.lock()
|
|
37
|
+
items[token] = item
|
|
38
|
+
lock.unlock()
|
|
39
|
+
queue.asyncAfter(deadline: .now() + .milliseconds(afterMs), execute: item)
|
|
40
|
+
return token
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public func cancel(_ token: CancelToken) {
|
|
44
|
+
lock.lock()
|
|
45
|
+
let item = items.removeValue(forKey: token)
|
|
46
|
+
lock.unlock()
|
|
47
|
+
item?.cancel()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private func forget(_ token: CancelToken) {
|
|
51
|
+
lock.lock()
|
|
52
|
+
items.removeValue(forKey: token)
|
|
53
|
+
lock.unlock()
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Reads IAB TCF v2 from the **standard** `UserDefaults`, which is where a
|
|
4
|
+
/// TCF-compliant CMP writes per the spec — deliberately not our own suite.
|
|
5
|
+
/// Reading our suite instead would report no consent data for every app on
|
|
6
|
+
/// earth, and do it silently.
|
|
7
|
+
public func readTcf(_ defaults: UserDefaults = .standard) -> Consent? {
|
|
8
|
+
// `object(forKey:)` rather than `integer(forKey:)`: the latter answers 0 for
|
|
9
|
+
// a missing key, which would turn "the CMP has not said" into "GDPR does not
|
|
10
|
+
// apply". Those are different, and the difference decides whether a user is
|
|
11
|
+
// gated.
|
|
12
|
+
let raw = defaults.object(forKey: "IABTCF_gdprApplies")
|
|
13
|
+
let gdprApplies = (raw as? NSNumber)?.intValue
|
|
14
|
+
let purposeConsents = defaults.string(forKey: "IABTCF_PurposeConsents")
|
|
15
|
+
|
|
16
|
+
if raw == nil && purposeConsents == nil { return nil }
|
|
17
|
+
return tcfToConsent(gdprApplies: gdprApplies, purposeConsents: purposeConsents)
|
|
18
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// The SDK's own UserDefaults suite. **This exact name is a compatibility
|
|
4
|
+
/// requirement**, not a preference: the React Native module writes here, and
|
|
5
|
+
/// the RN inversion (sub-project 3) has to find the same queue, session state
|
|
6
|
+
/// and install flag. A different suite silently orphans every install.
|
|
7
|
+
public let ADVENUE_SUITE = "io.advenue.sdk"
|
|
8
|
+
|
|
9
|
+
/// A dedicated suite keeps SDK keys out of the host app's defaults.
|
|
10
|
+
///
|
|
11
|
+
/// `@unchecked Sendable` is an assertion, and this one is true: Apple
|
|
12
|
+
/// documents `UserDefaults` as thread-safe. It is not the same move as
|
|
13
|
+
/// declaring `EventQueue` unchecked would have been — that type genuinely is
|
|
14
|
+
/// not thread-safe, so the assertion would have been false and the compiler
|
|
15
|
+
/// would have been silenced rather than satisfied.
|
|
16
|
+
public struct UserDefaultsStore: KeyValueStore, @unchecked Sendable {
|
|
17
|
+
private let defaults: UserDefaults
|
|
18
|
+
|
|
19
|
+
public init(suiteName: String = ADVENUE_SUITE) {
|
|
20
|
+
self.defaults = UserDefaults(suiteName: suiteName) ?? .standard
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public func string(forKey key: String) -> String? { defaults.string(forKey: key) }
|
|
24
|
+
public func set(_ value: String, forKey key: String) { defaults.set(value, forKey: key) }
|
|
25
|
+
public func removeObject(forKey key: String) { defaults.removeObject(forKey: key) }
|
|
26
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@advenue/react-native",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Advenue MMP React Native SDK — attribution, install tracking, and deep links (bundled iOS/Android Expo native modules)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,15 +23,14 @@
|
|
|
23
23
|
"ios",
|
|
24
24
|
"android",
|
|
25
25
|
"expo-module.config.json",
|
|
26
|
-
"!**/*.test.ts"
|
|
26
|
+
"!**/*.test.ts",
|
|
27
|
+
"ios/vendor",
|
|
28
|
+
"android/src/main/kotlin"
|
|
27
29
|
],
|
|
28
30
|
"publishConfig": {
|
|
29
31
|
"access": "public"
|
|
30
32
|
},
|
|
31
|
-
"dependencies": {
|
|
32
|
-
"@advenue/install-referrer": "0.3.0",
|
|
33
|
-
"@advenue/sdk-core": "0.5.0"
|
|
34
|
-
},
|
|
33
|
+
"dependencies": {},
|
|
35
34
|
"peerDependencies": {
|
|
36
35
|
"@expo/config-plugins": ">=8",
|
|
37
36
|
"react": ">=18.2",
|
|
@@ -49,14 +48,13 @@
|
|
|
49
48
|
"devDependencies": {
|
|
50
49
|
"tsup": "^8.5.1",
|
|
51
50
|
"typescript": "^5.7.2",
|
|
52
|
-
"vitest": "^2.1.8"
|
|
53
|
-
"@advenue/shared": "0.2.0",
|
|
54
|
-
"@advenue/signing": "0.0.0"
|
|
51
|
+
"vitest": "^2.1.8"
|
|
55
52
|
},
|
|
56
53
|
"react-native": "./dist/index.cjs",
|
|
57
54
|
"scripts": {
|
|
58
|
-
"build": "tsup && node scripts/check-dist.mjs",
|
|
55
|
+
"build": "node scripts/vendor-natives.mjs && node scripts/check-vendored-swift.mjs && tsup && node scripts/check-dist.mjs",
|
|
59
56
|
"typecheck": "tsc --noEmit",
|
|
60
|
-
"test": "vitest run"
|
|
57
|
+
"test": "vitest run --minWorkers=1 --maxWorkers=2 && node --test scripts/vendor-natives.test.mjs && node scripts/vendor-natives.mjs && node scripts/check-vendored-swift.mjs",
|
|
58
|
+
"vendor": "node scripts/vendor-natives.mjs"
|
|
61
59
|
}
|
|
62
60
|
}
|