@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.
- package/README.md +8 -7
- package/android/src/main/java/expo/modules/advenue/AdvenueAndroidModule.kt +117 -368
- package/android/src/main/kotlin/io/advenue/Advenue.kt +574 -0
- package/android/src/main/kotlin/io/advenue/AdvenueConfig.kt +141 -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 +193 -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 +436 -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 +50 -0
- package/android/src/main/kotlin/io/advenue/core/MetaReferrer.kt +80 -0
- package/android/src/main/kotlin/io/advenue/core/PiiScrub.kt +77 -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 +163 -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 +96 -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 +188 -675
- package/dist/index.d.cts +203 -336
- package/dist/index.d.ts +203 -336
- package/dist/index.js +187 -681
- package/ios/AdvenueIosModule.swift +143 -446
- package/ios/vendor/Advenue/Advenue.swift +632 -0
- package/ios/vendor/Advenue/AdvenueConfig.swift +89 -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 +607 -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/CacheFileStore.swift +90 -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 +77 -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 +155 -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 +13 -15
- 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 +273 -830
- package/src/native-types.ts +73 -181
- package/src/native.ts +0 -23
- package/src/types.ts +86 -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,189 @@
|
|
|
1
|
+
package io.advenue.core
|
|
2
|
+
|
|
3
|
+
internal const val SESSION_STATE_KEY: String = "advenue.session"
|
|
4
|
+
|
|
5
|
+
/** 30 minutes. */
|
|
6
|
+
internal const val DEFAULT_SESSION_WINDOW_MS: Long = 1_800_000L
|
|
7
|
+
|
|
8
|
+
/** A session lifecycle event, wrapped by the engine as a `type: "session"` event. */
|
|
9
|
+
internal data class SessionEvent(val name: String, val properties: Map<String, Any?>)
|
|
10
|
+
|
|
11
|
+
private data class SessionState(
|
|
12
|
+
val sessionId: String,
|
|
13
|
+
val sessionNumber: Long,
|
|
14
|
+
val lastBackgroundAt: Long?,
|
|
15
|
+
val subSessionCount: Long,
|
|
16
|
+
val activeStart: Long?,
|
|
17
|
+
val firstForegroundAt: Long,
|
|
18
|
+
val timeSpentMs: Long,
|
|
19
|
+
) {
|
|
20
|
+
/**
|
|
21
|
+
* The persisted blob. Field names are frozen: a rename would make RN-written
|
|
22
|
+
* state unloadable and silently restart every device's session numbering.
|
|
23
|
+
*/
|
|
24
|
+
fun toMap(): Map<String, Any?> =
|
|
25
|
+
mapOf(
|
|
26
|
+
"sessionId" to sessionId,
|
|
27
|
+
"sessionNumber" to sessionNumber,
|
|
28
|
+
"lastBackgroundAt" to lastBackgroundAt,
|
|
29
|
+
"subSessionCount" to subSessionCount,
|
|
30
|
+
"activeStart" to activeStart,
|
|
31
|
+
"firstForegroundAt" to firstForegroundAt,
|
|
32
|
+
"timeSpentMs" to timeSpentMs,
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Platform-agnostic session state machine. Pure: it persists state and returns
|
|
38
|
+
* the events to emit; enqueueing is the caller's job. Driven by foreground and
|
|
39
|
+
* background lifecycle signals.
|
|
40
|
+
*/
|
|
41
|
+
internal class SessionTracker(
|
|
42
|
+
private val store: KeyValueStore,
|
|
43
|
+
private val clock: Clock,
|
|
44
|
+
private val windowMs: Long = DEFAULT_SESSION_WINDOW_MS,
|
|
45
|
+
private val uuid: UuidSource,
|
|
46
|
+
) {
|
|
47
|
+
private var lastState: SessionState? = null
|
|
48
|
+
|
|
49
|
+
/** Drops the cached state (erasure) so a wiped store cannot be resurrected. */
|
|
50
|
+
public fun reset() {
|
|
51
|
+
lastState = null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Foreground transition, including cold start. Returns:
|
|
56
|
+
* - empty — a sub-session inside the window;
|
|
57
|
+
* - `[session_start]` — a new session;
|
|
58
|
+
* - `[session_end, session_start]` — kill recovery: the prior session was
|
|
59
|
+
* still open when the OS killed the app, so its end is synthesised first.
|
|
60
|
+
*/
|
|
61
|
+
public fun handleForeground(): List<SessionEvent> {
|
|
62
|
+
val state = load()
|
|
63
|
+
val now = clock.nowMs()
|
|
64
|
+
val gap = if (state?.lastBackgroundAt != null) now - state.lastBackgroundAt else null
|
|
65
|
+
|
|
66
|
+
if (state == null || gap == null || gap >= windowMs) {
|
|
67
|
+
val sessionNumber = (state?.sessionNumber ?: 0L) + 1L
|
|
68
|
+
val sessionId = uuid.next()
|
|
69
|
+
save(
|
|
70
|
+
SessionState(
|
|
71
|
+
sessionId = sessionId,
|
|
72
|
+
sessionNumber = sessionNumber,
|
|
73
|
+
lastBackgroundAt = null,
|
|
74
|
+
subSessionCount = 1L,
|
|
75
|
+
activeStart = now,
|
|
76
|
+
firstForegroundAt = now,
|
|
77
|
+
timeSpentMs = 0L,
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
val sessionStart =
|
|
82
|
+
SessionEvent(
|
|
83
|
+
"session_start",
|
|
84
|
+
mapOf(
|
|
85
|
+
"sessionId" to sessionId,
|
|
86
|
+
"sessionNumber" to sessionNumber,
|
|
87
|
+
"subSession" to 1L,
|
|
88
|
+
"isFirstSession" to (sessionNumber == 1L),
|
|
89
|
+
// 0 rather than infinity, which would not survive JSON.
|
|
90
|
+
"timeSinceLastSessionMs" to (gap ?: 0L),
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if (state?.activeStart != null) {
|
|
95
|
+
val killTime = state.lastBackgroundAt ?: now
|
|
96
|
+
val activeMs = Math.max(0L, killTime - state.activeStart)
|
|
97
|
+
val timeSpentMs = state.timeSpentMs + activeMs
|
|
98
|
+
val sessionEnd =
|
|
99
|
+
SessionEvent(
|
|
100
|
+
"session_end",
|
|
101
|
+
mapOf(
|
|
102
|
+
"sessionId" to state.sessionId,
|
|
103
|
+
"sessionNumber" to state.sessionNumber,
|
|
104
|
+
"subSession" to state.subSessionCount,
|
|
105
|
+
"activeMs" to activeMs,
|
|
106
|
+
"timeSpentMs" to timeSpentMs,
|
|
107
|
+
"sessionLengthMs" to Math.max(0L, killTime - state.firstForegroundAt),
|
|
108
|
+
"synthetic" to true,
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
return listOf(sessionEnd, sessionStart)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return listOf(sessionStart)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
save(state.copy(subSessionCount = state.subSessionCount + 1L, activeStart = now))
|
|
118
|
+
return emptyList()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Background transition. Returns a session_end with the active duration, or null. */
|
|
122
|
+
public fun handleBackground(): SessionEvent? {
|
|
123
|
+
val state = load() ?: return null
|
|
124
|
+
val activeStart = state.activeStart ?: return null
|
|
125
|
+
val now = clock.nowMs()
|
|
126
|
+
val activeMs = Math.max(0L, now - activeStart)
|
|
127
|
+
val timeSpentMs = state.timeSpentMs + activeMs
|
|
128
|
+
save(state.copy(lastBackgroundAt = now, activeStart = null, timeSpentMs = timeSpentMs))
|
|
129
|
+
return SessionEvent(
|
|
130
|
+
"session_end",
|
|
131
|
+
mapOf(
|
|
132
|
+
"sessionId" to state.sessionId,
|
|
133
|
+
"sessionNumber" to state.sessionNumber,
|
|
134
|
+
"subSession" to state.subSessionCount,
|
|
135
|
+
// This sub-session's foreground stretch.
|
|
136
|
+
"activeMs" to activeMs,
|
|
137
|
+
// Cumulative active time across the session's sub-sessions.
|
|
138
|
+
"timeSpentMs" to timeSpentMs,
|
|
139
|
+
// Wall-clock span.
|
|
140
|
+
"sessionLengthMs" to Math.max(0L, now - state.firstForegroundAt),
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private fun load(): SessionState? {
|
|
146
|
+
lastState?.let { return it }
|
|
147
|
+
val raw = store.getString(SESSION_STATE_KEY) ?: return null
|
|
148
|
+
val map = parseJson(raw) as? Map<*, *> ?: return null
|
|
149
|
+
|
|
150
|
+
val sessionId = map["sessionId"] as? String ?: return null
|
|
151
|
+
val sessionNumber = (map["sessionNumber"] as? Number)?.toLong() ?: return null
|
|
152
|
+
val subSessionCount = (map["subSessionCount"] as? Number)?.toLong() ?: return null
|
|
153
|
+
val lastBackgroundAt = (map["lastBackgroundAt"] as? Number)?.toLong()
|
|
154
|
+
val activeStart = (map["activeStart"] as? Number)?.toLong()
|
|
155
|
+
|
|
156
|
+
// Back-compat for state persisted before these two fields existed. Without
|
|
157
|
+
// it, an app updating from an older SDK would fail to load its own session
|
|
158
|
+
// and restart its numbering.
|
|
159
|
+
val timeSpentMs = (map["timeSpentMs"] as? Number)?.toLong() ?: 0L
|
|
160
|
+
val firstForegroundAt =
|
|
161
|
+
(map["firstForegroundAt"] as? Number)?.toLong()
|
|
162
|
+
?: activeStart
|
|
163
|
+
?: lastBackgroundAt
|
|
164
|
+
?: clock.nowMs()
|
|
165
|
+
|
|
166
|
+
val state =
|
|
167
|
+
SessionState(
|
|
168
|
+
sessionId = sessionId,
|
|
169
|
+
sessionNumber = sessionNumber,
|
|
170
|
+
lastBackgroundAt = lastBackgroundAt,
|
|
171
|
+
subSessionCount = subSessionCount,
|
|
172
|
+
activeStart = activeStart,
|
|
173
|
+
firstForegroundAt = firstForegroundAt,
|
|
174
|
+
timeSpentMs = timeSpentMs,
|
|
175
|
+
)
|
|
176
|
+
lastState = state
|
|
177
|
+
return state
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private fun save(state: SessionState) {
|
|
181
|
+
lastState = state
|
|
182
|
+
try {
|
|
183
|
+
store.setString(SESSION_STATE_KEY, writeCanonicalJson(state.toMap()))
|
|
184
|
+
} catch (_: Exception) {
|
|
185
|
+
// The in-memory state keeps this process consistent even if the write
|
|
186
|
+
// failed; losing session continuity is better than crashing the host app.
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
package io.advenue.core
|
|
2
|
+
|
|
3
|
+
import java.util.Timer
|
|
4
|
+
import java.util.TimerTask
|
|
5
|
+
import java.util.UUID
|
|
6
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
7
|
+
import java.util.concurrent.atomic.AtomicLong
|
|
8
|
+
|
|
9
|
+
/** Wall clock. */
|
|
10
|
+
internal class SystemClock : Clock {
|
|
11
|
+
override fun nowMs(): Long = System.currentTimeMillis()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Random v4 identifiers, lowercase, matching what TypeScript's generator emits. */
|
|
15
|
+
internal class SystemUuids : UuidSource {
|
|
16
|
+
override fun next(): String = UUID.randomUUID().toString()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Deferred execution for the queue's persist debounce.
|
|
21
|
+
*
|
|
22
|
+
* **Isolation contract.** The scheduled work touches the event list, which is
|
|
23
|
+
* confined to the engine's thread. Running it on the timer thread would race
|
|
24
|
+
* `enqueue` — an iteration over an `ArrayList` while another thread appends —
|
|
25
|
+
* so the timer only *wakes*, and [dispatcher] hops the work back onto the
|
|
26
|
+
* engine's thread. The Swift port put the same requirement on its `Scheduler`
|
|
27
|
+
* protocol for exactly this reason.
|
|
28
|
+
*
|
|
29
|
+
* [dispatcher] is assigned after the pipe exists. Until then it runs inline,
|
|
30
|
+
* which is safe because nothing can be enqueued before the pipe exists, so no
|
|
31
|
+
* persist can have been scheduled.
|
|
32
|
+
*/
|
|
33
|
+
internal class TimerScheduler : Scheduler {
|
|
34
|
+
private val timer = Timer("advenue-scheduler", true)
|
|
35
|
+
private val tasks = ConcurrentHashMap<CancelToken, TimerTask>()
|
|
36
|
+
private val next = AtomicLong(1)
|
|
37
|
+
|
|
38
|
+
@Volatile public var dispatcher: ((() -> Unit) -> Unit)? = null
|
|
39
|
+
|
|
40
|
+
override fun schedule(afterMs: Int, work: () -> Unit): CancelToken {
|
|
41
|
+
val token = next.getAndIncrement()
|
|
42
|
+
val task =
|
|
43
|
+
object : TimerTask() {
|
|
44
|
+
override fun run() {
|
|
45
|
+
tasks.remove(token)
|
|
46
|
+
val hop = dispatcher
|
|
47
|
+
if (hop != null) hop(work) else work()
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
tasks[token] = task
|
|
51
|
+
timer.schedule(task, afterMs.toLong())
|
|
52
|
+
return token
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
override fun cancel(token: CancelToken) {
|
|
56
|
+
tasks.remove(token)?.cancel()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
package io.advenue.core
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Maps raw IAB TCF v2 CMP data to the DMA [Consent] shape, per Google's EU User
|
|
5
|
+
* Consent Policy reading of the TCF purposes:
|
|
6
|
+
*
|
|
7
|
+
* - `ad_storage` ← Purpose 1
|
|
8
|
+
* - `ad_user_data` ← Purposes 1 **and** 7
|
|
9
|
+
* - `ad_personalization` ← Purposes 3 **and** 4
|
|
10
|
+
*
|
|
11
|
+
* Returns null when `gdprApplies` is neither 0 nor 1. Absent consent must stay
|
|
12
|
+
* absent: a guessed value is forwarded to ad networks as a real signal, and a
|
|
13
|
+
* signal invented on a user's behalf is the one thing consent plumbing must
|
|
14
|
+
* never do.
|
|
15
|
+
*/
|
|
16
|
+
internal fun tcfToConsent(gdprApplies: Int?, purposeConsents: String?): Consent? {
|
|
17
|
+
if (gdprApplies != 0 && gdprApplies != 1) return null
|
|
18
|
+
if (gdprApplies == 0) return Consent(isUserSubjectToGDPR = false)
|
|
19
|
+
|
|
20
|
+
val consents = purposeConsents ?: ""
|
|
21
|
+
fun purpose(n: Int): Boolean = consents.getOrNull(n - 1) == '1'
|
|
22
|
+
|
|
23
|
+
return Consent(
|
|
24
|
+
isUserSubjectToGDPR = true,
|
|
25
|
+
hasConsentForAdStorage = purpose(1),
|
|
26
|
+
hasConsentForDataUsage = purpose(1) && purpose(7),
|
|
27
|
+
hasConsentForAdsPersonalization = purpose(3) && purpose(4),
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Meta AEM `campaign_ids`, extracted from an `al_applink_data` payload.
|
|
33
|
+
*
|
|
34
|
+
* The input is the ALREADY percent-decoded query value: decoding again would
|
|
35
|
+
* corrupt a blob containing '%'. Meta does not publicly document the schema, so
|
|
36
|
+
* both shapes seen in the wild are accepted, top level first.
|
|
37
|
+
*
|
|
38
|
+
* The blob is opaque, Meta-encrypted, non-user metadata and is returned
|
|
39
|
+
* verbatim — never trimmed or re-encoded. The 2048 cap is URL hygiene; the
|
|
40
|
+
* server re-validates, because an SDK-side cap is advisory at a trust boundary.
|
|
41
|
+
*/
|
|
42
|
+
internal fun extractAemCampaignIds(alApplinkData: String): String? {
|
|
43
|
+
val parsed = parseJson(alApplinkData) as? Map<*, *> ?: return null
|
|
44
|
+
val top = parsed["campaign_ids"] as? String
|
|
45
|
+
val nested = (parsed["extras"] as? Map<*, *>)?.get("campaign_ids") as? String
|
|
46
|
+
val value = top?.takeIf { it.isNotEmpty() } ?: nested?.takeIf { it.isNotEmpty() } ?: return null
|
|
47
|
+
return value.takeIf { it.length <= 2048 }
|
|
48
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
package io.advenue.core
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Milliseconds since the epoch to the exact form TypeScript's `toISOString()`
|
|
5
|
+
* produces: always three fractional digits, always `Z`.
|
|
6
|
+
*
|
|
7
|
+
* Computed arithmetically rather than with `Calendar` or `SimpleDateFormat`.
|
|
8
|
+
* `SimpleDateFormat` is not thread-safe, `java.time` needs API 26, and both
|
|
9
|
+
* would make the function depend on a default locale and time zone that a host
|
|
10
|
+
* app can change underneath us. Every event on the wire carries this value, so
|
|
11
|
+
* it has to be a pure function of its input.
|
|
12
|
+
*/
|
|
13
|
+
internal fun iso8601(ms: Long): String {
|
|
14
|
+
// Floor to the second first, so a negative millisecond remainder cannot
|
|
15
|
+
// borrow from the second field for pre-epoch timestamps.
|
|
16
|
+
val wholeSeconds = Math.floorDiv(ms, 1000L)
|
|
17
|
+
val millis = ms - wholeSeconds * 1000L
|
|
18
|
+
|
|
19
|
+
val days = Math.floorDiv(wholeSeconds, 86_400L)
|
|
20
|
+
var secondOfDay = wholeSeconds - days * 86_400L
|
|
21
|
+
val hour = secondOfDay / 3600
|
|
22
|
+
secondOfDay -= hour * 3600
|
|
23
|
+
val minute = secondOfDay / 60
|
|
24
|
+
val second = secondOfDay - minute * 60
|
|
25
|
+
|
|
26
|
+
val (year, month, day) = civilFromDays(days)
|
|
27
|
+
return String.format(
|
|
28
|
+
"%04d-%02d-%02dT%02d:%02d:%02d.%03dZ",
|
|
29
|
+
year,
|
|
30
|
+
month,
|
|
31
|
+
day,
|
|
32
|
+
hour,
|
|
33
|
+
minute,
|
|
34
|
+
second,
|
|
35
|
+
millis,
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Days since 1970-01-01 to a proleptic Gregorian year/month/day. Hinnant's
|
|
41
|
+
* civil-from-days: exact for the whole range a Long of milliseconds can hold,
|
|
42
|
+
* and free of any calendar object.
|
|
43
|
+
*/
|
|
44
|
+
private fun civilFromDays(daysSinceEpoch: Long): Triple<Long, Long, Long> {
|
|
45
|
+
val z = daysSinceEpoch + 719_468L
|
|
46
|
+
val era = Math.floorDiv(z, 146_097L)
|
|
47
|
+
val dayOfEra = z - era * 146_097L
|
|
48
|
+
val yearOfEra =
|
|
49
|
+
(dayOfEra - dayOfEra / 1460 + dayOfEra / 36_524 - dayOfEra / 146_096) / 365
|
|
50
|
+
val year = yearOfEra + era * 400
|
|
51
|
+
val dayOfYear = dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100)
|
|
52
|
+
val monthPrime = (5 * dayOfYear + 2) / 153
|
|
53
|
+
val day = dayOfYear - (153 * monthPrime + 2) / 5 + 1
|
|
54
|
+
val month = if (monthPrime < 10) monthPrime + 3 else monthPrime - 9
|
|
55
|
+
return Triple(if (month <= 2) year + 1 else year, month, day)
|
|
56
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
package io.advenue.platform
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.net.Uri
|
|
5
|
+
import android.os.Build
|
|
6
|
+
import android.os.StatFs
|
|
7
|
+
import android.telephony.TelephonyManager
|
|
8
|
+
import java.util.Locale
|
|
9
|
+
import java.util.TimeZone
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Meta (Facebook/Instagram) install referrer, read from the installed Meta app's
|
|
13
|
+
* content provider. No dependency, which is why it lives in the base rather than
|
|
14
|
+
* a plugin.
|
|
15
|
+
*
|
|
16
|
+
* Two details are easy to get wrong and both come from Meta's own MIR
|
|
17
|
+
* documentation: Instagram's authority is `com.instagram.contentprovider.*`,
|
|
18
|
+
* **not** `<package>.provider.*` like the others, and the path segment is the
|
|
19
|
+
* **Meta app id**, not the Android package name.
|
|
20
|
+
*
|
|
21
|
+
* Returns null when no Meta app is installed, which is the common case.
|
|
22
|
+
*/
|
|
23
|
+
internal fun readMetaInstallReferrer(context: Context, fbAppId: String): Map<String, Any?>? {
|
|
24
|
+
val providers =
|
|
25
|
+
listOf(
|
|
26
|
+
"com.facebook.katana" to "com.facebook.katana.provider.InstallReferrerProvider",
|
|
27
|
+
"com.instagram.android" to "com.instagram.contentprovider.InstallReferrerProvider",
|
|
28
|
+
"com.facebook.lite" to "com.facebook.lite.provider.InstallReferrerProvider",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
for ((pkg, authority) in providers) {
|
|
32
|
+
val uri = Uri.parse("content://$authority/$fbAppId")
|
|
33
|
+
val cursor =
|
|
34
|
+
runCatching {
|
|
35
|
+
context.contentResolver.query(
|
|
36
|
+
uri,
|
|
37
|
+
arrayOf("install_referrer", "is_ct", "actual_timestamp"),
|
|
38
|
+
null,
|
|
39
|
+
null,
|
|
40
|
+
null,
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
.getOrNull() ?: continue
|
|
44
|
+
|
|
45
|
+
cursor.use {
|
|
46
|
+
if (it.moveToFirst()) {
|
|
47
|
+
val referrerIdx = it.getColumnIndex("install_referrer")
|
|
48
|
+
if (referrerIdx >= 0) {
|
|
49
|
+
val ctIdx = it.getColumnIndex("is_ct")
|
|
50
|
+
val tsIdx = it.getColumnIndex("actual_timestamp")
|
|
51
|
+
return mapOf(
|
|
52
|
+
"installReferrer" to it.getString(referrerIdx),
|
|
53
|
+
"isClickThrough" to (ctIdx >= 0 && it.getInt(ctIdx) == 1),
|
|
54
|
+
"actualTimestamp" to if (tsIdx >= 0) it.getLong(tsIdx) else 0L,
|
|
55
|
+
"provider" to pkg,
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* IAB TCF v2 data a compliant CMP writes to the **default**
|
|
66
|
+
* `"<package>_preferences"` file, per the TCF spec — deliberately not our own
|
|
67
|
+
* suite. Reading our file instead would report no consent data for every app on
|
|
68
|
+
* earth, silently.
|
|
69
|
+
*/
|
|
70
|
+
internal fun readTcf(context: Context): Map<String, Any?>? {
|
|
71
|
+
val prefs =
|
|
72
|
+
runCatching {
|
|
73
|
+
context.getSharedPreferences("${context.packageName}_preferences", Context.MODE_PRIVATE)
|
|
74
|
+
}
|
|
75
|
+
.getOrNull() ?: return null
|
|
76
|
+
|
|
77
|
+
// prefs.all plus a map lookup: a missing key reads as null with no default
|
|
78
|
+
// involved, so "missing" and "0" stay distinguishable. `as? Number` tolerates
|
|
79
|
+
// a CMP writing Int OR Long — SharedPreferences has no fixed numeric type and
|
|
80
|
+
// the TCF spec does not pick one.
|
|
81
|
+
val gdprApplies = runCatching { (prefs.all["IABTCF_gdprApplies"] as? Number)?.toInt() }.getOrNull()
|
|
82
|
+
val purposeConsents =
|
|
83
|
+
runCatching { prefs.getString("IABTCF_PurposeConsents", null) }.getOrNull()
|
|
84
|
+
|
|
85
|
+
return tcfFrom(gdprApplies, purposeConsents)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The pure half, so the distinction that matters is testable without a Context:
|
|
90
|
+
* a CMP that has never written `IABTCF_gdprApplies` is not the same as one that
|
|
91
|
+
* wrote zero, and treating them alike would apply GDPR gating to users it does
|
|
92
|
+
* not cover, or fail to apply it to users it does.
|
|
93
|
+
*/
|
|
94
|
+
internal fun tcfFrom(gdprApplies: Int?, purposeConsents: String?): Map<String, Any?>? {
|
|
95
|
+
if (gdprApplies == null && purposeConsents == null) return null
|
|
96
|
+
return mapOf("gdprApplies" to gdprApplies, "purposeConsents" to purposeConsents)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Device metadata for Meta CAPI `extinfo`.
|
|
101
|
+
*
|
|
102
|
+
* Best-effort per field: a field is **omitted**, never defaulted, when its
|
|
103
|
+
* source is unavailable — a misleading zero is worse than a missing value,
|
|
104
|
+
* because downstream it is indistinguishable from a real measurement.
|
|
105
|
+
*/
|
|
106
|
+
/**
|
|
107
|
+
* The app's own version (`versionName`) for every event's top-level
|
|
108
|
+
* `appVersion`. Resolved here, never taken from the app: the React Native 0.x
|
|
109
|
+
* layer filled it this way, and 1.0 relying on the config instead left every
|
|
110
|
+
* event unversioned (2026-09-19). Null when the package manager can't answer.
|
|
111
|
+
*/
|
|
112
|
+
internal fun readAppVersion(context: Context): String? =
|
|
113
|
+
runCatching {
|
|
114
|
+
@Suppress("DEPRECATION")
|
|
115
|
+
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
|
116
|
+
}
|
|
117
|
+
.getOrNull()
|
|
118
|
+
?.takeIf { it.isNotBlank() }
|
|
119
|
+
|
|
120
|
+
internal fun collectDeviceInfo(context: Context): Map<String, Any?> {
|
|
121
|
+
val map = LinkedHashMap<String, Any?>()
|
|
122
|
+
map["model"] = Build.MODEL
|
|
123
|
+
map["locale"] = Locale.getDefault().toString()
|
|
124
|
+
map["timezone"] = TimeZone.getDefault().id
|
|
125
|
+
map["cpuCores"] = Runtime.getRuntime().availableProcessors()
|
|
126
|
+
map["packageName"] = context.packageName
|
|
127
|
+
|
|
128
|
+
runCatching {
|
|
129
|
+
@Suppress("DEPRECATION")
|
|
130
|
+
val info = context.packageManager.getPackageInfo(context.packageName, 0)
|
|
131
|
+
info.versionName?.let { map["shortVersion"] = it }
|
|
132
|
+
val code =
|
|
133
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
134
|
+
info.longVersionCode
|
|
135
|
+
} else {
|
|
136
|
+
@Suppress("DEPRECATION") info.versionCode.toLong()
|
|
137
|
+
}
|
|
138
|
+
map["longVersion"] = code.toString()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
runCatching {
|
|
142
|
+
val metrics = context.resources.displayMetrics
|
|
143
|
+
map["screenWidth"] = metrics.widthPixels
|
|
144
|
+
map["screenHeight"] = metrics.heightPixels
|
|
145
|
+
map["screenDensity"] = metrics.density.toDouble()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
runCatching {
|
|
149
|
+
val stat = StatFs(context.filesDir.path)
|
|
150
|
+
val gb = 1024.0 * 1024.0 * 1024.0
|
|
151
|
+
map["totalStorageGb"] = (stat.blockCountLong * stat.blockSizeLong).toDouble() / gb
|
|
152
|
+
map["freeStorageGb"] = (stat.availableBlocksLong * stat.blockSizeLong).toDouble() / gb
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
runCatching {
|
|
156
|
+
val carrier =
|
|
157
|
+
(context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager)
|
|
158
|
+
?.networkOperatorName
|
|
159
|
+
if (!carrier.isNullOrEmpty()) map["carrier"] = carrier
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return map
|
|
163
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
package io.advenue.platform
|
|
2
|
+
|
|
3
|
+
import io.advenue.core.KeyValueStore
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Routes the three install-scoped keys to [InstallScopedStore] and everything
|
|
7
|
+
* else to [PreferencesStore].
|
|
8
|
+
*
|
|
9
|
+
* The engine writes `install_sent` through the ordinary `KeyValueStore`
|
|
10
|
+
* interface — it has no business knowing which Android directory a key belongs
|
|
11
|
+
* in. Without this router that write lands in SharedPreferences, which Auto
|
|
12
|
+
* Backup restores into a reinstall, and that installation's install event never
|
|
13
|
+
* fires again. The RN module solved it the same way, with an
|
|
14
|
+
* `isInstallScopedKey` check at the storage boundary.
|
|
15
|
+
*/
|
|
16
|
+
internal class CompositeStore(
|
|
17
|
+
private val preferences: PreferencesStore,
|
|
18
|
+
private val scoped: InstallScopedStore,
|
|
19
|
+
) : KeyValueStore {
|
|
20
|
+
|
|
21
|
+
override fun getString(key: String): String? =
|
|
22
|
+
if (isScoped(key)) scoped.get(key) else preferences.getString(key)
|
|
23
|
+
|
|
24
|
+
override fun setString(key: String, value: String) {
|
|
25
|
+
if (isScoped(key)) scoped.set(key, value) else preferences.setString(key, value)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
override fun remove(key: String) {
|
|
29
|
+
if (isScoped(key)) scoped.remove(key) else preferences.remove(key)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private fun isScoped(key: String) = key in InstallScopedStore.INSTALL_SCOPED_KEYS
|
|
33
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
package io.advenue.platform
|
|
2
|
+
|
|
3
|
+
import io.advenue.core.ConversionFetcher
|
|
4
|
+
import io.advenue.core.ConversionResult
|
|
5
|
+
import io.advenue.core.DeepLink
|
|
6
|
+
import io.advenue.core.IngestError
|
|
7
|
+
import io.advenue.core.parseJson
|
|
8
|
+
import java.io.IOException
|
|
9
|
+
import java.net.HttpURLConnection
|
|
10
|
+
import java.net.URL
|
|
11
|
+
import java.net.URLEncoder
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Asks the server what this device was attributed to.
|
|
15
|
+
*
|
|
16
|
+
* A `pending` answer means the pipeline has not decided yet. Most devices are
|
|
17
|
+
* organic and answer pending forever, which is why the caller has a budget
|
|
18
|
+
* rather than a loop.
|
|
19
|
+
*/
|
|
20
|
+
public class HttpConversionFetcher(
|
|
21
|
+
private val endpoint: String = DEFAULT_ENDPOINT,
|
|
22
|
+
private val apiKey: String,
|
|
23
|
+
private val deviceId: String,
|
|
24
|
+
private val timeoutMs: Int = 15_000,
|
|
25
|
+
) : ConversionFetcher {
|
|
26
|
+
|
|
27
|
+
override fun fetch(): ConversionResult {
|
|
28
|
+
val escaped = URLEncoder.encode(deviceId, "UTF-8")
|
|
29
|
+
val connection =
|
|
30
|
+
URL("$endpoint/sdk/conversion-data?deviceId=$escaped").openConnection()
|
|
31
|
+
as HttpURLConnection
|
|
32
|
+
try {
|
|
33
|
+
connection.requestMethod = "GET"
|
|
34
|
+
connection.connectTimeout = timeoutMs
|
|
35
|
+
connection.readTimeout = timeoutMs
|
|
36
|
+
connection.setRequestProperty("x-api-key", apiKey)
|
|
37
|
+
|
|
38
|
+
val status =
|
|
39
|
+
try {
|
|
40
|
+
connection.responseCode
|
|
41
|
+
} catch (_: IOException) {
|
|
42
|
+
// No status to reason about; 408 marks it retryable, as sdk-core does.
|
|
43
|
+
throw IngestError(408)
|
|
44
|
+
}
|
|
45
|
+
if (status < 200 || status >= 300) throw IngestError(status)
|
|
46
|
+
|
|
47
|
+
val body =
|
|
48
|
+
parseJson(connection.inputStream.use { it.readBytes() }.toString(Charsets.UTF_8))
|
|
49
|
+
as? Map<*, *> ?: return ConversionResult.Pending
|
|
50
|
+
if (body["pending"] == true) return ConversionResult.Pending
|
|
51
|
+
|
|
52
|
+
return ConversionResult.Resolved(
|
|
53
|
+
DeepLink(
|
|
54
|
+
deepLinkValue = body["deepLinkValue"] as? String,
|
|
55
|
+
campaign = body["campaign"] as? String,
|
|
56
|
+
network = body["network"] as? String,
|
|
57
|
+
influencerId = body["influencerId"] as? String,
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
} catch (error: IngestError) {
|
|
61
|
+
throw error
|
|
62
|
+
} catch (_: IOException) {
|
|
63
|
+
throw IngestError(408)
|
|
64
|
+
} finally {
|
|
65
|
+
connection.disconnect()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|