@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,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,149 @@
|
|
|
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
|
+
internal fun collectDeviceInfo(context: Context): Map<String, Any?> {
|
|
107
|
+
val map = LinkedHashMap<String, Any?>()
|
|
108
|
+
map["model"] = Build.MODEL
|
|
109
|
+
map["locale"] = Locale.getDefault().toString()
|
|
110
|
+
map["timezone"] = TimeZone.getDefault().id
|
|
111
|
+
map["cpuCores"] = Runtime.getRuntime().availableProcessors()
|
|
112
|
+
map["packageName"] = context.packageName
|
|
113
|
+
|
|
114
|
+
runCatching {
|
|
115
|
+
@Suppress("DEPRECATION")
|
|
116
|
+
val info = context.packageManager.getPackageInfo(context.packageName, 0)
|
|
117
|
+
info.versionName?.let { map["shortVersion"] = it }
|
|
118
|
+
val code =
|
|
119
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
120
|
+
info.longVersionCode
|
|
121
|
+
} else {
|
|
122
|
+
@Suppress("DEPRECATION") info.versionCode.toLong()
|
|
123
|
+
}
|
|
124
|
+
map["longVersion"] = code.toString()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
runCatching {
|
|
128
|
+
val metrics = context.resources.displayMetrics
|
|
129
|
+
map["screenWidth"] = metrics.widthPixels
|
|
130
|
+
map["screenHeight"] = metrics.heightPixels
|
|
131
|
+
map["screenDensity"] = metrics.density.toDouble()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
runCatching {
|
|
135
|
+
val stat = StatFs(context.filesDir.path)
|
|
136
|
+
val gb = 1024.0 * 1024.0 * 1024.0
|
|
137
|
+
map["totalStorageGb"] = (stat.blockCountLong * stat.blockSizeLong).toDouble() / gb
|
|
138
|
+
map["freeStorageGb"] = (stat.availableBlocksLong * stat.blockSizeLong).toDouble() / gb
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
runCatching {
|
|
142
|
+
val carrier =
|
|
143
|
+
(context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager)
|
|
144
|
+
?.networkOperatorName
|
|
145
|
+
if (!carrier.isNullOrEmpty()) map["carrier"] = carrier
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return map
|
|
149
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|