@advenue/react-native 0.9.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +8 -7
  2. package/android/src/main/java/expo/modules/advenue/AdvenueAndroidModule.kt +117 -368
  3. package/android/src/main/kotlin/io/advenue/Advenue.kt +574 -0
  4. package/android/src/main/kotlin/io/advenue/AdvenueConfig.kt +141 -0
  5. package/android/src/main/kotlin/io/advenue/core/Backoff.kt +28 -0
  6. package/android/src/main/kotlin/io/advenue/core/ClientEvent.kt +136 -0
  7. package/android/src/main/kotlin/io/advenue/core/CommandPipe.kt +193 -0
  8. package/android/src/main/kotlin/io/advenue/core/Consent.kt +52 -0
  9. package/android/src/main/kotlin/io/advenue/core/Contracts.kt +80 -0
  10. package/android/src/main/kotlin/io/advenue/core/Conversion.kt +69 -0
  11. package/android/src/main/kotlin/io/advenue/core/Engine.kt +436 -0
  12. package/android/src/main/kotlin/io/advenue/core/EventQueue.kt +89 -0
  13. package/android/src/main/kotlin/io/advenue/core/HmacSigner.kt +31 -0
  14. package/android/src/main/kotlin/io/advenue/core/InstallReferrer.kt +146 -0
  15. package/android/src/main/kotlin/io/advenue/core/Json.kt +272 -0
  16. package/android/src/main/kotlin/io/advenue/core/Limits.kt +50 -0
  17. package/android/src/main/kotlin/io/advenue/core/MetaReferrer.kt +80 -0
  18. package/android/src/main/kotlin/io/advenue/core/PiiScrub.kt +77 -0
  19. package/android/src/main/kotlin/io/advenue/core/SessionTracker.kt +189 -0
  20. package/android/src/main/kotlin/io/advenue/core/SystemServices.kt +58 -0
  21. package/android/src/main/kotlin/io/advenue/core/Tcf.kt +48 -0
  22. package/android/src/main/kotlin/io/advenue/core/Time.kt +56 -0
  23. package/android/src/main/kotlin/io/advenue/platform/Collectors.kt +163 -0
  24. package/android/src/main/kotlin/io/advenue/platform/CompositeStore.kt +33 -0
  25. package/android/src/main/kotlin/io/advenue/platform/ConversionFetcher.kt +68 -0
  26. package/android/src/main/kotlin/io/advenue/platform/ForegroundTracker.kt +90 -0
  27. package/android/src/main/kotlin/io/advenue/platform/HttpUrlTransport.kt +101 -0
  28. package/android/src/main/kotlin/io/advenue/platform/Identity.kt +79 -0
  29. package/android/src/main/kotlin/io/advenue/platform/InstallEnrichment.kt +197 -0
  30. package/android/src/main/kotlin/io/advenue/platform/InstallScopedStore.kt +96 -0
  31. package/android/src/main/kotlin/io/advenue/platform/LifecycleBridge.kt +72 -0
  32. package/android/src/main/kotlin/io/advenue/platform/PreferencesStore.kt +32 -0
  33. package/android/src/main/kotlin/io/advenue/plugin/Contracts.kt +67 -0
  34. package/android/src/main/kotlin/io/advenue/plugin/FirebaseAppInstanceIdSource.kt +73 -0
  35. package/android/src/main/kotlin/io/advenue/plugin/PlayAdvertisingIdSource.kt +47 -0
  36. package/android/src/main/kotlin/io/advenue/plugin/PlayInstallReferrerSource.kt +85 -0
  37. package/android/src/main/kotlin/io/advenue/plugin/PlayIntegritySource.kt +86 -0
  38. package/android/src/main/kotlin/io/advenue/plugin/PluginRegistry.kt +61 -0
  39. package/dist/index.cjs +188 -675
  40. package/dist/index.d.cts +203 -336
  41. package/dist/index.d.ts +203 -336
  42. package/dist/index.js +187 -681
  43. package/ios/AdvenueIosModule.swift +143 -446
  44. package/ios/vendor/Advenue/Advenue.swift +632 -0
  45. package/ios/vendor/Advenue/AdvenueConfig.swift +89 -0
  46. package/ios/vendor/AdvenueCore/AdvenueValue.swift +103 -0
  47. package/ios/vendor/AdvenueCore/Attestation.swift +52 -0
  48. package/ios/vendor/AdvenueCore/Backoff.swift +28 -0
  49. package/ios/vendor/AdvenueCore/ClientEvent.swift +154 -0
  50. package/ios/vendor/AdvenueCore/Consent.swift +40 -0
  51. package/ios/vendor/AdvenueCore/Contracts.swift +59 -0
  52. package/ios/vendor/AdvenueCore/Conversion.swift +74 -0
  53. package/ios/vendor/AdvenueCore/ConversionValue.swift +217 -0
  54. package/ios/vendor/AdvenueCore/Engine.swift +607 -0
  55. package/ios/vendor/AdvenueCore/EventQueue.swift +89 -0
  56. package/ios/vendor/AdvenueCore/Limits.swift +41 -0
  57. package/ios/vendor/AdvenueCore/PIIScrub.swift +102 -0
  58. package/ios/vendor/AdvenueCore/SessionTracker.swift +139 -0
  59. package/ios/vendor/AdvenueCore/SkanConfig.swift +76 -0
  60. package/ios/vendor/AdvenueCore/SkanReporter.swift +25 -0
  61. package/ios/vendor/AdvenueCore/SkanState.swift +258 -0
  62. package/ios/vendor/AdvenueCore/Tcf.swift +48 -0
  63. package/ios/vendor/AdvenueCore/Transport.swift +14 -0
  64. package/ios/vendor/AdvenueFirebase/FirebaseAppInstanceId.swift +33 -0
  65. package/ios/vendor/AdvenuePlatform/AdvertisingIdentity.swift +87 -0
  66. package/ios/vendor/AdvenuePlatform/CacheFileStore.swift +90 -0
  67. package/ios/vendor/AdvenuePlatform/ChallengeFetcher.swift +42 -0
  68. package/ios/vendor/AdvenuePlatform/ConversionFetcher.swift +63 -0
  69. package/ios/vendor/AdvenuePlatform/CryptoKitSigner.swift +19 -0
  70. package/ios/vendor/AdvenuePlatform/DeviceCheckAttestation.swift +91 -0
  71. package/ios/vendor/AdvenuePlatform/DeviceInfo.swift +77 -0
  72. package/ios/vendor/AdvenuePlatform/ForegroundTracker.swift +55 -0
  73. package/ios/vendor/AdvenuePlatform/HttpTransport.swift +96 -0
  74. package/ios/vendor/AdvenuePlatform/Identity.swift +52 -0
  75. package/ios/vendor/AdvenuePlatform/InstallEnrichment.swift +155 -0
  76. package/ios/vendor/AdvenuePlatform/KeychainStore.swift +95 -0
  77. package/ios/vendor/AdvenuePlatform/SearchAdsToken.swift +76 -0
  78. package/ios/vendor/AdvenuePlatform/SkanConfigFetcher.swift +70 -0
  79. package/ios/vendor/AdvenuePlatform/StoreKitSkanReporter.swift +140 -0
  80. package/ios/vendor/AdvenuePlatform/SystemServices.swift +55 -0
  81. package/ios/vendor/AdvenuePlatform/TcfReader.swift +18 -0
  82. package/ios/vendor/AdvenuePlatform/UserDefaultsStore.swift +26 -0
  83. package/package.json +13 -15
  84. package/scripts/check-dist.mjs +17 -0
  85. package/scripts/check-vendored-swift.mjs +148 -0
  86. package/scripts/vendor-natives.mjs +149 -0
  87. package/scripts/vendor-natives.test.mjs +112 -0
  88. package/src/deep-links.ts +19 -1
  89. package/src/index.ts +273 -830
  90. package/src/native-types.ts +73 -181
  91. package/src/native.ts +0 -23
  92. package/src/types.ts +86 -0
  93. package/src/aem.ts +0 -33
  94. package/src/mmkv-storage.ts +0 -21
  95. package/src/native-storage.ts +0 -63
  96. package/src/secure-store.ts +0 -26
@@ -0,0 +1,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
+ }