@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.
Files changed (95) hide show
  1. package/README.md +68 -6
  2. package/android/build.gradle +15 -0
  3. package/android/lc3Lib/build.gradle +6 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +63 -14
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +55 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +1 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkInstallId.kt +23 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +2 -2
  9. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheck.kt +184 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTracker.kt +388 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +4 -1
  12. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +2 -1
  13. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +6 -6
  14. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +223 -36
  15. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +24 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/NetworkDiagnostics.kt +25 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +3 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +4 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscovery.kt +70 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +11 -32
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/LatinTextSanitizer.kt +39 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +93 -18
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +3 -1
  24. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +58 -3
  25. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheckTest.kt +124 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTrackerTest.kt +263 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/NetworkDiagnosticsTest.kt +55 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/ScanCompletionTest.kt +5 -1
  29. package/android/src/test/java/com/mentra/bluetoothsdk/debug/BleTraceLoggerTest.kt +69 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscoveryTest.kt +124 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +29 -4
  32. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveClassicDisconnectTest.kt +133 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveIncidentDispatchTest.kt +126 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveWifiCredentialLogTest.kt +55 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +20 -7
  36. package/build/BluetoothSdk.types.d.ts +3 -0
  37. package/build/BluetoothSdk.types.d.ts.map +1 -1
  38. package/build/BluetoothSdk.types.js.map +1 -1
  39. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  40. package/build/_private/BluetoothSdkModule.js +2 -1
  41. package/build/_private/BluetoothSdkModule.js.map +1 -1
  42. package/build/_private/MentraLocalNetworkModule.d.ts +23 -0
  43. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  44. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  45. package/build/_private/nativeLogConsole.d.ts +19 -0
  46. package/build/_private/nativeLogConsole.d.ts.map +1 -1
  47. package/build/_private/nativeLogConsole.js +12 -0
  48. package/build/_private/nativeLogConsole.js.map +1 -1
  49. package/build/generated/changelogCatalog.d.ts +4 -1
  50. package/build/generated/changelogCatalog.d.ts.map +1 -1
  51. package/build/generated/changelogCatalog.js +5 -1
  52. package/build/generated/changelogCatalog.js.map +1 -1
  53. package/build/generated/releaseMetadata.js +6 -6
  54. package/build/generated/releaseMetadata.js.map +1 -1
  55. package/build/ota-transport/index.d.ts +4 -0
  56. package/build/ota-transport/index.d.ts.map +1 -1
  57. package/build/ota-transport/index.js +2 -0
  58. package/build/ota-transport/index.js.map +1 -1
  59. package/ios/BluetoothSdkModule.swift +8 -4
  60. package/ios/Source/BluetoothSdkDefaults.swift +2 -2
  61. package/ios/Source/DeviceManager.swift +6 -3
  62. package/ios/Source/GeneratedChangelogCatalog.swift +2 -1
  63. package/ios/Source/GeneratedReleaseMetadata.swift +6 -6
  64. package/ios/Source/MentraBluetoothSDK.swift +295 -54
  65. package/ios/Source/internal/BluetoothAvailability.swift +6 -0
  66. package/ios/Source/internal/BluetoothSdkAnalytics.swift +87 -13
  67. package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +54 -1
  68. package/ios/Source/internal/BluetoothSdkInstallId.swift +21 -0
  69. package/ios/Source/internal/BluetoothSdkUpdateCheck.swift +223 -0
  70. package/ios/Source/internal/BluetoothSdkUsageTracker.swift +428 -0
  71. package/ios/Source/sgcs/MentraLive.swift +71 -41
  72. package/ios/Source/sgcs/NimoCanvasCodec.swift +4 -1
  73. package/ios/Source/sgcs/SGCManager.swift +1 -1
  74. package/ios/Source/utils/AudioSessionMonitor.swift +44 -8
  75. package/ios/Source/utils/G1Text.swift +2 -24
  76. package/ios/Source/utils/G1TextSanitizer.swift +27 -0
  77. package/ios/Source/utils/LatinTextSanitizer.swift +48 -0
  78. package/ios/Tests/BluetoothAudioRouteTests.swift +26 -0
  79. package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +54 -3
  80. package/ios/Tests/BluetoothSdkUpdateCheckTests.swift +153 -0
  81. package/ios/Tests/BluetoothSdkUsageTrackerTests.swift +296 -0
  82. package/ios/Tests/G1TextSanitizerTests.swift +32 -0
  83. package/ios/Tests/IncidentLogDispatchTests.swift +111 -0
  84. package/ios/Tests/MentraLiveSessionReadinessTests.swift +163 -0
  85. package/ios/Tests/NimoCanvasCodecTests.swift +17 -4
  86. package/ios/Tests/VersionInfoCommandTests.swift +142 -1
  87. package/ios/Tests/WifiCredentialLoggingTests.swift +42 -0
  88. package/package.json +2 -2
  89. package/src/BluetoothSdk.types.ts +1 -0
  90. package/src/_private/BluetoothSdkModule.ts +2 -1
  91. package/src/_private/MentraLocalNetworkModule.ts +28 -0
  92. package/src/_private/nativeLogConsole.ts +19 -0
  93. package/src/generated/changelogCatalog.ts +5 -1
  94. package/src/generated/releaseMetadata.ts +6 -6
  95. 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(json: JSONObject?, wakeup: Boolean, bridgeLogging: Boolean): Boolean {
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
- sendJson(json, true)
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: Sent incidentId to glasses for log upload: " +
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
- Log.e(TAG, "Error creating upload_incident_logs command", e)
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: ✅ Successfully bonded with device - BT Classic connection established"
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
- connectA2dpProfile(device)
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 - connecting A2DP audio profile"
7569
+ "LIVE: CTKD: Device is already bonded - checking Classic audio services"
7529
7570
  )
7530
7571
  markPairingTiming("ctkd_already_bonded")
7531
- connectA2dpProfile(device)
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 (a2dpProfile == null || device == null) {
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 (isKilled || pairingYieldActive || connectedDevice == null) {
7691
- return@Runnable
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
- val classicDevice = connectedDevice
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("LIVE: Sending data to glasses: " + wireData, bridgeLogging)
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
- val encoded = encoder.encode(CharBuffer.wrap(text))
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
- ) = AnalyticsGlassesSnapshot(connected = connected, fullyBooted = fullyBooted, model = model, serialNumber = serial)
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.single().name).isEqualTo("bluetooth_sdk_glasses_connected")
59
- assertThat(ended.single().properties).containsEntry("glasses_model_unresolved", true).doesNotContainKey("glasses_model")
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
+ }