@advenue/react-native 0.8.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -7
- package/android/src/main/java/expo/modules/advenue/AdvenueAndroidModule.kt +118 -347
- package/android/src/main/kotlin/io/advenue/Advenue.kt +549 -0
- package/android/src/main/kotlin/io/advenue/AdvenueConfig.kt +114 -0
- package/android/src/main/kotlin/io/advenue/core/Backoff.kt +28 -0
- package/android/src/main/kotlin/io/advenue/core/ClientEvent.kt +136 -0
- package/android/src/main/kotlin/io/advenue/core/CommandPipe.kt +186 -0
- package/android/src/main/kotlin/io/advenue/core/Consent.kt +52 -0
- package/android/src/main/kotlin/io/advenue/core/Contracts.kt +80 -0
- package/android/src/main/kotlin/io/advenue/core/Conversion.kt +69 -0
- package/android/src/main/kotlin/io/advenue/core/Engine.kt +414 -0
- package/android/src/main/kotlin/io/advenue/core/EventQueue.kt +89 -0
- package/android/src/main/kotlin/io/advenue/core/HmacSigner.kt +31 -0
- package/android/src/main/kotlin/io/advenue/core/InstallReferrer.kt +146 -0
- package/android/src/main/kotlin/io/advenue/core/Json.kt +272 -0
- package/android/src/main/kotlin/io/advenue/core/Limits.kt +47 -0
- package/android/src/main/kotlin/io/advenue/core/MetaReferrer.kt +80 -0
- package/android/src/main/kotlin/io/advenue/core/SessionTracker.kt +189 -0
- package/android/src/main/kotlin/io/advenue/core/SystemServices.kt +58 -0
- package/android/src/main/kotlin/io/advenue/core/Tcf.kt +48 -0
- package/android/src/main/kotlin/io/advenue/core/Time.kt +56 -0
- package/android/src/main/kotlin/io/advenue/platform/Collectors.kt +149 -0
- package/android/src/main/kotlin/io/advenue/platform/CompositeStore.kt +33 -0
- package/android/src/main/kotlin/io/advenue/platform/ConversionFetcher.kt +68 -0
- package/android/src/main/kotlin/io/advenue/platform/ForegroundTracker.kt +90 -0
- package/android/src/main/kotlin/io/advenue/platform/HttpUrlTransport.kt +101 -0
- package/android/src/main/kotlin/io/advenue/platform/Identity.kt +79 -0
- package/android/src/main/kotlin/io/advenue/platform/InstallEnrichment.kt +197 -0
- package/android/src/main/kotlin/io/advenue/platform/InstallScopedStore.kt +91 -0
- package/android/src/main/kotlin/io/advenue/platform/LifecycleBridge.kt +72 -0
- package/android/src/main/kotlin/io/advenue/platform/PreferencesStore.kt +32 -0
- package/android/src/main/kotlin/io/advenue/plugin/Contracts.kt +67 -0
- package/android/src/main/kotlin/io/advenue/plugin/FirebaseAppInstanceIdSource.kt +73 -0
- package/android/src/main/kotlin/io/advenue/plugin/PlayAdvertisingIdSource.kt +47 -0
- package/android/src/main/kotlin/io/advenue/plugin/PlayInstallReferrerSource.kt +85 -0
- package/android/src/main/kotlin/io/advenue/plugin/PlayIntegritySource.kt +86 -0
- package/android/src/main/kotlin/io/advenue/plugin/PluginRegistry.kt +61 -0
- package/dist/index.cjs +183 -655
- package/dist/index.d.cts +199 -331
- package/dist/index.d.ts +199 -331
- package/dist/index.js +182 -661
- package/ios/AdvenueIosModule.swift +144 -432
- package/ios/vendor/Advenue/Advenue.swift +596 -0
- package/ios/vendor/Advenue/AdvenueConfig.swift +77 -0
- package/ios/vendor/AdvenueCore/AdvenueValue.swift +103 -0
- package/ios/vendor/AdvenueCore/Attestation.swift +52 -0
- package/ios/vendor/AdvenueCore/Backoff.swift +28 -0
- package/ios/vendor/AdvenueCore/ClientEvent.swift +154 -0
- package/ios/vendor/AdvenueCore/Consent.swift +40 -0
- package/ios/vendor/AdvenueCore/Contracts.swift +59 -0
- package/ios/vendor/AdvenueCore/Conversion.swift +74 -0
- package/ios/vendor/AdvenueCore/ConversionValue.swift +217 -0
- package/ios/vendor/AdvenueCore/Engine.swift +587 -0
- package/ios/vendor/AdvenueCore/EventQueue.swift +89 -0
- package/ios/vendor/AdvenueCore/Limits.swift +41 -0
- package/ios/vendor/AdvenueCore/PIIScrub.swift +102 -0
- package/ios/vendor/AdvenueCore/SessionTracker.swift +139 -0
- package/ios/vendor/AdvenueCore/SkanConfig.swift +76 -0
- package/ios/vendor/AdvenueCore/SkanReporter.swift +25 -0
- package/ios/vendor/AdvenueCore/SkanState.swift +258 -0
- package/ios/vendor/AdvenueCore/Tcf.swift +48 -0
- package/ios/vendor/AdvenueCore/Transport.swift +14 -0
- package/ios/vendor/AdvenueFirebase/FirebaseAppInstanceId.swift +33 -0
- package/ios/vendor/AdvenuePlatform/AdvertisingIdentity.swift +87 -0
- package/ios/vendor/AdvenuePlatform/ChallengeFetcher.swift +42 -0
- package/ios/vendor/AdvenuePlatform/ConversionFetcher.swift +63 -0
- package/ios/vendor/AdvenuePlatform/CryptoKitSigner.swift +19 -0
- package/ios/vendor/AdvenuePlatform/DeviceCheckAttestation.swift +91 -0
- package/ios/vendor/AdvenuePlatform/DeviceInfo.swift +65 -0
- package/ios/vendor/AdvenuePlatform/ForegroundTracker.swift +55 -0
- package/ios/vendor/AdvenuePlatform/HttpTransport.swift +96 -0
- package/ios/vendor/AdvenuePlatform/Identity.swift +52 -0
- package/ios/vendor/AdvenuePlatform/InstallEnrichment.swift +143 -0
- package/ios/vendor/AdvenuePlatform/KeychainStore.swift +95 -0
- package/ios/vendor/AdvenuePlatform/SearchAdsToken.swift +76 -0
- package/ios/vendor/AdvenuePlatform/SkanConfigFetcher.swift +70 -0
- package/ios/vendor/AdvenuePlatform/StoreKitSkanReporter.swift +140 -0
- package/ios/vendor/AdvenuePlatform/SystemServices.swift +55 -0
- package/ios/vendor/AdvenuePlatform/TcfReader.swift +18 -0
- package/ios/vendor/AdvenuePlatform/UserDefaultsStore.swift +26 -0
- package/package.json +9 -11
- package/scripts/check-dist.mjs +17 -0
- package/scripts/check-vendored-swift.mjs +148 -0
- package/scripts/vendor-natives.mjs +149 -0
- package/scripts/vendor-natives.test.mjs +112 -0
- package/src/deep-links.ts +19 -1
- package/src/index.ts +267 -803
- package/src/native-types.ts +74 -171
- package/src/native.ts +0 -23
- package/src/types.ts +81 -0
- package/src/aem.ts +0 -33
- package/src/mmkv-storage.ts +0 -21
- package/src/native-storage.ts +0 -63
- package/src/secure-store.ts +0 -26
|
@@ -0,0 +1,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,91 @@
|
|
|
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 java.io.File
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The three keys that define the installation boundary, kept in
|
|
11
|
+
* `noBackupFilesDir` — the one directory Android excludes from backup with no
|
|
12
|
+
* manifest cooperation.
|
|
13
|
+
*
|
|
14
|
+
* Auto Backup restores app state into a **reinstall**. A restored `install_sent`
|
|
15
|
+
* means that installation's install event never fires again: a lost install,
|
|
16
|
+
* which is strictly worse than a duplicate the backend dedup window absorbs. A
|
|
17
|
+
* restored `device_id` is worse still on a *new phone*, where two physical
|
|
18
|
+
* devices would then report one identity.
|
|
19
|
+
*
|
|
20
|
+
* A library cannot reliably alter the app's backup rules —
|
|
21
|
+
* `android:dataExtractionRules` is an application-level attribute and conflicts
|
|
22
|
+
* in manifest merging — so this is not enforcement by configuration. It is
|
|
23
|
+
* enforcement by choosing a directory the platform already excludes.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* The install-scoped read/write surface, as an interface so identity resolution
|
|
27
|
+
* — the part carrying the Direct Boot decision — is testable without a Context.
|
|
28
|
+
*/
|
|
29
|
+
internal interface ScopedStore {
|
|
30
|
+
public fun get(key: String): String?
|
|
31
|
+
|
|
32
|
+
public fun set(key: String, value: String)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
internal class InstallScopedStore(context: Context) : ScopedStore {
|
|
36
|
+
private val app = context.applicationContext
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Reads the file, falling back **once** to the legacy SharedPreferences key
|
|
40
|
+
* and migrating it.
|
|
41
|
+
*
|
|
42
|
+
* Without this read-through, an app updating from the RN SDK reads null for
|
|
43
|
+
* its own device id and install flag: every device looks new and every
|
|
44
|
+
* installation fires a duplicate install with a fresh installation id, which
|
|
45
|
+
* no server-side guard can absorb. The legacy key is deleted only after a
|
|
46
|
+
* successful write — leaving it would let Auto Backup restore it into a
|
|
47
|
+
* future reinstall, which is exactly what this directory exists to prevent.
|
|
48
|
+
*/
|
|
49
|
+
override fun get(key: String): String? =
|
|
50
|
+
runCatching {
|
|
51
|
+
val file = fileFor(key) ?: return null
|
|
52
|
+
if (file.exists()) return file.readText()
|
|
53
|
+
|
|
54
|
+
val legacy = legacyPrefs()?.getString(key, null) ?: return null
|
|
55
|
+
runCatching {
|
|
56
|
+
file.writeText(legacy)
|
|
57
|
+
legacyPrefs()?.edit()?.remove(key)?.apply()
|
|
58
|
+
}
|
|
59
|
+
legacy
|
|
60
|
+
}
|
|
61
|
+
.getOrNull()
|
|
62
|
+
|
|
63
|
+
override fun set(key: String, value: String) {
|
|
64
|
+
runCatching { fileFor(key)?.writeText(value) }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
public fun remove(key: String) {
|
|
68
|
+
runCatching { fileFor(key)?.delete() }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private fun legacyPrefs() =
|
|
72
|
+
runCatching { app.getSharedPreferences(ADVENUE_SUITE, Context.MODE_PRIVATE) }.getOrNull()
|
|
73
|
+
|
|
74
|
+
private fun fileFor(key: String): File? {
|
|
75
|
+
val name = FILE_NAMES[key] ?: return null
|
|
76
|
+
val dir = runCatching { app.noBackupFilesDir }.getOrNull() ?: return null
|
|
77
|
+
return File(dir, name)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
public companion object {
|
|
81
|
+
private val FILE_NAMES =
|
|
82
|
+
mapOf(
|
|
83
|
+
DEVICE_ID_KEY to "advenue-device-id",
|
|
84
|
+
INSTALLATION_ID_KEY to "advenue-installation-id",
|
|
85
|
+
INSTALL_SENT_KEY to "advenue-install-sent",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
/** The keys this store owns; everything else belongs in [PreferencesStore]. */
|
|
89
|
+
public val INSTALL_SCOPED_KEYS: Set<String> = FILE_NAMES.keys
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
package io.advenue.plugin
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The capability contracts each plugin artifact implements.
|
|
7
|
+
*
|
|
8
|
+
* The base declares them and never depends on Play Services or Firebase, so an
|
|
9
|
+
* app on Amazon, Huawei or F-Droid uses the base unchanged. A plugin brings its
|
|
10
|
+
* own vendor dependency and talks to it with full compile-time typing — the
|
|
11
|
+
* reflection is one lookup of the plugin's own class, not a wrapper around every
|
|
12
|
+
* vendor call.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Play Install Referrer. */
|
|
16
|
+
public interface InstallReferrerSource {
|
|
17
|
+
/**
|
|
18
|
+
* Blocking; the caller runs it off the main thread with its own deadline.
|
|
19
|
+
* Returns null when the referrer is unavailable, which is best-effort by
|
|
20
|
+
* design — an install with no referrer is organic, not a failure.
|
|
21
|
+
*/
|
|
22
|
+
public fun read(context: Context, timeoutMs: Long): ReferrerResult?
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
public data class ReferrerResult(
|
|
26
|
+
val referrer: String,
|
|
27
|
+
/** Device clock: `referrerClickTimestampSeconds`. */
|
|
28
|
+
val clickTimestamp: Long,
|
|
29
|
+
/** Device clock: `installBeginTimestampSeconds`. */
|
|
30
|
+
val installTimestamp: Long,
|
|
31
|
+
/** Play's server clock; 0 when the install is unverified. */
|
|
32
|
+
val clickServerTimestamp: Long,
|
|
33
|
+
val installServerTimestamp: Long,
|
|
34
|
+
val googlePlayInstant: Boolean,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
/** GAID and App Set ID. */
|
|
38
|
+
public interface AdvertisingIdSource {
|
|
39
|
+
/** Blocking; the caller runs it off the main thread. */
|
|
40
|
+
public fun read(context: Context): AdvertisingId?
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public data class AdvertisingId(
|
|
44
|
+
val gaid: String?,
|
|
45
|
+
val limitAdTracking: Boolean,
|
|
46
|
+
/**
|
|
47
|
+
* Developer-scoped, non-advertising. First-party fraud and dedup only —
|
|
48
|
+
* Google forbids using it for ads or measurement.
|
|
49
|
+
*/
|
|
50
|
+
val appSetId: String?,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
/** Play Integrity Standard API. */
|
|
54
|
+
public interface AttestationSource {
|
|
55
|
+
/**
|
|
56
|
+
* [requestHash] is `sha256Hex(deviceId)`. The server re-derives the same
|
|
57
|
+
* string and compares it byte for byte, so no app id is hashed in: the token
|
|
58
|
+
* is decoded against this app's Play package name, which binds the app
|
|
59
|
+
* already.
|
|
60
|
+
*/
|
|
61
|
+
public fun token(context: Context, requestHash: String): String?
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Firebase Analytics App Instance ID. */
|
|
65
|
+
public interface AppInstanceIdSource {
|
|
66
|
+
public fun read(context: Context, timeoutMs: Long): String?
|
|
67
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
package io.advenue.plugin
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import java.util.concurrent.CountDownLatch
|
|
5
|
+
import java.util.concurrent.TimeUnit
|
|
6
|
+
import java.util.concurrent.atomic.AtomicReference
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The Firebase App Instance ID source for React Native, by reflection.
|
|
10
|
+
*
|
|
11
|
+
* The class name and package are not incidental — they are the contract. The
|
|
12
|
+
* Android SDK resolves its enrichment plugins with `Class.forName` against the
|
|
13
|
+
* fixed names in `PluginRegistry`, so this file exists at exactly the name that
|
|
14
|
+
* lookup asks for. Rename it and the SDK stops finding it, silently.
|
|
15
|
+
*
|
|
16
|
+
* The monorepo's `plugin-firebase` artifact does the same job with ordinary
|
|
17
|
+
* typed code, and that is the better implementation — but it declares a compile
|
|
18
|
+
* dependency on `firebase-analytics`, and adding that here would force Firebase
|
|
19
|
+
* on every React Native app that installs this package. So this one is checked
|
|
20
|
+
* in rather than vendored, and reaches Firebase only if the host app already
|
|
21
|
+
* ships it.
|
|
22
|
+
*
|
|
23
|
+
* Every failure is best-effort null: Firebase absent, not initialised,
|
|
24
|
+
* analytics collection disabled, or simply slower than the deadline. The
|
|
25
|
+
* install goes out without the id rather than waiting for it.
|
|
26
|
+
*/
|
|
27
|
+
public class FirebaseAppInstanceIdSource : AppInstanceIdSource {
|
|
28
|
+
override fun read(context: Context, timeoutMs: Long): String? {
|
|
29
|
+
return try {
|
|
30
|
+
val ctx = context.applicationContext
|
|
31
|
+
val analyticsClass = Class.forName("com.google.firebase.analytics.FirebaseAnalytics")
|
|
32
|
+
val analytics =
|
|
33
|
+
analyticsClass.getMethod("getInstance", Context::class.java).invoke(null, ctx)
|
|
34
|
+
val task = analyticsClass.getMethod("getAppInstanceId").invoke(analytics)
|
|
35
|
+
|
|
36
|
+
val taskClass = Class.forName("com.google.android.gms.tasks.Task")
|
|
37
|
+
val listenerClass = Class.forName("com.google.android.gms.tasks.OnCompleteListener")
|
|
38
|
+
val result = AtomicReference<String?>(null)
|
|
39
|
+
val done = CountDownLatch(1)
|
|
40
|
+
|
|
41
|
+
val listener =
|
|
42
|
+
java.lang.reflect.Proxy.newProxyInstance(
|
|
43
|
+
listenerClass.classLoader,
|
|
44
|
+
arrayOf(listenerClass),
|
|
45
|
+
) { _, method, args ->
|
|
46
|
+
if (method.name == "onComplete") {
|
|
47
|
+
try {
|
|
48
|
+
val completed = args?.firstOrNull()
|
|
49
|
+
val ok =
|
|
50
|
+
completed != null && taskClass.getMethod("isSuccessful").invoke(completed) == true
|
|
51
|
+
if (ok) {
|
|
52
|
+
result.set(taskClass.getMethod("getResult").invoke(completed) as? String)
|
|
53
|
+
}
|
|
54
|
+
} catch (_: Throwable) {
|
|
55
|
+
// Best effort; the latch still counts down below.
|
|
56
|
+
} finally {
|
|
57
|
+
done.countDown()
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
taskClass.getMethod("addOnCompleteListener", listenerClass).invoke(task, listener)
|
|
64
|
+
// The caller already runs this on the enrichment pool behind its own
|
|
65
|
+
// deadline; blocking here is what turns Firebase's callback into the
|
|
66
|
+
// synchronous answer the contract asks for.
|
|
67
|
+
done.await(timeoutMs, TimeUnit.MILLISECONDS)
|
|
68
|
+
result.get()?.takeIf { it.isNotEmpty() }
|
|
69
|
+
} catch (_: Throwable) {
|
|
70
|
+
null
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
package io.advenue.plugin
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import com.google.android.gms.ads.identifier.AdvertisingIdClient
|
|
5
|
+
import com.google.android.gms.appset.AppSet
|
|
6
|
+
import com.google.android.gms.tasks.Tasks
|
|
7
|
+
import java.util.concurrent.TimeUnit
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* GAID and App Set ID.
|
|
11
|
+
*
|
|
12
|
+
* Adding this artifact merges `com.google.android.gms.permission.AD_ID` into the
|
|
13
|
+
* consumer's manifest. That is required for GAID access when targeting API 33+,
|
|
14
|
+
* **and** it changes the app's Play Console Data Safety declaration, and apps in
|
|
15
|
+
* the kids category must remove it. The integration docs carry the
|
|
16
|
+
* `tools:node="remove"` recipe — the consequence is the consumer's to accept,
|
|
17
|
+
* not ours to hide.
|
|
18
|
+
*/
|
|
19
|
+
public class PlayAdvertisingIdSource : AdvertisingIdSource {
|
|
20
|
+
override fun read(context: Context): AdvertisingId? {
|
|
21
|
+
val app = context.applicationContext
|
|
22
|
+
|
|
23
|
+
// Blocking by contract; the caller runs this off the main thread, where
|
|
24
|
+
// Play Services would throw.
|
|
25
|
+
val info = runCatching { AdvertisingIdClient.getAdvertisingIdInfo(app) }.getOrNull()
|
|
26
|
+
|
|
27
|
+
val raw = info?.id
|
|
28
|
+
// The all-zero GAID is what Play returns when tracking is limited. It looks
|
|
29
|
+
// like an identifier and is not one, so it must never travel.
|
|
30
|
+
val gaid = if (raw.isNullOrEmpty() || raw == ZERO_GAID) null else raw
|
|
31
|
+
|
|
32
|
+
val appSetId =
|
|
33
|
+
runCatching { Tasks.await(AppSet.getClient(app).appSetIdInfo, 2, TimeUnit.SECONDS).id }
|
|
34
|
+
.getOrNull()
|
|
35
|
+
|
|
36
|
+
if (gaid == null && appSetId == null && info == null) return null
|
|
37
|
+
return AdvertisingId(
|
|
38
|
+
gaid = gaid,
|
|
39
|
+
limitAdTracking = info?.isLimitAdTrackingEnabled ?: false,
|
|
40
|
+
appSetId = appSetId,
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private companion object {
|
|
45
|
+
private const val ZERO_GAID = "00000000-0000-0000-0000-000000000000"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
package io.advenue.plugin
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.os.Handler
|
|
5
|
+
import android.os.Looper
|
|
6
|
+
import com.android.installreferrer.api.InstallReferrerClient
|
|
7
|
+
import com.android.installreferrer.api.InstallReferrerStateListener
|
|
8
|
+
import java.util.concurrent.CountDownLatch
|
|
9
|
+
import java.util.concurrent.TimeUnit
|
|
10
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
11
|
+
import java.util.concurrent.atomic.AtomicReference
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Google Play Install Referrer.
|
|
15
|
+
*
|
|
16
|
+
* This is the deterministic attribution on Android: an install sent without it
|
|
17
|
+
* is an organic install. Which is why the caller waits for it, unlike iOS where
|
|
18
|
+
* waiting for ATT is wrong.
|
|
19
|
+
*
|
|
20
|
+
* The Play callback can fire setup-finished, disconnect, or never respond at all
|
|
21
|
+
* on an offline device. It must settle **exactly once** on every path and the
|
|
22
|
+
* service binding must be released on every path — a disconnect or a hung
|
|
23
|
+
* connection otherwise leaks the binding and hangs the install forever. The
|
|
24
|
+
* React Native module got this right with an AtomicBoolean; this keeps it.
|
|
25
|
+
*/
|
|
26
|
+
public class PlayInstallReferrerSource : InstallReferrerSource {
|
|
27
|
+
override fun read(context: Context, timeoutMs: Long): ReferrerResult? {
|
|
28
|
+
val app = context.applicationContext
|
|
29
|
+
val client = InstallReferrerClient.newBuilder(app).build()
|
|
30
|
+
val settled = AtomicBoolean(false)
|
|
31
|
+
val result = AtomicReference<ReferrerResult?>(null)
|
|
32
|
+
val done = CountDownLatch(1)
|
|
33
|
+
val handler = Handler(Looper.getMainLooper())
|
|
34
|
+
|
|
35
|
+
fun finish(value: ReferrerResult?) {
|
|
36
|
+
if (!settled.compareAndSet(false, true)) return
|
|
37
|
+
result.set(value)
|
|
38
|
+
runCatching { client.endConnection() }
|
|
39
|
+
done.countDown()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Watchdog: the service may never call back. The referrer is best-effort
|
|
43
|
+
// enrichment, so the caller must always be released.
|
|
44
|
+
handler.postDelayed({ finish(null) }, timeoutMs)
|
|
45
|
+
|
|
46
|
+
runCatching {
|
|
47
|
+
client.startConnection(
|
|
48
|
+
object : InstallReferrerStateListener {
|
|
49
|
+
override fun onInstallReferrerSetupFinished(responseCode: Int) {
|
|
50
|
+
if (responseCode != InstallReferrerClient.InstallReferrerResponse.OK) {
|
|
51
|
+
finish(null)
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
val value =
|
|
55
|
+
runCatching {
|
|
56
|
+
val details = client.installReferrer
|
|
57
|
+
ReferrerResult(
|
|
58
|
+
referrer = details.installReferrer,
|
|
59
|
+
clickTimestamp = details.referrerClickTimestampSeconds,
|
|
60
|
+
installTimestamp = details.installBeginTimestampSeconds,
|
|
61
|
+
// Play returns 0 here when the install is unverified; the
|
|
62
|
+
// caller forwards these as server-trusted only when non-zero.
|
|
63
|
+
clickServerTimestamp = details.referrerClickTimestampServerSeconds,
|
|
64
|
+
installServerTimestamp = details.installBeginTimestampServerSeconds,
|
|
65
|
+
googlePlayInstant = details.googlePlayInstantParam,
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
.getOrNull()
|
|
69
|
+
finish(value)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
override fun onInstallReferrerServiceDisconnected() {
|
|
73
|
+
// Dropped before setup finished, e.g. under memory pressure.
|
|
74
|
+
finish(null)
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
.onFailure { finish(null) }
|
|
80
|
+
|
|
81
|
+
done.await(timeoutMs + 500, TimeUnit.MILLISECONDS)
|
|
82
|
+
handler.removeCallbacksAndMessages(null)
|
|
83
|
+
return result.get()
|
|
84
|
+
}
|
|
85
|
+
}
|