@mentra/bluetooth-sdk 0.1.18 → 0.1.20

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 (77) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +135 -51
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +120 -17
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +5 -6
  5. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +34 -21
  6. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +1 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +13 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +38 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +488 -15
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +551 -159
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveL2capChannel.kt +274 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +361 -67
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +7501 -3704
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +134 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
  17. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +2 -3
  18. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +493 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +65 -5
  20. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleWireProtocol.java +108 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/utils/IncidentLogBleUploadService.java +4 -3
  22. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900LengthCodec.java +115 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +109 -73
  24. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +106 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +93 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +111 -11
  27. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkExceptionTest.kt +18 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +23 -9
  29. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +31 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +17 -1
  31. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BinaryMessageChunkerTest.java +84 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +158 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +9 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsEndiannessTest.java +172 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsTest.java +24 -0
  36. package/build/BluetoothSdk.types.d.ts +37 -1
  37. package/build/BluetoothSdk.types.d.ts.map +1 -1
  38. package/build/BluetoothSdk.types.js.map +1 -1
  39. package/build/_private/BluetoothSdkModule.d.ts +4 -0
  40. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  41. package/build/_private/BluetoothSdkModule.js.map +1 -1
  42. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  43. package/build/_private/photoRequestPayload.js +1 -0
  44. package/build/_private/photoRequestPayload.js.map +1 -1
  45. package/build/index.d.ts +1 -1
  46. package/build/index.d.ts.map +1 -1
  47. package/build/index.js +5 -0
  48. package/build/index.js.map +1 -1
  49. package/build/types/index.d.ts +3 -0
  50. package/build/types/index.d.ts.map +1 -0
  51. package/build/types/index.js +2 -0
  52. package/build/types/index.js.map +1 -0
  53. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  54. package/ios/Source/DeviceManager.swift +136 -30
  55. package/ios/Source/DeviceStore.swift +5 -5
  56. package/ios/Source/MentraBluetoothSDK.swift +40 -24
  57. package/ios/Source/OtaManifest.swift +1 -1
  58. package/ios/Source/camera/CameraModels.swift +17 -3
  59. package/ios/Source/sgcs/G1.swift +1 -1
  60. package/ios/Source/sgcs/G2.swift +518 -11
  61. package/ios/Source/sgcs/MentraLive.swift +443 -42
  62. package/ios/Source/sgcs/MentraLiveL2capChannel.swift +183 -0
  63. package/ios/Source/sgcs/MentraNex.swift +416 -32
  64. package/ios/Source/sgcs/SGCManager.swift +155 -0
  65. package/ios/Source/sgcs/mentraos_ble.pb.swift +726 -122
  66. package/ios/Source/status/DeviceStatus.swift +0 -8
  67. package/ios/Source/utils/BleJsonCompact.swift +395 -0
  68. package/ios/Source/utils/BleWireProtocol.swift +92 -0
  69. package/ios/Source/utils/G1Text.swift +30 -1
  70. package/ios/Source/utils/MessageChunkReassembler.swift +82 -0
  71. package/ios/Source/utils/MessageChunker.swift +73 -0
  72. package/package.json +14 -7
  73. package/src/BluetoothSdk.types.ts +39 -1
  74. package/src/_private/BluetoothSdkModule.ts +4 -0
  75. package/src/_private/photoRequestPayload.ts +1 -0
  76. package/src/index.ts +6 -0
  77. package/src/types/index.ts +7 -0
@@ -1469,6 +1469,13 @@ class G2 : SGCManager() {
1469
1469
  private val imageContainerIDPool: List<Int> = listOf(10, 11, 12, 13)
1470
1470
  private val textContainerIDPool: List<Int> = listOf(1, 2, 3, 4, 5, 6)
1471
1471
 
1472
+ /**
1473
+ * One firmware text line (hardware-calibrated 2026-07-03: 28px overflows —
1474
+ * the fw draws its overflow-indicator tick — 40px is clean). Text
1475
+ * containers are silently grown to at least this.
1476
+ */
1477
+ private val minTextContainerHeight = 40
1478
+
1472
1479
  /** Default container seeded into every fresh page: 200x100 centered at 188,44. */
1473
1480
  private val defaultImgX = 188
1474
1481
  private val defaultImgY = 44
@@ -2038,13 +2045,19 @@ class G2 : SGCManager() {
2038
2045
  }
2039
2046
 
2040
2047
  val rx = x ?: defaultTextX
2041
- val ry = y ?: defaultTextY
2048
+ // Legacy callers can hand us y beyond the canvas — clamp it first so
2049
+ // the height formula below can't go negative/below one fw line.
2050
+ val ry = minOf(maxOf(y ?: defaultTextY, 0), 288 - minTextContainerHeight)
2042
2051
  val rw = width ?: defaultTextWidth
2043
- val rh = height ?: defaultTextHeight
2044
- val rBorderWidth = defaultTextBorderWidth
2045
- val rBorderColor = defaultTextBorderColor
2046
- val rBorderRadius = defaultTextBorderRadius
2047
- val rPaddingLength = defaultTextPaddingLength
2052
+ // Firmware guard: grow to at least one fw line, clamped to the canvas.
2053
+ // Content-independent so rect keys stay stable across updates.
2054
+ val rh = minOf(maxOf(height ?: defaultTextHeight, minTextContainerHeight), 288 - ry)
2055
+ // Honor caller-provided border styling (scene rects render as bordered
2056
+ // empty containers — a border of 0-by-default here made them invisible).
2057
+ val rBorderWidth = borderWidth ?: defaultTextBorderWidth
2058
+ val rBorderColor = borderColor ?: defaultTextBorderColor
2059
+ val rBorderRadius = borderRadius ?: defaultTextBorderRadius
2060
+ val rPaddingLength = paddingLength ?: defaultTextPaddingLength
2048
2061
  val content = if (text.isEmpty()) " " else text
2049
2062
 
2050
2063
  // Pure state mutation: update the container's content and schedule its sends; the reconcile
@@ -2101,7 +2114,7 @@ class G2 : SGCManager() {
2101
2114
  textContainers[newIndex].pendingSends = 1 + EVEN_HUB_RESEND_COUNT
2102
2115
  }
2103
2116
  signalDisplayDirty()
2104
- rebuildPage()
2117
+ requestPageRebuild()
2105
2118
  }
2106
2119
 
2107
2120
  override fun sendDoubleTextWall(top: String, bottom: String) {
@@ -2122,7 +2135,7 @@ class G2 : SGCManager() {
2122
2135
  // loop pushes the blanked text on a live page, and a dead page is only resurrected for
2123
2136
  // meaningful (non-blank) content, so a clear can't churn it back up.
2124
2137
  for (i in textContainers.indices) {
2125
- textContainers[i].content = " "
2138
+ textContainers[i].content = "\n"
2126
2139
  textContainers[i].pendingSends = 1 + EVEN_HUB_RESEND_COUNT
2127
2140
  }
2128
2141
  for (i in imageContainers.indices) {
@@ -2136,6 +2149,24 @@ class G2 : SGCManager() {
2136
2149
  }
2137
2150
  }
2138
2151
  signalDisplayDirty()
2152
+
2153
+ // Purge scene HUD containers structurally: a blanked small box still
2154
+ // renders the firmware's overflow tick (a "\n" husk is two empty lines
2155
+ // in a one-line box) and corrupts whatever app draws next. Full-canvas
2156
+ // containers stay blanked-in-place — the shipped caption-gap behavior,
2157
+ // storm-safe (no rebuild on ordinary clears). Only when positioned HUD
2158
+ // husks exist (app exit) do we drop them + rebuild ONCE.
2159
+ val huskIds = textContainers
2160
+ .filter { !(it.x == 0 && it.y == 0 && it.width >= defaultTextWidth && it.height >= defaultTextHeight) }
2161
+ .map { it.id }
2162
+ .toSet()
2163
+ if (huskIds.isNotEmpty()) {
2164
+ textContainers.removeAll { it.id in huskIds }
2165
+ sceneTextByElement.entries.removeAll { it.value in huskIds }
2166
+ sceneImageByElement.clear()
2167
+ Bridge.log("G2: clearDisplay() — purging ${huskIds.size} positioned husk container(s), one rebuild")
2168
+ displayScope.launch { coalescedPageRebuild() }
2169
+ }
2139
2170
  }
2140
2171
 
2141
2172
  /**
@@ -2196,7 +2227,7 @@ class G2 : SGCManager() {
2196
2227
  // A brand-new page needs its structure built before the loop can push pixels; the dirty
2197
2228
  // flag stays set so the reconcile loop sends the image once the page exists.
2198
2229
  if (!pageCreated) {
2199
- displayScope.launch { rebuildPage() }
2230
+ if (sceneBatchActive) sceneStructuralPending = true else displayScope.launch { coalescedPageRebuild() }
2200
2231
  }
2201
2232
  return true
2202
2233
  } else {
@@ -2208,12 +2239,434 @@ class G2 : SGCManager() {
2208
2239
  signalDisplayDirty()
2209
2240
  Bridge.log("G2: displayBitmap() - added container ${container.id} for rect $rx,$ry ${rw}x$rh, rebuilding page")
2210
2241
  // New container changes page structure: rebuild it, then the loop sends the pixels.
2211
- displayScope.launch { rebuildPage() }
2242
+ if (sceneBatchActive) sceneStructuralPending = true else displayScope.launch { coalescedPageRebuild() }
2243
+ }
2244
+
2245
+ return true
2246
+ }
2247
+
2248
+ // ── Scene verbs (display.render() pipeline) ─────────────────────────────
2249
+ // Element-id-aware wrappers over the container machinery. The base
2250
+ // SGCManager.applySceneFrame walks host-diffed frames into these; identity
2251
+ // is the element id (DeviceManager sweeps the previous app's elements on an
2252
+ // app switch, so at most one app's ids are live at a time). Containers are
2253
+ // still rect-keyed underneath — the maps pin element↔container so content
2254
+ // updates go in place and moves recreate at the SAME container id.
2255
+ private val sceneTextByElement = mutableMapOf<String, Int>()
2256
+
2257
+ // Images map to an ARRAY of containers: firmware refuses image transfers
2258
+ // into containers beyond ~200x100 (hardware-verified 2026-07-03), so bigger
2259
+ // images tile across multiple containers (400x100 → two side-by-side,
2260
+ // 150x150 → two stacked).
2261
+ private val sceneImageByElement = mutableMapOf<String, List<Int>>()
2262
+
2263
+ private data class ImageTile(val dx: Int, val dy: Int, val w: Int, val h: Int)
2264
+
2265
+ /** Tile rects (relative to the element box) with firmware-acceptable sizes. Row-major. */
2266
+ private fun imageTileRects(w: Int, h: Int): List<ImageTile> {
2267
+ val maxW = 200
2268
+ val maxH = 100
2269
+ val cols = (w + maxW - 1) / maxW
2270
+ val rows = (h + maxH - 1) / maxH
2271
+ val out = ArrayList<ImageTile>(cols * rows)
2272
+ for (r in 0 until rows) {
2273
+ for (c in 0 until cols) {
2274
+ val dx = c * maxW
2275
+ val dy = r * maxH
2276
+ out.add(ImageTile(dx, dy, minOf(maxW, w - dx), minOf(maxH, h - dy)))
2277
+ }
2278
+ }
2279
+ return out
2280
+ }
2281
+
2282
+ // Scene-frame rebuild batching. A frame with several new containers must
2283
+ // NOT rebuild the page once per create: rebuildPage sends SHUTDOWN_PAGE,
2284
+ // and 4-5 shutdown/recover cycles back-to-back are a firmware rebuild
2285
+ // storm — the G2 punishes those by dropping the BLE link (same failure
2286
+ // family as the mic-session incidents). While a frame is being applied,
2287
+ // structural changes only mark the flag; applySceneFrame does ONE rebuild
2288
+ // at the end. Both flags are only touched on displayScope (Dispatchers.Main).
2289
+ private var sceneBatchActive = false
2290
+ private var sceneStructuralPending = false
2291
+
2292
+ /** Rebuild now — or, inside an applySceneFrame batch, once at frame end. */
2293
+ private suspend fun requestPageRebuild() {
2294
+ if (sceneBatchActive) {
2295
+ sceneStructuralPending = true
2296
+ return
2297
+ }
2298
+ coalescedPageRebuild()
2299
+ }
2300
+
2301
+ /**
2302
+ * Structural change: tear down + rebuild ONLY when the page is actually
2303
+ * live. When the page is already down (mid-recovery, or a prior frame's
2304
+ * rebuild in flight), sending another SHUTDOWN_PAGE restarts the firmware
2305
+ * recovery cycle — frames arriving faster than recovery completes then keep
2306
+ * the page down FOREVER (nothing renders, image fragments all fail). A down
2307
+ * page just needs the dirty signal: the reconcile loop resurrects it once,
2308
+ * with the full current container list, which already includes every
2309
+ * structural change accumulated while it was down.
2310
+ */
2311
+ private suspend fun coalescedPageRebuild() {
2312
+ if (pageCreated) {
2313
+ rebuildPage()
2314
+ } else {
2315
+ Bridge.log("G2: structural change while page down — deferring to reconcile rebuild (no extra shutdown)")
2316
+ signalDisplayDirty()
2317
+ }
2318
+ }
2319
+
2320
+ /**
2321
+ * G2 override of the default paint-then-sweep: identical walk, but run as
2322
+ * ONE displayScope coroutine with structural rebuilds coalesced to a single
2323
+ * shutdown/rebuild per frame.
2324
+ */
2325
+ override fun applySceneFrame(frame: SceneFrame) {
2326
+ displayScope.launch {
2327
+ if (frame.replay) {
2328
+ sceneTextByElement.clear()
2329
+ sceneImageByElement.clear()
2330
+ }
2331
+ sceneBatchActive = true
2332
+ sceneStructuralPending = false
2333
+ try {
2334
+ // Type-changed ids (removed AND re-painted this frame) must be
2335
+ // removed BEFORE the paint — post-paint removal would delete
2336
+ // the just-painted replacement (registries key by id).
2337
+ val paintedIds = frame.elements.mapTo(HashSet()) { it.id }
2338
+ for (id in frame.removed) {
2339
+ if (id in paintedIds) applySceneRemove(id)
2340
+ }
2341
+ for (el in frame.elements) {
2342
+ if (!frame.replay && el.change == "unchanged") continue
2343
+ when (el.type) {
2344
+ "text" ->
2345
+ applySceneText(el.text ?: "", el.x, el.y, el.w, el.h, el.border, el.radius, el.id)
2346
+ "rect" ->
2347
+ applySceneText("", el.x, el.y, el.w, el.h, maxOf(1, el.border), el.radius, el.id)
2348
+ "image" ->
2349
+ el.data?.let { applySceneBitmap(it, el.x, el.y, el.w, el.h, el.id) }
2350
+ else -> Bridge.log("G2: applySceneFrame: unknown element type ${el.type}")
2351
+ }
2352
+ }
2353
+ for (id in frame.removed) {
2354
+ if (id !in paintedIds) applySceneRemove(id)
2355
+ }
2356
+ } finally {
2357
+ // Flush inside finally: a mid-frame exception must not strand
2358
+ // a pending structural rebuild (the page would sit stale until
2359
+ // the next frame happened to be structural).
2360
+ sceneBatchActive = false
2361
+ if (sceneStructuralPending) {
2362
+ sceneStructuralPending = false
2363
+ Bridge.log("G2: applySceneFrame — structural changes, ONE coalesced rebuild for the whole frame")
2364
+ coalescedPageRebuild()
2365
+ }
2366
+ }
2367
+ }
2368
+ }
2369
+
2370
+ override fun onSceneReplay(appId: String) {
2371
+ // A replay frame repaints from scratch through the create path; forget
2372
+ // the element mapping so creates re-match/re-register cleanly.
2373
+ sceneTextByElement.clear()
2374
+ sceneImageByElement.clear()
2375
+ }
2376
+
2377
+ override fun drawLayoutText(
2378
+ text: String,
2379
+ x: Int,
2380
+ y: Int,
2381
+ width: Int,
2382
+ height: Int,
2383
+ borderWidth: Int,
2384
+ borderRadius: Int,
2385
+ elementId: String,
2386
+ layoutId: String?
2387
+ ) {
2388
+ displayScope.launch { applySceneText(text, x, y, width, height, borderWidth, borderRadius, elementId) }
2389
+ }
2390
+
2391
+ /** Scene text upsert — runs on displayScope; rebuilds go through [requestPageRebuild]. */
2392
+ private suspend fun applySceneText(
2393
+ text: String,
2394
+ x: Int,
2395
+ y: Int,
2396
+ width: Int,
2397
+ height: Int,
2398
+ borderWidth: Int,
2399
+ borderRadius: Int,
2400
+ elementId: String
2401
+ ) {
2402
+ // Same firmware min-height guard as sendText2, applied before the
2403
+ // registry rect checks so grown rects stay consistent across calls.
2404
+ @Suppress("NAME_SHADOWING")
2405
+ val y = minOf(maxOf(y, 0), 288 - minTextContainerHeight)
2406
+ @Suppress("NAME_SHADOWING")
2407
+ val height = minOf(maxOf(height, minTextContainerHeight), 288 - y)
2408
+ run {
2409
+ val content = if (text.isEmpty()) " " else text
2410
+ val existingId = sceneTextByElement[elementId]
2411
+ if (existingId != null) {
2412
+ val idx = textContainers.indexOfFirst { it.id == existingId }
2413
+ if (idx >= 0) {
2414
+ val c = textContainers[idx]
2415
+ if (c.x == x && c.y == y && c.width == width && c.height == height &&
2416
+ c.borderWidth == borderWidth && c.borderRadius == borderRadius
2417
+ ) {
2418
+ // Content-only change: update in place — NEVER a page
2419
+ // rebuild. On G2 this is correctness, not perf: page
2420
+ // teardown couples to mic state and firmware recovery
2421
+ // storms (design doc §3.4.1 hard rule).
2422
+ c.content = content
2423
+ c.pendingSends = 1 + EVEN_HUB_RESEND_COUNT
2424
+ signalDisplayDirty()
2425
+ return@run
2426
+ }
2427
+ // Moved/restyled: recreate at the SAME container id (structural).
2428
+ textContainers[idx] =
2429
+ TextContainer(
2430
+ id = existingId,
2431
+ x = x,
2432
+ y = y,
2433
+ width = width,
2434
+ height = height,
2435
+ content = content,
2436
+ borderWidth = borderWidth,
2437
+ borderColor = defaultTextBorderColor,
2438
+ borderRadius = borderRadius,
2439
+ paddingLength = defaultTextPaddingLength,
2440
+ pendingSends = 1 + EVEN_HUB_RESEND_COUNT
2441
+ )
2442
+ signalDisplayDirty()
2443
+ requestPageRebuild()
2444
+ return@run
2445
+ }
2446
+ // Container got evicted underneath us — fall through to create.
2447
+ sceneTextByElement.remove(elementId)
2448
+ }
2449
+
2450
+ sendText2(
2451
+ content,
2452
+ x,
2453
+ y,
2454
+ width,
2455
+ height,
2456
+ borderWidth,
2457
+ defaultTextBorderColor,
2458
+ borderRadius,
2459
+ defaultTextPaddingLength
2460
+ )
2461
+ val idx =
2462
+ textContainers.indexOfFirst {
2463
+ it.matches(x, y, width, height, borderWidth, defaultTextBorderColor, borderRadius, defaultTextPaddingLength)
2464
+ }
2465
+ if (idx >= 0) {
2466
+ val cid = textContainers[idx].id
2467
+ // The container id may have been LRU-recycled from another element.
2468
+ sceneTextByElement.entries.removeAll { it.value == cid }
2469
+ sceneTextByElement[elementId] = cid
2470
+ }
2471
+ }
2472
+ }
2473
+
2474
+ override fun drawLayoutBitmap(
2475
+ base64ImageData: String,
2476
+ x: Int,
2477
+ y: Int,
2478
+ width: Int,
2479
+ height: Int,
2480
+ elementId: String,
2481
+ layoutId: String?
2482
+ ): Boolean {
2483
+ // applySceneBitmap is suspend (tile encodes + batched rebuilds); legacy
2484
+ // per-element callers fire it on displayScope like every other verb.
2485
+ displayScope.launch { applySceneBitmap(base64ImageData, x, y, width, height, elementId) }
2486
+ return true
2487
+ }
2488
+
2489
+ /** Scene bitmap upsert — rebuilds go through the batch flag (see applySceneFrame). */
2490
+ private suspend fun applySceneBitmap(
2491
+ base64ImageData: String,
2492
+ x: Int,
2493
+ y: Int,
2494
+ width: Int,
2495
+ height: Int,
2496
+ elementId: String
2497
+ ): Boolean {
2498
+ val tiles = imageTileRects(width, height)
2499
+ if (tiles.size > imageContainerIDPool.size) {
2500
+ Bridge.log(
2501
+ "G2: applySceneBitmap '$elementId' ${width}x$height needs ${tiles.size} tiles — exceeds the ${imageContainerIDPool.size}-container pool, dropping"
2502
+ )
2503
+ return false
2212
2504
  }
2213
2505
 
2506
+ // Element moved or re-tiled: blacken any containers whose rects no
2507
+ // longer belong to this element's tile set.
2508
+ sceneImageByElement[elementId]?.let { existing ->
2509
+ val wantedRects = tiles.map { listOf(x + it.dx, y + it.dy, it.w, it.h) }.toSet()
2510
+ for (cid in existing) {
2511
+ val idx = imageContainers.indexOfFirst { it.id == cid }
2512
+ if (idx >= 0) {
2513
+ val c = imageContainers[idx]
2514
+ val stillWanted = listOf(c.x, c.y, c.width, c.height) in wantedRects
2515
+ if (!stillWanted && c.bmpData.isNotEmpty()) {
2516
+ c.bmpData = ByteArray(0)
2517
+ c.dirty = true
2518
+ signalDisplayDirty()
2519
+ }
2520
+ }
2521
+ }
2522
+ }
2523
+ sceneImageByElement.remove(elementId)
2524
+
2525
+ val cids = mutableListOf<Int>()
2526
+ if (tiles.size == 1) {
2527
+ // Single container — the existing path (decode, aspect-fit, encode).
2528
+ if (!displayBitmap(base64ImageData, x, y, width, height)) return false
2529
+ val idx = imageContainers.indexOfFirst { it.matches(x, y, width, height) }
2530
+ if (idx < 0) return false
2531
+ cids.add(imageContainers[idx].id)
2532
+ } else {
2533
+ // Tiled: render the whole image to grayscale ONCE at the element
2534
+ // size, then slice per-tile rows into their own 4-bit BMPs, one
2535
+ // firmware container per tile.
2536
+ val rawData = Base64.decode(base64ImageData, Base64.DEFAULT) ?: return false
2537
+ val gray = renderG2Grayscale(rawData, width, height) ?: run {
2538
+ Bridge.log("G2: applySceneBitmap - failed to render grayscale")
2539
+ return false
2540
+ }
2541
+ Bridge.log("G2: applySceneBitmap '$elementId' ${width}x$height → ${tiles.size} tiles")
2542
+ for (t in tiles) {
2543
+ val tilePixels = ByteArray(t.w * t.h)
2544
+ for (row in 0 until t.h) {
2545
+ System.arraycopy(gray, (t.dy + row) * width + t.dx, tilePixels, row * t.w, t.w)
2546
+ }
2547
+ val bmp = build4BitBmp(tilePixels, t.w, t.h) ?: run {
2548
+ Bridge.log("G2: applySceneBitmap - tile encode failed")
2549
+ return false
2550
+ }
2551
+ val tx = x + t.dx
2552
+ val ty = y + t.dy
2553
+ val idx = imageContainers.indexOfFirst { it.matches(tx, ty, t.w, t.h) }
2554
+ if (idx >= 0) {
2555
+ imageContainers[idx].bmpData = bmp
2556
+ imageContainers[idx].dirty = true
2557
+ cids.add(imageContainers[idx].id)
2558
+ } else {
2559
+ val container = addImageContainer(tx, ty, t.w, t.h, bmp)
2560
+ val j = imageContainers.indexOfFirst { it.id == container.id }
2561
+ if (j >= 0) imageContainers[j].dirty = true
2562
+ cids.add(container.id)
2563
+ requestPageRebuild()
2564
+ }
2565
+ }
2566
+ signalDisplayDirty()
2567
+ }
2568
+
2569
+ // Container ids may have been LRU-recycled from other elements.
2570
+ val taken = cids.toSet()
2571
+ val keys = sceneImageByElement.keys.toList()
2572
+ for (key in keys) {
2573
+ val remaining = sceneImageByElement[key]?.filter { it !in taken } ?: continue
2574
+ if (remaining.isEmpty()) sceneImageByElement.remove(key) else sceneImageByElement[key] = remaining
2575
+ }
2576
+ sceneImageByElement[elementId] = cids
2214
2577
  return true
2215
2578
  }
2216
2579
 
2580
+ /**
2581
+ * Decode an image and render it to raw 8-bit grayscale at target size
2582
+ * (aspect-fit, centered on black) — the front half of [convertToG2Bmp],
2583
+ * exposed for the tiler which encodes per-tile BMPs from one render.
2584
+ */
2585
+ private fun renderG2Grayscale(data: ByteArray, targetWidth: Int, targetHeight: Int): ByteArray? {
2586
+ val srcBitmap = BitmapFactory.decodeByteArray(data, 0, data.size) ?: return null
2587
+ val scale = minOf(targetWidth.toDouble() / srcBitmap.width, targetHeight.toDouble() / srcBitmap.height)
2588
+ val scaledW = maxOf(1, (srcBitmap.width * scale).toInt())
2589
+ val scaledH = maxOf(1, (srcBitmap.height * scale).toInt())
2590
+ val offsetX = (targetWidth - scaledW) / 2
2591
+ val offsetY = (targetHeight - scaledH) / 2
2592
+
2593
+ val destBitmap = Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888)
2594
+ val canvas = Canvas(destBitmap)
2595
+ canvas.drawColor(Color.BLACK)
2596
+ canvas.drawBitmap(
2597
+ srcBitmap,
2598
+ Rect(0, 0, srcBitmap.width, srcBitmap.height),
2599
+ Rect(offsetX, offsetY, offsetX + scaledW, offsetY + scaledH),
2600
+ Paint(Paint.FILTER_BITMAP_FLAG)
2601
+ )
2602
+
2603
+ val gray = ByteArray(targetWidth * targetHeight)
2604
+ for (yy in 0 until targetHeight) {
2605
+ for (xx in 0 until targetWidth) {
2606
+ val pixel = destBitmap.getPixel(xx, yy)
2607
+ val v = (Color.red(pixel) * 299 + Color.green(pixel) * 587 + Color.blue(pixel) * 114) / 1000
2608
+ gray[yy * targetWidth + xx] = v.toByte()
2609
+ }
2610
+ }
2611
+ srcBitmap.recycle()
2612
+ destBitmap.recycle()
2613
+ return gray
2614
+ }
2615
+
2616
+ override fun removeLayoutElement(elementId: String, layoutId: String?) {
2617
+ displayScope.launch { applySceneRemove(elementId) }
2618
+ }
2619
+
2620
+ /**
2621
+ * Scene element removal — STRUCTURAL. Blanked-in-place containers still
2622
+ * RENDER (the firmware draws a cursor-like mark at a whitespace container's
2623
+ * content origin — the stray tick; legacy never saw it because its only
2624
+ * container's origin sat above the visible eyebox). A removed element
2625
+ * leaves the page: drop it from the tracked list (freeing the pool id) and
2626
+ * mark the frame structural — the batched frame-end rebuild recreates the
2627
+ * page without it. Never a per-remove shutdown (mic coupling).
2628
+ */
2629
+ private suspend fun applySceneRemove(elementId: String) {
2630
+ sceneTextByElement.remove(elementId)?.let { id ->
2631
+ val idx = textContainers.indexOfFirst { it.id == id }
2632
+ if (idx >= 0) {
2633
+ textContainers.removeAt(idx)
2634
+ requestPageRebuild()
2635
+ }
2636
+ }
2637
+ sceneImageByElement.remove(elementId)?.let { ids ->
2638
+ for (id in ids) {
2639
+ val idx = imageContainers.indexOfFirst { it.id == id }
2640
+ if (idx >= 0 && imageContainers[idx].bmpData.isNotEmpty()) {
2641
+ imageContainers[idx].bmpData = ByteArray(0)
2642
+ imageContainers[idx].dirty = true
2643
+ }
2644
+ }
2645
+ signalDisplayDirty()
2646
+ requestPageRebuild()
2647
+ }
2648
+ }
2649
+
2650
+ /**
2651
+ * Sweep a set of scene elements as ONE batched structural change (called by
2652
+ * DeviceManager on scene→legacy and cross-app transitions, outside any
2653
+ * applySceneFrame batch — without batching, each remove would rebuild).
2654
+ */
2655
+ override fun clearSceneElements(elementIds: List<String>) {
2656
+ displayScope.launch {
2657
+ sceneBatchActive = true
2658
+ try {
2659
+ for (id in elementIds) applySceneRemove(id)
2660
+ } finally {
2661
+ sceneBatchActive = false
2662
+ }
2663
+ if (sceneStructuralPending) {
2664
+ sceneStructuralPending = false
2665
+ coalescedPageRebuild()
2666
+ }
2667
+ }
2668
+ }
2669
+
2217
2670
  /**
2218
2671
  * Add a new image container for the rect, evicting the oldest when the list is full (max 4).
2219
2672
  * Returns the newly tracked container (with an assigned ID from the pool).
@@ -2684,10 +3137,30 @@ class G2 : SGCManager() {
2684
3137
  // ---------- Private Display Helpers ----------
2685
3138
 
2686
3139
  private fun createPageWithContainers() {
2687
- // build the page's text containers from the live tracked list.
2688
- val textContainerProps: List<ByteArray> = ArrayList<ByteArray>(textContainers.size).apply {
2689
- for (i in textContainers.indices) {
2690
- val c = textContainers[i]
3140
+ // Dedicated event-capture container: id 0, 1x1, borderless, empty the
3141
+ // designated event-capture slot per the RE demos ("container 0 is
3142
+ // event-capture"). Touch events keep flowing through it, and no REAL
3143
+ // container carries the flag: marking whichever container happened to
3144
+ // be first painted a visible artifact once pages stopped being one
3145
+ // full-screen box (the stray persistent line).
3146
+ val textContainerProps: List<ByteArray> = ArrayList<ByteArray>(textContainers.size + 1).apply {
3147
+ add(
3148
+ EvenHubProto.textContainerProperty(
3149
+ x = 0,
3150
+ y = 0,
3151
+ width = 1,
3152
+ height = 1,
3153
+ borderWidth = 0,
3154
+ borderColor = 0,
3155
+ borderRadius = 0,
3156
+ paddingLength = 0,
3157
+ containerID = 0,
3158
+ containerName = "evt-0",
3159
+ isEventCapture = true,
3160
+ content = ""
3161
+ )
3162
+ )
3163
+ for (c in textContainers) {
2691
3164
  add(
2692
3165
  EvenHubProto.textContainerProperty(
2693
3166
  x = c.x,
@@ -2700,7 +3173,7 @@ class G2 : SGCManager() {
2700
3173
  paddingLength = c.paddingLength,
2701
3174
  containerID = c.id,
2702
3175
  containerName = c.name,
2703
- isEventCapture = i == 0,// the first container is the event capture container
3176
+ isEventCapture = false,
2704
3177
  content = c.content
2705
3178
  )
2706
3179
  )