@mentra/bluetooth-sdk 3.3.0-dev.516 → 3.3.0-dev.523

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 (50) hide show
  1. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +2 -0
  2. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +55 -10
  3. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  4. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +63 -6
  5. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +26 -13
  6. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +31 -19
  7. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
  8. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +111 -2
  9. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
  10. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +44 -4
  11. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +82 -13
  12. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
  13. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
  14. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +94 -0
  15. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +15 -9
  16. package/build/BluetoothSdk.types.d.ts +2 -0
  17. package/build/BluetoothSdk.types.d.ts.map +1 -1
  18. package/build/BluetoothSdk.types.js +1 -0
  19. package/build/BluetoothSdk.types.js.map +1 -1
  20. package/build/_private/BluetoothSdkModule.d.ts +2 -0
  21. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  22. package/build/_private/BluetoothSdkModule.js.map +1 -1
  23. package/build/generated/releaseMetadata.js +5 -5
  24. package/build/generated/releaseMetadata.js.map +1 -1
  25. package/ios/BluetoothSdkModule.swift +4 -0
  26. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  27. package/ios/Source/DeviceManager.swift +66 -50
  28. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  29. package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
  30. package/ios/Source/sgcs/Ar99.swift +1 -2
  31. package/ios/Source/sgcs/G2.swift +259 -245
  32. package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
  33. package/ios/Source/sgcs/Nimo.swift +104 -43
  34. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +20 -6
  35. package/ios/Source/sgcs/NimoCanvasSession.swift +30 -14
  36. package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
  37. package/ios/Source/status/DeviceStatus.swift +1 -0
  38. package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
  39. package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
  40. package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
  41. package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
  42. package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
  43. package/ios/Tests/NimoCanvasSessionTests.swift +74 -5
  44. package/ios/Tests/NimoCanvasTransportTests.swift +25 -0
  45. package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
  46. package/ios/Tests/NimoDiscoveryTests.swift +79 -0
  47. package/package.json +2 -2
  48. package/src/BluetoothSdk.types.ts +3 -0
  49. package/src/_private/BluetoothSdkModule.ts +2 -0
  50. package/src/generated/releaseMetadata.ts +5 -5
@@ -550,6 +550,8 @@ class BluetoothSdkModule : Module() {
550
550
 
551
551
  SdkAsyncFunction("forget") { -> sdk?.forget() }
552
552
 
553
+ SdkCoroutineFunction("unpair") { -> deviceManager?.unpair() }
554
+
553
555
  AsyncFunction("connectDefaultController") { deviceManager?.connectDefaultController() }
554
556
 
555
557
  AsyncFunction("disconnectController") { deviceManager?.disconnectController() }
@@ -35,7 +35,10 @@ import com.mentra.bluetoothsdk.utils.MicTypes
35
35
  import com.mentra.bluetoothsdk.utils.PhoneAudioMonitor
36
36
  import com.mentra.lc3Lib.Lc3Cpp
37
37
  import com.mentra.bluetoothsdk.stt.SherpaOnnxTranscriber
38
+ import kotlin.coroutines.resume
39
+ import kotlinx.coroutines.suspendCancellableCoroutine
38
40
  import kotlinx.coroutines.CoroutineScope
41
+ import kotlinx.coroutines.withContext
39
42
  import kotlinx.coroutines.Dispatchers
40
43
  import kotlinx.coroutines.launch
41
44
  import java.text.SimpleDateFormat
@@ -1378,7 +1381,8 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
1378
1381
  Bridge.log("MAN: Cleaning up previous sgc type: ${sgc?.type}")
1379
1382
  sgc?.cleanup()
1380
1383
  sgc = null
1381
- resetSystemTimeSync()
1384
+ DeviceStore.apply("glasses", "micEnabled", false)
1385
+ resetConnectionReadyState()
1382
1386
  }
1383
1387
 
1384
1388
  if (sgc != null) {
@@ -1536,6 +1540,7 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
1536
1540
  // re-apply display height/depth after reconnection
1537
1541
  mainHandler.postDelayed(
1538
1542
  {
1543
+ val device = sgc ?: return@postDelayed
1539
1544
  val h =
1540
1545
  (DeviceStore.store.get("bluetooth", "dashboard_height") as? Number)
1541
1546
  ?.toInt()
@@ -1544,8 +1549,8 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
1544
1549
  (DeviceStore.store.get("bluetooth", "dashboard_depth") as? Number)
1545
1550
  ?.toInt()
1546
1551
  ?: dashboardDepth // canonical default (2), not 1
1547
- val d = rawDepth.coerceIn(1, 4)
1548
- 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)
1549
1554
  },
1550
1555
  2000
1551
1556
  )
@@ -1574,10 +1579,11 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
1574
1579
  handleMach1Ready() // Z100 uses same initialization as Mach1
1575
1580
  }
1576
1581
 
1577
- // Re-apply microphone settings after reconnection
1578
- // 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().
1579
1585
  Bridge.log("MAN: Re-applying microphone settings after reconnection")
1580
- updateMicState()
1586
+ setMicState()
1581
1587
 
1582
1588
  // send to the server our battery status:
1583
1589
  Bridge.sendBatteryStatus(sgc?.batteryLevel ?: -1, false)
@@ -1617,7 +1623,11 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
1617
1623
  mainHandler.postDelayed(sync, 3000)
1618
1624
  }
1619
1625
 
1620
- 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
1621
1631
  pendingSystemTimeSync?.let { mainHandler.removeCallbacks(it) }
1622
1632
  pendingSystemTimeSync = null
1623
1633
  lastSystemTimeSyncConnectionKey = ""
@@ -1635,7 +1645,7 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
1635
1645
 
1636
1646
  fun handleDeviceDisconnected() {
1637
1647
  Bridge.log("MAN: Device disconnected")
1638
- resetSystemTimeSync()
1648
+ resetConnectionReadyState()
1639
1649
  resetMicHealth()
1640
1650
  DeviceStore.apply("glasses", "headUp", false)
1641
1651
  DeviceStore.apply(
@@ -2445,7 +2455,10 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
2445
2455
  val device = sgc
2446
2456
  if (device is Nimo) device.cleanup() else device?.disconnect()
2447
2457
  sgc = null // Clear the SGC reference after disconnect
2448
- 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()
2449
2462
  resetMicHealth()
2450
2463
  searching = false
2451
2464
  micEnabled = false
@@ -2492,6 +2505,37 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
2492
2505
  controller = null
2493
2506
  }
2494
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
+
2495
2539
  fun forget() {
2496
2540
  synchronized(recoveryLock) {
2497
2541
  recoverG2Connection = false
@@ -2508,7 +2552,8 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
2508
2552
  // session state without calling disconnect() again (that would hit a dead instance
2509
2553
  // and leave a destroyed MentraLive retained for the next scan).
2510
2554
  sgc = null
2511
- resetSystemTimeSync()
2555
+ DeviceStore.apply("glasses", "micEnabled", false)
2556
+ resetConnectionReadyState()
2512
2557
  searching = false
2513
2558
  micEnabled = false
2514
2559
  updateMicState()
@@ -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.3.0"
6
- const val RELEASE_IDENTITY: String = "3.3.0-dev.516"
7
- const val RELEASE_SET_ID: String = "mentra-3.3.0-dev.516"
8
- const val SOURCE_COMMIT: String = "e140b9dbfadd4922046b5a1d57692c1d9e268271"
9
- const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.516.json"
10
- const val OTA_MANIFEST_SHA256: String = "8c6a0d5badb8cecadc3da43bbb9ecbc22d9126fccb6aee3274fe9b75af61e893"
6
+ const val RELEASE_IDENTITY: String = "3.3.0-dev.523"
7
+ const val RELEASE_SET_ID: String = "mentra-3.3.0-dev.523"
8
+ const val SOURCE_COMMIT: String = "0dd41dbb1e055eb1d80fb39d26a7ba20448de657"
9
+ const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.523.json"
10
+ const val OTA_MANIFEST_SHA256: String = "ad3129a46a5ff8f018c80defcecc6acf88e7ab2d248d0914ed4c2eb097c3f3ec"
11
11
  }
@@ -69,6 +69,7 @@ internal object NimoProtocol {
69
69
  const val CMD_INSTRUCTION_REPORT = 0x06
70
70
  const val CMD_CONTROL_INSTRUCTION = 0x07
71
71
  const val CMD_CONTROL_FACTORY = 0x08
72
+ const val FACTORY_RECOVER = 0x03
72
73
  const val CMD_CONTROL_NOTIFICATION = 0x09
73
74
 
74
75
  // get parameter keys
@@ -89,6 +90,9 @@ internal object NimoProtocol {
89
90
  const val SET_DISPLAY_OFF = 0x0F
90
91
  const val SET_PHONE_TYPE = 0x14
91
92
  const val SET_HEIGHT_LEVEL = 0x17
93
+ // Dynamic-v1 firmware: 1-based UI locale; 1 selects English.
94
+ const val SET_SYSTEM_LANGUAGE = 0x24
95
+ const val LANGUAGE_ENGLISH = 0x01
92
96
 
93
97
  // control instruction keys
94
98
  const val CTRL_ENTER_APP = 0x01
@@ -889,6 +893,17 @@ class Nimo : SGCManager() {
889
893
  private const val KEY_LAST_ADDRESS = "nimo_lastDeviceAddress"
890
894
  private const val KEY_LAST_NAME = "nimo_lastDeviceName"
891
895
 
896
+ internal fun removeBluetoothBond(address: String?) {
897
+ if (address.isNullOrBlank()) return
898
+ try {
899
+ val device = BluetoothAdapter.getDefaultAdapter()?.getRemoteDevice(address) ?: return
900
+ val removed = device.javaClass.getMethod("removeBond").invoke(device) as? Boolean
901
+ Bridge.log("NIMO: Bluetooth unbond requested=$removed")
902
+ } catch (error: Exception) {
903
+ Bridge.log("NIMO: Bluetooth unbond failed: ${error.message}")
904
+ }
905
+ }
906
+
892
907
  private const val TWS_TIMEOUT_MS = 10_000L
893
908
  private const val ACK_TIMEOUT_MS = 5_000L
894
909
  // Generous: the glasses' classic bond can take 30-45s PER attempt and may need
@@ -1097,6 +1112,31 @@ class Nimo : SGCManager() {
1097
1112
  DeviceStore.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
1098
1113
  }
1099
1114
 
1115
+ /** Explicit Unpair only. Ordinary forget/cleanup must never reset the glasses. */
1116
+ internal fun resetForUnpair(onResult: (Boolean) -> Unit) {
1117
+ check(Looper.myLooper() == mainHandler.looper)
1118
+ if (gatt == null || txChar == null || handshakeState != HandshakeState.READY) {
1119
+ Bridge.log("NIMO: unpair while offline; remote reset unavailable")
1120
+ onResult(false)
1121
+ return
1122
+ }
1123
+ isDisconnecting = true
1124
+ stopTimers()
1125
+ reconnectionManager.stop()
1126
+ canvasEncoder.invalidate()
1127
+ canvas.disconnected()
1128
+ Bridge.log("NIMO: sending factory reset for explicit Unpair")
1129
+ sendAwaitingAck(NimoProtocol.CMD_CONTROL_FACTORY, NimoProtocol.FACTORY_RECOVER, byteArrayOf()) {
1130
+ Bridge.log("NIMO: factory reset acknowledged=$it")
1131
+ onResult(it)
1132
+ }
1133
+ }
1134
+
1135
+ internal fun removeBluetoothBond() {
1136
+ val address = gatt?.device?.address ?: lastDeviceAddress ?: return
1137
+ removeBluetoothBond(address)
1138
+ }
1139
+
1100
1140
  override fun forget() {
1101
1141
  if (Looper.myLooper() != mainHandler.looper) {
1102
1142
  runBlocking { withContext(Dispatchers.Main) { forget() } }
@@ -1980,7 +2020,10 @@ class Nimo : SGCManager() {
1980
2020
  }
1981
2021
  val timeout = Runnable {
1982
2022
  // Do not retry a timed-out key on the same transport: a late response is uncorrelated.
1983
- if (pendingAcks.remove(ackKey) != null) abortTransport("Handshake ACK timeout cmd=$cmd key=$key")
2023
+ pendingAcks.remove(ackKey)?.let { expired ->
2024
+ abortTransport("ACK timeout cmd=$cmd key=$key")
2025
+ expired.onResult(false)
2026
+ }
1984
2027
  }
1985
2028
  pendingAcks[ackKey] = PendingAck(onResult, timeout)
1986
2029
  mainHandler.postDelayed(timeout, timeoutMs)
@@ -2080,6 +2123,9 @@ class Nimo : SGCManager() {
2080
2123
  needsAck = false
2081
2124
  )
2082
2125
  )
2126
+ // Optional setting: older firmware may reject it without preventing connection.
2127
+ sendFrame(NimoFrameCodec.encodeFrame(NimoProtocol.CMD_SET_PARAMETER,
2128
+ NimoProtocol.SET_SYSTEM_LANGUAGE, byteArrayOf(NimoProtocol.LANGUAGE_ENGLISH.toByte())))
2083
2129
  getBatteryStatus()
2084
2130
  requestVersionInfo()
2085
2131
 
@@ -2099,7 +2145,10 @@ class Nimo : SGCManager() {
2099
2145
  )
2100
2146
  }
2101
2147
  diagnostics?.connected((negotiatedMtu - 3).coerceIn(20, 512))
2102
- canvas.readiness(twsConnected && peerCompanionReady == true)
2148
+ // Launch checks peer readiness in firmware; do not wait up to a minute for
2149
+ // the first unsolicited heartbeat just because peer status is still unknown.
2150
+ canvas.activate()
2151
+ canvas.readiness(twsConnected && peerCompanionReady != false)
2103
2152
  }
2104
2153
 
2105
2154
  private fun handshakeFailed() {
@@ -2117,6 +2166,10 @@ class Nimo : SGCManager() {
2117
2166
  canvasEncoder.invalidate()
2118
2167
  canvas.disconnected()
2119
2168
  audioClient.stop()
2169
+ // Nimo's decoder/stream belongs to this GATT session. Consumer demand
2170
+ // remains in bluetooth.should_send_*; readiness must start a fresh stream
2171
+ // even when reconnecting with this same adapter instance.
2172
+ DeviceStore.apply("glasses", "micEnabled", false)
2120
2173
  receiveAssembler.reset()
2121
2174
  writes.reset()
2122
2175
  negotiatedMtu = 23
@@ -2171,6 +2224,7 @@ class Nimo : SGCManager() {
2171
2224
  if (diagnostics?.onPacket(packet) == true) return
2172
2225
  if (packet.size >= 10 && packet[8].toInt() == 7 && (packet[9].toInt() and 255) in listOf(1, 3, 4)) {
2173
2226
  val response = NimoCanvasCodec.response(packet) ?: return
2227
+ if (response.first == 1) Bridge.log("NIMO: canvas Launch response status=${response.second.firstOrNull()?.toInt()}")
2174
2228
  canvas.response(response.first, response.second)
2175
2229
  return
2176
2230
  }
@@ -2202,13 +2256,13 @@ class Nimo : SGCManager() {
2202
2256
  if (appId != NimoCanvasCodec.APP_ID) {
2203
2257
  diagnostics?.cancelHeldCapture("Native app takeover preempted held capture", resumeCanvas = false)
2204
2258
  }
2205
- if (canvas.nativeApp(appId, true)) canvasEncoder.invalidate()
2259
+ canvas.nativeApp(appId, true)
2206
2260
  }
2207
2261
  NimoProtocol.STATE_EXIT -> {
2208
2262
  if (appId == NimoCanvasCodec.APP_ID) {
2209
2263
  diagnostics?.cancelHeldCapture("Canvas exit preempted held capture", resumeCanvas = false)
2210
2264
  }
2211
- if (canvas.nativeApp(appId, false)) canvasEncoder.invalidate()
2265
+ canvas.nativeApp(appId, false)
2212
2266
  }
2213
2267
  }
2214
2268
  }
@@ -2240,7 +2294,7 @@ class Nimo : SGCManager() {
2240
2294
  // Both predicates were sampled by this device report. TWS-only
2241
2295
  // reports and cached state cannot release a status-7 wait.
2242
2296
  if (handshakeState == HandshakeState.READY) {
2243
- canvas.confirmedReadiness(twsConnected && peerCompanionReady == true)
2297
+ canvas.confirmedReadiness(twsConnected && peerCompanionReady != false)
2244
2298
  }
2245
2299
  }
2246
2300
  }
@@ -2266,7 +2320,7 @@ class Nimo : SGCManager() {
2266
2320
  if (!connected && handshakeState == HandshakeState.READY) {
2267
2321
  Bridge.log("NIMO: TWS service dropped mid-session (arm removed/off?)")
2268
2322
  }
2269
- if (handshakeState == HandshakeState.READY) canvas.readiness(connected && peerCompanionReady == true)
2323
+ if (handshakeState == HandshakeState.READY) canvas.readiness(connected && peerCompanionReady != false)
2270
2324
  }
2271
2325
 
2272
2326
  private fun handleInputEvent(code: Int) {
@@ -2302,6 +2356,9 @@ class Nimo : SGCManager() {
2302
2356
  // 5 not supported, 6 bad parameter, 7 device busy.
2303
2357
  Bridge.log("NIMO: response cmd=$cmd key=$key error status=$statusCode")
2304
2358
  }
2359
+ if (cmd == NimoProtocol.CMD_SET_PARAMETER && key == NimoProtocol.SET_SYSTEM_LANGUAGE) {
2360
+ Bridge.log("NIMO: English menu setting acknowledged=${statusCode == 0} status=$statusCode")
2361
+ }
2305
2362
  resolvePendingAck(cmd, key, statusCode == 0)
2306
2363
 
2307
2364
  if (cmd != NimoProtocol.CMD_GET_PARAMETER || statusCode != 0) return
@@ -132,6 +132,7 @@ internal class NimoCanvasCoordinator(
132
132
  private var holding = false
133
133
  private var heldScope: String? = null
134
134
  private var cancelDeadline: (() -> Unit)? = null
135
+ private var cancelRetry: (() -> Unit)? = null
135
136
 
136
137
  fun offer(bytes: ByteArray, scope: String, force: Boolean = false) {
137
138
  if (holding && scope != heldScope) {
@@ -140,8 +141,15 @@ internal class NimoCanvasCoordinator(
140
141
  }
141
142
  run(session.offer(bytes, scope, force))
142
143
  }
143
- fun readiness(ready: Boolean) = run(session.readiness(ready))
144
- fun confirmedReadiness(ready: Boolean) = run(session.confirmedReadiness(ready))
144
+ fun activate() = run(session.activate())
145
+ fun readiness(ready: Boolean) {
146
+ if (!ready) { cancelRetry?.invoke(); cancelRetry = null }
147
+ run(session.readiness(ready))
148
+ }
149
+ fun confirmedReadiness(ready: Boolean) {
150
+ if (!ready) { cancelRetry?.invoke(); cancelRetry = null }
151
+ run(session.confirmedReadiness(ready))
152
+ }
145
153
  fun hold(onReady: () -> Unit) {
146
154
  session.hold(true)
147
155
  holding = true
@@ -155,19 +163,14 @@ internal class NimoCanvasCoordinator(
155
163
  heldScope = null
156
164
  run(session.hold(false, resume))
157
165
  }
158
- fun exit() = run(session.exit())
159
- /** Whether this report also invalidates an unfinished host encode. Exit's report does not
160
- * invalidate a NEW scene submitted while the explicit Exit waits for its business ACK. */
161
- fun nativeApp(appId: Int, entered: Boolean): Boolean {
162
- val takeover = (appId == NimoCanvasCodec.APP_ID && !entered) ||
163
- (appId != NimoCanvasCodec.APP_ID && entered)
164
- val expectedExitReport = appId == NimoCanvasCodec.APP_ID && !entered && flight?.action?.key == 3
165
- val invalidateEncoding = takeover && !expectedExitReport
166
- run(session.nativeApp(appId, entered))
167
- return invalidateEncoding
166
+ fun exit() {
167
+ cancelRetry?.invoke(); cancelRetry = null
168
+ run(session.exit())
168
169
  }
170
+ fun nativeApp(appId: Int, entered: Boolean) = run(session.nativeApp(appId, entered))
169
171
 
170
172
  fun disconnected() {
173
+ cancelRetry?.invoke(); cancelRetry = null
171
174
  cancelDeadline?.invoke(); cancelDeadline = null
172
175
  flight = null
173
176
  holdReady = null
@@ -199,6 +202,7 @@ internal class NimoCanvasCoordinator(
199
202
  private fun run(actions: List<NimoCanvasSession.Action>) {
200
203
  for (action in actions) when (action) {
201
204
  is NimoCanvasSession.Action.Send -> {
205
+ cancelRetry?.invoke(); cancelRetry = null
202
206
  cancelDeadline?.invoke()
203
207
  val current = Flight(action)
204
208
  flight = current
@@ -219,7 +223,16 @@ internal class NimoCanvasCoordinator(
219
223
  }
220
224
  }
221
225
  is NimoCanvasSession.Action.Reconnect -> { disconnected(); reconnect(action.reason) }
222
- is NimoCanvasSession.Action.Rejected -> rejected(action.status)
226
+ is NimoCanvasSession.Action.Rejected -> {
227
+ rejected(action.status)
228
+ if (action.status == 7) {
229
+ cancelRetry?.invoke()
230
+ cancelRetry = scheduler.post(1_000) {
231
+ cancelRetry = null
232
+ run(session.retryNotReady())
233
+ }
234
+ }
235
+ }
223
236
  }
224
237
  }
225
238
  }
@@ -9,6 +9,7 @@ internal class NimoCanvasSession {
9
9
  }
10
10
  private data class Flight(val key: Int, val frame: ByteArray, val ticket: Long)
11
11
  private var ready = false
12
+ private var transportReady = false
12
13
  private var active = false
13
14
  private var held = false
14
15
  private var locked = false
@@ -16,6 +17,7 @@ internal class NimoCanvasSession {
16
17
  private var waitingReadiness = false
17
18
  private var observedNotReady = false
18
19
  private var readinessRetries = 0
20
+ private var probeRetries = 0
19
21
  private var exitRequested = false
20
22
  private var desired: ByteArray? = null
21
23
  private var accepted: ByteArray? = null
@@ -35,6 +37,23 @@ internal class NimoCanvasSession {
35
37
  return pump()
36
38
  }
37
39
 
40
+ /** Enter a blank canvas on a fresh connection unless a newer host scene is retained. */
41
+ fun activate(): List<Action> {
42
+ if (desired == null) desired = byteArrayOf(0, 0, 1)
43
+ exitRequested = false
44
+ return pump()
45
+ }
46
+
47
+ /** Launch itself checks peer readiness in firmware. Retry only explicit NOT_READY,
48
+ * with a small budget; never retry an ambiguous/missing business ACK on this link. */
49
+ fun retryNotReady(): List<Action> {
50
+ if (!transportReady || !waitingReadiness || desired == null || probeRetries >= 3) return emptyList()
51
+ probeRetries++
52
+ waitingReadiness = false
53
+ ready = true
54
+ return pump()
55
+ }
56
+
38
57
  /** Debug capture seam; production behavior is unchanged unless explicitly held. */
39
58
  fun hold(value: Boolean, resume: Boolean = true): List<Action> {
40
59
  held = value
@@ -50,7 +69,8 @@ internal class NimoCanvasSession {
50
69
  fun confirmedReadiness(value: Boolean): List<Action> = updateReadiness(value, confirmed = true)
51
70
 
52
71
  private fun updateReadiness(value: Boolean, confirmed: Boolean): List<Action> {
53
- if (!value) readinessRetries = 0
72
+ transportReady = value
73
+ if (!value) { readinessRetries = 0; probeRetries = 0 }
54
74
  if (!value && flight != null) return resetLink("Readiness lost during canvas command")
55
75
  if (waitingReadiness) {
56
76
  if (!value) observedNotReady = true
@@ -67,9 +87,9 @@ internal class NimoCanvasSession {
67
87
 
68
88
  /** A new transport generation has no accepted frame. Retain only the latest desired scene. */
69
89
  fun disconnected() {
70
- ready = false; active = false; held = false; locked = false; launchBlocked = false
90
+ ready = false; transportReady = false; active = false; held = false; locked = false; launchBlocked = false
71
91
  waitingReadiness = false; observedNotReady = false
72
- readinessRetries = 0
92
+ readinessRetries = 0; probeRetries = 0
73
93
  accepted = null; rejected = null; flight = null; ticket++; forceRevision++
74
94
  }
75
95
 
@@ -78,21 +98,13 @@ internal class NimoCanvasSession {
78
98
  return pump()
79
99
  }
80
100
 
81
- /** A local gesture/native app takeover suppresses replay until the host submits another scene. */
101
+ /** Firmware can restore its stock UI after reconnect. Keep host ownership and
102
+ * replay the newest scene, serializing behind any outstanding command ACK. */
82
103
  fun nativeApp(appId: Int, entered: Boolean): List<Action> {
83
104
  if ((appId == NimoCanvasCodec.APP_ID && !entered) || (appId != NimoCanvasCodec.APP_ID && entered)) {
84
- // The expected FD Exit report preserves newer host content. A different
85
- // native app entering is a real takeover, even while our Exit waits for ACK.
86
- if (flight?.key == 3) {
87
- active = false; accepted = null
88
- if (appId != NimoCanvasCodec.APP_ID && entered) {
89
- desired = null; rejected = null; scope = null
90
- }
91
- return emptyList()
92
- }
93
- val ambiguous = flight != null
94
- desired = null; accepted = null; rejected = null; active = false; exitRequested = false; scope = null
95
- if (ambiguous) return resetLink("Native app transition interrupted canvas command")
105
+ active = false
106
+ accepted = null
107
+ return pump()
96
108
  }
97
109
  return emptyList()
98
110
  }
@@ -130,8 +142,8 @@ internal class NimoCanvasSession {
130
142
  return listOf(Action.Rejected(status)) + pump()
131
143
  }
132
144
  when (key) {
133
- 1 -> active = true
134
- 4 -> { accepted = current.frame; rejected = null; readinessRetries = 0 }
145
+ 1 -> { active = true; accepted = null }
146
+ 4 -> { accepted = current.frame; rejected = null; readinessRetries = 0; probeRetries = 0 }
135
147
  3 -> { active = false; accepted = null; exitRequested = false }
136
148
  }
137
149
  return pump()
@@ -143,7 +155,7 @@ internal class NimoCanvasSession {
143
155
  }
144
156
 
145
157
  private fun resetLink(reason: String): List<Action> {
146
- ready = false; active = false; accepted = null; flight = null; ticket++; forceRevision++
158
+ ready = false; transportReady = false; active = false; accepted = null; flight = null; ticket++; forceRevision++
147
159
  return listOf(Action.Reconnect(reason))
148
160
  }
149
161
 
@@ -4,6 +4,7 @@ import android.bluetooth.BluetoothGatt
4
4
  import android.bluetooth.BluetoothGattCallback
5
5
  import android.bluetooth.BluetoothGattCharacteristic
6
6
  import android.bluetooth.BluetoothGattDescriptor
7
+ import android.os.Build
7
8
 
8
9
  /**
9
10
  * Binder callbacks only capture their arguments. Session validation and all callback effects run
@@ -45,6 +46,10 @@ internal abstract class SerializedGattCallback(
45
46
  final override fun onCharacteristicChanged(
46
47
  gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic,
47
48
  ) {
49
+ // Android 13 introduced the value callback; some framework versions also call
50
+ // this deprecated overload for the same notification. Decoding both replays
51
+ // each LC3 packet and doubles recorded audio. Older Android uses this path.
52
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return
48
53
  val value = characteristic.value?.copyOf() ?: return
49
54
  dispatch(gatt) { handleCharacteristicChanged(gatt, characteristic, value) }
50
55
  }
@@ -24,6 +24,110 @@ import java.util.concurrent.TimeUnit
24
24
  class DeviceManagerSceneHandoffTest {
25
25
  @Before fun setup() { Bridge.initialize(ApplicationProvider.getApplicationContext()) }
26
26
 
27
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
28
+ fun delayedReconnectReplayPreservesNimoDepthEndpoints() {
29
+ for (depth in listOf(0, 10)) {
30
+ withReadyRecordingDevice { manager, device, _ ->
31
+ DeviceStore.set("bluetooth", "dashboard_height", 7)
32
+ DeviceStore.set("bluetooth", "dashboard_depth", depth)
33
+ manager.handleDeviceReady()
34
+ assertTrue(device.positions.isEmpty())
35
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(2, TimeUnit.SECONDS)
36
+ assertEquals(listOf(7 to depth), device.positions)
37
+ }
38
+ }
39
+ }
40
+
41
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
42
+ fun reconnectRestoresUnchangedAudioRequests() {
43
+ for (request in listOf("should_send_lc3", "should_send_pcm", "should_send_transcript", "local_stt_fallback_active")) {
44
+ withReadyRecordingDevice { manager, original, _ ->
45
+ for (key in listOf("should_send_lc3", "should_send_pcm", "should_send_transcript", "local_stt_fallback_active")) {
46
+ DeviceStore.set("bluetooth", key, key == request)
47
+ }
48
+ DeviceStore.set("bluetooth", "micRanking", listOf("glasses"))
49
+ DeviceStore.set("glasses", "micEnabled", false)
50
+ original.hasMic = true
51
+ manager.setMicState()
52
+ assertEquals(true, DeviceStore.get("bluetooth", "micEnabled"))
53
+ assertEquals(true, DeviceStore.get("glasses", "micEnabled"))
54
+
55
+ repeat(3) {
56
+ manager.disconnect()
57
+ assertEquals(false, DeviceStore.get("bluetooth", "micEnabled"))
58
+ assertEquals(false, DeviceStore.get("glasses", "micEnabled"))
59
+ assertEquals(true, DeviceStore.get("bluetooth", request))
60
+ val replacement = NimoRecordingSGC().apply { hasMic = true }
61
+ manager.sgc = replacement
62
+
63
+ // No test reset of glasses.micEnabled: production teardown must
64
+ // clear it. Replaying the same consumer request is deduplicated.
65
+ DeviceStore.apply("bluetooth", request, true)
66
+ assertEquals(false, DeviceStore.get("bluetooth", "micEnabled"))
67
+ DeviceStore.apply("glasses", "fullyBooted", true)
68
+
69
+ assertEquals("Reconnect must restore $request", true, DeviceStore.get("bluetooth", "micEnabled"))
70
+ assertEquals(listOf(true), replacement.micChanges)
71
+ assertEquals("glasses", manager.activeMicSource())
72
+ }
73
+ }
74
+ }
75
+ }
76
+
77
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
78
+ fun linkDisconnectRetainsIntentForTheSameCommunicator() {
79
+ withReadyRecordingDevice { manager, original, _ ->
80
+ original.hasMic = true
81
+ DeviceStore.set("bluetooth", "micRanking", listOf("glasses"))
82
+ DeviceStore.set("glasses", "micEnabled", false)
83
+ DeviceStore.set("bluetooth", "should_send_lc3", true)
84
+ manager.setMicState()
85
+ assertEquals(listOf(true), original.micChanges)
86
+ DeviceStore.set("glasses", "fullyBooted", true)
87
+ DeviceStore.apply("glasses", "fullyBooted", false)
88
+ assertSame(original, manager.sgc)
89
+ assertEquals(true, DeviceStore.get("bluetooth", "should_send_lc3"))
90
+ assertEquals(true, DeviceStore.get("glasses", "micEnabled"))
91
+ }
92
+ }
93
+
94
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
95
+ fun discardingCommunicatorInvalidatesMicCache() {
96
+ withReadyRecordingDevice { manager, original, _ ->
97
+ original.hasMic = true
98
+ DeviceStore.set("bluetooth", "micRanking", listOf("glasses"))
99
+ DeviceStore.set("glasses", "micEnabled", false)
100
+ DeviceStore.set("bluetooth", "should_send_lc3", true)
101
+ manager.setMicState()
102
+ manager.initSGC(original.type)
103
+ assertEquals(true, DeviceStore.get("glasses", "micEnabled"))
104
+
105
+ // An unsupported model exercises disposal without starting real BLE.
106
+ manager.initSGC("Unavailable test glasses")
107
+ assertNull(manager.sgc)
108
+ assertEquals(false, DeviceStore.get("glasses", "micEnabled"))
109
+ assertEquals(true, DeviceStore.get("bluetooth", "should_send_lc3"))
110
+ }
111
+ }
112
+
113
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
114
+ fun reconnectWithoutAudioRequestsLeavesMicrophoneOff() {
115
+ withReadyRecordingDevice { manager, _, _ ->
116
+ for (key in listOf("should_send_lc3", "should_send_pcm", "should_send_transcript", "local_stt_fallback_active")) {
117
+ DeviceStore.set("bluetooth", key, false)
118
+ }
119
+ DeviceStore.set("glasses", "micEnabled", true)
120
+ manager.disconnect()
121
+ assertEquals(false, DeviceStore.get("glasses", "micEnabled"))
122
+ val replacement = NimoRecordingSGC().apply { hasMic = true }
123
+ manager.sgc = replacement
124
+ DeviceStore.apply("glasses", "fullyBooted", true)
125
+ assertEquals(false, DeviceStore.get("bluetooth", "micEnabled"))
126
+ assertEquals("", manager.activeMicSource())
127
+ assertTrue(replacement.micChanges.isEmpty())
128
+ }
129
+ }
130
+
27
131
  @Test @LooperMode(LooperMode.Mode.PAUSED)
28
132
  fun nimoBrightnessChangesDoNotReplaceTheSelectedScene() {
29
133
  for (headUp in listOf(false, true)) for ((key, value) in brightnessChanges()) {
@@ -499,8 +603,10 @@ class DeviceManagerSceneHandoffTest {
499
603
  }
500
604
 
501
605
  private open class RecordingSGC(override val sceneHandoffRequiresClear: Boolean) : SGCManager() {
606
+ val micChanges = mutableListOf<Boolean>()
502
607
  val calls = CopyOnWriteArrayList<String>()
503
608
  val brightnessCalls = mutableListOf<String>()
609
+ val positions = mutableListOf<Pair<Int, Int>>()
504
610
  val textSent = CountDownLatch(1)
505
611
  var lastTextNanos = 0L
506
612
  var lastClearNanos = 0L
@@ -511,7 +617,10 @@ class DeviceManagerSceneHandoffTest {
511
617
  override fun sendTextWall(text: String) { lastTextNanos = System.nanoTime(); calls += "text:$text"; textSent.countDown() }
512
618
  override fun disconnect() { calls += "disconnect" }
513
619
  override fun cleanup() { calls += "cleanup" }
514
- override fun setMicEnabled(enabled: Boolean) {}
620
+ override fun setMicEnabled(enabled: Boolean) {
621
+ micChanges += enabled
622
+ DeviceStore.set("glasses", "micEnabled", enabled)
623
+ }
515
624
  override fun sortMicRanking(list: MutableList<String>) = list
516
625
  override fun requestPhoto(request: PhotoRequest) {}
517
626
  override fun startStream(message: MutableMap<String, Any>) {}
@@ -528,7 +637,7 @@ class DeviceManagerSceneHandoffTest {
528
637
  override fun sendDoubleTextWall(top: String, bottom: String) {}
529
638
  override fun displayBitmap(base64ImageData: String, x: Int?, y: Int?, width: Int?, height: Int?) = true
530
639
  override fun showDashboard() {}
531
- override fun setDashboardPosition(height: Int, depth: Int) {}
640
+ override fun setDashboardPosition(height: Int, depth: Int) { positions += height to depth }
532
641
  override fun setHeadUpAngle(angle: Int) {}
533
642
  override fun getBatteryStatus() {}
534
643
  override fun setSilentMode(enabled: Boolean) {}