@mentra/bluetooth-sdk 3.2.0-dev.208 → 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.
- package/README.md +45 -8
- package/android/build.gradle +2 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +89 -90
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsHost.kt +106 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsQueue.kt +111 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +139 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTransport.kt +49 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +11 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2SerialResolution.kt +23 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsHostTest.kt +64 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsQueueTest.kt +86 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +148 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTransportTest.kt +44 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/G2SerialResolutionTest.kt +25 -0
- package/build/generated/changelogCatalog.d.ts +1 -1
- package/build/generated/changelogCatalog.js +1 -1
- package/build/generated/changelogCatalog.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/internal/BluetoothSdkAnalytics.swift +127 -76
- package/ios/Source/internal/BluetoothSdkAnalyticsHost.swift +91 -0
- package/ios/Source/internal/BluetoothSdkAnalyticsQueue.swift +117 -0
- package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +157 -0
- package/ios/Source/internal/BluetoothSdkAnalyticsTransport.swift +13 -0
- package/ios/Source/sgcs/G2.swift +374 -359
- package/ios/Source/sgcs/G2SerialResolution.swift +23 -0
- package/ios/Tests/BluetoothSdkAnalyticsHostTests.swift +63 -0
- package/ios/Tests/BluetoothSdkAnalyticsQueueTests.swift +97 -0
- package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +122 -0
- package/ios/Tests/BluetoothSdkAnalyticsTransportTests.swift +36 -0
- package/ios/Tests/G2SerialResolutionTests.swift +19 -0
- package/package.json +1 -1
- package/plugin/build/analyticsProps.d.ts +9 -0
- package/plugin/build/analyticsProps.js +34 -0
- package/plugin/build/index.d.ts +8 -0
- package/plugin/build/withAndroid.js +12 -18
- package/plugin/build/withIos.d.ts +1 -0
- package/plugin/build/withIos.js +8 -18
- package/src/generated/changelogCatalog.ts +1 -1
- 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.
|
|
7
|
-
const val RELEASE_SET_ID: String = "mentra-3.2.0-dev.
|
|
8
|
-
const val SOURCE_COMMIT: String = "
|
|
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.
|
|
10
|
-
const val OTA_MANIFEST_SHA256: String = "
|
|
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 =
|
|
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
|