@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,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
|
+
}
|