@mentra/bluetooth-sdk 3.2.0-dev.209 → 3.2.0-dev.210

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 (45) hide show
  1. package/README.md +45 -8
  2. package/android/build.gradle +2 -0
  3. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +89 -90
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsHost.kt +106 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsQueue.kt +111 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +139 -0
  7. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTransport.kt +49 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +1 -1
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +11 -1
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2SerialResolution.kt +23 -0
  12. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsHostTest.kt +64 -0
  13. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsQueueTest.kt +86 -0
  14. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +148 -0
  15. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTransportTest.kt +44 -0
  16. package/android/src/test/java/com/mentra/bluetoothsdk/G2SerialResolutionTest.kt +25 -0
  17. package/build/generated/changelogCatalog.d.ts +1 -1
  18. package/build/generated/changelogCatalog.js +1 -1
  19. package/build/generated/changelogCatalog.js.map +1 -1
  20. package/build/generated/releaseMetadata.js +5 -5
  21. package/build/generated/releaseMetadata.js.map +1 -1
  22. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  23. package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
  24. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  25. package/ios/Source/internal/BluetoothSdkAnalytics.swift +127 -76
  26. package/ios/Source/internal/BluetoothSdkAnalyticsHost.swift +91 -0
  27. package/ios/Source/internal/BluetoothSdkAnalyticsQueue.swift +117 -0
  28. package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +157 -0
  29. package/ios/Source/internal/BluetoothSdkAnalyticsTransport.swift +13 -0
  30. package/ios/Source/sgcs/G2.swift +374 -359
  31. package/ios/Source/sgcs/G2SerialResolution.swift +23 -0
  32. package/ios/Tests/BluetoothSdkAnalyticsHostTests.swift +63 -0
  33. package/ios/Tests/BluetoothSdkAnalyticsQueueTests.swift +97 -0
  34. package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +122 -0
  35. package/ios/Tests/BluetoothSdkAnalyticsTransportTests.swift +36 -0
  36. package/ios/Tests/G2SerialResolutionTests.swift +19 -0
  37. package/package.json +1 -1
  38. package/plugin/build/analyticsProps.d.ts +9 -0
  39. package/plugin/build/analyticsProps.js +34 -0
  40. package/plugin/build/index.d.ts +8 -0
  41. package/plugin/build/withAndroid.js +12 -18
  42. package/plugin/build/withIos.d.ts +1 -0
  43. package/plugin/build/withIos.js +8 -18
  44. package/src/generated/changelogCatalog.ts +1 -1
  45. package/src/generated/releaseMetadata.ts +5 -5
package/README.md CHANGED
@@ -214,10 +214,19 @@ enterprise device deployments:
214
214
 
215
215
  - `bluetooth_sdk_started`: sent once per app runtime after the native SDK starts.
216
216
  - `bluetooth_sdk_glasses_connected`: sent when SDK status transitions from not connected to connected.
217
- - `bluetooth_sdk_glasses_identified`: sent once per connection after the SDK receives a valid manufacturing serial from the glasses. This fires for every supported model that reports a serial. G1 and Ar99 decode the serial from the glasses' BLE advertisement. Mentra Live reports the product serial provisioned by its Android firmware through `asg_client`.
217
+ - `bluetooth_sdk_glasses_identified`: sent once per connection after the SDK receives a valid manufacturing serial from the glasses, and again as a `glasses_heartbeat` on the first status update of each new reporting day (`America/Los_Angeles` calendar day, the calendar Mentra's weekly reporting is cut on) while that connection is still up, so a connection that spans a week boundary is visible in both weeks. Heartbeats ride on glasses status updates; a connection whose status never changes for a whole day produces none. This fires for every supported model that reports a serial. G1, G2, and Ar99 decode the serial from the glasses' BLE advertisement. Mentra Live reports the product serial provisioned by its Android firmware through `asg_client`.
218
218
 
219
- Analytics delivery is fire-and-forget: events are submitted asynchronously, do
220
- not block Bluetooth SDK behavior, and are not retried if delivery fails.
219
+ The connected event waits for the glasses model when the model is not known at
220
+ the moment the connection flag flips; if the connection ends first it is sent
221
+ without a model and with `glasses_model_unresolved=true`.
222
+
223
+ Analytics delivery never blocks Bluetooth SDK behavior: events are submitted
224
+ asynchronously off the caller thread. An upload that fails (no network, non-2xx)
225
+ is kept in a small on-device queue (at most 100 events, 7 days) and retried on
226
+ the next successful send or the next SDK start; a payload PostHog rejects
227
+ outright (4xx other than 408/429) is dropped rather than retried. Each event
228
+ carries its own `uuid` and capture `timestamp`, so a retry neither double counts
229
+ nor moves the event to a later week.
221
230
 
222
231
  React Native / Expo apps can disable these events before SDK startup through
223
232
  the config plugin:
@@ -243,6 +252,21 @@ Native Android apps can pass `BluetoothSdkAnalyticsConfig.disabled()` in
243
252
  Native iOS apps can pass `.disabled` in `MentraBluetoothSDKConfiguration` or set
244
253
  `MentraBluetoothSdkAnalyticsDisabled` to `true` in `Info.plist`.
245
254
 
255
+ Hosts that ship the same package id through several lanes (dev, staging, store)
256
+ can label the lane so Mentra can separate them. Pass
257
+ `{"analytics": {"environment": "prod"}}` to the config plugin, or set the
258
+ `com.mentra.bluetoothsdk.analytics.environment` Android metadata /
259
+ `MentraBluetoothSdkAnalyticsEnvironment` `Info.plist` key directly. Values are
260
+ trimmed and lowercased, must start with a letter or digit, may then contain
261
+ `[a-z0-9_-]`, are at most 32 characters, and are reported as `app_environment`.
262
+
263
+ Mentra counts an install as production only when `app_install_source` is
264
+ `app_store` or `play_store`, `app_build_type` is `release`, and, for hosts that
265
+ declare a lane, `app_environment` is `prod`. Play cannot distinguish its testing
266
+ tracks from production (both report `play_store`), so the lane is what separates
267
+ them for the Mentra App; hosts without a lane are reported as an unclassified
268
+ store cohort rather than assumed production.
269
+
246
270
  Mentra's PostHog project API key is embedded in the SDK as a public analytics
247
271
  write token, not a private PostHog personal API key. Apps do not configure the
248
272
  analytics destination; these SDK usage events are always sent to Mentra's
@@ -251,11 +275,24 @@ PostHog project unless analytics are disabled.
251
275
  Captured properties include `event_source`, `sdk_platform`, `sdk_surface`,
252
276
  `sdk_version`, `app_identifier` (the Android package or iOS bundle identifier),
253
277
  the platform-specific `app_package` or `app_bundle_identifier`, OS
254
- platform/version, and `event_kind`. Connection events also include
255
- `fully_booted` and a glasses model value when known. The identification event
256
- intentionally includes the glasses manufacturing serial as `glasses_device_id`,
257
- with `glasses_device_id_type=manufacturing_serial`, so Mentra can correlate
258
- fleet deployments across supported models. This serial identifies the glasses
278
+ platform/version, and `event_kind`. Every event also carries host build facts:
279
+ `app_version`, `app_build`, `app_build_type` (`debug` / `release`),
280
+ `app_install_source` (`play_store`, `app_store`, `testflight`, `adhoc_or_dev`,
281
+ `simulator`, `sideload`, a named third-party store, `other_store`, or `unknown`
282
+ when the platform gave no usable evidence), the raw Android
283
+ `app_installer_package` when present, and `app_environment` when the host
284
+ declares one. Connection and identification events include `fully_booted`,
285
+ a glasses model value when known, and `glasses_is_simulated`. The identification
286
+ event intentionally includes the glasses manufacturing serial as
287
+ `glasses_device_id`, with `glasses_device_id_type=manufacturing_serial`, so
288
+ Mentra can correlate fleet deployments across supported models, plus the
289
+ glasses-side software versions the SDK already holds (`glasses_firmware_version`,
290
+ `glasses_bes_firmware_version`, `glasses_mtk_firmware_version`,
291
+ `glasses_android_version`, `glasses_app_version`, `glasses_build_number`) so
292
+ identified glasses can be grouped by firmware. Glasses that never report a
293
+ serial produce no identification event; that coverage gap is measured as
294
+ connections without identification per model and SDK version, not from these
295
+ fields. This serial identifies the glasses
259
296
  hardware, not the user or the host phone. Its source depends on the model:
260
297
  Mentra Live reports the serial provisioned in BES NV storage, while G1 and Ar99
261
298
  decode it from the glasses' BLE advertisement / manufacturer data.
@@ -274,6 +274,8 @@ dependencies {
274
274
  testImplementation 'junit:junit:4.13.2'
275
275
  testImplementation 'org.robolectric:robolectric:4.11.1'
276
276
  testImplementation 'org.assertj:assertj-core:3.24.2'
277
+ // The Android SDK jar stubs org.json in JVM unit tests; the queue serializes real JSON.
278
+ testImplementation 'org.json:json:20240303'
277
279
  testImplementation 'androidx.test:core:1.5.0'
278
280
 
279
281
  // Local photo upload receiver
@@ -3,13 +3,15 @@ package com.mentra.bluetoothsdk
3
3
  import android.content.Context
4
4
  import android.content.pm.PackageManager
5
5
  import android.os.Build
6
+ import com.mentra.bluetoothsdk.utils.DeviceTypes
6
7
  import org.json.JSONObject
7
8
  import java.net.HttpURLConnection
8
9
  import java.net.URL
10
+ import java.text.SimpleDateFormat
11
+ import java.util.Date
12
+ import java.util.Locale
13
+ import java.util.TimeZone
9
14
  import java.util.UUID
10
- import java.util.concurrent.ExecutorService
11
- import java.util.concurrent.Executors
12
- import java.util.concurrent.RejectedExecutionException
13
15
 
14
16
  class BluetoothSdkAnalyticsConfig private constructor(
15
17
  val enabled: Boolean,
@@ -40,25 +42,23 @@ internal class BluetoothSdkAnalytics(
40
42
  initialConfig: BluetoothSdkAnalyticsConfig,
41
43
  ) {
42
44
  private val appContext = context.applicationContext
43
- private val executor: ExecutorService = Executors.newSingleThreadExecutor { runnable ->
44
- Thread(runnable, "MentraBluetoothSdkAnalytics").apply { isDaemon = true }
45
- }
46
- // startedCaptured/lastConnected are touched from store listeners and SDK
47
- // entry points, hence @Synchronized on methods that read or write them.
48
45
  private val config = initialConfig.toRuntimeConfig().resolvedForApp(appContext)
46
+ // The tracker is touched from store listeners and SDK entry points, hence
47
+ // @Synchronized on the methods that read or write it.
48
+ private val tracker = BluetoothSdkAnalyticsTracker(DeviceTypes.SIMULATED)
49
49
  private var startedCaptured = false
50
- private var lastConnected = false
51
- private var identifiedCapturedForConnection = false
50
+ // Resolved once, lazily, on the transport executor: PackageManager lookups
51
+ // must not run on the caller (often main or the Bluetooth status) thread.
52
+ private val hostProperties: Map<String, Any> by lazy { BluetoothSdkAnalyticsHost.resolve(appContext).toMap() }
53
+ // The retry queue and its executor are process-wide (see BluetoothSdkAnalyticsTransport).
54
+ private val queue: BluetoothSdkAnalyticsQueue get() = BluetoothSdkAnalyticsTransport.queue(appContext)
55
+ private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
56
+ timeZone = TimeZone.getTimeZone("UTC")
57
+ }
52
58
 
53
59
  @Synchronized
54
60
  fun initializeGlassesStatus(status: GlassesStatus) {
55
- lastConnected = status.analyticsConnected
56
- // Only treat identification as already captured when a valid serial is
57
- // present at init. If the glasses are connected but the serial has not
58
- // arrived yet (Mentra Live fills it via version_info after connect), leave
59
- // this false so the identify event still fires once the serial arrives.
60
- identifiedCapturedForConnection =
61
- status.analyticsConnected && status.serialNumber.validManufacturingSerial() != null
61
+ tracker.initialize(status.toAnalyticsSnapshot(), BluetoothSdkAnalyticsTracker.reportingDay(System.currentTimeMillis()))
62
62
  }
63
63
 
64
64
  @Synchronized
@@ -66,52 +66,23 @@ internal class BluetoothSdkAnalytics(
66
66
  if (startedCaptured || !config.isReady) return
67
67
  startedCaptured = true
68
68
  capture("bluetooth_sdk_started", mapOf("event_kind" to "sdk_started"))
69
+ // A fresh runtime is the natural moment to retry what an earlier one could not deliver.
70
+ runOnExecutor { queue.drain(System.currentTimeMillis()) { payload -> send(payload) } }
69
71
  }
70
72
 
71
73
  @Synchronized
72
74
  fun observeGlassesStatus(status: GlassesStatus) {
73
- val isConnected = status.analyticsConnected
74
- val wasConnected = lastConnected
75
- lastConnected = isConnected
75
+ val events = tracker.observe(status.toAnalyticsSnapshot(), BluetoothSdkAnalyticsTracker.reportingDay(System.currentTimeMillis()))
76
76
  if (!config.isReady) return
77
- if (!isConnected) {
78
- identifiedCapturedForConnection = false
79
- return
80
- }
81
- if (isConnected && !wasConnected) {
82
- identifiedCapturedForConnection = false
83
- capture(
84
- "bluetooth_sdk_glasses_connected",
85
- buildMap {
86
- put("event_kind", "glasses_connected")
87
- put("fully_booted", status.fullyBooted)
88
- status.deviceModel.takeIf { it.isNotBlank() }?.let { put("glasses_model", it) }
89
- },
90
- )
91
- // Fall through: a serial already present at connect time (G1/Ar99 report
92
- // it in the advertisement) should be identified now rather than waiting
93
- // for some later, unrelated glasses-store update to run.
94
- }
95
-
96
- val serialNumber = status.serialNumber.validManufacturingSerial() ?: return
97
- if (!identifiedCapturedForConnection) {
98
- identifiedCapturedForConnection = true
99
- capture(
100
- "bluetooth_sdk_glasses_identified",
101
- buildMap {
102
- put("event_kind", "glasses_identified")
103
- put("fully_booted", status.fullyBooted)
104
- put("glasses_device_id", serialNumber)
105
- put("glasses_device_id_type", "manufacturing_serial")
106
- status.deviceModel.takeIf { it.isNotBlank() }?.let { put("glasses_model", it) }
107
- },
108
- )
109
- }
77
+ for (event in events) capture(event.name, event.properties)
110
78
  }
111
79
 
112
- fun shutdown() {
113
- executor.shutdown()
114
- }
80
+ /**
81
+ * Delivery is owned by the process-wide transport, so nothing is torn down
82
+ * here: a retry in flight when this instance closes still completes, and a
83
+ * replacement instance cannot race it on the retry file.
84
+ */
85
+ fun shutdown() {}
115
86
 
116
87
  private fun capture(
117
88
  eventName: String,
@@ -119,40 +90,60 @@ internal class BluetoothSdkAnalytics(
119
90
  ) {
120
91
  val activeConfig = config
121
92
  if (!activeConfig.isReady) return
93
+ // Identity and time are fixed at capture, not at (re)send, so a retried
94
+ // event neither double counts nor drifts into a later week.
95
+ val uuid = UUID.randomUUID().toString()
96
+ val capturedAt = System.currentTimeMillis()
97
+ runOnExecutor {
98
+ val payload =
99
+ JSONObject(
100
+ mapOf(
101
+ "api_key" to DEFAULT_POSTHOG_API_KEY,
102
+ "uuid" to uuid,
103
+ "event" to eventName,
104
+ "distinct_id" to distinctId(),
105
+ "timestamp" to isoFormat.format(Date(capturedAt)),
106
+ "properties" to baseProperties(activeConfig) + hostProperties + eventProperties,
107
+ )
108
+ )
109
+ when (send(payload)) {
110
+ SendOutcome.DELIVERED -> queue.drain(capturedAt) { queued -> send(queued) }
111
+ SendOutcome.RETRY -> queue.enqueue(payload, capturedAt)
112
+ SendOutcome.DISCARD -> Unit
113
+ }
114
+ }
115
+ }
116
+
117
+ private fun runOnExecutor(block: () -> Unit) {
118
+ BluetoothSdkAnalyticsTransport.submit(block)
119
+ }
122
120
 
121
+ /**
122
+ * 2xx is delivered. A 4xx other than 408/429 means PostHog rejected this payload
123
+ * for good (bad key, malformed body) and retrying it would only block the queue.
124
+ * Everything else (network errors, 5xx, 408, 429) is worth retrying later.
125
+ */
126
+ private fun send(payload: JSONObject): SendOutcome =
123
127
  try {
124
- // Payload construction stays on the executor: distinctId() reads
125
- // SharedPreferences, which must not run on the caller (often main) thread.
126
- executor.execute {
127
- try {
128
- val payload =
129
- JSONObject(
130
- mapOf(
131
- "api_key" to DEFAULT_POSTHOG_API_KEY,
132
- "event" to eventName,
133
- "distinct_id" to distinctId(),
134
- "properties" to baseProperties(activeConfig) + eventProperties,
135
- )
136
- )
137
- val connection = URL(captureUrl()).openConnection() as HttpURLConnection
138
- connection.requestMethod = "POST"
139
- connection.connectTimeout = 4_000
140
- connection.readTimeout = 4_000
141
- connection.doOutput = true
142
- connection.setRequestProperty("Content-Type", "application/json")
143
- connection.outputStream.use { output ->
144
- output.write(payload.toString().toByteArray(Charsets.UTF_8))
145
- }
146
- connection.inputStream.close()
147
- connection.disconnect()
148
- } catch (_: Exception) {
149
- // Analytics must never affect Bluetooth SDK behavior.
128
+ val connection = URL(captureUrl()).openConnection() as HttpURLConnection
129
+ try {
130
+ connection.requestMethod = "POST"
131
+ connection.connectTimeout = 4_000
132
+ connection.readTimeout = 4_000
133
+ connection.doOutput = true
134
+ connection.setRequestProperty("Content-Type", "application/json")
135
+ connection.outputStream.use { output ->
136
+ output.write(payload.toString().toByteArray(Charsets.UTF_8))
150
137
  }
138
+ val code = connection.responseCode
139
+ if (code in 200..299) connection.inputStream.close() else connection.errorStream?.close()
140
+ SendOutcome.fromHttpStatus(code)
141
+ } finally {
142
+ connection.disconnect()
151
143
  }
152
- } catch (_: RejectedExecutionException) {
153
- // The executor was shut down (SDK closed); dropping the event is fine.
144
+ } catch (_: Exception) {
145
+ SendOutcome.RETRY
154
146
  }
155
- }
156
147
 
157
148
  private fun baseProperties(activeConfig: BluetoothSdkAnalyticsRuntimeConfig): Map<String, Any> =
158
149
  buildMap {
@@ -209,8 +200,16 @@ private fun BluetoothSdkAnalyticsRuntimeConfig.resolvedForApp(context: Context):
209
200
  )
210
201
  }
211
202
 
212
- private val GlassesStatus.analyticsConnected: Boolean
213
- get() = connectionState.isConnected || connected || fullyBooted
214
-
215
- private fun String.validManufacturingSerial(): String? =
216
- trim().takeIf { it.isNotEmpty() && !it.matches(Regex("0+")) }
203
+ private fun GlassesStatus.toAnalyticsSnapshot(): AnalyticsGlassesSnapshot =
204
+ AnalyticsGlassesSnapshot(
205
+ connected = connectionState.isConnected || connected || fullyBooted,
206
+ fullyBooted = fullyBooted,
207
+ model = deviceModel,
208
+ serialNumber = serialNumber,
209
+ firmwareVersion = firmwareVersion,
210
+ besFirmwareVersion = besFirmwareVersion,
211
+ mtkFirmwareVersion = mtkFirmwareVersion,
212
+ androidVersion = androidVersion,
213
+ appVersion = appVersion,
214
+ buildNumber = buildNumber,
215
+ )
@@ -0,0 +1,106 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import android.content.Context
4
+ import android.content.pm.ApplicationInfo
5
+ import android.content.pm.PackageManager
6
+ import android.os.Build
7
+
8
+ /**
9
+ * Host-app facts attached to every analytics event so Mentra can separate store
10
+ * installs from sideloaded, debuggable, and internal-lane builds.
11
+ */
12
+ internal data class BluetoothSdkAnalyticsHostProperties(
13
+ val appVersion: String?,
14
+ val appBuild: String?,
15
+ val buildType: String,
16
+ val installSource: String,
17
+ val installerPackage: String?,
18
+ val environment: String?,
19
+ ) {
20
+ fun toMap(): Map<String, Any> =
21
+ buildMap {
22
+ appVersion?.let { put("app_version", it) }
23
+ appBuild?.let { put("app_build", it) }
24
+ put("app_build_type", buildType)
25
+ put("app_install_source", installSource)
26
+ installerPackage?.let { put("app_installer_package", it) }
27
+ environment?.let { put("app_environment", it) }
28
+ }
29
+ }
30
+
31
+ internal object BluetoothSdkAnalyticsHost {
32
+ /**
33
+ * Optional host-declared lane (`dev`, `staging`, `prod`, ...). Stamped by the Expo
34
+ * config plugin's `analytics.environment` option; native apps may set it directly.
35
+ */
36
+ const val META_ENVIRONMENT = "com.mentra.bluetoothsdk.analytics.environment"
37
+
38
+ private val environmentPattern = Regex("^[a-z0-9][a-z0-9_-]{0,31}$")
39
+
40
+ /**
41
+ * Android cannot tell a Play production install from a Play testing track: both
42
+ * arrive through `com.android.vending`. `app_environment` is the host's way to
43
+ * add that distinction. A missing installer is documented to mean adb, a file
44
+ * manager, or an IDE put the APK on the device; a failed lookup is reported as
45
+ * `unknown` rather than guessed, because this value decides whether an install
46
+ * counts as production.
47
+ */
48
+ fun installSourceFor(installerPackage: String?, lookupFailed: Boolean = false): String =
49
+ when {
50
+ lookupFailed -> "unknown"
51
+ else -> installerPackage?.trim()?.takeIf { it.isNotEmpty() }.let { installer ->
52
+ when (installer) {
53
+ null -> "sideload"
54
+ "com.android.vending" -> "play_store"
55
+ "com.amazon.venezia" -> "amazon_appstore"
56
+ "com.sec.android.app.samsungapps" -> "galaxy_store"
57
+ "com.huawei.appmarket" -> "huawei_appgallery"
58
+ "com.xiaomi.market" -> "xiaomi_getapps"
59
+ else -> "other_store"
60
+ }
61
+ }
62
+ }
63
+
64
+ fun normalizedEnvironment(raw: String?): String? =
65
+ raw?.trim()?.lowercase()?.takeIf { environmentPattern.matches(it) }
66
+
67
+ fun resolve(context: Context): BluetoothSdkAnalyticsHostProperties {
68
+ val packageManager = context.packageManager
69
+ val packageName = context.packageName
70
+ val packageInfo =
71
+ try {
72
+ packageManager.getPackageInfo(packageName, 0)
73
+ } catch (_: Exception) {
74
+ null
75
+ }
76
+ val applicationInfo =
77
+ try {
78
+ packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA)
79
+ } catch (_: Exception) {
80
+ null
81
+ }
82
+ var installerLookupFailed = false
83
+ val installer =
84
+ try {
85
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
86
+ packageManager.getInstallSourceInfo(packageName).installingPackageName
87
+ } else {
88
+ @Suppress("DEPRECATION")
89
+ packageManager.getInstallerPackageName(packageName)
90
+ }
91
+ } catch (_: Exception) {
92
+ installerLookupFailed = true
93
+ null
94
+ }
95
+ val debuggable = ((applicationInfo?.flags ?: 0) and ApplicationInfo.FLAG_DEBUGGABLE) != 0
96
+
97
+ return BluetoothSdkAnalyticsHostProperties(
98
+ appVersion = packageInfo?.versionName?.trim()?.takeIf { it.isNotEmpty() },
99
+ appBuild = packageInfo?.longVersionCode?.toString(),
100
+ buildType = if (debuggable) "debug" else "release",
101
+ installSource = installSourceFor(installer, installerLookupFailed),
102
+ installerPackage = installer?.trim()?.takeIf { it.isNotEmpty() },
103
+ environment = normalizedEnvironment(applicationInfo?.metaData?.getString(META_ENVIRONMENT)),
104
+ )
105
+ }
106
+ }
@@ -0,0 +1,111 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import org.json.JSONObject
4
+ import java.io.File
5
+
6
+ internal enum class SendOutcome {
7
+ DELIVERED,
8
+ RETRY,
9
+ DISCARD,
10
+ ;
11
+
12
+ companion object {
13
+ fun fromHttpStatus(code: Int): SendOutcome =
14
+ when {
15
+ code in 200..299 -> DELIVERED
16
+ code == 408 || code == 429 -> RETRY
17
+ code in 400..499 -> DISCARD
18
+ else -> RETRY
19
+ }
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Bounded on-disk retry queue for analytics payloads whose upload failed.
25
+ * One JSON object per line. Oldest entries are dropped past [maxEntries]; entries
26
+ * older than [maxAgeMillis] are discarded on drain. Every payload carries its own
27
+ * `uuid` and `timestamp`, so a retried event neither double counts nor moves in time.
28
+ *
29
+ * Not thread-safe by itself: callers serialize access on the analytics executor.
30
+ */
31
+ internal class BluetoothSdkAnalyticsQueue(
32
+ private val file: File,
33
+ private val maxEntries: Int = DEFAULT_MAX_ENTRIES,
34
+ private val maxAgeMillis: Long = DEFAULT_MAX_AGE_MILLIS,
35
+ ) {
36
+ fun enqueue(payload: JSONObject, nowMillis: Long) {
37
+ val entries = read().toMutableList()
38
+ entries += JSONObject().put("enqueued_at", nowMillis).put("payload", payload)
39
+ while (entries.size > maxEntries) entries.removeAt(0)
40
+ write(entries)
41
+ }
42
+
43
+ fun size(): Int = read().size
44
+
45
+ /**
46
+ * Sends queued payloads oldest-first through [send]. Delivered and permanently
47
+ * rejected entries are dropped; the first retryable failure stops the drain so
48
+ * ordering is preserved and a dead network does not burn through every entry.
49
+ */
50
+ fun drain(nowMillis: Long, send: (JSONObject) -> SendOutcome) {
51
+ val entries = read()
52
+ if (entries.isEmpty()) return
53
+ val remaining = mutableListOf<JSONObject>()
54
+ var blocked = false
55
+ for (entry in entries) {
56
+ val enqueuedAt = entry.optLong("enqueued_at", nowMillis)
57
+ if (nowMillis - enqueuedAt > maxAgeMillis) continue
58
+ val payload = entry.optJSONObject("payload") ?: continue
59
+ if (blocked) {
60
+ remaining += entry
61
+ continue
62
+ }
63
+ val outcome =
64
+ try {
65
+ send(payload)
66
+ } catch (_: Exception) {
67
+ SendOutcome.RETRY
68
+ }
69
+ if (outcome == SendOutcome.RETRY) {
70
+ blocked = true
71
+ remaining += entry
72
+ }
73
+ }
74
+ write(remaining)
75
+ }
76
+
77
+ private fun read(): List<JSONObject> {
78
+ if (!file.exists()) return emptyList()
79
+ return try {
80
+ file.readLines().mapNotNull { line ->
81
+ line.takeIf { it.isNotBlank() }?.let { runCatching { JSONObject(it) }.getOrNull() }
82
+ }
83
+ } catch (_: Exception) {
84
+ emptyList()
85
+ }
86
+ }
87
+
88
+ private fun write(entries: List<JSONObject>) {
89
+ try {
90
+ if (entries.isEmpty()) {
91
+ file.delete()
92
+ return
93
+ }
94
+ file.parentFile?.mkdirs()
95
+ val tmp = File(file.parentFile, file.name + ".tmp")
96
+ tmp.writeText(entries.joinToString("\n") { it.toString() } + "\n")
97
+ if (!tmp.renameTo(file)) {
98
+ file.writeText(entries.joinToString("\n") { it.toString() } + "\n")
99
+ tmp.delete()
100
+ }
101
+ } catch (_: Exception) {
102
+ // A queue write failure only loses retries, never live behavior.
103
+ }
104
+ }
105
+
106
+ companion object {
107
+ const val DEFAULT_MAX_ENTRIES = 100
108
+ const val DEFAULT_MAX_AGE_MILLIS = 7L * 24 * 60 * 60 * 1000
109
+ const val FILE_NAME = "mentra_bluetooth_sdk_analytics_queue.jsonl"
110
+ }
111
+ }