@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.
Files changed (94) hide show
  1. package/README.md +8 -7
  2. package/android/src/main/java/expo/modules/advenue/AdvenueAndroidModule.kt +118 -347
  3. package/android/src/main/kotlin/io/advenue/Advenue.kt +549 -0
  4. package/android/src/main/kotlin/io/advenue/AdvenueConfig.kt +114 -0
  5. package/android/src/main/kotlin/io/advenue/core/Backoff.kt +28 -0
  6. package/android/src/main/kotlin/io/advenue/core/ClientEvent.kt +136 -0
  7. package/android/src/main/kotlin/io/advenue/core/CommandPipe.kt +186 -0
  8. package/android/src/main/kotlin/io/advenue/core/Consent.kt +52 -0
  9. package/android/src/main/kotlin/io/advenue/core/Contracts.kt +80 -0
  10. package/android/src/main/kotlin/io/advenue/core/Conversion.kt +69 -0
  11. package/android/src/main/kotlin/io/advenue/core/Engine.kt +414 -0
  12. package/android/src/main/kotlin/io/advenue/core/EventQueue.kt +89 -0
  13. package/android/src/main/kotlin/io/advenue/core/HmacSigner.kt +31 -0
  14. package/android/src/main/kotlin/io/advenue/core/InstallReferrer.kt +146 -0
  15. package/android/src/main/kotlin/io/advenue/core/Json.kt +272 -0
  16. package/android/src/main/kotlin/io/advenue/core/Limits.kt +47 -0
  17. package/android/src/main/kotlin/io/advenue/core/MetaReferrer.kt +80 -0
  18. package/android/src/main/kotlin/io/advenue/core/SessionTracker.kt +189 -0
  19. package/android/src/main/kotlin/io/advenue/core/SystemServices.kt +58 -0
  20. package/android/src/main/kotlin/io/advenue/core/Tcf.kt +48 -0
  21. package/android/src/main/kotlin/io/advenue/core/Time.kt +56 -0
  22. package/android/src/main/kotlin/io/advenue/platform/Collectors.kt +149 -0
  23. package/android/src/main/kotlin/io/advenue/platform/CompositeStore.kt +33 -0
  24. package/android/src/main/kotlin/io/advenue/platform/ConversionFetcher.kt +68 -0
  25. package/android/src/main/kotlin/io/advenue/platform/ForegroundTracker.kt +90 -0
  26. package/android/src/main/kotlin/io/advenue/platform/HttpUrlTransport.kt +101 -0
  27. package/android/src/main/kotlin/io/advenue/platform/Identity.kt +79 -0
  28. package/android/src/main/kotlin/io/advenue/platform/InstallEnrichment.kt +197 -0
  29. package/android/src/main/kotlin/io/advenue/platform/InstallScopedStore.kt +91 -0
  30. package/android/src/main/kotlin/io/advenue/platform/LifecycleBridge.kt +72 -0
  31. package/android/src/main/kotlin/io/advenue/platform/PreferencesStore.kt +32 -0
  32. package/android/src/main/kotlin/io/advenue/plugin/Contracts.kt +67 -0
  33. package/android/src/main/kotlin/io/advenue/plugin/FirebaseAppInstanceIdSource.kt +73 -0
  34. package/android/src/main/kotlin/io/advenue/plugin/PlayAdvertisingIdSource.kt +47 -0
  35. package/android/src/main/kotlin/io/advenue/plugin/PlayInstallReferrerSource.kt +85 -0
  36. package/android/src/main/kotlin/io/advenue/plugin/PlayIntegritySource.kt +86 -0
  37. package/android/src/main/kotlin/io/advenue/plugin/PluginRegistry.kt +61 -0
  38. package/dist/index.cjs +183 -655
  39. package/dist/index.d.cts +199 -331
  40. package/dist/index.d.ts +199 -331
  41. package/dist/index.js +182 -661
  42. package/ios/AdvenueIosModule.swift +144 -432
  43. package/ios/vendor/Advenue/Advenue.swift +596 -0
  44. package/ios/vendor/Advenue/AdvenueConfig.swift +77 -0
  45. package/ios/vendor/AdvenueCore/AdvenueValue.swift +103 -0
  46. package/ios/vendor/AdvenueCore/Attestation.swift +52 -0
  47. package/ios/vendor/AdvenueCore/Backoff.swift +28 -0
  48. package/ios/vendor/AdvenueCore/ClientEvent.swift +154 -0
  49. package/ios/vendor/AdvenueCore/Consent.swift +40 -0
  50. package/ios/vendor/AdvenueCore/Contracts.swift +59 -0
  51. package/ios/vendor/AdvenueCore/Conversion.swift +74 -0
  52. package/ios/vendor/AdvenueCore/ConversionValue.swift +217 -0
  53. package/ios/vendor/AdvenueCore/Engine.swift +587 -0
  54. package/ios/vendor/AdvenueCore/EventQueue.swift +89 -0
  55. package/ios/vendor/AdvenueCore/Limits.swift +41 -0
  56. package/ios/vendor/AdvenueCore/PIIScrub.swift +102 -0
  57. package/ios/vendor/AdvenueCore/SessionTracker.swift +139 -0
  58. package/ios/vendor/AdvenueCore/SkanConfig.swift +76 -0
  59. package/ios/vendor/AdvenueCore/SkanReporter.swift +25 -0
  60. package/ios/vendor/AdvenueCore/SkanState.swift +258 -0
  61. package/ios/vendor/AdvenueCore/Tcf.swift +48 -0
  62. package/ios/vendor/AdvenueCore/Transport.swift +14 -0
  63. package/ios/vendor/AdvenueFirebase/FirebaseAppInstanceId.swift +33 -0
  64. package/ios/vendor/AdvenuePlatform/AdvertisingIdentity.swift +87 -0
  65. package/ios/vendor/AdvenuePlatform/ChallengeFetcher.swift +42 -0
  66. package/ios/vendor/AdvenuePlatform/ConversionFetcher.swift +63 -0
  67. package/ios/vendor/AdvenuePlatform/CryptoKitSigner.swift +19 -0
  68. package/ios/vendor/AdvenuePlatform/DeviceCheckAttestation.swift +91 -0
  69. package/ios/vendor/AdvenuePlatform/DeviceInfo.swift +65 -0
  70. package/ios/vendor/AdvenuePlatform/ForegroundTracker.swift +55 -0
  71. package/ios/vendor/AdvenuePlatform/HttpTransport.swift +96 -0
  72. package/ios/vendor/AdvenuePlatform/Identity.swift +52 -0
  73. package/ios/vendor/AdvenuePlatform/InstallEnrichment.swift +143 -0
  74. package/ios/vendor/AdvenuePlatform/KeychainStore.swift +95 -0
  75. package/ios/vendor/AdvenuePlatform/SearchAdsToken.swift +76 -0
  76. package/ios/vendor/AdvenuePlatform/SkanConfigFetcher.swift +70 -0
  77. package/ios/vendor/AdvenuePlatform/StoreKitSkanReporter.swift +140 -0
  78. package/ios/vendor/AdvenuePlatform/SystemServices.swift +55 -0
  79. package/ios/vendor/AdvenuePlatform/TcfReader.swift +18 -0
  80. package/ios/vendor/AdvenuePlatform/UserDefaultsStore.swift +26 -0
  81. package/package.json +9 -11
  82. package/scripts/check-dist.mjs +17 -0
  83. package/scripts/check-vendored-swift.mjs +148 -0
  84. package/scripts/vendor-natives.mjs +149 -0
  85. package/scripts/vendor-natives.test.mjs +112 -0
  86. package/src/deep-links.ts +19 -1
  87. package/src/index.ts +267 -803
  88. package/src/native-types.ts +74 -171
  89. package/src/native.ts +0 -23
  90. package/src/types.ts +81 -0
  91. package/src/aem.ts +0 -33
  92. package/src/mmkv-storage.ts +0 -21
  93. package/src/native-storage.ts +0 -63
  94. 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.8.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
  }