@mentra/bluetooth-sdk 3.2.1-beta.315 → 3.2.1-beta.354

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 (83) hide show
  1. package/README.md +1 -1
  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 +4 -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 +67 -12
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +1 -1
  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/services/Foreground.kt +33 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +64 -14
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +73 -3
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +257 -395
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +245 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +225 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +168 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  24. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  25. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +557 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
  29. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +220 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +308 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +270 -0
  34. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  35. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  36. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +194 -0
  37. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +199 -0
  38. package/build/BluetoothSdk.types.d.ts +11 -0
  39. package/build/BluetoothSdk.types.d.ts.map +1 -1
  40. package/build/BluetoothSdk.types.js.map +1 -1
  41. package/build/_private/BluetoothSdkModule.d.ts +2 -0
  42. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  43. package/build/_private/BluetoothSdkModule.js +3 -0
  44. package/build/_private/BluetoothSdkModule.js.map +1 -1
  45. package/build/generated/changelogCatalog.d.ts +1 -1
  46. package/build/generated/changelogCatalog.js +1 -1
  47. package/build/generated/changelogCatalog.js.map +1 -1
  48. package/build/generated/releaseMetadata.js +5 -5
  49. package/build/generated/releaseMetadata.js.map +1 -1
  50. package/build/index.d.ts.map +1 -1
  51. package/build/index.js +1 -0
  52. package/build/index.js.map +1 -1
  53. package/ios/BluetoothSdkModule.swift +5 -0
  54. package/ios/MentraBluetoothSDK.podspec +3 -2
  55. package/ios/Source/BluetoothSdkDefaults.swift +6 -1
  56. package/ios/Source/Bridge.swift +15 -2
  57. package/ios/Source/DeviceManager.swift +23 -10
  58. package/ios/Source/DeviceStore.swift +26 -7
  59. package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
  60. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  61. package/ios/Source/MentraBluetoothSDK.swift +4 -0
  62. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
  63. package/ios/Source/sgcs/G2.swift +2 -2
  64. package/ios/Source/sgcs/MentraLive.swift +66 -3
  65. package/ios/Source/sgcs/Nimo.swift +182 -371
  66. package/ios/Source/sgcs/NimoCanvasCodec.swift +269 -0
  67. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +219 -0
  68. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  69. package/ios/Source/sgcs/NimoCanvasSession.swift +150 -0
  70. package/ios/Source/sgcs/SGCManager.swift +24 -0
  71. package/ios/Source/utils/G2Text.swift +11 -0
  72. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  73. package/ios/Tests/G2TextTests.swift +10 -0
  74. package/ios/Tests/NimoCanvasCodecTests.swift +204 -0
  75. package/ios/Tests/NimoCanvasSessionTests.swift +112 -0
  76. package/ios/Tests/NimoCanvasTransportTests.swift +161 -0
  77. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
  78. package/package.json +2 -2
  79. package/src/BluetoothSdk.types.ts +11 -0
  80. package/src/_private/BluetoothSdkModule.ts +6 -0
  81. package/src/generated/changelogCatalog.ts +1 -1
  82. package/src/generated/releaseMetadata.ts +5 -5
  83. 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
@@ -45,12 +46,15 @@ import java.util.concurrent.Executors
45
46
  import java.util.concurrent.TimeUnit
46
47
  import kotlin.jvm.JvmStatic
47
48
 
48
- class DeviceManager {
49
+ class DeviceManager internal constructor(initializeHardware: Boolean) {
50
+ constructor() : this(initializeHardware = true)
49
51
  companion object {
50
52
 
51
53
  @Volatile
52
54
  private var _instance: DeviceManager? = null
53
55
 
56
+ internal fun isInitialized(): Boolean = _instance != null
57
+
54
58
  @JvmStatic
55
59
  fun getInstance(): DeviceManager {
56
60
  return _instance
@@ -59,6 +63,10 @@ class DeviceManager {
59
63
  }
60
64
 
61
65
  // MARK: - Unique (Android)
66
+ private val connectionRecovery = G2ConnectionRecovery(Bridge.getContext())
67
+ private val recoveryLock = Any()
68
+ private var recoverG2Connection = false
69
+ private var recoveryListenerId: String? = null
62
70
  private var serviceStarted = false
63
71
  private val mainHandler = Handler(Looper.getMainLooper())
64
72
  private val executor: ExecutorService = Executors.newSingleThreadExecutor()
@@ -79,7 +87,8 @@ class DeviceManager {
79
87
  // MARK: - End Unique
80
88
 
81
89
  // MARK: - Properties
82
- var sgc: SGCManager? = null
90
+ // Read by the welcome executor as well as device lifecycle handlers.
91
+ @Volatile var sgc: SGCManager? = null
83
92
  var controller: ControllerManager? = null
84
93
 
85
94
  // settings:
@@ -283,7 +292,22 @@ class DeviceManager {
283
292
 
284
293
  init {
285
294
  Bridge.log("DeviceManager: init()")
295
+ recoveryListenerId = DeviceStore.store.addListener { category, changes ->
296
+ if (category == "bluetooth" && changes.keys.any { it in G2ConnectionRecovery.keys }) {
297
+ synchronized(recoveryLock) {
298
+ if (recoverG2Connection) {
299
+ connectionRecovery.save(DeviceStore.store.getCategory("bluetooth"))
300
+ }
301
+ }
302
+ }
303
+ }
286
304
  initializeViewStates()
305
+ if (initializeHardware) initializeHardwareServices()
306
+ }
307
+
308
+ // Keep host routing usable without starting audio codecs, receivers or services.
309
+ // The public constructor always initializes the hardware environment.
310
+ private fun initializeHardwareServices() {
287
311
  startForegroundService()
288
312
  // setupPermissionMonitoring()
289
313
  setupBluetoothStateMonitoring()
@@ -1497,6 +1521,10 @@ class DeviceManager {
1497
1521
  Bridge.log("MAN: handleDeviceReady() ${sgc?.type}")
1498
1522
  resetMicHealth()
1499
1523
  defaultWearable = sgc?.type ?: ""
1524
+ synchronized(recoveryLock) {
1525
+ recoverG2Connection = defaultWearable == DeviceTypes.G2
1526
+ connectionRecovery.save(DeviceStore.store.getCategory("bluetooth"))
1527
+ }
1500
1528
  searching = false
1501
1529
 
1502
1530
  syncSystemTimeOnceForConnection(readyKey)
@@ -1518,13 +1546,18 @@ class DeviceManager {
1518
1546
  2000
1519
1547
  )
1520
1548
 
1521
- // Show welcome message on first connect for all display glasses
1549
+ // A full-canvas device can opt out so this temporary message does not
1550
+ // erase the host's connected-edge scene replay.
1522
1551
  if (shouldSendBootingMessage) {
1523
1552
  shouldSendBootingMessage = false
1524
- executor.execute {
1525
- sgc?.sendTextWall("// MentraOS Connected")
1526
- Thread.sleep(3000)
1527
- sgc?.clearDisplay()
1553
+ val device = sgc
1554
+ if (device?.showConnectionConfirmation == true) {
1555
+ executor.execute {
1556
+ if (sgc !== device) return@execute
1557
+ device.sendTextWall("// MentraOS Connected")
1558
+ Thread.sleep(3000)
1559
+ if (sgc === device) device.clearDisplay()
1560
+ }
1528
1561
  }
1529
1562
  }
1530
1563
 
@@ -1677,7 +1710,7 @@ class DeviceManager {
1677
1710
  dashboardSceneCleanupPending = false
1678
1711
  val elementIds = pendingDashboardSceneElementIds.toList()
1679
1712
  pendingDashboardSceneElementIds.clear()
1680
- if (elementIds.isNotEmpty()) {
1713
+ if (elementIds.isNotEmpty() && sgc?.sceneHandoffRequiresClear == true) {
1681
1714
  sgc?.clearSceneElements(elementIds)
1682
1715
  }
1683
1716
  }
@@ -1714,7 +1747,7 @@ class DeviceManager {
1714
1747
  sceneStates[stateIndex] = null
1715
1748
  if (stateIndex == 1 && dashboardSceneCleanupPending) {
1716
1749
  pendingDashboardSceneElementIds.addAll(prevFrame.elements.map { it.id })
1717
- } else if (layoutType != "clear_view") {
1750
+ } else if (layoutType != "clear_view" && shouldClearSceneHandoff(stateIndex)) {
1718
1751
  sgc?.clearSceneElements(prevFrame.elements.map { it.id })
1719
1752
  }
1720
1753
  }
@@ -1781,7 +1814,7 @@ class DeviceManager {
1781
1814
  val prevLegacyType = viewStates[stateIndex].layoutType
1782
1815
  val cleanupDeferred = stateIndex == 1 && dashboardSceneCleanupPending
1783
1816
  if (
1784
- !cleanupDeferred &&
1817
+ !cleanupDeferred && shouldClearSceneHandoff(stateIndex) &&
1785
1818
  prevLegacyType.isNotEmpty() &&
1786
1819
  prevLegacyType != "clear_view" &&
1787
1820
  prevLegacyType != "scene"
@@ -1797,7 +1830,7 @@ class DeviceManager {
1797
1830
  // blank.
1798
1831
  if (stateIndex == 1 && dashboardSceneCleanupPending) {
1799
1832
  pendingDashboardSceneElementIds.addAll(prevFrame.elements.map { it.id })
1800
- } else {
1833
+ } else if (shouldClearSceneHandoff(stateIndex)) {
1801
1834
  sgc?.clearSceneElements(prevFrame.elements.map { it.id })
1802
1835
  }
1803
1836
  frame = frame.copy(replay = true, elements = frame.elements.map { it.copy(change = "created") })
@@ -1816,6 +1849,15 @@ class DeviceManager {
1816
1849
  }
1817
1850
  }
1818
1851
 
1852
+ // Hidden-view updates only change their replay slot. They must never clear
1853
+ // the visible view, and full-frame adapters need no separate handoff sweep.
1854
+ private fun shouldClearSceneHandoff(stateIndex: Int): Boolean {
1855
+ val visibleIndex = if (headUp && contextualDashboard) 1 else 0
1856
+ val device = sgc ?: return false
1857
+ return stateIndex == visibleIndex && !screenDisabled && device.fullyBooted &&
1858
+ !device.type.contains(DeviceTypes.SIMULATED) && device.sceneHandoffRequiresClear
1859
+ }
1860
+
1819
1861
  /** Guarded scene dispatch — mirrors sendCurrentState's send conditions. */
1820
1862
  private fun dispatchSceneFrame(stateIndex: Int, frame: SceneFrame) {
1821
1863
  if (screenDisabled) return
@@ -2389,8 +2431,15 @@ class DeviceManager {
2389
2431
  }
2390
2432
 
2391
2433
  fun disconnect(clearPendingIdentity: Boolean = true) {
2434
+ synchronized(recoveryLock) {
2435
+ recoverG2Connection = false
2436
+ connectionRecovery.clear()
2437
+ }
2392
2438
  sgc?.clearDisplay()
2393
- sgc?.disconnect()
2439
+ // NIMO owns a background canvas encoder and this path discards its instance.
2440
+ // A link-level reconnect keeps the instance (and encoder) through disconnect().
2441
+ val device = sgc
2442
+ if (device is Nimo) device.cleanup() else device?.disconnect()
2394
2443
  sgc = null // Clear the SGC reference after disconnect
2395
2444
  resetSystemTimeSync()
2396
2445
  resetMicHealth()
@@ -2440,6 +2489,10 @@ class DeviceManager {
2440
2489
  }
2441
2490
 
2442
2491
  fun forget() {
2492
+ synchronized(recoveryLock) {
2493
+ recoverG2Connection = false
2494
+ connectionRecovery.clear()
2495
+ }
2443
2496
  Bridge.log("MAN: Forgetting smart glasses")
2444
2497
 
2445
2498
  val live = sgc as? MentraLive
@@ -2554,6 +2607,8 @@ class DeviceManager {
2554
2607
 
2555
2608
  // MARK: Cleanup
2556
2609
  fun cleanup() {
2610
+ recoveryListenerId?.let { DeviceStore.store.removeListener(it) }
2611
+ recoveryListenerId = null
2557
2612
  stopBluetoothStateMonitoring()
2558
2613
 
2559
2614
  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,7 +3,7 @@ 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."),
6
+ ReleaseChangelog(version = "3.2.1", markdown = "This release is under active development. User-facing changes will be documented as they land.\n\n- Mentra Miniapp SDK: `session.stream.preview({source: \"call\"})` and the `<StreamPreview>` component (from `@mentra/miniapp/react`) show the live video of a call your miniapp owns inside its UI. Requires the `CAMERA` permission; the lease survives the UI closing and ends with `handle.stop()` or the meeting. See [Stream preview](https://docs.mentraglass.com/app-devs/core-concepts/stream-preview)."),
7
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`."),
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."),
@@ -3,9 +3,9 @@ package com.mentra.bluetoothsdk
3
3
  /** Generated by release CI. Do not edit in a release checkout. */
4
4
  internal object GeneratedReleaseMetadata {
5
5
  const val FAMILY_BASE_VERSION: String = "3.2.1"
6
- const val RELEASE_IDENTITY: String = "3.2.1-beta.315"
7
- const val RELEASE_SET_ID: String = "mentra-3.2.1-beta.315"
8
- const val SOURCE_COMMIT: String = "fdbe3c85943e23f410cde4e794e1cfcc8affa6a6"
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.315.json"
10
- const val OTA_MANIFEST_SHA256: String = "e4402248a33933105ef96f40913be7ab0708d60927ca4add07495d922b707cd7"
6
+ const val RELEASE_IDENTITY: String = "3.2.1-beta.354"
7
+ const val RELEASE_SET_ID: String = "mentra-3.2.1-beta.354"
8
+ const val SOURCE_COMMIT: String = "b8fb9cd0d58df574fdf1eeb5580ce0b5144f788e"
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.354.json"
10
+ const val OTA_MANIFEST_SHA256: String = "58b07e4b77901b404a59b44f7fb45218e9be3312983659bfa07b579a437b1034"
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
@@ -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
+ }
@@ -1,5 +1,6 @@
1
1
  package com.mentra.bluetoothsdk.sgcs
2
2
 
3
+ import com.mentra.bluetoothsdk.utils.G2Text
3
4
  import com.mentra.bluetoothsdk.PhotoRequest
4
5
  import android.bluetooth.BluetoothAdapter
5
6
  import android.bluetooth.BluetoothGatt
@@ -1664,8 +1665,15 @@ class G2 : SGCManager() {
1664
1665
  if (char == null || gatt == null) return
1665
1666
  char.value = packet
1666
1667
  char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
1667
- val ok = gatt.writeCharacteristic(char)
1668
- bgcapNoteWriteResult(ok) // BGCAP
1668
+ // A rejected write never entered Android's queue. Retry on this dedicated
1669
+ // BLE worker before later commands (especially a page replacement) overtake it.
1670
+ var ok = false
1671
+ for (attempt in 0 until 5) {
1672
+ ok = gatt.writeCharacteristic(char)
1673
+ bgcapNoteWriteResult(ok)
1674
+ if (ok) break
1675
+ if (attempt < 4) Thread.sleep(BLE_PACKET_GAP_MS)
1676
+ }
1669
1677
  if (leg != null) {
1670
1678
  Bridge.log("G2/FILE: write $leg ${packet.size}B -> ${if (ok) "queued" else "DROPPED (stack busy)"}")
1671
1679
  }
@@ -2190,7 +2198,7 @@ class G2 : SGCManager() {
2190
2198
  val rBorderColor = borderColor ?: defaultTextBorderColor
2191
2199
  val rBorderRadius = borderRadius ?: defaultTextBorderRadius
2192
2200
  val rPaddingLength = paddingLength ?: defaultTextPaddingLength
2193
- val content = if (text.isEmpty()) " " else text
2201
+ val content = G2Text.containerContent(text)
2194
2202
 
2195
2203
  // Pure state mutation: update the container's content and schedule its sends; the reconcile
2196
2204
  // loop does the actual updateText writes. Reuse an existing container if the rect matches
@@ -2257,6 +2265,12 @@ class G2 : SGCManager() {
2257
2265
  }
2258
2266
 
2259
2267
  override fun clearDisplay() {
2268
+ // Expo invokes this on its worker queue; all container mutations must
2269
+ // share Main with reconcileDisplay and scene updates.
2270
+ if (Looper.myLooper() != Looper.getMainLooper()) {
2271
+ mainHandler.post { clearDisplay() }
2272
+ return
2273
+ }
2260
2274
  Bridge.log("G2: clearDisplay()")
2261
2275
  // Clear the text in place — do NOT shut down + rebuild the EvenHub page. Tearing the page
2262
2276
  // down (rebuildPage) kills audio streaming AND triggers a firmware systemExit /
@@ -2266,9 +2280,22 @@ class G2 : SGCManager() {
2266
2280
  // several seconds (incident 8164175a). Just blank the text + clear images; the reconcile
2267
2281
  // loop pushes the blanked text on a live page, and a dead page is only resurrected for
2268
2282
  // meaningful (non-blank) content, so a clear can't churn it back up.
2269
- for (i in textContainers.indices) {
2270
- textContainers[i].content = "\n"
2271
- textContainers[i].pendingSends = 1 + EVEN_HUB_RESEND_COUNT
2283
+ for (container in textContainers) {
2284
+ // Erase the firmware's existing text BEFORE forgetting its container.
2285
+ // A queued reconcile cannot do this after the positioned container is removed.
2286
+ // Cover the previous byte range, rather than replacing only its first character.
2287
+ val blank = " ".repeat(maxOf(1, container.content.toByteArray(Charsets.UTF_8).size))
2288
+ container.content = blank
2289
+ container.pendingSends = 1 + EVEN_HUB_RESEND_COUNT
2290
+ if (pageCreated) {
2291
+ val erase = EvenHubProto.updateTextMessage(
2292
+ containerID = container.id,
2293
+ contentOffset = 0,
2294
+ contentLength = blank.length,
2295
+ content = blank
2296
+ )
2297
+ repeat(1 + EVEN_HUB_RESEND_COUNT) { sendEvenHubCommand(erase) }
2298
+ }
2272
2299
  }
2273
2300
  for (i in imageContainers.indices) {
2274
2301
  // The firmware still shows this container's image; emptying bmpData locally never reaches
@@ -2296,8 +2323,13 @@ class G2 : SGCManager() {
2296
2323
  textContainers.removeAll { it.id in huskIds }
2297
2324
  sceneTextByElement.entries.removeAll { it.value in huskIds }
2298
2325
  sceneImageByElement.clear()
2299
- Bridge.log("G2: clearDisplay() — purging ${huskIds.size} positioned husk container(s), one rebuild")
2300
- displayScope.launch { coalescedPageRebuild() }
2326
+ // Replace the live page now, while still on Main. A shutdown followed by
2327
+ // reconcile cannot clear this case: reconcile intentionally skips blank pages,
2328
+ // leaving the firmware's old positioned text visible until new speech arrives.
2329
+ // Queue the blank replacement before any following miniapp frame, without
2330
+ // tearing down the page (or its microphone session).
2331
+ Bridge.log("G2: clearDisplay() — replacing page after removing ${huskIds.size} positioned container(s)")
2332
+ if (pageCreated) createPageWithContainers()
2301
2333
  }
2302
2334
  }
2303
2335
 
@@ -2344,6 +2376,15 @@ class G2 : SGCManager() {
2344
2376
  return false
2345
2377
  }
2346
2378
 
2379
+ if (Looper.myLooper() != Looper.getMainLooper()) {
2380
+ mainHandler.post { applyBitmap(rx, ry, rw, rh, bmpData) }
2381
+ return true
2382
+ }
2383
+ return applyBitmap(rx, ry, rw, rh, bmpData)
2384
+ }
2385
+
2386
+ // Called only on Main, including the inline scene-frame path.
2387
+ private fun applyBitmap(rx: Int, ry: Int, rw: Int, rh: Int, bmpData: ByteArray): Boolean {
2347
2388
  // Pure state mutation: update the target container's bytes and mark it dirty. The reconcile
2348
2389
  // loop is the sole sender, so two displayBitmap calls can never overlap a sendImageData and
2349
2390
  // clobber the single-slot image ACK — no lock needed. Reuse an existing container if the rect
@@ -2538,7 +2579,7 @@ class G2 : SGCManager() {
2538
2579
  @Suppress("NAME_SHADOWING")
2539
2580
  val height = minOf(maxOf(height, minTextContainerHeight), 288 - y)
2540
2581
  run {
2541
- val content = if (text.isEmpty()) " " else text
2582
+ val content = G2Text.containerContent(text)
2542
2583
  val existingId = sceneTextByElement[elementId]
2543
2584
  if (existingId != null) {
2544
2585
  val idx = textContainers.indexOfFirst { it.id == existingId }
@@ -2999,15 +3040,15 @@ class G2 : SGCManager() {
2999
3040
  }
3000
3041
  // Only settle the flag if it's still empty — a displayBitmap during the await would
3001
3042
  // have set new bytes, so leave it dirty for the next pass to send the real image.
3002
- val jj = imageContainers.indexOfFirst { it.id == container.id }
3043
+ val jj = imageContainers.indexOfFirst { it === container }
3003
3044
  if (jj >= 0 && imageContainers[jj].bmpData.isEmpty()) {
3004
3045
  imageContainers[jj].dirty = false
3005
3046
  }
3006
3047
  continue
3007
3048
  }
3008
3049
  sendImageData(container.id, container.name, sentBytes)
3009
- // Re-find by id: the list may have shifted (eviction) during the await.
3010
- val j = imageContainers.indexOfFirst { it.id == container.id }
3050
+ // Re-find by identity: eviction can reuse this ID during the await.
3051
+ val j = imageContainers.indexOfFirst { it === container }
3011
3052
  if (j >= 0 && imageContainers[j].bmpData.contentEquals(sentBytes)) {
3012
3053
  imageContainers[j].dirty = false
3013
3054
  }
@@ -3677,8 +3718,17 @@ class G2 : SGCManager() {
3677
3718
  rightAuthenticated = false
3678
3719
  startupPageCreated = false
3679
3720
  pageCreated = false
3680
- imageContainers.clear()
3681
- textContainers.clear()
3721
+ // Keep GATT teardown synchronous for manager replacement, but never
3722
+ // remove containers underneath Main's reconcile/scene iteration.
3723
+ if (Looper.myLooper() == Looper.getMainLooper()) {
3724
+ imageContainers.clear()
3725
+ textContainers.clear()
3726
+ } else {
3727
+ mainHandler.post {
3728
+ imageContainers.clear()
3729
+ textContainers.clear()
3730
+ }
3731
+ }
3682
3732
  dashboardShowing = 0
3683
3733
  dashboardOpening = false
3684
3734
  heartbeatCounter = 0