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