@mentra/bluetooth-sdk 3.2.1-dev.311 → 3.2.1

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 (133) hide show
  1. package/README.md +4 -4
  2. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
  3. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +3 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +6 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +129 -25
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +2 -2
  10. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +4 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
  13. package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +1 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +72 -21
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +101 -22
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -8
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +314 -395
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +245 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +238 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +180 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddress.kt +17 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  29. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/DashboardContentFormatterTest.kt +2 -2
  31. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceIdentityPairingTest.kt +59 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +666 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
  36. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1PairingTest.kt +105 -0
  37. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
  38. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2PairingTargetTest.kt +90 -0
  39. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveFileTransferSessionTest.kt +55 -0
  40. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
  41. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingTargetTest.kt +65 -0
  42. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraNexPairingTest.kt +151 -0
  43. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  44. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +220 -0
  45. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +348 -0
  46. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +339 -0
  47. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
  48. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
  49. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddressTest.kt +27 -0
  50. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  51. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  52. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +288 -0
  53. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +205 -0
  54. package/build/BluetoothSdk.types.d.ts +13 -0
  55. package/build/BluetoothSdk.types.d.ts.map +1 -1
  56. package/build/BluetoothSdk.types.js +1 -0
  57. package/build/BluetoothSdk.types.js.map +1 -1
  58. package/build/_private/BluetoothSdkModule.d.ts +4 -0
  59. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  60. package/build/_private/BluetoothSdkModule.js +3 -0
  61. package/build/_private/BluetoothSdkModule.js.map +1 -1
  62. package/build/generated/changelogCatalog.d.ts +2 -2
  63. package/build/generated/changelogCatalog.js +2 -2
  64. package/build/generated/changelogCatalog.js.map +1 -1
  65. package/build/generated/releaseMetadata.js +5 -5
  66. package/build/generated/releaseMetadata.js.map +1 -1
  67. package/build/index.d.ts.map +1 -1
  68. package/build/index.js +1 -0
  69. package/build/index.js.map +1 -1
  70. package/ios/BluetoothSdkModule.swift +9 -0
  71. package/ios/MentraBluetoothSDK.podspec +3 -2
  72. package/ios/Source/BluetoothBackgroundMode.swift +32 -0
  73. package/ios/Source/BluetoothSdkDefaults.swift +6 -1
  74. package/ios/Source/Bridge.swift +15 -2
  75. package/ios/Source/DeviceManager.swift +96 -63
  76. package/ios/Source/DeviceStore.swift +26 -7
  77. package/ios/Source/GeneratedChangelogCatalog.swift +2 -2
  78. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  79. package/ios/Source/MentraBluetoothSDK.swift +18 -0
  80. package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
  81. package/ios/Source/requests/DisplayRequests.swift +1 -1
  82. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
  83. package/ios/Source/sgcs/Ar99.swift +1 -2
  84. package/ios/Source/sgcs/G1.swift +68 -38
  85. package/ios/Source/sgcs/G1ConnectionTarget.swift +13 -0
  86. package/ios/Source/sgcs/G2.swift +261 -247
  87. package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
  88. package/ios/Source/sgcs/Mach1.swift +19 -7
  89. package/ios/Source/sgcs/MentraLive.swift +201 -156
  90. package/ios/Source/sgcs/MentraLiveFileTransferSession.swift +137 -0
  91. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +47 -0
  92. package/ios/Source/sgcs/MentraNex.swift +8 -31
  93. package/ios/Source/sgcs/NexConnectionTarget.swift +15 -0
  94. package/ios/Source/sgcs/Nimo.swift +280 -408
  95. package/ios/Source/sgcs/NimoCanvasCodec.swift +269 -0
  96. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +233 -0
  97. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  98. package/ios/Source/sgcs/NimoCanvasSession.swift +166 -0
  99. package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
  100. package/ios/Source/sgcs/SGCManager.swift +24 -0
  101. package/ios/Source/sgcs/VuzixConnectionTarget.swift +15 -0
  102. package/ios/Source/status/DeviceStatus.swift +1 -0
  103. package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
  104. package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
  105. package/ios/Source/utils/G2Text.swift +11 -0
  106. package/ios/Tests/BluetoothBackgroundModeTests.swift +32 -0
  107. package/ios/Tests/DashboardContentFormatterTests.swift +2 -2
  108. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  109. package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
  110. package/ios/Tests/DeviceIdentityPairingTests.swift +49 -0
  111. package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
  112. package/ios/Tests/G1PairingTests.swift +162 -0
  113. package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
  114. package/ios/Tests/G2TextTests.swift +10 -0
  115. package/ios/Tests/MentraLiveFileTransferSessionTests.swift +47 -0
  116. package/ios/Tests/MentraLiveReconnectPolicyTests.swift +58 -0
  117. package/ios/Tests/NimoCanvasCodecTests.swift +204 -0
  118. package/ios/Tests/NimoCanvasSessionTests.swift +181 -0
  119. package/ios/Tests/NimoCanvasTransportTests.swift +186 -0
  120. package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
  121. package/ios/Tests/NimoDiscoveryTests.swift +79 -0
  122. package/ios/Tests/SGCConnectionTargetTests.swift +48 -0
  123. package/ios/Tests/VersionInfoCommandTests.swift +53 -0
  124. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
  125. package/ios/Tests/VideoRecordingCommandTests.swift +81 -0
  126. package/package.json +2 -2
  127. package/plugin/build/withIos.d.ts +2 -0
  128. package/plugin/build/withIos.js +12 -1
  129. package/src/BluetoothSdk.types.ts +14 -0
  130. package/src/_private/BluetoothSdkModule.ts +8 -0
  131. package/src/generated/changelogCatalog.ts +2 -2
  132. package/src/generated/releaseMetadata.ts +5 -5
  133. package/src/index.ts +1 -0
@@ -0,0 +1,13 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import com.mentra.bluetoothsdk.utils.G2Text
4
+ import org.junit.Assert.assertEquals
5
+ import org.junit.Test
6
+
7
+ class G2TextTest {
8
+ @Test fun blankRowsAreEncodedOnlyAtTheDeviceBoundary() {
9
+ assertEquals("hello\n\u200B\nworld\n\u200B", G2Text.containerContent("hello\n\nworld\n"))
10
+ assertEquals(" ", G2Text.containerContent(""))
11
+ assertEquals("hello\nworld", G2Text.containerContent("hello\nworld"))
12
+ }
13
+ }
@@ -102,6 +102,77 @@ class VersionInfoResponseAccumulatorTest {
102
102
  .isEqualTo(VersionInfoAccumulatorOutcome.Waiting)
103
103
  }
104
104
 
105
+ @Test
106
+ fun factoryAsg27CompletesAfterItsTwoChunks() {
107
+ // Exact January wire shape after Bridge normalization; firmware can be unknown.
108
+ for (firmware in listOf("", "17.26.1.13")) {
109
+ val accumulator = VersionInfoResponseAccumulator("request-1")
110
+ accumulator.accept(chunk("version_info_1", null, "buildNumber" to "27", "appVersion" to "27.0"))
111
+ val complete = accumulator.accept(chunk("version_info_2", null,
112
+ "buildNumber" to "", "otaVersionUrl" to "https://ota.example/live_version.json", "firmwareVersion" to firmware,
113
+ )) as VersionInfoAccumulatorOutcome.Complete
114
+ assertThat(complete.result.buildNumber).isEqualTo("27")
115
+ assertThat(complete.result.otaVersionUrl).isEqualTo("https://ota.example/live_version.json")
116
+ assertThat(complete.result.firmwareVersion).isEqualTo(firmware)
117
+ }
118
+ }
119
+
120
+ @Test
121
+ fun otherLegacyBuildsStillRequireTheirThirdChunk() {
122
+ for (build in listOf("", "26", "28", "31", "37", "42")) {
123
+ val accumulator = VersionInfoResponseAccumulator("request-1")
124
+ accumulator.accept(chunk("version_info_1", null, "buildNumber" to build))
125
+ assertThat(accumulator.accept(chunk("version_info_2", null,
126
+ "otaVersionUrl" to "https://ota.example/version.json",
127
+ ))).isEqualTo(VersionInfoAccumulatorOutcome.Waiting)
128
+ assertThat(accumulator.accept(chunk("version_info_3", null, "besFirmwareVersion" to "new")))
129
+ .isInstanceOf(VersionInfoAccumulatorOutcome.Complete::class.java)
130
+ }
131
+ }
132
+
133
+ @Test
134
+ fun factorySecondChunkCannotCompleteWithoutFirstChunkAndOtaUrl() {
135
+ val accumulator = VersionInfoResponseAccumulator("request-1")
136
+ val second = chunk("version_info_2", null, "otaVersionUrl" to "https://ota.example/version.json")
137
+ assertThat(accumulator.accept(second)).isEqualTo(VersionInfoAccumulatorOutcome.Ignored)
138
+ accumulator.accept(chunk("version_info_1", null, "buildNumber" to "27"))
139
+ for (values in listOf(emptyMap(), mapOf("otaVersionUrl" to ""), mapOf("otaVersionUrl" to " \n"))) {
140
+ assertThat(accumulator.accept(chunk("version_info_2", null) + values))
141
+ .isEqualTo(VersionInfoAccumulatorOutcome.Waiting)
142
+ }
143
+ assertThat(accumulator.accept(second)).isInstanceOf(VersionInfoAccumulatorOutcome.Complete::class.java)
144
+ }
145
+
146
+ @Test
147
+ fun secondChunkCannotChangeBuildIntoFactoryProtocol() {
148
+ val accumulator = VersionInfoResponseAccumulator("request-1")
149
+ accumulator.accept(chunk("version_info_1", null, "buildNumber" to "31"))
150
+ assertThat(accumulator.accept(chunk("version_info_2", null,
151
+ "buildNumber" to "27", "otaVersionUrl" to "https://ota.example/version.json",
152
+ ))).isEqualTo(VersionInfoAccumulatorOutcome.Waiting)
153
+ }
154
+
155
+ @Test
156
+ fun repeatedFirstChunkClearsFactoryCompletionRule() {
157
+ val accumulator = VersionInfoResponseAccumulator("request-1")
158
+ accumulator.accept(chunk("version_info_1", null, "buildNumber" to "27"))
159
+ accumulator.accept(chunk("version_info_1", null, "buildNumber" to "31"))
160
+ assertThat(accumulator.accept(chunk("version_info_2", null,
161
+ "otaVersionUrl" to "https://ota.example/version.json",
162
+ ))).isEqualTo(VersionInfoAccumulatorOutcome.Waiting)
163
+ }
164
+
165
+ @Test
166
+ fun factoryBuildCannotBypassModernCorrelation() {
167
+ val accumulator = VersionInfoResponseAccumulator("request-1")
168
+ accumulator.accept(chunk("version_info_1", "request-1", "buildNumber" to "27"))
169
+ assertThat(accumulator.accept(chunk("version_info_2", null,
170
+ "otaVersionUrl" to "https://ota.example/version.json",
171
+ ))).isEqualTo(VersionInfoAccumulatorOutcome.Ignored)
172
+ assertThat(accumulator.accept(chunk("version_info_3", "request-1", "besFirmwareVersion" to "new")))
173
+ .isInstanceOf(VersionInfoAccumulatorOutcome.Complete::class.java)
174
+ }
175
+
105
176
  @Test
106
177
  fun modernFinalChunkWaitsForMissingFirstChunk() {
107
178
  val accumulator = VersionInfoResponseAccumulator("request-1")
@@ -0,0 +1,51 @@
1
+ package com.mentra.bluetoothsdk.services
2
+
3
+ import com.mentra.bluetoothsdk.utils.DeviceTypes
4
+ import org.junit.Assert.*
5
+ import org.junit.Before
6
+ import org.junit.Test
7
+ import org.junit.runner.RunWith
8
+ import org.robolectric.RobolectricTestRunner
9
+ import org.robolectric.RuntimeEnvironment
10
+ import org.robolectric.annotation.Config
11
+
12
+ @RunWith(RobolectricTestRunner::class)
13
+ @Config(sdk = [28])
14
+ class G2ConnectionRecoveryTest {
15
+ private fun recovery() = G2ConnectionRecovery(RuntimeEnvironment.getApplication())
16
+ private fun settings() = mapOf<String, Any>(
17
+ "default_wearable" to DeviceTypes.G2, "device_name" to "S200-test",
18
+ "brightness" to 75, "auto_brightness" to false,
19
+ "core_token" to "must-not-persist", "micEnabled" to true,
20
+ "searching" to true, "pending_device_name" to "unpaired",
21
+ )
22
+
23
+ @Before fun reset() { recovery().clear() }
24
+
25
+ @Test fun `a fresh recovery owner restores identity and settings but not runtime or auth state`() {
26
+ recovery().save(settings())
27
+ val restored = recovery().read()!!
28
+ assertEquals("S200-test", restored["device_name"])
29
+ assertEquals(75, restored["brightness"])
30
+ assertEquals(false, restored["auto_brightness"])
31
+ assertFalse(restored.containsKey("core_token"))
32
+ assertFalse(restored.containsKey("micEnabled"))
33
+ assertFalse(restored.containsKey("searching"))
34
+ assertFalse(restored.containsKey("pending_device_name"))
35
+ }
36
+
37
+ @Test fun `explicit disconnect clears recovery across owner recreation`() {
38
+ recovery().save(settings())
39
+ recovery().clear()
40
+ assertNull(recovery().read())
41
+ }
42
+
43
+ @Test fun `forget or switching models cannot resurrect old G2 pairing`() {
44
+ recovery().save(settings())
45
+ recovery().save(settings() + ("device_name" to ""))
46
+ assertNull(recovery().read())
47
+ recovery().save(settings())
48
+ recovery().save(settings() + ("default_wearable" to DeviceTypes.G1))
49
+ assertNull(recovery().read())
50
+ }
51
+ }
@@ -0,0 +1,105 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import android.bluetooth.BluetoothAdapter
4
+ import android.bluetooth.BluetoothDevice
5
+ import android.bluetooth.BluetoothGatt
6
+ import android.bluetooth.BluetoothProfile
7
+ import android.bluetooth.le.ScanResult
8
+ import android.bluetooth.le.ScanSettings
9
+ import android.os.Looper
10
+ import androidx.test.core.app.ApplicationProvider
11
+ import com.mentra.bluetoothsdk.Bridge
12
+ import com.mentra.bluetoothsdk.DeviceStore
13
+ import com.mentra.lc3Lib.Lc3Cpp
14
+ import org.junit.Assert.assertEquals
15
+ import org.junit.Assert.assertTrue
16
+ import org.junit.Before
17
+ import org.junit.Test
18
+ import org.junit.runner.RunWith
19
+ import org.robolectric.RobolectricTestRunner
20
+ import org.robolectric.Shadows.shadowOf
21
+ import org.robolectric.annotation.Config
22
+ import org.robolectric.annotation.Implementation
23
+ import org.robolectric.annotation.Implements
24
+ import org.robolectric.annotation.LooperMode
25
+ import java.util.concurrent.TimeUnit
26
+
27
+ @RunWith(RobolectricTestRunner::class)
28
+ @Config(sdk = [33], shadows = [G1PairingTest.ShadowLc3Cpp::class], instrumentedPackages = ["com.mentra.lc3Lib"])
29
+ @LooperMode(LooperMode.Mode.PAUSED)
30
+ class G1PairingTest {
31
+ @Before
32
+ fun setup() {
33
+ Bridge.initialize(ApplicationProvider.getApplicationContext())
34
+ shadowOf(BluetoothAdapter.getDefaultAdapter()).setState(BluetoothAdapter.STATE_ON)
35
+ }
36
+
37
+ @Test
38
+ fun `fresh pairing connects the selected G1 without a saved identity`() {
39
+ assertSelectedGlassesConnect("")
40
+ }
41
+
42
+ @Test
43
+ fun `pairing from simulated glasses connects the selected G1`() {
44
+ assertSelectedGlassesConnect("Simulated Glasses")
45
+ }
46
+
47
+ @Test
48
+ fun `switching G1 pairs ignores the previously saved pair`() {
49
+ assertSelectedGlassesConnect("12")
50
+ }
51
+
52
+ @Test
53
+ fun `reconnecting the saved G1 still connects both halves`() {
54
+ assertSelectedGlassesConnect("47")
55
+ }
56
+
57
+ private fun assertSelectedGlassesConnect(savedName: String) {
58
+ DeviceStore.set("bluetooth", "device_name", savedName)
59
+ DeviceStore.set("bluetooth", "pending_device_name", "47")
60
+ val adapter = BluetoothAdapter.getDefaultAdapter()
61
+ val scanner = shadowOf(adapter.bluetoothLeScanner)
62
+ val glasses = G1()
63
+ try {
64
+ glasses.connectById("47")
65
+ val callback = scanner.scanCallbacks.single()
66
+ val unrelated = device("00:00:00:00:12:01", "Even G1_12_L_000001")
67
+ val left = device("00:00:00:00:47:01", "Even G1_47_L_000001")
68
+ val right = device("00:00:00:00:47:02", "Even G1_47_R_000002")
69
+
70
+ for (device in listOf(unrelated, left, right)) {
71
+ callback.onScanResult(
72
+ ScanSettings.CALLBACK_TYPE_ALL_MATCHES,
73
+ ScanResult(device, null, -50, 0),
74
+ )
75
+ }
76
+ shadowOf(Looper.getMainLooper()).idle()
77
+ assertTrue("The previously saved pair must not receive a GATT connection", shadowOf(unrelated).bluetoothGatts.isEmpty())
78
+ assertEquals("Connect the selected left half", 1, shadowOf(left).bluetoothGatts.size)
79
+ shadowOf(left).simulateGattConnectionChange(BluetoothGatt.GATT_SUCCESS, BluetoothProfile.STATE_CONNECTED)
80
+ shadowOf(Looper.getMainLooper()).idleFor(2, TimeUnit.SECONDS)
81
+ assertEquals("Connect the selected right half", 1, shadowOf(right).bluetoothGatts.size)
82
+ assertTrue("Stop scanning once both selected halves are found", scanner.scanCallbacks.isEmpty())
83
+ assertEquals("Scanning must not promote an unready pairing", savedName, DeviceStore.get("bluetooth", "device_name"))
84
+ } finally {
85
+ glasses.disconnect()
86
+ }
87
+ }
88
+
89
+ private fun device(address: String, name: String): BluetoothDevice =
90
+ BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address).also {
91
+ shadowOf(it).setName(name)
92
+ shadowOf(it).setAlias(name)
93
+ shadowOf(it).setBondState(BluetoothDevice.BOND_BONDED)
94
+ }
95
+
96
+ // Pairing tests exercise the real G1 driver; only its unrelated native audio codec is stubbed.
97
+ @Implements(Lc3Cpp::class)
98
+ class ShadowLc3Cpp {
99
+ companion object {
100
+ @JvmStatic @Implementation fun __staticInitializer__() {}
101
+ @JvmStatic @Implementation fun initDecoder(): Long = 1L
102
+ @JvmStatic @Implementation fun freeDecoder(decoderPtr: Long) {}
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,120 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import android.os.Looper
4
+ import android.bluetooth.BluetoothGatt
5
+ import android.bluetooth.BluetoothGattCharacteristic
6
+ import java.util.UUID
7
+ import java.util.concurrent.ExecutorService
8
+ import java.util.concurrent.TimeUnit
9
+ import org.junit.Assert.assertTrue
10
+ import org.robolectric.annotation.Implementation
11
+ import org.robolectric.annotation.Implements
12
+ import org.robolectric.shadow.api.Shadow
13
+ import com.mentra.bluetoothsdk.Bridge
14
+ import org.junit.Assert.assertEquals
15
+ import org.junit.Test
16
+ import org.junit.runner.RunWith
17
+ import org.robolectric.RobolectricTestRunner
18
+ import org.robolectric.RuntimeEnvironment
19
+ import org.robolectric.Shadows.shadowOf
20
+ import org.robolectric.annotation.Config
21
+ import org.robolectric.annotation.LooperMode
22
+
23
+ @RunWith(RobolectricTestRunner::class)
24
+ @Config(sdk = [28], shadows = [RecordingGatt::class])
25
+ @LooperMode(LooperMode.Mode.PAUSED)
26
+ class G2DisplayThreadTest {
27
+ @Test fun `worker clear cannot remove containers under the main-thread reconcile loop`() {
28
+ Bridge.initialize(RuntimeEnvironment.getApplication())
29
+ val g2 = G2()
30
+ val add = G2::class.java.declaredMethods.single { it.name == "addTextContainer" }
31
+ add.isAccessible = true
32
+ add.invoke(g2, 0, 0, 576, 288, "captions", 0, 0, 0, 0)
33
+ add.invoke(g2, 10, 10, 100, 40, "HUD", 0, 0, 0, 0)
34
+ val field = G2::class.java.getDeclaredField("textContainers").apply { isAccessible = true }
35
+ val containers = field.get(g2) as List<*>
36
+ // This is the heartbeat's captured index range at the crash site.
37
+ val indices = containers.indices
38
+ val worker = Thread { g2.clearDisplay() }
39
+ worker.start()
40
+ worker.join()
41
+ assertEquals(2, containers.size)
42
+ indices.forEach { containers[it] } // crashed here when clear ran on the worker
43
+ shadowOf(Looper.getMainLooper()).idle()
44
+ assertEquals(1, containers.size) // only the positioned HUD husk is purged
45
+ }
46
+
47
+ @Test fun `closing positioned captions clears the live page without waiting for new speech`() {
48
+ Bridge.initialize(RuntimeEnvironment.getApplication())
49
+ val g2 = G2()
50
+ val add = G2::class.java.declaredMethods.single { it.name == "addTextContainer" }
51
+ add.isAccessible = true
52
+ add.invoke(g2, 10, 10, 500, 100, "old captions", 0, 0, 0, 0)
53
+ val page = G2::class.java.getDeclaredField("pageCreated").apply { isAccessible = true }
54
+ page.setBoolean(g2, true)
55
+
56
+ g2.clearDisplay()
57
+ shadowOf(Looper.getMainLooper()).idle()
58
+
59
+ val field = G2::class.java.getDeclaredField("textContainers").apply { isAccessible = true }
60
+ assertEquals(0, (field.get(g2) as List<*>).size)
61
+ // No shutdown or deferred recreation: the replacement page is already live,
62
+ // even if the user remains silent and no new caption frame ever arrives.
63
+ assertEquals(true, page.getBoolean(g2))
64
+ g2.clearDisplay()
65
+ shadowOf(Looper.getMainLooper()).idle()
66
+ assertEquals(true, page.getBoolean(g2))
67
+ }
68
+
69
+
70
+ @Test fun `clear transmits an erase before dropping the old caption container and retries busy writes`() {
71
+ Bridge.initialize(RuntimeEnvironment.getApplication())
72
+ val g2 = G2()
73
+ val gatt = Shadow.newInstanceOf(BluetoothGatt::class.java)
74
+ val recorder = Shadow.extract<RecordingGatt>(gatt)
75
+ recorder.rejectWrites = 1
76
+ G2::class.java.getDeclaredField("rightGatt").apply { isAccessible = true }.set(g2, gatt)
77
+ G2::class.java.getDeclaredField("rightWriteChar").apply { isAccessible = true }.set(
78
+ g2, BluetoothGattCharacteristic(UUID.randomUUID(), 4, 16)
79
+ )
80
+ val add = G2::class.java.declaredMethods.single { it.name == "addTextContainer" }
81
+ add.isAccessible = true
82
+ add.invoke(g2, 10, 10, 500, 100, "old captions", 0, 0, 0, 0)
83
+ G2::class.java.getDeclaredField("pageCreated").apply { isAccessible = true }.setBoolean(g2, true)
84
+
85
+ g2.clearDisplay()
86
+ shadowOf(Looper.getMainLooper()).idle()
87
+ val executor = G2::class.java.getDeclaredField("bleWriteExecutor").apply { isAccessible = true }
88
+ .get(g2) as ExecutorService
89
+ executor.submit {}.get(5, TimeUnit.SECONDS)
90
+
91
+ // Transport header is eight bytes; protobuf field 1 is the EvenHub command.
92
+ val commands = recorder.accepted.map { it[9].toInt() }
93
+ assertEquals(5, commands.first()) // UPDATE_TEXT_DATA, before REBUILD_PAGE (7)
94
+ assertTrue(commands.indexOf(7) > 0)
95
+ assertTrue(String(recorder.accepted.first(), Charsets.UTF_8).contains(" ".repeat(12)))
96
+ assertTrue(recorder.attempted[0].contentEquals(recorder.attempted[1]))
97
+ assertEquals(recorder.accepted.size + 1, recorder.attempted.size)
98
+ }
99
+
100
+ }
101
+
102
+
103
+ @Implements(BluetoothGatt::class)
104
+ class RecordingGatt {
105
+ var rejectWrites = 0
106
+ val attempted = mutableListOf<ByteArray>()
107
+ val accepted = mutableListOf<ByteArray>()
108
+
109
+ @Implementation
110
+ fun writeCharacteristic(characteristic: BluetoothGattCharacteristic): Boolean {
111
+ val packet = characteristic.value.copyOf()
112
+ attempted.add(packet)
113
+ if (rejectWrites > 0) {
114
+ rejectWrites--
115
+ return false
116
+ }
117
+ accepted.add(packet)
118
+ return true
119
+ }
120
+ }
@@ -0,0 +1,90 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import android.bluetooth.BluetoothAdapter
4
+ import android.bluetooth.BluetoothGatt
5
+ import android.bluetooth.BluetoothProfile
6
+ import android.bluetooth.le.ScanRecord
7
+ import android.bluetooth.le.ScanResult
8
+ import android.bluetooth.le.ScanSettings
9
+ import android.content.Context
10
+ import android.os.Looper
11
+ import com.mentra.bluetoothsdk.Bridge
12
+ import org.junit.Assert.assertEquals
13
+ import org.junit.Assert.assertTrue
14
+ import org.junit.Before
15
+ import org.junit.Test
16
+ import org.junit.runner.RunWith
17
+ import org.robolectric.RobolectricTestRunner
18
+ import org.robolectric.RuntimeEnvironment
19
+ import org.robolectric.Shadows.shadowOf
20
+ import org.robolectric.annotation.Config
21
+ import org.robolectric.annotation.LooperMode
22
+ import org.robolectric.util.ReflectionHelpers
23
+ import org.robolectric.util.ReflectionHelpers.ClassParameter
24
+
25
+ @RunWith(RobolectricTestRunner::class)
26
+ @Config(sdk = [33])
27
+ @LooperMode(LooperMode.Mode.PAUSED)
28
+ class G2PairingTargetTest {
29
+ @Before fun setup() {
30
+ Bridge.initialize(RuntimeEnvironment.getApplication())
31
+ shadowOf(BluetoothAdapter.getDefaultAdapter()).setState(BluetoothAdapter.STATE_ON)
32
+ }
33
+
34
+ @Test fun `unscoped legacy addresses cannot connect a different G2 pair`() {
35
+ RuntimeEnvironment.getApplication().getSharedPreferences("G2Prefs", Context.MODE_PRIVATE).edit()
36
+ .putString("g2_leftGlassAddress", "00:00:00:00:01:01")
37
+ .putString("g2_rightGlassAddress", "00:00:00:00:01:02").apply()
38
+ val g2 = G2()
39
+ try {
40
+ g2.connectById("S2NEW000000001")
41
+ val adapter = BluetoothAdapter.getDefaultAdapter()
42
+ assertTrue(shadowOf(adapter.getRemoteDevice("00:00:00:00:01:01")).bluetoothGatts.isEmpty())
43
+ assertTrue(shadowOf(adapter.getRemoteDevice("00:00:00:00:01:02")).bluetoothGatts.isEmpty())
44
+ assertEquals(1, shadowOf(adapter.bluetoothLeScanner).scanCallbacks.size)
45
+ } finally { g2.disconnect() }
46
+ }
47
+
48
+ @Test fun `learned addresses reconnect only their own serial across driver recreation`() {
49
+ val serial = "S2NEW000000001"
50
+ val adapter = BluetoothAdapter.getDefaultAdapter()
51
+ val left = adapter.getRemoteDevice("00:00:00:00:02:01")
52
+ val right = adapter.getRemoteDevice("00:00:00:00:02:02")
53
+ shadowOf(left).setName("Even G2_47_L_000001")
54
+ shadowOf(right).setName("Even G2_47_R_000002")
55
+ var g2 = G2()
56
+ try {
57
+ g2.connectById(serial)
58
+ val callback = shadowOf(adapter.bluetoothLeScanner).scanCallbacks.single()
59
+ // Manufacturer record: company ID 0x4552, followed by the advertised serial.
60
+ val payload = byteArrayOf(0x52, 0x45) + serial.toByteArray(Charsets.US_ASCII)
61
+ val record = ReflectionHelpers.callStaticMethod<ScanRecord>(
62
+ ScanRecord::class.java, "parseFromBytes",
63
+ ClassParameter.from(ByteArray::class.java, byteArrayOf((payload.size + 1).toByte(), 0xff.toByte()) + payload + byteArrayOf(0)),
64
+ )
65
+ for (device in listOf(left, right)) {
66
+ callback.onScanResult(ScanSettings.CALLBACK_TYPE_ALL_MATCHES, ScanResult(device, record, -50, 0))
67
+ }
68
+ shadowOf(Looper.getMainLooper()).idle()
69
+ for (device in listOf(left, right)) {
70
+ assertEquals(1, shadowOf(device).bluetoothGatts.size)
71
+ shadowOf(device).simulateGattConnectionChange(BluetoothGatt.GATT_SUCCESS, BluetoothProfile.STATE_CONNECTED)
72
+ }
73
+ shadowOf(Looper.getMainLooper()).idle()
74
+ g2.disconnect()
75
+
76
+ g2 = G2()
77
+ g2.connectById("S2OTHER0000001")
78
+ assertEquals(1, shadowOf(left).bluetoothGatts.size)
79
+ assertEquals(1, shadowOf(right).bluetoothGatts.size)
80
+ assertEquals(1, shadowOf(adapter.bluetoothLeScanner).scanCallbacks.size)
81
+ g2.disconnect()
82
+
83
+ g2 = G2()
84
+ g2.connectById(serial)
85
+ assertEquals(2, shadowOf(left).bluetoothGatts.size)
86
+ assertEquals(2, shadowOf(right).bluetoothGatts.size)
87
+ assertTrue(shadowOf(adapter.bluetoothLeScanner).scanCallbacks.isEmpty())
88
+ } finally { g2.disconnect() }
89
+ }
90
+ }
@@ -0,0 +1,55 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import org.junit.Assert.*
4
+ import org.junit.Test
5
+ import org.junit.runner.RunWith
6
+ import org.robolectric.RobolectricTestRunner
7
+ import org.robolectric.annotation.Config
8
+
9
+ @RunWith(RobolectricTestRunner::class)
10
+ @Config(sdk = [33])
11
+ class MentraLiveFileTransferSessionTest {
12
+ @Test fun dynamicPayloadUsesTrueSizeEvenWhenDivisibleBy400() {
13
+ for (pack in listOf(244, 474, 800)) {
14
+ val session = MentraLive.FileTransferSession("photo", 80000, 2)
15
+ session.recalculateTotalPackets(pack)
16
+ assertEquals((80000 + pack - 1) / pack, session.totalPackets)
17
+ for (index in 0 until session.totalPackets) {
18
+ assertTrue(session.addPacket(index, ByteArray(minOf(pack, 80000 - index * pack))))
19
+ }
20
+ assertTrue(session.isComplete)
21
+ assertEquals(80000, session.assembleFile()!!.size)
22
+ }
23
+ }
24
+
25
+ @Test fun legacyInflatedSizeAndLargeUnflaggedPayloads() {
26
+ // Flag 1 enables batched ACKs but still uses legacy size semantics.
27
+ for ((pack, headerSize, actualSize, packets) in listOf(
28
+ listOf(221, 80000, 43982, 200),
29
+ listOf(400, 80000, 79777, 200),
30
+ listOf(800, 80000, 80000, 100))) {
31
+ val session = MentraLive.FileTransferSession("photo", headerSize, 1)
32
+ session.recalculateTotalPackets(pack)
33
+ assertEquals(packets, session.totalPackets)
34
+ val source = ByteArray(actualSize) { (it % 251).toByte() }
35
+ for (index in (0 until packets).reversed()) {
36
+ val start = index * pack
37
+ assertTrue(session.addPacket(index, source.copyOfRange(start, minOf(start + pack, actualSize))))
38
+ }
39
+ assertTrue(session.isComplete)
40
+ assertArrayEquals(source, session.assembleFile())
41
+ }
42
+ }
43
+
44
+ @Test fun malformedOrDuplicatePacketsCannotCompleteFile() {
45
+ val session = MentraLive.FileTransferSession("photo", 1000, 2)
46
+ session.recalculateTotalPackets(800)
47
+ assertFalse(session.addPacket(0, ByteArray(799)))
48
+ assertFalse(session.addPacket(2, ByteArray(200)))
49
+ assertTrue(session.addPacket(1, ByteArray(200)))
50
+ assertFalse(session.isComplete)
51
+ assertFalse(session.addPacket(1, ByteArray(200)))
52
+ assertTrue(session.addPacket(0, ByteArray(800)))
53
+ assertEquals(1000, session.assembleFile()!!.size)
54
+ }
55
+ }
@@ -78,6 +78,39 @@ class MentraLiveGattCallbackTest {
78
78
  assertArrayEquals(byteArrayOf(3, 2), callback.packets[1])
79
79
  }
80
80
 
81
+ @Test
82
+ @Config(sdk = [33])
83
+ @Suppress("DEPRECATION")
84
+ fun `modern and legacy callbacks for one notification deliver one packet`() {
85
+ val queue = ArrayDeque<() -> Unit>()
86
+ val callback = RecordingCallback({ work -> queue.addLast(work) })
87
+ val gatt = gatt()
88
+ val characteristic = BluetoothGattCharacteristic(UUID.randomUUID(), 0, 0)
89
+ val value = byteArrayOf(0xf1.toByte(), 7, 1, 2)
90
+ characteristic.value = value
91
+ callback.onCharacteristicChanged(gatt, characteristic, value)
92
+ callback.onCharacteristicChanged(gatt, characteristic)
93
+ value[2] = 9
94
+ while (queue.isNotEmpty()) queue.removeFirst().invoke()
95
+ assertEquals(1, callback.packets.size)
96
+ assertArrayEquals(byteArrayOf(0xf1.toByte(), 7, 1, 2), callback.packets.single())
97
+ }
98
+
99
+ @Test
100
+ @Config(sdk = [33])
101
+ fun `separate modern notifications retain identical payloads`() {
102
+ val queue = ArrayDeque<() -> Unit>()
103
+ val callback = RecordingCallback({ work -> queue.addLast(work) })
104
+ val gatt = gatt()
105
+ val characteristic = BluetoothGattCharacteristic(UUID.randomUUID(), 0, 0)
106
+ val value = byteArrayOf(1, 2)
107
+ repeat(2) { callback.onCharacteristicChanged(gatt, characteristic, value) }
108
+ value[0] = 9
109
+ while (queue.isNotEmpty()) queue.removeFirst().invoke()
110
+ assertEquals(2, callback.packets.size)
111
+ callback.packets.forEach { assertArrayEquals(byteArrayOf(1, 2), it) }
112
+ }
113
+
81
114
  @Test
82
115
  fun `replacement invalidates callbacks already waiting on the lifecycle queue`() {
83
116
  val queue = ArrayDeque<() -> Unit>()
@@ -0,0 +1,65 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import android.bluetooth.BluetoothAdapter
4
+ import android.os.Looper
5
+ import androidx.test.core.app.ApplicationProvider
6
+ import com.mentra.bluetoothsdk.Bridge
7
+ import com.mentra.bluetoothsdk.DeviceStore
8
+ import java.time.Duration
9
+ import org.junit.Assert.assertEquals
10
+ import org.junit.Assert.assertTrue
11
+ import org.junit.Before
12
+ import org.junit.Test
13
+ import org.junit.runner.RunWith
14
+ import org.robolectric.RobolectricTestRunner
15
+ import org.robolectric.Shadows.shadowOf
16
+ import org.robolectric.annotation.Config
17
+ import org.robolectric.annotation.LooperMode
18
+
19
+ @RunWith(RobolectricTestRunner::class)
20
+ @Config(sdk = [33], shadows = [G1PairingTest.ShadowLc3Cpp::class], instrumentedPackages = ["com.mentra.lc3Lib"])
21
+ @LooperMode(LooperMode.Mode.PAUSED)
22
+ class MentraLivePairingTargetTest {
23
+ @Before fun setup() {
24
+ Bridge.initialize(ApplicationProvider.getApplicationContext())
25
+ shadowOf(BluetoothAdapter.getDefaultAdapter()).setState(BluetoothAdapter.STATE_ON)
26
+ DeviceStore.set("bluetooth", "pending_device_name", "MENTRA_LIVE_NEW")
27
+ }
28
+
29
+ @Test fun `name-only selection scans instead of connecting the previous Live`() {
30
+ assertTarget("MENTRA_LIVE_OLD", "00:00:00:00:00:01", "", scanExpected = true)
31
+ }
32
+
33
+ @Test fun `normal pairing connects the pending Live address`() {
34
+ assertTarget("MENTRA_LIVE_OLD", "00:00:00:00:00:01", "00:00:00:00:00:02")
35
+ }
36
+
37
+ @Test fun `normal saved Live reconnect keeps the matching address`() {
38
+ assertTarget("MENTRA_LIVE_NEW", "00:00:00:00:00:02", "")
39
+ }
40
+
41
+ private fun assertTarget(savedName: String, savedAddress: String, pendingAddress: String, scanExpected: Boolean = false) {
42
+ DeviceStore.set("bluetooth", "device_name", savedName)
43
+ DeviceStore.set("bluetooth", "device_address", savedAddress)
44
+ DeviceStore.set("bluetooth", "pending_device_address", pendingAddress)
45
+ val adapter = BluetoothAdapter.getDefaultAdapter()
46
+ val old = adapter.getRemoteDevice("00:00:00:00:00:01")
47
+ val selected = adapter.getRemoteDevice("00:00:00:00:00:02")
48
+ shadowOf(old).setName("MENTRA_LIVE_OLD")
49
+ shadowOf(selected).setName("MENTRA_LIVE_NEW")
50
+ val live = MentraLive()
51
+ try {
52
+ live.connectById("MENTRA_LIVE_NEW")
53
+ shadowOf(Looper.getMainLooper()).idle()
54
+ assertTrue(shadowOf(old).bluetoothGatts.isEmpty())
55
+ assertEquals(if (scanExpected) 0 else 1, shadowOf(selected).bluetoothGatts.size)
56
+ assertEquals(if (scanExpected) 1 else 0, shadowOf(adapter.bluetoothLeScanner).scanCallbacks.size)
57
+ assertEquals(savedName, DeviceStore.get("bluetooth", "device_name"))
58
+ } finally {
59
+ live.destroy()
60
+ // Robolectric does not deliver the disconnect callback automatically.
61
+ // Complete the production teardown timeout before another test connects.
62
+ shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(2))
63
+ }
64
+ }
65
+ }