@mentra/bluetooth-sdk 3.2.1-dev.314 → 3.2.1

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 (133) hide show
  1. package/README.md +4 -4
  2. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
  3. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +3 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +6 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +129 -25
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +2 -2
  10. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +4 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
  13. package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +1 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +72 -21
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +101 -22
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -8
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +314 -395
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +245 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +238 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +180 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddress.kt +17 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  29. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/DashboardContentFormatterTest.kt +2 -2
  31. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceIdentityPairingTest.kt +59 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +666 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
  36. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1PairingTest.kt +105 -0
  37. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
  38. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2PairingTargetTest.kt +90 -0
  39. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveFileTransferSessionTest.kt +55 -0
  40. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
  41. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingTargetTest.kt +65 -0
  42. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraNexPairingTest.kt +151 -0
  43. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  44. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +220 -0
  45. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +348 -0
  46. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +339 -0
  47. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
  48. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
  49. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddressTest.kt +27 -0
  50. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  51. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  52. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +288 -0
  53. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +205 -0
  54. package/build/BluetoothSdk.types.d.ts +13 -0
  55. package/build/BluetoothSdk.types.d.ts.map +1 -1
  56. package/build/BluetoothSdk.types.js +1 -0
  57. package/build/BluetoothSdk.types.js.map +1 -1
  58. package/build/_private/BluetoothSdkModule.d.ts +4 -0
  59. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  60. package/build/_private/BluetoothSdkModule.js +3 -0
  61. package/build/_private/BluetoothSdkModule.js.map +1 -1
  62. package/build/generated/changelogCatalog.d.ts +2 -2
  63. package/build/generated/changelogCatalog.js +2 -2
  64. package/build/generated/changelogCatalog.js.map +1 -1
  65. package/build/generated/releaseMetadata.js +5 -5
  66. package/build/generated/releaseMetadata.js.map +1 -1
  67. package/build/index.d.ts.map +1 -1
  68. package/build/index.js +1 -0
  69. package/build/index.js.map +1 -1
  70. package/ios/BluetoothSdkModule.swift +9 -0
  71. package/ios/MentraBluetoothSDK.podspec +3 -2
  72. package/ios/Source/BluetoothBackgroundMode.swift +32 -0
  73. package/ios/Source/BluetoothSdkDefaults.swift +6 -1
  74. package/ios/Source/Bridge.swift +15 -2
  75. package/ios/Source/DeviceManager.swift +96 -63
  76. package/ios/Source/DeviceStore.swift +26 -7
  77. package/ios/Source/GeneratedChangelogCatalog.swift +2 -2
  78. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  79. package/ios/Source/MentraBluetoothSDK.swift +18 -0
  80. package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
  81. package/ios/Source/requests/DisplayRequests.swift +1 -1
  82. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
  83. package/ios/Source/sgcs/Ar99.swift +1 -2
  84. package/ios/Source/sgcs/G1.swift +68 -38
  85. package/ios/Source/sgcs/G1ConnectionTarget.swift +13 -0
  86. package/ios/Source/sgcs/G2.swift +261 -247
  87. package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
  88. package/ios/Source/sgcs/Mach1.swift +19 -7
  89. package/ios/Source/sgcs/MentraLive.swift +201 -156
  90. package/ios/Source/sgcs/MentraLiveFileTransferSession.swift +137 -0
  91. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +47 -0
  92. package/ios/Source/sgcs/MentraNex.swift +8 -31
  93. package/ios/Source/sgcs/NexConnectionTarget.swift +15 -0
  94. package/ios/Source/sgcs/Nimo.swift +280 -408
  95. package/ios/Source/sgcs/NimoCanvasCodec.swift +269 -0
  96. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +233 -0
  97. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  98. package/ios/Source/sgcs/NimoCanvasSession.swift +166 -0
  99. package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
  100. package/ios/Source/sgcs/SGCManager.swift +24 -0
  101. package/ios/Source/sgcs/VuzixConnectionTarget.swift +15 -0
  102. package/ios/Source/status/DeviceStatus.swift +1 -0
  103. package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
  104. package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
  105. package/ios/Source/utils/G2Text.swift +11 -0
  106. package/ios/Tests/BluetoothBackgroundModeTests.swift +32 -0
  107. package/ios/Tests/DashboardContentFormatterTests.swift +2 -2
  108. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  109. package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
  110. package/ios/Tests/DeviceIdentityPairingTests.swift +49 -0
  111. package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
  112. package/ios/Tests/G1PairingTests.swift +162 -0
  113. package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
  114. package/ios/Tests/G2TextTests.swift +10 -0
  115. package/ios/Tests/MentraLiveFileTransferSessionTests.swift +47 -0
  116. package/ios/Tests/MentraLiveReconnectPolicyTests.swift +58 -0
  117. package/ios/Tests/NimoCanvasCodecTests.swift +204 -0
  118. package/ios/Tests/NimoCanvasSessionTests.swift +181 -0
  119. package/ios/Tests/NimoCanvasTransportTests.swift +186 -0
  120. package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
  121. package/ios/Tests/NimoDiscoveryTests.swift +79 -0
  122. package/ios/Tests/SGCConnectionTargetTests.swift +48 -0
  123. package/ios/Tests/VersionInfoCommandTests.swift +53 -0
  124. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
  125. package/ios/Tests/VideoRecordingCommandTests.swift +81 -0
  126. package/package.json +2 -2
  127. package/plugin/build/withIos.d.ts +2 -0
  128. package/plugin/build/withIos.js +12 -1
  129. package/src/BluetoothSdk.types.ts +14 -0
  130. package/src/_private/BluetoothSdkModule.ts +8 -0
  131. package/src/generated/changelogCatalog.ts +2 -2
  132. package/src/generated/releaseMetadata.ts +5 -5
  133. package/src/index.ts +1 -0
@@ -13,6 +13,7 @@ import androidx.core.content.ContextCompat
13
13
  import com.mentra.bluetoothsdk.controllers.ControllerManager
14
14
  import com.mentra.bluetoothsdk.controllers.R1
15
15
  import com.mentra.bluetoothsdk.services.ForegroundService
16
+ import com.mentra.bluetoothsdk.services.G2ConnectionRecovery
16
17
  import com.mentra.bluetoothsdk.services.PhoneMic
17
18
  import com.mentra.bluetoothsdk.sgcs.Ar99
18
19
  import com.mentra.bluetoothsdk.sgcs.G1
@@ -34,7 +35,10 @@ import com.mentra.bluetoothsdk.utils.MicTypes
34
35
  import com.mentra.bluetoothsdk.utils.PhoneAudioMonitor
35
36
  import com.mentra.lc3Lib.Lc3Cpp
36
37
  import com.mentra.bluetoothsdk.stt.SherpaOnnxTranscriber
38
+ import kotlin.coroutines.resume
39
+ import kotlinx.coroutines.suspendCancellableCoroutine
37
40
  import kotlinx.coroutines.CoroutineScope
41
+ import kotlinx.coroutines.withContext
38
42
  import kotlinx.coroutines.Dispatchers
39
43
  import kotlinx.coroutines.launch
40
44
  import java.text.SimpleDateFormat
@@ -45,12 +49,15 @@ import java.util.concurrent.Executors
45
49
  import java.util.concurrent.TimeUnit
46
50
  import kotlin.jvm.JvmStatic
47
51
 
48
- class DeviceManager {
52
+ class DeviceManager internal constructor(initializeHardware: Boolean) {
53
+ constructor() : this(initializeHardware = true)
49
54
  companion object {
50
55
 
51
56
  @Volatile
52
57
  private var _instance: DeviceManager? = null
53
58
 
59
+ internal fun isInitialized(): Boolean = _instance != null
60
+
54
61
  @JvmStatic
55
62
  fun getInstance(): DeviceManager {
56
63
  return _instance
@@ -59,6 +66,10 @@ class DeviceManager {
59
66
  }
60
67
 
61
68
  // MARK: - Unique (Android)
69
+ private val connectionRecovery = G2ConnectionRecovery(Bridge.getContext())
70
+ private val recoveryLock = Any()
71
+ private var recoverG2Connection = false
72
+ private var recoveryListenerId: String? = null
62
73
  private var serviceStarted = false
63
74
  private val mainHandler = Handler(Looper.getMainLooper())
64
75
  private val executor: ExecutorService = Executors.newSingleThreadExecutor()
@@ -79,7 +90,8 @@ class DeviceManager {
79
90
  // MARK: - End Unique
80
91
 
81
92
  // MARK: - Properties
82
- var sgc: SGCManager? = null
93
+ // Read by the welcome executor as well as device lifecycle handlers.
94
+ @Volatile var sgc: SGCManager? = null
83
95
  var controller: ControllerManager? = null
84
96
 
85
97
  // settings:
@@ -283,7 +295,22 @@ class DeviceManager {
283
295
 
284
296
  init {
285
297
  Bridge.log("DeviceManager: init()")
298
+ recoveryListenerId = DeviceStore.store.addListener { category, changes ->
299
+ if (category == "bluetooth" && changes.keys.any { it in G2ConnectionRecovery.keys }) {
300
+ synchronized(recoveryLock) {
301
+ if (recoverG2Connection) {
302
+ connectionRecovery.save(DeviceStore.store.getCategory("bluetooth"))
303
+ }
304
+ }
305
+ }
306
+ }
286
307
  initializeViewStates()
308
+ if (initializeHardware) initializeHardwareServices()
309
+ }
310
+
311
+ // Keep host routing usable without starting audio codecs, receivers or services.
312
+ // The public constructor always initializes the hardware environment.
313
+ private fun initializeHardwareServices() {
287
314
  startForegroundService()
288
315
  // setupPermissionMonitoring()
289
316
  setupBluetoothStateMonitoring()
@@ -1354,7 +1381,8 @@ class DeviceManager {
1354
1381
  Bridge.log("MAN: Cleaning up previous sgc type: ${sgc?.type}")
1355
1382
  sgc?.cleanup()
1356
1383
  sgc = null
1357
- resetSystemTimeSync()
1384
+ DeviceStore.apply("glasses", "micEnabled", false)
1385
+ resetConnectionReadyState()
1358
1386
  }
1359
1387
 
1360
1388
  if (sgc != null) {
@@ -1477,12 +1505,16 @@ class DeviceManager {
1477
1505
  return
1478
1506
  }
1479
1507
 
1508
+ // A new identity must never inherit the previous glasses' address when
1509
+ // pairing supplied only a name. Same-device reconnects retain their cache.
1510
+ val identityChanged = pendingDeviceName.isNotEmpty() &&
1511
+ (pendingDeviceName != deviceName || sgc?.type != defaultWearable)
1512
+ if (identityChanged || pendingDeviceAddress.isNotEmpty()) {
1513
+ deviceAddress = pendingDeviceAddress
1514
+ }
1480
1515
  if (pendingDeviceName.isNotEmpty()) {
1481
1516
  deviceName = pendingDeviceName
1482
1517
  }
1483
- if (pendingDeviceAddress.isNotEmpty()) {
1484
- deviceAddress = pendingDeviceAddress
1485
- }
1486
1518
  clearPendingConnection()
1487
1519
 
1488
1520
  val readyKey = "${sgc?.type}:${deviceName}"
@@ -1497,6 +1529,10 @@ class DeviceManager {
1497
1529
  Bridge.log("MAN: handleDeviceReady() ${sgc?.type}")
1498
1530
  resetMicHealth()
1499
1531
  defaultWearable = sgc?.type ?: ""
1532
+ synchronized(recoveryLock) {
1533
+ recoverG2Connection = defaultWearable == DeviceTypes.G2
1534
+ connectionRecovery.save(DeviceStore.store.getCategory("bluetooth"))
1535
+ }
1500
1536
  searching = false
1501
1537
 
1502
1538
  syncSystemTimeOnceForConnection(readyKey)
@@ -1504,6 +1540,7 @@ class DeviceManager {
1504
1540
  // re-apply display height/depth after reconnection
1505
1541
  mainHandler.postDelayed(
1506
1542
  {
1543
+ val device = sgc ?: return@postDelayed
1507
1544
  val h =
1508
1545
  (DeviceStore.store.get("bluetooth", "dashboard_height") as? Number)
1509
1546
  ?.toInt()
@@ -1512,19 +1549,24 @@ class DeviceManager {
1512
1549
  (DeviceStore.store.get("bluetooth", "dashboard_depth") as? Number)
1513
1550
  ?.toInt()
1514
1551
  ?: dashboardDepth // canonical default (2), not 1
1515
- val d = rawDepth.coerceIn(1, 4)
1516
- sgc?.setDashboardPosition(h, d)
1552
+ val d = if (device.type == DeviceTypes.NIMO) rawDepth.coerceIn(0, 10) else rawDepth.coerceIn(1, 4)
1553
+ device.setDashboardPosition(h, d)
1517
1554
  },
1518
1555
  2000
1519
1556
  )
1520
1557
 
1521
- // Show welcome message on first connect for all display glasses
1558
+ // A full-canvas device can opt out so this temporary message does not
1559
+ // erase the host's connected-edge scene replay.
1522
1560
  if (shouldSendBootingMessage) {
1523
1561
  shouldSendBootingMessage = false
1524
- executor.execute {
1525
- sgc?.sendTextWall("// MentraOS Connected")
1526
- Thread.sleep(3000)
1527
- sgc?.clearDisplay()
1562
+ val device = sgc
1563
+ if (device?.showConnectionConfirmation == true) {
1564
+ executor.execute {
1565
+ if (sgc !== device) return@execute
1566
+ device.sendTextWall("// MentraOS Connected")
1567
+ Thread.sleep(3000)
1568
+ if (sgc === device) device.clearDisplay()
1569
+ }
1528
1570
  }
1529
1571
  }
1530
1572
 
@@ -1537,10 +1579,11 @@ class DeviceManager {
1537
1579
  handleMach1Ready() // Z100 uses same initialization as Mach1
1538
1580
  }
1539
1581
 
1540
- // Re-apply microphone settings after reconnection
1541
- // Cache was cleared on disconnect, so this will definitely send commands
1582
+ // Disconnect clears micEnabled but preserves the consumers' audio requests.
1583
+ // Recompute that derived flag before selecting a microphone; replaying an
1584
+ // unchanged should_send_* setting is deduplicated by DeviceStore.apply().
1542
1585
  Bridge.log("MAN: Re-applying microphone settings after reconnection")
1543
- updateMicState()
1586
+ setMicState()
1544
1587
 
1545
1588
  // send to the server our battery status:
1546
1589
  Bridge.sendBatteryStatus(sgc?.batteryLevel ?: -1, false)
@@ -1580,7 +1623,11 @@ class DeviceManager {
1580
1623
  mainHandler.postDelayed(sync, 3000)
1581
1624
  }
1582
1625
 
1583
- private fun resetSystemTimeSync() {
1626
+ private fun resetConnectionReadyState() {
1627
+ // Suppress duplicate readiness only within one connection, never across
1628
+ // a genuine disconnect/reconnect of the same glasses inside two seconds.
1629
+ lastReadyHandledKey = ""
1630
+ lastReadyHandledAtMs = 0L
1584
1631
  pendingSystemTimeSync?.let { mainHandler.removeCallbacks(it) }
1585
1632
  pendingSystemTimeSync = null
1586
1633
  lastSystemTimeSyncConnectionKey = ""
@@ -1598,7 +1645,7 @@ class DeviceManager {
1598
1645
 
1599
1646
  fun handleDeviceDisconnected() {
1600
1647
  Bridge.log("MAN: Device disconnected")
1601
- resetSystemTimeSync()
1648
+ resetConnectionReadyState()
1602
1649
  resetMicHealth()
1603
1650
  DeviceStore.apply("glasses", "headUp", false)
1604
1651
  DeviceStore.apply(
@@ -1677,7 +1724,7 @@ class DeviceManager {
1677
1724
  dashboardSceneCleanupPending = false
1678
1725
  val elementIds = pendingDashboardSceneElementIds.toList()
1679
1726
  pendingDashboardSceneElementIds.clear()
1680
- if (elementIds.isNotEmpty()) {
1727
+ if (elementIds.isNotEmpty() && sgc?.sceneHandoffRequiresClear == true) {
1681
1728
  sgc?.clearSceneElements(elementIds)
1682
1729
  }
1683
1730
  }
@@ -1714,7 +1761,7 @@ class DeviceManager {
1714
1761
  sceneStates[stateIndex] = null
1715
1762
  if (stateIndex == 1 && dashboardSceneCleanupPending) {
1716
1763
  pendingDashboardSceneElementIds.addAll(prevFrame.elements.map { it.id })
1717
- } else if (layoutType != "clear_view") {
1764
+ } else if (layoutType != "clear_view" && shouldClearSceneHandoff(stateIndex)) {
1718
1765
  sgc?.clearSceneElements(prevFrame.elements.map { it.id })
1719
1766
  }
1720
1767
  }
@@ -1781,7 +1828,7 @@ class DeviceManager {
1781
1828
  val prevLegacyType = viewStates[stateIndex].layoutType
1782
1829
  val cleanupDeferred = stateIndex == 1 && dashboardSceneCleanupPending
1783
1830
  if (
1784
- !cleanupDeferred &&
1831
+ !cleanupDeferred && shouldClearSceneHandoff(stateIndex) &&
1785
1832
  prevLegacyType.isNotEmpty() &&
1786
1833
  prevLegacyType != "clear_view" &&
1787
1834
  prevLegacyType != "scene"
@@ -1797,7 +1844,7 @@ class DeviceManager {
1797
1844
  // blank.
1798
1845
  if (stateIndex == 1 && dashboardSceneCleanupPending) {
1799
1846
  pendingDashboardSceneElementIds.addAll(prevFrame.elements.map { it.id })
1800
- } else {
1847
+ } else if (shouldClearSceneHandoff(stateIndex)) {
1801
1848
  sgc?.clearSceneElements(prevFrame.elements.map { it.id })
1802
1849
  }
1803
1850
  frame = frame.copy(replay = true, elements = frame.elements.map { it.copy(change = "created") })
@@ -1816,6 +1863,15 @@ class DeviceManager {
1816
1863
  }
1817
1864
  }
1818
1865
 
1866
+ // Hidden-view updates only change their replay slot. They must never clear
1867
+ // the visible view, and full-frame adapters need no separate handoff sweep.
1868
+ private fun shouldClearSceneHandoff(stateIndex: Int): Boolean {
1869
+ val visibleIndex = if (headUp && contextualDashboard) 1 else 0
1870
+ val device = sgc ?: return false
1871
+ return stateIndex == visibleIndex && !screenDisabled && device.fullyBooted &&
1872
+ !device.type.contains(DeviceTypes.SIMULATED) && device.sceneHandoffRequiresClear
1873
+ }
1874
+
1819
1875
  /** Guarded scene dispatch — mirrors sendCurrentState's send conditions. */
1820
1876
  private fun dispatchSceneFrame(stateIndex: Int, frame: SceneFrame) {
1821
1877
  if (screenDisabled) return
@@ -2389,10 +2445,20 @@ class DeviceManager {
2389
2445
  }
2390
2446
 
2391
2447
  fun disconnect(clearPendingIdentity: Boolean = true) {
2448
+ synchronized(recoveryLock) {
2449
+ recoverG2Connection = false
2450
+ connectionRecovery.clear()
2451
+ }
2392
2452
  sgc?.clearDisplay()
2393
- sgc?.disconnect()
2453
+ // NIMO owns a background canvas encoder and this path discards its instance.
2454
+ // A link-level reconnect keeps the instance (and encoder) through disconnect().
2455
+ val device = sgc
2456
+ if (device is Nimo) device.cleanup() else device?.disconnect()
2394
2457
  sgc = null // Clear the SGC reference after disconnect
2395
- resetSystemTimeSync()
2458
+ // This cache belongs to the discarded connection. Keep consumer demand,
2459
+ // but require a new mic-enable command when replacement glasses are ready.
2460
+ DeviceStore.apply("glasses", "micEnabled", false)
2461
+ resetConnectionReadyState()
2396
2462
  resetMicHealth()
2397
2463
  searching = false
2398
2464
  micEnabled = false
@@ -2439,7 +2505,42 @@ class DeviceManager {
2439
2505
  controller = null
2440
2506
  }
2441
2507
 
2508
+ private var unpairInProgress = false
2509
+
2510
+ /** Explicit user Unpair, separate from passive pairing cleanup and logout. */
2511
+ suspend fun unpair() = withContext(Dispatchers.Main) {
2512
+ check(!unpairInProgress) { "Unpair already in progress" }
2513
+ unpairInProgress = true
2514
+ try {
2515
+ val target = sgc
2516
+ val nimo = target as? Nimo
2517
+ val savedNimoAddress = when {
2518
+ defaultWearable == DeviceTypes.NIMO -> deviceAddress
2519
+ pendingWearable == DeviceTypes.NIMO -> pendingDeviceAddress
2520
+ else -> null
2521
+ }
2522
+ if (nimo != null) {
2523
+ suspendCancellableCoroutine<Boolean> { continuation ->
2524
+ nimo.resetForUnpair { if (continuation.isActive) continuation.resume(it) }
2525
+ }
2526
+ check(sgc === target) { "Glasses changed during Unpair" }
2527
+ // Drop the encrypted link before removing the phone's bond.
2528
+ nimo.disconnect()
2529
+ nimo.removeBluetoothBond()
2530
+ } else if (target == null && savedNimoAddress != null) {
2531
+ Nimo.removeBluetoothBond(savedNimoAddress)
2532
+ }
2533
+ forget()
2534
+ } finally {
2535
+ unpairInProgress = false
2536
+ }
2537
+ }
2538
+
2442
2539
  fun forget() {
2540
+ synchronized(recoveryLock) {
2541
+ recoverG2Connection = false
2542
+ connectionRecovery.clear()
2543
+ }
2443
2544
  Bridge.log("MAN: Forgetting smart glasses")
2444
2545
 
2445
2546
  val live = sgc as? MentraLive
@@ -2451,7 +2552,8 @@ class DeviceManager {
2451
2552
  // session state without calling disconnect() again (that would hit a dead instance
2452
2553
  // and leave a destroyed MentraLive retained for the next scan).
2453
2554
  sgc = null
2454
- resetSystemTimeSync()
2555
+ DeviceStore.apply("glasses", "micEnabled", false)
2556
+ resetConnectionReadyState()
2455
2557
  searching = false
2456
2558
  micEnabled = false
2457
2559
  updateMicState()
@@ -2554,6 +2656,8 @@ class DeviceManager {
2554
2656
 
2555
2657
  // MARK: Cleanup
2556
2658
  fun cleanup() {
2659
+ recoveryListenerId?.let { DeviceStore.store.removeListener(it) }
2660
+ recoveryListenerId = null
2557
2661
  stopBluetoothStateMonitoring()
2558
2662
 
2559
2663
  micReinitRunnable?.let { mainHandler.removeCallbacks(it) }
@@ -142,6 +142,7 @@ object DeviceStore {
142
142
  store.set("bluetooth", "gallery_mode", true)
143
143
  store.set("bluetooth", "voice_activity_detection_enabled", BluetoothSdkDefaults.VOICE_ACTIVITY_DETECTION_ENABLED)
144
144
  store.set("bluetooth", "loudness_gate_enabled", BluetoothSdkDefaults.LOUDNESS_GATE_ENABLED)
145
+ store.set("bluetooth", "auto_power_off_enabled", BluetoothSdkDefaults.AUTO_POWER_OFF_ENABLED)
145
146
  store.set("bluetooth", "screen_disabled", false)
146
147
  store.set("bluetooth", "button_photo_size", "max")
147
148
  store.set("bluetooth", "button_max_recording_time", 10)
@@ -219,14 +220,30 @@ object DeviceStore {
219
220
  }
220
221
 
221
222
  // BLUETOOTH:
223
+ "bluetooth" to "contextual_dashboard" -> {
224
+ if (value is Boolean) {
225
+ CoroutineScope(Dispatchers.Main.immediate).launch {
226
+ // The setting changes the selected view even without a new head event.
227
+ // Re-read state on Main and retain the manager's readiness/display guards.
228
+ if (store.get("glasses", "headUp") == true) {
229
+ DeviceManager.getInstance().sendCurrentState()
230
+ }
231
+ }
232
+ }
233
+ }
222
234
  "bluetooth" to "brightness" -> {
223
235
  val b = (value as? Number)?.toInt() ?: 50
224
236
  val auto = (store.get("bluetooth", "auto_brightness") as? Boolean) ?: true
225
237
  CoroutineScope(Dispatchers.Main).launch {
226
- DeviceManager.getInstance().sgc?.setBrightness(b, auto)
227
- DeviceManager.getInstance().sgc?.sendTextWall("Set brightness to $b%")
228
- delay(800) // 0.8 seconds
229
- DeviceManager.getInstance().sgc?.clearDisplay()
238
+ val device = DeviceManager.getInstance().sgc ?: return@launch
239
+ device.setBrightness(b, auto)
240
+ if (device.showBrightnessConfirmation) {
241
+ device.sendTextWall("Set brightness to $b%")
242
+ delay(800) // 0.8 seconds
243
+ if (DeviceManager.getInstance().sgc === device) {
244
+ device.clearDisplay()
245
+ }
246
+ }
230
247
  }
231
248
  }
232
249
  "bluetooth" to "auto_brightness" -> {
@@ -234,16 +251,17 @@ object DeviceStore {
234
251
  val auto = (value as? Boolean) ?: true
235
252
  val autoBrightnessChanged = (oldValue as? Boolean) != auto
236
253
  CoroutineScope(Dispatchers.Main).launch {
237
- DeviceManager.getInstance().sgc?.setBrightness(b, auto)
238
- if (autoBrightnessChanged) {
239
- DeviceManager.getInstance()
240
- .sgc
241
- ?.sendTextWall(
242
- if (auto) "Enabled auto brightness"
243
- else "Disabled auto brightness"
244
- )
254
+ val device = DeviceManager.getInstance().sgc ?: return@launch
255
+ device.setBrightness(b, auto)
256
+ if (autoBrightnessChanged && device.showBrightnessConfirmation) {
257
+ device.sendTextWall(
258
+ if (auto) "Enabled auto brightness"
259
+ else "Disabled auto brightness"
260
+ )
245
261
  delay(800) // 0.8 seconds
246
- DeviceManager.getInstance().sgc?.clearDisplay()
262
+ if (DeviceManager.getInstance().sgc === device) {
263
+ device.clearDisplay()
264
+ }
247
265
  }
248
266
  }
249
267
  }
@@ -291,6 +309,12 @@ object DeviceStore {
291
309
  "bluetooth" to "loudness_gate_enabled" -> {
292
310
  DeviceManager.getInstance().sgc?.sendLoudnessGateSetting()
293
311
  }
312
+ "bluetooth" to "auto_power_off_enabled" -> {
313
+ Bridge.log(
314
+ "DeviceStore: auto_power_off_enabled changed to $value — sending cs_swit type 11"
315
+ )
316
+ DeviceManager.getInstance().sgc?.sendAutoPowerOffSetting()
317
+ }
294
318
  // Deliberately has no seeded default above: the key starts absent so
295
319
  // that a session where the engine has not authorized tuning can only
296
320
  // ever send a reset.
@@ -3,8 +3,8 @@ package com.mentra.bluetoothsdk
3
3
  /** Generated from /changelogs. Do not edit directly. */
4
4
  internal val GENERATED_RELEASE_CHANGELOGS: List<ReleaseChangelog> =
5
5
  listOf(
6
- ReleaseChangelog(version = "3.2.1", markdown = "This release is under active development. User-facing changes will be documented as they land."),
7
- ReleaseChangelog(version = "3.2.0", markdown = "This release is under active development. User-facing changes will be documented as they land.\n\n- Breaking Bluetooth SDK change: `forgetWifiNetwork(ssid)` now returns `WifiForgetResult` instead of `WifiStatusChangeEvent`. Migrate status-only consumers to the semantic `outcome`; the requested network remains `ssid`, while optional post-command connectivity is reported by `connected`, `currentSsid`, and `localIp`."),
6
+ ReleaseChangelog(version = "3.2.1", markdown = "- **Livestreamer:** \"Stream Here\" now works without a Wi-Fi network. Improved stream startup and connection reliability.\n- **Photos and gallery:** More reliable photo capture and gallery syncing.\n- **Mentra AI:** Reliability improvements and bug fixes.\n- **Miniapps:** Smoother transitions, with back buttons and gestures that take you to the previous screen inside a miniapp.\n- **Pairing and connections:** Improved G1 pairing and G2 background reconnection.\n- **Glasses updates:** Fixed repeated update checks and improved recovery after interrupted updates.\n- **Mentra Maps:** Bug fixes.\n- **NIMO:** Improved text and map layouts.\n- **Self-hosting:** Connect the Mentra App to your own self-hosted Mentra backend."),
7
+ ReleaseChangelog(version = "3.2.0", markdown = "- Improved glasses connectivity and Wi-Fi setup."),
8
8
  ReleaseChangelog(version = "3.1.1", markdown = "- Improved Google login on iOS.\n- Pairing no longer requires phone microphone access."),
9
9
  ReleaseChangelog(version = "3.1.0", markdown = "**Mentra App and glasses**\n\n- Improved glasses pairing and reconnection, including on iPad.\n- Improved Android background reliability for captions and miniapps.\n- Added Xingyi AR99 support.\n- More reliable photo and video capture, with improved shutter sounds and privacy-light timing. Taking photos no longer interrupts your running miniapp.\n- Added video thumbnails and smoother gallery browsing.\n- Easier Mentra Live updates through your phone, with clearer progress and better recovery from interruptions.\n\n**Miniapp updates**\n\n- **Mentra AI:** Control whether photos are included with questions. Improved visual answers and connected-app follow-ups.\n- **Captions:** Added offline transcription settings and top/bottom positioning on supported displays.\n- **Translation:** Restored all 60 languages.\n- **Mentra Maps:** Clearer directions on G1/G2, less display flickering, and improved Android offline routing.\n- **Livestreamer:** More video-quality controls and custom streaming options, plus improved startup and sharing.\n- **Merge:** Tap to expand an insight; swipe down or back to dismiss it.\n- **Mentra Notes:** Smoother back navigation.\n- **Recorder:** Fixed audio sharing."),
10
10
  )
@@ -3,9 +3,9 @@ package com.mentra.bluetoothsdk
3
3
  /** Generated by release CI. Do not edit in a release checkout. */
4
4
  internal object GeneratedReleaseMetadata {
5
5
  const val FAMILY_BASE_VERSION: String = "3.2.1"
6
- const val RELEASE_IDENTITY: String = "3.2.1-dev.314"
7
- const val RELEASE_SET_ID: String = "mentra-3.2.1-dev.314"
8
- const val SOURCE_COMMIT: String = "00ab17c37df5902fbdb1a5824a41288b6850acc5"
9
- const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-dev.314.json"
10
- const val OTA_MANIFEST_SHA256: String = "1062c560fcced026e5b43245e84cdd5febfd603f3ad19fbebef8fac455ba21d6"
6
+ const val RELEASE_IDENTITY: String = "3.2.1"
7
+ const val RELEASE_SET_ID: String = "mentra-3.2.1"
8
+ const val SOURCE_COMMIT: String = "be56e1456c69f681985642f376ed3d397ec022cc"
9
+ const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.578.json"
10
+ const val OTA_MANIFEST_SHA256: String = "21af62269e29fd2ee6b9737745a895694bc221672b191d80795057b5a832f400"
11
11
  }
@@ -651,6 +651,10 @@ class MentraBluetoothSdk private constructor(
651
651
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "loudness_gate_enabled", enabled)
652
652
  }
653
653
 
654
+ fun setAutoPowerOffEnabled(enabled: Boolean) {
655
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "auto_power_off_enabled", enabled)
656
+ }
657
+
654
658
  @Deprecated(
655
659
  message =
656
660
  "Sticky action-button photo presets are deprecated. Prefer per-request " +
@@ -6,13 +6,14 @@ internal sealed interface VersionInfoAccumulatorOutcome {
6
6
  data class Complete(val result: VersionInfoResult) : VersionInfoAccumulatorOutcome
7
7
  }
8
8
 
9
- /** One request, explicit modern completion, or the deployed legacy chunk-3 terminal boundary. */
9
+ /** One request, explicit modern completion, or the legacy firmware's terminal chunk. */
10
10
  internal class VersionInfoResponseAccumulator(private val expectedRequestId: String) {
11
11
  private val values = mutableMapOf<String, Any>()
12
12
  private val chunks = mutableMapOf<Int, Map<String, Any>>()
13
13
  private var count: Int? = null
14
14
  private var sid: String? = null
15
15
  private var legacyStarted = false
16
+ private var legacyTwoChunkResponse = false
16
17
  private var completed = false
17
18
 
18
19
  fun accept(event: Map<String, Any>): VersionInfoAccumulatorOutcome {
@@ -41,10 +42,20 @@ internal class VersionInfoResponseAccumulator(private val expectedRequestId: Str
41
42
  if (count != null) return VersionInfoAccumulatorOutcome.Ignored
42
43
  when (event[RESPONSE_CHUNK_KEY] as? String ?: "version_info") {
43
44
  "version_info" -> { values.clear(); merge(event); return finish() }
44
- "version_info_1" -> { values.clear(); legacyStarted = true; merge(event) }
45
+ "version_info_1" -> {
46
+ values.clear()
47
+ legacyStarted = true
48
+ legacyTwoChunkResponse = event["buildNumber"] == "27"
49
+ merge(event)
50
+ }
45
51
  "version_info_2" -> {
46
52
  if (!legacyStarted) return VersionInfoAccumulatorOutcome.Ignored
53
+ // Factory ASG27 sends only chunks 1 and 2. Its second chunk carries the
54
+ // OTA URL and legacy firmware version; ASG31+ moves firmware to chunk 3.
47
55
  merge(event)
56
+ if (legacyTwoChunkResponse && values["buildNumber"] == "27" &&
57
+ !(event["otaVersionUrl"] as? String).isNullOrBlank()
58
+ ) return finish()
48
59
  }
49
60
  "version_info_3" -> {
50
61
  if (!legacyStarted) return VersionInfoAccumulatorOutcome.Ignored
@@ -1,7 +1,7 @@
1
1
  package com.mentra.bluetoothsdk
2
2
 
3
3
  internal object DashboardContentFormatter {
4
- const val STATUS_HEADER = "\$TIME12$ \$DATE$ \$GBATT$ \$CONNECTION_STATUS$"
4
+ const val STATUS_HEADER = "\$TIME12$ \$DATE$ \$GBATT$"
5
5
 
6
6
  fun template(content: String): String =
7
7
  if (content.isEmpty()) STATUS_HEADER else "$STATUS_HEADER\n\n$content"
@@ -13,6 +13,8 @@ import android.os.IBinder
13
13
  import androidx.core.app.NotificationCompat
14
14
  import androidx.core.content.ContextCompat
15
15
  import com.mentra.bluetoothsdk.Bridge
16
+ import com.mentra.bluetoothsdk.DeviceManager
17
+ import com.mentra.bluetoothsdk.DeviceStore
16
18
  import com.mentra.bluetoothsdk.debug.BleTraceLogger
17
19
 
18
20
  class ForegroundService : Service() {
@@ -107,9 +109,40 @@ class ForegroundService : Service() {
107
109
  }
108
110
  // Re-check permissions in case they changed
109
111
  startForegroundWithAutoDetectedType()
112
+ // A null intent is Android restarting START_STICKY after process death.
113
+ // Do not wait for an Activity/Expo module to restore the native BLE owner.
114
+ if (intent == null && !DeviceManager.isInitialized()) {
115
+ val settings = G2ConnectionRecovery(this).read()
116
+ if (settings != null && !startHostRuntimeRecovery()) {
117
+ Bridge.initialize(applicationContext)
118
+ settings.forEach { (key, value) -> DeviceStore.set("bluetooth", key, value) }
119
+ Bridge.log("ForegroundService: restoring G2 connection after process restart")
120
+ try {
121
+ DeviceManager.getInstance().connectDefault()
122
+ } catch (error: Exception) {
123
+ Bridge.log("ForegroundService: G2 recovery failed: ${error.message}")
124
+ }
125
+ }
126
+ }
110
127
  return START_STICKY
111
128
  }
112
129
 
130
+ private fun startHostRuntimeRecovery(): Boolean {
131
+ // Keep the standalone SDK free of React dependencies. The Mentra App
132
+ // opts in with a private headless service declared by its host module.
133
+ val serviceName = packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA)
134
+ .metaData?.getString("com.mentra.bluetoothsdk.RUNTIME_RECOVERY_SERVICE")
135
+ ?: return false
136
+ return try {
137
+ if (startService(Intent().setClassName(packageName, serviceName)) == null) return false
138
+ Bridge.log("ForegroundService: requested host JavaScript runtime recovery")
139
+ true
140
+ } catch (error: Exception) {
141
+ Bridge.log("ForegroundService: host runtime recovery could not start: ${error.message}")
142
+ false
143
+ }
144
+ }
145
+
113
146
  private fun startForegroundWithAutoDetectedType() {
114
147
  startForegroundWithType(detectServiceType())
115
148
  }
@@ -0,0 +1,47 @@
1
+ package com.mentra.bluetoothsdk.services
2
+
3
+ import android.content.Context
4
+ import com.mentra.bluetoothsdk.utils.DeviceTypes
5
+ import org.json.JSONObject
6
+
7
+ /** Native recovery of the last active G2 link when Android restarts our sticky service. */
8
+ internal class G2ConnectionRecovery(context: Context) {
9
+ private val preferences = context.getSharedPreferences("g2_connection_recovery", Context.MODE_PRIVATE)
10
+
11
+ companion object {
12
+ // Deliberately exclude transient connection flags, mic intent, tokens and pending pairing.
13
+ internal val keys = setOf(
14
+ "default_wearable", "device_name", "device_address", "project_name",
15
+ "brightness", "auto_brightness", "dashboard_height", "dashboard_depth",
16
+ "head_up_angle", "use_native_dashboard", "twelve_hour_time", "metric_system",
17
+ "screen_disabled", "power_saving_mode", "lc3_frame_size",
18
+ )
19
+ }
20
+
21
+ fun save(settings: Map<String, Any>) {
22
+ if (settings["default_wearable"] != DeviceTypes.G2 ||
23
+ (settings["device_name"] as? String).isNullOrBlank()) {
24
+ clear()
25
+ return
26
+ }
27
+ val snapshot = settings.filterKeys { it in keys }
28
+ preferences.edit().putString("snapshot", JSONObject(snapshot).toString()).apply()
29
+ }
30
+
31
+ fun clear() {
32
+ preferences.edit().remove("snapshot").apply()
33
+ }
34
+
35
+ fun read(): Map<String, Any>? {
36
+ val raw = preferences.getString("snapshot", null) ?: return null
37
+ return try {
38
+ val json = JSONObject(raw)
39
+ if (json.optString("default_wearable") != DeviceTypes.G2 ||
40
+ json.optString("device_name").isBlank()) return null
41
+ keys.filter { json.has(it) && !json.isNull(it) }.associateWith { json.get(it) }
42
+ } catch (_: Exception) {
43
+ clear()
44
+ null
45
+ }
46
+ }
47
+ }
@@ -1370,8 +1370,8 @@ class G1 : SGCManager() {
1370
1370
  context!!.registerReceiver(bondingReceiver, filter)
1371
1371
  isBondingReceiverRegistered = true
1372
1372
 
1373
- preferredG1DeviceId = DeviceStore.get("bluetooth", "device_name") as String?
1374
-
1373
+ // Keep the target supplied by connectById. device_name still belongs to
1374
+ // the previous pairing until DeviceManager promotes the ready device.
1375
1375
  if (!bluetoothAdapter!!.isEnabled()) {
1376
1376
  return
1377
1377
  }