@advenue/react-native 0.9.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 -368
  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 -675
  39. package/dist/index.d.cts +199 -336
  40. package/dist/index.d.ts +199 -336
  41. package/dist/index.js +182 -681
  42. package/ios/AdvenueIosModule.swift +144 -446
  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 +266 -830
  88. package/src/native-types.ts +74 -181
  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,41 @@
1
+ import Foundation
2
+
3
+ /// `clientEventSchema.name` — min 1, max 128.
4
+ public let MAX_NAME_LENGTH = 128
5
+ /// `propertiesSchema` — at most this many keys.
6
+ public let MAX_PROPERTIES_KEYS = 50
7
+ /// `propertiesSchema` — serialised length must not exceed this.
8
+ public let MAX_PROPERTIES_BYTES = 8192
9
+
10
+ /// Reasons `track()` refuses an event. Raw values match the TypeScript
11
+ /// implementation so an `onError` context reads the same across SDKs.
12
+ public enum TrackRejection: String, Sendable {
13
+ case nameEmpty = "name_empty"
14
+ case nameTooLong = "name_too_long"
15
+ case propertiesTooManyKeys = "properties_too_many_keys"
16
+ case propertiesTooLarge = "properties_too_large"
17
+ case propertiesUnserialisable = "properties_unserialisable"
18
+ }
19
+
20
+ /// Client-side enforcement of the ingest schema's budgets.
21
+ ///
22
+ /// The server parses a batch as a whole, so an event it will reject costs an
23
+ /// isolation pass — one failed batch POST followed by up to `batchSize`
24
+ /// individual POSTs on a mobile radio — and the event is discarded regardless.
25
+ /// Refusing here loses the same one event and spends nothing.
26
+ ///
27
+ /// Values mirror `packages/shared/src/events.ts`, which is the authority.
28
+ public func checkTrackInput(
29
+ name: String,
30
+ properties: [String: AdvenueValue]?
31
+ ) -> TrackRejection? {
32
+ if name.isEmpty { return .nameEmpty }
33
+ if name.count > MAX_NAME_LENGTH { return .nameTooLong }
34
+ guard let properties else { return nil }
35
+ if properties.count > MAX_PROPERTIES_KEYS { return .propertiesTooManyKeys }
36
+ guard let data = try? EventEncoding.canonicalEncoder().encode(properties) else {
37
+ return .propertiesUnserialisable
38
+ }
39
+ if data.count > MAX_PROPERTIES_BYTES { return .propertiesTooLarge }
40
+ return nil
41
+ }
@@ -0,0 +1,102 @@
1
+ import Foundation
2
+
3
+ /// M2: track properties PII scrubbing (varsayılan-açık).
4
+ ///
5
+ /// Anahtar-adı tabanlı (e-posta/telefon/ad-soyadı çağrıştıran anahtarlar) +
6
+ /// değer-örüntü tabanlı (e-posta/telefon regex) eşleşen değerler gönderilmeden
7
+ /// önce kaldırılır; ham değer ağa çıkmaz. Yalnızca Foundation kullanır, böylece
8
+ /// AdvenueCore'un Linux taşınabilirliği korunur.
9
+ public enum PIIScrub {
10
+ /// Tam-eşleşme yapılan normalize anahtarlar (küçük harf, alfanümerik dışı
11
+ /// karakterler atılmış). Kısa anahtarlar (`ad`, `tel`) bilinçli olarak
12
+ /// listededir: spec'in anahtar-adı sözleşmesi bunu gerektirir.
13
+ private static let blockedKeys: Set<String> = [
14
+ "email", "emailaddress", "emailadres", "eposta", "mail",
15
+ "phone", "phonenumber", "mobile", "mobilenumber", "mobil",
16
+ "telefon", "telefonno", "tel", "telno", "gsm",
17
+ "firstname", "lastname", "ad", "soyad", "adsoyad", "adisoyadi",
18
+ "fullname", "namesurname", "isim", "isimsoyisim",
19
+ ]
20
+
21
+ /// Alt-dize eşleşmesi yapılan parçalar: `user_email`, `phone_number` gibi
22
+ /// bileşik anahtarları yakalar.
23
+ private static let blockedSubstrings = [
24
+ "email", "phone", "telefon", "soyad", "adsoyad", "firstname", "lastname",
25
+ "fullname",
26
+ ]
27
+
28
+ private static let emailPattern =
29
+ "[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}"
30
+
31
+ private static func normalizeKey(_ key: String) -> String {
32
+ key.lowercased().filter { $0.isLetter || $0.isNumber }
33
+ }
34
+
35
+ static func isSensitiveKey(_ key: String) -> Bool {
36
+ let normalized = normalizeKey(key)
37
+ if blockedKeys.contains(normalized) { return true }
38
+ return blockedSubstrings.contains { normalized.contains($0) }
39
+ }
40
+
41
+ private static func matches(_ value: String, pattern: String) -> Bool {
42
+ guard
43
+ let regex = try? NSRegularExpression(
44
+ pattern: pattern, options: [.caseInsensitive])
45
+ else { return false }
46
+ let range = NSRange(value.startIndex..., in: value)
47
+ return regex.firstMatch(in: value, options: [], range: range) != nil
48
+ }
49
+
50
+ /// Telefon sezgiseli: çapasız alt-dize araması YOK — o yaklaşım hex hash
51
+ /// (`sourceUrlHash`), tarih (`2026-09-08`) ve sürüm dizgelerindeki rakam
52
+ /// dizilerini telefon sanıp PII olmayan özellikleri siliyordu. Değerin TÜMÜ
53
+ /// telefon karakterlerinden oluşmalı ve rakam sayısı E.164 aralığında
54
+ /// (10–15) olmalı.
55
+ private static func looksLikePhone(_ value: String) -> Bool {
56
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
57
+ guard !trimmed.isEmpty else { return false }
58
+ let allowed = CharacterSet(charactersIn: "+0123456789 \t.-()")
59
+ guard trimmed.unicodeScalars.allSatisfy({ allowed.contains($0) }) else {
60
+ return false
61
+ }
62
+ guard let first = trimmed.first, first == "+" || first.isNumber,
63
+ let last = trimmed.last, last.isNumber
64
+ else { return false }
65
+ return (10...15).contains(trimmed.filter { $0.isNumber }.count)
66
+ }
67
+
68
+ /// Dize değer PII örüntüsü taşıyorsa true döner.
69
+ public static func isSensitiveValue(_ value: String) -> Bool {
70
+ matches(value, pattern: emailPattern) || looksLikePhone(value)
71
+ }
72
+
73
+ private static func scrubValue(_ value: AdvenueValue) -> AdvenueValue? {
74
+ switch value {
75
+ case .string(let s):
76
+ return isSensitiveValue(s) ? nil : value
77
+ case .array(let items):
78
+ return .array(items.compactMap { scrubValue($0) })
79
+ case .object(let dict):
80
+ guard let cleaned = scrub(dict) else { return nil }
81
+ return .object(cleaned)
82
+ case .int, .double, .bool, .null:
83
+ return value
84
+ }
85
+ }
86
+
87
+ /// PII taşıyan girdileri kaldırır. Girdi nil ise nil döner.
88
+ public static func scrub(
89
+ _ properties: [String: AdvenueValue]?
90
+ ) -> [String: AdvenueValue]? {
91
+ guard let properties else { return nil }
92
+ var kept: [String: AdvenueValue] = [:]
93
+ kept.reserveCapacity(properties.count)
94
+ for (key, value) in properties {
95
+ if isSensitiveKey(key) { continue }
96
+ if let cleaned = scrubValue(value) {
97
+ kept[key] = cleaned
98
+ }
99
+ }
100
+ return kept
101
+ }
102
+ }
@@ -0,0 +1,139 @@
1
+ import Foundation
2
+
3
+ public let SESSION_STATE_KEY = "advenue.session"
4
+ public let DEFAULT_SESSION_WINDOW_MS: Int64 = 1_800_000 // 30 minutes
5
+
6
+ /// A session lifecycle event. Values are numbers, strings and bools only.
7
+ public struct SessionEvent: Equatable, Sendable {
8
+ public let name: String
9
+ public let properties: [String: AdvenueValue]
10
+ }
11
+
12
+ /// Persisted state. Field names match the TypeScript shape exactly — the RN
13
+ /// inversion reads state this SDK wrote and vice versa, and a renamed field
14
+ /// would silently restart every device's session numbering.
15
+ struct SessionState: Codable {
16
+ var sessionId: String
17
+ var sessionNumber: Int
18
+ var lastBackgroundAt: Int64?
19
+ var subSessionCount: Int
20
+ var activeStart: Int64?
21
+ var firstForegroundAt: Int64
22
+ var timeSpentMs: Int64
23
+ }
24
+
25
+ /// Platform-agnostic session state machine. Pure: it persists state and
26
+ /// returns the events to emit; enqueueing is the caller's job.
27
+ public final class SessionTracker {
28
+ private var cached: SessionState?
29
+ private let store: KeyValueStore
30
+ private let clock: Clock
31
+ private let windowMs: Int64
32
+ private let uuid: UUIDSource
33
+
34
+ public init(store: KeyValueStore, clock: Clock, windowMs: Int64, uuid: UUIDSource) {
35
+ self.store = store
36
+ self.clock = clock
37
+ self.windowMs = windowMs
38
+ self.uuid = uuid
39
+ }
40
+
41
+ /// Drops the in-memory cache so a wiped store cannot be resurrected by a
42
+ /// later lifecycle call (GDPR erasure).
43
+ public func reset() { cached = nil }
44
+
45
+ private func load() -> SessionState? {
46
+ if let cached { return cached }
47
+ guard let raw = store.string(forKey: SESSION_STATE_KEY),
48
+ let data = raw.data(using: .utf8),
49
+ let parsed = try? JSONDecoder().decode(SessionState.self, from: data)
50
+ else { return nil }
51
+ cached = parsed
52
+ return parsed
53
+ }
54
+
55
+ private func save(_ state: SessionState) {
56
+ cached = state
57
+ guard let data = try? EventEncoding.canonicalEncoder().encode(state) else { return }
58
+ store.set(String(decoding: data, as: UTF8.self), forKey: SESSION_STATE_KEY)
59
+ }
60
+
61
+ /// Foreground transition, including cold start. Returns the events to emit:
62
+ /// none for a sub-session, one `session_start` for a new session, or a
63
+ /// synthetic `session_end` FOLLOWED BY the start when the previous session
64
+ /// was still open — the OS killed the app before it could background. The
65
+ /// order is load-bearing: a consumer must never see two sessions open.
66
+ public func handleForeground() -> [SessionEvent] {
67
+ let state = load()
68
+ let now = clock.nowMs()
69
+ let gap: Int64? = state?.lastBackgroundAt.map { now - $0 }
70
+
71
+ if state == nil || gap == nil || gap! >= windowMs {
72
+ let sessionNumber = (state?.sessionNumber ?? 0) + 1
73
+ let sessionId = uuid.next()
74
+ save(
75
+ SessionState(
76
+ sessionId: sessionId, sessionNumber: sessionNumber, lastBackgroundAt: nil,
77
+ subSessionCount: 1, activeStart: now, firstForegroundAt: now, timeSpentMs: 0))
78
+
79
+ let start = SessionEvent(
80
+ name: "session_start",
81
+ properties: [
82
+ "sessionId": .string(sessionId),
83
+ "sessionNumber": .int(sessionNumber),
84
+ "subSession": .int(1),
85
+ "isFirstSession": .bool(sessionNumber == 1),
86
+ "timeSinceLastSessionMs": .int(Int(gap ?? 0)),
87
+ ])
88
+
89
+ if let prior = state, let activeStart = prior.activeStart {
90
+ let killTime = prior.lastBackgroundAt ?? now
91
+ let activeMs = max(0, killTime - activeStart)
92
+ let end = SessionEvent(
93
+ name: "session_end",
94
+ properties: [
95
+ "sessionId": .string(prior.sessionId),
96
+ "sessionNumber": .int(prior.sessionNumber),
97
+ "subSession": .int(prior.subSessionCount),
98
+ "activeMs": .int(Int(activeMs)),
99
+ "timeSpentMs": .int(Int(prior.timeSpentMs + activeMs)),
100
+ "sessionLengthMs": .int(Int(max(0, killTime - prior.firstForegroundAt))),
101
+ "synthetic": .bool(true),
102
+ ])
103
+ return [end, start]
104
+ }
105
+ return [start]
106
+ }
107
+
108
+ var updated = state!
109
+ updated.subSessionCount += 1
110
+ updated.activeStart = now
111
+ save(updated)
112
+ return []
113
+ }
114
+
115
+ /// Background transition. Emits a `session_end` carrying this sub-session's
116
+ /// stretch (`activeMs`), the session's cumulative active time
117
+ /// (`timeSpentMs`) and its wall-clock span (`sessionLengthMs`) — three
118
+ /// different numbers a port is likely to collapse into one.
119
+ public func handleBackground() -> SessionEvent? {
120
+ guard var state = load(), let activeStart = state.activeStart else { return nil }
121
+ let now = clock.nowMs()
122
+ let activeMs = max(0, now - activeStart)
123
+ let timeSpentMs = state.timeSpentMs + activeMs
124
+ state.lastBackgroundAt = now
125
+ state.activeStart = nil
126
+ state.timeSpentMs = timeSpentMs
127
+ save(state)
128
+ return SessionEvent(
129
+ name: "session_end",
130
+ properties: [
131
+ "sessionId": .string(state.sessionId),
132
+ "sessionNumber": .int(state.sessionNumber),
133
+ "subSession": .int(state.subSessionCount),
134
+ "activeMs": .int(Int(activeMs)),
135
+ "timeSpentMs": .int(Int(timeSpentMs)),
136
+ "sessionLengthMs": .int(Int(max(0, now - state.firstForegroundAt))),
137
+ ])
138
+ }
139
+ }
@@ -0,0 +1,76 @@
1
+ import Foundation
2
+
3
+ /// The conversion-value config as the server serves it, with the version the
4
+ /// server uses to tell which config produced a reported value.
5
+ public struct RemoteSkanConfig: Equatable, Sendable {
6
+ public let version: Int
7
+ public let rules: ConversionValueConfig
8
+ public let etag: String?
9
+
10
+ public init(version: Int, rules: ConversionValueConfig, etag: String? = nil) {
11
+ self.version = version
12
+ self.rules = rules
13
+ self.etag = etag
14
+ }
15
+ }
16
+
17
+ public enum SkanConfigResult: Sendable {
18
+ case fetched(RemoteSkanConfig)
19
+ /// The server answered 304: whatever is cached is still current.
20
+ case notModified
21
+ }
22
+
23
+ public protocol SkanConfigSource: Sendable {
24
+ func fetch(etag: String?) async throws -> SkanConfigResult
25
+ }
26
+
27
+ public let SKAN_CONFIG_KEY = "advenue.skan.config"
28
+
29
+ /// Decides which config to use, given what the server said and what is cached.
30
+ ///
31
+ /// The rules are about staying measurable rather than staying current:
32
+ ///
33
+ /// - A **304** or a **failure** keeps the cached config. A device on a flaky
34
+ /// network must not lose SKAN entirely; a config from last week measures far
35
+ /// more than no config at all.
36
+ /// - A fetched config that does **not validate** is refused and the cache kept.
37
+ /// A bad config pushed to production would otherwise brick measurement on
38
+ /// every device at once, which is the failure mode a remote config exists to
39
+ /// avoid rather than create.
40
+ /// - Only when there is no cache and no valid fetch does the app-supplied
41
+ /// config apply, which is why it stays in `AdvenueConfig` as an offline
42
+ /// default rather than being removed.
43
+ public func chooseSkanConfig(
44
+ fetched: SkanConfigResult?,
45
+ cached: RemoteSkanConfig?,
46
+ fallback: ConversionValueConfig?
47
+ ) -> RemoteSkanConfig? {
48
+ if case .fetched(let remote) = fetched, (try? ConversionValueMapper(remote.rules)) != nil {
49
+ return remote
50
+ }
51
+ if let cached { return cached }
52
+ guard let fallback, (try? ConversionValueMapper(fallback)) != nil else { return nil }
53
+ // Version 0 marks "not from the server", which is what the server reads it as.
54
+ return RemoteSkanConfig(version: 0, rules: fallback)
55
+ }
56
+
57
+
58
+ extension RemoteSkanConfig: Codable {
59
+ private enum CodingKeys: String, CodingKey { case version, rules, etag }
60
+ }
61
+
62
+ /// Reads and writes the cached config. Persisted so a launch with no network
63
+ /// still measures — the alternative is that every offline cold start silently
64
+ /// stops reporting conversion values.
65
+ public enum SkanConfigCache {
66
+ public static func load(_ store: KeyValueStore) -> RemoteSkanConfig? {
67
+ guard let raw = store.string(forKey: SKAN_CONFIG_KEY), let data = raw.data(using: .utf8)
68
+ else { return nil }
69
+ return try? JSONDecoder().decode(RemoteSkanConfig.self, from: data)
70
+ }
71
+
72
+ public static func save(_ config: RemoteSkanConfig, to store: KeyValueStore) {
73
+ guard let data = try? EventEncoding.canonicalEncoder().encode(config) else { return }
74
+ store.set(String(decoding: data, as: UTF8.self), forKey: SKAN_CONFIG_KEY)
75
+ }
76
+ }
@@ -0,0 +1,25 @@
1
+ import Foundation
2
+
3
+ /// What the core needs from SKAdNetwork, and no more.
4
+ ///
5
+ /// Declared here so `AdvenueCore` keeps importing only Foundation while still
6
+ /// owning the decision of *when* to report — that decision is what the tests
7
+ /// can settle, and the Apple call is what they cannot.
8
+ public protocol SkanReporter: Sendable {
9
+ /// Registers the install for attribution. Apple wants this at first launch;
10
+ /// a late call loses the attribution window.
11
+ func register()
12
+
13
+ /// Reports a conversion value. Throws when the platform refuses it, and the
14
+ /// caller must NOT confirm a throw — a device that recorded a value it never
15
+ /// sent would refuse to send it again, and that window would report nothing
16
+ /// for the rest of its life.
17
+ func update(fine: Int, coarse: CoarseValue, lockWindow: Bool) async throws
18
+ }
19
+
20
+ /// A reporter that does nothing, for platforms and configurations with no SKAN.
21
+ public struct NoopSkanReporter: SkanReporter {
22
+ public init() {}
23
+ public func register() {}
24
+ public func update(fine: Int, coarse: CoarseValue, lockWindow: Bool) async throws {}
25
+ }
@@ -0,0 +1,258 @@
1
+ import Foundation
2
+
3
+ public let DAY_MS: Int64 = 86_400_000
4
+
5
+ /// SKAdNetwork 4 measures three windows from first launch. Boundaries are
6
+ /// Apple's and inclusive at the top: `[0,2d] → 1`, `(2d,7d] → 2`,
7
+ /// `(7d,35d] → 3`, beyond that nothing is measurable.
8
+ ///
9
+ /// A device whose clock is behind first launch produces a negative age and
10
+ /// reads as window 1. Degrade, never throw: this runs on a path the SDK
11
+ /// swallows, so an exception here would silently stop measurement instead of
12
+ /// reporting anything.
13
+ public func deriveWindowIndex(firstLaunchAt: Int64, nowMs: Int64) -> Int? {
14
+ let age = nowMs - firstLaunchAt
15
+ if age <= 2 * DAY_MS { return 1 }
16
+ if age <= 7 * DAY_MS { return 2 }
17
+ if age <= 35 * DAY_MS { return 3 }
18
+ return nil
19
+ }
20
+
21
+ public func skanStateKey(installationId: String) -> String {
22
+ "advenue.skan.state.\(installationId)"
23
+ }
24
+
25
+ /// One measurement window's accumulation.
26
+ public struct WindowState: Codable, Equatable, Sendable {
27
+ public var seenEvents: [String] = []
28
+ /// Canonical micros. A string, because money must not go through a Double.
29
+ public var revenueMicros: String = "0"
30
+ public var lastFine: Int?
31
+ public var lastCoarse: CoarseValue?
32
+ public var locked: Bool = false
33
+ /// Write-ahead intent: what we are about to tell Apple. Persisted BEFORE the
34
+ /// call so a crash between the two cannot leave the device believing it
35
+ /// reported a value it never sent.
36
+ public var pendingFine: Int?
37
+ public var pendingCoarse: CoarseValue?
38
+
39
+ public init() {}
40
+ }
41
+
42
+ /// Coarse buckets are ordered, and the gate below compares them as an order
43
+ /// rather than for equality — a move from low to medium is progress, the
44
+ /// reverse is not.
45
+ func coarseOrder(_ value: CoarseValue) -> Int {
46
+ switch value {
47
+ case .low: return 0
48
+ case .medium: return 1
49
+ case .high: return 2
50
+ }
51
+ }
52
+
53
+ /// The persisted blob. **Field names are frozen**: the RN inversion has to read
54
+ /// what TypeScript wrote, and a rename silently restarts every device's SKAN
55
+ /// measurement — which looks like a fleet-wide drop in postback quality with no
56
+ /// error anywhere.
57
+ public struct SkanMeasurementState: Codable, Equatable, Sendable {
58
+ public var v: Int = 2
59
+ public var firstLaunchAt: Int64
60
+ public var configVersion: Int = 0
61
+ public var windows: [String: WindowState]
62
+
63
+ public init(firstLaunchAt: Int64) {
64
+ self.firstLaunchAt = firstLaunchAt
65
+ self.windows = ["w1": WindowState(), "w2": WindowState(), "w3": WindowState()]
66
+ }
67
+ }
68
+
69
+ /// What the platform layer should tell Apple, or nil when there is nothing
70
+ /// worth saying.
71
+ public struct SkanUpdate: Equatable, Sendable {
72
+ public let fineValue: Int
73
+ public let coarseValue: CoarseValue
74
+ public let windowIndex: Int
75
+ /// True on the last update of a window: Apple stops accepting changes and
76
+ /// sends the postback sooner.
77
+ public let lockWindow: Bool
78
+ }
79
+
80
+ /// The client half of SKAN 4: accumulate into the current window, recompute,
81
+ /// and decide whether an update is worth making.
82
+ ///
83
+ /// Confined to the engine's thread like every other piece of state.
84
+ public final class SkanStateMachine {
85
+ private let store: KeyValueStore
86
+ private let clock: Clock
87
+ private let installationId: String
88
+ private let mapper: ConversionValueMapper
89
+ private let currency: String?
90
+ private var state: SkanMeasurementState
91
+
92
+ public init(
93
+ store: KeyValueStore,
94
+ clock: Clock,
95
+ installationId: String,
96
+ mapper: ConversionValueMapper,
97
+ currency: String? = nil,
98
+ configVersion: Int = 0
99
+ ) {
100
+ self.store = store
101
+ self.clock = clock
102
+ self.installationId = installationId
103
+ self.mapper = mapper
104
+ self.currency = currency
105
+ self.state = Self.load(store, key: skanStateKey(installationId: installationId))
106
+ ?? SkanMeasurementState(firstLaunchAt: clock.nowMs())
107
+ // Recorded so the server can tell which config produced a reported value.
108
+ // A schema change mid-window otherwise looks like a device behaving oddly.
109
+ self.state.configVersion = configVersion
110
+ persist()
111
+ }
112
+
113
+ /// Records an event and returns the update to make, or nil.
114
+ ///
115
+ /// Nil for three distinct reasons, all of which matter: the install is past
116
+ /// the last window, the window is locked, or the computed value has not
117
+ /// changed. That last one is not an optimisation — SKAN updates are
118
+ /// rate-limited by the system and each one restarts a timer, so a no-op
119
+ /// update costs the advertiser measurement resolution.
120
+ @discardableResult
121
+ public func record(
122
+ event: String?, revenueMicros: String? = nil, revenueCurrency: String? = nil
123
+ ) -> SkanUpdate? {
124
+ guard let index = deriveWindowIndex(firstLaunchAt: state.firstLaunchAt, nowMs: clock.nowMs())
125
+ else { return nil }
126
+ let key = "w\(index)"
127
+ var window = state.windows[key] ?? WindowState()
128
+ if window.locked { return nil }
129
+
130
+ if let event, !window.seenEvents.contains(event) { window.seenEvents.append(event) }
131
+ if let revenueMicros, isCanonicalMicros(revenueMicros) {
132
+ // Revenue accumulates within a window and does not carry into the next:
133
+ // each SKAN window measures its own period.
134
+ if window.revenueMicros == "0" || revenueCurrency == currency {
135
+ window.revenueMicros = addMicros(window.revenueMicros, revenueMicros)
136
+ }
137
+ }
138
+
139
+ let computed = mapper.compute(
140
+ MeasurementState(
141
+ events: window.seenEvents,
142
+ revenueMicros: window.revenueMicros,
143
+ revenueCurrency: revenueCurrency ?? currency))
144
+
145
+ // Monotonic within the window, and the two baselines differ on purpose.
146
+ //
147
+ // Fine baseline is **0**, not -1: a computed fine of 0 is the mapper's "no
148
+ // rule matched" default, so on its own it is not news and must not spend a
149
+ // rate-limited update. Fine is also only meaningful in window 1 — after
150
+ // that Apple ignores it for v4 ads.
151
+ //
152
+ // Coarse baseline is **-1**, so the first coarse activation fires even at
153
+ // `low`. That is what keeps windows 2 and 3 reporting at all; gating them
154
+ // on fine alone silently no-ops them.
155
+ let fineIncreased = index == 1 && computed.fineValue > (window.lastFine ?? 0)
156
+ let coarseIncreased =
157
+ coarseOrder(computed.coarseValue) > (window.lastCoarse.map(coarseOrder) ?? -1)
158
+
159
+ guard fineIncreased || coarseIncreased else {
160
+ state.windows[key] = window
161
+ persist()
162
+ return nil
163
+ }
164
+
165
+ // After window 1, send window 1's FROZEN fine: Apple ignores it on v4 ads,
166
+ // and a valid value still serves v3-signed ones.
167
+ let fineToSend =
168
+ index == 1 ? computed.fineValue : (state.windows["w1"]?.lastFine ?? 0)
169
+
170
+ window.pendingFine = index == 1 ? computed.fineValue : nil
171
+ window.pendingCoarse = computed.coarseValue
172
+ state.windows[key] = window
173
+ persist()
174
+
175
+ return SkanUpdate(
176
+ fineValue: fineToSend, coarseValue: computed.coarseValue,
177
+ windowIndex: index, lockWindow: false)
178
+ }
179
+
180
+ /// Commits an update the platform layer actually delivered.
181
+ ///
182
+ /// Separate from `record` because the Apple call can fail, and a device that
183
+ /// recorded a value it never sent would refuse to send it again — the window
184
+ /// would report nothing for the rest of its life.
185
+ public func confirm(_ update: SkanUpdate) {
186
+ let key = "w\(update.windowIndex)"
187
+ guard var window = state.windows[key] else { return }
188
+ if update.windowIndex == 1, let pending = window.pendingFine { window.lastFine = pending }
189
+ if let pending = window.pendingCoarse { window.lastCoarse = pending }
190
+ if update.lockWindow { window.locked = true }
191
+ window.pendingFine = nil
192
+ window.pendingCoarse = nil
193
+ state.windows[key] = window
194
+ persist()
195
+ }
196
+
197
+ /// Discards an intent the platform layer could not deliver, so the next
198
+ /// qualifying event tries again.
199
+ public func abandon(_ update: SkanUpdate) {
200
+ let key = "w\(update.windowIndex)"
201
+ guard var window = state.windows[key] else { return }
202
+ window.pendingFine = nil
203
+ window.pendingCoarse = nil
204
+ state.windows[key] = window
205
+ persist()
206
+ }
207
+
208
+ /// Closes a window to further updates. Apple then stops accepting changes, so
209
+ /// sending after this is an update the system silently discards while the SDK
210
+ /// believes it landed.
211
+ public func lock(window index: Int) {
212
+ let key = "w\(index)"
213
+ guard var window = state.windows[key] else { return }
214
+ window.locked = true
215
+ state.windows[key] = window
216
+ persist()
217
+ }
218
+
219
+ public func snapshot() -> SkanMeasurementState { state }
220
+
221
+ private func persist() {
222
+ guard let data = try? EventEncoding.canonicalEncoder().encode(state) else { return }
223
+ store.set(
224
+ String(decoding: data, as: UTF8.self),
225
+ forKey: skanStateKey(installationId: installationId))
226
+ }
227
+
228
+ /// A corrupt blob loads as fresh state rather than throwing — the same rule
229
+ /// the event queue follows, and for the same reason: bricking measurement on
230
+ /// every launch is worse than losing one device's accumulation.
231
+ private static func load(_ store: KeyValueStore, key: String) -> SkanMeasurementState? {
232
+ guard let raw = store.string(forKey: key), let data = raw.data(using: .utf8) else {
233
+ return nil
234
+ }
235
+ return try? JSONDecoder().decode(SkanMeasurementState.self, from: data)
236
+ }
237
+ }
238
+
239
+ /// Adds two canonical non-negative base-10 micro amounts without a bignum and
240
+ /// without a Double, which would lose exactness above 2^53 micros.
241
+ func addMicros(_ lhs: String, _ rhs: String) -> String {
242
+ var carry = 0
243
+ var out: [Character] = []
244
+ let a = Array(lhs.reversed()), b = Array(rhs.reversed())
245
+ for i in 0..<max(a.count, b.count) {
246
+ let x = i < a.count ? Int(String(a[i])) ?? 0 : 0
247
+ let y = i < b.count ? Int(String(b[i])) ?? 0 : 0
248
+ let sum = x + y + carry
249
+ out.append(Character(String(sum % 10)))
250
+ carry = sum / 10
251
+ }
252
+ if carry > 0 { out.append(Character(String(carry))) }
253
+ let result = String(out.reversed())
254
+ // Strip leading zeros to keep the value canonical, which the comparison and
255
+ // the server's schema both require.
256
+ let trimmed = result.drop(while: { $0 == "0" })
257
+ return trimmed.isEmpty ? "0" : String(trimmed)
258
+ }