@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,90 @@
1
+ package io.advenue.platform
2
+
3
+ /** What the Android lifecycle told us. */
4
+ internal enum class LifecycleSignal {
5
+ Started,
6
+ Stopped,
7
+ /** `onActivityStopped` where `isChangingConfigurations` was true. */
8
+ StoppedForConfigChange,
9
+ /** The background debounce expired without an activity starting. */
10
+ DebounceElapsed,
11
+ }
12
+
13
+ internal enum class ForegroundTransition {
14
+ None,
15
+ EnteredForeground,
16
+ /** Arm the debounce; a start before it elapses cancels the background. */
17
+ ScheduleBackgroundCheck,
18
+ EnteredBackground,
19
+ }
20
+
21
+ /**
22
+ * The foreground/background decision, as a pure state machine over lifecycle
23
+ * signals — no Android type appears in this file.
24
+ *
25
+ * Extracted rather than tested through Robolectric on purpose. Robolectric
26
+ * reimplements Android, so the interleaving of `onStop(old)` and `onStart(new)`
27
+ * under rotation would be *its* model, not a device's. Testing the machine
28
+ * directly makes the logic provable here and leaves exactly one device claim on
29
+ * the #27 list: that the real callback order matches the signals fed in. Same
30
+ * pattern as `KeychainStore.interpret` on iOS.
31
+ *
32
+ * The case that matters is rotation. A naive started-activity counter goes
33
+ * 1 → 0 → 1 across a configuration change, which reads as background then
34
+ * foreground and emits a spurious `session_end`: corrupt session durations and
35
+ * a phantom pair in the data. Two guards, because either alone leaks — the
36
+ * configuration-change check, and a 700 ms debounce for the slow rotation on a
37
+ * low-end device that outruns it. The 700 is not invented; it is
38
+ * `ProcessLifecycleOwner`'s constant. We cannot take the dependency, but we can
39
+ * take its number.
40
+ */
41
+ internal class ForegroundTracker {
42
+ private var started = 0
43
+ private var inForeground = false
44
+ private var backgroundPending = false
45
+
46
+ public fun onSignal(signal: LifecycleSignal): ForegroundTransition =
47
+ when (signal) {
48
+ LifecycleSignal.Started -> {
49
+ started += 1
50
+ // A start always cancels a pending background, whether it came from a
51
+ // rotation or from the user coming back within the debounce.
52
+ backgroundPending = false
53
+ if (!inForeground) {
54
+ inForeground = true
55
+ ForegroundTransition.EnteredForeground
56
+ } else {
57
+ ForegroundTransition.None
58
+ }
59
+ }
60
+
61
+ LifecycleSignal.StoppedForConfigChange -> {
62
+ // The activity is being recreated. It never left the foreground, so
63
+ // nothing is scheduled and nothing is emitted.
64
+ if (started > 0) started -= 1
65
+ ForegroundTransition.None
66
+ }
67
+
68
+ LifecycleSignal.Stopped -> {
69
+ if (started > 0) started -= 1
70
+ if (started == 0 && inForeground) {
71
+ backgroundPending = true
72
+ ForegroundTransition.ScheduleBackgroundCheck
73
+ } else {
74
+ ForegroundTransition.None
75
+ }
76
+ }
77
+
78
+ LifecycleSignal.DebounceElapsed ->
79
+ if (backgroundPending && started == 0) {
80
+ backgroundPending = false
81
+ inForeground = false
82
+ ForegroundTransition.EnteredBackground
83
+ } else {
84
+ ForegroundTransition.None
85
+ }
86
+ }
87
+ }
88
+
89
+ /** `ProcessLifecycleOwner`'s delay, without the dependency. */
90
+ internal const val BACKGROUND_DEBOUNCE_MS: Long = 700L
@@ -0,0 +1,101 @@
1
+ package io.advenue.platform
2
+
3
+ import io.advenue.core.Clock
4
+ import io.advenue.core.ClientEvent
5
+ import io.advenue.core.EventTransport
6
+ import io.advenue.core.IngestError
7
+ import io.advenue.core.JvmHmacSigner
8
+ import io.advenue.core.Signer
9
+ import io.advenue.core.signRequest
10
+ import io.advenue.core.parseJson
11
+ import io.advenue.core.writeCanonicalJson
12
+ import java.io.IOException
13
+ import java.net.HttpURLConnection
14
+ import java.net.URL
15
+
16
+ internal const val DEFAULT_ENDPOINT: String = "https://ingest.advenue.io"
17
+
18
+ /**
19
+ * Batched ingest over `HttpURLConnection` — dependency-free, and backed by
20
+ * OkHttp inside Android since 4.4 without being a declared dependency, so the
21
+ * SDK never joins the host app's version resolution for its HTTP client.
22
+ *
23
+ * Throws [IngestError] on a non-2xx so the caller can tell a transient failure
24
+ * from a poison payload; a transport failure maps to 408, matching sdk-core so
25
+ * all three SDKs retry exactly the same cases.
26
+ *
27
+ * No gzip: the TypeScript transport does not compress, and this port does not
28
+ * invent behaviour the other implementations do not have.
29
+ */
30
+ internal class HttpUrlTransport(
31
+ private val endpoint: String = DEFAULT_ENDPOINT,
32
+ private val apiKey: String,
33
+ private val signingSecret: String? = null,
34
+ private val signer: Signer = JvmHmacSigner(),
35
+ private val clock: Clock,
36
+ private val connectTimeoutMs: Int = 15_000,
37
+ private val readTimeoutMs: Int = 15_000,
38
+ /**
39
+ * Receives the app the server reported for an ACCEPTED batch. Purely
40
+ * diagnostic: it is invoked after the batch is already accepted, and nothing
41
+ * it does — including throwing — can turn a successful ingest into a failure.
42
+ */
43
+ private val onAccepted: (String) -> Unit = {},
44
+ ) : EventTransport {
45
+
46
+ /**
47
+ * The exact body this transport would send. Internal so a test can assert the
48
+ * wire shape without a network round trip, which is the part that has to be
49
+ * right.
50
+ */
51
+ internal fun buildBody(events: List<ClientEvent>): String =
52
+ writeCanonicalJson(mapOf("apiKey" to apiKey, "events" to events.map { it.toMap() }))
53
+
54
+ /** The headers for [body]; empty of signature headers when no secret is set. */
55
+ internal fun buildHeaders(body: String, timestamp: String): Map<String, String> {
56
+ val headers = linkedMapOf("content-type" to "application/json")
57
+ val secret = signingSecret ?: return headers
58
+ headers["X-Advenue-Timestamp"] = timestamp
59
+ headers["X-Advenue-Signature"] = signRequest(signer, secret, timestamp, body)
60
+ return headers
61
+ }
62
+
63
+ override fun send(events: List<ClientEvent>) {
64
+ val body = buildBody(events)
65
+ val headers = buildHeaders(body, clock.nowMs().toString())
66
+
67
+ val connection = URL("$endpoint/v1/events").openConnection() as HttpURLConnection
68
+ try {
69
+ connection.requestMethod = "POST"
70
+ connection.doOutput = true
71
+ connection.connectTimeout = connectTimeoutMs
72
+ connection.readTimeout = readTimeoutMs
73
+ headers.forEach { (name, value) -> connection.setRequestProperty(name, value) }
74
+
75
+ connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
76
+
77
+ val status =
78
+ try {
79
+ connection.responseCode
80
+ } catch (_: IOException) {
81
+ // No response at all is a transient failure, not a poison payload.
82
+ throw IngestError(408)
83
+ }
84
+ if (status < 200 || status >= 300) throw IngestError(status)
85
+
86
+ // Drain so the connection can be reused rather than torn down, and read
87
+ // the app the server resolved this API key to.
88
+ runCatching {
89
+ val payload = connection.inputStream.use { it.readBytes() }.toString(Charsets.UTF_8)
90
+ val appId = (parseJson(payload) as? Map<*, *>)?.get("appId") as? String
91
+ if (!appId.isNullOrEmpty()) onAccepted(appId)
92
+ }
93
+ } catch (error: IngestError) {
94
+ throw error
95
+ } catch (_: IOException) {
96
+ throw IngestError(408)
97
+ } finally {
98
+ connection.disconnect()
99
+ }
100
+ }
101
+ }
@@ -0,0 +1,79 @@
1
+ package io.advenue.platform
2
+
3
+ import android.content.Context
4
+ import android.os.Build
5
+ import android.os.UserManager
6
+ import io.advenue.core.DEVICE_ID_KEY
7
+ import io.advenue.core.INSTALLATION_ID_KEY
8
+ import io.advenue.core.UuidSource
9
+
10
+ /**
11
+ * Whether credential-encrypted storage is readable right now.
12
+ *
13
+ * Android 7+ **Direct Boot** runs before the first unlock, and the default
14
+ * storage is unavailable then. Whether a read throws or returns empty is not
15
+ * asserted here — both are handled the same way, by not reading at all.
16
+ */
17
+ internal fun interface UnlockProbe {
18
+ public fun isUnlocked(): Boolean
19
+ }
20
+
21
+ /**
22
+ * Production probe. Below API 24 Direct Boot does not exist, so the device is
23
+ * always considered unlocked. No `androidx.core` dependency: the system service
24
+ * is enough.
25
+ */
26
+ internal fun systemUnlockProbe(context: Context): UnlockProbe = UnlockProbe {
27
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return@UnlockProbe true
28
+ runCatching {
29
+ (context.applicationContext.getSystemService(Context.USER_SERVICE) as? UserManager)
30
+ ?.isUserUnlocked ?: true
31
+ }
32
+ .getOrDefault(true)
33
+ }
34
+
35
+ internal sealed class IdentityResolution {
36
+ public data class Resolved(val deviceId: String, val installationId: String) :
37
+ IdentityResolution()
38
+
39
+ /**
40
+ * The store could not be read. Nothing is minted and nothing starts, so no
41
+ * event carries an invented identifier. The caller registers for
42
+ * `ACTION_USER_UNLOCKED` and retries — a deferral with no exit would trade a
43
+ * phantom id for permanent silence.
44
+ */
45
+ public object Deferred : IdentityResolution()
46
+ }
47
+
48
+ /**
49
+ * Resolves the pair the SDK stamps on every event.
50
+ *
51
+ * Unlike iOS, both halves live in the same place: Android has no
52
+ * reinstall-durable secure store, so `device_id` dies with the app just as
53
+ * `installation_id` does. A reinstall is legitimately a new install here, and
54
+ * returning users are separated by the reattribution layer rather than by
55
+ * device identity.
56
+ *
57
+ * The exposure this guards is narrower than the iOS locked-Keychain hole and is
58
+ * justified on cost, not parity: Direct Boot only runs components a host app has
59
+ * explicitly marked `directBootAware`. It is roughly ten lines against minting
60
+ * a device id belonging to no real device.
61
+ *
62
+ * The honest limit: a `SharedPreferences` file corrupted by power loss is
63
+ * discarded by Android and reads empty, which is indistinguishable from a first
64
+ * launch. Nothing client-side can defend that; the backend dedup window absorbs
65
+ * it.
66
+ */
67
+ internal fun resolveIdentity(
68
+ store: ScopedStore,
69
+ unlock: UnlockProbe,
70
+ uuid: UuidSource,
71
+ ): IdentityResolution {
72
+ // Checked BEFORE any read, so an unreadable store mints nothing at all.
73
+ if (!unlock.isUnlocked()) return IdentityResolution.Deferred
74
+
75
+ val deviceId = store.get(DEVICE_ID_KEY) ?: uuid.next().also { store.set(DEVICE_ID_KEY, it) }
76
+ val installationId =
77
+ store.get(INSTALLATION_ID_KEY) ?: uuid.next().also { store.set(INSTALLATION_ID_KEY, it) }
78
+ return IdentityResolution.Resolved(deviceId, installationId)
79
+ }
@@ -0,0 +1,197 @@
1
+ package io.advenue.platform
2
+
3
+ import android.content.Context
4
+ import io.advenue.core.parseInstallReferrer
5
+ import io.advenue.core.referrerProperties
6
+ import io.advenue.plugin.AdvertisingIdSource
7
+ import io.advenue.plugin.AppInstanceIdSource
8
+ import io.advenue.plugin.AttestationSource
9
+ import io.advenue.plugin.InstallReferrerSource
10
+ import io.advenue.core.MetaReferrer
11
+ import io.advenue.core.parseMetaInstallReferrer
12
+ import io.advenue.plugin.PluginRegistry
13
+ import java.security.MessageDigest
14
+ import java.util.concurrent.Callable
15
+ import java.util.concurrent.Executors
16
+ import java.util.concurrent.TimeUnit
17
+
18
+ /**
19
+ * Spec §3: the install waits this long for the referrer and no longer. Matches
20
+ * the watchdog the React Native module already uses.
21
+ *
22
+ * Waiting is **correct here**, and this is the one place Android inverts iOS. On
23
+ * iOS, waiting for ATT was wrong because the install could hang forever if the
24
+ * app never prompts. The Play referrer is a bounded local IPC call and it *is*
25
+ * the deterministic attribution — an install sent without it is an organic
26
+ * install. Five seconds against attribution windows measured in hours is noise;
27
+ * a misclassification is not.
28
+ */
29
+ internal const val REFERRER_DEADLINE_MS: Long = 5_000
30
+
31
+ /** A shorter budget for the enrichment that is not attribution-critical. */
32
+ internal const val ENRICHMENT_DEADLINE_MS: Long = 3_000
33
+
34
+ internal data class Enrichment(
35
+ val referrerProperties: Map<String, Any?>? = null,
36
+ val network: String? = null,
37
+ val campaign: String? = null,
38
+ val gaid: String? = null,
39
+ val limitAdTracking: Boolean = false,
40
+ val appSetId: String? = null,
41
+ val appInstanceId: String? = null,
42
+ val attestationToken: String? = null,
43
+ /** Meta install-referrer properties, in the names the server reads. */
44
+ val metaProperties: Map<String, Any?>? = null,
45
+ )
46
+
47
+ /**
48
+ * The plugins the collector draws on, injected so the deadline behaviour is
49
+ * testable without Play Services — none of which a JVM test or an emulator can
50
+ * exercise honestly.
51
+ */
52
+ internal data class EnrichmentSources(
53
+ val referrer: InstallReferrerSource? = null,
54
+ val advertisingId: AdvertisingIdSource? = null,
55
+ val attestation: AttestationSource? = null,
56
+ val appInstanceId: AppInstanceIdSource? = null,
57
+ ) {
58
+ public companion object {
59
+ /** Whatever the consumer put on the classpath, resolved once. */
60
+ public fun discovered(): EnrichmentSources =
61
+ EnrichmentSources(
62
+ referrer = PluginRegistry.load(PluginRegistry.PLAY_REFERRER),
63
+ advertisingId = PluginRegistry.load(PluginRegistry.PLAY_IDENTITY),
64
+ attestation = PluginRegistry.load(PluginRegistry.PLAY_INTEGRITY),
65
+ appInstanceId = PluginRegistry.load(PluginRegistry.FIREBASE),
66
+ )
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Runs the plugin reads concurrently behind a hard deadline and returns what
72
+ * arrived. A source still running contributes nothing and its value rides a
73
+ * later event rather than holding the install.
74
+ */
75
+ internal fun collectEnrichment(
76
+ context: Context,
77
+ deviceId: String,
78
+ sources: EnrichmentSources,
79
+ deadlineMs: Long = REFERRER_DEADLINE_MS,
80
+ /** Meta app id. Null disables the Meta provider query entirely. */
81
+ fbAppId: String? = null,
82
+ ): Enrichment {
83
+ // Five, because there are five sources. A pool sized to four would leave the
84
+ // fifth waiting for a thread while the shared deadline ran down, and a source
85
+ // that never got to run contributes nothing — silently, which is the failure
86
+ // this file is otherwise built to avoid.
87
+ val pool = Executors.newFixedThreadPool(5) { r -> Thread(r, "advenue-enrich").apply { isDaemon = true } }
88
+ try {
89
+ val referrerTask =
90
+ sources.referrer?.let { pool.submit(Callable { it.read(context, deadlineMs) }) }
91
+ val adTask = sources.advertisingId?.let { pool.submit(Callable { it.read(context) }) }
92
+ val instanceTask =
93
+ sources.appInstanceId?.let {
94
+ pool.submit(Callable { it.read(context, ENRICHMENT_DEADLINE_MS) })
95
+ }
96
+ val attestTask =
97
+ sources.attestation?.let {
98
+ // sha256Hex(deviceId) — the server re-derives the same string and
99
+ // compares byte for byte, so no app id is hashed in.
100
+ val hash = sha256Hex(deviceId)
101
+ pool.submit(Callable { it.token(context, hash) })
102
+ }
103
+
104
+ // Meta's install referrer. Not a plugin: it needs no vendor dependency,
105
+ // only the <queries> entries the SDK's own manifest declares. A content
106
+ // provider on another app can hang, so it rides the same deadline.
107
+ val metaTask =
108
+ fbAppId?.let { id -> pool.submit(Callable { readMetaInstallReferrer(context, id) }) }
109
+
110
+ val deadline = System.currentTimeMillis() + deadlineMs
111
+ fun <T> settle(task: java.util.concurrent.Future<T>?): T? {
112
+ task ?: return null
113
+ val remaining = deadline - System.currentTimeMillis()
114
+ if (remaining <= 0) return null
115
+ return runCatching { task.get(remaining, TimeUnit.MILLISECONDS) }.getOrNull()
116
+ }
117
+
118
+ val referrer = settle(referrerTask)
119
+ val parsed = referrer?.let { parseInstallReferrer(it.referrer) }
120
+ val ad = settle(adTask)
121
+
122
+ return Enrichment(
123
+ referrerProperties =
124
+ referrer?.let {
125
+ val props =
126
+ referrerProperties(
127
+ referrer = it.referrer,
128
+ clickTimestamp = it.clickTimestamp,
129
+ installTimestamp = it.installTimestamp,
130
+ clickServerTimestamp = it.clickServerTimestamp,
131
+ installServerTimestamp = it.installServerTimestamp,
132
+ googlePlayInstant = it.googlePlayInstant,
133
+ )
134
+ .toMutableMap()
135
+ parsed?.gclid?.let { value -> props["gclid"] = value }
136
+ parsed?.fbclid?.let { value -> props["fbclid"] = value }
137
+ props
138
+ },
139
+ network = parsed?.network,
140
+ campaign = parsed?.campaign,
141
+ gaid = ad?.gaid,
142
+ limitAdTracking = ad?.limitAdTracking ?: false,
143
+ appSetId = ad?.appSetId,
144
+ appInstanceId = settle(instanceTask),
145
+ attestationToken = settle(attestTask),
146
+ metaProperties = settle(metaTask)?.let(::parseMetaColumns)?.let(::metaReferrerProperties),
147
+ )
148
+ } finally {
149
+ pool.shutdownNow()
150
+ }
151
+ }
152
+
153
+ /**
154
+ * The provider's columns, as the parser wants them.
155
+ *
156
+ * `readMetaInstallReferrer` returns what the cursor held; the shape of the
157
+ * referrer string itself — JSON claim or utm query string — is the parser's
158
+ * problem, and keeping the two apart is what lets the parser be tested without
159
+ * a content provider.
160
+ */
161
+ private fun parseMetaColumns(columns: Map<String, Any?>): MetaReferrer? {
162
+ val raw = columns["installReferrer"] as? String ?: return null
163
+ return parseMetaInstallReferrer(
164
+ raw = raw,
165
+ isClickThrough = columns["isClickThrough"] == true,
166
+ actualTimestamp = (columns["actualTimestamp"] as? Number)?.toLong() ?: 0L,
167
+ )
168
+ }
169
+
170
+ /**
171
+ * The Meta referrer as event properties, in the exact names the server reads.
172
+ *
173
+ * All four, not two. `meta-referrer-claim.ts` reads `metaReferrerData` and
174
+ * `metaReferrerNonce` to find the claim, and then `metaReferrerIsCt` to decide
175
+ * click-through against view-through and `metaReferrerTimestamp` to validate
176
+ * the claim's age. The React Native SDK sent only the first two before the
177
+ * inversion, so every claim it ever produced was read as a view-through with no
178
+ * age check — a half-wired path that looked like it worked.
179
+ */
180
+ internal fun metaReferrerProperties(meta: MetaReferrer): Map<String, Any?> = buildMap {
181
+ put("metaInstallReferrer", meta.raw)
182
+ meta.encryptedData?.let { put("metaReferrerData", it) }
183
+ meta.nonce?.let { put("metaReferrerNonce", it) }
184
+ put("metaReferrerIsCt", if (meta.isClickThrough) 1 else 0)
185
+ if (meta.timestamp > 0L) put("metaReferrerTimestamp", meta.timestamp)
186
+ }
187
+
188
+ /** Lowercase hex SHA-256, the form the server's verdict mapping re-derives. */
189
+ internal fun sha256Hex(value: String): String {
190
+ val digest = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8))
191
+ val out = StringBuilder(digest.size * 2)
192
+ for (byte in digest) {
193
+ out.append("0123456789abcdef"[(byte.toInt() shr 4) and 0xF])
194
+ out.append("0123456789abcdef"[byte.toInt() and 0xF])
195
+ }
196
+ return out.toString()
197
+ }
@@ -0,0 +1,96 @@
1
+ package io.advenue.platform
2
+
3
+ import android.content.Context
4
+ import io.advenue.core.DEVICE_ID_KEY
5
+ import io.advenue.core.INSTALLATION_ID_KEY
6
+ import io.advenue.core.INSTALL_SENT_KEY
7
+ import io.advenue.core.QUEUE_KEY
8
+ import java.io.File
9
+
10
+ /**
11
+ * The three keys that define the installation boundary, kept in
12
+ * `noBackupFilesDir` — the one directory Android excludes from backup with no
13
+ * manifest cooperation.
14
+ *
15
+ * Auto Backup restores app state into a **reinstall**. A restored `install_sent`
16
+ * means that installation's install event never fires again: a lost install,
17
+ * which is strictly worse than a duplicate the backend dedup window absorbs. A
18
+ * restored `device_id` is worse still on a *new phone*, where two physical
19
+ * devices would then report one identity.
20
+ *
21
+ * A library cannot reliably alter the app's backup rules —
22
+ * `android:dataExtractionRules` is an application-level attribute and conflicts
23
+ * in manifest merging — so this is not enforcement by configuration. It is
24
+ * enforcement by choosing a directory the platform already excludes.
25
+ */
26
+ /**
27
+ * The install-scoped read/write surface, as an interface so identity resolution
28
+ * — the part carrying the Direct Boot decision — is testable without a Context.
29
+ */
30
+ internal interface ScopedStore {
31
+ public fun get(key: String): String?
32
+
33
+ public fun set(key: String, value: String)
34
+ }
35
+
36
+ internal class InstallScopedStore(context: Context) : ScopedStore {
37
+ private val app = context.applicationContext
38
+
39
+ /**
40
+ * Reads the file, falling back **once** to the legacy SharedPreferences key
41
+ * and migrating it.
42
+ *
43
+ * Without this read-through, an app updating from the RN SDK reads null for
44
+ * its own device id and install flag: every device looks new and every
45
+ * installation fires a duplicate install with a fresh installation id, which
46
+ * no server-side guard can absorb. The legacy key is deleted only after a
47
+ * successful write — leaving it would let Auto Backup restore it into a
48
+ * future reinstall, which is exactly what this directory exists to prevent.
49
+ */
50
+ override fun get(key: String): String? =
51
+ runCatching {
52
+ val file = fileFor(key) ?: return null
53
+ if (file.exists()) return file.readText()
54
+
55
+ val legacy = legacyPrefs()?.getString(key, null) ?: return null
56
+ runCatching {
57
+ file.writeText(legacy)
58
+ legacyPrefs()?.edit()?.remove(key)?.apply()
59
+ }
60
+ legacy
61
+ }
62
+ .getOrNull()
63
+
64
+ override fun set(key: String, value: String) {
65
+ runCatching { fileFor(key)?.writeText(value) }
66
+ }
67
+
68
+ public fun remove(key: String) {
69
+ runCatching { fileFor(key)?.delete() }
70
+ }
71
+
72
+ private fun legacyPrefs() =
73
+ runCatching { app.getSharedPreferences(ADVENUE_SUITE, Context.MODE_PRIVATE) }.getOrNull()
74
+
75
+ private fun fileFor(key: String): File? {
76
+ val name = FILE_NAMES[key] ?: return null
77
+ val dir = runCatching { app.noBackupFilesDir }.getOrNull() ?: return null
78
+ return File(dir, name)
79
+ }
80
+
81
+ public companion object {
82
+ private val FILE_NAMES =
83
+ mapOf(
84
+ DEVICE_ID_KEY to "advenue-device-id",
85
+ INSTALLATION_ID_KEY to "advenue-installation-id",
86
+ INSTALL_SENT_KEY to "advenue-install-sent",
87
+ // B4: kuyruk blob'u (GAID'li olay gövdeleri) yedekten çıkar — geri
88
+ // yüklenen kuyruk hem PII sızıntısı hem de bayat olay yağmurudur.
89
+ // Eski SharedPreferences anahtarı ilk okumada migrate edilir.
90
+ QUEUE_KEY to "advenue-queue",
91
+ )
92
+
93
+ /** The keys this store owns; everything else belongs in [PreferencesStore]. */
94
+ public val INSTALL_SCOPED_KEYS: Set<String> = FILE_NAMES.keys
95
+ }
96
+ }
@@ -0,0 +1,72 @@
1
+ package io.advenue.platform
2
+
3
+ import android.app.Activity
4
+ import android.app.Application
5
+ import android.os.Bundle
6
+ import android.os.Handler
7
+ import android.os.Looper
8
+
9
+ /**
10
+ * Turns `ActivityLifecycleCallbacks` into the signals [ForegroundTracker]
11
+ * understands, and owns the debounce timer.
12
+ *
13
+ * `androidx.lifecycle` would give this for free and would also be a dependency
14
+ * the host app's Gradle resolution has to accommodate, which for a base SDK is
15
+ * the thing to avoid. All the judgement lives in the tracker; this file is
16
+ * plumbing.
17
+ */
18
+ internal class LifecycleBridge(
19
+ private val application: Application,
20
+ private val onForeground: () -> Unit,
21
+ private val onBackground: () -> Unit,
22
+ ) : Application.ActivityLifecycleCallbacks {
23
+ private val tracker = ForegroundTracker()
24
+ private val handler = Handler(Looper.getMainLooper())
25
+ private val debounce = Runnable { apply(tracker.onSignal(LifecycleSignal.DebounceElapsed)) }
26
+
27
+ fun start() {
28
+ application.registerActivityLifecycleCallbacks(this)
29
+ }
30
+
31
+ fun stop() {
32
+ handler.removeCallbacks(debounce)
33
+ application.unregisterActivityLifecycleCallbacks(this)
34
+ }
35
+
36
+ override fun onActivityStarted(activity: Activity) {
37
+ handler.removeCallbacks(debounce)
38
+ apply(tracker.onSignal(LifecycleSignal.Started))
39
+ }
40
+
41
+ override fun onActivityStopped(activity: Activity) {
42
+ // A rotation is not a background. Checked here as well as behind the
43
+ // debounce, because a slow rotation on a low-end device can outrun 700 ms.
44
+ val signal =
45
+ if (activity.isChangingConfigurations) {
46
+ LifecycleSignal.StoppedForConfigChange
47
+ } else {
48
+ LifecycleSignal.Stopped
49
+ }
50
+ apply(tracker.onSignal(signal))
51
+ }
52
+
53
+ private fun apply(transition: ForegroundTransition) {
54
+ when (transition) {
55
+ ForegroundTransition.EnteredForeground -> onForeground()
56
+ ForegroundTransition.EnteredBackground -> onBackground()
57
+ ForegroundTransition.ScheduleBackgroundCheck ->
58
+ handler.postDelayed(debounce, BACKGROUND_DEBOUNCE_MS)
59
+ ForegroundTransition.None -> Unit
60
+ }
61
+ }
62
+
63
+ override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?): Unit = Unit
64
+
65
+ override fun onActivityResumed(activity: Activity): Unit = Unit
66
+
67
+ override fun onActivityPaused(activity: Activity): Unit = Unit
68
+
69
+ override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle): Unit = Unit
70
+
71
+ override fun onActivityDestroyed(activity: Activity): Unit = Unit
72
+ }
@@ -0,0 +1,32 @@
1
+ package io.advenue.platform
2
+
3
+ import android.content.Context
4
+ import io.advenue.core.KeyValueStore
5
+
6
+ /**
7
+ * A dedicated preferences file, so SDK keys never mix with the host app's. The
8
+ * name matches the RN SDK's, because the RN inversion has to read what it wrote.
9
+ */
10
+ internal const val ADVENUE_SUITE: String = "io.advenue.sdk"
11
+
12
+ /**
13
+ * The general-purpose store: queue, session state, consent and user id.
14
+ *
15
+ * Deliberately NOT the home of the install-scoped keys. Auto Backup can restore
16
+ * this file into a reinstall, and those three keys define the installation
17
+ * boundary — see [InstallScopedStore].
18
+ */
19
+ internal class PreferencesStore(context: Context) : KeyValueStore {
20
+ private val prefs =
21
+ context.applicationContext.getSharedPreferences(ADVENUE_SUITE, Context.MODE_PRIVATE)
22
+
23
+ override fun getString(key: String): String? = runCatching { prefs.getString(key, null) }.getOrNull()
24
+
25
+ override fun setString(key: String, value: String) {
26
+ runCatching { prefs.edit().putString(key, value).apply() }
27
+ }
28
+
29
+ override fun remove(key: String) {
30
+ runCatching { prefs.edit().remove(key).apply() }
31
+ }
32
+ }