@advenue/react-native 0.9.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -7
- package/android/src/main/java/expo/modules/advenue/AdvenueAndroidModule.kt +118 -368
- 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 -675
- package/dist/index.d.cts +199 -336
- package/dist/index.d.ts +199 -336
- package/dist/index.js +182 -681
- package/ios/AdvenueIosModule.swift +144 -446
- 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 +266 -830
- package/src/native-types.ts +74 -181
- 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,136 @@
|
|
|
1
|
+
package io.advenue.core
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The wire envelope, mirroring `clientEventSchema` in
|
|
5
|
+
* `packages/shared/src/events.ts` and `ClientEvent` in the Swift port.
|
|
6
|
+
*
|
|
7
|
+
* Every optional is genuinely optional on the wire: the server uses zod
|
|
8
|
+
* `.optional()`, which accepts a missing key and **rejects** an explicit
|
|
9
|
+
* `null`. [writeCanonicalJson] omits null object entries, which is exactly the
|
|
10
|
+
* behaviour required — a serialiser emitting `null` would 400 the whole batch,
|
|
11
|
+
* every event in it.
|
|
12
|
+
*/
|
|
13
|
+
internal data class ClientEvent(
|
|
14
|
+
public val id: String,
|
|
15
|
+
public val deviceId: String,
|
|
16
|
+
public val type: String,
|
|
17
|
+
public val name: String,
|
|
18
|
+
public val timestamp: String,
|
|
19
|
+
public val platform: String,
|
|
20
|
+
public val installationId: String? = null,
|
|
21
|
+
public val osVersion: String? = null,
|
|
22
|
+
public val appVersion: String? = null,
|
|
23
|
+
public val sdkVersion: String? = null,
|
|
24
|
+
public val network: String? = null,
|
|
25
|
+
public val campaign: String? = null,
|
|
26
|
+
public val country: String? = null,
|
|
27
|
+
public val idfa: String? = null,
|
|
28
|
+
public val gaid: String? = null,
|
|
29
|
+
public val limitAdTracking: Boolean? = null,
|
|
30
|
+
public val vendorId: String? = null,
|
|
31
|
+
public val androidId: String? = null,
|
|
32
|
+
public val customerUserId: String? = null,
|
|
33
|
+
public val appInstanceId: String? = null,
|
|
34
|
+
public val consent: Map<String, Any?>? = null,
|
|
35
|
+
public val properties: Map<String, Any?>? = null,
|
|
36
|
+
public val pushToken: String? = null,
|
|
37
|
+
public val pushProvider: String? = null,
|
|
38
|
+
public val attestationToken: String? = null,
|
|
39
|
+
public val attestationType: String? = null,
|
|
40
|
+
public val attestationChallenge: String? = null,
|
|
41
|
+
public val adservicesToken: String? = null,
|
|
42
|
+
) {
|
|
43
|
+
internal fun toMap(): Map<String, Any?> =
|
|
44
|
+
mapOf(
|
|
45
|
+
"id" to id,
|
|
46
|
+
"deviceId" to deviceId,
|
|
47
|
+
"type" to type,
|
|
48
|
+
"name" to name,
|
|
49
|
+
"timestamp" to timestamp,
|
|
50
|
+
"platform" to platform,
|
|
51
|
+
"installationId" to installationId,
|
|
52
|
+
"osVersion" to osVersion,
|
|
53
|
+
"appVersion" to appVersion,
|
|
54
|
+
"sdkVersion" to sdkVersion,
|
|
55
|
+
"network" to network,
|
|
56
|
+
"campaign" to campaign,
|
|
57
|
+
"country" to country,
|
|
58
|
+
"idfa" to idfa,
|
|
59
|
+
"gaid" to gaid,
|
|
60
|
+
"limitAdTracking" to limitAdTracking,
|
|
61
|
+
"vendorId" to vendorId,
|
|
62
|
+
"androidId" to androidId,
|
|
63
|
+
"customerUserId" to customerUserId,
|
|
64
|
+
"appInstanceId" to appInstanceId,
|
|
65
|
+
"consent" to consent,
|
|
66
|
+
"properties" to properties,
|
|
67
|
+
"pushToken" to pushToken,
|
|
68
|
+
"pushProvider" to pushProvider,
|
|
69
|
+
"attestationToken" to attestationToken,
|
|
70
|
+
"attestationType" to attestationType,
|
|
71
|
+
"attestationChallenge" to attestationChallenge,
|
|
72
|
+
"adservicesToken" to adservicesToken,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
/** The exact bytes this event puts on the wire. */
|
|
76
|
+
public fun encodeCanonical(): String = writeCanonicalJson(toMap())
|
|
77
|
+
|
|
78
|
+
public companion object {
|
|
79
|
+
/**
|
|
80
|
+
* The queue's reload path — and `properties` is restored with everything
|
|
81
|
+
* else, which it was not.
|
|
82
|
+
*
|
|
83
|
+
* It used to be dropped, on the premise that "a reloaded event is only ever
|
|
84
|
+
* re-sent as the bytes already written". The bytes are not re-sent: the
|
|
85
|
+
* object is. `EventQueue` assigns `events = load(store)` and `peek()`
|
|
86
|
+
* returns from `events`, so a reloaded event is exactly what the transport
|
|
87
|
+
* re-encodes. An app killed before flushing therefore sent every buffered
|
|
88
|
+
* event with no payload — no revenue, no currency, no sku. The writer had
|
|
89
|
+
* always written them; only this refused to read them.
|
|
90
|
+
*
|
|
91
|
+
* A queue conformance vector now pins it in both SDKs at once.
|
|
92
|
+
*/
|
|
93
|
+
internal fun fromMap(map: Map<*, *>): ClientEvent? {
|
|
94
|
+
fun str(key: String): String? = map[key] as? String
|
|
95
|
+
val id = str("id") ?: return null
|
|
96
|
+
val deviceId = str("deviceId") ?: return null
|
|
97
|
+
val type = str("type") ?: return null
|
|
98
|
+
val name = str("name") ?: return null
|
|
99
|
+
val timestamp = str("timestamp") ?: return null
|
|
100
|
+
val platform = str("platform") ?: return null
|
|
101
|
+
return ClientEvent(
|
|
102
|
+
id = id,
|
|
103
|
+
deviceId = deviceId,
|
|
104
|
+
type = type,
|
|
105
|
+
name = name,
|
|
106
|
+
timestamp = timestamp,
|
|
107
|
+
platform = platform,
|
|
108
|
+
installationId = str("installationId"),
|
|
109
|
+
osVersion = str("osVersion"),
|
|
110
|
+
appVersion = str("appVersion"),
|
|
111
|
+
sdkVersion = str("sdkVersion"),
|
|
112
|
+
network = str("network"),
|
|
113
|
+
campaign = str("campaign"),
|
|
114
|
+
country = str("country"),
|
|
115
|
+
idfa = str("idfa"),
|
|
116
|
+
gaid = str("gaid"),
|
|
117
|
+
limitAdTracking = map["limitAdTracking"] as? Boolean,
|
|
118
|
+
vendorId = str("vendorId"),
|
|
119
|
+
androidId = str("androidId"),
|
|
120
|
+
customerUserId = str("customerUserId"),
|
|
121
|
+
appInstanceId = str("appInstanceId"),
|
|
122
|
+
// parseJson already yields the nested Map/List shape this field holds,
|
|
123
|
+
// so there is nothing to convert — only to stop discarding.
|
|
124
|
+
properties = (map["properties"] as? Map<*, *>)?.entries?.associate {
|
|
125
|
+
(it.key as String) to it.value
|
|
126
|
+
},
|
|
127
|
+
pushToken = str("pushToken"),
|
|
128
|
+
pushProvider = str("pushProvider"),
|
|
129
|
+
attestationToken = str("attestationToken"),
|
|
130
|
+
attestationType = str("attestationType"),
|
|
131
|
+
attestationChallenge = str("attestationChallenge"),
|
|
132
|
+
adservicesToken = str("adservicesToken"),
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
package io.advenue.core
|
|
2
|
+
|
|
3
|
+
import java.util.concurrent.LinkedBlockingQueue
|
|
4
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
5
|
+
|
|
6
|
+
/** A command submitted to the engine through the ordered ingress. */
|
|
7
|
+
internal sealed class Command {
|
|
8
|
+
public data class Track(
|
|
9
|
+
val name: String,
|
|
10
|
+
val properties: Map<String, Any?>?,
|
|
11
|
+
val type: String,
|
|
12
|
+
) : Command()
|
|
13
|
+
|
|
14
|
+
public data class SetConsent(val granted: Boolean) : Command()
|
|
15
|
+
|
|
16
|
+
public data class SetUserId(val id: String?) : Command()
|
|
17
|
+
|
|
18
|
+
public data class SetIdentity(
|
|
19
|
+
val gaid: String?,
|
|
20
|
+
val androidId: String?,
|
|
21
|
+
val appInstanceId: String?,
|
|
22
|
+
) : Command()
|
|
23
|
+
|
|
24
|
+
internal data class SetAppInstanceId(val id: String?) : Command()
|
|
25
|
+
|
|
26
|
+
public data class TrackInstall(val properties: Map<String, Any?>?) : Command()
|
|
27
|
+
|
|
28
|
+
internal data class SetConsentData(val consent: Consent?) : Command()
|
|
29
|
+
|
|
30
|
+
internal data class SetPushToken(val token: String?, val provider: String?) : Command()
|
|
31
|
+
|
|
32
|
+
public object ForgetMe : Command()
|
|
33
|
+
|
|
34
|
+
public object Foreground : Command()
|
|
35
|
+
|
|
36
|
+
public object Background : Command()
|
|
37
|
+
|
|
38
|
+
public object Flush : Command()
|
|
39
|
+
|
|
40
|
+
/** Posted back by the io thread; carries what the network said. */
|
|
41
|
+
internal data class BatchOutcome(val error: Throwable?) : Command()
|
|
42
|
+
|
|
43
|
+
internal data class SingleOutcome(val event: ClientEvent, val error: Throwable?) : Command()
|
|
44
|
+
|
|
45
|
+
/** Deferred work hopped back onto the engine's thread — see TimerScheduler. */
|
|
46
|
+
internal data class RunScheduled(val work: () -> Unit) : Command()
|
|
47
|
+
|
|
48
|
+
internal object Shutdown : Command()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The ordered ingress: a synchronous, non-blocking [submit] feeding one
|
|
53
|
+
* consumer thread.
|
|
54
|
+
*
|
|
55
|
+
* Why a queue and not a thread per call: a thread pool does not preserve
|
|
56
|
+
* submission order, so an event could overtake its own `session_start` and be
|
|
57
|
+
* attributed to the wrong session. Adding to a `LinkedBlockingQueue` is
|
|
58
|
+
* non-blocking and ordered, which is exactly the property `AsyncStream.yield`
|
|
59
|
+
* gives the Swift port.
|
|
60
|
+
*
|
|
61
|
+
* **Two threads, on purpose.** `advenue-core` owns all state; `advenue-io`
|
|
62
|
+
* performs the transport call. In Swift, `flush` awaited inside an actor and
|
|
63
|
+
* reentrancy let other work proceed. A single blocking thread has no such
|
|
64
|
+
* property: a 15-second HTTP timeout would block every lifecycle command queued
|
|
65
|
+
* behind it.
|
|
66
|
+
*/
|
|
67
|
+
internal class CommandPipe(
|
|
68
|
+
private val engine: AdvenueEngine,
|
|
69
|
+
private val onError: (String, Throwable) -> Unit = { _, _ -> },
|
|
70
|
+
) {
|
|
71
|
+
/** A send the io thread must perform, and the events it covers. */
|
|
72
|
+
private class Send(val events: List<ClientEvent>, val single: ClientEvent?)
|
|
73
|
+
|
|
74
|
+
private val commands = LinkedBlockingQueue<Command>()
|
|
75
|
+
private val sends = LinkedBlockingQueue<Send>()
|
|
76
|
+
private val running = AtomicBoolean(true)
|
|
77
|
+
|
|
78
|
+
private val core =
|
|
79
|
+
thread("advenue-core") {
|
|
80
|
+
while (running.get()) {
|
|
81
|
+
val command = commands.take()
|
|
82
|
+
if (command === Command.Shutdown) break
|
|
83
|
+
// Each command is handled inside its own try/catch. An uncaught throw
|
|
84
|
+
// would END this thread: the queue would stop draining, submit() would
|
|
85
|
+
// keep accepting, nothing would send, nothing would error, and the app
|
|
86
|
+
// would not crash — attribution would simply stop. Silent total failure
|
|
87
|
+
// is the worst mode available, so this loop must be unkillable.
|
|
88
|
+
try {
|
|
89
|
+
handle(command)
|
|
90
|
+
} catch (error: Throwable) {
|
|
91
|
+
onError("engine.command", error)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Performs network calls and nothing else. It never touches engine state: the
|
|
98
|
+
* decision of what to send and what to do with the answer stays on the core
|
|
99
|
+
* thread, which is what keeps the queue confined to one thread while still
|
|
100
|
+
* letting a 15-second request run without blocking anything.
|
|
101
|
+
*/
|
|
102
|
+
private val io =
|
|
103
|
+
thread("advenue-io") {
|
|
104
|
+
while (running.get()) {
|
|
105
|
+
val send = sends.take()
|
|
106
|
+
if (!running.get()) break
|
|
107
|
+
val error = engine.send(send.events)
|
|
108
|
+
commands.put(
|
|
109
|
+
if (send.single != null) {
|
|
110
|
+
Command.SingleOutcome(send.single, error)
|
|
111
|
+
} else {
|
|
112
|
+
Command.BatchOutcome(error)
|
|
113
|
+
},
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private fun handle(command: Command) {
|
|
119
|
+
when (command) {
|
|
120
|
+
is Command.Track -> engine.track(command.name, command.properties, command.type)
|
|
121
|
+
is Command.SetConsent -> engine.setConsent(command.granted)
|
|
122
|
+
is Command.SetUserId -> engine.setUserId(command.id)
|
|
123
|
+
is Command.SetIdentity ->
|
|
124
|
+
engine.setIdentity(command.gaid, command.androidId, command.appInstanceId)
|
|
125
|
+
is Command.SetAppInstanceId -> engine.setAppInstanceId(command.id)
|
|
126
|
+
is Command.TrackInstall -> engine.trackInstall(command.properties)
|
|
127
|
+
is Command.SetConsentData -> engine.setConsentData(command.consent)
|
|
128
|
+
is Command.SetPushToken -> engine.setPushToken(command.token, command.provider)
|
|
129
|
+
Command.ForgetMe -> engine.forgetMe()
|
|
130
|
+
Command.Foreground -> engine.notifyForeground()
|
|
131
|
+
Command.Background -> engine.notifyBackground()
|
|
132
|
+
Command.Flush -> engine.beginFlush()?.let { sends.put(Send(it, single = null)) }
|
|
133
|
+
is Command.BatchOutcome ->
|
|
134
|
+
engine.onBatchOutcome(command.error)?.let { sends.put(Send(listOf(it), single = it)) }
|
|
135
|
+
is Command.SingleOutcome ->
|
|
136
|
+
engine.onSingleOutcome(command.event, command.error)?.let {
|
|
137
|
+
sends.put(Send(listOf(it), single = it))
|
|
138
|
+
}
|
|
139
|
+
is Command.RunScheduled -> command.work()
|
|
140
|
+
Command.Shutdown -> Unit
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Synchronous, non-blocking and **ordered** — the entire reason the ingress
|
|
146
|
+
* is a queue.
|
|
147
|
+
*/
|
|
148
|
+
public fun submit(command: Command) {
|
|
149
|
+
if (!running.get()) return
|
|
150
|
+
commands.put(command)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
public fun shutdown() {
|
|
154
|
+
if (!running.compareAndSet(true, false)) return
|
|
155
|
+
commands.put(Command.Shutdown)
|
|
156
|
+
sends.put(Send(emptyList(), single = null))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Test surface: block until both queues are empty or the deadline passes. */
|
|
160
|
+
internal fun drain(timeoutMs: Long = 2_000) {
|
|
161
|
+
val deadline = System.currentTimeMillis() + timeoutMs
|
|
162
|
+
while (
|
|
163
|
+
(commands.isNotEmpty() || sends.isNotEmpty()) && System.currentTimeMillis() < deadline
|
|
164
|
+
) {
|
|
165
|
+
Thread.sleep(5)
|
|
166
|
+
}
|
|
167
|
+
Thread.sleep(20)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private fun thread(name: String, body: () -> Unit): Thread =
|
|
171
|
+
Thread(
|
|
172
|
+
{
|
|
173
|
+
try {
|
|
174
|
+
body()
|
|
175
|
+
} catch (_: InterruptedException) {
|
|
176
|
+
// shutdown
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
name,
|
|
180
|
+
)
|
|
181
|
+
.apply {
|
|
182
|
+
// Daemon: the SDK must never hold a process open.
|
|
183
|
+
isDaemon = true
|
|
184
|
+
start()
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
package io.advenue.core
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Granular ad-platform consent (Google DMA), forwarded to Google and Meta by
|
|
5
|
+
* server-side postbacks as `gdpr_applies`, `ad_user_data`, `ad_personalization`
|
|
6
|
+
* and `ad_storage`.
|
|
7
|
+
*
|
|
8
|
+
* Three of the four fields are optional, and that is the point rather than
|
|
9
|
+
* laxity: **"not stated" is not "denied".** A CMP that has not asked about ad
|
|
10
|
+
* storage yet must not have `false` invented on its behalf, because downstream
|
|
11
|
+
* that is a recorded refusal. The canonical writer omits nulls, which is what
|
|
12
|
+
* keeps the distinction on the wire.
|
|
13
|
+
*/
|
|
14
|
+
// Public because the facade both takes and returns it: an app that calls
|
|
15
|
+
// setConsentData must be able to read back what it set, and the iOS SDK's
|
|
16
|
+
// Consent is public for the same reason.
|
|
17
|
+
public data class Consent(
|
|
18
|
+
val isUserSubjectToGDPR: Boolean,
|
|
19
|
+
val hasConsentForDataUsage: Boolean? = null,
|
|
20
|
+
val hasConsentForAdsPersonalization: Boolean? = null,
|
|
21
|
+
val hasConsentForAdStorage: Boolean? = null,
|
|
22
|
+
) {
|
|
23
|
+
internal fun toMap(): Map<String, Any?> =
|
|
24
|
+
mapOf(
|
|
25
|
+
"isUserSubjectToGDPR" to isUserSubjectToGDPR,
|
|
26
|
+
"hasConsentForDataUsage" to hasConsentForDataUsage,
|
|
27
|
+
"hasConsentForAdsPersonalization" to hasConsentForAdsPersonalization,
|
|
28
|
+
"hasConsentForAdStorage" to hasConsentForAdStorage,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
internal companion object {
|
|
32
|
+
internal fun fromMap(map: Map<*, *>): Consent? {
|
|
33
|
+
val gdpr = map["isUserSubjectToGDPR"] as? Boolean ?: return null
|
|
34
|
+
return Consent(
|
|
35
|
+
isUserSubjectToGDPR = gdpr,
|
|
36
|
+
hasConsentForDataUsage = map["hasConsentForDataUsage"] as? Boolean,
|
|
37
|
+
hasConsentForAdsPersonalization = map["hasConsentForAdsPersonalization"] as? Boolean,
|
|
38
|
+
hasConsentForAdStorage = map["hasConsentForAdStorage"] as? Boolean,
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Validates a push token against the server's bound and charset.
|
|
46
|
+
*
|
|
47
|
+
* Rejecting here costs one dropped registration; letting it through costs every
|
|
48
|
+
* event in the batch it would have ridden in, because ingest parses a batch as
|
|
49
|
+
* a whole.
|
|
50
|
+
*/
|
|
51
|
+
internal fun isValidPushToken(token: String): Boolean =
|
|
52
|
+
token.isNotEmpty() && token.length <= 512 && token.all { it.isLetterOrDigit() || it in "_:.-" }
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
package io.advenue.core
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Synchronous key/value storage. On Android this is a `SharedPreferences` file;
|
|
5
|
+
* in tests it is a map. Mirrors `StorageAdapter` in sdk-core and `KeyValueStore`
|
|
6
|
+
* in the Swift port.
|
|
7
|
+
*/
|
|
8
|
+
internal interface KeyValueStore {
|
|
9
|
+
public fun getString(key: String): String?
|
|
10
|
+
|
|
11
|
+
public fun setString(key: String, value: String)
|
|
12
|
+
|
|
13
|
+
public fun remove(key: String)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Milliseconds since the Unix epoch. Injected so vectors are deterministic. */
|
|
17
|
+
internal interface Clock {
|
|
18
|
+
public fun nowMs(): Long
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Opaque handle returned by [Scheduler.schedule]. */
|
|
22
|
+
internal typealias CancelToken = Long
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Deferred execution, injected so the queue's persist debounce is observable
|
|
26
|
+
* without waiting for it.
|
|
27
|
+
*
|
|
28
|
+
* **Isolation contract.** The scheduled work touches state confined to the
|
|
29
|
+
* engine's thread, so an implementation must run it on that same thread. The
|
|
30
|
+
* production scheduler satisfies this by posting back onto the command pipe;
|
|
31
|
+
* the test scheduler satisfies it by running inline.
|
|
32
|
+
*/
|
|
33
|
+
internal interface Scheduler {
|
|
34
|
+
public fun schedule(afterMs: Int, work: () -> Unit): CancelToken
|
|
35
|
+
|
|
36
|
+
public fun cancel(token: CancelToken)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Event, session and device id source. Injected for the same reason. */
|
|
40
|
+
internal interface UuidSource {
|
|
41
|
+
public fun next(): String
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* HMAC-SHA256. An interface rather than a bare function so tests can pin the
|
|
46
|
+
* digest, but unlike the Swift port there is only ever one implementation:
|
|
47
|
+
* `javax.crypto` is in every JDK and every Android runtime, so the core can own
|
|
48
|
+
* it without taking a dependency.
|
|
49
|
+
*/
|
|
50
|
+
internal interface Signer {
|
|
51
|
+
/** Lowercase hex digest of [message] under [secret], both UTF-8. */
|
|
52
|
+
public fun hmacSha256Hex(secret: String, message: String): String
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* HMAC over `"{timestamp}.{body}"` — the Stripe-style construction in
|
|
57
|
+
* `packages/signing`. Binding the timestamp is what makes replay detectable, so
|
|
58
|
+
* the separator and the order are part of the contract, not a detail.
|
|
59
|
+
*/
|
|
60
|
+
internal fun signRequest(
|
|
61
|
+
signer: Signer,
|
|
62
|
+
secret: String,
|
|
63
|
+
timestamp: String,
|
|
64
|
+
body: String,
|
|
65
|
+
): String = signer.hmacSha256Hex(secret, "$timestamp.$body")
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What the core needs from a transport, and no more.
|
|
69
|
+
*
|
|
70
|
+
* Declared in the core rather than the platform layer because the flush loop is
|
|
71
|
+
* behaviour the `flush/` conformance vectors bind, and a rule the vectors cannot
|
|
72
|
+
* reach is a rule three implementations can each interpret differently.
|
|
73
|
+
*/
|
|
74
|
+
internal interface EventTransport {
|
|
75
|
+
/**
|
|
76
|
+
* Delivers one batch. Throws [IngestError] on a non-2xx response, and on a
|
|
77
|
+
* transport failure, which maps to 408.
|
|
78
|
+
*/
|
|
79
|
+
public fun send(events: List<ClientEvent>)
|
|
80
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
package io.advenue.core
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Deferred deep linking: the install that came from a link carrying a
|
|
5
|
+
* destination, where the destination has to survive the trip through the 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 come back pending forever for
|
|
9
|
+
* most devices — which is why the loop gives up rather than polling on.
|
|
10
|
+
*/
|
|
11
|
+
public data class DeepLink(
|
|
12
|
+
val deepLinkValue: String? = null,
|
|
13
|
+
val campaign: String? = null,
|
|
14
|
+
val network: String? = null,
|
|
15
|
+
val influencerId: String? = null,
|
|
16
|
+
val params: Map<String, Any?>? = null,
|
|
17
|
+
val isDeferred: Boolean = true,
|
|
18
|
+
val isFirstLaunch: Boolean = true,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
/** What a lookup returned: still deciding, decided, or refused. */
|
|
22
|
+
public sealed class ConversionResult {
|
|
23
|
+
public object Pending : ConversionResult()
|
|
24
|
+
|
|
25
|
+
public data class Resolved(val link: DeepLink) : ConversionResult()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public interface ConversionFetcher {
|
|
29
|
+
/** Throws [IngestError] on a non-2xx; [IngestError.isRetryable] decides. */
|
|
30
|
+
public fun fetch(): ConversionResult
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Copied from `packages/sdk-core/src/conversion.ts`, not chosen here. */
|
|
34
|
+
public val DEFAULT_CONVERSION_BACKOFF_MS: List<Long> = listOf(500L, 1_000L, 2_000L, 4_000L)
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Polls the conversion lookup until it resolves, the budget runs out, or the
|
|
38
|
+
* server says never.
|
|
39
|
+
*
|
|
40
|
+
* The three exits are deliberately different. A **pending** answer means the
|
|
41
|
+
* pipeline has not decided yet, so it is worth waiting. A **non-retryable**
|
|
42
|
+
* error is the server saying it never will, and retrying it is pure battery
|
|
43
|
+
* cost on a device that will never get an answer. A **transient** error is
|
|
44
|
+
* indistinguishable from pending, so it falls through to the same backoff.
|
|
45
|
+
*/
|
|
46
|
+
public fun resolveDeferredDeepLink(
|
|
47
|
+
fetcher: ConversionFetcher,
|
|
48
|
+
maxAttempts: Int = 5,
|
|
49
|
+
backoffMs: List<Long> = DEFAULT_CONVERSION_BACKOFF_MS,
|
|
50
|
+
sleep: (Long) -> Unit = { Thread.sleep(it) },
|
|
51
|
+
): DeepLink? {
|
|
52
|
+
for (attempt in 0 until maxAttempts) {
|
|
53
|
+
try {
|
|
54
|
+
when (val result = fetcher.fetch()) {
|
|
55
|
+
is ConversionResult.Resolved -> return result.link
|
|
56
|
+
ConversionResult.Pending -> Unit
|
|
57
|
+
}
|
|
58
|
+
} catch (error: Throwable) {
|
|
59
|
+
val ingest = error as? IngestError
|
|
60
|
+
if (ingest != null && !ingest.isRetryable) return null
|
|
61
|
+
// Retryable, or a transport failure: fall through to the backoff.
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (attempt < maxAttempts - 1) {
|
|
65
|
+
sleep(backoffMs.getOrElse(attempt) { backoffMs.lastOrNull() ?: 0L })
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null
|
|
69
|
+
}
|