@mentra/bluetooth-sdk 3.2.0-dev.180 → 3.2.0-dev.181
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +6 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/ConnectedDeviceMatcher.kt +69 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +57 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/connection/ConnectionModels.kt +38 -1
- package/android/src/test/java/com/mentra/bluetoothsdk/ConnectedDeviceMatcherTest.kt +161 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/ScanCompletionTest.kt +87 -0
- package/build/BluetoothSdk.types.d.ts +7 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +2 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +28 -49
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/scanSession.d.ts +21 -0
- package/build/_private/scanSession.d.ts.map +1 -0
- package/build/_private/scanSession.js +75 -0
- package/build/_private/scanSession.js.map +1 -0
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js.map +1 -1
- package/build/react/useBluetoothScan.d.ts +2 -1
- package/build/react/useBluetoothScan.d.ts.map +1 -1
- package/build/react/useBluetoothScan.js +13 -0
- package/build/react/useBluetoothScan.js.map +1 -1
- package/build/react/useMentraBluetooth.d.ts +2 -1
- package/build/react/useMentraBluetooth.d.ts.map +1 -1
- package/build/react/useMentraBluetooth.js +1 -0
- package/build/react/useMentraBluetooth.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +7 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +43 -2
- package/ios/Source/connection/ScanSession.swift +11 -0
- package/ios/Source/internal/BluetoothAvailability.swift +7 -0
- package/ios/Source/internal/ConnectedDeviceMatcher.swift +45 -0
- package/ios/Tests/ConnectedDeviceMatcherTests.swift +27 -0
- package/package.json +1 -1
- package/src/BluetoothSdk.types.ts +8 -0
- package/src/_private/BluetoothSdkModule.ts +39 -57
- package/src/_private/scanSession.ts +88 -0
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/index.ts +1 -0
- package/src/react/useBluetoothScan.ts +12 -1
- package/src/react/useMentraBluetooth.ts +2 -0
package/README.md
CHANGED
|
@@ -125,6 +125,7 @@ const devices = await BluetoothSdk.scan(DeviceModels.MentraLive, {
|
|
|
125
125
|
onResults: (nextDevices) => {
|
|
126
126
|
console.log('Nearby glasses:', nextDevices)
|
|
127
127
|
},
|
|
128
|
+
onDiagnostic: (hint) => showScanHint(hint.message),
|
|
128
129
|
})
|
|
129
130
|
|
|
130
131
|
const device = await chooseDevice(devices)
|
|
@@ -137,6 +138,12 @@ const versionInfo = await BluetoothSdk.requestVersionInfo()
|
|
|
137
138
|
console.log(versionInfo.buildNumber)
|
|
138
139
|
```
|
|
139
140
|
|
|
141
|
+
`onDiagnostic` optionally reports a matching phone connection after an empty scan
|
|
142
|
+
on Android and iOS. It is advisory, does not identify another app, and leaves
|
|
143
|
+
normal empty completion unchanged. React hooks expose it as `scan.diagnostic`
|
|
144
|
+
on `useMentraBluetooth()`, or `diagnostic` on `useBluetoothScan()`. Clear the hint
|
|
145
|
+
when retrying or connecting.
|
|
146
|
+
|
|
140
147
|
In multi-device environments, present an explicit picker instead of
|
|
141
148
|
auto-connecting to the first nearby device.
|
|
142
149
|
|
|
@@ -541,6 +541,12 @@ class BluetoothSdkModule : Module() {
|
|
|
541
541
|
sdk?.startScan(DeviceModel.fromDeviceType(model))
|
|
542
542
|
}
|
|
543
543
|
|
|
544
|
+
SdkAsyncFunction("getScanDiagnostic") { model: String ->
|
|
545
|
+
sdk?.connectedDeviceScanDiagnostic(DeviceModel.fromDeviceType(model))?.let {
|
|
546
|
+
mapOf("code" to it.code, "message" to it.message)
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
544
550
|
SdkAsyncFunction("stopScan") { -> sdk?.stopScan() }
|
|
545
551
|
|
|
546
552
|
SdkAsyncFunction("cancelConnectionAttempt") { -> sdk?.cancelConnectionAttempt() }
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure matcher deciding whether a GATT-connected peripheral (as enumerated by
|
|
5
|
+
* `BluetoothManager.getConnectedDevices`) is plausibly the glasses a scan for a
|
|
6
|
+
* given [DeviceModel] was looking for. This is a name/address heuristic, not
|
|
7
|
+
* proof of device identity or connection ownership.
|
|
8
|
+
*
|
|
9
|
+
* Kept free of Android framework types so the logic is unit-testable on the
|
|
10
|
+
* JVM (see `ConnectedDeviceMatcherTest`).
|
|
11
|
+
*/
|
|
12
|
+
internal object ConnectedDeviceMatcher {
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* True when the candidate peripheral either matches the saved default
|
|
16
|
+
* device for the scanned [model] (by address ignoring case when both
|
|
17
|
+
* addresses exist, otherwise by a nonblank exact name) or carries a
|
|
18
|
+
* known advertised-name prefix for that model.
|
|
19
|
+
*
|
|
20
|
+
* [defaultDevice] only counts when its model equals the scanned [model];
|
|
21
|
+
* a saved Mentra Live cannot explain an empty Nex scan. [DeviceModel.SIMULATED]
|
|
22
|
+
* never matches anything.
|
|
23
|
+
*/
|
|
24
|
+
fun matches(
|
|
25
|
+
model: DeviceModel,
|
|
26
|
+
defaultDevice: Device?,
|
|
27
|
+
candidateName: String?,
|
|
28
|
+
candidateAddress: String?,
|
|
29
|
+
): Boolean {
|
|
30
|
+
if (model == DeviceModel.SIMULATED) return false
|
|
31
|
+
val matchesDefault = if (defaultDevice?.model == model) {
|
|
32
|
+
val savedAddress = defaultDevice.address?.takeIf { it.isNotBlank() }
|
|
33
|
+
val address = candidateAddress?.takeIf { it.isNotBlank() }
|
|
34
|
+
if (savedAddress != null && address != null) {
|
|
35
|
+
address.equals(savedAddress, ignoreCase = true)
|
|
36
|
+
} else {
|
|
37
|
+
!candidateName.isNullOrBlank() && candidateName == defaultDevice.name
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
false
|
|
41
|
+
}
|
|
42
|
+
return matchesDefault || (candidateName != null && matchesModelNamePrefix(model, candidateName))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Per-model advertised/bonded-name filters, mirroring the scan filters the
|
|
47
|
+
* SGCs apply: MentraLive.kt (`Xy_A`, `XyBLE_`, `MENTRA_LIVE_BLE`,
|
|
48
|
+
* `MENTRA_LIVE_BT`, lowercase `mentra_live`) and MentraNex.kt (`Nex1-`,
|
|
49
|
+
* `MENTRA_DISPLAY_`). Models without a Mentra-branded advertised name
|
|
50
|
+
* (G1, G2, Nimo, Ar99, ...) have no prefix entry here and are detected
|
|
51
|
+
* only through the saved-default match above.
|
|
52
|
+
*/
|
|
53
|
+
private fun matchesModelNamePrefix(
|
|
54
|
+
model: DeviceModel,
|
|
55
|
+
name: String,
|
|
56
|
+
): Boolean =
|
|
57
|
+
when (model) {
|
|
58
|
+
DeviceModel.MENTRA_LIVE ->
|
|
59
|
+
name == "Xy_A" ||
|
|
60
|
+
name.startsWith("XyBLE_") ||
|
|
61
|
+
name.startsWith("MENTRA_LIVE_BLE") ||
|
|
62
|
+
name.startsWith("MENTRA_LIVE_BT") ||
|
|
63
|
+
name.lowercase().startsWith("mentra_live")
|
|
64
|
+
DeviceModel.MENTRA_NEX ->
|
|
65
|
+
name.startsWith("Nex1-") ||
|
|
66
|
+
name.startsWith("MENTRA_DISPLAY_")
|
|
67
|
+
else -> false
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -3,9 +3,9 @@ package com.mentra.bluetoothsdk
|
|
|
3
3
|
/** Generated by release CI. Do not edit in a release checkout. */
|
|
4
4
|
internal object GeneratedReleaseMetadata {
|
|
5
5
|
const val FAMILY_BASE_VERSION: String = "3.2.0"
|
|
6
|
-
const val RELEASE_IDENTITY: String = "3.2.0-dev.
|
|
7
|
-
const val RELEASE_SET_ID: String = "mentra-3.2.0-dev.
|
|
8
|
-
const val SOURCE_COMMIT: String = "
|
|
9
|
-
const val OTA_MANIFEST_URL: String = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.
|
|
10
|
-
const val OTA_MANIFEST_SHA256: String = "
|
|
6
|
+
const val RELEASE_IDENTITY: String = "3.2.0-dev.181"
|
|
7
|
+
const val RELEASE_SET_ID: String = "mentra-3.2.0-dev.181"
|
|
8
|
+
const val SOURCE_COMMIT: String = "89a4792321ce95bd16aadd721bc3dbc20e338861"
|
|
9
|
+
const val OTA_MANIFEST_URL: String = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.181.json"
|
|
10
|
+
const val OTA_MANIFEST_SHA256: String = "d9a100cc81733d02848a86c7617eff154f4cd9d714d1d36407789aeebbf71251"
|
|
11
11
|
}
|
|
@@ -3,6 +3,7 @@ package com.mentra.bluetoothsdk
|
|
|
3
3
|
import android.app.Activity
|
|
4
4
|
import android.app.Application
|
|
5
5
|
import android.bluetooth.BluetoothManager
|
|
6
|
+
import android.bluetooth.BluetoothProfile
|
|
6
7
|
import android.content.Context
|
|
7
8
|
import android.os.Bundle
|
|
8
9
|
import android.os.Handler
|
|
@@ -347,6 +348,16 @@ class MentraBluetoothSdk private constructor(
|
|
|
347
348
|
timeoutMs = timeoutMs,
|
|
348
349
|
)
|
|
349
350
|
|
|
351
|
+
/**
|
|
352
|
+
* Scans for glasses of [model], reporting through [callback].
|
|
353
|
+
*
|
|
354
|
+
* A completed or cancelled scan ends with [ScanCallback.onComplete].
|
|
355
|
+
* [ScanCallback.onError] remains reserved for failure to start scanning.
|
|
356
|
+
* Callbacks implementing [ScanDiagnosticCallback] may receive a best-effort
|
|
357
|
+
* hint before an empty completed scan. Android reports phone-wide connection
|
|
358
|
+
* state, so this hint cannot identify an owning app or prove why scanning
|
|
359
|
+
* found no devices. React Native's separate scan implementation is unaffected.
|
|
360
|
+
*/
|
|
350
361
|
@JvmOverloads
|
|
351
362
|
fun scan(
|
|
352
363
|
model: DeviceModel,
|
|
@@ -378,7 +389,9 @@ class MentraBluetoothSdk private constructor(
|
|
|
378
389
|
mainHandler.removeCallbacks(timeoutRunnable)
|
|
379
390
|
session.markStopped()
|
|
380
391
|
stopScan(reason)
|
|
381
|
-
callback.
|
|
392
|
+
callback.completeScan(reason, latestResults.toList()) {
|
|
393
|
+
connectedDeviceScanDiagnostic(model)
|
|
394
|
+
}
|
|
382
395
|
}
|
|
383
396
|
|
|
384
397
|
timeoutRunnable = Runnable { finish(ScanStopReason.COMPLETED) }
|
|
@@ -1494,6 +1507,49 @@ class MentraBluetoothSdk private constructor(
|
|
|
1494
1507
|
)
|
|
1495
1508
|
}
|
|
1496
1509
|
|
|
1510
|
+
/**
|
|
1511
|
+
* A model-compatible device already connected to this phone can explain an
|
|
1512
|
+
* empty scan, but Android does not expose app ownership or exclusive use.
|
|
1513
|
+
* Skip known SDK connections/attempts and unavailable Bluetooth permissions.
|
|
1514
|
+
*/
|
|
1515
|
+
internal fun connectedDeviceScanDiagnostic(model: DeviceModel): ScanDiagnostic? {
|
|
1516
|
+
if (model == DeviceModel.SIMULATED) return null
|
|
1517
|
+
val glassesStatus = getRawGlassesStatus()
|
|
1518
|
+
if (glassesStatus.connected ||
|
|
1519
|
+
glassesStatus.connectionState == GlassesConnectionState.CONNECTED ||
|
|
1520
|
+
glassesStatus.connectionState == GlassesConnectionState.CONNECTING ||
|
|
1521
|
+
glassesStatus.connectionState == GlassesConnectionState.BONDING
|
|
1522
|
+
) {
|
|
1523
|
+
return null
|
|
1524
|
+
}
|
|
1525
|
+
return try {
|
|
1526
|
+
val bluetoothManager =
|
|
1527
|
+
appContext.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager ?: return null
|
|
1528
|
+
val adapter = bluetoothManager.adapter ?: return null
|
|
1529
|
+
if (!adapter.isEnabled) return null
|
|
1530
|
+
val defaultDevice = currentDefaultDevice()
|
|
1531
|
+
val device = bluetoothManager.getConnectedDevices(BluetoothProfile.GATT)
|
|
1532
|
+
.firstOrNull { device ->
|
|
1533
|
+
ConnectedDeviceMatcher.matches(
|
|
1534
|
+
model = model,
|
|
1535
|
+
defaultDevice = defaultDevice,
|
|
1536
|
+
candidateName = device.name,
|
|
1537
|
+
candidateAddress = device.address,
|
|
1538
|
+
)
|
|
1539
|
+
} ?: return null
|
|
1540
|
+
val name = device.name?.takeIf { it.isNotBlank() } ?: device.address
|
|
1541
|
+
ScanDiagnostic(
|
|
1542
|
+
code = "device_connected_on_phone",
|
|
1543
|
+
message =
|
|
1544
|
+
"Scan found no glasses, but a matching device \"$name\" is already connected to this phone. " +
|
|
1545
|
+
"If another app is using it, disconnect it there and scan again.",
|
|
1546
|
+
)
|
|
1547
|
+
} catch (error: SecurityException) {
|
|
1548
|
+
// BLUETOOTH_CONNECT can be absent or revoked while scanning.
|
|
1549
|
+
null
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1497
1553
|
private fun requireBluetoothReady(operation: String) {
|
|
1498
1554
|
val bluetoothManager = appContext.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
|
|
1499
1555
|
val adapter =
|
|
@@ -53,13 +53,50 @@ enum class ScanStopReason {
|
|
|
53
53
|
ERROR,
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Callbacks for `MentraBluetoothSdk.scan`.
|
|
58
|
+
*
|
|
59
|
+
* Completed and cancelled scans report [onComplete]. A failure to start reports
|
|
60
|
+
* [onError] and is thrown to the caller. Optional empty-scan hints are delivered
|
|
61
|
+
* separately through [ScanDiagnosticCallback], never as errors.
|
|
62
|
+
*/
|
|
56
63
|
interface ScanCallback {
|
|
57
64
|
fun onResults(devices: List<Device>) {}
|
|
58
65
|
fun onComplete(devices: List<Device>) {}
|
|
59
66
|
fun onError(error: BluetoothError) {}
|
|
60
67
|
}
|
|
61
68
|
|
|
62
|
-
|
|
69
|
+
/** A non-fatal scan hint; it does not establish the cause of an empty result. */
|
|
70
|
+
data class ScanDiagnostic(val code: String, val message: String)
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Optional scan diagnostics, delivered before [onComplete] for an empty completed
|
|
74
|
+
* scan. Kept separate so existing compiled [ScanCallback] implementations do not
|
|
75
|
+
* need a new method. Android's `device_connected_on_phone` hint identifies a
|
|
76
|
+
* model-compatible GATT connection on this phone, not which app owns it.
|
|
77
|
+
*/
|
|
78
|
+
interface ScanDiagnosticCallback : ScanCallback {
|
|
79
|
+
fun onDiagnostic(diagnostic: ScanDiagnostic)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
abstract class MentraBluetoothScanCallback : ScanDiagnosticCallback {
|
|
83
|
+
override fun onDiagnostic(diagnostic: ScanDiagnostic) {}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Complete once, with an optional advisory that never replaces completion. */
|
|
87
|
+
internal fun ScanCallback.completeScan(
|
|
88
|
+
reason: ScanStopReason,
|
|
89
|
+
devices: List<Device>,
|
|
90
|
+
diagnostic: () -> ScanDiagnostic?,
|
|
91
|
+
) {
|
|
92
|
+
try {
|
|
93
|
+
if (this is ScanDiagnosticCallback && reason == ScanStopReason.COMPLETED && devices.isEmpty()) {
|
|
94
|
+
diagnostic()?.let { onDiagnostic(it) }
|
|
95
|
+
}
|
|
96
|
+
} finally {
|
|
97
|
+
onComplete(devices)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
63
100
|
|
|
64
101
|
class ScanSession internal constructor(
|
|
65
102
|
private val stopAction: () -> Unit,
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk
|
|
2
|
+
|
|
3
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
4
|
+
import org.junit.Test
|
|
5
|
+
|
|
6
|
+
class ConnectedDeviceMatcherTest {
|
|
7
|
+
private val savedLive =
|
|
8
|
+
Device(
|
|
9
|
+
model = DeviceModel.MENTRA_LIVE,
|
|
10
|
+
name = "Mentra Live 4X2A",
|
|
11
|
+
address = "AA:BB:CC:DD:EE:FF",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
@Test
|
|
15
|
+
fun `matches Mentra Live advertised name prefixes`() {
|
|
16
|
+
for (name in listOf("Xy_A", "XyBLE_1234", "MENTRA_LIVE_BLE_9", "MENTRA_LIVE_BT_9", "mentra_live_abc")) {
|
|
17
|
+
assertThat(
|
|
18
|
+
ConnectedDeviceMatcher.matches(
|
|
19
|
+
model = DeviceModel.MENTRA_LIVE,
|
|
20
|
+
defaultDevice = null,
|
|
21
|
+
candidateName = name,
|
|
22
|
+
candidateAddress = null,
|
|
23
|
+
),
|
|
24
|
+
).describedAs(name).isTrue()
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@Test
|
|
29
|
+
fun `matches Mentra Nex advertised name prefixes`() {
|
|
30
|
+
for (name in listOf("Nex1-77", "MENTRA_DISPLAY_02")) {
|
|
31
|
+
assertThat(
|
|
32
|
+
ConnectedDeviceMatcher.matches(
|
|
33
|
+
model = DeviceModel.MENTRA_NEX,
|
|
34
|
+
defaultDevice = null,
|
|
35
|
+
candidateName = name,
|
|
36
|
+
candidateAddress = null,
|
|
37
|
+
),
|
|
38
|
+
).describedAs(name).isTrue()
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@Test
|
|
43
|
+
fun `name prefixes are scoped to the scanned model`() {
|
|
44
|
+
// A held Mentra Live can never explain an empty Nex (or G1) scan.
|
|
45
|
+
assertThat(
|
|
46
|
+
ConnectedDeviceMatcher.matches(
|
|
47
|
+
model = DeviceModel.MENTRA_NEX,
|
|
48
|
+
defaultDevice = null,
|
|
49
|
+
candidateName = "XyBLE_1234",
|
|
50
|
+
candidateAddress = null,
|
|
51
|
+
),
|
|
52
|
+
).isFalse()
|
|
53
|
+
assertThat(
|
|
54
|
+
ConnectedDeviceMatcher.matches(
|
|
55
|
+
model = DeviceModel.G1,
|
|
56
|
+
defaultDevice = null,
|
|
57
|
+
candidateName = "MENTRA_LIVE_BT_9",
|
|
58
|
+
candidateAddress = null,
|
|
59
|
+
),
|
|
60
|
+
).isFalse()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@Test
|
|
64
|
+
fun `rejects non-Mentra peripherals`() {
|
|
65
|
+
for (name in listOf("Even G1_22_L_", "Pixel Watch", "MX Master 3S")) {
|
|
66
|
+
assertThat(
|
|
67
|
+
ConnectedDeviceMatcher.matches(
|
|
68
|
+
model = DeviceModel.MENTRA_LIVE,
|
|
69
|
+
defaultDevice = savedLive,
|
|
70
|
+
candidateName = name,
|
|
71
|
+
candidateAddress = "11:22:33:44:55:66",
|
|
72
|
+
),
|
|
73
|
+
).describedAs(name).isFalse()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@Test
|
|
78
|
+
fun `matches the saved default by exact name`() {
|
|
79
|
+
assertThat(
|
|
80
|
+
ConnectedDeviceMatcher.matches(
|
|
81
|
+
model = DeviceModel.MENTRA_LIVE,
|
|
82
|
+
defaultDevice = savedLive,
|
|
83
|
+
candidateName = "Mentra Live 4X2A",
|
|
84
|
+
candidateAddress = null,
|
|
85
|
+
),
|
|
86
|
+
).isTrue()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@Test
|
|
90
|
+
fun `matches the saved default by address ignoring case`() {
|
|
91
|
+
// Stored addresses can arrive lowercase from the JS side.
|
|
92
|
+
assertThat(
|
|
93
|
+
ConnectedDeviceMatcher.matches(
|
|
94
|
+
model = DeviceModel.MENTRA_LIVE,
|
|
95
|
+
defaultDevice = savedLive.copy(address = "aa:bb:cc:dd:ee:ff"),
|
|
96
|
+
candidateName = null,
|
|
97
|
+
candidateAddress = "AA:BB:CC:DD:EE:FF",
|
|
98
|
+
),
|
|
99
|
+
).isTrue()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@Test
|
|
103
|
+
fun `saved default only counts for its own model`() {
|
|
104
|
+
// Scanning for G1 while the saved default is a Mentra Live.
|
|
105
|
+
assertThat(
|
|
106
|
+
ConnectedDeviceMatcher.matches(
|
|
107
|
+
model = DeviceModel.G1,
|
|
108
|
+
defaultDevice = savedLive,
|
|
109
|
+
candidateName = "Mentra Live 4X2A",
|
|
110
|
+
candidateAddress = "AA:BB:CC:DD:EE:FF",
|
|
111
|
+
),
|
|
112
|
+
).isFalse()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
@Test
|
|
116
|
+
fun `models without a branded name rely on the saved default`() {
|
|
117
|
+
val savedG1 = Device(model = DeviceModel.G1, name = "Even G1_22_L_", address = null)
|
|
118
|
+
assertThat(
|
|
119
|
+
ConnectedDeviceMatcher.matches(
|
|
120
|
+
model = DeviceModel.G1,
|
|
121
|
+
defaultDevice = savedG1,
|
|
122
|
+
candidateName = "Even G1_22_L_",
|
|
123
|
+
candidateAddress = null,
|
|
124
|
+
),
|
|
125
|
+
).isTrue()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@Test
|
|
129
|
+
fun `simulated model never matches`() {
|
|
130
|
+
assertThat(
|
|
131
|
+
ConnectedDeviceMatcher.matches(
|
|
132
|
+
model = DeviceModel.SIMULATED,
|
|
133
|
+
defaultDevice = savedLive,
|
|
134
|
+
candidateName = "Mentra Live 4X2A",
|
|
135
|
+
candidateAddress = "AA:BB:CC:DD:EE:FF",
|
|
136
|
+
),
|
|
137
|
+
).isFalse()
|
|
138
|
+
}
|
|
139
|
+
@Test
|
|
140
|
+
fun `blank saved identities never identify an unrelated connection`() {
|
|
141
|
+
val saved = Device(DeviceModel.G1, name = "", address = "")
|
|
142
|
+
assertThat(ConnectedDeviceMatcher.matches(DeviceModel.G1, saved, "", "")).isFalse()
|
|
143
|
+
assertThat(ConnectedDeviceMatcher.matches(DeviceModel.G1, saved, null, null)).isFalse()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
@Test
|
|
147
|
+
fun `different known addresses override a matching saved name`() {
|
|
148
|
+
val saved = Device(DeviceModel.G1, name = "Even G1", address = "AA:BB:CC:DD:EE:FF")
|
|
149
|
+
assertThat(
|
|
150
|
+
ConnectedDeviceMatcher.matches(DeviceModel.G1, saved, "Even G1", "11:22:33:44:55:66"),
|
|
151
|
+
).isFalse()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
@Test
|
|
155
|
+
fun `a branded candidate can still match its model without being the saved device`() {
|
|
156
|
+
assertThat(
|
|
157
|
+
ConnectedDeviceMatcher.matches(DeviceModel.MENTRA_LIVE, savedLive, "XyBLE_5678", "11:22:33:44:55:66"),
|
|
158
|
+
).isTrue()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk
|
|
2
|
+
|
|
3
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
4
|
+
import org.junit.Test
|
|
5
|
+
|
|
6
|
+
class ScanCompletionTest {
|
|
7
|
+
private val hint = ScanDiagnostic("device_connected_on_phone", "A matching device is connected to this phone.")
|
|
8
|
+
|
|
9
|
+
@Test
|
|
10
|
+
fun `empty completed scan delivers diagnostic then completion without error`() {
|
|
11
|
+
val events = mutableListOf<String>()
|
|
12
|
+
val callback = object : MentraBluetoothScanCallback() {
|
|
13
|
+
override fun onDiagnostic(diagnostic: ScanDiagnostic) {
|
|
14
|
+
assertThat(diagnostic).isEqualTo(hint)
|
|
15
|
+
events.add("diagnostic")
|
|
16
|
+
}
|
|
17
|
+
override fun onComplete(devices: List<Device>) {
|
|
18
|
+
assertThat(devices).isEmpty()
|
|
19
|
+
events.add("complete")
|
|
20
|
+
}
|
|
21
|
+
override fun onError(error: BluetoothError) {
|
|
22
|
+
events.add("error")
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
callback.completeScan(ScanStopReason.COMPLETED, emptyList()) { hint }
|
|
26
|
+
assertThat(events).containsExactly("diagnostic", "complete")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@Test
|
|
30
|
+
fun `legacy callbacks complete without querying diagnostics`() {
|
|
31
|
+
var completed = false
|
|
32
|
+
// Implement only the pre-existing interface; it gains no abstract method.
|
|
33
|
+
val callback = object : ScanCallback {
|
|
34
|
+
override fun onComplete(devices: List<Device>) { completed = true }
|
|
35
|
+
}
|
|
36
|
+
callback.completeScan(ScanStopReason.COMPLETED, emptyList()) {
|
|
37
|
+
error("Legacy callbacks must not query diagnostics")
|
|
38
|
+
}
|
|
39
|
+
assertThat(completed).isTrue()
|
|
40
|
+
assertThat(ScanCallback::class.java.declaredMethods.map { it.name })
|
|
41
|
+
.containsExactlyInAnyOrder("onResults", "onComplete", "onError")
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@Test
|
|
45
|
+
fun `cancelled scans and nonempty scans never query diagnostics`() {
|
|
46
|
+
val device = Device(DeviceModel.MENTRA_LIVE, "XyBLE_1234")
|
|
47
|
+
val completed = mutableListOf<List<Device>>()
|
|
48
|
+
val callback = object : MentraBluetoothScanCallback() {
|
|
49
|
+
override fun onComplete(devices: List<Device>) { completed.add(devices) }
|
|
50
|
+
override fun onDiagnostic(diagnostic: ScanDiagnostic) { error("Unexpected diagnostic") }
|
|
51
|
+
}
|
|
52
|
+
for ((reason, devices) in listOf(
|
|
53
|
+
ScanStopReason.CANCELLED to emptyList(),
|
|
54
|
+
ScanStopReason.COMPLETED to listOf(device),
|
|
55
|
+
)) {
|
|
56
|
+
callback.completeScan(reason, devices) { error("Unexpected diagnostic query") }
|
|
57
|
+
}
|
|
58
|
+
assertThat(completed).containsExactly(emptyList(), listOf(device))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@Test
|
|
62
|
+
fun `missing diagnostic leaves an ordinary empty completion`() {
|
|
63
|
+
val events = mutableListOf<String>()
|
|
64
|
+
val callback = object : MentraBluetoothScanCallback() {
|
|
65
|
+
override fun onComplete(devices: List<Device>) { events.add("complete") }
|
|
66
|
+
override fun onDiagnostic(diagnostic: ScanDiagnostic) { events.add("diagnostic") }
|
|
67
|
+
override fun onError(error: BluetoothError) { events.add("error") }
|
|
68
|
+
}
|
|
69
|
+
callback.completeScan(ScanStopReason.COMPLETED, emptyList()) { null }
|
|
70
|
+
assertThat(events).containsExactly("complete")
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@Test
|
|
74
|
+
fun `completion still runs if the diagnostic handler throws`() {
|
|
75
|
+
var completed = false
|
|
76
|
+
val failure = IllegalStateException("consumer diagnostic handler failed")
|
|
77
|
+
val callback = object : MentraBluetoothScanCallback() {
|
|
78
|
+
override fun onComplete(devices: List<Device>) { completed = true }
|
|
79
|
+
override fun onDiagnostic(diagnostic: ScanDiagnostic) { throw failure }
|
|
80
|
+
}
|
|
81
|
+
val thrown = runCatching {
|
|
82
|
+
callback.completeScan(ScanStopReason.COMPLETED, emptyList()) { hint }
|
|
83
|
+
}.exceptionOrNull()
|
|
84
|
+
assertThat(thrown).isSameAs(failure)
|
|
85
|
+
assertThat(completed).isTrue()
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -1178,6 +1178,11 @@ export interface ConnectOptions {
|
|
|
1178
1178
|
cancelExistingConnectionAttempt?: boolean;
|
|
1179
1179
|
}
|
|
1180
1180
|
export type ScanResultsCallback = (devices: Device[]) => void;
|
|
1181
|
+
/** Advisory for an empty scan. System connection state does not identify an owning app. */
|
|
1182
|
+
export interface ScanDiagnostic {
|
|
1183
|
+
code: string;
|
|
1184
|
+
message: string;
|
|
1185
|
+
}
|
|
1181
1186
|
export interface ScanOptions {
|
|
1182
1187
|
model: DeviceModel;
|
|
1183
1188
|
/** Defaults to 15000. */
|
|
@@ -1186,6 +1191,8 @@ export interface ScanOptions {
|
|
|
1186
1191
|
timeout?: number;
|
|
1187
1192
|
/** Called every time the discovered device list changes during the scan. */
|
|
1188
1193
|
onResults?: ScanResultsCallback;
|
|
1194
|
+
/** Optional non-fatal hint before an empty scan resolves, on Android and iOS. */
|
|
1195
|
+
onDiagnostic?: (diagnostic: ScanDiagnostic) => void;
|
|
1189
1196
|
}
|
|
1190
1197
|
export type ScanModelOptions = Omit<ScanOptions, "model">;
|
|
1191
1198
|
export interface WifiSearchResult {
|