@mentra/bluetooth-sdk 3.3.0-dev.382 → 3.3.0-dev.391

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 (30) hide show
  1. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +7 -3
  2. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  3. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
  4. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +8 -7
  5. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +15 -10
  6. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -8
  7. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddress.kt +17 -0
  8. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceIdentityPairingTest.kt +59 -0
  9. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1PairingTest.kt +105 -0
  10. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2PairingTargetTest.kt +90 -0
  11. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingTargetTest.kt +65 -0
  12. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraNexPairingTest.kt +151 -0
  13. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddressTest.kt +27 -0
  14. package/build/generated/releaseMetadata.js +5 -5
  15. package/build/generated/releaseMetadata.js.map +1 -1
  16. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  17. package/ios/Source/DeviceManager.swift +7 -3
  18. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  19. package/ios/Source/sgcs/G1.swift +21 -6
  20. package/ios/Source/sgcs/G1ConnectionTarget.swift +13 -0
  21. package/ios/Source/sgcs/Mach1.swift +19 -7
  22. package/ios/Source/sgcs/MentraLive.swift +5 -2
  23. package/ios/Source/sgcs/MentraNex.swift +8 -31
  24. package/ios/Source/sgcs/NexConnectionTarget.swift +15 -0
  25. package/ios/Source/sgcs/VuzixConnectionTarget.swift +15 -0
  26. package/ios/Tests/DeviceIdentityPairingTests.swift +49 -0
  27. package/ios/Tests/SGCConnectionTargetTests.swift +48 -0
  28. package/ios/Tests/VersionInfoCommandTests.swift +53 -0
  29. package/package.json +2 -2
  30. package/src/generated/releaseMetadata.ts +5 -5
@@ -1501,12 +1501,16 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
1501
1501
  return
1502
1502
  }
1503
1503
 
1504
+ // A new identity must never inherit the previous glasses' address when
1505
+ // pairing supplied only a name. Same-device reconnects retain their cache.
1506
+ val identityChanged = pendingDeviceName.isNotEmpty() &&
1507
+ (pendingDeviceName != deviceName || sgc?.type != defaultWearable)
1508
+ if (identityChanged || pendingDeviceAddress.isNotEmpty()) {
1509
+ deviceAddress = pendingDeviceAddress
1510
+ }
1504
1511
  if (pendingDeviceName.isNotEmpty()) {
1505
1512
  deviceName = pendingDeviceName
1506
1513
  }
1507
- if (pendingDeviceAddress.isNotEmpty()) {
1508
- deviceAddress = pendingDeviceAddress
1509
- }
1510
1514
  clearPendingConnection()
1511
1515
 
1512
1516
  val readyKey = "${sgc?.type}:${deviceName}"
@@ -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.3.0"
6
- const val RELEASE_IDENTITY: String = "3.3.0-dev.382"
7
- const val RELEASE_SET_ID: String = "mentra-3.3.0-dev.382"
8
- const val SOURCE_COMMIT: String = "6a757abb267592562702b3320cdbb3281e90a59e"
9
- const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.382.json"
10
- const val OTA_MANIFEST_SHA256: String = "7d807fb6eea2ed3b6e0fe915decfe79524504752c1c7d8f48b24a3c1e5d56d31"
6
+ const val RELEASE_IDENTITY: String = "3.3.0-dev.391"
7
+ const val RELEASE_SET_ID: String = "mentra-3.3.0-dev.391"
8
+ const val SOURCE_COMMIT: String = "2763761a9cff7660e63412e9d006b4e20033f90b"
9
+ const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.391.json"
10
+ const val OTA_MANIFEST_SHA256: String = "c079797bc98050c666d513a4960fd41a6fb5dba5ea809f876954633ae08621fa"
11
11
  }
@@ -1370,8 +1370,8 @@ class G1 : SGCManager() {
1370
1370
  context!!.registerReceiver(bondingReceiver, filter)
1371
1371
  isBondingReceiverRegistered = true
1372
1372
 
1373
- preferredG1DeviceId = DeviceStore.get("bluetooth", "device_name") as String?
1374
-
1373
+ // Keep the target supplied by connectById. device_name still belongs to
1374
+ // the previous pairing until DeviceManager promotes the ready device.
1375
1375
  if (!bluetoothAdapter!!.isEnabled()) {
1376
1376
  return
1377
1377
  }
@@ -1364,17 +1364,18 @@ class G2 : SGCManager() {
1364
1364
  // Map device names to serial numbers (populated from manufacturer data during scan)
1365
1365
  private val deviceNameToSerialNumber = mutableMapOf<String, String>()
1366
1366
 
1367
- // Saved addresses for reconnection
1367
+ // Scope each side's address to the selected serial. Legacy global addresses have
1368
+ // no trustworthy owner, so the first connection after upgrading learns them by scan.
1368
1369
  private var leftGlassAddress: String?
1369
1370
  get() =
1370
1371
  context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
1371
- .getString(KEY_LEFT_ADDRESS, null)
1372
+ .getString("$KEY_LEFT_ADDRESS:$DEVICE_SEARCH_ID", null)
1372
1373
  set(value) {
1373
1374
  context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
1374
1375
  .edit()
1375
1376
  .apply {
1376
- if (value != null) putString(KEY_LEFT_ADDRESS, value)
1377
- else remove(KEY_LEFT_ADDRESS)
1377
+ if (value != null) putString("$KEY_LEFT_ADDRESS:$DEVICE_SEARCH_ID", value)
1378
+ else remove("$KEY_LEFT_ADDRESS:$DEVICE_SEARCH_ID")
1378
1379
  }
1379
1380
  .apply()
1380
1381
  }
@@ -1382,13 +1383,13 @@ class G2 : SGCManager() {
1382
1383
  private var rightGlassAddress: String?
1383
1384
  get() =
1384
1385
  context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
1385
- .getString(KEY_RIGHT_ADDRESS, null)
1386
+ .getString("$KEY_RIGHT_ADDRESS:$DEVICE_SEARCH_ID", null)
1386
1387
  set(value) {
1387
1388
  context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
1388
1389
  .edit()
1389
1390
  .apply {
1390
- if (value != null) putString(KEY_RIGHT_ADDRESS, value)
1391
- else remove(KEY_RIGHT_ADDRESS)
1391
+ if (value != null) putString("$KEY_RIGHT_ADDRESS:$DEVICE_SEARCH_ID", value)
1392
+ else remove("$KEY_RIGHT_ADDRESS:$DEVICE_SEARCH_ID")
1392
1393
  }
1393
1394
  .apply()
1394
1395
  }
@@ -2148,7 +2148,7 @@ class MentraLive : SGCManager() {
2148
2148
  // than scanning).
2149
2149
  // Falls back to name-based scan if no address is saved.
2150
2150
  val lastDeviceAddress =
2151
- DeviceStore.get("bluetooth", "device_address") as String?
2151
+ selectedConnectionAddress()
2152
2152
  if (lastDeviceAddress != null &&
2153
2153
  !lastDeviceAddress.isEmpty() &&
2154
2154
  bluetoothAdapter != null
@@ -4138,7 +4138,9 @@ class MentraLive : SGCManager() {
4138
4138
  "controllerId" to json.opt("controllerId"), "streamId" to json.opt("streamId"),
4139
4139
  "probeId" to json.opt("probeId"))
4140
4140
  com.mentra.bluetoothsdk.streaming.StreamControllerProbe.response(values)?.let {
4141
- sendJson(JSONObject(it))
4141
+ // BES buffers non-waking commands when MTK enters standby, even while
4142
+ // its streaming CPU lease is held. The current probe needs a live reply.
4143
+ sendJson(JSONObject(it), true)
4142
4144
  }
4143
4145
  }
4144
4146
  "pong" ->
@@ -6161,7 +6163,8 @@ class MentraLive : SGCManager() {
6161
6163
  val json = JSONObject()
6162
6164
  json.put("type", "request_version")
6163
6165
  requestId?.let { json.put("request_id", it) }
6164
- sendJson(json, false)
6166
+ // Wake ASG so the version request and its response can finish after idle.
6167
+ sendJson(json, true)
6165
6168
  Bridge.log("LIVE: 📱 Requesting version info from glasses")
6166
6169
  } catch (e: JSONException) {
6167
6170
  Log.e(TAG, "📱 Error creating request_version command", e)
@@ -6623,6 +6626,14 @@ class MentraLive : SGCManager() {
6623
6626
  return false
6624
6627
  }
6625
6628
 
6629
+ private fun selectedConnectionAddress(): String? = SelectedDeviceAddress.resolve(
6630
+ savedDeviceName,
6631
+ DeviceStore.get("bluetooth", "pending_device_name") as? String,
6632
+ DeviceStore.get("bluetooth", "pending_device_address") as? String,
6633
+ DeviceStore.get("bluetooth", "device_name") as? String,
6634
+ DeviceStore.get("bluetooth", "device_address") as? String,
6635
+ )
6636
+
6626
6637
  fun connectToSmartGlasses() {
6627
6638
  if (postGattLifecycle { connectToSmartGlasses() }) return
6628
6639
  if (pairingYieldActive) {
@@ -6657,13 +6668,7 @@ class MentraLive : SGCManager() {
6657
6668
  // var context = Bridge.getContext();
6658
6669
  // SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
6659
6670
  // String lastDeviceAddress = prefs.getString(PREF_DEVICE_NAME, null);
6660
- val pendingAddress =
6661
- (DeviceStore.get("bluetooth", "pending_device_address") as String?)?.takeIf {
6662
- it.isNotEmpty()
6663
- }
6664
- val lastDeviceAddress =
6665
- pendingAddress
6666
- ?: (DeviceStore.get("bluetooth", "device_address") as String?)
6671
+ val lastDeviceAddress = selectedConnectionAddress()
6667
6672
 
6668
6673
  if (lastDeviceAddress != null && lastDeviceAddress.length > 0) {
6669
6674
  // Connect to last known device if available
@@ -208,7 +208,7 @@ class MentraNex : SGCManager() {
208
208
 
209
209
  // If we already have saved device names for main...
210
210
  if (name != null && preferredMainDeviceId != null) {
211
- if (!name.contains(preferredMainDeviceId!!)) {
211
+ if (name != preferredMainDeviceId) {
212
212
  return // Not a matching device
213
213
  }
214
214
  }
@@ -230,7 +230,6 @@ class MentraNex : SGCManager() {
230
230
  type = DeviceTypes.NEX
231
231
  hasMic = true
232
232
  DeviceStore.apply("glasses", "micEnabled", false)
233
- preferredMainDeviceId = DeviceManager.getInstance().deviceName
234
233
 
235
234
  // Initialize LC3 audio player
236
235
  lc3AudioPlayer = Lc3Player(context)
@@ -1315,23 +1314,28 @@ class MentraNex : SGCManager() {
1315
1314
  }
1316
1315
 
1317
1316
  private fun connectToSmartGlasses() {
1318
- val deviceModelName = DeviceManager.getInstance().deviceName
1319
- val deviceAddress = DeviceManager.getInstance().deviceAddress
1317
+ val target = preferredMainDeviceId?.takeIf { it.isNotBlank() } ?: return
1318
+ val deviceAddress = SelectedDeviceAddress.resolve(
1319
+ target,
1320
+ DeviceStore.get("bluetooth", "pending_device_name") as? String,
1321
+ DeviceStore.get("bluetooth", "pending_device_address") as? String,
1322
+ DeviceStore.get("bluetooth", "device_name") as? String,
1323
+ DeviceStore.get("bluetooth", "device_address") as? String,
1324
+ )
1320
1325
 
1321
1326
  // Register bonding receiver
1322
1327
  Bridge.log("connectToSmartGlasses start")
1323
- Bridge.log("try to ConnectToSmartGlassesing deviceModelName: ${deviceModelName} deviceAddress: ${deviceAddress}")
1324
- preferredMainDeviceId = DeviceManager.getInstance().deviceName
1328
+ Bridge.log("Connecting to selected Nex: $target address: $deviceAddress")
1325
1329
  if (!bluetoothAdapter.isEnabled) {
1326
1330
  return
1327
1331
  }
1328
1332
  when {
1329
- !deviceModelName.isNullOrEmpty() && !deviceAddress.isNullOrEmpty() -> {
1333
+ deviceAddress != null -> {
1330
1334
  stopScan()
1331
1335
  mainDevice = bluetoothAdapter.getRemoteDevice(deviceAddress)
1332
1336
  mainTaskHandler?.sendEmptyMessageDelayed(MAIN_TASK_HANDLER_CODE_RECONNECT_DEVICE, 0)
1333
1337
  }
1334
- savedNexMainAddress != null -> {
1338
+ savedNexMainName == target && savedNexMainAddress != null -> {
1335
1339
  mainDevice = bluetoothAdapter.getRemoteDevice(savedNexMainAddress)
1336
1340
  mainTaskHandler?.sendEmptyMessageDelayed(MAIN_TASK_HANDLER_CODE_RECONNECT_DEVICE, 0)
1337
1341
  }
@@ -0,0 +1,17 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ /** An address is usable only when its accompanying name identifies the requested device. */
4
+ internal object SelectedDeviceAddress {
5
+ fun resolve(
6
+ target: String,
7
+ pendingName: String?,
8
+ pendingAddress: String?,
9
+ savedName: String?,
10
+ savedAddress: String?,
11
+ ): String? {
12
+ if (target.isBlank()) return null
13
+ if (pendingName == target && !pendingAddress.isNullOrBlank()) return pendingAddress
14
+ if (savedName == target && !savedAddress.isNullOrBlank()) return savedAddress
15
+ return null
16
+ }
17
+ }
@@ -0,0 +1,59 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import androidx.test.core.app.ApplicationProvider
4
+ import com.mentra.bluetoothsdk.sgcs.Simulated
5
+ import org.junit.Assert.assertEquals
6
+ import org.junit.Test
7
+ import org.junit.runner.RunWith
8
+ import org.robolectric.RobolectricTestRunner
9
+ import org.robolectric.annotation.Config
10
+ import org.robolectric.annotation.LooperMode
11
+
12
+ @RunWith(RobolectricTestRunner::class)
13
+ @Config(sdk = [33])
14
+ @LooperMode(LooperMode.Mode.PAUSED)
15
+ class DeviceIdentityPairingTest {
16
+ @Test fun `readiness promotes a coherent name and address while preserving same-device reconnects`() {
17
+ Bridge.initialize(ApplicationProvider.getApplicationContext())
18
+ val saved = listOf("bluetooth", "glasses").associateWith { DeviceStore.store.getCategory(it) }
19
+ val singleton = DeviceManager::class.java.getDeclaredField("_instance").apply { isAccessible = true }
20
+ val previous = singleton.get(null)
21
+ val cases = listOf(
22
+ // pending name, pending address, saved model, expected address
23
+ listOf("new", "", "test", ""),
24
+ listOf("new", "new-address", "test", "new-address"),
25
+ listOf("old", "", "test", "old-address"),
26
+ listOf("", "", "test", "old-address"),
27
+ listOf("old", "", "other-model", ""),
28
+ )
29
+ try {
30
+ for ((pendingName, pendingAddress, savedModel, expectedAddress) in cases) {
31
+ val manager = DeviceManager(initializeHardware = false)
32
+ singleton.set(null, manager)
33
+ manager.sgc = Simulated().apply { type = "test" }
34
+ DeviceStore.set("bluetooth", "default_wearable", savedModel)
35
+ DeviceStore.set("bluetooth", "device_name", "old")
36
+ DeviceStore.set("bluetooth", "device_address", "old-address")
37
+ DeviceStore.set("bluetooth", "pending_device_name", pendingName)
38
+ DeviceStore.set("bluetooth", "pending_device_address", pendingAddress)
39
+ DeviceStore.set("bluetooth", "shouldSendBootingMessage", false)
40
+ try {
41
+ manager.handleDeviceReady()
42
+ assertEquals(pendingName.ifEmpty { "old" }, DeviceStore.get("bluetooth", "device_name"))
43
+ assertEquals(expectedAddress, DeviceStore.get("bluetooth", "device_address"))
44
+ assertEquals("", DeviceStore.get("bluetooth", "pending_device_name"))
45
+ assertEquals("", DeviceStore.get("bluetooth", "pending_device_address"))
46
+ } finally {
47
+ manager.disconnect()
48
+ manager.cleanup()
49
+ }
50
+ }
51
+ } finally {
52
+ singleton.set(null, previous)
53
+ saved.forEach { (category, values) ->
54
+ (DeviceStore.store.getCategory(category).keys - values.keys).forEach { DeviceStore.store.remove(category, it) }
55
+ values.forEach { (key, value) -> DeviceStore.set(category, key, value) }
56
+ }
57
+ }
58
+ }
59
+ }
@@ -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,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,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
+ }