@mentra/bluetooth-sdk 3.2.1 → 3.3.0-beta.942
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 +68 -6
- package/android/build.gradle +15 -0
- package/android/lc3Lib/build.gradle +6 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +63 -14
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +55 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkInstallId.kt +23 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +2 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheck.kt +184 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTracker.kt +388 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +4 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +2 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +6 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +223 -36
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +24 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/NetworkDiagnostics.kt +25 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +3 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +4 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscovery.kt +70 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +11 -32
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/LatinTextSanitizer.kt +39 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +93 -18
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +3 -1
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +58 -3
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheckTest.kt +124 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTrackerTest.kt +263 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/NetworkDiagnosticsTest.kt +55 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/ScanCompletionTest.kt +5 -1
- package/android/src/test/java/com/mentra/bluetoothsdk/debug/BleTraceLoggerTest.kt +69 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscoveryTest.kt +124 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +29 -4
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveClassicDisconnectTest.kt +133 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveIncidentDispatchTest.kt +126 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveWifiCredentialLogTest.kt +55 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +20 -7
- package/build/BluetoothSdk.types.d.ts +3 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +2 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/MentraLocalNetworkModule.d.ts +23 -0
- package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
- package/build/_private/MentraLocalNetworkModule.js.map +1 -1
- package/build/_private/nativeLogConsole.d.ts +19 -0
- package/build/_private/nativeLogConsole.d.ts.map +1 -1
- package/build/_private/nativeLogConsole.js +12 -0
- package/build/_private/nativeLogConsole.js.map +1 -1
- package/build/generated/changelogCatalog.d.ts +4 -1
- package/build/generated/changelogCatalog.d.ts.map +1 -1
- package/build/generated/changelogCatalog.js +5 -1
- package/build/generated/changelogCatalog.js.map +1 -1
- package/build/generated/releaseMetadata.js +6 -6
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/ota-transport/index.d.ts +4 -0
- package/build/ota-transport/index.d.ts.map +1 -1
- package/build/ota-transport/index.js +2 -0
- package/build/ota-transport/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +8 -4
- package/ios/Source/BluetoothSdkDefaults.swift +2 -2
- package/ios/Source/DeviceManager.swift +6 -3
- package/ios/Source/GeneratedChangelogCatalog.swift +2 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +6 -6
- package/ios/Source/MentraBluetoothSDK.swift +295 -54
- package/ios/Source/internal/BluetoothAvailability.swift +6 -0
- package/ios/Source/internal/BluetoothSdkAnalytics.swift +87 -13
- package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +54 -1
- package/ios/Source/internal/BluetoothSdkInstallId.swift +21 -0
- package/ios/Source/internal/BluetoothSdkUpdateCheck.swift +223 -0
- package/ios/Source/internal/BluetoothSdkUsageTracker.swift +428 -0
- package/ios/Source/sgcs/MentraLive.swift +71 -41
- package/ios/Source/sgcs/NimoCanvasCodec.swift +4 -1
- package/ios/Source/sgcs/SGCManager.swift +1 -1
- package/ios/Source/utils/AudioSessionMonitor.swift +44 -8
- package/ios/Source/utils/G1Text.swift +2 -24
- package/ios/Source/utils/G1TextSanitizer.swift +27 -0
- package/ios/Source/utils/LatinTextSanitizer.swift +48 -0
- package/ios/Tests/BluetoothAudioRouteTests.swift +26 -0
- package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +54 -3
- package/ios/Tests/BluetoothSdkUpdateCheckTests.swift +153 -0
- package/ios/Tests/BluetoothSdkUsageTrackerTests.swift +296 -0
- package/ios/Tests/G1TextSanitizerTests.swift +32 -0
- package/ios/Tests/IncidentLogDispatchTests.swift +111 -0
- package/ios/Tests/MentraLiveSessionReadinessTests.swift +163 -0
- package/ios/Tests/NimoCanvasCodecTests.swift +17 -4
- package/ios/Tests/VersionInfoCommandTests.swift +142 -1
- package/ios/Tests/WifiCredentialLoggingTests.swift +42 -0
- package/package.json +2 -2
- package/src/BluetoothSdk.types.ts +1 -0
- package/src/_private/BluetoothSdkModule.ts +2 -1
- package/src/_private/MentraLocalNetworkModule.ts +28 -0
- package/src/_private/nativeLogConsole.ts +19 -0
- package/src/generated/changelogCatalog.ts +5 -1
- package/src/generated/releaseMetadata.ts +6 -6
- package/src/ota-transport/index.ts +10 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk.sgcs
|
|
2
|
+
|
|
3
|
+
import java.text.Normalizer
|
|
4
|
+
|
|
5
|
+
private val LATIN_COMBINING_MARKS = Regex("\\p{M}+")
|
|
6
|
+
private val LATIN_LETTERS_WITH_MARKS = Regex("\\p{IsLatin}\\p{M}*")
|
|
7
|
+
|
|
8
|
+
/** Converts accented and extended Latin letters to base glyphs at the device boundary. */
|
|
9
|
+
internal fun sanitizeLatinDisplayText(text: String, preserveOtherScripts: Boolean = false): String {
|
|
10
|
+
val expanded = buildString(text.length) {
|
|
11
|
+
text.forEach { character ->
|
|
12
|
+
when (character) {
|
|
13
|
+
'Đ', 'Ð' -> append('D')
|
|
14
|
+
'đ', 'ð' -> append('d')
|
|
15
|
+
'Ł' -> append('L')
|
|
16
|
+
'ł' -> append('l')
|
|
17
|
+
'Ø' -> append('O')
|
|
18
|
+
'ø' -> append('o')
|
|
19
|
+
'Æ' -> append("AE")
|
|
20
|
+
'æ' -> append("ae")
|
|
21
|
+
'Œ' -> append("OE")
|
|
22
|
+
'œ' -> append("oe")
|
|
23
|
+
'ẞ' -> append("SS")
|
|
24
|
+
'ß' -> append("ss")
|
|
25
|
+
'Þ' -> append("TH")
|
|
26
|
+
'þ' -> append("th")
|
|
27
|
+
else -> append(character)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (preserveOtherScripts) {
|
|
33
|
+
return LATIN_LETTERS_WITH_MARKS.replace(expanded) { match ->
|
|
34
|
+
Normalizer.normalize(match.value, Normalizer.Form.NFD).replace(LATIN_COMBINING_MARKS, "")
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return Normalizer.normalize(expanded, Normalizer.Form.NFD)
|
|
38
|
+
.replace(LATIN_COMBINING_MARKS, "")
|
|
39
|
+
}
|
|
@@ -34,6 +34,7 @@ import android.os.SystemClock
|
|
|
34
34
|
import com.mentra.bluetoothsdk.utils.NativeLog as Log
|
|
35
35
|
import androidx.core.app.ActivityCompat
|
|
36
36
|
import com.mentra.bluetoothsdk.BluetoothSdkDefaults
|
|
37
|
+
import com.mentra.bluetoothsdk.BluetoothSdkException
|
|
37
38
|
import com.mentra.bluetoothsdk.Bridge
|
|
38
39
|
import com.mentra.bluetoothsdk.DeviceManager
|
|
39
40
|
import com.mentra.bluetoothsdk.PhotoRequest
|
|
@@ -645,6 +646,10 @@ class MentraLive : SGCManager() {
|
|
|
645
646
|
private var a2dpProfile: BluetoothA2dp? = null
|
|
646
647
|
private var isA2dpProxyRegistered = false
|
|
647
648
|
private var a2dpConnectAttempts = 0
|
|
649
|
+
private val classicAudioServiceDiscovery =
|
|
650
|
+
ClassicAudioServiceDiscovery(handler, CONNECTION_TIMEOUT_MS.toLong()) {
|
|
651
|
+
Bridge.log("LIVE: Classic: $it")
|
|
652
|
+
}
|
|
648
653
|
|
|
649
654
|
private data class OutgoingBleCommandTraceInfo(
|
|
650
655
|
val commandType: String,
|
|
@@ -1708,6 +1713,7 @@ class MentraLive : SGCManager() {
|
|
|
1708
1713
|
readinessCompletedThisBleSession = false
|
|
1709
1714
|
glassesReadyReceived = false
|
|
1710
1715
|
ctkdInitiatedThisGattSession = false
|
|
1716
|
+
classicAudioServiceDiscovery.cancel()
|
|
1711
1717
|
audioConnected = false
|
|
1712
1718
|
notificationsEnabled = false
|
|
1713
1719
|
currentMtu = 23
|
|
@@ -1983,6 +1989,7 @@ class MentraLive : SGCManager() {
|
|
|
1983
1989
|
readinessCompletedThisBleSession = false
|
|
1984
1990
|
glassesReadyReceived = false
|
|
1985
1991
|
ctkdInitiatedThisGattSession = false
|
|
1992
|
+
classicAudioServiceDiscovery.cancel()
|
|
1986
1993
|
audioConnected = false
|
|
1987
1994
|
notificationsEnabled = false
|
|
1988
1995
|
processSendQueueRunnable?.let { handler.removeCallbacks(it) }
|
|
@@ -2350,6 +2357,7 @@ class MentraLive : SGCManager() {
|
|
|
2350
2357
|
registerBondingReceiver()
|
|
2351
2358
|
bondingRetryCount = 0
|
|
2352
2359
|
ctkdInitiatedThisGattSession = false
|
|
2360
|
+
classicAudioServiceDiscovery.cancel()
|
|
2353
2361
|
Bridge.log(
|
|
2354
2362
|
"LIVE: CTKD: BLE GATT up - deferring Classic/A2DP until notifications ready"
|
|
2355
2363
|
)
|
|
@@ -2398,6 +2406,7 @@ class MentraLive : SGCManager() {
|
|
|
2398
2406
|
// Reset audio pairing flags
|
|
2399
2407
|
glassesReadyReceived = false
|
|
2400
2408
|
ctkdInitiatedThisGattSession = false
|
|
2409
|
+
classicAudioServiceDiscovery.cancel()
|
|
2401
2410
|
audioConnected = false
|
|
2402
2411
|
|
|
2403
2412
|
notificationsEnabled = false
|
|
@@ -2460,6 +2469,7 @@ class MentraLive : SGCManager() {
|
|
|
2460
2469
|
glassesReadyReceived = false
|
|
2461
2470
|
audioConnected = false
|
|
2462
2471
|
ctkdInitiatedThisGattSession = false
|
|
2472
|
+
classicAudioServiceDiscovery.cancel()
|
|
2463
2473
|
|
|
2464
2474
|
notificationsEnabled = false
|
|
2465
2475
|
|
|
@@ -3371,10 +3381,12 @@ class MentraLive : SGCManager() {
|
|
|
3371
3381
|
sendJson(json, wakeup, true)
|
|
3372
3382
|
}
|
|
3373
3383
|
|
|
3374
|
-
private fun sendJson(
|
|
3384
|
+
private fun sendJson(
|
|
3385
|
+
json: JSONObject?, wakeup: Boolean, bridgeLogging: Boolean,
|
|
3386
|
+
sessionGeneration: Long = bleSessionGeneration.get(),
|
|
3387
|
+
): Boolean {
|
|
3375
3388
|
if (json != null) {
|
|
3376
3389
|
try {
|
|
3377
|
-
val sessionGeneration = bleSessionGeneration.get()
|
|
3378
3390
|
if (buildNumberInt < 5) {
|
|
3379
3391
|
val jsonStr = json.toString()
|
|
3380
3392
|
// Bridge.log("LIVE: 📤 Sending JSON with esoteric message ID: " + jsonStr);
|
|
@@ -6075,6 +6087,17 @@ class MentraLive : SGCManager() {
|
|
|
6075
6087
|
}
|
|
6076
6088
|
|
|
6077
6089
|
override fun sendIncidentId(incidentId: String, apiBaseUrl: String?) {
|
|
6090
|
+
if (!isConnected || bluetoothGatt == null || txCharacteristic == null) {
|
|
6091
|
+
throw BluetoothSdkException(
|
|
6092
|
+
"glasses_not_connected", "Cannot request incident logs because the glasses BLE link is not ready.",
|
|
6093
|
+
)
|
|
6094
|
+
}
|
|
6095
|
+
// The JS token sync may happen after glasses_ready. Use only its current native store value.
|
|
6096
|
+
val coreToken = DeviceStore.get("bluetooth", "core_token") as? String
|
|
6097
|
+
if (coreToken.isNullOrBlank()) {
|
|
6098
|
+
throw BluetoothSdkException("core_token_unavailable", "Cannot request incident logs without the current Core access token.")
|
|
6099
|
+
}
|
|
6100
|
+
val sessionGeneration = bleSessionGeneration.get()
|
|
6078
6101
|
try {
|
|
6079
6102
|
var base = if (apiBaseUrl != null) apiBaseUrl.trim() else ""
|
|
6080
6103
|
if (base.isEmpty()) {
|
|
@@ -6087,13 +6110,25 @@ class MentraLive : SGCManager() {
|
|
|
6087
6110
|
bleIncidentLogRelays[lKey] =
|
|
6088
6111
|
BleIncidentLogRelay(lKey, incidentId, base, BleIncidentLogKind.LOGCAT)
|
|
6089
6112
|
|
|
6113
|
+
val auth = JSONObject()
|
|
6114
|
+
auth.put("type", "auth_token")
|
|
6115
|
+
auth.put("coreToken", coreToken)
|
|
6116
|
+
auth.put("timestamp", System.currentTimeMillis())
|
|
6090
6117
|
val json = JSONObject()
|
|
6091
6118
|
json.put("type", "upload_incident_logs")
|
|
6092
6119
|
json.put("incidentId", incidentId)
|
|
6093
6120
|
json.put("apiBaseUrl", base)
|
|
6094
|
-
|
|
6121
|
+
// Wake ASG with its current token before the upload command enters the same FIFO.
|
|
6122
|
+
if (!sendJson(auth, true, true, sessionGeneration) ||
|
|
6123
|
+
sessionGeneration != bleSessionGeneration.get() ||
|
|
6124
|
+
!isConnected || bluetoothGatt == null || txCharacteristic == null ||
|
|
6125
|
+
!sendJson(json, true, true, sessionGeneration) || sessionGeneration != bleSessionGeneration.get()) {
|
|
6126
|
+
bleIncidentLogRelays.remove(bKey)
|
|
6127
|
+
bleIncidentLogRelays.remove(lKey)
|
|
6128
|
+
throw BluetoothSdkException("incident_dispatch_failed", "Could not queue the glasses incident-log request.")
|
|
6129
|
+
}
|
|
6095
6130
|
Bridge.log(
|
|
6096
|
-
"LIVE:
|
|
6131
|
+
"LIVE: Queued incidentId to glasses for log upload: " +
|
|
6097
6132
|
incidentId +
|
|
6098
6133
|
" (BLE relay keys " +
|
|
6099
6134
|
bKey +
|
|
@@ -6102,7 +6137,7 @@ class MentraLive : SGCManager() {
|
|
|
6102
6137
|
")"
|
|
6103
6138
|
)
|
|
6104
6139
|
} catch (e: JSONException) {
|
|
6105
|
-
|
|
6140
|
+
throw BluetoothSdkException("incident_dispatch_failed", "Could not create the glasses incident-log request.", e)
|
|
6106
6141
|
}
|
|
6107
6142
|
}
|
|
6108
6143
|
|
|
@@ -7208,7 +7243,7 @@ class MentraLive : SGCManager() {
|
|
|
7208
7243
|
BluetoothDevice.BOND_BONDED ->
|
|
7209
7244
|
run {
|
|
7210
7245
|
Bridge.log(
|
|
7211
|
-
"LIVE: CTKD:
|
|
7246
|
+
"LIVE: CTKD: Successfully bonded - checking Classic audio services"
|
|
7212
7247
|
)
|
|
7213
7248
|
markPairingTiming(
|
|
7214
7249
|
"ctkd_bonded",
|
|
@@ -7227,7 +7262,7 @@ class MentraLive : SGCManager() {
|
|
|
7227
7262
|
// path does this at GATT connect).
|
|
7228
7263
|
// markAudioConnected / sendAudioConnected fire
|
|
7229
7264
|
// when the A2DP path completes.
|
|
7230
|
-
|
|
7265
|
+
prepareClassicAudio(device)
|
|
7231
7266
|
|
|
7232
7267
|
// Safety net: if glasses_ready somehow missed
|
|
7233
7268
|
// setting fullyBooted, catch up here.
|
|
@@ -7264,6 +7299,7 @@ class MentraLive : SGCManager() {
|
|
|
7264
7299
|
}
|
|
7265
7300
|
}
|
|
7266
7301
|
BluetoothDevice.BOND_NONE -> {
|
|
7302
|
+
classicAudioServiceDiscovery.cancel()
|
|
7267
7303
|
Bridge.log(
|
|
7268
7304
|
"LIVE: CTKD: ❌ Bonding failed or removed for device"
|
|
7269
7305
|
)
|
|
@@ -7385,6 +7421,10 @@ class MentraLive : SGCManager() {
|
|
|
7385
7421
|
classicAudioReceiver =
|
|
7386
7422
|
object : BroadcastReceiver() {
|
|
7387
7423
|
override fun onReceive(context: Context?, intent: Intent?) {
|
|
7424
|
+
if (intent?.action == BluetoothDevice.ACTION_UUID) {
|
|
7425
|
+
intent.bluetoothDeviceExtra()?.let { classicAudioServiceDiscovery.onUuids(it) }
|
|
7426
|
+
return
|
|
7427
|
+
}
|
|
7388
7428
|
if (intent?.action != BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED &&
|
|
7389
7429
|
intent?.action != BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED) return
|
|
7390
7430
|
val device = intent.bluetoothDeviceExtra() ?: return
|
|
@@ -7401,6 +7441,7 @@ class MentraLive : SGCManager() {
|
|
|
7401
7441
|
try {
|
|
7402
7442
|
val filter =
|
|
7403
7443
|
IntentFilter().apply {
|
|
7444
|
+
addAction(BluetoothDevice.ACTION_UUID)
|
|
7404
7445
|
addAction(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED)
|
|
7405
7446
|
addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED)
|
|
7406
7447
|
}
|
|
@@ -7525,10 +7566,10 @@ class MentraLive : SGCManager() {
|
|
|
7525
7566
|
when (device.bondState) {
|
|
7526
7567
|
BluetoothDevice.BOND_BONDED -> {
|
|
7527
7568
|
Bridge.log(
|
|
7528
|
-
"LIVE: CTKD: Device is already bonded -
|
|
7569
|
+
"LIVE: CTKD: Device is already bonded - checking Classic audio services"
|
|
7529
7570
|
)
|
|
7530
7571
|
markPairingTiming("ctkd_already_bonded")
|
|
7531
|
-
|
|
7572
|
+
prepareClassicAudio(device)
|
|
7532
7573
|
}
|
|
7533
7574
|
BluetoothDevice.BOND_BONDING -> {
|
|
7534
7575
|
// Secure pairing firmware may request SMP as soon as GATT connects.
|
|
@@ -7587,6 +7628,19 @@ class MentraLive : SGCManager() {
|
|
|
7587
7628
|
return classicAudioConnectionTracker.connected
|
|
7588
7629
|
}
|
|
7589
7630
|
|
|
7631
|
+
private fun prepareClassicAudio(device: BluetoothDevice) {
|
|
7632
|
+
if (!canConnectClassicAudio(device)) return
|
|
7633
|
+
val gatt = bluetoothGatt
|
|
7634
|
+
classicAudioServiceDiscovery.connectWhenDiscovered(device) {
|
|
7635
|
+
if (bluetoothGatt === gatt && canConnectClassicAudio(device)) connectA2dpProfile(device)
|
|
7636
|
+
}
|
|
7637
|
+
}
|
|
7638
|
+
|
|
7639
|
+
private fun canConnectClassicAudio(device: BluetoothDevice): Boolean =
|
|
7640
|
+
!isKilled && !pairingYieldActive && isConnected && notificationsEnabled &&
|
|
7641
|
+
ctkdInitiatedThisGattSession && connectedDevice?.address == device.address &&
|
|
7642
|
+
device.bondState == BluetoothDevice.BOND_BONDED
|
|
7643
|
+
|
|
7590
7644
|
/** A2DP profile service listener for connecting to already-bonded devices */
|
|
7591
7645
|
private val a2dpServiceListener: BluetoothProfile.ServiceListener =
|
|
7592
7646
|
object : BluetoothProfile.ServiceListener {
|
|
@@ -7635,7 +7689,9 @@ class MentraLive : SGCManager() {
|
|
|
7635
7689
|
Bridge.log("LIVE: A2DP: Skipping connectA2dpWithProxy — SGC destroyed")
|
|
7636
7690
|
return
|
|
7637
7691
|
}
|
|
7638
|
-
if (
|
|
7692
|
+
if (device == null || !canConnectClassicAudio(device) ||
|
|
7693
|
+
!ClassicAudioServiceDiscovery.hasAudioSink(device)) return
|
|
7694
|
+
if (a2dpProfile == null) {
|
|
7639
7695
|
Bridge.log("LIVE: A2DP: Cannot connect - proxy or device is null")
|
|
7640
7696
|
return
|
|
7641
7697
|
}
|
|
@@ -7685,14 +7741,11 @@ class MentraLive : SGCManager() {
|
|
|
7685
7741
|
"LIVE: A2DP: Retry $a2dpConnectAttempts/$A2DP_CONNECT_MAX_ATTEMPTS in " +
|
|
7686
7742
|
"${A2DP_CONNECT_RETRY_MS}ms ($reason)"
|
|
7687
7743
|
)
|
|
7744
|
+
val gatt = bluetoothGatt
|
|
7688
7745
|
handler.postDelayed(
|
|
7689
7746
|
Runnable {
|
|
7690
|
-
if (
|
|
7691
|
-
|
|
7692
|
-
}
|
|
7693
|
-
val target = connectedDevice ?: return@Runnable
|
|
7694
|
-
if (a2dpProfile != null) {
|
|
7695
|
-
connectA2dpWithProxy(target)
|
|
7747
|
+
if (bluetoothGatt === gatt && canConnectClassicAudio(device)) {
|
|
7748
|
+
connectA2dpWithProxy(device)
|
|
7696
7749
|
}
|
|
7697
7750
|
},
|
|
7698
7751
|
A2DP_CONNECT_RETRY_MS
|
|
@@ -7912,6 +7965,7 @@ class MentraLive : SGCManager() {
|
|
|
7912
7965
|
|
|
7913
7966
|
// Mark as killed to prevent reconnection attempts
|
|
7914
7967
|
isKilled = true
|
|
7968
|
+
classicAudioServiceDiscovery.cancel()
|
|
7915
7969
|
explicitConnectByName = false
|
|
7916
7970
|
pairingYieldActive = false
|
|
7917
7971
|
pairingYieldAwaitingReclaim = false
|
|
@@ -7933,7 +7987,11 @@ class MentraLive : SGCManager() {
|
|
|
7933
7987
|
|
|
7934
7988
|
// Tear down Classic (A2DP/HFP) before GATT close. Closing only the proxy left the
|
|
7935
7989
|
// phone's system Bluetooth UI showing Mentra Live still connected.
|
|
7936
|
-
|
|
7990
|
+
// BLE loss clears connectedDevice while the OS audio profiles can remain connected.
|
|
7991
|
+
// Use this session's existing Classic target before resetting the tracker below.
|
|
7992
|
+
val classicDevice = connectedDevice ?: classicAudioConnectionTracker.targetForDisconnect()?.let {
|
|
7993
|
+
bluetoothAdapter?.getRemoteDevice(it)
|
|
7994
|
+
}
|
|
7937
7995
|
disconnectClassicProfiles(classicDevice)
|
|
7938
7996
|
closeA2dpProxy()
|
|
7939
7997
|
classicAudioConnectionTracker.reset()
|
|
@@ -8010,6 +8068,7 @@ class MentraLive : SGCManager() {
|
|
|
8010
8068
|
glassesReadyReceived = false
|
|
8011
8069
|
audioConnected = false
|
|
8012
8070
|
ctkdInitiatedThisGattSession = false
|
|
8071
|
+
classicAudioServiceDiscovery.cancel()
|
|
8013
8072
|
DeviceStore.apply("glasses", "fullyBooted", false)
|
|
8014
8073
|
updateConnectionState(ConnTypes.DISCONNECTED)
|
|
8015
8074
|
|
|
@@ -9497,7 +9556,11 @@ class MentraLive : SGCManager() {
|
|
|
9497
9556
|
}
|
|
9498
9557
|
} else {
|
|
9499
9558
|
// Normal single message transmission
|
|
9500
|
-
transportLog(
|
|
9559
|
+
transportLog(
|
|
9560
|
+
"LIVE: Sending data to glasses: " +
|
|
9561
|
+
loggableOutgoingPayload(wireData, commandTraceInfo.commandType),
|
|
9562
|
+
bridgeLogging
|
|
9563
|
+
)
|
|
9501
9564
|
|
|
9502
9565
|
// Pack the data using the centralized utility with the negotiated endianness
|
|
9503
9566
|
val packedData =
|
|
@@ -9799,6 +9862,18 @@ class MentraLive : SGCManager() {
|
|
|
9799
9862
|
}
|
|
9800
9863
|
}
|
|
9801
9864
|
|
|
9865
|
+
/**
|
|
9866
|
+
* Credentials are sent unchanged but never logged; the BLE trace redacts their fields.
|
|
9867
|
+
*/
|
|
9868
|
+
private fun loggableOutgoingPayload(payload: String, commandType: String): String =
|
|
9869
|
+
try {
|
|
9870
|
+
val json = JSONObject(payload)
|
|
9871
|
+
if (json.has("password") || json.has("coreToken")) "<$commandType with credentials omitted>"
|
|
9872
|
+
else payload
|
|
9873
|
+
} catch (_: JSONException) {
|
|
9874
|
+
payload
|
|
9875
|
+
}
|
|
9876
|
+
|
|
9802
9877
|
private fun summarizeOutgoingMessage(payload: String?): String {
|
|
9803
9878
|
if (payload == null || payload.isEmpty()) {
|
|
9804
9879
|
return "type=unknown, requestId=none, appId=none, mode=none, transferMethod=none, bleImgId=none, exposureTimeNs=none, iso=none, mId=none"
|
|
@@ -103,7 +103,9 @@ internal object NimoCanvasCodec {
|
|
|
103
103
|
require(text.length <= 2048 && '\u0000' !in text)
|
|
104
104
|
val encoder = Charsets.UTF_8.newEncoder().onMalformedInput(CodingErrorAction.REPORT)
|
|
105
105
|
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
|
106
|
-
|
|
106
|
+
// Apply the shared Latin fallback and NIMO's dash fix before counting wire bytes.
|
|
107
|
+
val displayText = sanitizeLatinDisplayText(text, preserveOtherScripts = true).replace('\u2014', '-')
|
|
108
|
+
val encoded = encoder.encode(CharBuffer.wrap(displayText))
|
|
107
109
|
val utf8 = ByteArray(encoded.remaining()).also { encoded.get(it) }
|
|
108
110
|
require(utf8.size in 1..2048) { "Label exceeds 2048 UTF-8 bytes" }
|
|
109
111
|
return Object(3, words(x, y, width, height, language) + bytes(font, intensity) + words(utf8.size) + utf8,
|
|
@@ -12,7 +12,61 @@ class BluetoothSdkAnalyticsTrackerTest {
|
|
|
12
12
|
model: String = "Mentra Live",
|
|
13
13
|
serial: String = "",
|
|
14
14
|
fullyBooted: Boolean = connected,
|
|
15
|
-
|
|
15
|
+
battery: Int = -1,
|
|
16
|
+
charging: Boolean = false,
|
|
17
|
+
) = AnalyticsGlassesSnapshot(
|
|
18
|
+
connected = connected,
|
|
19
|
+
fullyBooted = fullyBooted,
|
|
20
|
+
model = model,
|
|
21
|
+
serialNumber = serial,
|
|
22
|
+
batteryLevel = battery,
|
|
23
|
+
charging = charging,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
@Test
|
|
27
|
+
fun `a counted connection reports its length, reason and battery when it ends`() {
|
|
28
|
+
tracker.initialize(snapshot(connected = false), reportingDay = 100)
|
|
29
|
+
tracker.observe(snapshot(connected = true), reportingDay = 100, nowMs = 1_000)
|
|
30
|
+
tracker.observe(snapshot(connected = true, battery = 80), reportingDay = 100, nowMs = 2_000)
|
|
31
|
+
tracker.observe(snapshot(connected = true, battery = 71, charging = true), reportingDay = 100, nowMs = 3_000)
|
|
32
|
+
|
|
33
|
+
val ended =
|
|
34
|
+
tracker.observe(snapshot(connected = false), reportingDay = 100, nowMs = 61_000) {
|
|
35
|
+
BluetoothSdkAnalyticsTracker.DISCONNECT_USER
|
|
36
|
+
}
|
|
37
|
+
val disconnected = ended.single()
|
|
38
|
+
assertThat(disconnected.name).isEqualTo("bluetooth_sdk_glasses_disconnected")
|
|
39
|
+
assertThat(disconnected.properties)
|
|
40
|
+
.containsEntry("session_duration_ms", 60_000L)
|
|
41
|
+
.containsEntry("disconnect_reason", "user_disconnect")
|
|
42
|
+
.containsEntry("battery_level_at_connect", 80)
|
|
43
|
+
.containsEntry("battery_level_at_disconnect", 71)
|
|
44
|
+
.containsEntry("charging", true)
|
|
45
|
+
.containsEntry("glasses_model", "Mentra Live")
|
|
46
|
+
|
|
47
|
+
// Further disconnected updates do not report the connection again.
|
|
48
|
+
assertThat(tracker.observe(snapshot(connected = false), reportingDay = 100, nowMs = 62_000)).isEmpty()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@Test
|
|
52
|
+
fun `a connection already up when the SDK started is not reported as ending`() {
|
|
53
|
+
tracker.initialize(snapshot(connected = true, serial = "MLAB0001"), reportingDay = 100)
|
|
54
|
+
assertThat(tracker.observe(snapshot(connected = false), reportingDay = 100, nowMs = 5_000)).isEmpty()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@Test
|
|
58
|
+
fun `battery from a previous connection does not leak into the next one`() {
|
|
59
|
+
tracker.initialize(snapshot(connected = false), reportingDay = 100)
|
|
60
|
+
tracker.observe(snapshot(connected = true, battery = 90), reportingDay = 100, nowMs = 0)
|
|
61
|
+
tracker.observe(snapshot(connected = false), reportingDay = 100, nowMs = 1_000)
|
|
62
|
+
tracker.observe(snapshot(connected = true), reportingDay = 100, nowMs = 2_000)
|
|
63
|
+
val second = tracker.observe(snapshot(connected = false), reportingDay = 100, nowMs = 3_000).single()
|
|
64
|
+
assertThat(second.properties)
|
|
65
|
+
.doesNotContainKey("battery_level_at_connect")
|
|
66
|
+
.doesNotContainKey("battery_level_at_disconnect")
|
|
67
|
+
.doesNotContainKey("charging")
|
|
68
|
+
.containsEntry("disconnect_reason", "unknown")
|
|
69
|
+
}
|
|
16
70
|
|
|
17
71
|
@Test
|
|
18
72
|
fun `connect then serial emits connected and identified once, reconnects do not repeat identification within a connection`() {
|
|
@@ -55,8 +109,9 @@ class BluetoothSdkAnalyticsTrackerTest {
|
|
|
55
109
|
tracker.initialize(snapshot(connected = false), reportingDay = 100)
|
|
56
110
|
tracker.observe(snapshot(connected = true, model = ""), reportingDay = 100)
|
|
57
111
|
val ended = tracker.observe(snapshot(connected = false, model = ""), reportingDay = 100)
|
|
58
|
-
assertThat(ended.
|
|
59
|
-
|
|
112
|
+
assertThat(ended.map { it.name })
|
|
113
|
+
.containsExactly("bluetooth_sdk_glasses_connected", "bluetooth_sdk_glasses_disconnected")
|
|
114
|
+
assertThat(ended.first().properties).containsEntry("glasses_model_unresolved", true).doesNotContainKey("glasses_model")
|
|
60
115
|
}
|
|
61
116
|
|
|
62
117
|
@Test
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk
|
|
2
|
+
|
|
3
|
+
import androidx.test.core.app.ApplicationProvider
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
6
|
+
import org.json.JSONObject
|
|
7
|
+
import org.junit.Test
|
|
8
|
+
import org.junit.runner.RunWith
|
|
9
|
+
import org.robolectric.RobolectricTestRunner
|
|
10
|
+
import org.robolectric.annotation.Config
|
|
11
|
+
|
|
12
|
+
@RunWith(RobolectricTestRunner::class)
|
|
13
|
+
@Config(sdk = [33])
|
|
14
|
+
class BluetoothSdkUpdateCheckTest {
|
|
15
|
+
private val context: Context = ApplicationProvider.getApplicationContext()
|
|
16
|
+
|
|
17
|
+
private fun result(status: String, latest: String? = "3.2.1", minimum: String? = null) =
|
|
18
|
+
BluetoothSdkUpdateCheckResult(status, "latest", latest, minimum)
|
|
19
|
+
|
|
20
|
+
@Test
|
|
21
|
+
fun `parses the Core response, treating null and blank versions as unknown`() {
|
|
22
|
+
val parsed =
|
|
23
|
+
BluetoothSdkUpdateCheckResult.parse(
|
|
24
|
+
"""{"status":"update_available","channel":"dev","latestVersion":"3.3.0-dev.821","minimumSupportedVersion":null}""",
|
|
25
|
+
)!!
|
|
26
|
+
assertThat(parsed).isEqualTo(BluetoothSdkUpdateCheckResult("update_available", "dev", "3.3.0-dev.821", null))
|
|
27
|
+
assertThat(BluetoothSdkUpdateCheckResult.parse("""{"status":"current","latestVersion":""}""")!!.latestVersion).isNull()
|
|
28
|
+
assertThat(BluetoothSdkUpdateCheckResult.parse("<html>502</html>")).isNull()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@Test
|
|
32
|
+
fun `the banner names the installed and latest versions and the surface's update command`() {
|
|
33
|
+
val reactNative = BluetoothSdkUpdateNotice.banner("3.1.0", result("update_available"), "react_native")!!
|
|
34
|
+
assertThat(reactNative)
|
|
35
|
+
.contains("MENTRA BLUETOOTH SDK UPDATE AVAILABLE")
|
|
36
|
+
.contains("Installed: 3.1.0")
|
|
37
|
+
.contains("Latest: 3.2.1")
|
|
38
|
+
.contains("bun add @mentra/bluetooth-sdk@3.2.1")
|
|
39
|
+
.doesNotContain("NO LONGER SUPPORTED")
|
|
40
|
+
assertThat(BluetoothSdkUpdateNotice.banner("3.1.0", result("update_available"), "android")!!)
|
|
41
|
+
.contains("com.mentraglass:bluetooth-sdk:3.2.1")
|
|
42
|
+
assertThat(BluetoothSdkUpdateNotice.banner("3.1.0", result("update_available"), "ios")!!)
|
|
43
|
+
.contains("MentraBluetoothSDK Swift package at 3.2.1")
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@Test
|
|
47
|
+
fun `an unsupported version gets the stronger banner`() {
|
|
48
|
+
val banner = BluetoothSdkUpdateNotice.banner("3.0.5", result("unsupported", minimum = "3.1.0"), "android")!!
|
|
49
|
+
assertThat(banner)
|
|
50
|
+
.contains("MENTRA BLUETOOTH SDK 3.0.5 IS NO LONGER SUPPORTED")
|
|
51
|
+
.contains("known issues")
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@Test
|
|
55
|
+
fun `no banner when current, unknown, or no latest version to point at`() {
|
|
56
|
+
assertThat(BluetoothSdkUpdateNotice.banner("3.2.1", result("current"), "android")).isNull()
|
|
57
|
+
assertThat(BluetoothSdkUpdateNotice.banner("3.2.1", result("unknown", latest = null), "android")).isNull()
|
|
58
|
+
assertThat(BluetoothSdkUpdateNotice.banner("3.2.1", result("update_available", latest = null), "android")).isNull()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@Test
|
|
62
|
+
fun `the request identifies the install, app and SDK`() {
|
|
63
|
+
val body = BluetoothSdkUpdateCheck(context, "react_native", sdkVersion = "3.1.0", transport = { null }) { _, _ -> }.requestBody()
|
|
64
|
+
assertThat(body.getString("install_id")).isEqualTo(BluetoothSdkInstallId.get(context)).startsWith("mentra-bt-sdk-")
|
|
65
|
+
assertThat(body.getString("app_identifier")).isEqualTo(context.packageName)
|
|
66
|
+
assertThat(body.getString("sdk_version")).isEqualTo("3.1.0")
|
|
67
|
+
assertThat(body.getString("sdk_platform")).isEqualTo("android")
|
|
68
|
+
assertThat(body.getString("sdk_surface")).isEqualTo("react_native")
|
|
69
|
+
assertThat(body.has("app_build_type")).isTrue()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@Test
|
|
73
|
+
fun `an out-of-date SDK prints the banner once at start and stops on close`() {
|
|
74
|
+
val notices = mutableListOf<String>()
|
|
75
|
+
var sentBody: String? = null
|
|
76
|
+
val check =
|
|
77
|
+
BluetoothSdkUpdateCheck(
|
|
78
|
+
context,
|
|
79
|
+
"android",
|
|
80
|
+
sdkVersion = "3.1.0",
|
|
81
|
+
transport = { body ->
|
|
82
|
+
sentBody = body
|
|
83
|
+
"""{"status":"update_available","channel":"latest","latestVersion":"3.2.1","minimumSupportedVersion":null}"""
|
|
84
|
+
},
|
|
85
|
+
) { banner, _ -> notices += banner }
|
|
86
|
+
check.start()
|
|
87
|
+
BluetoothSdkUpdateCheck.awaitIdle()
|
|
88
|
+
assertThat(notices).hasSize(1)
|
|
89
|
+
assertThat(notices.single()).contains("Latest: 3.2.1")
|
|
90
|
+
assertThat(JSONObject(sentBody!!).getString("sdk_version")).isEqualTo("3.1.0")
|
|
91
|
+
check.stop()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
@Test
|
|
95
|
+
fun `failures and unknown versions are silent`() {
|
|
96
|
+
val notices = mutableListOf<String>()
|
|
97
|
+
BluetoothSdkUpdateCheck(context, "android", sdkVersion = "3.1.0", transport = { null }) { banner, _ -> notices += banner }.start()
|
|
98
|
+
BluetoothSdkUpdateCheck(context, "android", sdkVersion = "3.1.0", transport = { throw IllegalStateException("offline") }) { banner, _ -> notices += banner }.start()
|
|
99
|
+
var called = false
|
|
100
|
+
BluetoothSdkUpdateCheck(context, "android", sdkVersion = "unknown", transport = { called = true; null }) { banner, _ -> notices += banner }.start()
|
|
101
|
+
BluetoothSdkUpdateCheck.awaitIdle()
|
|
102
|
+
assertThat(notices).isEmpty()
|
|
103
|
+
assertThat(called).isFalse()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@Test
|
|
107
|
+
fun `a check stopped before the response arrives prints nothing`() {
|
|
108
|
+
val notices = mutableListOf<String>()
|
|
109
|
+
lateinit var check: BluetoothSdkUpdateCheck
|
|
110
|
+
check =
|
|
111
|
+
BluetoothSdkUpdateCheck(
|
|
112
|
+
context,
|
|
113
|
+
"android",
|
|
114
|
+
sdkVersion = "3.1.0",
|
|
115
|
+
transport = {
|
|
116
|
+
check.stop()
|
|
117
|
+
"""{"status":"update_available","latestVersion":"3.2.1"}"""
|
|
118
|
+
},
|
|
119
|
+
) { banner, _ -> notices += banner }
|
|
120
|
+
check.start()
|
|
121
|
+
BluetoothSdkUpdateCheck.awaitIdle()
|
|
122
|
+
assertThat(notices).isEmpty()
|
|
123
|
+
}
|
|
124
|
+
}
|