@mentra/bluetooth-sdk 3.2.1-beta.379 → 3.2.1-beta.392
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/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +7 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +2 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +8 -7
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +10 -8
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -8
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddress.kt +17 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/DeviceIdentityPairingTest.kt +59 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1PairingTest.kt +105 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2PairingTargetTest.kt +90 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingTargetTest.kt +65 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraNexPairingTest.kt +151 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddressTest.kt +27 -0
- package/build/generated/changelogCatalog.d.ts +2 -2
- package/build/generated/changelogCatalog.js +2 -2
- 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/DeviceManager.swift +7 -3
- package/ios/Source/GeneratedChangelogCatalog.swift +2 -2
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/sgcs/G1.swift +21 -6
- package/ios/Source/sgcs/G1ConnectionTarget.swift +13 -0
- package/ios/Source/sgcs/Mach1.swift +19 -7
- package/ios/Source/sgcs/MentraNex.swift +8 -31
- package/ios/Source/sgcs/NexConnectionTarget.swift +15 -0
- package/ios/Source/sgcs/VuzixConnectionTarget.swift +15 -0
- package/ios/Tests/DeviceIdentityPairingTests.swift +49 -0
- package/ios/Tests/SGCConnectionTargetTests.swift +48 -0
- package/package.json +2 -2
- package/src/generated/changelogCatalog.ts +2 -2
- 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,8 +3,8 @@ package com.mentra.bluetoothsdk
|
|
|
3
3
|
/** Generated from /changelogs. Do not edit directly. */
|
|
4
4
|
internal val GENERATED_RELEASE_CHANGELOGS: List<ReleaseChangelog> =
|
|
5
5
|
listOf(
|
|
6
|
-
ReleaseChangelog(version = "3.2.1", markdown = "
|
|
7
|
-
ReleaseChangelog(version = "3.2.0", markdown = "
|
|
6
|
+
ReleaseChangelog(version = "3.2.1", markdown = "- Mentra Miniapp SDK: `session.stream.preview({source: \"call\"})` and the `<StreamPreview>` component (from `@mentra/miniapp/react`) show the live video of a call your miniapp owns inside its UI. Requires the `CAMERA` permission; the lease survives the UI closing and ends with `handle.stop()` or the meeting. See [Stream preview](https://docs.mentraglass.com/app-devs/core-concepts/stream-preview)."),
|
|
7
|
+
ReleaseChangelog(version = "3.2.0", markdown = "- 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`."),
|
|
8
8
|
ReleaseChangelog(version = "3.1.1", markdown = "- Improved Google login on iOS.\n- Pairing no longer requires phone microphone access."),
|
|
9
9
|
ReleaseChangelog(version = "3.1.0", markdown = "**Mentra App and glasses**\n\n- Improved glasses pairing and reconnection, including on iPad.\n- Improved Android background reliability for captions and miniapps.\n- Added Xingyi AR99 support.\n- More reliable photo and video capture, with improved shutter sounds and privacy-light timing. Taking photos no longer interrupts your running miniapp.\n- Added video thumbnails and smoother gallery browsing.\n- Easier Mentra Live updates through your phone, with clearer progress and better recovery from interruptions.\n\n**Miniapp updates**\n\n- **Mentra AI:** Control whether photos are included with questions. Improved visual answers and connected-app follow-ups.\n- **Captions:** Added offline transcription settings and top/bottom positioning on supported displays.\n- **Translation:** Restored all 60 languages.\n- **Mentra Maps:** Clearer directions on G1/G2, less display flickering, and improved Android offline routing.\n- **Livestreamer:** More video-quality controls and custom streaming options, plus improved startup and sharing.\n- **Merge:** Tap to expand an insight; swipe down or back to dismiss it.\n- **Mentra Notes:** Smoother back navigation.\n- **Recorder:** Fixed audio sharing."),
|
|
10
10
|
)
|
|
@@ -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.1"
|
|
6
|
-
const val RELEASE_IDENTITY: String = "3.2.1-beta.
|
|
7
|
-
const val RELEASE_SET_ID: String = "mentra-3.2.1-beta.
|
|
8
|
-
const val SOURCE_COMMIT: String = "
|
|
9
|
-
const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.
|
|
10
|
-
const val OTA_MANIFEST_SHA256: String = "
|
|
6
|
+
const val RELEASE_IDENTITY: String = "3.2.1-beta.392"
|
|
7
|
+
const val RELEASE_SET_ID: String = "mentra-3.2.1-beta.392"
|
|
8
|
+
const val SOURCE_COMMIT: String = "bbae280bb8e4bc806c5a48132da53e1142dacad9"
|
|
9
|
+
const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.392.json"
|
|
10
|
+
const val OTA_MANIFEST_SHA256: String = "054af4bc31556c705a80e3dd8a0af77383d7af984c2f6fd236c471c4b1d001ff"
|
|
11
11
|
}
|
|
@@ -1370,8 +1370,8 @@ class G1 : SGCManager() {
|
|
|
1370
1370
|
context!!.registerReceiver(bondingReceiver, filter)
|
|
1371
1371
|
isBondingReceiverRegistered = true
|
|
1372
1372
|
|
|
1373
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
2151
|
+
selectedConnectionAddress()
|
|
2152
2152
|
if (lastDeviceAddress != null &&
|
|
2153
2153
|
!lastDeviceAddress.isEmpty() &&
|
|
2154
2154
|
bluetoothAdapter != null
|
|
@@ -6626,6 +6626,14 @@ class MentraLive : SGCManager() {
|
|
|
6626
6626
|
return false
|
|
6627
6627
|
}
|
|
6628
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
|
+
|
|
6629
6637
|
fun connectToSmartGlasses() {
|
|
6630
6638
|
if (postGattLifecycle { connectToSmartGlasses() }) return
|
|
6631
6639
|
if (pairingYieldActive) {
|
|
@@ -6660,13 +6668,7 @@ class MentraLive : SGCManager() {
|
|
|
6660
6668
|
// var context = Bridge.getContext();
|
|
6661
6669
|
// SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
|
6662
6670
|
// String lastDeviceAddress = prefs.getString(PREF_DEVICE_NAME, null);
|
|
6663
|
-
val
|
|
6664
|
-
(DeviceStore.get("bluetooth", "pending_device_address") as String?)?.takeIf {
|
|
6665
|
-
it.isNotEmpty()
|
|
6666
|
-
}
|
|
6667
|
-
val lastDeviceAddress =
|
|
6668
|
-
pendingAddress
|
|
6669
|
-
?: (DeviceStore.get("bluetooth", "device_address") as String?)
|
|
6671
|
+
val lastDeviceAddress = selectedConnectionAddress()
|
|
6670
6672
|
|
|
6671
6673
|
if (lastDeviceAddress != null && lastDeviceAddress.length > 0) {
|
|
6672
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 (
|
|
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
|
|
1319
|
-
val 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("
|
|
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
|
-
|
|
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
|
+
}
|