@advenue/react-native 0.9.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +8 -7
  2. package/android/src/main/java/expo/modules/advenue/AdvenueAndroidModule.kt +117 -368
  3. package/android/src/main/kotlin/io/advenue/Advenue.kt +574 -0
  4. package/android/src/main/kotlin/io/advenue/AdvenueConfig.kt +141 -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 +193 -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 +436 -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 +50 -0
  17. package/android/src/main/kotlin/io/advenue/core/MetaReferrer.kt +80 -0
  18. package/android/src/main/kotlin/io/advenue/core/PiiScrub.kt +77 -0
  19. package/android/src/main/kotlin/io/advenue/core/SessionTracker.kt +189 -0
  20. package/android/src/main/kotlin/io/advenue/core/SystemServices.kt +58 -0
  21. package/android/src/main/kotlin/io/advenue/core/Tcf.kt +48 -0
  22. package/android/src/main/kotlin/io/advenue/core/Time.kt +56 -0
  23. package/android/src/main/kotlin/io/advenue/platform/Collectors.kt +163 -0
  24. package/android/src/main/kotlin/io/advenue/platform/CompositeStore.kt +33 -0
  25. package/android/src/main/kotlin/io/advenue/platform/ConversionFetcher.kt +68 -0
  26. package/android/src/main/kotlin/io/advenue/platform/ForegroundTracker.kt +90 -0
  27. package/android/src/main/kotlin/io/advenue/platform/HttpUrlTransport.kt +101 -0
  28. package/android/src/main/kotlin/io/advenue/platform/Identity.kt +79 -0
  29. package/android/src/main/kotlin/io/advenue/platform/InstallEnrichment.kt +197 -0
  30. package/android/src/main/kotlin/io/advenue/platform/InstallScopedStore.kt +96 -0
  31. package/android/src/main/kotlin/io/advenue/platform/LifecycleBridge.kt +72 -0
  32. package/android/src/main/kotlin/io/advenue/platform/PreferencesStore.kt +32 -0
  33. package/android/src/main/kotlin/io/advenue/plugin/Contracts.kt +67 -0
  34. package/android/src/main/kotlin/io/advenue/plugin/FirebaseAppInstanceIdSource.kt +73 -0
  35. package/android/src/main/kotlin/io/advenue/plugin/PlayAdvertisingIdSource.kt +47 -0
  36. package/android/src/main/kotlin/io/advenue/plugin/PlayInstallReferrerSource.kt +85 -0
  37. package/android/src/main/kotlin/io/advenue/plugin/PlayIntegritySource.kt +86 -0
  38. package/android/src/main/kotlin/io/advenue/plugin/PluginRegistry.kt +61 -0
  39. package/dist/index.cjs +188 -675
  40. package/dist/index.d.cts +203 -336
  41. package/dist/index.d.ts +203 -336
  42. package/dist/index.js +187 -681
  43. package/ios/AdvenueIosModule.swift +143 -446
  44. package/ios/vendor/Advenue/Advenue.swift +632 -0
  45. package/ios/vendor/Advenue/AdvenueConfig.swift +89 -0
  46. package/ios/vendor/AdvenueCore/AdvenueValue.swift +103 -0
  47. package/ios/vendor/AdvenueCore/Attestation.swift +52 -0
  48. package/ios/vendor/AdvenueCore/Backoff.swift +28 -0
  49. package/ios/vendor/AdvenueCore/ClientEvent.swift +154 -0
  50. package/ios/vendor/AdvenueCore/Consent.swift +40 -0
  51. package/ios/vendor/AdvenueCore/Contracts.swift +59 -0
  52. package/ios/vendor/AdvenueCore/Conversion.swift +74 -0
  53. package/ios/vendor/AdvenueCore/ConversionValue.swift +217 -0
  54. package/ios/vendor/AdvenueCore/Engine.swift +607 -0
  55. package/ios/vendor/AdvenueCore/EventQueue.swift +89 -0
  56. package/ios/vendor/AdvenueCore/Limits.swift +41 -0
  57. package/ios/vendor/AdvenueCore/PIIScrub.swift +102 -0
  58. package/ios/vendor/AdvenueCore/SessionTracker.swift +139 -0
  59. package/ios/vendor/AdvenueCore/SkanConfig.swift +76 -0
  60. package/ios/vendor/AdvenueCore/SkanReporter.swift +25 -0
  61. package/ios/vendor/AdvenueCore/SkanState.swift +258 -0
  62. package/ios/vendor/AdvenueCore/Tcf.swift +48 -0
  63. package/ios/vendor/AdvenueCore/Transport.swift +14 -0
  64. package/ios/vendor/AdvenueFirebase/FirebaseAppInstanceId.swift +33 -0
  65. package/ios/vendor/AdvenuePlatform/AdvertisingIdentity.swift +87 -0
  66. package/ios/vendor/AdvenuePlatform/CacheFileStore.swift +90 -0
  67. package/ios/vendor/AdvenuePlatform/ChallengeFetcher.swift +42 -0
  68. package/ios/vendor/AdvenuePlatform/ConversionFetcher.swift +63 -0
  69. package/ios/vendor/AdvenuePlatform/CryptoKitSigner.swift +19 -0
  70. package/ios/vendor/AdvenuePlatform/DeviceCheckAttestation.swift +91 -0
  71. package/ios/vendor/AdvenuePlatform/DeviceInfo.swift +77 -0
  72. package/ios/vendor/AdvenuePlatform/ForegroundTracker.swift +55 -0
  73. package/ios/vendor/AdvenuePlatform/HttpTransport.swift +96 -0
  74. package/ios/vendor/AdvenuePlatform/Identity.swift +52 -0
  75. package/ios/vendor/AdvenuePlatform/InstallEnrichment.swift +155 -0
  76. package/ios/vendor/AdvenuePlatform/KeychainStore.swift +95 -0
  77. package/ios/vendor/AdvenuePlatform/SearchAdsToken.swift +76 -0
  78. package/ios/vendor/AdvenuePlatform/SkanConfigFetcher.swift +70 -0
  79. package/ios/vendor/AdvenuePlatform/StoreKitSkanReporter.swift +140 -0
  80. package/ios/vendor/AdvenuePlatform/SystemServices.swift +55 -0
  81. package/ios/vendor/AdvenuePlatform/TcfReader.swift +18 -0
  82. package/ios/vendor/AdvenuePlatform/UserDefaultsStore.swift +26 -0
  83. package/package.json +13 -15
  84. package/scripts/check-dist.mjs +17 -0
  85. package/scripts/check-vendored-swift.mjs +148 -0
  86. package/scripts/vendor-natives.mjs +149 -0
  87. package/scripts/vendor-natives.test.mjs +112 -0
  88. package/src/deep-links.ts +19 -1
  89. package/src/index.ts +273 -830
  90. package/src/native-types.ts +73 -181
  91. package/src/native.ts +0 -23
  92. package/src/types.ts +86 -0
  93. package/src/aem.ts +0 -33
  94. package/src/mmkv-storage.ts +0 -21
  95. package/src/native-storage.ts +0 -63
  96. package/src/secure-store.ts +0 -26
@@ -0,0 +1,89 @@
1
+ import Foundation
2
+
3
+ public struct AdvenueConfig: Sendable {
4
+ public var apiKey: String
5
+ public var endpoint: String
6
+ /// Ignored: the SDK reads the app version from the bundle itself. Setting it
7
+ /// reports `config.ignored:appVersion` through `onError`. Removed in the next major.
8
+ public var appVersion: String?
9
+ public var requireConsent: Bool
10
+ public var sessionWindowMs: Int64
11
+ /// Events per request. Matches sdk-core.
12
+ public var batchSize: Int
13
+ /// Auto-flush period. Zero disables the timer, which is what tests want and
14
+ /// no shipping app does.
15
+ public var flushIntervalMs: Int
16
+
17
+ /// Per-key HMAC secret. **Read this before setting it.** The signature
18
+ /// provides integrity and replay protection, not authentication: anything
19
+ /// shipped inside an app binary can be extracted from it, exactly as it can
20
+ /// from a JavaScript bundle. Leaving it nil sends unsigned requests, which
21
+ /// the server accepts unless the app enforces signatures.
22
+ public var signingSecret: String?
23
+
24
+ /// SKAdNetwork conversion values. Absent means SKAN is not armed at all —
25
+ /// there is nothing to report without rules, so the machine is not built
26
+ /// rather than built and idle.
27
+ public var conversionValues: ConversionValueConfig?
28
+
29
+ /// Called when the SDK swallows a best-effort failure. Never receives PII.
30
+ public var onError: @Sendable (String, any Error) -> Void
31
+
32
+ /// Overrides the version stamped on every event. Set by a WRAPPER SDK, never
33
+ /// by an app.
34
+ ///
35
+ /// An event stamped with the Swift SDK's own version says the same thing for
36
+ /// every install and answers nothing. The useful answer is which wrapper
37
+ /// produced it — a React Native or Flutter release pins the native snapshot
38
+ /// inside it, so the wrapper's version identifies both, and wrapper-specific
39
+ /// bugs are the ones that need identifying. Adjust and AppsFlyer report the
40
+ /// wrapper for the same reason.
41
+ ///
42
+ /// Capped at 32 characters by the ingest schema; a longer value would take
43
+ /// the whole batch down with a 400, so it is truncated rather than sent.
44
+ public var sdkVersion: String?
45
+
46
+ /// B5: `https://` şema kuralı. Fail-fast `precondition` ile kurulumda
47
+ /// yakalanır; kuralın kendisi test edilebilir saf fonksiyondur.
48
+ public static func isSecureEndpoint(_ endpoint: String) -> Bool {
49
+ endpoint.lowercased().hasPrefix("https://")
50
+ }
51
+
52
+ public init(
53
+ apiKey: String,
54
+ endpoint: String = DEFAULT_ENDPOINT,
55
+ appVersion: String? = nil,
56
+ requireConsent: Bool = false,
57
+ sessionWindowMs: Int64 = DEFAULT_SESSION_WINDOW_MS,
58
+ batchSize: Int = 20,
59
+ flushIntervalMs: Int = 15_000,
60
+ signingSecret: String? = nil,
61
+ conversionValues: ConversionValueConfig? = nil,
62
+ onError: @escaping @Sendable (String, any Error) -> Void = { _, _ in },
63
+ sdkVersion: String? = nil,
64
+ allowInsecureHttp: Bool = false
65
+ ) {
66
+ precondition(
67
+ allowInsecureHttp || Self.isSecureEndpoint(endpoint),
68
+ "Advenue endpoint must use https:// (allowInsecureHttp is test-only)")
69
+ self.apiKey = apiKey
70
+ self.endpoint = endpoint
71
+ self.appVersion = appVersion
72
+ self.requireConsent = requireConsent
73
+ self.sessionWindowMs = sessionWindowMs
74
+ self.batchSize = batchSize
75
+ self.flushIntervalMs = flushIntervalMs
76
+ self.signingSecret = signingSecret
77
+ self.conversionValues = conversionValues
78
+ self.onError = onError
79
+ self.sdkVersion = sdkVersion
80
+ }
81
+ }
82
+
83
+ /// Stamped on every event as `sdkVersion`. Swift has no runtime access to its
84
+ /// package version, so this is a constant — and a constant drifts from the
85
+ /// release tag unless something checks. CI does, because the first question
86
+ /// every field report raises is which build produced the event.
87
+ public enum AdvenueVersion {
88
+ public static let current = "1.0.0"
89
+ }
@@ -0,0 +1,103 @@
1
+ import Foundation
2
+
3
+ /// A JSON value an event property may hold.
4
+ ///
5
+ /// The server accepts `z.record(z.string(), z.unknown())`, and Swift has no
6
+ /// safe `[String: Any]` Codable — an unencodable value would trap at runtime,
7
+ /// inside the SDK, in someone else's app. A closed enum makes that a compile
8
+ /// error instead, and the literal conformances keep the call site looking like
9
+ /// the dictionary a caller expects to write:
10
+ ///
11
+ /// Advenue.track("purchase", properties: ["price": 9.99, "tier": "gold"])
12
+ public enum AdvenueValue: Sendable, Equatable {
13
+ case string(String)
14
+ case int(Int)
15
+ case double(Double)
16
+ case bool(Bool)
17
+ case array([AdvenueValue])
18
+ case object([String: AdvenueValue])
19
+ case null
20
+ }
21
+
22
+ extension AdvenueValue: ExpressibleByStringLiteral {
23
+ public init(stringLiteral value: String) { self = .string(value) }
24
+ }
25
+ extension AdvenueValue: ExpressibleByIntegerLiteral {
26
+ public init(integerLiteral value: Int) { self = .int(value) }
27
+ }
28
+ extension AdvenueValue: ExpressibleByFloatLiteral {
29
+ public init(floatLiteral value: Double) { self = .double(value) }
30
+ }
31
+ extension AdvenueValue: ExpressibleByBooleanLiteral {
32
+ public init(booleanLiteral value: Bool) { self = .bool(value) }
33
+ }
34
+ extension AdvenueValue: ExpressibleByNilLiteral {
35
+ public init(nilLiteral: ()) { self = .null }
36
+ }
37
+ extension AdvenueValue: ExpressibleByArrayLiteral {
38
+ public init(arrayLiteral elements: AdvenueValue...) { self = .array(elements) }
39
+ }
40
+ extension AdvenueValue: ExpressibleByDictionaryLiteral {
41
+ public init(dictionaryLiteral elements: (String, AdvenueValue)...) {
42
+ self = .object(Dictionary(uniqueKeysWithValues: elements))
43
+ }
44
+ }
45
+
46
+ extension AdvenueValue: Encodable {
47
+ public func encode(to encoder: Encoder) throws {
48
+ var container = encoder.singleValueContainer()
49
+ switch self {
50
+ case .string(let v): try container.encode(v)
51
+ case .int(let v): try container.encode(v)
52
+ case .double(let v): try container.encode(v)
53
+ case .bool(let v): try container.encode(v)
54
+ case .array(let v): try container.encode(v)
55
+ // Null-valued keys are dropped, exactly as an absent optional is. For an
56
+ // event property `{"x":null}` and `{}` say the same thing to every
57
+ // consumer, and the server's 8 KB properties cap makes the difference pure
58
+ // cost. Kotlin's canonical writer has always done this; Swift did not, so
59
+ // the same call produced different warehouse rows by platform.
60
+ //
61
+ // Only in OBJECTS. The `.array` case above keeps its nulls, because there
62
+ // is no key to omit and dropping one would shift every element after it.
63
+ case .object(let v): try container.encode(v.filter { $0.value != .null })
64
+ case .null: try container.encodeNil()
65
+ }
66
+ }
67
+ }
68
+
69
+ /// Reads back exactly what `Encodable` above writes, and refuses anything else.
70
+ ///
71
+ /// The closed type exists to protect the CALL SITE — a caller must not be able
72
+ /// to hand the SDK a value it cannot encode. Decoding is the other direction:
73
+ /// these bytes were written from an `AdvenueValue`, so accepting the shapes it
74
+ /// can represent takes nothing away from that guarantee. Anything outside them
75
+ /// throws, and `EventQueue.load` already turns a throwing blob into an empty
76
+ /// queue rather than bricking the SDK.
77
+ ///
78
+ /// Order matters. `Int` is tried before `Double`, because a decoder that read
79
+ /// `3` as `3.0` would restore the property and silently change its type on the
80
+ /// wire — a quieter version of the bug this conformance exists to fix.
81
+ extension AdvenueValue: Decodable {
82
+ public init(from decoder: Decoder) throws {
83
+ let container = try decoder.singleValueContainer()
84
+ if container.decodeNil() {
85
+ self = .null
86
+ } else if let v = try? container.decode(Bool.self) {
87
+ self = .bool(v)
88
+ } else if let v = try? container.decode(Int.self) {
89
+ self = .int(v)
90
+ } else if let v = try? container.decode(Double.self) {
91
+ self = .double(v)
92
+ } else if let v = try? container.decode(String.self) {
93
+ self = .string(v)
94
+ } else if let v = try? container.decode([AdvenueValue].self) {
95
+ self = .array(v)
96
+ } else if let v = try? container.decode([String: AdvenueValue].self) {
97
+ self = .object(v)
98
+ } else {
99
+ throw DecodingError.dataCorruptedError(
100
+ in: container, debugDescription: "not a value AdvenueValue can represent")
101
+ }
102
+ }
103
+ }
@@ -0,0 +1,52 @@
1
+ import Foundation
2
+
3
+ /// What App Attest produces: the key Apple minted and the CBOR attestation
4
+ /// object the server verifies against it.
5
+ public struct AttestationResult: Equatable, Sendable {
6
+ public let keyId: String
7
+ /// Base64. Passed straight through to the server's verifier.
8
+ public let attestationObject: String
9
+
10
+ public init(keyId: String, attestationObject: String) {
11
+ self.keyId = keyId
12
+ self.attestationObject = attestationObject
13
+ }
14
+ }
15
+
16
+ public enum AttestationError: Error, Equatable, CustomStringConvertible {
17
+ /// The device cannot attest at all — every simulator, and every Mac. Distinct
18
+ /// from a failure so the caller can omit the fields quietly rather than retry
19
+ /// something that can never work.
20
+ case unsupported
21
+ case badChallenge
22
+ case keyGeneration
23
+ case attestFailed
24
+
25
+ public var description: String {
26
+ switch self {
27
+ case .unsupported: return "device attestation is not supported here"
28
+ case .badChallenge: return "the challenge could not be encoded as UTF-8"
29
+ case .keyGeneration: return "App Attest key generation failed"
30
+ case .attestFailed: return "attestKey failed"
31
+ }
32
+ }
33
+ }
34
+
35
+ /// Device attestation, injected so the install path can be tested without a
36
+ /// device — which is the only way it can be tested, since
37
+ /// `DCAppAttestService.isSupported` is false in every simulator.
38
+ public protocol Attestation: Sendable {
39
+ /// Attests a server-issued challenge. Throws rather than returning nil so the
40
+ /// caller can tell "cannot" from "did not".
41
+ func attest(challenge: String) async throws -> AttestationResult
42
+
43
+ /// The legacy DeviceCheck token: two bits of per-device state Apple keeps
44
+ /// across reinstalls, which is what makes it useful for reinstall abuse.
45
+ func deviceCheckToken() async throws -> String
46
+ }
47
+
48
+ /// Where the one-time challenge comes from. It is server-issued and single-use,
49
+ /// so it cannot be generated on the device.
50
+ public protocol ChallengeSource: Sendable {
51
+ func challenge(deviceId: String) async throws -> String
52
+ }
@@ -0,0 +1,28 @@
1
+ import Foundation
2
+
3
+ /// A non-2xx ingest response. `isRetryable` separates a transient failure from
4
+ /// a poison payload: retrying a 400 forever would block the queue head, and
5
+ /// dropping a 429 would discard events over a throttle.
6
+ public struct IngestError: Error, Equatable, Sendable {
7
+ public let status: Int
8
+ public init(status: Int) { self.status = status }
9
+
10
+ public var isRetryable: Bool {
11
+ status == 408 || status == 429 || status >= 500
12
+ }
13
+ }
14
+
15
+ /// The delay armed after the Nth consecutive transient failure.
16
+ ///
17
+ /// Mirrors sdk-core's `armBackoff`: the cap applies to the exponential term
18
+ /// BEFORE the jitter is added, so the true maximum is `capMs * 1.2`, not
19
+ /// `capMs`. `random` is injected so the schedule is a pure function.
20
+ public func backoffDelayMs(
21
+ failures: Int,
22
+ baseMs: Double,
23
+ capMs: Double,
24
+ random: Double
25
+ ) -> Double {
26
+ let exponential = min(baseMs * pow(2, Double(failures - 1)), capMs)
27
+ return exponential + exponential * 0.2 * random
28
+ }
@@ -0,0 +1,154 @@
1
+ import Foundation
2
+
3
+ /// The wire envelope, mirroring `clientEventSchema` in
4
+ /// `packages/shared/src/events.ts`.
5
+ ///
6
+ /// Every optional is genuinely optional on the wire: the server uses zod
7
+ /// `.optional()`, which accepts a missing key and **rejects** an explicit
8
+ /// `null`. Swift's synthesised encoder omits nil for optional properties,
9
+ /// which is the behaviour required here — a hand-written encoder emitting
10
+ /// `null` would 400 the whole batch.
11
+ public struct ClientEvent: Codable, Sendable, Equatable {
12
+ public var id: String
13
+ public var deviceId: String
14
+ public var installationId: String?
15
+ public var type: String
16
+ public var name: String
17
+ public var timestamp: String
18
+ public var platform: String
19
+ public var osVersion: String?
20
+ public var appVersion: String?
21
+ public var sdkVersion: String?
22
+ public var network: String?
23
+ public var campaign: String?
24
+ public var country: String?
25
+ public var idfa: String?
26
+ public var gaid: String?
27
+ public var limitAdTracking: Bool?
28
+ public var vendorId: String?
29
+ public var androidId: String?
30
+ public var customerUserId: String?
31
+ public var appInstanceId: String?
32
+ public var consent: Consent?
33
+ /// Null-valued keys are dropped on assignment, not at encode time.
34
+ ///
35
+ /// The nested case is handled by `AdvenueValue`'s encoder, but this
36
+ /// dictionary is encoded by the SYNTHESISED encoder — which must stay
37
+ /// synthesised, because it is what omits the two dozen optional envelope
38
+ /// fields the server rejects an explicit null for. So the filter lives here,
39
+ /// on the way in, which also means the persisted queue blob matches what is
40
+ /// sent rather than carrying keys the wire will not.
41
+ ///
42
+ /// A property observer rather than three call sites: `track` and
43
+ /// `trackInstall` both assign, and a fourth path added later would otherwise
44
+ /// reintroduce the divergence silently. Observers do not fire during
45
+ /// initialisation, so the decode path is untouched — it reads data already
46
+ /// filtered when it was written.
47
+ public var properties: [String: AdvenueValue]? {
48
+ didSet {
49
+ if let current = properties, current.values.contains(.null) {
50
+ properties = current.filter { $0.value != .null }
51
+ }
52
+ }
53
+ }
54
+ public var pushToken: String?
55
+ public var pushProvider: String?
56
+ public var attestationToken: String?
57
+ public var attestationType: String?
58
+ public var attestationKeyId: String?
59
+ public var attestationChallenge: String?
60
+ public var deviceCheckToken: String?
61
+ public var adservicesToken: String?
62
+
63
+ public init(
64
+ id: String, deviceId: String, type: String, name: String, timestamp: String, platform: String
65
+ ) {
66
+ self.id = id
67
+ self.deviceId = deviceId
68
+ self.type = type
69
+ self.name = name
70
+ self.timestamp = timestamp
71
+ self.platform = platform
72
+ }
73
+
74
+ /// Required for the queue's reload path — and `properties` is decoded with
75
+ /// everything else, which it was not.
76
+ ///
77
+ /// It used to be dropped, on the premise that "nothing reads it back". The
78
+ /// premise was false: `EventQueue` assigns `events = load(store)` and
79
+ /// `peek()` returns from `events`, so a reloaded object is exactly what the
80
+ /// transport re-encodes and sends. An app killed before flushing therefore
81
+ /// sent every buffered event with no payload — no revenue, no currency, no
82
+ /// sku. The encoder had always written them; only this refused to read them.
83
+ ///
84
+ /// A queue conformance vector now pins it in both SDKs at once.
85
+ public init(from decoder: Decoder) throws {
86
+ let c = try decoder.container(keyedBy: CodingKeys.self)
87
+ id = try c.decode(String.self, forKey: .id)
88
+ deviceId = try c.decode(String.self, forKey: .deviceId)
89
+ type = try c.decode(String.self, forKey: .type)
90
+ name = try c.decode(String.self, forKey: .name)
91
+ timestamp = try c.decode(String.self, forKey: .timestamp)
92
+ platform = try c.decode(String.self, forKey: .platform)
93
+ installationId = try c.decodeIfPresent(String.self, forKey: .installationId)
94
+ osVersion = try c.decodeIfPresent(String.self, forKey: .osVersion)
95
+ appVersion = try c.decodeIfPresent(String.self, forKey: .appVersion)
96
+ sdkVersion = try c.decodeIfPresent(String.self, forKey: .sdkVersion)
97
+ network = try c.decodeIfPresent(String.self, forKey: .network)
98
+ campaign = try c.decodeIfPresent(String.self, forKey: .campaign)
99
+ country = try c.decodeIfPresent(String.self, forKey: .country)
100
+ idfa = try c.decodeIfPresent(String.self, forKey: .idfa)
101
+ gaid = try c.decodeIfPresent(String.self, forKey: .gaid)
102
+ limitAdTracking = try c.decodeIfPresent(Bool.self, forKey: .limitAdTracking)
103
+ vendorId = try c.decodeIfPresent(String.self, forKey: .vendorId)
104
+ androidId = try c.decodeIfPresent(String.self, forKey: .androidId)
105
+ customerUserId = try c.decodeIfPresent(String.self, forKey: .customerUserId)
106
+ appInstanceId = try c.decodeIfPresent(String.self, forKey: .appInstanceId)
107
+ pushToken = try c.decodeIfPresent(String.self, forKey: .pushToken)
108
+ pushProvider = try c.decodeIfPresent(String.self, forKey: .pushProvider)
109
+ attestationToken = try c.decodeIfPresent(String.self, forKey: .attestationToken)
110
+ attestationType = try c.decodeIfPresent(String.self, forKey: .attestationType)
111
+ attestationKeyId = try c.decodeIfPresent(String.self, forKey: .attestationKeyId)
112
+ attestationChallenge = try c.decodeIfPresent(String.self, forKey: .attestationChallenge)
113
+ deviceCheckToken = try c.decodeIfPresent(String.self, forKey: .deviceCheckToken)
114
+ adservicesToken = try c.decodeIfPresent(String.self, forKey: .adservicesToken)
115
+ consent = try c.decodeIfPresent(Consent.self, forKey: .consent)
116
+ properties = try c.decodeIfPresent([String: AdvenueValue].self, forKey: .properties)
117
+ }
118
+ }
119
+
120
+ public enum EventEncoding {
121
+ /// Milliseconds-since-epoch to the exact form TypeScript's `toISOString()`
122
+ /// produces: always three fractional digits, always `Z`. Foundation's
123
+ /// `.iso8601` strategy drops fractional seconds entirely, which would change
124
+ /// every timestamp on the wire.
125
+ public static func iso8601(ms: Int64) -> String {
126
+ // Floor to the second so a negative millisecond remainder cannot borrow
127
+ // from the second field.
128
+ let wholeSeconds = Int64((Double(ms) / 1000.0).rounded(.down))
129
+ let millis = ms - wholeSeconds * 1000
130
+ let date = Date(timeIntervalSince1970: Double(wholeSeconds))
131
+ var calendar = Calendar(identifier: .gregorian)
132
+ calendar.timeZone = TimeZone(secondsFromGMT: 0)!
133
+ let c = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date)
134
+ return String(
135
+ format: "%04d-%02d-%02dT%02d:%02d:%02d.%03dZ",
136
+ c.year!, c.month!, c.day!, c.hour!, c.minute!, c.second!, Int(millis))
137
+ }
138
+
139
+ /// The canonical JSON encoder: keys sorted so output is byte-comparable with
140
+ /// the conformance snapshots, and nothing pretty-printed on the wire.
141
+ public static func canonicalEncoder() -> JSONEncoder {
142
+ let encoder = JSONEncoder()
143
+ encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
144
+ return encoder
145
+ }
146
+ }
147
+
148
+ extension ClientEvent {
149
+ /// The exact bytes this event puts on the wire.
150
+ public func encodeCanonical() throws -> String {
151
+ let data = try EventEncoding.canonicalEncoder().encode(self)
152
+ return String(decoding: data, as: UTF8.self)
153
+ }
154
+ }
@@ -0,0 +1,40 @@
1
+ import Foundation
2
+
3
+ /// Granular ad-platform consent (Google DMA), forwarded to Google and Meta by
4
+ /// server-side postbacks as `gdpr_applies`, `ad_user_data`, `ad_personalization`
5
+ /// and `ad_storage`.
6
+ ///
7
+ /// Three of the four fields are optional, and that is the point rather than
8
+ /// laxity: **"not stated" is not "denied".** A CMP that has not asked about ad
9
+ /// storage yet must not have `false` invented on its behalf, because downstream
10
+ /// that is a recorded refusal. The encoder omits nil, which is what keeps the
11
+ /// distinction on the wire.
12
+ public struct Consent: Codable, Equatable, Sendable {
13
+ public var isUserSubjectToGDPR: Bool
14
+ public var hasConsentForDataUsage: Bool?
15
+ public var hasConsentForAdsPersonalization: Bool?
16
+ public var hasConsentForAdStorage: Bool?
17
+
18
+ public init(
19
+ isUserSubjectToGDPR: Bool,
20
+ hasConsentForDataUsage: Bool? = nil,
21
+ hasConsentForAdsPersonalization: Bool? = nil,
22
+ hasConsentForAdStorage: Bool? = nil
23
+ ) {
24
+ self.isUserSubjectToGDPR = isUserSubjectToGDPR
25
+ self.hasConsentForDataUsage = hasConsentForDataUsage
26
+ self.hasConsentForAdsPersonalization = hasConsentForAdsPersonalization
27
+ self.hasConsentForAdStorage = hasConsentForAdStorage
28
+ }
29
+ }
30
+
31
+ /// Validates a push token against the server's bound and charset.
32
+ ///
33
+ /// Rejecting here costs one dropped registration; letting it through costs
34
+ /// every event in the batch it would have ridden in, because ingest parses a
35
+ /// batch as a whole.
36
+ public func isValidPushToken(_ token: String) -> Bool {
37
+ guard !token.isEmpty, token.count <= 512 else { return false }
38
+ let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_:.-"))
39
+ return token.unicodeScalars.allSatisfy { allowed.contains($0) }
40
+ }
@@ -0,0 +1,59 @@
1
+ import Foundation
2
+
3
+ /// Synchronous key/value storage. On iOS this is a `UserDefaults` suite; in
4
+ /// tests it is an in-memory dictionary. Mirrors `StorageAdapter` in sdk-core.
5
+ public protocol KeyValueStore: Sendable {
6
+ func string(forKey key: String) -> String?
7
+ func set(_ value: String, forKey key: String)
8
+ func removeObject(forKey key: String)
9
+ }
10
+
11
+ /// HMAC-SHA256, injected so `AdvenueCore` never imports a crypto library and
12
+ /// therefore builds anywhere. Production supplies CryptoKit; the Linux test
13
+ /// path supplies swift-crypto.
14
+ public protocol Signer: Sendable {
15
+ /// Lowercase hex digest of `message` under `secret`, both UTF-8.
16
+ func hmacSHA256Hex(secret: String, message: String) -> String
17
+ }
18
+
19
+ /// Milliseconds since the Unix epoch. Injected so vectors are deterministic.
20
+ public protocol Clock: Sendable {
21
+ func nowMs() -> Int64
22
+ }
23
+
24
+ /// Opaque handle returned by `Scheduler.schedule`.
25
+ public typealias CancelToken = UInt64
26
+
27
+ /// Deferred execution, injected so the persist debounce is observable without
28
+ /// waiting for it.
29
+ ///
30
+ /// **Isolation contract.** `work` is NOT `@Sendable`: an implementation must
31
+ /// run it in the same isolation domain that called `schedule`. The iOS
32
+ /// scheduler satisfies this by hopping back onto the engine actor before
33
+ /// invoking it; the test scheduler satisfies it by running it inline.
34
+ ///
35
+ /// The alternative — marking it `@Sendable` and declaring the queue
36
+ /// `@unchecked Sendable` so it can be captured — would compile by asserting
37
+ /// thread safety the queue does not have. This way the requirement is on the
38
+ /// scheduler, where it can actually be met.
39
+ public protocol Scheduler: Sendable {
40
+ func schedule(afterMs: Int, _ work: @escaping () -> Void) -> CancelToken
41
+ func cancel(_ token: CancelToken)
42
+ }
43
+
44
+ /// Event, session and device id source. Injected for the same reason.
45
+ public protocol UUIDSource: Sendable {
46
+ func next() -> String
47
+ }
48
+
49
+ /// HMAC over `"\(timestamp).\(body)"` — the Stripe-style construction in
50
+ /// `packages/signing`. Binding the timestamp is what makes replay detectable,
51
+ /// so the separator and the order are part of the contract, not a detail.
52
+ public func signRequest(
53
+ _ signer: Signer,
54
+ secret: String,
55
+ timestamp: String,
56
+ body: String
57
+ ) -> String {
58
+ signer.hmacSHA256Hex(secret: secret, message: "\(timestamp).\(body)")
59
+ }
@@ -0,0 +1,74 @@
1
+ import Foundation
2
+
3
+ /// Deferred deep linking: the install that came from a link carrying a
4
+ /// destination, where the destination has to survive the trip through the App
5
+ /// Store.
6
+ ///
7
+ /// The device asks the server what it was attributed to. An organic install is
8
+ /// never attributed, so the lookup is *expected* to answer pending forever for
9
+ /// most devices — which is why the loop gives up rather than polling on.
10
+ public struct DeepLink: Equatable, Sendable {
11
+ public var deepLinkValue: String?
12
+ public var campaign: String?
13
+ public var network: String?
14
+ public var influencerId: String?
15
+ public var isDeferred: Bool
16
+ public var isFirstLaunch: Bool
17
+
18
+ public init(
19
+ deepLinkValue: String? = nil, campaign: String? = nil, network: String? = nil,
20
+ influencerId: String? = nil, isDeferred: Bool = true, isFirstLaunch: Bool = true
21
+ ) {
22
+ self.deepLinkValue = deepLinkValue
23
+ self.campaign = campaign
24
+ self.network = network
25
+ self.influencerId = influencerId
26
+ self.isDeferred = isDeferred
27
+ self.isFirstLaunch = isFirstLaunch
28
+ }
29
+ }
30
+
31
+ /// What a lookup returned: still deciding, or decided.
32
+ public enum ConversionResult: Equatable, Sendable {
33
+ case pending
34
+ case resolved(DeepLink)
35
+ }
36
+
37
+ public protocol ConversionFetcher: Sendable {
38
+ /// Throws `IngestError` on a non-2xx; `isRetryable` decides what happens next.
39
+ func fetch() async throws -> ConversionResult
40
+ }
41
+
42
+ /// Copied from `packages/sdk-core/src/conversion.ts`, not chosen here.
43
+ public let DEFAULT_CONVERSION_BACKOFF_MS: [Int] = [500, 1000, 2000, 4000]
44
+
45
+ /// Polls the conversion lookup until it resolves, the budget runs out, or the
46
+ /// server says never.
47
+ ///
48
+ /// The three exits are deliberately different. A **pending** answer means the
49
+ /// pipeline has not decided yet, so it is worth waiting. A **non-retryable**
50
+ /// error is the server saying it never will, and retrying is pure battery cost
51
+ /// on a device that will never get an answer. A **transient** error is
52
+ /// indistinguishable from pending, so it falls through to the same backoff.
53
+ public func resolveDeferredDeepLink(
54
+ fetcher: any ConversionFetcher,
55
+ maxAttempts: Int = 5,
56
+ backoffMs: [Int] = DEFAULT_CONVERSION_BACKOFF_MS,
57
+ sleep: @Sendable (Int) async -> Void = { ms in
58
+ try? await Task.sleep(nanoseconds: UInt64(ms) * 1_000_000)
59
+ }
60
+ ) async -> DeepLink? {
61
+ for attempt in 0..<maxAttempts {
62
+ do {
63
+ if case .resolved(let link) = try await fetcher.fetch() { return link }
64
+ } catch {
65
+ if let ingest = error as? IngestError, !ingest.isRetryable { return nil }
66
+ // Retryable, or a transport failure: fall through to the backoff.
67
+ }
68
+
69
+ if attempt < maxAttempts - 1 {
70
+ await sleep(attempt < backoffMs.count ? backoffMs[attempt] : (backoffMs.last ?? 0))
71
+ }
72
+ }
73
+ return nil
74
+ }