@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
@@ -0,0 +1,139 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import java.util.TimeZone
4
+
5
+ /** The subset of glasses status the analytics decision logic needs. */
6
+ internal data class AnalyticsGlassesSnapshot(
7
+ val connected: Boolean,
8
+ val fullyBooted: Boolean,
9
+ val model: String,
10
+ val serialNumber: String,
11
+ val firmwareVersion: String = "",
12
+ val besFirmwareVersion: String = "",
13
+ val mtkFirmwareVersion: String = "",
14
+ val androidVersion: String = "",
15
+ val appVersion: String = "",
16
+ val buildNumber: String = "",
17
+ )
18
+
19
+ internal data class AnalyticsEvent(
20
+ val name: String,
21
+ val properties: Map<String, Any>,
22
+ )
23
+
24
+ /**
25
+ * Pure decision logic for the SDK usage events. It owns no threads and no I/O so
26
+ * the connection/identification/heartbeat rules can be unit-tested directly.
27
+ *
28
+ * Rules:
29
+ * - `bluetooth_sdk_glasses_connected` once per connection. If the model is not
30
+ * known yet at connect time the event waits for it (Mentra Live and some Android
31
+ * paths set the model after the connected flag), and is emitted without a model
32
+ * only if the connection ends first.
33
+ * - `bluetooth_sdk_glasses_identified` once per connection per serial, plus a
34
+ * `glasses_heartbeat` re-emission on the first status update of each new
35
+ * reporting day while still connected, so a connection spanning a week boundary
36
+ * is visible in both weeks. Reporting days follow `America/Los_Angeles`, the
37
+ * calendar the WAU weeks are cut on; a UTC day would miss the Sunday-evening to
38
+ * Monday-morning Pacific boundary, which is one UTC day.
39
+ */
40
+ internal class BluetoothSdkAnalyticsTracker(private val simulatedModel: String) {
41
+ private var lastConnected = false
42
+ private var connectedPendingModel = false
43
+ private var identifiedSerial: String? = null
44
+ private var identifiedReportingDay: Long? = null
45
+
46
+ fun initialize(snapshot: AnalyticsGlassesSnapshot, reportingDay: Long) {
47
+ lastConnected = snapshot.connected
48
+ connectedPendingModel = false
49
+ // Only treat identification as already captured when a valid serial is present
50
+ // at init. If the glasses are connected but the serial has not arrived yet
51
+ // (Mentra Live fills it via version_info after connect), leave this null so the
52
+ // identify event still fires once the serial arrives.
53
+ identifiedSerial = if (snapshot.connected) snapshot.serialNumber.validManufacturingSerial() else null
54
+ identifiedReportingDay = identifiedSerial?.let { reportingDay }
55
+ }
56
+
57
+ fun observe(snapshot: AnalyticsGlassesSnapshot, reportingDay: Long): List<AnalyticsEvent> {
58
+ val events = mutableListOf<AnalyticsEvent>()
59
+ val wasConnected = lastConnected
60
+ lastConnected = snapshot.connected
61
+
62
+ if (!snapshot.connected) {
63
+ if (connectedPendingModel) {
64
+ connectedPendingModel = false
65
+ events += connectedEvent(snapshot, modelUnresolved = true)
66
+ }
67
+ identifiedSerial = null
68
+ identifiedReportingDay = null
69
+ return events
70
+ }
71
+
72
+ if (!wasConnected) {
73
+ identifiedSerial = null
74
+ identifiedReportingDay = null
75
+ if (snapshot.model.isBlank()) {
76
+ connectedPendingModel = true
77
+ } else {
78
+ events += connectedEvent(snapshot, modelUnresolved = false)
79
+ }
80
+ } else if (connectedPendingModel && snapshot.model.isNotBlank()) {
81
+ connectedPendingModel = false
82
+ events += connectedEvent(snapshot, modelUnresolved = false)
83
+ }
84
+
85
+ val serial = snapshot.serialNumber.validManufacturingSerial() ?: return events
86
+ if (identifiedSerial != serial) {
87
+ identifiedSerial = serial
88
+ identifiedReportingDay = reportingDay
89
+ events += identifiedEvent(snapshot, serial, kind = "glasses_identified")
90
+ } else if (identifiedReportingDay != reportingDay) {
91
+ identifiedReportingDay = reportingDay
92
+ events += identifiedEvent(snapshot, serial, kind = "glasses_heartbeat")
93
+ }
94
+ return events
95
+ }
96
+
97
+ private fun connectedEvent(snapshot: AnalyticsGlassesSnapshot, modelUnresolved: Boolean) =
98
+ AnalyticsEvent(
99
+ "bluetooth_sdk_glasses_connected",
100
+ buildMap {
101
+ put("event_kind", "glasses_connected")
102
+ put("fully_booted", snapshot.fullyBooted)
103
+ snapshot.model.takeIf { it.isNotBlank() }?.let { put("glasses_model", it) }
104
+ if (modelUnresolved) put("glasses_model_unresolved", true)
105
+ put("glasses_is_simulated", snapshot.model == simulatedModel)
106
+ },
107
+ )
108
+
109
+ private fun identifiedEvent(snapshot: AnalyticsGlassesSnapshot, serial: String, kind: String) =
110
+ AnalyticsEvent(
111
+ "bluetooth_sdk_glasses_identified",
112
+ buildMap {
113
+ put("event_kind", kind)
114
+ put("fully_booted", snapshot.fullyBooted)
115
+ put("glasses_device_id", serial)
116
+ put("glasses_device_id_type", "manufacturing_serial")
117
+ snapshot.model.takeIf { it.isNotBlank() }?.let { put("glasses_model", it) }
118
+ put("glasses_is_simulated", snapshot.model == simulatedModel)
119
+ snapshot.firmwareVersion.takeIf { it.isNotBlank() }?.let { put("glasses_firmware_version", it) }
120
+ snapshot.besFirmwareVersion.takeIf { it.isNotBlank() }?.let { put("glasses_bes_firmware_version", it) }
121
+ snapshot.mtkFirmwareVersion.takeIf { it.isNotBlank() }?.let { put("glasses_mtk_firmware_version", it) }
122
+ snapshot.androidVersion.takeIf { it.isNotBlank() }?.let { put("glasses_android_version", it) }
123
+ snapshot.appVersion.takeIf { it.isNotBlank() }?.let { put("glasses_app_version", it) }
124
+ snapshot.buildNumber.takeIf { it.isNotBlank() }?.let { put("glasses_build_number", it) }
125
+ },
126
+ )
127
+
128
+ companion object {
129
+ const val MILLIS_PER_DAY = 86_400_000L
130
+ val REPORTING_ZONE: TimeZone = TimeZone.getTimeZone("America/Los_Angeles")
131
+
132
+ /** Calendar day in the reporting zone, as days since the epoch of that zone's midnight. */
133
+ fun reportingDay(epochMillis: Long): Long =
134
+ Math.floorDiv(epochMillis + REPORTING_ZONE.getOffset(epochMillis), MILLIS_PER_DAY)
135
+ }
136
+ }
137
+
138
+ internal fun String.validManufacturingSerial(): String? =
139
+ trim().takeIf { it.isNotEmpty() && !it.matches(Regex("0+")) }
@@ -0,0 +1,49 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import android.content.Context
4
+ import java.io.File
5
+ import java.util.concurrent.ExecutorService
6
+ import java.util.concurrent.Executors
7
+ import java.util.concurrent.RejectedExecutionException
8
+
9
+ /**
10
+ * Process-wide owner of analytics delivery: one serial executor and one retry
11
+ * queue for the whole process. Connection tracking stays per SDK instance, but
12
+ * two instances can overlap briefly when the SDK is recreated, and if each
13
+ * owned its own executor a drain started by the old instance could rewrite the
14
+ * retry file over an event the new instance had just persisted. A single
15
+ * executor serializes every read-modify-write of that file.
16
+ */
17
+ internal object BluetoothSdkAnalyticsTransport {
18
+ private val executor: ExecutorService =
19
+ Executors.newSingleThreadExecutor { runnable ->
20
+ Thread(runnable, "MentraBluetoothSdkAnalytics").apply { isDaemon = true }
21
+ }
22
+
23
+ @Volatile
24
+ private var queue: BluetoothSdkAnalyticsQueue? = null
25
+
26
+ fun queue(context: Context): BluetoothSdkAnalyticsQueue =
27
+ queue ?: synchronized(this) {
28
+ queue ?: BluetoothSdkAnalyticsQueue(File(context.applicationContext.filesDir, BluetoothSdkAnalyticsQueue.FILE_NAME))
29
+ .also { queue = it }
30
+ }
31
+
32
+ /** Runs [block] on the transport executor; analytics must never affect SDK behavior. */
33
+ fun submit(block: () -> Unit) {
34
+ try {
35
+ executor.execute {
36
+ try {
37
+ block()
38
+ } catch (_: Exception) {
39
+ }
40
+ }
41
+ } catch (_: RejectedExecutionException) {
42
+ }
43
+ }
44
+
45
+ /** Test hook: waits until every task submitted so far has run. */
46
+ internal fun awaitIdle() {
47
+ executor.submit {}.get()
48
+ }
49
+ }
@@ -4,5 +4,5 @@ package com.mentra.bluetoothsdk
4
4
  internal val GENERATED_RELEASE_CHANGELOGS: List<ReleaseChangelog> =
5
5
  listOf(
6
6
  ReleaseChangelog(version = "3.2.0", markdown = "This release is under active development. User-facing changes will be documented as they land.\n\n- Breaking Bluetooth SDK change: `forgetWifiNetwork(ssid)` now returns `WifiForgetResult` instead of `WifiStatusChangeEvent`. Migrate status-only consumers to the semantic `outcome`; the requested network remains `ssid`, while optional post-command connectivity is reported by `connected`, `currentSsid`, and `localIp`."),
7
- ReleaseChangelog(version = "3.1.0", markdown = "Software updates are more reliable, with clearer progress and recovery when the glasses restart.\n\n- MentraOS, Mentra Engine, the Bluetooth SDK, and the glasses client now share one coordinated release version.\n- Mentra Live updates can continue across APK, system, and firmware restarts without asking the user to start the same update again.\n- Bluetooth photo capture and transfer diagnostics are more precise and less disruptive to normal glasses traffic."),
7
+ ReleaseChangelog(version = "3.1.0", markdown = "Software updates are more reliable, with clearer progress and recovery when the glasses restart.\n\n- MentraOS, Mentra Engine, the Bluetooth SDK, and the glasses client now share one coordinated release version.\n- Mentra Live updates can continue across APK, system, and firmware restarts without asking the user to start the same update again.\n- Bluetooth photo capture and transfer diagnostics are more precise and less disruptive to normal glasses traffic.\n- Bluetooth SDK usage analytics now report the host app's version, build type, and install source (store, TestFlight, sideload, simulator), plus the glasses firmware versions on identification, so store usage can be measured separately from development builds.\n- Bluetooth SDK usage analytics now identify G2 glasses by serial, keep long-lived connections visible across day boundaries, and retry failed uploads instead of dropping them."),
8
8
  )
@@ -3,9 +3,9 @@ package com.mentra.bluetoothsdk
3
3
  /** Generated by release CI. Do not edit in a release checkout. */
4
4
  internal object GeneratedReleaseMetadata {
5
5
  const val FAMILY_BASE_VERSION: String = "3.2.0"
6
- const val RELEASE_IDENTITY: String = "3.2.0-dev.209"
7
- const val RELEASE_SET_ID: String = "mentra-3.2.0-dev.209"
8
- const val SOURCE_COMMIT: String = "b1606374e1508f0993d1a7e391fddeb4f8fac5bd"
9
- const val OTA_MANIFEST_URL: String = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.209.json"
10
- const val OTA_MANIFEST_SHA256: String = "de5936b765f7deb07a1fd448bb4e9c00f36e1b1313d16c021aff843cf801b489"
6
+ const val RELEASE_IDENTITY: String = "3.2.0-dev.210"
7
+ const val RELEASE_SET_ID: String = "mentra-3.2.0-dev.210"
8
+ const val SOURCE_COMMIT: String = "259fb93fd2ecae4ed7bf280fe3b4e2b2cc594611"
9
+ const val OTA_MANIFEST_URL: String = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.210.json"
10
+ const val OTA_MANIFEST_SHA256: String = "7d8fb1f69ca9a0a6b9d00f87a60694e74aa5377776d1818a7b982b09d9bb65dc"
11
11
  }
@@ -1921,9 +1921,19 @@ class G2 : SGCManager() {
1921
1921
 
1922
1922
  // Set device_name so DeviceManager can save it for reconnection
1923
1923
  val peripheralName = rightGatt?.device?.name ?: leftGatt?.device?.name
1924
- val serialNumber = peripheralName?.let { deviceNameToSerialNumber[it] }
1924
+ val serialNumber =
1925
+ G2SerialResolution.resolve(
1926
+ scannedSerial = peripheralName?.let { deviceNameToSerialNumber[it] },
1927
+ requestedId = DEVICE_SEARCH_ID,
1928
+ persistedDeviceName = DeviceStore.get("bluetooth", "device_name") as? String ?: "",
1929
+ )
1925
1930
  if (serialNumber != null) {
1926
1931
  DeviceStore.apply("bluetooth", "device_name", serialNumber)
1932
+ // The advertisement serial is the manufacturing serial; expose it where
1933
+ // the SDK status (and analytics identification) read it, not only in the
1934
+ // reconnection name slot. Cached reconnects skip the scan, so the
1935
+ // persisted name is the serial source there (see G2SerialResolution).
1936
+ DeviceStore.apply("glasses", "serialNumber", serialNumber)
1927
1937
  Bridge.log("G2: Set device_name to $serialNumber")
1928
1938
  }
1929
1939
 
@@ -0,0 +1,23 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ /**
4
+ * Which manufacturing serial to publish for a G2 connection.
5
+ *
6
+ * A fresh pairing decodes the serial from the advertisement during the scan. A
7
+ * cached reconnect (address based, after a process restart) skips the scan, so the
8
+ * only serial the SDK still holds is the one it persisted as the device name at
9
+ * the previous auth completion. That persisted value is reused only when it is
10
+ * exactly the id this connection was asked for: the search id can be a partial
11
+ * string typed by a host, and a partial must never be reported as a serial.
12
+ */
13
+ internal object G2SerialResolution {
14
+ private const val UNSET = "NOT_SET"
15
+
16
+ fun resolve(
17
+ scannedSerial: String?,
18
+ requestedId: String,
19
+ persistedDeviceName: String,
20
+ ): String? =
21
+ scannedSerial?.trim()?.takeIf { it.isNotEmpty() }
22
+ ?: persistedDeviceName.trim().takeIf { it.isNotEmpty() && it != UNSET && it == requestedId.trim() }
23
+ }
@@ -0,0 +1,64 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.junit.Test
5
+
6
+ class BluetoothSdkAnalyticsHostTest {
7
+ @Test
8
+ fun `maps known store installers and treats a missing installer as sideload`() {
9
+ assertThat(BluetoothSdkAnalyticsHost.installSourceFor("com.android.vending")).isEqualTo("play_store")
10
+ assertThat(BluetoothSdkAnalyticsHost.installSourceFor("com.amazon.venezia")).isEqualTo("amazon_appstore")
11
+ assertThat(BluetoothSdkAnalyticsHost.installSourceFor("com.sec.android.app.samsungapps")).isEqualTo("galaxy_store")
12
+ assertThat(BluetoothSdkAnalyticsHost.installSourceFor("com.example.thirdparty.store")).isEqualTo("other_store")
13
+ assertThat(BluetoothSdkAnalyticsHost.installSourceFor(null)).isEqualTo("sideload")
14
+ assertThat(BluetoothSdkAnalyticsHost.installSourceFor(" ")).isEqualTo("sideload")
15
+ }
16
+
17
+ @Test
18
+ fun `a failed installer lookup is unknown, never a guess`() {
19
+ assertThat(BluetoothSdkAnalyticsHost.installSourceFor(null, lookupFailed = true)).isEqualTo("unknown")
20
+ assertThat(BluetoothSdkAnalyticsHost.installSourceFor("com.android.vending", lookupFailed = true)).isEqualTo("unknown")
21
+ }
22
+
23
+ @Test
24
+ fun `normalizes the host environment and rejects values that cannot be filtered on`() {
25
+ assertThat(BluetoothSdkAnalyticsHost.normalizedEnvironment(" Prod ")).isEqualTo("prod")
26
+ assertThat(BluetoothSdkAnalyticsHost.normalizedEnvironment("staging-eu_1")).isEqualTo("staging-eu_1")
27
+ assertThat(BluetoothSdkAnalyticsHost.normalizedEnvironment(null)).isNull()
28
+ assertThat(BluetoothSdkAnalyticsHost.normalizedEnvironment("")).isNull()
29
+ assertThat(BluetoothSdkAnalyticsHost.normalizedEnvironment("-leading")).isNull()
30
+ assertThat(BluetoothSdkAnalyticsHost.normalizedEnvironment("has space")).isNull()
31
+ assertThat(BluetoothSdkAnalyticsHost.normalizedEnvironment("a".repeat(33))).isNull()
32
+ }
33
+
34
+ @Test
35
+ fun `serializes only the facts that are known`() {
36
+ val full =
37
+ BluetoothSdkAnalyticsHostProperties(
38
+ appVersion = "3.2.0",
39
+ appBuild = "53356754",
40
+ buildType = "release",
41
+ installSource = "play_store",
42
+ installerPackage = "com.android.vending",
43
+ environment = "prod",
44
+ ).toMap()
45
+ assertThat(full)
46
+ .containsEntry("app_version", "3.2.0")
47
+ .containsEntry("app_build", "53356754")
48
+ .containsEntry("app_build_type", "release")
49
+ .containsEntry("app_install_source", "play_store")
50
+ .containsEntry("app_installer_package", "com.android.vending")
51
+ .containsEntry("app_environment", "prod")
52
+
53
+ val sparse =
54
+ BluetoothSdkAnalyticsHostProperties(
55
+ appVersion = null,
56
+ appBuild = null,
57
+ buildType = "debug",
58
+ installSource = "sideload",
59
+ installerPackage = null,
60
+ environment = null,
61
+ ).toMap()
62
+ assertThat(sparse).containsOnlyKeys("app_build_type", "app_install_source")
63
+ }
64
+ }
@@ -0,0 +1,86 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.json.JSONObject
5
+ import org.junit.Rule
6
+ import org.junit.Test
7
+ import org.junit.rules.TemporaryFolder
8
+
9
+ class BluetoothSdkAnalyticsQueueTest {
10
+ @get:Rule
11
+ val folder = TemporaryFolder()
12
+
13
+ private fun payload(id: String) = JSONObject().put("uuid", id).put("event", "bluetooth_sdk_started")
14
+
15
+ @Test
16
+ fun `drains oldest first and keeps what still fails, preserving order`() {
17
+ val queue = BluetoothSdkAnalyticsQueue(folder.newFile("queue.jsonl"))
18
+ queue.enqueue(payload("a"), nowMillis = 1_000)
19
+ queue.enqueue(payload("b"), nowMillis = 2_000)
20
+ queue.enqueue(payload("c"), nowMillis = 3_000)
21
+
22
+ val sent = mutableListOf<String>()
23
+ queue.drain(nowMillis = 4_000) { p ->
24
+ val id = p.getString("uuid")
25
+ if (id == "b") SendOutcome.RETRY else SendOutcome.DELIVERED.also { sent.add(id) }
26
+ }
27
+ assertThat(sent).containsExactly("a")
28
+ assertThat(queue.size()).isEqualTo(2)
29
+
30
+ val second = mutableListOf<String>()
31
+ queue.drain(nowMillis = 5_000) { SendOutcome.DELIVERED.also { _ -> second.add(it.getString("uuid")) } }
32
+ assertThat(second).containsExactly("b", "c")
33
+ assertThat(queue.size()).isZero()
34
+ }
35
+
36
+ @Test
37
+ fun `drops the oldest entries past the cap and expired entries on drain`() {
38
+ val queue = BluetoothSdkAnalyticsQueue(folder.newFile("queue.jsonl"), maxEntries = 2, maxAgeMillis = 10_000)
39
+ queue.enqueue(payload("old"), nowMillis = 0)
40
+ queue.enqueue(payload("mid"), nowMillis = 5_000)
41
+ queue.enqueue(payload("new"), nowMillis = 6_000)
42
+ assertThat(queue.size()).isEqualTo(2)
43
+
44
+ val sent = mutableListOf<String>()
45
+ queue.drain(nowMillis = 16_000) { SendOutcome.DELIVERED.also { _ -> sent.add(it.getString("uuid")) } }
46
+ assertThat(sent).containsExactly("new")
47
+ }
48
+
49
+ @Test
50
+ fun `survives a corrupt line and a throwing sender`() {
51
+ val file = folder.newFile("queue.jsonl")
52
+ val queue = BluetoothSdkAnalyticsQueue(file)
53
+ queue.enqueue(payload("a"), nowMillis = 1_000)
54
+ file.appendText("not json\n")
55
+ queue.enqueue(payload("b"), nowMillis = 2_000)
56
+ assertThat(queue.size()).isEqualTo(2)
57
+
58
+ queue.drain(nowMillis = 3_000) { throw IllegalStateException("network") }
59
+ assertThat(queue.size()).isEqualTo(2)
60
+ }
61
+
62
+ @Test
63
+ fun `a permanently rejected payload is dropped without blocking the rest`() {
64
+ val queue = BluetoothSdkAnalyticsQueue(folder.newFile("queue.jsonl"))
65
+ queue.enqueue(payload("bad"), nowMillis = 1_000)
66
+ queue.enqueue(payload("good"), nowMillis = 2_000)
67
+
68
+ val sent = mutableListOf<String>()
69
+ queue.drain(nowMillis = 3_000) { p ->
70
+ val id = p.getString("uuid")
71
+ if (id == "bad") SendOutcome.DISCARD else SendOutcome.DELIVERED.also { sent.add(id) }
72
+ }
73
+ assertThat(sent).containsExactly("good")
74
+ assertThat(queue.size()).isZero()
75
+ }
76
+
77
+ @Test
78
+ fun `http status maps to an outcome`() {
79
+ assertThat(SendOutcome.fromHttpStatus(200)).isEqualTo(SendOutcome.DELIVERED)
80
+ assertThat(SendOutcome.fromHttpStatus(400)).isEqualTo(SendOutcome.DISCARD)
81
+ assertThat(SendOutcome.fromHttpStatus(401)).isEqualTo(SendOutcome.DISCARD)
82
+ assertThat(SendOutcome.fromHttpStatus(408)).isEqualTo(SendOutcome.RETRY)
83
+ assertThat(SendOutcome.fromHttpStatus(429)).isEqualTo(SendOutcome.RETRY)
84
+ assertThat(SendOutcome.fromHttpStatus(503)).isEqualTo(SendOutcome.RETRY)
85
+ }
86
+ }
@@ -0,0 +1,148 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.junit.Test
5
+ import java.time.Instant
6
+
7
+ class BluetoothSdkAnalyticsTrackerTest {
8
+ private val tracker = BluetoothSdkAnalyticsTracker(simulatedModel = "Simulated Glasses")
9
+
10
+ private fun snapshot(
11
+ connected: Boolean,
12
+ model: String = "Mentra Live",
13
+ serial: String = "",
14
+ fullyBooted: Boolean = connected,
15
+ ) = AnalyticsGlassesSnapshot(connected = connected, fullyBooted = fullyBooted, model = model, serialNumber = serial)
16
+
17
+ @Test
18
+ fun `connect then serial emits connected and identified once, reconnects do not repeat identification within a connection`() {
19
+ tracker.initialize(snapshot(connected = false), reportingDay = 100)
20
+ val first = tracker.observe(snapshot(connected = true), reportingDay = 100)
21
+ assertThat(first.map { it.name }).containsExactly("bluetooth_sdk_glasses_connected")
22
+
23
+ val identified = tracker.observe(snapshot(connected = true, serial = "MLAB0001"), reportingDay = 100)
24
+ assertThat(identified.map { it.name }).containsExactly("bluetooth_sdk_glasses_identified")
25
+ assertThat(identified.single().properties)
26
+ .containsEntry("event_kind", "glasses_identified")
27
+ .containsEntry("glasses_device_id", "MLAB0001")
28
+ .containsEntry("glasses_is_simulated", false)
29
+
30
+ assertThat(tracker.observe(snapshot(connected = true, serial = "MLAB0001"), reportingDay = 100)).isEmpty()
31
+ }
32
+
33
+ @Test
34
+ fun `a new connection identifies the same serial again`() {
35
+ tracker.initialize(snapshot(connected = false), reportingDay = 100)
36
+ tracker.observe(snapshot(connected = true, serial = "MLAB0001"), reportingDay = 100)
37
+ tracker.observe(snapshot(connected = false), reportingDay = 100)
38
+ val again = tracker.observe(snapshot(connected = true, serial = "MLAB0001"), reportingDay = 100)
39
+ assertThat(again.map { it.name })
40
+ .containsExactly("bluetooth_sdk_glasses_connected", "bluetooth_sdk_glasses_identified")
41
+ }
42
+
43
+ @Test
44
+ fun `connected waits for the model and is emitted once the model arrives`() {
45
+ tracker.initialize(snapshot(connected = false), reportingDay = 100)
46
+ assertThat(tracker.observe(snapshot(connected = true, model = ""), reportingDay = 100)).isEmpty()
47
+ val withModel = tracker.observe(snapshot(connected = true, model = "Even Realities G2"), reportingDay = 100)
48
+ assertThat(withModel.single().name).isEqualTo("bluetooth_sdk_glasses_connected")
49
+ assertThat(withModel.single().properties).containsEntry("glasses_model", "Even Realities G2")
50
+ assertThat(withModel.single().properties).doesNotContainKey("glasses_model_unresolved")
51
+ }
52
+
53
+ @Test
54
+ fun `connected without a model is still counted when the connection ends first`() {
55
+ tracker.initialize(snapshot(connected = false), reportingDay = 100)
56
+ tracker.observe(snapshot(connected = true, model = ""), reportingDay = 100)
57
+ val ended = tracker.observe(snapshot(connected = false, model = ""), reportingDay = 100)
58
+ assertThat(ended.single().name).isEqualTo("bluetooth_sdk_glasses_connected")
59
+ assertThat(ended.single().properties).containsEntry("glasses_model_unresolved", true).doesNotContainKey("glasses_model")
60
+ }
61
+
62
+ @Test
63
+ fun `a connection that crosses a UTC day boundary heartbeats once per day`() {
64
+ tracker.initialize(snapshot(connected = false), reportingDay = 100)
65
+ tracker.observe(snapshot(connected = true, serial = "MLAB0001"), reportingDay = 100)
66
+ assertThat(tracker.observe(snapshot(connected = true, serial = "MLAB0001"), reportingDay = 100)).isEmpty()
67
+
68
+ val nextDay = tracker.observe(snapshot(connected = true, serial = "MLAB0001"), reportingDay = 101)
69
+ assertThat(nextDay.single().name).isEqualTo("bluetooth_sdk_glasses_identified")
70
+ assertThat(nextDay.single().properties).containsEntry("event_kind", "glasses_heartbeat")
71
+ assertThat(tracker.observe(snapshot(connected = true, serial = "MLAB0001"), reportingDay = 101)).isEmpty()
72
+ }
73
+
74
+ @Test
75
+ fun `initializing while already connected and identified suppresses a duplicate identification but not later heartbeats`() {
76
+ tracker.initialize(snapshot(connected = true, serial = "MLAB0001"), reportingDay = 100)
77
+ assertThat(tracker.observe(snapshot(connected = true, serial = "MLAB0001"), reportingDay = 100)).isEmpty()
78
+ val nextDay = tracker.observe(snapshot(connected = true, serial = "MLAB0001"), reportingDay = 101)
79
+ assertThat(nextDay.single().properties).containsEntry("event_kind", "glasses_heartbeat")
80
+ }
81
+
82
+ @Test
83
+ fun `initializing while connected but before the serial arrives still identifies later`() {
84
+ tracker.initialize(snapshot(connected = true, serial = ""), reportingDay = 100)
85
+ val identified = tracker.observe(snapshot(connected = true, serial = "MLAB0001"), reportingDay = 100)
86
+ assertThat(identified.single().properties).containsEntry("event_kind", "glasses_identified")
87
+ }
88
+
89
+ @Test
90
+ fun `placeholder serials are ignored and simulated glasses are flagged`() {
91
+ tracker.initialize(snapshot(connected = false), reportingDay = 100)
92
+ val events = tracker.observe(snapshot(connected = true, model = "Simulated Glasses", serial = "0000"), reportingDay = 100)
93
+ assertThat(events.map { it.name }).containsExactly("bluetooth_sdk_glasses_connected")
94
+ assertThat(events.single().properties).containsEntry("glasses_is_simulated", true)
95
+ }
96
+
97
+ @Test
98
+ fun `identification carries the glasses software versions that are known`() {
99
+ tracker.initialize(snapshot(connected = false), reportingDay = 100)
100
+ val events =
101
+ tracker.observe(
102
+ AnalyticsGlassesSnapshot(
103
+ connected = true,
104
+ fullyBooted = true,
105
+ model = "Mentra Live",
106
+ serialNumber = "MLAB0001",
107
+ firmwareVersion = "26.9.3.0",
108
+ mtkFirmwareVersion = "20260709",
109
+ appVersion = "5.2.1",
110
+ ),
111
+ reportingDay = 100,
112
+ )
113
+ val identified = events.single { it.name == "bluetooth_sdk_glasses_identified" }
114
+ assertThat(identified.properties)
115
+ .containsEntry("glasses_firmware_version", "26.9.3.0")
116
+ .containsEntry("glasses_mtk_firmware_version", "20260709")
117
+ .containsEntry("glasses_app_version", "5.2.1")
118
+ .doesNotContainKeys("glasses_bes_firmware_version", "glasses_android_version", "glasses_build_number")
119
+ }
120
+
121
+ @Test
122
+ fun `reporting day follows the Los Angeles calendar, not UTC`() {
123
+ val sundayEveningPacific = Instant.parse("2026-09-14T00:30:00Z").toEpochMilli() // Sun 2026-09-13 17:30 PDT
124
+ val mondayEarlyPacific = Instant.parse("2026-09-14T08:00:00Z").toEpochMilli() // Mon 2026-09-14 01:00 PDT
125
+ assertThat(Math.floorDiv(sundayEveningPacific, 86_400_000L)).isEqualTo(Math.floorDiv(mondayEarlyPacific, 86_400_000L))
126
+ assertThat(BluetoothSdkAnalyticsTracker.reportingDay(sundayEveningPacific))
127
+ .isEqualTo(BluetoothSdkAnalyticsTracker.reportingDay(mondayEarlyPacific) - 1)
128
+
129
+ // Spring-forward day: 01:30 PST and 03:30 PDT are the same Pacific day.
130
+ val beforeDst = Instant.parse("2026-03-08T09:30:00Z").toEpochMilli()
131
+ val afterDst = Instant.parse("2026-03-08T10:30:00Z").toEpochMilli()
132
+ assertThat(BluetoothSdkAnalyticsTracker.reportingDay(beforeDst)).isEqualTo(BluetoothSdkAnalyticsTracker.reportingDay(afterDst))
133
+ // Pacific midnight is the boundary.
134
+ val justBeforeMidnight = Instant.parse("2026-09-14T06:59:59Z").toEpochMilli()
135
+ val midnight = Instant.parse("2026-09-14T07:00:00Z").toEpochMilli()
136
+ assertThat(BluetoothSdkAnalyticsTracker.reportingDay(justBeforeMidnight) + 1).isEqualTo(BluetoothSdkAnalyticsTracker.reportingDay(midnight))
137
+ }
138
+
139
+ @Test
140
+ fun `a connection across the Pacific Sunday to Monday boundary heartbeats on Monday`() {
141
+ tracker.initialize(snapshot(connected = false), reportingDay = 0)
142
+ val sunday = BluetoothSdkAnalyticsTracker.reportingDay(Instant.parse("2026-09-14T00:30:00Z").toEpochMilli())
143
+ val monday = BluetoothSdkAnalyticsTracker.reportingDay(Instant.parse("2026-09-14T08:00:00Z").toEpochMilli())
144
+ tracker.observe(snapshot(connected = true, serial = "MLAB0001"), reportingDay = sunday)
145
+ val onMonday = tracker.observe(snapshot(connected = true, serial = "MLAB0001"), reportingDay = monday)
146
+ assertThat(onMonday.single().properties).containsEntry("event_kind", "glasses_heartbeat")
147
+ }
148
+ }
@@ -0,0 +1,44 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.json.JSONObject
5
+ import org.junit.Rule
6
+ import org.junit.Test
7
+ import org.junit.rules.TemporaryFolder
8
+ import java.util.concurrent.CountDownLatch
9
+ import java.util.concurrent.TimeUnit
10
+
11
+ class BluetoothSdkAnalyticsTransportTest {
12
+ @get:Rule
13
+ val folder = TemporaryFolder()
14
+
15
+ private fun payload(id: String) = JSONObject().put("uuid", id)
16
+
17
+ @Test
18
+ fun `a drain started by an old instance cannot overwrite an event a new instance persists`() {
19
+ val queue = BluetoothSdkAnalyticsQueue(folder.newFile("queue.jsonl"))
20
+ queue.enqueue(payload("old"), nowMillis = 1_000)
21
+ val drainStarted = CountDownLatch(1)
22
+ val newInstanceReady = CountDownLatch(1)
23
+
24
+ // "Old instance": a slow drain that snapshots the file, then rewrites it.
25
+ BluetoothSdkAnalyticsTransport.submit {
26
+ queue.drain(nowMillis = 2_000) {
27
+ drainStarted.countDown()
28
+ newInstanceReady.await(2, TimeUnit.SECONDS)
29
+ SendOutcome.DELIVERED
30
+ }
31
+ }
32
+ // "New instance": persists a failed event while that drain is in progress.
33
+ // Through the shared executor it is serialized after the drain instead of
34
+ // racing its rewrite.
35
+ assertThat(drainStarted.await(2, TimeUnit.SECONDS)).isTrue()
36
+ BluetoothSdkAnalyticsTransport.submit { queue.enqueue(payload("new"), nowMillis = 3_000) }
37
+ newInstanceReady.countDown()
38
+ BluetoothSdkAnalyticsTransport.awaitIdle()
39
+
40
+ val remaining = mutableListOf<String>()
41
+ queue.drain(nowMillis = 4_000) { p -> SendOutcome.RETRY.also { remaining.add(p.getString("uuid")) } }
42
+ assertThat(remaining).containsExactly("new")
43
+ }
44
+ }
@@ -0,0 +1,25 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import com.mentra.bluetoothsdk.sgcs.G2SerialResolution
4
+ import org.assertj.core.api.Assertions.assertThat
5
+ import org.junit.Test
6
+
7
+ class G2SerialResolutionTest {
8
+ @Test
9
+ fun `a scanned serial always wins`() {
10
+ assertThat(G2SerialResolution.resolve("S2ABCD12345678", "OTHER", "S2OLD000000000")).isEqualTo("S2ABCD12345678")
11
+ }
12
+
13
+ @Test
14
+ fun `a cached reconnect reuses the persisted serial only when it is the requested id`() {
15
+ assertThat(G2SerialResolution.resolve(null, "S2ABCD12345678", "S2ABCD12345678")).isEqualTo("S2ABCD12345678")
16
+ assertThat(G2SerialResolution.resolve(null, "12345678", "S2ABCD12345678")).isNull()
17
+ assertThat(G2SerialResolution.resolve(null, "S2ABCD12345678", "")).isNull()
18
+ assertThat(G2SerialResolution.resolve(null, "NOT_SET", "NOT_SET")).isNull()
19
+ }
20
+
21
+ @Test
22
+ fun `switching to a different pair does not leak the previous serial`() {
23
+ assertThat(G2SerialResolution.resolve(null, "S2NEW000000001", "S2OLD000000000")).isNull()
24
+ }
25
+ }
@@ -4,6 +4,6 @@ export declare const GENERATED_RELEASE_CHANGELOGS: readonly [{
4
4
  readonly markdown: "This release is under active development. User-facing changes will be documented as they land.\n\n- Breaking Bluetooth SDK change: `forgetWifiNetwork(ssid)` now returns `WifiForgetResult` instead of `WifiStatusChangeEvent`. Migrate status-only consumers to the semantic `outcome`; the requested network remains `ssid`, while optional post-command connectivity is reported by `connected`, `currentSsid`, and `localIp`.";
5
5
  }, {
6
6
  readonly version: "3.1.0";
7
- readonly markdown: "Software updates are more reliable, with clearer progress and recovery when the glasses restart.\n\n- MentraOS, Mentra Engine, the Bluetooth SDK, and the glasses client now share one coordinated release version.\n- Mentra Live updates can continue across APK, system, and firmware restarts without asking the user to start the same update again.\n- Bluetooth photo capture and transfer diagnostics are more precise and less disruptive to normal glasses traffic.";
7
+ readonly markdown: "Software updates are more reliable, with clearer progress and recovery when the glasses restart.\n\n- MentraOS, Mentra Engine, the Bluetooth SDK, and the glasses client now share one coordinated release version.\n- Mentra Live updates can continue across APK, system, and firmware restarts without asking the user to start the same update again.\n- Bluetooth photo capture and transfer diagnostics are more precise and less disruptive to normal glasses traffic.\n- Bluetooth SDK usage analytics now report the host app's version, build type, and install source (store, TestFlight, sideload, simulator), plus the glasses firmware versions on identification, so store usage can be measured separately from development builds.\n- Bluetooth SDK usage analytics now identify G2 glasses by serial, keep long-lived connections visible across day boundaries, and retry failed uploads instead of dropping them.";
8
8
  }];
9
9
  //# sourceMappingURL=changelogCatalog.d.ts.map
@@ -6,7 +6,7 @@ export const GENERATED_RELEASE_CHANGELOGS = Object.freeze([
6
6
  },
7
7
  {
8
8
  "version": "3.1.0",
9
- "markdown": "Software updates are more reliable, with clearer progress and recovery when the glasses restart.\n\n- MentraOS, Mentra Engine, the Bluetooth SDK, and the glasses client now share one coordinated release version.\n- Mentra Live updates can continue across APK, system, and firmware restarts without asking the user to start the same update again.\n- Bluetooth photo capture and transfer diagnostics are more precise and less disruptive to normal glasses traffic."
9
+ "markdown": "Software updates are more reliable, with clearer progress and recovery when the glasses restart.\n\n- MentraOS, Mentra Engine, the Bluetooth SDK, and the glasses client now share one coordinated release version.\n- Mentra Live updates can continue across APK, system, and firmware restarts without asking the user to start the same update again.\n- Bluetooth photo capture and transfer diagnostics are more precise and less disruptive to normal glasses traffic.\n- Bluetooth SDK usage analytics now report the host app's version, build type, and install source (store, TestFlight, sideload, simulator), plus the glasses firmware versions on identification, so store usage can be measured separately from development builds.\n- Bluetooth SDK usage analytics now identify G2 glasses by serial, keep long-lived connections visible across day boundaries, and retry failed uploads instead of dropping them."
10
10
  }
11
11
  ]);
12
12
  //# sourceMappingURL=changelogCatalog.js.map