@mentra/bluetooth-sdk 0.1.13 → 0.1.16

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 (80) hide show
  1. package/README.md +18 -8
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +48 -48
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +5 -20
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +360 -269
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +3 -7
  6. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +152 -72
  7. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +12 -12
  8. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +25 -11
  9. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +1 -2
  10. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +1 -2
  11. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -22
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +51 -8
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +1184 -800
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -5
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +423 -117
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +4 -6
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +2532 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +17 -5
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +3 -7
  20. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
  21. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +16 -33
  22. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +5 -3
  23. package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +2 -1
  24. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +60 -1
  25. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +333 -0
  26. package/build/BluetoothSdk.types.d.ts +50 -24
  27. package/build/BluetoothSdk.types.d.ts.map +1 -1
  28. package/build/BluetoothSdk.types.js +1 -0
  29. package/build/BluetoothSdk.types.js.map +1 -1
  30. package/build/_internal.d.ts +4 -4
  31. package/build/_internal.js +4 -4
  32. package/build/_internal.js.map +1 -1
  33. package/build/_private/BluetoothSdkModule.d.ts +14 -8
  34. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  35. package/build/_private/BluetoothSdkModule.js +4 -3
  36. package/build/_private/BluetoothSdkModule.js.map +1 -1
  37. package/build/_private/cameraRequestPayload.d.ts +4 -0
  38. package/build/_private/cameraRequestPayload.d.ts.map +1 -0
  39. package/build/_private/cameraRequestPayload.js +25 -0
  40. package/build/_private/cameraRequestPayload.js.map +1 -0
  41. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  42. package/build/_private/photoRequestPayload.js +11 -3
  43. package/build/_private/photoRequestPayload.js.map +1 -1
  44. package/build/index.d.ts +1 -1
  45. package/build/index.d.ts.map +1 -1
  46. package/build/index.js +70 -61
  47. package/build/index.js.map +1 -1
  48. package/ios/BluetoothSdkModule.swift +51 -28
  49. package/ios/Source/Bridge.swift +4 -16
  50. package/ios/Source/DeviceManager.swift +76 -22
  51. package/ios/Source/DeviceStore.swift +0 -4
  52. package/ios/Source/MentraBluetoothSDK.swift +144 -78
  53. package/ios/Source/OtaManifest.swift +13 -13
  54. package/ios/Source/camera/CameraModels.swift +82 -20
  55. package/ios/Source/controllers/ControllerManager.swift +1 -2
  56. package/ios/Source/controllers/R1.swift +1 -2
  57. package/ios/Source/errors/{BluetoothError.swift → BluetoothSdkError.swift} +1 -1
  58. package/ios/Source/events/BluetoothEvents.swift +5 -27
  59. package/ios/Source/internal/BluetoothAvailability.swift +5 -5
  60. package/ios/Source/sgcs/Frame.swift +0 -2
  61. package/ios/Source/sgcs/G1.swift +52 -4
  62. package/ios/Source/sgcs/G2.swift +643 -204
  63. package/ios/Source/sgcs/Mach1.swift +1 -3
  64. package/ios/Source/sgcs/MentraLive.swift +64 -77
  65. package/ios/Source/sgcs/MentraNex.swift +1 -3
  66. package/ios/Source/sgcs/Nimo.swift +1928 -0
  67. package/ios/Source/sgcs/SGCManager.swift +16 -10
  68. package/ios/Source/sgcs/Simulated.swift +2 -6
  69. package/ios/Source/status/DeviceStatus.swift +0 -8
  70. package/ios/Source/streaming/StreamModels.swift +7 -32
  71. package/ios/Source/stt/STTTools.swift +1 -1
  72. package/ios/Source/types/DeviceModels.swift +5 -0
  73. package/ios/Source/utils/Constants.swift +2 -0
  74. package/package.json +6 -1
  75. package/src/BluetoothSdk.types.ts +54 -28
  76. package/src/_internal.ts +4 -4
  77. package/src/_private/BluetoothSdkModule.ts +20 -11
  78. package/src/_private/cameraRequestPayload.ts +29 -0
  79. package/src/_private/photoRequestPayload.ts +12 -3
  80. package/src/index.ts +77 -67
@@ -28,13 +28,16 @@ import java.io.ByteArrayOutputStream
28
28
  import java.util.TimeZone
29
29
  import java.util.UUID
30
30
  import java.util.regex.Pattern
31
+ import kotlinx.coroutines.CompletableDeferred
31
32
  import kotlinx.coroutines.CoroutineScope
32
33
  import kotlinx.coroutines.Dispatchers
34
+ import kotlinx.coroutines.Job
33
35
  import kotlinx.coroutines.SupervisorJob
36
+ import kotlinx.coroutines.channels.Channel
34
37
  import kotlinx.coroutines.delay
38
+ import kotlinx.coroutines.isActive
35
39
  import kotlinx.coroutines.launch
36
- import kotlinx.coroutines.sync.Mutex
37
- import kotlinx.coroutines.sync.withLock
40
+ import kotlinx.coroutines.withTimeoutOrNull
38
41
 
39
42
  // ---------- G2 Protocol Constants ----------
40
43
 
@@ -264,10 +267,12 @@ private class ProtobufReader(private val data: ByteArray) {
264
267
  val v = readVarint()
265
268
  if (v != null) fields[fieldNum] = v.toInt()
266
269
  }
270
+
267
271
  2 -> {
268
272
  val d = readBytes()
269
273
  if (d != null) fields[fieldNum] = d
270
274
  }
275
+
271
276
  else -> skipField(wireType)
272
277
  }
273
278
  }
@@ -279,18 +284,18 @@ private class ProtobufReader(private val data: ByteArray) {
279
284
 
280
285
  private object EvenHubProto {
281
286
  fun textContainerProperty(
282
- x: Int,
283
- y: Int,
284
- width: Int,
285
- height: Int,
286
- borderWidth: Int = 0,
287
- borderColor: Int = 0,
288
- borderRadius: Int = 0,
289
- paddingLength: Int = 0,
290
- containerID: Int,
291
- containerName: String? = null,
292
- isEventCapture: Boolean = false,
293
- content: String? = null
287
+ x: Int,
288
+ y: Int,
289
+ width: Int,
290
+ height: Int,
291
+ borderWidth: Int = 0,
292
+ borderColor: Int = 0,
293
+ borderRadius: Int = 0,
294
+ paddingLength: Int = 0,
295
+ containerID: Int,
296
+ containerName: String? = null,
297
+ isEventCapture: Boolean = false,
298
+ content: String? = null
294
299
  ): ByteArray {
295
300
  val w = ProtobufWriter()
296
301
  w.writeInt32Field(1, x)
@@ -309,12 +314,12 @@ private object EvenHubProto {
309
314
  }
310
315
 
311
316
  fun imageContainerProperty(
312
- x: Int,
313
- y: Int,
314
- width: Int,
315
- height: Int,
316
- containerID: Int,
317
- containerName: String? = null
317
+ x: Int,
318
+ y: Int,
319
+ width: Int,
320
+ height: Int,
321
+ containerID: Int,
322
+ containerName: String? = null
318
323
  ): ByteArray {
319
324
  val w = ProtobufWriter()
320
325
  w.writeInt32Field(1, x)
@@ -327,14 +332,14 @@ private object EvenHubProto {
327
332
  }
328
333
 
329
334
  fun imageRawDataUpdate(
330
- containerID: Int,
331
- containerName: String? = null,
332
- mapSessionId: Int,
333
- mapTotalSize: Int,
334
- compressMode: Int = 0,
335
- mapFragmentIndex: Int,
336
- mapFragmentPacketSize: Int,
337
- mapRawData: ByteArray
335
+ containerID: Int,
336
+ containerName: String? = null,
337
+ mapSessionId: Int,
338
+ mapTotalSize: Int,
339
+ compressMode: Int = 0,
340
+ mapFragmentIndex: Int,
341
+ mapFragmentPacketSize: Int,
342
+ mapRawData: ByteArray
338
343
  ): ByteArray {
339
344
  val w = ProtobufWriter()
340
345
  w.writeInt32Field(1, containerID)
@@ -349,9 +354,9 @@ private object EvenHubProto {
349
354
  }
350
355
 
351
356
  fun createStartupPageContainer(
352
- containerTotalNum: Int,
353
- textContainers: List<ByteArray> = emptyList(),
354
- imageContainers: List<ByteArray> = emptyList()
357
+ containerTotalNum: Int,
358
+ textContainers: List<ByteArray> = emptyList(),
359
+ imageContainers: List<ByteArray> = emptyList()
355
360
  ): ByteArray {
356
361
  val w = ProtobufWriter()
357
362
  w.writeInt32Field(1, containerTotalNum)
@@ -361,10 +366,10 @@ private object EvenHubProto {
361
366
  }
362
367
 
363
368
  fun textContainerUpgrade(
364
- containerID: Int,
365
- contentOffset: Int = 0,
366
- contentLength: Int,
367
- content: String
369
+ containerID: Int,
370
+ contentOffset: Int = 0,
371
+ contentLength: Int,
372
+ content: String
368
373
  ): ByteArray {
369
374
  val w = ProtobufWriter()
370
375
  w.writeInt32Field(1, containerID)
@@ -393,11 +398,11 @@ private object EvenHubProto {
393
398
  }
394
399
 
395
400
  fun evenHubMessage(
396
- cmd: EvenHubCmd,
397
- subFieldNumber: Int,
398
- subMessage: ByteArray,
399
- magicRandom: Int = 0,
400
- appId: Int? = null
401
+ cmd: EvenHubCmd,
402
+ subFieldNumber: Int,
403
+ subMessage: ByteArray,
404
+ magicRandom: Int = 0,
405
+ appId: Int? = null
401
406
  ): ByteArray {
402
407
  val w = ProtobufWriter()
403
408
  w.writeInt32Field(1, cmd.value) // Cmd (field 1, enum)
@@ -408,68 +413,68 @@ private object EvenHubProto {
408
413
  }
409
414
 
410
415
  fun createPageMessage(
411
- textContainers: List<ByteArray> = emptyList(),
412
- imageContainers: List<ByteArray> = emptyList(),
413
- magicRandom: Int = 0,
414
- appId: Int? = null
416
+ textContainers: List<ByteArray> = emptyList(),
417
+ imageContainers: List<ByteArray> = emptyList(),
418
+ magicRandom: Int = 0,
419
+ appId: Int? = null
415
420
  ): ByteArray {
416
421
  val total = textContainers.size + imageContainers.size
417
422
  val createMsg = createStartupPageContainer(total, textContainers, imageContainers)
418
423
  return evenHubMessage(
419
- EvenHubCmd.CREATE_STARTUP_PAGE,
420
- 3,
421
- createMsg,
422
- magicRandom = magicRandom,
423
- appId = null
424
+ EvenHubCmd.CREATE_STARTUP_PAGE,
425
+ 3,
426
+ createMsg,
427
+ magicRandom = magicRandom,
428
+ appId = null
424
429
  )
425
430
  }
426
431
 
427
432
  fun rebuildPageMessage(
428
- textContainers: List<ByteArray> = emptyList(),
429
- imageContainers: List<ByteArray> = emptyList(),
430
- magicRandom: Int = 0,
431
- appId: Int? = null
433
+ textContainers: List<ByteArray> = emptyList(),
434
+ imageContainers: List<ByteArray> = emptyList(),
435
+ magicRandom: Int = 0,
436
+ appId: Int? = null
432
437
  ): ByteArray {
433
438
  val total = textContainers.size + imageContainers.size
434
439
  val rebuildMsg = createStartupPageContainer(total, textContainers, imageContainers)
435
440
  return evenHubMessage(
436
- EvenHubCmd.REBUILD_PAGE,
437
- 7,
438
- rebuildMsg,
439
- magicRandom = magicRandom,
440
- appId = appId
441
+ EvenHubCmd.REBUILD_PAGE,
442
+ 7,
443
+ rebuildMsg,
444
+ magicRandom = magicRandom,
445
+ appId = appId
441
446
  )
442
447
  }
443
448
 
444
449
  fun updateImageRawDataMessage(
445
- containerID: Int,
446
- containerName: String? = null,
447
- mapSessionId: Int,
448
- mapTotalSize: Int,
449
- compressMode: Int = 0,
450
- mapFragmentIndex: Int,
451
- mapFragmentPacketSize: Int,
452
- mapRawData: ByteArray
450
+ containerID: Int,
451
+ containerName: String? = null,
452
+ mapSessionId: Int,
453
+ mapTotalSize: Int,
454
+ compressMode: Int = 0,
455
+ mapFragmentIndex: Int,
456
+ mapFragmentPacketSize: Int,
457
+ mapRawData: ByteArray
453
458
  ): ByteArray {
454
459
  val updateMsg =
455
- imageRawDataUpdate(
456
- containerID,
457
- containerName,
458
- mapSessionId,
459
- mapTotalSize,
460
- compressMode,
461
- mapFragmentIndex,
462
- mapFragmentPacketSize,
463
- mapRawData
464
- )
460
+ imageRawDataUpdate(
461
+ containerID,
462
+ containerName,
463
+ mapSessionId,
464
+ mapTotalSize,
465
+ compressMode,
466
+ mapFragmentIndex,
467
+ mapFragmentPacketSize,
468
+ mapRawData
469
+ )
465
470
  return evenHubMessage(EvenHubCmd.UPDATE_IMAGE_RAW_DATA, 5, updateMsg)
466
471
  }
467
472
 
468
473
  fun updateTextMessage(
469
- containerID: Int,
470
- contentOffset: Int = 0,
471
- contentLength: Int,
472
- content: String
474
+ containerID: Int,
475
+ contentOffset: Int = 0,
476
+ contentLength: Int,
477
+ content: String
473
478
  ): ByteArray {
474
479
  val upgradeMsg = textContainerUpgrade(containerID, contentOffset, contentLength, content)
475
480
  return evenHubMessage(EvenHubCmd.UPDATE_TEXT_DATA, 9, upgradeMsg)
@@ -524,9 +529,9 @@ private object EvenHubProto {
524
529
  * ImuReportPace pacing code; ignored when disabling.
525
530
  */
526
531
  fun imuControlMessage(
527
- enable: Boolean,
528
- reportFrq: Int = IMU_PACE_P100,
529
- magicRandom: Int = 0
532
+ enable: Boolean,
533
+ reportFrq: Int = IMU_PACE_P100,
534
+ magicRandom: Int = 0
530
535
  ): ByteArray {
531
536
  val imuMsg = imuCtrlCmd(enable, reportFrq)
532
537
  val w = ProtobufWriter()
@@ -596,15 +601,15 @@ private object DevSettingsProto {
596
601
  }
597
602
 
598
603
  fun ringConnectInfo(
599
- magicRandom: Int,
600
- connect: Boolean,
601
- ringMac: ByteArray,
602
- ringName: String = ""
604
+ magicRandom: Int,
605
+ connect: Boolean,
606
+ ringMac: ByteArray,
607
+ ringName: String = ""
603
608
  ): ByteArray {
604
609
  val w = ProtobufWriter()
605
610
  w.writeInt32Field(
606
- 1,
607
- DevCfgCommandId.RING_CONNECT_INFO.value
611
+ 1,
612
+ DevCfgCommandId.RING_CONNECT_INFO.value
608
613
  ) // commandId = RING_CONNECT_INFO (6)
609
614
  w.writeInt32Field(2, magicRandom)
610
615
 
@@ -856,6 +861,7 @@ private object MenuProto {
856
861
  val appIdMap = mutableMapOf<Int, String>()
857
862
 
858
863
  data class WireItem(val displayName: String?, val appId: Int, val isBuiltIn: Boolean)
864
+
859
865
  val wireItems = mutableListOf<WireItem>()
860
866
 
861
867
  // Always first: built-in Notification (SID=4)
@@ -867,8 +873,8 @@ private object MenuProto {
867
873
  appIdMap[appId] = item.packageName
868
874
 
869
875
  val truncated =
870
- if (item.name.length > MAX_NAME_LENGTH) item.name.take(MAX_NAME_LENGTH)
871
- else item.name
876
+ if (item.name.length > MAX_NAME_LENGTH) item.name.take(MAX_NAME_LENGTH)
877
+ else item.name
872
878
  val prefix = if (item.running) "● " else " "
873
879
  wireItems.add(WireItem(prefix + truncated, appId, false))
874
880
  }
@@ -925,11 +931,11 @@ private object CalendarProto {
925
931
  * which treat timestamps as already-local, display local time)
926
932
  */
927
933
  private fun schedule(
928
- scheduleId: Int,
929
- title: String?,
930
- location: String?,
931
- time: String?,
932
- endTimestamp: Int
934
+ scheduleId: Int,
935
+ title: String?,
936
+ location: String?,
937
+ time: String?,
938
+ endTimestamp: Int
933
939
  ): ByteArray {
934
940
  val w = ProtobufWriter()
935
941
  w.writeInt32Field(1, scheduleId)
@@ -958,16 +964,16 @@ private object CalendarProto {
958
964
  * }
959
965
  */
960
966
  fun calendarPush(
961
- magicRandom: Int,
962
- packageId: Int,
963
- scheduleId: Int,
964
- title: String?,
965
- location: String?,
966
- time: String?,
967
- endTimestamp: Int,
968
- scheduleAuthority: Int,
969
- scheduleTotal: Int,
970
- scheduleNum: Int
967
+ magicRandom: Int,
968
+ packageId: Int,
969
+ scheduleId: Int,
970
+ title: String?,
971
+ location: String?,
972
+ time: String?,
973
+ endTimestamp: Int,
974
+ scheduleAuthority: Int,
975
+ scheduleTotal: Int,
976
+ scheduleNum: Int
971
977
  ): ByteArray {
972
978
  val sched = schedule(scheduleId, title, location, time, endTimestamp)
973
979
 
@@ -1028,10 +1034,10 @@ private object CalendarProto {
1028
1034
 
1029
1035
  private object EvenBLETransport {
1030
1036
  fun buildPackets(
1031
- syncId: Byte,
1032
- serviceId: Byte,
1033
- payload: ByteArray,
1034
- reserveFlag: Boolean = false
1037
+ syncId: Byte,
1038
+ serviceId: Byte,
1039
+ payload: ByteArray,
1040
+ reserveFlag: Boolean = false
1035
1041
  ): List<ByteArray> {
1036
1042
  val maxPayload = G2BLE.MAX_PACKET_PAYLOAD
1037
1043
 
@@ -1069,7 +1075,7 @@ private object EvenBLETransport {
1069
1075
  val packet = ByteArrayOutputStream()
1070
1076
  packet.write(G2BLE.HEADER_BYTE.toInt() and 0xFF)
1071
1077
  packet.write(
1072
- ((G2BLE.DEST_GLASSES.toInt() shl 4) or G2BLE.SOURCE_PHONE.toInt()) and 0xFF
1078
+ ((G2BLE.DEST_GLASSES.toInt() shl 4) or G2BLE.SOURCE_PHONE.toInt()) and 0xFF
1073
1079
  )
1074
1080
  packet.write(syncId.toInt() and 0xFF)
1075
1081
  packet.write(payloadLen.toInt() and 0xFF)
@@ -1111,9 +1117,9 @@ private class G2SendManager {
1111
1117
  }
1112
1118
 
1113
1119
  fun buildPackets(
1114
- serviceId: Byte,
1115
- payload: ByteArray,
1116
- reserveFlag: Boolean = false
1120
+ serviceId: Byte,
1121
+ payload: ByteArray,
1122
+ reserveFlag: Boolean = false
1117
1123
  ): List<ByteArray> {
1118
1124
  val sid = nextSyncId()
1119
1125
  return EvenBLETransport.buildPackets(sid, serviceId, payload, reserveFlag)
@@ -1179,8 +1185,8 @@ private class G2ReceiveManager {
1179
1185
  // ---------- G2 Reconnection Manager ----------
1180
1186
 
1181
1187
  private class G2ReconnectionManager(
1182
- private val intervalMs: Long = 30_000L,
1183
- private val maxAttempts: Int = -1 // -1 for unlimited
1188
+ private val intervalMs: Long = 30_000L,
1189
+ private val maxAttempts: Int = -1 // -1 for unlimited
1184
1190
  ) {
1185
1191
  private val handler = Handler(Looper.getMainLooper())
1186
1192
  private var runnable: Runnable? = null
@@ -1193,27 +1199,27 @@ private class G2ReconnectionManager(
1193
1199
  attempts = 0
1194
1200
 
1195
1201
  val r =
1196
- object : Runnable {
1197
- override fun run() {
1198
- if (maxAttempts > 0 && attempts >= maxAttempts) {
1199
- Bridge.log("G2: Max reconnection attempts ($maxAttempts) reached")
1200
- stop()
1201
- return
1202
- }
1203
-
1204
- attempts++
1205
- Bridge.log("G2: Reconnection attempt $attempts")
1202
+ object : Runnable {
1203
+ override fun run() {
1204
+ if (maxAttempts > 0 && attempts >= maxAttempts) {
1205
+ Bridge.log("G2: Max reconnection attempts ($maxAttempts) reached")
1206
+ stop()
1207
+ return
1208
+ }
1206
1209
 
1207
- val shouldStop = onAttempt()
1208
- if (shouldStop) {
1209
- Bridge.log("G2: Reconnection successful, stopping")
1210
- stop()
1211
- return
1212
- }
1210
+ attempts++
1211
+ Bridge.log("G2: Reconnection attempt $attempts")
1213
1212
 
1214
- handler.postDelayed(this, intervalMs)
1213
+ val shouldStop = onAttempt()
1214
+ if (shouldStop) {
1215
+ Bridge.log("G2: Reconnection successful, stopping")
1216
+ stop()
1217
+ return
1215
1218
  }
1219
+
1220
+ handler.postDelayed(this, intervalMs)
1216
1221
  }
1222
+ }
1217
1223
  runnable = r
1218
1224
  handler.postDelayed(r, intervalMs)
1219
1225
  }
@@ -1259,36 +1265,37 @@ class G2 : SGCManager() {
1259
1265
 
1260
1266
  // Device search
1261
1267
  private var DEVICE_SEARCH_ID = "NOT_SET"
1268
+
1262
1269
  // Map device names to serial numbers (populated from manufacturer data during scan)
1263
1270
  private val deviceNameToSerialNumber = mutableMapOf<String, String>()
1264
1271
 
1265
1272
  // Saved addresses for reconnection
1266
1273
  private var leftGlassAddress: String?
1267
1274
  get() =
1268
- context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
1269
- .getString(KEY_LEFT_ADDRESS, null)
1275
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
1276
+ .getString(KEY_LEFT_ADDRESS, null)
1270
1277
  set(value) {
1271
1278
  context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
1272
- .edit()
1273
- .apply {
1274
- if (value != null) putString(KEY_LEFT_ADDRESS, value)
1275
- else remove(KEY_LEFT_ADDRESS)
1276
- }
1277
- .apply()
1279
+ .edit()
1280
+ .apply {
1281
+ if (value != null) putString(KEY_LEFT_ADDRESS, value)
1282
+ else remove(KEY_LEFT_ADDRESS)
1283
+ }
1284
+ .apply()
1278
1285
  }
1279
1286
 
1280
1287
  private var rightGlassAddress: String?
1281
1288
  get() =
1282
- context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
1283
- .getString(KEY_RIGHT_ADDRESS, null)
1289
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
1290
+ .getString(KEY_RIGHT_ADDRESS, null)
1284
1291
  set(value) {
1285
1292
  context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
1286
- .edit()
1287
- .apply {
1288
- if (value != null) putString(KEY_RIGHT_ADDRESS, value)
1289
- else remove(KEY_RIGHT_ADDRESS)
1290
- }
1291
- .apply()
1293
+ .edit()
1294
+ .apply {
1295
+ if (value != null) putString(KEY_RIGHT_ADDRESS, value)
1296
+ else remove(KEY_RIGHT_ADDRESS)
1297
+ }
1298
+ .apply()
1292
1299
  }
1293
1300
 
1294
1301
  // Reconnection
@@ -1300,23 +1307,61 @@ class G2 : SGCManager() {
1300
1307
  private val mainHandler = Handler(Looper.getMainLooper())
1301
1308
  private var heartbeatRunnable: Runnable? = null
1302
1309
  private var devSettingsHeartbeatRunnable: Runnable? = null
1303
- private var evenHubQueueRunnable: Runnable? = null
1304
- private var pendingTextMsg: ByteArray? = null
1305
- private var lastEvenHubMsg: ByteArray? = null
1306
- private var lastEvenHubResendsRemaining: Int = 0
1310
+
1311
+ /** How many redundant resends each text update gets (text has no ACK). The reconcile loop sets a
1312
+ * container's [TextContainer.pendingSends] to `1 + EVEN_HUB_RESEND_COUNT` on change. */
1307
1313
  private val EVEN_HUB_RESEND_COUNT: Int = 1
1314
+
1315
+ /** ~100ms idle-tick interval for the reconcile loop (text resends, image retries while idle). */
1308
1316
  private val EVEN_HUB_QUEUE_TICK_MS = 100L
1309
1317
  private var startupPageCreated: Boolean = false
1310
1318
  private var pageCreated: Boolean = false
1319
+ // Live hardware truth: is the firmware mic actually streaming. DISTINCT from the
1320
+ // glasses/micEnabled DeviceStore flag, which is *intent* (does the user want the mic on).
1321
+ // Cleared on every page teardown WITHOUT touching intent, so recovery can re-arm iff intent
1322
+ // still says the mic should be on. See the iOS G2.swift counterpart.
1323
+ private var evenHubMicActive: Boolean = false
1311
1324
  private var currentTextContent: String = ""
1312
1325
  private var textContainerID: Int = 1
1313
1326
  private var imageSessionCounter: Int = 0
1314
1327
  private var heartbeatCounter: Int = 0
1328
+
1329
+ // Image-send ACK/retry: the glasses ACK EACH fragment with an ImgResCmd carrying MapSessionId
1330
+ // (field 3), MapFragmentIndex (field 6) and ErrorCode (field 8; 4=success, 5=failed). One
1331
+ // MapSessionId identifies the WHOLE image transfer (constant across its fragments) — the
1332
+ // glasses key their reassembly buffer on it — so each fragment reuses the same session id with
1333
+ // an incrementing MapFragmentIndex. We correlate a fragment's ACK by the (session,
1334
+ // fragmentIndex) pair; sendImageData() awaits it before sending the next fragment. Only one
1335
+ // transfer is ever outstanding (the reconcile loop is the sole sender of images), so a single
1336
+ // slot suffices.
1337
+ // @Volatile: written on the main thread (in sendImageData) but read/completed on the BLE
1338
+ // callback thread (in correlateImageAck) so the ACK is never delayed behind a backed-up main
1339
+ // queue. CompletableDeferred.complete() is itself thread-safe.
1340
+ @Volatile
1341
+ private var pendingImgAckSession: Int? = null
1342
+
1343
+ @Volatile
1344
+ private var pendingImgAckFragment: Int? = null
1345
+
1346
+ @Volatile
1347
+ private var pendingImgAck: CompletableDeferred<Boolean>? = null
1348
+ private val IMG_ACK_TIMEOUT_MS = 2000L // matches Dart host
1349
+ private val IMG_MAX_ATTEMPTS = 3
1315
1350
  private var authStarted: Boolean = false
1316
1351
  private var leftAuthenticated: Boolean = false
1317
1352
  private var rightAuthenticated: Boolean = false
1318
1353
  private var currentBitmapBase64: String = ""
1319
1354
  private var dashboardShowing = 0
1355
+ // The 08011A00 gesture_ctrl event is ambiguous: the firmware sends it BOTH when the dashboard
1356
+ // opens (shuts our page down to take the screen) and when it closes. showDashboard() sets this
1357
+ // latch; the next 08011A00 is the OPEN confirm — consume it WITHOUT recovering (else we rebuild
1358
+ // and snatch the screen back). The following 08011A00 is the real CLOSE → recover. See iOS.
1359
+ private var dashboardOpening = false
1360
+ // Recovery throttle (see iOS G2.swift): the firmware spams systemExit + dashboard-close
1361
+ // ~1×/sec. Coalesce so recovery can't storm — one rebuild in flight, one per RECOVERY_DEBOUNCE_MS.
1362
+ private var recoveryInFlight = false
1363
+ private var lastRecoveryRebuildMs: Long = 0
1364
+ private val RECOVERY_DEBOUNCE_MS: Long = 1500
1320
1365
 
1321
1366
  // Dashboard menu state
1322
1367
  private var menuAppIdToPackageName: MutableMap<Int, String> = mutableMapOf()
@@ -1328,66 +1373,89 @@ class G2 : SGCManager() {
1328
1373
  private var lastGestureCtrlTimestamp: Long? = null
1329
1374
 
1330
1375
  /**
1331
- * Serializes all display mutations (text/bitmap/clear/rebuild). Each public display entry point
1332
- * launches on [displayScope] and runs its body under [displayMutex] so page shutdown/create and
1333
- * fragment sends can never interleave with another update on the glasses.
1376
+ * Owns every display mutation. Each public display entry point launches on this single-threaded
1377
+ * Main scope and only mutates container state (content / bmpData / dirty flags), never sends
1378
+ * directly. The [displayReconcileJob] background loop also on this scope — is the sole sender,
1379
+ * so exactly one image transfer is ever in flight by construction (no lock needed; matches the
1380
+ * iOS reconcile design that replaced DisplayMutex).
1334
1381
  */
1335
1382
  private val displayScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
1336
- private val displayMutex = Mutex()
1383
+
1384
+ /** Background loop that owns ALL display sends (text + images): each pass pushes dirty text
1385
+ * containers, then dirty image containers one at a time. Display ops just mark containers dirty
1386
+ * and signal [displayDirtySignal]. */
1387
+ private var displayReconcileJob: Job? = null
1388
+
1389
+ /** ~100ms ticker that nudges the reconcile loop while idle, so text resends and image retries
1390
+ * still happen with no new mutations. Just sends into [displayDirtySignal]. */
1391
+ private var displayTickJob: Job? = null
1392
+
1393
+ /** Wakes the reconcile loop the instant a container is marked dirty, instead of waiting out the
1394
+ * idle tick. CONFLATED (1-deep, newest-wins) so a burst of mutations coalesces into one wake —
1395
+ * the Kotlin analogue of iOS's `AsyncStream(bufferingPolicy: .bufferingNewest(1))`. */
1396
+ private var displayDirtySignal: Channel<Unit>? = null
1337
1397
 
1338
1398
  /** A tracked image container on the current page. Keyed by its rect for reuse. */
1339
1399
  private data class ImgContainer(
1340
- val id: Int,
1341
- val x: Int,
1342
- val y: Int,
1343
- val width: Int,
1344
- val height: Int,
1345
- // The converted BMP cached so the container can be re-sent on a page rebuild.
1346
- // NOTE: this ByteArray makes the auto-generated equals/hashCode unreliable, but the
1347
- // class is only ever compared via matches()/id, so that is never relied upon.
1348
- var bmpData: ByteArray
1400
+ val id: Int,
1401
+ val x: Int,
1402
+ val y: Int,
1403
+ val width: Int,
1404
+ val height: Int,
1405
+ // The converted BMP cached so the container can be re-sent on a page rebuild.
1406
+ // NOTE: this ByteArray makes the auto-generated equals/hashCode unreliable, but the
1407
+ // class is only ever compared via matches()/id, so that is never relied upon.
1408
+ var bmpData: ByteArray,
1409
+ // Set true when bmpData changes and the new pixels haven't been pushed yet. The reconcile
1410
+ // loop is the sole sender; it clears this once the exact bytes it sent still match the
1411
+ // container. Lets every display op be a pure state mutation (no DisplayMutex needed).
1412
+ var dirty: Boolean = false
1349
1413
  ) {
1350
1414
  val name: String
1351
1415
  get() = "img-$id"
1352
1416
 
1353
1417
  fun matches(x: Int, y: Int, width: Int, height: Int): Boolean =
1354
- this.x == x && this.y == y && this.width == width && this.height == height
1418
+ this.x == x && this.y == y && this.width == width && this.height == height
1355
1419
  }
1356
1420
 
1357
1421
  /** A tracked text container on the current page. Keyed by its rect for reuse. */
1358
1422
  private data class TextContainer(
1359
- val id: Int,
1360
- val x: Int,
1361
- val y: Int,
1362
- val width: Int,
1363
- val height: Int,
1364
- var content: String,
1365
- val borderWidth: Int,
1366
- val borderColor: Int,
1367
- val borderRadius: Int,
1368
- val paddingLength: Int
1423
+ val id: Int,
1424
+ val x: Int,
1425
+ val y: Int,
1426
+ val width: Int,
1427
+ val height: Int,
1428
+ var content: String,
1429
+ val borderWidth: Int,
1430
+ val borderColor: Int,
1431
+ val borderRadius: Int,
1432
+ val paddingLength: Int,
1433
+ // Remaining sends the reconcile loop owes this container. Text has no ACK, so each content
1434
+ // change schedules `1 + EVEN_HUB_RESEND_COUNT` sends (the update + a redundant resend on a
1435
+ // later tick) as a delivery hedge; the loop sends once and decrements per tick until 0.
1436
+ var pendingSends: Int = 0
1369
1437
  ) {
1370
1438
  val name: String
1371
1439
  get() = "text-$id"
1372
1440
 
1373
1441
  fun matches(
1374
- x: Int,
1375
- y: Int,
1376
- width: Int,
1377
- height: Int,
1378
- borderWidth: Int,
1379
- borderColor: Int,
1380
- borderRadius: Int,
1381
- paddingLength: Int
1442
+ x: Int,
1443
+ y: Int,
1444
+ width: Int,
1445
+ height: Int,
1446
+ borderWidth: Int,
1447
+ borderColor: Int,
1448
+ borderRadius: Int,
1449
+ paddingLength: Int
1382
1450
  ): Boolean =
1383
- this.x == x &&
1384
- this.y == y &&
1385
- this.width == width &&
1386
- this.height == height &&
1387
- this.borderWidth == borderWidth &&
1388
- this.borderColor == borderColor &&
1389
- this.borderRadius == borderRadius &&
1390
- this.paddingLength == paddingLength
1451
+ this.x == x &&
1452
+ this.y == y &&
1453
+ this.width == width &&
1454
+ this.height == height &&
1455
+ this.borderWidth == borderWidth &&
1456
+ this.borderColor == borderColor &&
1457
+ this.borderRadius == borderRadius &&
1458
+ this.paddingLength == paddingLength
1391
1459
  }
1392
1460
 
1393
1461
  /**
@@ -1396,14 +1464,17 @@ class G2 : SGCManager() {
1396
1464
  */
1397
1465
  private val imageContainers: MutableList<ImgContainer> = mutableListOf()
1398
1466
  private val textContainers: MutableList<TextContainer> = mutableListOf()
1467
+
1399
1468
  /** Fixed pool of container IDs the page protocol expects. */
1400
1469
  private val imageContainerIDPool: List<Int> = listOf(10, 11, 12, 13)
1401
1470
  private val textContainerIDPool: List<Int> = listOf(1, 2, 3, 4, 5, 6)
1471
+
1402
1472
  /** Default container seeded into every fresh page: 200x100 centered at 188,44. */
1403
1473
  private val defaultImgX = 188
1404
1474
  private val defaultImgY = 44
1405
1475
  private val defaultImgWidth = 200
1406
1476
  private val defaultImgHeight = 100
1477
+
1407
1478
  /** Default text container: full-screen 576x288 with a 4px padding. */
1408
1479
  private val defaultTextX = 0
1409
1480
  private val defaultTextY = 0
@@ -1443,14 +1514,47 @@ class G2 : SGCManager() {
1443
1514
  // Matches the 8 ms G1.java uses for its bitmap chunk loop (ANDROID_CHUNK_DELAY_MS).
1444
1515
  private val BLE_PACKET_GAP_MS = 8L
1445
1516
 
1517
+ // Dedicated single-thread executor for pacing BLE packet bursts. We must NOT spread the burst
1518
+ // across [mainHandler]: the incoming image ACK is also delivered on the main thread, and a long
1519
+ // run of postDelayed writes (plus heartbeats and the text-queue tick) can push ACK processing
1520
+ // past IMG_ACK_TIMEOUT_MS — the glasses appear to "stop responding" even though the ACK arrived.
1521
+ // Pacing here keeps the main looper free so ACKs are processed promptly.
1522
+ private val bleWriteExecutor: java.util.concurrent.ExecutorService =
1523
+ java.util.concurrent.Executors.newSingleThreadExecutor { r ->
1524
+ Thread(r, "G2-ble-write").apply { isDaemon = true }
1525
+ }
1526
+
1446
1527
  @Suppress("deprecation")
1528
+ // BGCAP diagnostic: Android G2's text path is already direct (single packet -> writeOnePacket),
1529
+ // so the iOS root cause (the canSend gate) does NOT exist here. This measures whether
1530
+ // writeCharacteristic is being REJECTED (returns false = Android GATT stack busy/throttled, the
1531
+ // packet is dropped) in the background — the suspected Android accumulation/loss point. The
1532
+ // current code discards the return value. Rate-limited; "BGCAP:" prefix; remove after the
1533
+ // Android repro pins the mechanism.
1534
+ private var bgcapWriteOk = 0
1535
+ private var bgcapWriteFail = 0
1536
+ private var bgcapWriteLogAt = 0L
1537
+
1538
+ private fun bgcapNoteWriteResult(ok: Boolean) {
1539
+ if (ok) bgcapWriteOk++ else bgcapWriteFail++
1540
+ val now = android.os.SystemClock.uptimeMillis()
1541
+ if (now - bgcapWriteLogAt >= 1000) {
1542
+ if (bgcapWriteOk > 0 || bgcapWriteFail > 0) {
1543
+ Bridge.log("BGCAP: g2 writeCharacteristic ok=$bgcapWriteOk fail=$bgcapWriteFail in ${now - bgcapWriteLogAt}ms (fail = stack busy/dropped)")
1544
+ }
1545
+ bgcapWriteOk = 0
1546
+ bgcapWriteFail = 0
1547
+ bgcapWriteLogAt = now
1548
+ }
1549
+ }
1550
+
1447
1551
  private fun writeOnePacket(packet: ByteArray, left: Boolean, right: Boolean) {
1448
1552
  if (right) {
1449
1553
  rightWriteChar?.let { char ->
1450
1554
  rightGatt?.let { gatt ->
1451
1555
  char.value = packet
1452
1556
  char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
1453
- gatt.writeCharacteristic(char)
1557
+ bgcapNoteWriteResult(gatt.writeCharacteristic(char)) // BGCAP
1454
1558
  }
1455
1559
  }
1456
1560
  }
@@ -1459,16 +1563,16 @@ class G2 : SGCManager() {
1459
1563
  leftGatt?.let { gatt ->
1460
1564
  char.value = packet
1461
1565
  char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
1462
- gatt.writeCharacteristic(char)
1566
+ bgcapNoteWriteResult(gatt.writeCharacteristic(char)) // BGCAP
1463
1567
  }
1464
1568
  }
1465
1569
  }
1466
1570
  }
1467
1571
 
1468
1572
  private fun sendToGlasses(
1469
- packets: List<ByteArray>,
1470
- left: Boolean = false,
1471
- right: Boolean = true
1573
+ packets: List<ByteArray>,
1574
+ left: Boolean = false,
1575
+ right: Boolean = true
1472
1576
  ) {
1473
1577
  // Bridge.log("G2: sendToGlasses() - sending ${packets.size} packets first byte: ${packets[0][0]}")
1474
1578
  if (packets.isEmpty()) return
@@ -1477,116 +1581,125 @@ class G2 : SGCManager() {
1477
1581
  writeOnePacket(packets[0], left, right)
1478
1582
  return
1479
1583
  }
1480
- // Multi-packet bursts (bitmaps, large protobufs): write the first packet immediately,
1481
- // then schedule the rest with BLE_PACKET_GAP_MS spacing so the Android BLE stack can
1482
- // actually drain each write before the next one is queued.
1483
- writeOnePacket(packets[0], left, right)
1484
- for (i in 1 until packets.size) {
1485
- val packet = packets[i]
1486
- mainHandler.postDelayed({ writeOnePacket(packet, left, right) }, BLE_PACKET_GAP_MS * i)
1584
+ // Multi-packet bursts (bitmaps, large protobufs): pace the whole burst on a dedicated
1585
+ // write thread with a blocking gap between packets, so the Android BLE stack can drain each
1586
+ // write before the next is queued WITHOUT occupying the main looper (which also carries the
1587
+ // image ACK and would otherwise starve it under load — see [bleWriteExecutor]).
1588
+ bleWriteExecutor.execute {
1589
+ for (i in packets.indices) {
1590
+ writeOnePacket(packets[i], left, right)
1591
+ if (i < packets.size - 1) {
1592
+ try {
1593
+ Thread.sleep(BLE_PACKET_GAP_MS)
1594
+ } catch (_: InterruptedException) {
1595
+ Thread.currentThread().interrupt()
1596
+ return@execute
1597
+ }
1598
+ }
1599
+ }
1487
1600
  }
1488
1601
  }
1489
1602
 
1490
1603
  private fun sendEvenHubCommand(payload: ByteArray) {
1491
1604
  val packets =
1492
- sendManager.buildPackets(
1493
- serviceId = ServiceID.EVEN_HUB.value,
1494
- payload = payload,
1495
- reserveFlag = true
1496
- )
1497
- sendToGlasses(packets, left = true, right = true)
1605
+ sendManager.buildPackets(
1606
+ serviceId = ServiceID.EVEN_HUB.value,
1607
+ payload = payload,
1608
+ reserveFlag = true
1609
+ )
1610
+ sendToGlasses(packets)
1498
1611
  }
1499
1612
 
1500
1613
  private fun sendDevSettingsCommand(
1501
- payload: ByteArray,
1502
- left: Boolean = false,
1503
- right: Boolean = true
1614
+ payload: ByteArray,
1615
+ left: Boolean = false,
1616
+ right: Boolean = true
1504
1617
  ) {
1505
1618
  val packets =
1506
- sendManager.buildPackets(
1507
- serviceId = ServiceID.DEVICE_SETTINGS.value,
1508
- payload = payload
1509
- )
1619
+ sendManager.buildPackets(
1620
+ serviceId = ServiceID.DEVICE_SETTINGS.value,
1621
+ payload = payload
1622
+ )
1510
1623
  sendToGlasses(packets, left = left, right = right)
1511
1624
  }
1512
1625
 
1513
1626
  private fun sendNavigationCommand(payload: ByteArray) {
1514
1627
  val packets =
1515
- sendManager.buildPackets(
1516
- serviceId = ServiceID.NAVIGATION.value,
1517
- payload = payload,
1518
- reserveFlag = true
1519
- )
1628
+ sendManager.buildPackets(
1629
+ serviceId = ServiceID.NAVIGATION.value,
1630
+ payload = payload,
1631
+ reserveFlag = true
1632
+ )
1520
1633
  sendToGlasses(packets)
1521
1634
  }
1522
1635
 
1523
1636
  private fun sendG2SettingCommand(payload: ByteArray) {
1524
1637
  val packets =
1525
- sendManager.buildPackets(
1526
- serviceId = ServiceID.G2_SETTING.value,
1527
- payload = payload,
1528
- reserveFlag = true
1529
- )
1638
+ sendManager.buildPackets(
1639
+ serviceId = ServiceID.G2_SETTING.value,
1640
+ payload = payload,
1641
+ reserveFlag = true
1642
+ )
1530
1643
  sendToGlasses(packets)
1531
1644
  }
1532
1645
 
1533
1646
  private fun sendOnboardingCommand(payload: ByteArray) {
1534
1647
  val packets =
1535
- sendManager.buildPackets(
1536
- serviceId = ServiceID.ONBOARDING.value,
1537
- payload = payload,
1538
- reserveFlag = true
1539
- )
1648
+ sendManager.buildPackets(
1649
+ serviceId = ServiceID.ONBOARDING.value,
1650
+ payload = payload,
1651
+ reserveFlag = true
1652
+ )
1540
1653
  sendToGlasses(packets)
1541
1654
  }
1542
1655
 
1543
1656
  private fun sendEvenAICommand(payload: ByteArray) {
1544
1657
  val packets =
1545
- sendManager.buildPackets(
1546
- serviceId = ServiceID.EVEN_AI.value,
1547
- payload = payload,
1548
- reserveFlag = true
1549
- )
1658
+ sendManager.buildPackets(
1659
+ serviceId = ServiceID.EVEN_AI.value,
1660
+ payload = payload,
1661
+ reserveFlag = true
1662
+ )
1550
1663
  sendToGlasses(packets)
1551
1664
  }
1552
1665
 
1553
1666
  private fun sendMenuCommand(payload: ByteArray) {
1554
1667
  val packets =
1555
- sendManager.buildPackets(
1556
- serviceId = ServiceID.MENU.value,
1557
- payload = payload,
1558
- reserveFlag = true
1559
- )
1668
+ sendManager.buildPackets(
1669
+ serviceId = ServiceID.MENU.value,
1670
+ payload = payload,
1671
+ reserveFlag = true
1672
+ )
1560
1673
  sendToGlasses(packets)
1561
1674
  }
1562
1675
 
1563
1676
  private fun sendGestureCtrlCommand(payload: ByteArray) {
1564
1677
  val packets =
1565
- sendManager.buildPackets(
1566
- serviceId = ServiceID.GESTURE_CTRL.value,
1567
- payload = payload,
1568
- reserveFlag = true
1569
- )
1678
+ sendManager.buildPackets(
1679
+ serviceId = ServiceID.GESTURE_CTRL.value,
1680
+ payload = payload,
1681
+ reserveFlag = true
1682
+ )
1570
1683
  sendToGlasses(packets)
1571
1684
  }
1572
1685
 
1573
1686
  private fun sendEvenHubCtrlCommand(payload: ByteArray) {
1574
1687
  val packets =
1575
- sendManager.buildPackets(
1576
- serviceId = ServiceID.EVEN_HUB_CTRL.value,
1577
- payload = payload,
1578
- reserveFlag = true
1579
- )
1688
+ sendManager.buildPackets(
1689
+ serviceId = ServiceID.EVEN_HUB_CTRL.value,
1690
+ payload = payload,
1691
+ reserveFlag = true
1692
+ )
1580
1693
  sendToGlasses(packets)
1581
1694
  }
1582
1695
 
1583
1696
  private fun sendDashboardCommand(payload: ByteArray) {
1584
1697
  val packets =
1585
- sendManager.buildPackets(
1586
- serviceId = ServiceID.DASHBOARD.value,
1587
- payload = payload,
1588
- reserveFlag = true
1589
- )
1698
+ sendManager.buildPackets(
1699
+ serviceId = ServiceID.DASHBOARD.value,
1700
+ payload = payload,
1701
+ reserveFlag = true
1702
+ )
1590
1703
  sendToGlasses(packets)
1591
1704
  }
1592
1705
 
@@ -1632,11 +1745,11 @@ class G2 : SGCManager() {
1632
1745
  uiSettW.writeInt32Field(2, sendManager.nextMagicRandom())
1633
1746
  uiSettW.writeMessageField(4, byteArrayOf(0x08, 0x01, 0x10, 0x00)) // {1:1, 2:0}
1634
1747
  sendToGlasses(
1635
- sendManager.buildPackets(
1636
- serviceId = 0x0C,
1637
- payload = uiSettW.toByteArray(),
1638
- reserveFlag = true
1639
- )
1748
+ sendManager.buildPackets(
1749
+ serviceId = 0x0C,
1750
+ payload = uiSettW.toByteArray(),
1751
+ reserveFlag = true
1752
+ )
1640
1753
  )
1641
1754
 
1642
1755
  // 6. Dashboard init (0x01) — display settings
@@ -1649,8 +1762,8 @@ class G2 : SGCManager() {
1649
1762
  dashDisplayW.writeInt32Field(4, 4) // widgetDisplayCount
1650
1763
  // WidgetType: 1=News, 2=Stock, 3=Schedule, 4=Quicklist, 5=Health
1651
1764
  dashDisplayW.writeMessageField(
1652
- 5,
1653
- byteArrayOf(3, 1, 2, 4, 5)
1765
+ 5,
1766
+ byteArrayOf(3, 1, 2, 4, 5)
1654
1767
  ) // widgetDisplayOrder: Schedule, News, Stock, Quicklist
1655
1768
  dashDisplayW.writeInt32Field(6, dashboardHalfDayFormat()) // halfDayFormat
1656
1769
  dashDisplayW.writeInt32Field(7, dashboardTemperatureUnit()) // temperatureUnit
@@ -1748,36 +1861,47 @@ class G2 : SGCManager() {
1748
1861
  stopHeartbeats()
1749
1862
 
1750
1863
  val hbRunnable =
1751
- object : Runnable {
1752
- override fun run() {
1753
- sendEvenHubHeartbeat()
1754
- mainHandler.postDelayed(this, 10000)
1755
- }
1864
+ object : Runnable {
1865
+ override fun run() {
1866
+ sendEvenHubHeartbeat()
1867
+ mainHandler.postDelayed(this, 10000)
1756
1868
  }
1869
+ }
1757
1870
  heartbeatRunnable = hbRunnable
1758
1871
  mainHandler.postDelayed(hbRunnable, 10000)
1759
1872
 
1760
1873
  // DevSettings heartbeat every 5 seconds
1761
1874
  val dsRunnable =
1762
- object : Runnable {
1763
- override fun run() {
1764
- sendDevSettingsHeartbeat()
1765
- mainHandler.postDelayed(this, 5000)
1766
- }
1875
+ object : Runnable {
1876
+ override fun run() {
1877
+ sendDevSettingsHeartbeat()
1878
+ mainHandler.postDelayed(this, 5000)
1767
1879
  }
1880
+ }
1768
1881
  devSettingsHeartbeatRunnable = dsRunnable
1769
1882
  mainHandler.postDelayed(dsRunnable, 5000)
1770
1883
 
1771
- // EvenHub text command queue: drain the most recent pending updateText every 100ms
1772
- val queueRunnable =
1773
- object : Runnable {
1774
- override fun run() {
1775
- drainEvenHubQueue()
1776
- mainHandler.postDelayed(this, EVEN_HUB_QUEUE_TICK_MS)
1777
- }
1884
+ // Display reconcile loop: push any dirty text containers (one send each, with a redundant
1885
+ // resend), then any dirty image containers one at a time. Single sender, so a sendImageData
1886
+ // never overlaps another and can't clobber the pending image ACK — the invariant displayMutex
1887
+ // used to enforce. The loop wakes on each channel element: mutations send one immediately
1888
+ // (instant reaction), and a ~100ms ticker sends one when idle so periodic work (text resends,
1889
+ // image retries) still runs. The CONFLATED buffer coalesces bursts into a single wake.
1890
+ val signal = Channel<Unit>(Channel.CONFLATED)
1891
+ displayDirtySignal = signal
1892
+ displayTickJob =
1893
+ displayScope.launch {
1894
+ while (isActive) {
1895
+ delay(EVEN_HUB_QUEUE_TICK_MS)
1896
+ signal.trySend(Unit)
1897
+ }
1898
+ }
1899
+ displayReconcileJob =
1900
+ displayScope.launch {
1901
+ for (sig in signal) {
1902
+ reconcileDisplay()
1778
1903
  }
1779
- evenHubQueueRunnable = queueRunnable
1780
- mainHandler.postDelayed(queueRunnable, EVEN_HUB_QUEUE_TICK_MS)
1904
+ }
1781
1905
  }
1782
1906
 
1783
1907
  private fun stopHeartbeats() {
@@ -1785,11 +1909,19 @@ class G2 : SGCManager() {
1785
1909
  heartbeatRunnable = null
1786
1910
  devSettingsHeartbeatRunnable?.let { mainHandler.removeCallbacks(it) }
1787
1911
  devSettingsHeartbeatRunnable = null
1788
- evenHubQueueRunnable?.let { mainHandler.removeCallbacks(it) }
1789
- evenHubQueueRunnable = null
1790
- pendingTextMsg = null
1791
- lastEvenHubMsg = null
1792
- lastEvenHubResendsRemaining = 0
1912
+ displayTickJob?.cancel()
1913
+ displayTickJob = null
1914
+ displayReconcileJob?.cancel()
1915
+ displayReconcileJob = null
1916
+ displayDirtySignal?.close()
1917
+ displayDirtySignal = null
1918
+ }
1919
+
1920
+ /** Wake the reconcile loop now (a container was just marked dirty / had sends scheduled). Cheap
1921
+ * and idempotent: coalesced by the conflated channel, so a burst of mutations wakes at most once
1922
+ * extra. */
1923
+ private fun signalDisplayDirty() {
1924
+ displayDirtySignal?.trySend(Unit)
1793
1925
  }
1794
1926
 
1795
1927
  private fun sendEvenHubHeartbeat() {
@@ -1824,7 +1956,7 @@ class G2 : SGCManager() {
1824
1956
 
1825
1957
  private fun sendMenuApps() {
1826
1958
  val menuItems =
1827
- DeviceStore.get("bluetooth", "menu_apps") as? List<Map<String, Any>> ?: emptyList()
1959
+ DeviceStore.get("bluetooth", "menu_apps") as? List<Map<String, Any>> ?: emptyList()
1828
1960
  if (menuItems.isNotEmpty()) {
1829
1961
  setDashboardMenu(menuItems)
1830
1962
  }
@@ -1832,8 +1964,8 @@ class G2 : SGCManager() {
1832
1964
 
1833
1965
  private fun sendStoredCalendarEvents() {
1834
1966
  val calendarEvents =
1835
- DeviceStore.get("bluetooth", "calendar_events") as? List<Map<String, Any>>
1836
- ?: emptyList()
1967
+ DeviceStore.get("bluetooth", "calendar_events") as? List<Map<String, Any>>
1968
+ ?: emptyList()
1837
1969
  sendCalendarEvents(calendarEvents)
1838
1970
  }
1839
1971
 
@@ -1841,46 +1973,66 @@ class G2 : SGCManager() {
1841
1973
 
1842
1974
  override fun sendText(text: String) {
1843
1975
  displayScope.launch {
1844
- displayMutex.withLock {
1845
- sendText2(text)
1846
- }
1976
+ sendText2(text)
1847
1977
  }
1848
1978
  }
1849
1979
 
1850
1980
  override fun sendTextWall(text: String) {
1851
1981
  displayScope.launch {
1852
- displayMutex.withLock {
1853
- sendText2(
1854
- text,
1855
- x = defaultTextX,
1856
- y = defaultTextY,
1857
- width = defaultTextWidth,
1858
- height = defaultTextHeight,
1859
- borderWidth = defaultTextBorderWidth,
1860
- borderColor = defaultTextBorderColor,
1861
- borderRadius = defaultTextBorderRadius,
1862
- paddingLength = defaultTextPaddingLength
1863
- )
1864
- }
1982
+ sendText2(
1983
+ text,
1984
+ x = defaultTextX,
1985
+ y = defaultTextY,
1986
+ width = defaultTextWidth,
1987
+ height = defaultTextHeight,
1988
+ borderWidth = defaultTextBorderWidth,
1989
+ borderColor = defaultTextBorderColor,
1990
+ borderRadius = defaultTextBorderRadius,
1991
+ paddingLength = defaultTextPaddingLength
1992
+ )
1993
+ }
1994
+ }
1995
+
1996
+ override fun sendPositionedText(
1997
+ text: String,
1998
+ x: Int,
1999
+ y: Int,
2000
+ width: Int,
2001
+ height: Int,
2002
+ borderWidth: Int,
2003
+ borderRadius: Int
2004
+ ) {
2005
+ displayScope.launch {
2006
+ sendText2(
2007
+ text,
2008
+ x = x,
2009
+ y = y,
2010
+ width = width,
2011
+ height = height,
2012
+ borderWidth = borderWidth,
2013
+ borderColor = defaultTextBorderColor,
2014
+ borderRadius = borderRadius,
2015
+ paddingLength = defaultTextPaddingLength
2016
+ )
1865
2017
  }
1866
2018
  }
1867
2019
 
1868
2020
  private suspend fun sendText2(
1869
- text: String,
1870
- x: Int? = null,
1871
- y: Int? = null,
1872
- width: Int? = null,
1873
- height: Int? = null,
1874
- borderWidth: Int? = null,
1875
- borderColor: Int? = null,
1876
- borderRadius: Int? = null,
1877
- paddingLength: Int? = null
2021
+ text: String,
2022
+ x: Int? = null,
2023
+ y: Int? = null,
2024
+ width: Int? = null,
2025
+ height: Int? = null,
2026
+ borderWidth: Int? = null,
2027
+ borderColor: Int? = null,
2028
+ borderRadius: Int? = null,
2029
+ paddingLength: Int? = null
1878
2030
  ) {
1879
2031
  // Bridge.log("G2: sendTextWall(${text.take(50)}...)")
1880
2032
 
1881
2033
  // ignore events while the ER dashboard is open:
1882
2034
  val useNativeDashboard =
1883
- DeviceStore.get("bluetooth", "use_native_dashboard") as? Boolean ?: false
2035
+ DeviceStore.get("bluetooth", "use_native_dashboard") as? Boolean ?: false
1884
2036
  if (useNativeDashboard && dashboardShowing > 0) {
1885
2037
  return
1886
2038
  }
@@ -1895,53 +2047,60 @@ class G2 : SGCManager() {
1895
2047
  val rPaddingLength = defaultTextPaddingLength
1896
2048
  val content = if (text.isEmpty()) " " else text
1897
2049
 
1898
- // Reuse an existing container if the rect matches exactly; otherwise add a new one.
2050
+ // Pure state mutation: update the container's content and schedule its sends; the reconcile
2051
+ // loop does the actual updateText writes. Reuse an existing container if the rect matches
2052
+ // exactly; otherwise add a new one.
1899
2053
  val container: TextContainer
1900
2054
  val existingIndex =
1901
- textContainers.indexOfFirst {
1902
- it.matches(
1903
- rx,
1904
- ry,
1905
- rw,
1906
- rh,
1907
- rBorderWidth,
1908
- rBorderColor,
1909
- rBorderRadius,
1910
- rPaddingLength
1911
- )
1912
- }
2055
+ textContainers.indexOfFirst {
2056
+ it.matches(
2057
+ rx,
2058
+ ry,
2059
+ rw,
2060
+ rh,
2061
+ rBorderWidth,
2062
+ rBorderColor,
2063
+ rBorderRadius,
2064
+ rPaddingLength
2065
+ )
2066
+ }
1913
2067
  if (existingIndex >= 0) {
1914
2068
  textContainers[existingIndex].content = content
2069
+ textContainers[existingIndex].pendingSends = 1 + EVEN_HUB_RESEND_COUNT
1915
2070
  container = textContainers[existingIndex]
1916
- Bridge.log("G2: sendText() - reusing container ${container.id} for rect $rx,$ry ${rw}x$rh")
2071
+ // Wake the reconcile loop either way. When the page is live it sends the text; when the
2072
+ // page is down the loop coalesces the burst into a single rebuild (see reconcileDisplay)
2073
+ // instead of one shutdown/rebuild per caption. The container's content is overwritten in
2074
+ // place (last-wins), so a backlog that piled up while we were suspended collapses to one
2075
+ // catch-up render — no flood on resume.
2076
+ signalDisplayDirty()
1917
2077
  if (!pageCreated) {
1918
- rebuildPage()
2078
+ Bridge.log("G2: sendText() - page down, buffering latest content for container ${container.id} (rebuild deferred to reconcile)")
1919
2079
  return
1920
2080
  }
1921
- // update the text container:
1922
- val msg =
1923
- EvenHubProto.updateTextMessage(
1924
- containerID = container.id,
1925
- contentOffset = 0,
1926
- contentLength = container.content.toByteArray(Charsets.UTF_8).size,
1927
- content = container.content
1928
- )
1929
- queueEvenHubCommand(msg)
2081
+ Bridge.log("G2: sendText() - reusing container ${container.id} for rect $rx,$ry ${rw}x$rh")
1930
2082
  return
1931
2083
  }
1932
2084
  container =
1933
- addTextContainer(
1934
- rx,
1935
- ry,
1936
- rw,
1937
- rh,
1938
- content,
1939
- rBorderWidth,
1940
- rBorderColor,
1941
- rBorderRadius,
1942
- rPaddingLength
1943
- )
2085
+ addTextContainer(
2086
+ rx,
2087
+ ry,
2088
+ rw,
2089
+ rh,
2090
+ content,
2091
+ rBorderWidth,
2092
+ rBorderColor,
2093
+ rBorderRadius,
2094
+ rPaddingLength
2095
+ )
1944
2096
  Bridge.log("G2: sendText() - added text container ${container.id} for rect $rx,$ry ${rw}x$rh, rebuilding page")
2097
+ // New container changes page structure: rebuild it (the rebuild embeds initial content), then
2098
+ // schedule sends so the loop refreshes it.
2099
+ val newIndex = textContainers.indexOfFirst { it.id == container.id }
2100
+ if (newIndex >= 0) {
2101
+ textContainers[newIndex].pendingSends = 1 + EVEN_HUB_RESEND_COUNT
2102
+ }
2103
+ signalDisplayDirty()
1945
2104
  rebuildPage()
1946
2105
  }
1947
2106
 
@@ -1954,19 +2113,29 @@ class G2 : SGCManager() {
1954
2113
 
1955
2114
  override fun clearDisplay() {
1956
2115
  Bridge.log("G2: clearDisplay()")
1957
- displayScope.launch {
1958
- displayMutex.withLock {
1959
- // reset the content of all text containers to empty:
1960
- for (i in textContainers.indices) {
1961
- textContainers[i].content = " "
1962
- }
1963
- for (i in imageContainers.indices) {
1964
- imageContainers[i].bmpData = ByteArray(0)
1965
- }
1966
- // shutdown the page and then recreate the containers without the content.
1967
- rebuildPage()
2116
+ // Clear the text in place — do NOT shut down + rebuild the EvenHub page. Tearing the page
2117
+ // down (rebuildPage) kills audio streaming AND triggers a firmware systemExit /
2118
+ // dashboard-close recovery another rebuild. The cloud sends clearDisplay in bursts
2119
+ // (caption gaps → clear → new caption), and when each clear tore down + rebuilt the page
2120
+ // those bursts became a rebuild storm that churned the display and dropped audio for
2121
+ // several seconds (incident 8164175a). Just blank the text + clear images; the reconcile
2122
+ // loop pushes the blanked text on a live page, and a dead page is only resurrected for
2123
+ // meaningful (non-blank) content, so a clear can't churn it back up.
2124
+ for (i in textContainers.indices) {
2125
+ textContainers[i].content = " "
2126
+ textContainers[i].pendingSends = 1 + EVEN_HUB_RESEND_COUNT
2127
+ }
2128
+ for (i in imageContainers.indices) {
2129
+ // The firmware still shows this container's image; emptying bmpData locally never reaches
2130
+ // it (#3232 dropped the teardown that used to drop empty containers on rebuild). Empty +
2131
+ // dirty tells the reconcile loop to push an all-black frame that overwrites the image
2132
+ // on-glass — the page stays up, so no audio/mic churn. Skip already-empty containers.
2133
+ if (imageContainers[i].bmpData.isNotEmpty()) {
2134
+ imageContainers[i].bmpData = ByteArray(0)
2135
+ imageContainers[i].dirty = true
1968
2136
  }
1969
2137
  }
2138
+ signalDisplayDirty()
1970
2139
  }
1971
2140
 
1972
2141
  /**
@@ -1981,17 +2150,17 @@ class G2 : SGCManager() {
1981
2150
  * Omitted params default to a 100x100 container in the top-left corner.
1982
2151
  */
1983
2152
  override fun displayBitmap(
1984
- base64ImageData: String,
1985
- x: Int?,
1986
- y: Int?,
1987
- width: Int?,
1988
- height: Int?
2153
+ base64ImageData: String,
2154
+ x: Int?,
2155
+ y: Int?,
2156
+ width: Int?,
2157
+ height: Int?
1989
2158
  ): Boolean {
1990
2159
  val rx = x ?: defaultImgX
1991
2160
  val ry = y ?: defaultImgY
1992
2161
  val rw = width ?: defaultImgWidth
1993
2162
  val rh = height ?: defaultImgHeight
1994
-
2163
+
1995
2164
  // ignore events while the ER dashboard is open:
1996
2165
  val useNativeDashboard = DeviceStore.get("bluetooth", "use_native_dashboard") as? Boolean ?: false
1997
2166
  if (useNativeDashboard && dashboardShowing > 0) {
@@ -1999,44 +2168,47 @@ class G2 : SGCManager() {
1999
2168
  }
2000
2169
 
2001
2170
  val rawData =
2002
- Base64.decode(base64ImageData, Base64.DEFAULT)
2003
- ?: run {
2004
- Bridge.log("G2: displayBitmap() - failed to decode base64")
2005
- return false
2006
- }
2171
+ Base64.decode(base64ImageData, Base64.DEFAULT)
2172
+ ?: run {
2173
+ Bridge.log("G2: displayBitmap() - failed to decode base64")
2174
+ return false
2175
+ }
2007
2176
 
2008
2177
  val bmpData =
2009
- convertToG2Bmp(rawData, containerWidth = rw, containerHeight = rh)
2010
- ?: run {
2011
- Bridge.log("G2: displayBitmap() - failed to convert image to BMP")
2012
- return false
2013
- }
2178
+ convertToG2Bmp(rawData, containerWidth = rw, containerHeight = rh)
2179
+ ?: run {
2180
+ Bridge.log("G2: displayBitmap() - failed to convert image to BMP")
2181
+ return false
2182
+ }
2014
2183
 
2015
- // Reuse an existing container if the rect matches exactly; otherwise add a new one.
2184
+ // Pure state mutation: update the target container's bytes and mark it dirty. The reconcile
2185
+ // loop is the sole sender, so two displayBitmap calls can never overlap a sendImageData and
2186
+ // clobber the single-slot image ACK — no lock needed. Reuse an existing container if the rect
2187
+ // matches exactly; otherwise add a new one.
2016
2188
  val container: ImgContainer
2017
2189
  val existingIndex = imageContainers.indexOfFirst { it.matches(rx, ry, rw, rh) }
2018
2190
  if (existingIndex >= 0) {
2019
2191
  imageContainers[existingIndex].bmpData = bmpData
2192
+ imageContainers[existingIndex].dirty = true
2193
+ signalDisplayDirty()
2020
2194
  container = imageContainers[existingIndex]
2021
2195
  Bridge.log("G2: displayBitmap() - reusing container ${container.id} for rect $rx,$ry ${rw}x$rh")
2022
- displayScope.launch {
2023
- displayMutex.withLock {
2024
- if (!pageCreated) {
2025
- rebuildPage()
2026
- } else {
2027
- sendImageData(container.id, container.name, container.bmpData)
2028
- }
2029
- }
2196
+ // A brand-new page needs its structure built before the loop can push pixels; the dirty
2197
+ // flag stays set so the reconcile loop sends the image once the page exists.
2198
+ if (!pageCreated) {
2199
+ displayScope.launch { rebuildPage() }
2030
2200
  }
2031
2201
  return true
2032
2202
  } else {
2033
2203
  container = addImageContainer(rx, ry, rw, rh, bmpData)
2034
- Bridge.log("G2: displayBitmap() - added container ${container.id} for rect $rx,$ry ${rw}x$rh, rebuilding page")
2035
- displayScope.launch {
2036
- displayMutex.withLock {
2037
- rebuildPage()
2038
- }
2204
+ val newIndex = imageContainers.indexOfFirst { it.id == container.id }
2205
+ if (newIndex >= 0) {
2206
+ imageContainers[newIndex].dirty = true
2039
2207
  }
2208
+ signalDisplayDirty()
2209
+ Bridge.log("G2: displayBitmap() - added container ${container.id} for rect $rx,$ry ${rw}x$rh, rebuilding page")
2210
+ // New container changes page structure: rebuild it, then the loop sends the pixels.
2211
+ displayScope.launch { rebuildPage() }
2040
2212
  }
2041
2213
 
2042
2214
  return true
@@ -2047,11 +2219,11 @@ class G2 : SGCManager() {
2047
2219
  * Returns the newly tracked container (with an assigned ID from the pool).
2048
2220
  */
2049
2221
  private fun addImageContainer(
2050
- x: Int,
2051
- y: Int,
2052
- width: Int,
2053
- height: Int,
2054
- bmpData: ByteArray
2222
+ x: Int,
2223
+ y: Int,
2224
+ width: Int,
2225
+ height: Int,
2226
+ bmpData: ByteArray
2055
2227
  ): ImgContainer {
2056
2228
  // Evict the oldest container when at capacity, freeing its ID for reuse.
2057
2229
  if (imageContainers.size >= imageContainerIDPool.size) {
@@ -2062,21 +2234,21 @@ class G2 : SGCManager() {
2062
2234
  val usedIDs = imageContainers.map { it.id }.toSet()
2063
2235
  val id = imageContainerIDPool.firstOrNull { it !in usedIDs } ?: imageContainerIDPool[0]
2064
2236
  val container =
2065
- ImgContainer(id = id, x = x, y = y, width = width, height = height, bmpData = bmpData)
2237
+ ImgContainer(id = id, x = x, y = y, width = width, height = height, bmpData = bmpData)
2066
2238
  imageContainers.add(container)
2067
2239
  return container
2068
2240
  }
2069
2241
 
2070
2242
  private fun addTextContainer(
2071
- x: Int,
2072
- y: Int,
2073
- width: Int,
2074
- height: Int,
2075
- content: String,
2076
- borderWidth: Int,
2077
- borderColor: Int,
2078
- borderRadius: Int,
2079
- paddingLength: Int
2243
+ x: Int,
2244
+ y: Int,
2245
+ width: Int,
2246
+ height: Int,
2247
+ content: String,
2248
+ borderWidth: Int,
2249
+ borderColor: Int,
2250
+ borderRadius: Int,
2251
+ paddingLength: Int
2080
2252
  ): TextContainer {
2081
2253
  // Evict the oldest container when at capacity, freeing its ID for reuse.
2082
2254
  if (textContainers.size >= textContainerIDPool.size) {
@@ -2087,18 +2259,18 @@ class G2 : SGCManager() {
2087
2259
  val usedIDs = textContainers.map { it.id }.toSet()
2088
2260
  val id = textContainerIDPool.firstOrNull { it !in usedIDs } ?: textContainerIDPool[0]
2089
2261
  val container =
2090
- TextContainer(
2091
- id = id,
2092
- x = x,
2093
- y = y,
2094
- width = width,
2095
- height = height,
2096
- content = content,
2097
- borderWidth = borderWidth,
2098
- borderColor = borderColor,
2099
- borderRadius = borderRadius,
2100
- paddingLength = paddingLength
2101
- )
2262
+ TextContainer(
2263
+ id = id,
2264
+ x = x,
2265
+ y = y,
2266
+ width = width,
2267
+ height = height,
2268
+ content = content,
2269
+ borderWidth = borderWidth,
2270
+ borderColor = borderColor,
2271
+ borderRadius = borderRadius,
2272
+ paddingLength = paddingLength
2273
+ )
2102
2274
  textContainers.add(container)
2103
2275
  return container
2104
2276
  }
@@ -2107,95 +2279,249 @@ class G2 : SGCManager() {
2107
2279
  * Shutdown and rebuild everything, re-sending all data to the glasses.
2108
2280
  *
2109
2281
  * Suspends until the rebuild (shutdown → create → image/text re-send) has been issued, so
2110
- * callers no longer race a detached coroutine. Always invoke from within [displayMutex].
2282
+ * callers no longer race a detached coroutine. Always invoke on [displayScope].
2111
2283
  */
2112
2284
  private suspend fun rebuildPage() {
2113
2285
  val msg = EvenHubProto.shutdownMessage()
2114
2286
  sendEvenHubCommand(msg)
2115
2287
  pageCreated = false
2116
- rebuildState()
2288
+ // we will automatically rebuild state when we detect the glasses shutdown:
2289
+ delay(300) // 300ms to settle
2290
+ // rebuildState()
2117
2291
  }
2118
2292
 
2119
2293
  /**
2120
- * Re-creates the containers and sends all images and text again to the glasses.
2294
+ * Re-creates the containers and re-sends all images to the glasses.
2121
2295
  *
2122
2296
  * Runs inline (suspending) rather than launching a detached coroutine, so the page is fully
2123
- * rebuilt before the caller continues. Always invoke from within [displayMutex].
2297
+ * rebuilt before the caller continues. Always invoke on [displayScope].
2124
2298
  */
2125
2299
  private suspend fun rebuildState() {
2126
2300
  Bridge.log("G2: rebuildState()")
2127
- // recreate the containers:
2301
+ // recreate the containers (sets pageCreated = true; embeds text content directly):
2128
2302
  createPageWithContainers()
2129
-
2130
2303
  delay(300) // 300ms to settle
2131
2304
 
2132
- // go through each image container and send the data:
2133
- for (container in imageContainers) {
2134
- sendImageData(container.id, container.name, container.bmpData)
2135
- delay(300) // 300ms between containers
2136
- }
2137
-
2138
- // go through each text container and re-send its content, otherwise text stays blank after a
2139
- // disabled because text containers are initialized with their content:
2140
- // for (container in textContainers) {
2141
- // val textMsg =
2142
- // EvenHubProto.updateTextMessage(
2143
- // containerID = container.id,
2144
- // contentOffset = 0,
2145
- // contentLength = container.content.toByteArray(Charsets.UTF_8).size,
2146
- // content = container.content
2147
- // )
2148
- // sendEvenHubCommand(textMsg)
2149
- // delay(100)
2150
- // }
2305
+ // Mark every image container dirty and let the reconcile loop re-send them, one at a time.
2306
+ // Doing the sends here directly is what used to race a concurrent displayBitmap and clobber
2307
+ // the image ACK; routing through the dirty flag keeps a single sender (the reconcile loop).
2308
+ // Text needs no resend here: createPageWithContainers already embeds each container's content.
2309
+ for (i in imageContainers.indices) {
2310
+ if (imageContainers[i].bmpData.isNotEmpty()) {
2311
+ Bridge.log(
2312
+ "G2: rebuildState() - marking container ${imageContainers[i].id} dirty (${imageContainers[i].bmpData.size} bytes)"
2313
+ )
2314
+ imageContainers[i].dirty = true
2315
+ }
2316
+ }
2317
+ signalDisplayDirty()
2151
2318
 
2152
2319
  delay(300) // 300ms to settle
2153
2320
  restartMicIfAlreadyEnabled()
2154
2321
  }
2155
2322
 
2323
+ /**
2324
+ * Single coalesced recovery: rebuild the page + re-arm the mic from intent, never stacking
2325
+ * rebuilds (the firmware spams systemExit/dashboard-close ~1×/sec). Skips when the page is
2326
+ * already alive and the mic matches intent; otherwise one rebuild in flight, one per
2327
+ * RECOVERY_DEBOUNCE_MS. See iOS G2.swift.
2328
+ */
2329
+ private fun recoverPageAndMic(reason: String) {
2330
+ val now = System.currentTimeMillis()
2331
+ // Page alive and mic matches intent → phantom firmware event, nothing to recover.
2332
+ val micIntent = DeviceStore.get("glasses", "micEnabled") as? Boolean ?: false
2333
+ if (pageCreated && evenHubMicActive == micIntent) {
2334
+ return
2335
+ }
2336
+ if (recoveryInFlight) {
2337
+ // Bridge.log("G2: recover($reason) skipped — already in flight")
2338
+ return
2339
+ }
2340
+ if (now - lastRecoveryRebuildMs < RECOVERY_DEBOUNCE_MS) {
2341
+ // Bridge.log("G2: recover($reason) skipped — debounced")
2342
+ return
2343
+ }
2344
+ recoveryInFlight = true
2345
+ lastRecoveryRebuildMs = now
2346
+ Bridge.log("G2: recover($reason) — rebuilding EvenHub page")
2347
+ displayScope.launch {
2348
+ rebuildState()
2349
+ DeviceManager.getInstance().sendCurrentState()
2350
+ recoveryInFlight = false
2351
+ }
2352
+ }
2353
+
2354
+ /**
2355
+ * Push pending display state to the glasses: dirty text containers first (one updateText each,
2356
+ * with a redundant resend per [TextContainer.pendingSends]), then dirty image containers one at a
2357
+ * time. This awaits each [sendImageData] in turn, so image sends never overlap and the single
2358
+ * image-ACK slot is never clobbered. A failed image send leaves the container dirty for the next
2359
+ * cycle; if its bytes changed mid-send, the flag stays set so the newer image is sent next.
2360
+ */
2361
+ private suspend fun reconcileDisplay() {
2362
+ // Page is dead but content is waiting (e.g. captions kept arriving while we were suspended
2363
+ // and the firmware tore the session down). Rebuild the page ONCE here — the reconcile loop
2364
+ // is coalesced, so a burst of buffered sendText calls collapses into a single rebuild
2365
+ // instead of one shutdown/rebuild per caption. rebuildState() recreates the page, re-pushes
2366
+ // the current text/image, and re-arms the mic iff intent says so. Skip while the native
2367
+ // dashboard owns the screen.
2368
+ if (!pageCreated) {
2369
+ val useNativeDashboard = DeviceStore.get("bluetooth", "use_native_dashboard") as? Boolean ?: false
2370
+ // Only resurrect a dead page for MEANINGFUL content. A clearDisplay (blank " ") on an
2371
+ // already-dead page has nothing to show, so don't rebuild just to render blankness —
2372
+ // that would let a clear burst churn the page back up pointlessly.
2373
+ val hasPendingText = textContainers.any { it.pendingSends > 0 && it.content.isNotBlank() }
2374
+ val hasPendingImage = imageContainers.any { it.dirty && it.bmpData.isNotEmpty() }
2375
+ if ((hasPendingText || hasPendingImage) && !(useNativeDashboard && dashboardShowing > 0)) {
2376
+ Bridge.log("G2: reconcileDisplay() - page down with pending content, rebuilding once")
2377
+ rebuildState()
2378
+ }
2379
+ return
2380
+ }
2381
+
2382
+ // Text: synchronous, no ACK. Send one update per container with pending sends and decrement.
2383
+ for (i in textContainers.indices) {
2384
+ if (textContainers[i].pendingSends <= 0) continue
2385
+ val container = textContainers[i]
2386
+ val msg =
2387
+ EvenHubProto.updateTextMessage(
2388
+ containerID = container.id,
2389
+ contentOffset = 0,
2390
+ contentLength = container.content.toByteArray(Charsets.UTF_8).size,
2391
+ content = container.content
2392
+ )
2393
+ sendEvenHubCommand(msg)
2394
+ textContainers[i].pendingSends -= 1
2395
+ }
2396
+
2397
+ // Images: ACK-gated, exactly one in flight. Cap iterations defensively so a container that
2398
+ // keeps being re-dirtied mid-send can't spin this pass forever (next tick picks it up).
2399
+ var guardCount = 0
2400
+ while (pageCreated && guardCount < imageContainerIDPool.size) {
2401
+ val i = imageContainers.indexOfFirst { it.dirty }
2402
+ if (i < 0) break
2403
+ guardCount += 1
2404
+ val container = imageContainers[i]
2405
+ val sentBytes = container.bmpData
2406
+ // Empty + dirty means "just cleared": the firmware still shows the old image, so push an
2407
+ // all-black frame sized to the container to overwrite it on-glass (the page stays up — no
2408
+ // teardown, no mic churn). A container only reaches here when dirty, and clearDisplay is
2409
+ // the sole source of an empty-but-dirty container, so this fires exactly on a clear.
2410
+ if (sentBytes.isEmpty()) {
2411
+ val blank = blankBmp(container.width, container.height)
2412
+ if (blank != null) {
2413
+ sendImageData(container.id, container.name, blank)
2414
+ }
2415
+ // Only settle the flag if it's still empty — a displayBitmap during the await would
2416
+ // have set new bytes, so leave it dirty for the next pass to send the real image.
2417
+ val jj = imageContainers.indexOfFirst { it.id == container.id }
2418
+ if (jj >= 0 && imageContainers[jj].bmpData.isEmpty()) {
2419
+ imageContainers[jj].dirty = false
2420
+ }
2421
+ continue
2422
+ }
2423
+ sendImageData(container.id, container.name, sentBytes)
2424
+ // Re-find by id: the list may have shifted (eviction) during the await.
2425
+ val j = imageContainers.indexOfFirst { it.id == container.id }
2426
+ if (j >= 0 && imageContainers[j].bmpData.contentEquals(sentBytes)) {
2427
+ imageContainers[j].dirty = false
2428
+ }
2429
+ }
2430
+ }
2431
+
2156
2432
  /**
2157
2433
  * Send a bitmap to an image container as fragmented updateImageRawData packets.
2158
2434
  *
2159
- * Suspends until every fragment has been sent, mirroring iOS `sendImageData` (which awaits
2160
- * 200ms after every fragment, including the last). Callers therefore get the same serialized
2161
- * timing window: the 300ms settle in [rebuildState] only runs once all fragments are out.
2435
+ * One MapSessionId identifies the whole image transfer (constant across its fragments); the
2436
+ * glasses ACK EACH fragment with an ImgResCmd ErrorCode (4=success, 5=failed). Each fragment is
2437
+ * gated on its own ACK correlated by (session, fragmentIndex) before the next is sent, so
2438
+ * the ACK itself paces the transfer (no fixed inter-fragment delay). A `failed` ACK OR no ACK
2439
+ * within [IMG_ACK_TIMEOUT_MS] abandons the attempt and re-sends the entire image (fresh session
2440
+ * id) up to [IMG_MAX_ATTEMPTS] times. On exhausting all attempts it logs a warning and returns
2441
+ * (best-effort — callers are unaffected).
2442
+ *
2443
+ * Suspends until the image is acknowledged (or all attempts fail), so the 300ms settle in
2444
+ * [rebuildState] only runs once the transfer has fully resolved.
2162
2445
  */
2163
2446
  private suspend fun sendImageData(
2164
- containerID: Int,
2165
- containerName: String,
2166
- bmpData: ByteArray
2447
+ containerID: Int,
2448
+ containerName: String,
2449
+ bmpData: ByteArray
2167
2450
  ) {
2168
2451
  val fragmentSize = 4096
2169
- imageSessionCounter++
2170
- val sessionId = imageSessionCounter
2171
2452
  val totalSize = bmpData.size
2172
- var fragmentIndex = 0
2173
- var offset = 0
2174
-
2175
- Bridge.log("G2: sendImageData($containerName) - $fragmentIndex fragments, ${bmpData.size} bytes")
2453
+ val fragmentCount = (bmpData.size + fragmentSize - 1) / fragmentSize
2176
2454
 
2177
- while (offset < bmpData.size) {
2178
- val end = minOf(offset + fragmentSize, bmpData.size)
2179
- val fragment = bmpData.copyOfRange(offset, end)
2455
+ // skip if the image is empty:
2456
+ if (bmpData.size == 0) {
2457
+ return
2458
+ }
2180
2459
 
2181
- val msg =
2460
+ // Bridge.log("G2: sendImageData($containerName) - $fragmentCount fragments, ${bmpData.size} bytes")
2461
+
2462
+ for (attempt in 1..IMG_MAX_ATTEMPTS) {
2463
+ // One session id per WHOLE image transfer (per attempt). The glasses key their
2464
+ // reassembly buffer on MapSessionId, so every fragment reuses it with an incrementing
2465
+ // MapFragmentIndex. A retry uses a fresh session so a stale ACK from a prior attempt
2466
+ // can't match.
2467
+ imageSessionCounter = (imageSessionCounter + 1) % 256
2468
+ val sessionId = imageSessionCounter
2469
+
2470
+ var fragmentIndex = 0
2471
+ var offset = 0
2472
+ var transferOk = true
2473
+ // if (attempt > 1) {
2474
+ // Bridge.log("G2: sendImageData($containerName) - attempt $attempt starting")
2475
+ // }
2476
+ while (offset < bmpData.size) {
2477
+ val end = minOf(offset + fragmentSize, bmpData.size)
2478
+ val fragment = bmpData.copyOfRange(offset, end)
2479
+
2480
+ val ack = CompletableDeferred<Boolean>()
2481
+ pendingImgAckSession = sessionId
2482
+ pendingImgAckFragment = fragmentIndex
2483
+ pendingImgAck = ack
2484
+
2485
+ Bridge.log("G2: img_sen: session=$sessionId fragment=$fragmentIndex")
2486
+
2487
+ val msg =
2182
2488
  EvenHubProto.updateImageRawDataMessage(
2183
- containerID = containerID,
2184
- containerName = containerName,
2185
- mapSessionId = sessionId,
2186
- mapTotalSize = totalSize,
2187
- compressMode = 0,
2188
- mapFragmentIndex = fragmentIndex,
2189
- mapFragmentPacketSize = fragment.size,
2190
- mapRawData = fragment
2489
+ containerID = containerID,
2490
+ containerName = containerName,
2491
+ mapSessionId = sessionId,
2492
+ mapTotalSize = totalSize,
2493
+ compressMode = 0,
2494
+ mapFragmentIndex = fragmentIndex,
2495
+ mapFragmentPacketSize = fragment.size,
2496
+ mapRawData = fragment
2191
2497
  )
2192
- sendEvenHubCommand(msg)
2193
- Bridge.log("G2: sendImageData($containerName) - sent fragment $fragmentIndex")
2498
+ sendEvenHubCommand(msg)
2499
+
2500
+ // Gate on THIS fragment's ACK before sending the next (the ACK provides pacing).
2501
+ // null=timeout, false=img_failed → abandon the attempt and retry the whole image.
2502
+ val ok = withTimeoutOrNull(IMG_ACK_TIMEOUT_MS) { ack.await() }
2503
+ if (pendingImgAck === ack) {
2504
+ pendingImgAck = null
2505
+ pendingImgAckSession = null
2506
+ pendingImgAckFragment = null
2507
+ }
2508
+ if (ok != true) {
2509
+ val reason = if (ok == null) "timeout" else "img_failed"
2510
+ // Bridge.log("G2: sendImageData($containerName) - attempt $attempt fragment $fragmentIndex failed ($reason)")
2511
+ transferOk = false
2512
+ break
2513
+ }
2514
+ fragmentIndex++
2515
+ offset = end
2516
+ }
2194
2517
 
2195
- fragmentIndex++
2196
- offset = end
2197
- delay(200) // 200ms between fragments (and after the last, matching iOS)
2518
+ if (transferOk) {
2519
+ // Bridge.log("G2: img_sen: container=$containerName - success=true")
2520
+ return
2521
+ }
2198
2522
  }
2523
+
2524
+ Bridge.log("G2: img_sen: sendImageData($containerName) - failed after $IMG_MAX_ATTEMPTS attempts")
2199
2525
  }
2200
2526
 
2201
2527
  /// Bring the Even Realities dashboard (the OS-level home/idle screen) to
@@ -2203,10 +2529,14 @@ class G2 : SGCManager() {
2203
2529
  /// The glasses fall back to the dashboard automatically when no page is up.
2204
2530
  override fun showDashboard() {
2205
2531
  Bridge.log("G2: showDashboard()")
2206
- dashboardShowing += 2
2532
+ // Dashboard is open: a 0/1 flag (the old +=2/-=1 depth dance drifted >0 and wedged the
2533
+ // mic). dashboardOpening latches so the open-confirm 08011A00 doesn't trigger recovery.
2534
+ dashboardShowing = 1
2535
+ dashboardOpening = true
2207
2536
  val msg = EvenHubProto.shutdownMessage()
2208
2537
  sendEvenHubCommand(msg)
2209
2538
  pageCreated = false
2539
+ evenHubMicActive = false // dashboard takes EvenHub focus; firmware kills the mic
2210
2540
  currentBitmapBase64 = ""
2211
2541
  mainHandler.postDelayed({
2212
2542
  // activate the dashboard by setting depth to the current setting:
@@ -2238,12 +2568,12 @@ class G2 : SGCManager() {
2238
2568
  override fun setDashboardMenu(items: List<Map<String, Any>>) {
2239
2569
  Bridge.log("G2: setDashboardMenu -- items: $items")
2240
2570
  val menuItems =
2241
- items.mapNotNull { dict ->
2242
- val name = dict["name"] as? String ?: return@mapNotNull null
2243
- val packageName = dict["packageName"] as? String ?: return@mapNotNull null
2244
- val running = dict["running"] as? Boolean ?: false
2245
- MenuProto.MenuItem(packageName, name, running)
2246
- }
2571
+ items.mapNotNull { dict ->
2572
+ val name = dict["name"] as? String ?: return@mapNotNull null
2573
+ val packageName = dict["packageName"] as? String ?: return@mapNotNull null
2574
+ val running = dict["running"] as? Boolean ?: false
2575
+ MenuProto.MenuItem(packageName, name, running)
2576
+ }
2247
2577
  dashboardMenuItems.clear()
2248
2578
  dashboardMenuItems.addAll(menuItems)
2249
2579
  Bridge.log("G2: setDashboardMenu -- sending ${menuItems.size} items")
@@ -2267,11 +2597,11 @@ class G2 : SGCManager() {
2267
2597
  Bridge.log("G2: sendCalendarEvents -- ${events.size} events")
2268
2598
  if (events.isEmpty()) {
2269
2599
  sendDashboardCommand(
2270
- CalendarProto.calendarClear(
2271
- magicRandom = sendManager.nextMagicRandom(),
2272
- packageId = 1,
2273
- scheduleAuthority = 1
2274
- )
2600
+ CalendarProto.calendarClear(
2601
+ magicRandom = sendManager.nextMagicRandom(),
2602
+ packageId = 1,
2603
+ scheduleAuthority = 1
2604
+ )
2275
2605
  )
2276
2606
  return
2277
2607
  }
@@ -2287,18 +2617,18 @@ class G2 : SGCManager() {
2287
2617
  val location = ev["location"] as? String
2288
2618
 
2289
2619
  val payload =
2290
- CalendarProto.calendarPush(
2291
- magicRandom = sendManager.nextMagicRandom(),
2292
- packageId = 1,
2293
- scheduleId = i + 1,
2294
- title = title,
2295
- location = location,
2296
- time = time,
2297
- endTimestamp = (endTs + tzSec).toInt(),
2298
- scheduleAuthority = 1,
2299
- scheduleTotal = total,
2300
- scheduleNum = i
2301
- )
2620
+ CalendarProto.calendarPush(
2621
+ magicRandom = sendManager.nextMagicRandom(),
2622
+ packageId = 1,
2623
+ scheduleId = i + 1,
2624
+ title = title,
2625
+ location = location,
2626
+ time = time,
2627
+ endTimestamp = (endTs + tzSec).toInt(),
2628
+ scheduleAuthority = 1,
2629
+ scheduleTotal = total,
2630
+ scheduleNum = i
2631
+ )
2302
2632
  sendDashboardCommand(payload)
2303
2633
  }
2304
2634
  }
@@ -2315,27 +2645,27 @@ class G2 : SGCManager() {
2315
2645
  override suspend fun showNotificationsPanel() {
2316
2646
  Bridge.log("G2: showNotificationsPanel()")
2317
2647
  val enterPayload = EvenAIProto.aiCtrl(
2318
- magicRandom = sendManager.nextMagicRandom(),
2319
- status = 2 // EVEN_AI_ENTER
2648
+ magicRandom = sendManager.nextMagicRandom(),
2649
+ status = 2 // EVEN_AI_ENTER
2320
2650
  )
2321
2651
  sendEvenAICommand(enterPayload)
2322
2652
 
2323
2653
  delay(400)
2324
2654
  val askPayload = EvenAIProto.aiAsk(
2325
- magicRandom = sendManager.nextMagicRandom(),
2326
- text = " ",
2327
- streamEnable = 0
2655
+ magicRandom = sendManager.nextMagicRandom(),
2656
+ text = " ",
2657
+ streamEnable = 0
2328
2658
  )
2329
2659
  sendEvenAICommand(askPayload)
2330
2660
 
2331
2661
  delay(400)
2332
2662
  val skillPayload = EvenAIProto.triggerSkill(
2333
- magicRandom = sendManager.nextMagicRandom(),
2334
- skillId = 3, // NOTIFICATION
2335
- skillParam = 1, // show
2336
- text = " ",
2337
- streamEnable = 1,
2338
- fTextEnd = 1
2663
+ magicRandom = sendManager.nextMagicRandom(),
2664
+ skillId = 3, // NOTIFICATION
2665
+ skillParam = 1, // show
2666
+ text = " ",
2667
+ streamEnable = 1,
2668
+ fTextEnd = 1
2339
2669
  )
2340
2670
  sendEvenAICommand(skillPayload)
2341
2671
  }
@@ -2343,11 +2673,11 @@ class G2 : SGCManager() {
2343
2673
  override fun setBrightness(level: Int, autoMode: Boolean) {
2344
2674
  Bridge.log("G2: setBrightness($level, auto=$autoMode)")
2345
2675
  val msg =
2346
- G2SettingProto.setBrightness(
2347
- magicRandom = sendManager.nextMagicRandom(),
2348
- level = level,
2349
- autoAdjust = autoMode
2350
- )
2676
+ G2SettingProto.setBrightness(
2677
+ magicRandom = sendManager.nextMagicRandom(),
2678
+ level = level,
2679
+ autoAdjust = autoMode
2680
+ )
2351
2681
  sendG2SettingCommand(msg)
2352
2682
  }
2353
2683
 
@@ -2376,7 +2706,7 @@ class G2 : SGCManager() {
2376
2706
  )
2377
2707
  }
2378
2708
  }
2379
-
2709
+
2380
2710
 
2381
2711
  // iterate all image containers, remove any entries with duplicate ids or empty data,
2382
2712
  // and ensure the ids are still in the imageContainerIDPool:
@@ -2395,95 +2725,73 @@ class G2 : SGCManager() {
2395
2725
 
2396
2726
  // Build the page's image containers from the live tracked list.
2397
2727
  val imageContainerProps: List<ByteArray> =
2398
- imageContainers.map { c ->
2399
- EvenHubProto.imageContainerProperty(
2400
- x = c.x,
2401
- y = c.y,
2402
- width = c.width,
2403
- height = c.height,
2404
- containerID = c.id,
2405
- containerName = c.name
2406
- )
2407
- }
2728
+ imageContainers.map { c ->
2729
+ EvenHubProto.imageContainerProperty(
2730
+ x = c.x,
2731
+ y = c.y,
2732
+ width = c.width,
2733
+ height = c.height,
2734
+ containerID = c.id,
2735
+ containerName = c.name
2736
+ )
2737
+ }
2408
2738
 
2409
2739
  val msg: ByteArray
2410
2740
  if (!pageCreated) {
2411
2741
  Bridge.log("G2: using createPageMessage (first time)")
2412
2742
  msg =
2413
- EvenHubProto.createPageMessage(
2414
- textContainers = textContainerProps,
2415
- imageContainers = imageContainerProps,
2416
- magicRandom = sendManager.nextMagicRandom(),
2417
- appId = activeMenuAppId
2418
- )
2743
+ EvenHubProto.createPageMessage(
2744
+ textContainers = textContainerProps,
2745
+ imageContainers = imageContainerProps,
2746
+ magicRandom = sendManager.nextMagicRandom(),
2747
+ appId = activeMenuAppId
2748
+ )
2419
2749
  } else {
2420
2750
  Bridge.log("G2: using rebuildPageMessage")
2421
2751
  msg =
2422
- EvenHubProto.rebuildPageMessage(
2423
- textContainers = textContainerProps,
2424
- imageContainers = imageContainerProps,
2425
- magicRandom = sendManager.nextMagicRandom(),
2426
- appId = activeMenuAppId
2427
- )
2752
+ EvenHubProto.rebuildPageMessage(
2753
+ textContainers = textContainerProps,
2754
+ imageContainers = imageContainerProps,
2755
+ magicRandom = sendManager.nextMagicRandom(),
2756
+ appId = activeMenuAppId
2757
+ )
2428
2758
  }
2429
2759
  sendEvenHubCommand(msg)
2430
2760
  pageCreated = true
2431
2761
  }
2432
2762
 
2433
- @Synchronized
2434
- private fun queueEvenHubCommand(payload: ByteArray) {
2435
- pendingTextMsg = payload
2436
- }
2437
-
2438
- @Synchronized
2439
- private fun drainEvenHubQueue() {
2440
- val msg = pendingTextMsg
2441
- pendingTextMsg = null
2442
- val toSend: ByteArray? = if (msg != null) {
2443
- lastEvenHubMsg = msg
2444
- lastEvenHubResendsRemaining = EVEN_HUB_RESEND_COUNT
2445
- msg
2446
- } else if (lastEvenHubResendsRemaining > 0 && lastEvenHubMsg != null) {
2447
- lastEvenHubResendsRemaining -= 1
2448
- lastEvenHubMsg
2449
- } else {
2450
- null
2451
- }
2452
- toSend?.let { sendEvenHubCommand(it) }
2453
- }
2454
-
2455
2763
  // ---------- Bitmap Conversion ----------
2456
2764
 
2457
2765
  private fun convertToG2Bmp(
2458
- data: ByteArray,
2459
- containerWidth: Int,
2460
- containerHeight: Int
2766
+ data: ByteArray,
2767
+ containerWidth: Int,
2768
+ containerHeight: Int
2461
2769
  ): ByteArray? {
2462
2770
  val srcBitmap =
2463
- BitmapFactory.decodeByteArray(data, 0, data.size)
2464
- ?: run {
2465
- Bridge.log("G2: convertToG2Bmp - could not decode image")
2466
- return null
2467
- }
2771
+ BitmapFactory.decodeByteArray(data, 0, data.size)
2772
+ ?: run {
2773
+ Bridge.log("G2: convertToG2Bmp - could not decode image")
2774
+ return null
2775
+ }
2468
2776
 
2469
2777
  val srcWidth = srcBitmap.width
2470
2778
  val srcHeight = srcBitmap.height
2471
2779
 
2472
2780
  // Scale to fit within container (maintain aspect ratio)
2473
2781
  val scale =
2474
- minOf(containerWidth.toDouble() / srcWidth, containerHeight.toDouble() / srcHeight)
2782
+ minOf(containerWidth.toDouble() / srcWidth, containerHeight.toDouble() / srcHeight)
2475
2783
  val scaledW = maxOf(1, (srcWidth * scale).toInt())
2476
2784
  val scaledH = maxOf(1, (srcHeight * scale).toInt())
2477
2785
  val offsetX = (containerWidth - scaledW) / 2
2478
2786
  val offsetY = (containerHeight - scaledH) / 2
2479
2787
 
2480
- Bridge.log(
2481
- "G2: convertToG2Bmp - input ${srcWidth}x${srcHeight} → scaled ${scaledW}x${scaledH} in ${containerWidth}x${containerHeight}"
2482
- )
2788
+ // Bridge.log(
2789
+ // "G2: convertToG2Bmp - input ${srcWidth}x${srcHeight} → scaled ${scaledW}x${scaledH} in ${containerWidth}x${containerHeight}"
2790
+ // )
2483
2791
 
2484
2792
  // Render to container-sized bitmap with black background
2485
2793
  val destBitmap =
2486
- Bitmap.createBitmap(containerWidth, containerHeight, Bitmap.Config.ARGB_8888)
2794
+ Bitmap.createBitmap(containerWidth, containerHeight, Bitmap.Config.ARGB_8888)
2487
2795
  val canvas = Canvas(destBitmap)
2488
2796
  canvas.drawColor(Color.BLACK)
2489
2797
 
@@ -2511,6 +2819,17 @@ class G2 : SGCManager() {
2511
2819
  return build4BitBmp(grayscalePixels, containerWidth, containerHeight)
2512
2820
  }
2513
2821
 
2822
+ /**
2823
+ * Build an all-black BMP sized to a container. Sent to overwrite (and thus visually clear) an
2824
+ * image container without tearing the page down — on the green monochrome display, pixel 0 is
2825
+ * unlit, so an all-zero frame reads as blank. Used by the reconcile loop to clear a bitmap.
2826
+ */
2827
+ private fun blankBmp(width: Int, height: Int): ByteArray? {
2828
+ if (width <= 0 || height <= 0) return null
2829
+ val zeros = ByteArray(width * height) // all-zero 8-bit grayscale = black
2830
+ return build4BitBmp(zeros, width, height)
2831
+ }
2832
+
2514
2833
  private fun build4BitBmp(grayscalePixels: ByteArray, width: Int, height: Int): ByteArray? {
2515
2834
  // 4-bit: 2 pixels per byte, rows padded to 4-byte boundary
2516
2835
  val bytesPerRow4bit = (width + 1) / 2
@@ -2605,28 +2924,43 @@ class G2 : SGCManager() {
2605
2924
  }
2606
2925
 
2607
2926
  fun restartMic() {
2608
- // if already enabled, set to disabled, then send enabled after 500ms:
2927
+ // Intent is "mic on". The mic only exists inside a live EvenHub page, so we toggle it
2928
+ // off then back on (the firmware needs the off→on edge to re-arm).
2609
2929
  DeviceStore.apply("glasses", "micEnabled", true)
2930
+ evenHubMicActive = false
2610
2931
  val msg = EvenHubProto.audioControlMessage(false)
2611
2932
  sendEvenHubCommand(msg)
2612
2933
  mainHandler.postDelayed({
2613
2934
  val useNativeDashboard = DeviceStore.get("bluetooth", "use_native_dashboard") as? Boolean ?: false
2614
2935
  // Bridge.log("G2: setMicEnabled - useNativeDashboard=$useNativeDashboard, dashboardShowing=$dashboardShowing")
2936
+ // Dashboard owns the screen + session right now — don't arm the mic into a page the
2937
+ // dashboard has taken over; recovery re-arms on dashboard close.
2615
2938
  if (useNativeDashboard && dashboardShowing > 0) {
2616
2939
  return@postDelayed
2617
2940
  }
2941
+ // Never send audioControl(enable=true) without a live page — no page means no mic.
2942
+ // Rebuild first, which itself re-arms the mic at the end (intent is on), so we're done.
2618
2943
  if (!pageCreated) {
2619
- DeviceManager.getInstance().sendCurrentState() // should re-create the page if needed
2944
+ displayScope.launch {
2945
+ rebuildState()
2946
+ DeviceManager.getInstance().sendCurrentState()
2947
+ }
2948
+ return@postDelayed
2620
2949
  }
2621
2950
  val msg = EvenHubProto.audioControlMessage(true)
2622
2951
  sendEvenHubCommand(msg)
2952
+ evenHubMicActive = true
2623
2953
  }, 500)
2624
2954
  }
2625
2955
 
2626
2956
  override fun setMicEnabled(enabled: Boolean) {
2627
2957
  Bridge.log("G2: setMicEnabled($enabled)")
2958
+ if (enabled && !pageCreated) {
2959
+ restartMic()
2960
+ return
2961
+ }
2628
2962
  val currentEnabled = DeviceStore.get("glasses", "micEnabled") as? Boolean ?: false
2629
- if (currentEnabled && enabled) {
2963
+ if (enabled && currentEnabled) {
2630
2964
  restartMic()
2631
2965
  return
2632
2966
  }
@@ -2634,6 +2968,7 @@ class G2 : SGCManager() {
2634
2968
  DeviceStore.apply("glasses", "micEnabled", enabled)
2635
2969
  val msg = EvenHubProto.audioControlMessage(enabled)
2636
2970
  sendEvenHubCommand(msg)
2971
+ evenHubMicActive = enabled
2637
2972
  }
2638
2973
 
2639
2974
  override fun sortMicRanking(list: MutableList<String>): MutableList<String> {
@@ -2658,10 +2993,9 @@ class G2 : SGCManager() {
2658
2993
  }
2659
2994
 
2660
2995
  override fun startVideoRecording(
2661
- requestId: String,
2662
- save: Boolean,
2663
- flash: Boolean,
2664
- sound: Boolean
2996
+ requestId: String,
2997
+ save: Boolean,
2998
+ sound: Boolean
2665
2999
  ) {
2666
3000
  Bridge.log("G2: startVideoRecording - not supported")
2667
3001
  }
@@ -2683,10 +3017,6 @@ class G2 : SGCManager() {
2683
3017
  Bridge.log("G2: sendButtonMaxRecordingTime")
2684
3018
  }
2685
3019
 
2686
- override fun sendButtonCameraLedSetting() {
2687
- Bridge.log("G2: sendButtonCameraLedSetting")
2688
- }
2689
-
2690
3020
  override fun sendCameraFovSetting() {
2691
3021
  Bridge.log("G2: sendCameraFovSetting")
2692
3022
  }
@@ -2724,6 +3054,7 @@ class G2 : SGCManager() {
2724
3054
  override fun disconnect() {
2725
3055
  Bridge.log("G2: disconnect()")
2726
3056
  isDisconnecting = true
3057
+ clearDisplay()
2727
3058
  cancelPairingTimeout()
2728
3059
  stopScan()
2729
3060
  stopHeartbeats()
@@ -2744,6 +3075,7 @@ class G2 : SGCManager() {
2744
3075
  imageContainers.clear()
2745
3076
  textContainers.clear()
2746
3077
  dashboardShowing = 0
3078
+ dashboardOpening = false
2747
3079
  heartbeatCounter = 0
2748
3080
  currentBitmapBase64 = ""
2749
3081
  menuAppIdToPackageName.clear()
@@ -2799,6 +3131,7 @@ class G2 : SGCManager() {
2799
3131
  // } else {
2800
3132
  // stopCompass()
2801
3133
  // }
3134
+ displayScope.launch { runAuthSequence() }
2802
3135
  }
2803
3136
 
2804
3137
  /**
@@ -2841,23 +3174,20 @@ class G2 : SGCManager() {
2841
3174
  suspend fun setImuEnabled(enabled: Boolean, reportFrq: Int) {
2842
3175
  Bridge.log("G2: setImuEnabled($enabled, frq=$reportFrq)")
2843
3176
 
2844
- displayMutex.withLock {
2845
- // IMU requires an active EvenHub page (same prerequisite as the mic). Await the
2846
- // rebuild so the control packet is sent only after the page actually exists — page
2847
- // creation is async with variable delays, so a fixed wait could send too early and
2848
- // reporting would never start.
2849
- if (enabled && !pageCreated) {
2850
- rebuildState()
2851
- }
2852
-
2853
- val msg =
2854
- EvenHubProto.imuControlMessage(
2855
- enable = enabled,
2856
- reportFrq = reportFrq,
2857
- magicRandom = sendManager.nextMagicRandom()
2858
- )
2859
- sendEvenHubCommand(msg)
3177
+ // IMU requires an active EvenHub page (same prerequisite as the mic). Await the rebuild so
3178
+ // the control packet is sent only after the page actually exists page creation is async
3179
+ // with variable delays, so a fixed wait could send too early and reporting would never start.
3180
+ if (enabled && !pageCreated) {
3181
+ rebuildState()
2860
3182
  }
3183
+
3184
+ val msg =
3185
+ EvenHubProto.imuControlMessage(
3186
+ enable = enabled,
3187
+ reportFrq = reportFrq,
3188
+ magicRandom = sendManager.nextMagicRandom()
3189
+ )
3190
+ sendEvenHubCommand(msg)
2861
3191
  }
2862
3192
 
2863
3193
  fun reconnectController() {
@@ -2946,9 +3276,6 @@ class G2 : SGCManager() {
2946
3276
  }
2947
3277
 
2948
3278
  override fun sendShutdown() {
2949
- // Send the EvenHub shutdown synchronously before tearing down BLE. clearDisplay() is now
2950
- // fire-and-forget (it serializes on displayScope), so calling it here would let disconnect()
2951
- // close the GATT before the deferred shutdown packet was ever written.
2952
3279
  val msg = EvenHubProto.shutdownMessage()
2953
3280
  sendEvenHubCommand(msg)
2954
3281
  pageCreated = false
@@ -2968,13 +3295,13 @@ class G2 : SGCManager() {
2968
3295
  }
2969
3296
 
2970
3297
  override fun sendRgbLedControl(
2971
- requestId: String,
2972
- packageName: String?,
2973
- action: String,
2974
- color: String?,
2975
- onDurationMs: Int,
2976
- offDurationMs: Int,
2977
- count: Int
3298
+ requestId: String,
3299
+ packageName: String?,
3300
+ action: String,
3301
+ color: String?,
3302
+ onDurationMs: Int,
3303
+ offDurationMs: Int,
3304
+ count: Int
2978
3305
  ) {
2979
3306
  // G2 doesn't have RGB LEDs
2980
3307
  Bridge.sendRgbLedControlResponse(requestId, false, "device_not_supported")
@@ -3015,11 +3342,11 @@ class G2 : SGCManager() {
3015
3342
  stopScan()
3016
3343
 
3017
3344
  val adapter =
3018
- bluetoothAdapter
3019
- ?: run {
3020
- Bridge.log("G2: BluetoothAdapter not available")
3021
- return false
3022
- }
3345
+ bluetoothAdapter
3346
+ ?: run {
3347
+ Bridge.log("G2: BluetoothAdapter not available")
3348
+ return false
3349
+ }
3023
3350
 
3024
3351
  if (!adapter.isEnabled) {
3025
3352
  Bridge.log("G2: Bluetooth not enabled")
@@ -3034,94 +3361,94 @@ class G2 : SGCManager() {
3034
3361
  }
3035
3362
 
3036
3363
  val scanner =
3037
- adapter.bluetoothLeScanner
3038
- ?: run {
3039
- Bridge.log("G2: BluetoothLeScanner not available")
3040
- return false
3041
- }
3364
+ adapter.bluetoothLeScanner
3365
+ ?: run {
3366
+ Bridge.log("G2: BluetoothLeScanner not available")
3367
+ return false
3368
+ }
3042
3369
 
3043
3370
  val settings =
3044
- ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
3371
+ ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
3045
3372
 
3046
3373
  val callback =
3047
- object : ScanCallback() {
3048
- override fun onScanResult(callbackType: Int, result: ScanResult?) {
3049
- result ?: return
3050
- val device = result.device ?: return
3051
- val name = device.name ?: return
3052
-
3053
- if (!name.contains("G2")) return
3054
-
3055
- mainHandler.post {
3056
- // Extract serial number from manufacturer data (like iOS)
3057
- val serialNumber = extractSNFromScanRecord(result)
3058
- if (serialNumber == null) {
3059
- Bridge.log("G2: Discovered: $name but no SN in mfg data")
3060
- return@post
3061
- }
3374
+ object : ScanCallback() {
3375
+ override fun onScanResult(callbackType: Int, result: ScanResult?) {
3376
+ result ?: return
3377
+ val device = result.device ?: return
3378
+ val name = device.name ?: return
3379
+
3380
+ if (!name.contains("G2")) return
3381
+
3382
+ mainHandler.post {
3383
+ // Extract serial number from manufacturer data (like iOS)
3384
+ val serialNumber = extractSNFromScanRecord(result)
3385
+ if (serialNumber == null) {
3386
+ Bridge.log("G2: Discovered: $name but no SN in mfg data")
3387
+ return@post
3388
+ }
3062
3389
 
3063
- val mfgFirst = result.scanRecord?.manufacturerSpecificData?.valueAt(0)
3064
- val mfgHex =
3065
- mfgFirst?.joinToString(" ") { String.format("%02X", it) }
3066
- ?: "none"
3067
- Bridge.log(
3068
- "G2: Discovered: $name (SN: $serialNumber) mfgData[${mfgFirst?.size ?: 0}]: $mfgHex"
3069
- )
3070
- deviceNameToSerialNumber[name] = serialNumber
3071
-
3072
- // Save MAC per side; ring's advStart needs the left lens MAC.
3073
- val mac = extractMacFromScanRecord(result)
3074
- if (mac != null) {
3075
- if (name.contains("_L_")) {
3076
- DeviceStore.apply("glasses", "leftMacAddress", mac)
3077
- DeviceStore.apply("glasses", "bluetoothMacAddress", mac)
3078
- } else if (name.contains("_R_")) {
3079
- DeviceStore.apply("glasses", "rightMacAddress", mac)
3080
- }
3081
- }
3082
- // Stop scanning once we have both
3083
- if (leftGatt != null && rightGatt != null) {
3084
- stopScan()
3085
- Bridge.log("G2: Stopped scan after discovering both devices")
3086
- return@post
3390
+ val mfgFirst = result.scanRecord?.manufacturerSpecificData?.valueAt(0)
3391
+ val mfgHex =
3392
+ mfgFirst?.joinToString(" ") { String.format("%02X", it) }
3393
+ ?: "none"
3394
+ Bridge.log(
3395
+ "G2: Discovered: $name (SN: $serialNumber) mfgData[${mfgFirst?.size ?: 0}]: $mfgHex"
3396
+ )
3397
+ deviceNameToSerialNumber[name] = serialNumber
3398
+
3399
+ // Save MAC per side; ring's advStart needs the left lens MAC.
3400
+ val mac = extractMacFromScanRecord(result)
3401
+ if (mac != null) {
3402
+ if (name.contains("_L_")) {
3403
+ DeviceStore.apply("glasses", "leftMacAddress", mac)
3404
+ DeviceStore.apply("glasses", "bluetoothMacAddress", mac)
3405
+ } else if (name.contains("_R_")) {
3406
+ DeviceStore.apply("glasses", "rightMacAddress", mac)
3087
3407
  }
3408
+ }
3409
+ // Stop scanning once we have both
3410
+ if (leftGatt != null && rightGatt != null) {
3411
+ stopScan()
3412
+ Bridge.log("G2: Stopped scan after discovering both devices")
3413
+ return@post
3414
+ }
3088
3415
 
3089
- // Always emit discovered device to frontend
3090
- emitDiscoveredDevice(serialNumber)
3416
+ // Always emit discovered device to frontend
3417
+ emitDiscoveredDevice(serialNumber)
3091
3418
 
3092
- // If scan-only mode, don't auto-connect
3093
- if (DEVICE_SEARCH_ID == "NOT_SET") return@post
3419
+ // If scan-only mode, don't auto-connect
3420
+ if (DEVICE_SEARCH_ID == "NOT_SET") return@post
3094
3421
 
3095
- // Only connect to devices matching our search ID
3096
- if (!serialNumber.contains(DEVICE_SEARCH_ID)) return@post
3422
+ // Only connect to devices matching our search ID
3423
+ if (!serialNumber.contains(DEVICE_SEARCH_ID)) return@post
3097
3424
 
3098
- if (name.contains("_L_")) {
3099
- if (leftGatt == null) {
3100
- Bridge.log("G2: Connecting to LEFT: $name")
3101
- leftGatt = device.connectGatt(context, false, leftGattCallback)
3102
- }
3103
- } else if (name.contains("_R_")) {
3104
- if (rightGatt == null) {
3105
- Bridge.log("G2: Connecting to RIGHT: $name")
3106
- rightGatt =
3107
- device.connectGatt(context, false, rightGattCallback)
3108
- }
3425
+ if (name.contains("_L_")) {
3426
+ if (leftGatt == null) {
3427
+ Bridge.log("G2: Connecting to LEFT: $name")
3428
+ leftGatt = device.connectGatt(context, false, leftGattCallback)
3109
3429
  }
3110
-
3111
- // Stop scanning once we have both
3112
- if (leftGatt != null && rightGatt != null) {
3113
- stopScan()
3114
- cancelPairingTimeout()
3115
- Bridge.log("G2: Stopped scan after discovering both devices2")
3430
+ } else if (name.contains("_R_")) {
3431
+ if (rightGatt == null) {
3432
+ Bridge.log("G2: Connecting to RIGHT: $name")
3433
+ rightGatt =
3434
+ device.connectGatt(context, false, rightGattCallback)
3116
3435
  }
3117
3436
  }
3118
- }
3119
3437
 
3120
- override fun onScanFailed(errorCode: Int) {
3121
- Bridge.log("G2: Scan failed with error code: $errorCode")
3438
+ // Stop scanning once we have both
3439
+ if (leftGatt != null && rightGatt != null) {
3440
+ stopScan()
3441
+ cancelPairingTimeout()
3442
+ Bridge.log("G2: Stopped scan after discovering both devices2")
3443
+ }
3122
3444
  }
3123
3445
  }
3124
3446
 
3447
+ override fun onScanFailed(errorCode: Int) {
3448
+ Bridge.log("G2: Scan failed with error code: $errorCode")
3449
+ }
3450
+ }
3451
+
3125
3452
  scanCallback = callback
3126
3453
  try {
3127
3454
  scanner.startScan(null, settings, callback)
@@ -3159,7 +3486,7 @@ class G2 : SGCManager() {
3159
3486
  val rightDevice = adapter.getRemoteDevice(rightAddr)
3160
3487
 
3161
3488
  Bridge.log(
3162
- "G2: connectByAddress - left: ${leftDevice.name ?: leftAddr}, right: ${rightDevice.name ?: rightAddr}"
3489
+ "G2: connectByAddress - left: ${leftDevice.name ?: leftAddr}, right: ${rightDevice.name ?: rightAddr}"
3163
3490
  )
3164
3491
 
3165
3492
  leftGatt = leftDevice.connectGatt(context, false, leftGattCallback)
@@ -3191,8 +3518,8 @@ class G2 : SGCManager() {
3191
3518
  // Read 14 bytes of ASCII SN starting at offset 0
3192
3519
  val snBytes = data.copyOfRange(0, minOf(14, data.size))
3193
3520
  val sn =
3194
- String(snBytes, Charsets.US_ASCII)
3195
- .replace(Regex("[\\x00-\\x1F\\x7F]"), "") // Strip control chars
3521
+ String(snBytes, Charsets.US_ASCII)
3522
+ .replace(Regex("[\\x00-\\x1F\\x7F]"), "") // Strip control chars
3196
3523
  return if (sn.isNotEmpty()) sn else null
3197
3524
  }
3198
3525
 
@@ -3251,17 +3578,17 @@ class G2 : SGCManager() {
3251
3578
  // discoverServices is deferred to onMtuChanged so the larger MTU is in
3252
3579
  // effect for the rest of the setup.
3253
3580
  val mtuRequested =
3254
- try {
3255
- gatt.requestMtu(247)
3256
- } catch (e: SecurityException) {
3257
- Bridge.log(
3258
- "G2: requestMtu SecurityException on $side: ${e.message}"
3259
- )
3260
- false
3261
- }
3581
+ try {
3582
+ gatt.requestMtu(247)
3583
+ } catch (e: SecurityException) {
3584
+ Bridge.log(
3585
+ "G2: requestMtu SecurityException on $side: ${e.message}"
3586
+ )
3587
+ false
3588
+ }
3262
3589
  if (!mtuRequested) {
3263
3590
  Bridge.log(
3264
- "G2: requestMtu returned false on $side, proceeding without MTU bump"
3591
+ "G2: requestMtu returned false on $side, proceeding without MTU bump"
3265
3592
  )
3266
3593
  gatt.discoverServices()
3267
3594
  }
@@ -3273,7 +3600,7 @@ class G2 : SGCManager() {
3273
3600
  gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
3274
3601
  } catch (e: SecurityException) {
3275
3602
  Bridge.log(
3276
- "G2: requestConnectionPriority SecurityException on $side: ${e.message}"
3603
+ "G2: requestConnectionPriority SecurityException on $side: ${e.message}"
3277
3604
  )
3278
3605
  }
3279
3606
  } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
@@ -3301,6 +3628,7 @@ class G2 : SGCManager() {
3301
3628
  startupPageCreated = false
3302
3629
  pageCreated = false
3303
3630
  dashboardShowing = 0
3631
+ dashboardOpening = false
3304
3632
  DeviceStore.apply("glasses", "connected", false)
3305
3633
  DeviceStore.apply("glasses", "fullyBooted", false)
3306
3634
 
@@ -3335,17 +3663,17 @@ class G2 : SGCManager() {
3335
3663
 
3336
3664
  var propStr = mutableListOf<String>()
3337
3665
  if (props and BluetoothGattCharacteristic.PROPERTY_READ != 0)
3338
- propStr.add("read")
3666
+ propStr.add("read")
3339
3667
  if (props and BluetoothGattCharacteristic.PROPERTY_WRITE != 0)
3340
- propStr.add("write")
3668
+ propStr.add("write")
3341
3669
  if (props and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE !=
3342
- 0
3670
+ 0
3343
3671
  )
3344
- propStr.add("writeNoResp")
3672
+ propStr.add("writeNoResp")
3345
3673
  if (props and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0)
3346
- propStr.add("notify")
3674
+ propStr.add("notify")
3347
3675
  if (props and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0)
3348
- propStr.add("indicate")
3676
+ propStr.add("indicate")
3349
3677
  Bridge.log("G2: $side char $uuid props=[${propStr.joinToString(",")}]")
3350
3678
 
3351
3679
  when (uuid) {
@@ -3354,12 +3682,14 @@ class G2 : SGCManager() {
3354
3682
  if (side == "LEFT") leftWriteChar = char
3355
3683
  else rightWriteChar = char
3356
3684
  }
3685
+
3357
3686
  G2BLE.CHAR_NOTIFY -> {
3358
3687
  Bridge.log("G2: Found NOTIFY char on $side")
3359
3688
  if (side == "LEFT") leftNotifyChar = char
3360
3689
  else rightNotifyChar = char
3361
3690
  enqueueGattOp { enableNotifications(gatt, char) }
3362
3691
  }
3692
+
3363
3693
  G2BLE.AUDIO_NOTIFY -> {
3364
3694
  Bridge.log("G2: Found AUDIO char on $side")
3365
3695
  if (side == "LEFT") leftAudioChar = char
@@ -3393,22 +3723,30 @@ class G2 : SGCManager() {
3393
3723
 
3394
3724
  @Deprecated("Deprecated in API level 33")
3395
3725
  override fun onCharacteristicChanged(
3396
- gatt: BluetoothGatt,
3397
- characteristic: BluetoothGattCharacteristic
3726
+ gatt: BluetoothGatt,
3727
+ characteristic: BluetoothGattCharacteristic
3398
3728
  ) {
3399
3729
  val data = characteristic.value ?: return
3400
3730
 
3401
3731
  val sourceKey = if (side == "LEFT") "L" else "R"
3402
3732
  when (characteristic.uuid) {
3403
3733
  G2BLE.AUDIO_NOTIFY -> handleAudioData(data, sourceKey)
3404
- G2BLE.CHAR_NOTIFY -> mainHandler.post { handleNotifyData(data, sourceKey) }
3734
+ G2BLE.CHAR_NOTIFY -> {
3735
+ // Correlate an in-flight image ACK INLINE on the BLE callback thread, before
3736
+ // posting the rest of the handling to the (potentially backed-up) main
3737
+ // queue. This guarantees the ACK that sendImageData() is awaiting resolves
3738
+ // promptly even while the main looper is busy draining a packet burst /
3739
+ // heartbeats — the cause of the intermittent "glasses stopped responding".
3740
+ correlateImageAck(data, sourceKey)
3741
+ mainHandler.post { handleNotifyData(data, sourceKey) }
3742
+ }
3405
3743
  }
3406
3744
  }
3407
3745
 
3408
3746
  override fun onDescriptorWrite(
3409
- gatt: BluetoothGatt,
3410
- descriptor: BluetoothGattDescriptor,
3411
- status: Int
3747
+ gatt: BluetoothGatt,
3748
+ descriptor: BluetoothGattDescriptor,
3749
+ status: Int
3412
3750
  ) {
3413
3751
  mainHandler.post {
3414
3752
  // Process next queued GATT operation
@@ -3436,8 +3774,8 @@ class G2 : SGCManager() {
3436
3774
 
3437
3775
  @Suppress("deprecation")
3438
3776
  private fun enableNotifications(
3439
- gatt: BluetoothGatt,
3440
- characteristic: BluetoothGattCharacteristic
3777
+ gatt: BluetoothGatt,
3778
+ characteristic: BluetoothGattCharacteristic
3441
3779
  ) {
3442
3780
  gatt.setCharacteristicNotification(characteristic, true)
3443
3781
  val descriptor = characteristic.getDescriptor(G2BLE.CLIENT_CHARACTERISTIC_CONFIG)
@@ -3453,12 +3791,66 @@ class G2 : SGCManager() {
3453
3791
 
3454
3792
  // ---------- Incoming Data Handling ----------
3455
3793
 
3794
+ /**
3795
+ * Resolve an in-flight image-fragment ACK directly from a raw notify packet, on the BLE
3796
+ * callback thread. This runs BEFORE [handleNotifyData] is posted to [mainHandler] so the ACK
3797
+ * that [sendImageData] is awaiting completes promptly even when the main looper is saturated by
3798
+ * a packet burst, heartbeats, or the text-queue tick — the root cause of the intermittent
3799
+ * "glasses stop responding to image sends".
3800
+ *
3801
+ * Deliberately a read-only, SINGLE-PACKET parse: it does NOT touch the stateful [receiveManager]
3802
+ * (that stays exclusively on the main thread). An ImgResCmd always fits in one BLE packet, so a
3803
+ * multi-packet frame (totalPackets > 1) is not an image ACK and is left entirely to the
3804
+ * main-thread path. Completing an already-completed CompletableDeferred is a no-op, so the
3805
+ * duplicate L/R ACK and the residual main-thread correlation are both harmless.
3806
+ */
3807
+ private fun correlateImageAck(rawData: ByteArray, @Suppress("UNUSED_PARAMETER") sourceKey: String) {
3808
+ // Fast path: only proceed if a transfer is actually outstanding.
3809
+ val ack = pendingImgAck ?: return
3810
+ if (rawData.size < 8) return
3811
+ if (rawData[0] != G2BLE.HEADER_BYTE) return
3812
+
3813
+ val payloadLen = rawData[3].toInt() and 0xFF
3814
+ val expectedLen = payloadLen + 8
3815
+ if (rawData.size < expectedLen) return
3816
+
3817
+ val totalPackets = rawData[4].toInt() and 0xFF
3818
+ val serialNum = rawData[5].toInt() and 0xFF
3819
+ val serviceId = rawData[6]
3820
+ val status = rawData[7].toInt() and 0xFF
3821
+ val resultCode = (status shr 1) and 0x0F
3822
+ if (resultCode != 0) return
3823
+ if (serviceId != ServiceID.EVEN_HUB.value) return
3824
+ // Single complete packet only (ImgResCmd never spans packets); else defer to main thread.
3825
+ if (totalPackets != 1 || serialNum != 1) return
3826
+
3827
+ // Last packet carries a 2-byte CRC trailer; strip it from the payload.
3828
+ val payloadEnd = 8 + payloadLen - 2
3829
+ if (payloadEnd < 8 || payloadEnd > rawData.size) return
3830
+ val payload = rawData.copyOfRange(8, payloadEnd)
3831
+
3832
+ val fields = ProtobufReader(payload).parseFields()
3833
+ val resData = fields[6] as? ByteArray ?: return // field 6 = ImgResCmd
3834
+ val resFields = ProtobufReader(resData).parseFields()
3835
+ val errorCode = resFields[8] as? Int ?: return
3836
+ val ackSession = resFields[3] as? Int ?: return
3837
+ val ackFragment = (resFields[6] as? Int) ?: 0
3838
+ Bridge.log("G2: img_res: session=$ackSession fragment=$ackFragment errorCode=$errorCode success=${errorCode == 4}")
3839
+ if (ackSession == pendingImgAckSession && ackFragment == pendingImgAckFragment) {
3840
+ ack.complete(errorCode == 4)
3841
+ }
3842
+ }
3843
+
3456
3844
  private fun handleNotifyData(data: ByteArray, sourceKey: String) {
3457
3845
  val result = receiveManager.handlePacket(data, sourceKey) ?: return
3458
3846
 
3459
3847
  val serviceId = result.first
3460
3848
  val payload = result.second
3461
3849
 
3850
+
3851
+ // print raw log, first 32 bytes:
3852
+ // Bridge.log("G2: handleNotifyData() - serviceId=$serviceId, payload=${payload.take(32).joinToString("") { String.format("%02X", it) }}")
3853
+
3462
3854
  when (serviceId) {
3463
3855
  ServiceID.EVEN_HUB.value -> handleEvenHubResponse(payload)
3464
3856
  ServiceID.DEVICE_SETTINGS.value -> handleDevSettingsResponse(payload, sourceKey)
@@ -3471,7 +3863,7 @@ class G2 : SGCManager() {
3471
3863
  ServiceID.EVEN_HUB_CTRL.value -> handleEvenHubCtrlResponse(payload)
3472
3864
  else -> {
3473
3865
  Bridge.log(
3474
- "G2: Unhandled service ${serviceId.toInt() and 0xFF} (${payload.size} bytes): ${
3866
+ "G2: Unhandled service ${serviceId.toInt() and 0xFF} (${payload.size} bytes): ${
3475
3867
  payload.take(32).joinToString("") { String.format("%02X", it) }
3476
3868
  }"
3477
3869
  )
@@ -3493,11 +3885,18 @@ class G2 : SGCManager() {
3493
3885
  val cFields = cReader.parseFields()
3494
3886
  val voiceSwitch = cFields[1] as? Int ?: 0 // omitted = 0 = OFF
3495
3887
  Bridge.log(
3496
- "G2: EvenAI CONFIG echo — voiceSwitch=$voiceSwitch (${if (voiceSwitch == 1) "ON" else "OFF"}) config=$cFields"
3888
+ "G2: EvenAI CONFIG echo — voiceSwitch=$voiceSwitch (${if (voiceSwitch == 1) "ON" else "OFF"}) config=$cFields"
3497
3889
  )
3498
3890
  } else {
3499
3891
  Bridge.log(
3500
- "G2: EvenAI cmd=$cmd fields=${fields.keys.sorted()} raw=${payload.joinToString("") { String.format("%02X", it) }}"
3892
+ "G2: EvenAI cmd=$cmd fields=${fields.keys.sorted()} raw=${
3893
+ payload.joinToString("") {
3894
+ String.format(
3895
+ "%02X",
3896
+ it
3897
+ )
3898
+ }
3899
+ }"
3501
3900
  )
3502
3901
  }
3503
3902
  }
@@ -3524,17 +3923,19 @@ class G2 : SGCManager() {
3524
3923
  // Heading in degrees, 0…359.
3525
3924
  Bridge.log("G2: compass heading=$heading°")
3526
3925
  Bridge.sendTypedMessage(
3527
- "CompassHeadingEvent",
3528
- mapOf(
3529
- "heading" to heading,
3530
- "timestamp" to System.currentTimeMillis()
3531
- )
3926
+ "CompassHeadingEvent",
3927
+ mapOf(
3928
+ "heading" to heading,
3929
+ "timestamp" to System.currentTimeMillis()
3930
+ )
3532
3931
  )
3533
3932
  }
3933
+
3534
3934
  NavigationCmd.OS_NOTIFY_COMPASS_CALIBRATE_START.value -> {
3535
3935
  Bridge.log("G2: compass calibration started — wearer should look around")
3536
3936
  Bridge.sendTypedMessage("CompassCalibrationEvent", mapOf("status" to "start"))
3537
3937
  }
3938
+
3538
3939
  NavigationCmd.OS_NOTIFY_COMPASS_CALIBRATE_COMPLETE.value -> {
3539
3940
  Bridge.log("G2: compass calibration complete")
3540
3941
  Bridge.sendTypedMessage("CompassCalibrationEvent", mapOf("status" to "complete"))
@@ -3547,18 +3948,23 @@ class G2 : SGCManager() {
3547
3948
  val fields = reader.parseFields()
3548
3949
 
3549
3950
  // print raw payload:
3550
- Bridge.log("G2: EvenHub response payload: ${payload.joinToString("") { String.format("%02X", it) }}")
3951
+ val payloadStr = payload.joinToString("") { String.format("%02X", it) }
3952
+ if (payloadStr.contains("080C7A02100C") || payloadStr.contains("080652020808")) {
3953
+ // heartbeat response
3954
+ return
3955
+ }
3956
+ Bridge.log("G2: res: ${payload.joinToString("") { String.format("%02X", it) }}")
3551
3957
 
3552
3958
  val cmdValue =
3553
- fields[1] as? Int
3554
- ?: run {
3555
- Bridge.log(
3556
- "G2: EvenHub response - no cmd field, ${payload.size} bytes: ${
3557
- payload.joinToString("") { String.format("%02X", it) }
3558
- }"
3559
- )
3560
- return
3561
- }
3959
+ fields[1] as? Int
3960
+ ?: run {
3961
+ Bridge.log(
3962
+ "G2: EvenHub response - no cmd field, ${payload.size} bytes: ${
3963
+ payload.joinToString("") { String.format("%02X", it) }
3964
+ }"
3965
+ )
3966
+ return
3967
+ }
3562
3968
 
3563
3969
  if (cmdValue == EvenHubResponseCmd.OS_NOTIFY_EVENT_TO_APP.value) {
3564
3970
  // Touch/gesture event from glasses
@@ -3595,8 +4001,10 @@ class G2 : SGCManager() {
3595
4001
  Bridge.log("G2: Menu selection ignored — placeholder or unknown appId=$appId")
3596
4002
  }
3597
4003
  } else {
3598
- // Dedup only the non-critical logging path (img-success/error chatter), which L and R
3599
- // both deliver. Page-state resets above are intentionally outside this window.
4004
+ // NOTE: the per-fragment image ACK is correlated inline on the BLE callback thread in
4005
+ // correlateImageAck() (called before this runs is posted to the main looper) so it is
4006
+ // never delayed behind a saturated main queue. Nothing to do here for ImgResCmd.
4007
+
3600
4008
  val timestamp = System.currentTimeMillis()
3601
4009
  val lastResponse = lastEvenHubResponseTimestamp
3602
4010
  if (lastResponse != null && timestamp - lastResponse < 100) {
@@ -3608,6 +4016,7 @@ class G2 : SGCManager() {
3608
4016
  if (cmdValue == 9 || cmdValue == 10) {
3609
4017
  Bridge.log("G2: ERROR: Glasses shutdown our EvenHub page — resetting page state")
3610
4018
  pageCreated = false
4019
+ evenHubMicActive = false // mic dies with the page
3611
4020
  }
3612
4021
 
3613
4022
  // Scan response fields for a shutdown/error code regardless of the debounce window.
@@ -3622,26 +4031,28 @@ class G2 : SGCManager() {
3622
4031
  // 7=rebuild_failed, 8=text_success, 9=text_failed
3623
4032
  if (errorCode == 9) {
3624
4033
  Bridge.log(
3625
- "G2: WARN: Glasses shutdown our EvenHub page — resetting page state"
4034
+ "G2: WARN: Glasses shutdown our EvenHub page — resetting page state"
3626
4035
  )
3627
4036
  pageCreated = false
4037
+ evenHubMicActive = false // mic dies with the page
3628
4038
  }
3629
4039
  }
3630
4040
  }
3631
4041
 
3632
- for (resField in listOf(4, 6, 8, 10)) {
3633
- val resData = fields[resField] as? ByteArray ?: continue
3634
- val resReader = ProtobufReader(resData)
3635
- val resFields = resReader.parseFields()
3636
- (resFields[8] as? Int)?.let { errorCode ->
3637
- // ImgResCmd has ErrorCode in field 8
3638
- if (errorCode == 4) {
3639
- Bridge.log("G2: img_success")
3640
- } else {
3641
- Bridge.log("G2: EvenHub ImgRes errorCode=$errorCode")
3642
- }
3643
- }
3644
- }
4042
+ // for (resField in listOf(4, 6, 8, 10)) {
4043
+ // val resData = fields[resField] as? ByteArray ?: continue
4044
+ // val resReader = ProtobufReader(resData)
4045
+ // val resFields = resReader.parseFields()
4046
+ // (resFields[8] as? Int)?.let { errorCode ->
4047
+ // // ImgResCmd ErrorCode in field 8 (the sendImageData ACK is completed above,
4048
+ // // before the dedup window — this is just the deduped logging path).
4049
+ // if (errorCode == 4) {
4050
+ // Bridge.log("G2: img_success")
4051
+ // } else {
4052
+ // Bridge.log("G2: EvenHub ImgRes errorCode=$errorCode")
4053
+ // }
4054
+ // }
4055
+ // }
3645
4056
  }
3646
4057
  }
3647
4058
 
@@ -3658,9 +4069,9 @@ class G2 : SGCManager() {
3658
4069
 
3659
4070
  private fun setControllerFullyConnected() {
3660
4071
  val isControllerConnected =
3661
- DeviceStore.get("glasses", "controllerConnected") as? Boolean ?: false
4072
+ DeviceStore.get("glasses", "controllerConnected") as? Boolean ?: false
3662
4073
  val isControllerFullyBooted =
3663
- DeviceStore.get("glasses", "controllerFullyBooted") as? Boolean ?: false
4074
+ DeviceStore.get("glasses", "controllerFullyBooted") as? Boolean ?: false
3664
4075
  if (!isControllerConnected) {
3665
4076
  DeviceStore.apply("glasses", "controllerConnected", true)
3666
4077
  }
@@ -3732,7 +4143,7 @@ class G2 : SGCManager() {
3732
4143
 
3733
4144
  val normalType = sysFields[1] as? Int
3734
4145
  val eventType: OsEventType? =
3735
- if (normalType != null) OsEventType.fromInt(normalType) else OsEventType.CLICK
4146
+ if (normalType != null) OsEventType.fromInt(normalType) else OsEventType.CLICK
3736
4147
  val eventSource: Int? = sysFields[2] as? Int
3737
4148
 
3738
4149
  if (eventType == null) {
@@ -3767,13 +4178,15 @@ class G2 : SGCManager() {
3767
4178
  }
3768
4179
  }
3769
4180
 
3770
- // System exit: glasses killed our EvenHub page (user opened menu or another app)
3771
- // Reset page state and re-create the page to reclaim EvenHub focus
4181
+ // System exit: the firmware killed our page (and the mic). ONLY mark state dead; do
4182
+ // NOT rebuild here. systemExit is fired alongside the dashboard-close (08011A00) event
4183
+ // for the same transition — if both rebuilt, the fresh page gets torn down again →
4184
+ // rebuild→exit→rebuild loop. Recovery is owned by one place: the dashboard-close
4185
+ // handler (and the reconcile page-down path). Don't touch micEnabled (user intent) —
4186
+ // recovery reads it to re-arm; clobbering it strands the mic.
3772
4187
  if (eventType == OsEventType.SYSTEM_EXIT || eventType == OsEventType.ABNORMAL_EXIT) {
3773
4188
  pageCreated = false
3774
- // Firmware kills the mic on system exit; re-arm it if it should be on
3775
- DeviceStore.apply("glasses", "micEnabled", false)
3776
- DeviceManager.getInstance().updateMicState()
4189
+ evenHubMicActive = false // firmware killed the mic with the page
3777
4190
  }
3778
4191
  return
3779
4192
  }
@@ -3889,13 +4302,13 @@ class G2 : SGCManager() {
3889
4302
 
3890
4303
  private fun handleMenuResponse(payload: ByteArray) {
3891
4304
  Bridge.log(
3892
- "G2: menu response: ${payload.take(32).joinToString("") { String.format("%02X", it) }}"
4305
+ "G2: menu response: ${payload.take(32).joinToString("") { String.format("%02X", it) }}"
3893
4306
  )
3894
4307
  }
3895
4308
 
3896
4309
  private fun handleDashboardResponse(payload: ByteArray) {
3897
4310
  Bridge.log(
3898
- "G2: dashboard response: ${payload.take(32).joinToString("") { String.format("%02X", it) }}"
4311
+ "G2: dashboard response: ${payload.take(32).joinToString("") { String.format("%02X", it) }}"
3899
4312
  )
3900
4313
  val reader = ProtobufReader(payload)
3901
4314
  val fields = reader.parseFields()
@@ -3927,7 +4340,7 @@ class G2 : SGCManager() {
3927
4340
 
3928
4341
  private fun handleEvenHubCtrlResponse(payload: ByteArray) {
3929
4342
  Bridge.log(
3930
- "G2: evenHubCtrl response: ${payload.take(8).joinToString("") { String.format("%02X", it) }}"
4343
+ "G2: evenHubCtrl response: ${payload.take(8).joinToString("") { String.format("%02X", it) }}"
3931
4344
  )
3932
4345
  }
3933
4346
 
@@ -3941,50 +4354,21 @@ class G2 : SGCManager() {
3941
4354
  }
3942
4355
  lastGestureCtrlTimestamp = timestamp
3943
4356
 
3944
- // Dashboard close detection: 08011A00 means dashboard closed
4357
+ // 08011A00 is the dashboard open/close toggle it fires on BOTH transitions. Use the
4358
+ // dashboardOpening latch (set by showDashboard) to tell them apart:
4359
+ // • First event after showDashboard → OPEN confirm. Consume it, keep the dashboard up,
4360
+ // do NOT recover (recovering rebuilds our page and snatches the screen back — the
4361
+ // "double-tap flickers captions but never opens the dashboard" bug).
4362
+ // • Next event → real CLOSE. Reset state and recover our page + mic.
3945
4363
  if (payload.contentEquals(byteArrayOf(0x08, 0x01, 0x1A, 0x00))) {
3946
- Bridge.log("G2: dashboard closed / shutdown - dashboardShowing=$dashboardShowing")
3947
- val useNativeDashboard = DeviceStore.get("bluetooth", "use_native_dashboard") as? Boolean ?: false
3948
- if (!useNativeDashboard) {
3949
- dashboardShowing = 0
3950
- // Rebuild the page from cached containers, then reconcile against
3951
- // DeviceManager's authoritative current view so the glasses match the phone
3952
- // (not just the last-cached G2 containers) after returning from the dashboard.
3953
- displayScope.launch {
3954
- displayMutex.withLock { rebuildState() }
3955
- DeviceManager.getInstance().sendCurrentState()
3956
- // set the mic back on if it should be on — only after the rebuild
3957
- // completes, matching iOS (which awaits rebuildState before restartMic).
3958
- val micEnabled = DeviceStore.get("glasses", "micEnabled") as? Boolean ?: false
3959
- if (micEnabled) {
3960
- restartMic()
3961
- }
3962
- }
4364
+ Bridge.log("G2: dashboard toggle - dashboardShowing=$dashboardShowing opening=$dashboardOpening")
4365
+ if (dashboardOpening) {
4366
+ dashboardOpening = false // open confirmed; dashboard now owns the screen
3963
4367
  return
3964
- } else {
3965
- // if we aren't trying to show the dashboard
3966
- // then we need to turn the mic back on and display the mentra main page:
3967
- if (dashboardShowing <= 1) {
3968
- dashboardShowing = 0
3969
- // Rebuild the page from cached containers, then reconcile against
3970
- // DeviceManager's authoritative current view so the glasses match the phone
3971
- // (not just the last-cached G2 containers) after returning from the dashboard.
3972
- displayScope.launch {
3973
- displayMutex.withLock { rebuildState() }
3974
- // set the mic back on if it should be on
3975
- val micEnabled = DeviceStore.get("glasses", "micEnabled") as? Boolean ?: false
3976
- if (micEnabled) {
3977
- restartMic()
3978
- }
3979
- }
3980
- return
3981
- }
3982
- // do nothing this time since we just closed the dashboard
3983
- dashboardShowing -= 1
3984
- if (dashboardShowing < 0) {
3985
- dashboardShowing = 0
3986
- }
3987
4368
  }
4369
+ dashboardShowing = 0
4370
+ recoverPageAndMic("dashboard-close")
4371
+ return
3988
4372
  }
3989
4373
  }
3990
4374
 
@@ -3995,7 +4379,7 @@ class G2 : SGCManager() {
3995
4379
  val cmdValue = fields[1] as? Int ?: return
3996
4380
 
3997
4381
  if (cmdValue == G2SettingCommandId.DEVICE_RECEIVE_REQUEST.value ||
3998
- cmdValue == G2SettingCommandId.DEVICE_SEND_TO_APP.value
4382
+ cmdValue == G2SettingCommandId.DEVICE_SEND_TO_APP.value
3999
4383
  ) {
4000
4384
  (fields[4] as? ByteArray)?.let { parseDeviceRequestResponse(it) }
4001
4385
  (fields[5] as? ByteArray)?.let { parseDeviceSendToApp(it) }