@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.
Files changed (94) hide show
  1. package/README.md +8 -7
  2. package/android/src/main/java/expo/modules/advenue/AdvenueAndroidModule.kt +118 -347
  3. package/android/src/main/kotlin/io/advenue/Advenue.kt +549 -0
  4. package/android/src/main/kotlin/io/advenue/AdvenueConfig.kt +114 -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 +186 -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 +414 -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 +47 -0
  17. package/android/src/main/kotlin/io/advenue/core/MetaReferrer.kt +80 -0
  18. package/android/src/main/kotlin/io/advenue/core/SessionTracker.kt +189 -0
  19. package/android/src/main/kotlin/io/advenue/core/SystemServices.kt +58 -0
  20. package/android/src/main/kotlin/io/advenue/core/Tcf.kt +48 -0
  21. package/android/src/main/kotlin/io/advenue/core/Time.kt +56 -0
  22. package/android/src/main/kotlin/io/advenue/platform/Collectors.kt +149 -0
  23. package/android/src/main/kotlin/io/advenue/platform/CompositeStore.kt +33 -0
  24. package/android/src/main/kotlin/io/advenue/platform/ConversionFetcher.kt +68 -0
  25. package/android/src/main/kotlin/io/advenue/platform/ForegroundTracker.kt +90 -0
  26. package/android/src/main/kotlin/io/advenue/platform/HttpUrlTransport.kt +101 -0
  27. package/android/src/main/kotlin/io/advenue/platform/Identity.kt +79 -0
  28. package/android/src/main/kotlin/io/advenue/platform/InstallEnrichment.kt +197 -0
  29. package/android/src/main/kotlin/io/advenue/platform/InstallScopedStore.kt +91 -0
  30. package/android/src/main/kotlin/io/advenue/platform/LifecycleBridge.kt +72 -0
  31. package/android/src/main/kotlin/io/advenue/platform/PreferencesStore.kt +32 -0
  32. package/android/src/main/kotlin/io/advenue/plugin/Contracts.kt +67 -0
  33. package/android/src/main/kotlin/io/advenue/plugin/FirebaseAppInstanceIdSource.kt +73 -0
  34. package/android/src/main/kotlin/io/advenue/plugin/PlayAdvertisingIdSource.kt +47 -0
  35. package/android/src/main/kotlin/io/advenue/plugin/PlayInstallReferrerSource.kt +85 -0
  36. package/android/src/main/kotlin/io/advenue/plugin/PlayIntegritySource.kt +86 -0
  37. package/android/src/main/kotlin/io/advenue/plugin/PluginRegistry.kt +61 -0
  38. package/dist/index.cjs +183 -655
  39. package/dist/index.d.cts +199 -331
  40. package/dist/index.d.ts +199 -331
  41. package/dist/index.js +182 -661
  42. package/ios/AdvenueIosModule.swift +144 -432
  43. package/ios/vendor/Advenue/Advenue.swift +596 -0
  44. package/ios/vendor/Advenue/AdvenueConfig.swift +77 -0
  45. package/ios/vendor/AdvenueCore/AdvenueValue.swift +103 -0
  46. package/ios/vendor/AdvenueCore/Attestation.swift +52 -0
  47. package/ios/vendor/AdvenueCore/Backoff.swift +28 -0
  48. package/ios/vendor/AdvenueCore/ClientEvent.swift +154 -0
  49. package/ios/vendor/AdvenueCore/Consent.swift +40 -0
  50. package/ios/vendor/AdvenueCore/Contracts.swift +59 -0
  51. package/ios/vendor/AdvenueCore/Conversion.swift +74 -0
  52. package/ios/vendor/AdvenueCore/ConversionValue.swift +217 -0
  53. package/ios/vendor/AdvenueCore/Engine.swift +587 -0
  54. package/ios/vendor/AdvenueCore/EventQueue.swift +89 -0
  55. package/ios/vendor/AdvenueCore/Limits.swift +41 -0
  56. package/ios/vendor/AdvenueCore/PIIScrub.swift +102 -0
  57. package/ios/vendor/AdvenueCore/SessionTracker.swift +139 -0
  58. package/ios/vendor/AdvenueCore/SkanConfig.swift +76 -0
  59. package/ios/vendor/AdvenueCore/SkanReporter.swift +25 -0
  60. package/ios/vendor/AdvenueCore/SkanState.swift +258 -0
  61. package/ios/vendor/AdvenueCore/Tcf.swift +48 -0
  62. package/ios/vendor/AdvenueCore/Transport.swift +14 -0
  63. package/ios/vendor/AdvenueFirebase/FirebaseAppInstanceId.swift +33 -0
  64. package/ios/vendor/AdvenuePlatform/AdvertisingIdentity.swift +87 -0
  65. package/ios/vendor/AdvenuePlatform/ChallengeFetcher.swift +42 -0
  66. package/ios/vendor/AdvenuePlatform/ConversionFetcher.swift +63 -0
  67. package/ios/vendor/AdvenuePlatform/CryptoKitSigner.swift +19 -0
  68. package/ios/vendor/AdvenuePlatform/DeviceCheckAttestation.swift +91 -0
  69. package/ios/vendor/AdvenuePlatform/DeviceInfo.swift +65 -0
  70. package/ios/vendor/AdvenuePlatform/ForegroundTracker.swift +55 -0
  71. package/ios/vendor/AdvenuePlatform/HttpTransport.swift +96 -0
  72. package/ios/vendor/AdvenuePlatform/Identity.swift +52 -0
  73. package/ios/vendor/AdvenuePlatform/InstallEnrichment.swift +143 -0
  74. package/ios/vendor/AdvenuePlatform/KeychainStore.swift +95 -0
  75. package/ios/vendor/AdvenuePlatform/SearchAdsToken.swift +76 -0
  76. package/ios/vendor/AdvenuePlatform/SkanConfigFetcher.swift +70 -0
  77. package/ios/vendor/AdvenuePlatform/StoreKitSkanReporter.swift +140 -0
  78. package/ios/vendor/AdvenuePlatform/SystemServices.swift +55 -0
  79. package/ios/vendor/AdvenuePlatform/TcfReader.swift +18 -0
  80. package/ios/vendor/AdvenuePlatform/UserDefaultsStore.swift +26 -0
  81. package/package.json +9 -11
  82. package/scripts/check-dist.mjs +17 -0
  83. package/scripts/check-vendored-swift.mjs +148 -0
  84. package/scripts/vendor-natives.mjs +149 -0
  85. package/scripts/vendor-natives.test.mjs +112 -0
  86. package/src/deep-links.ts +19 -1
  87. package/src/index.ts +267 -803
  88. package/src/native-types.ts +74 -171
  89. package/src/native.ts +0 -23
  90. package/src/types.ts +81 -0
  91. package/src/aem.ts +0 -33
  92. package/src/mmkv-storage.ts +0 -21
  93. package/src/native-storage.ts +0 -63
  94. 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
+ }