@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
@@ -1622,6 +1622,43 @@ class G2: NSObject, SGCManager {
1622
1622
  /// Fixed pool of container IDs the page protocol expects.
1623
1623
  private let imageContainerIDPool: [Int32] = [10, 11, 12, 13]
1624
1624
  private let textContainerIDPool: [Int32] = [1, 2, 3, 4, 5, 6]
1625
+
1626
+ /// One firmware text line (hardware-calibrated 2026-07-03: 28px overflows,
1627
+ /// 40px clean). Text containers are silently grown to at least this.
1628
+ private static let minTextContainerHeight: Int32 = 40
1629
+
1630
+ /// Scene-verb registries (display.render() pipeline): element id → container
1631
+ /// id(s). Containers stay rect-keyed underneath — these pin element↔container
1632
+ /// so content updates go in place and moves recreate at the SAME container
1633
+ /// id. Images map to an ARRAY of containers: firmware refuses image
1634
+ /// transfers into containers beyond ~200x100, so bigger images tile across
1635
+ /// multiple containers (e.g. 400x100 → two side-by-side, 150x150 → two
1636
+ /// stacked). At most one app's ids are live at a time (DeviceManager sweeps
1637
+ /// the previous app's elements on an app switch).
1638
+ private var sceneTextByElement: [String: Int32] = [:]
1639
+ private var sceneImageByElement: [String: [Int32]] = [:]
1640
+
1641
+ /// Max image-container size the firmware accepts pixels for
1642
+ /// (hardware-verified 2026-07-03: 150x150 refused, 100x100 clean; 200x100
1643
+ /// is the firmware default container and the old docs' boundary).
1644
+ private static let maxImageTileW: Int32 = 200
1645
+ private static let maxImageTileH: Int32 = 100
1646
+
1647
+ /// Tile rects (relative to the element box) covering w×h with tiles the
1648
+ /// firmware will accept. Row-major.
1649
+ private static func imageTileRects(w: Int32, h: Int32) -> [(dx: Int32, dy: Int32, w: Int32, h: Int32)] {
1650
+ let cols = Int((w + maxImageTileW - 1) / maxImageTileW)
1651
+ let rows = Int((h + maxImageTileH - 1) / maxImageTileH)
1652
+ var rects: [(Int32, Int32, Int32, Int32)] = []
1653
+ for r in 0 ..< rows {
1654
+ for c in 0 ..< cols {
1655
+ let dx = Int32(c) * maxImageTileW
1656
+ let dy = Int32(r) * maxImageTileH
1657
+ rects.append((dx, dy, min(maxImageTileW, w - dx), min(maxImageTileH, h - dy)))
1658
+ }
1659
+ }
1660
+ return rects
1661
+ }
1625
1662
  private static let defaultImgContainer = (
1626
1663
  x: Int32(188), y: Int32(44), width: Int32(200), height: Int32(100)
1627
1664
  )
@@ -2149,9 +2186,16 @@ class G2: NSObject, SGCManager {
2149
2186
  }
2150
2187
 
2151
2188
  let rx = x ?? G2.defaultTextContainer.x
2152
- let ry = y ?? G2.defaultTextContainer.y
2189
+ // Legacy callers can hand us y beyond the canvas — clamp it first so
2190
+ // the height formula below can't go negative/below one fw line.
2191
+ let ry = min(max(y ?? G2.defaultTextContainer.y, 0), 288 - G2.minTextContainerHeight)
2153
2192
  let rw = width ?? G2.defaultTextContainer.width
2154
- let rh = height ?? G2.defaultTextContainer.height
2193
+ // Firmware guard (hardware-calibrated): a text container shorter than
2194
+ // one firmware line (~40px; 28px overflowed) makes the fw draw its
2195
+ // overflow-indicator tick. Silently grow to one line, clamped to the
2196
+ // canvas. Content-independent so rect keys stay stable across updates.
2197
+ let rhRequested = height ?? G2.defaultTextContainer.height
2198
+ let rh = min(max(rhRequested, G2.minTextContainerHeight), 288 - ry)
2155
2199
  let borderWidth = borderWidth ?? G2.defaultTextContainer.borderWidth
2156
2200
  let borderColor = borderColor ?? G2.defaultTextContainer.borderColor
2157
2201
  let borderRadius = borderRadius ?? G2.defaultTextContainer.borderRadius
@@ -2199,7 +2243,7 @@ class G2: NSObject, SGCManager {
2199
2243
  textContainers[j].pendingSends = 1 + EVEN_HUB_RESEND_COUNT
2200
2244
  }
2201
2245
  signalDisplayDirty()
2202
- await rebuildPage()
2246
+ await requestPageRebuild()
2203
2247
  }
2204
2248
 
2205
2249
  func sendDoubleTextWall(_ top: String, _ bottom: String) async {
@@ -2209,13 +2253,342 @@ class G2: NSObject, SGCManager {
2209
2253
  await sendTextWall(combined)
2210
2254
  }
2211
2255
 
2256
+ // MARK: - Scene verbs (display.render() pipeline)
2257
+
2258
+ /// Scene-frame rebuild batching. A frame with several new containers must
2259
+ /// NOT rebuild the page once per create: rebuildPage sends SHUTDOWN_PAGE,
2260
+ /// and 4-5 shutdown/recover cycles back-to-back are a firmware rebuild
2261
+ /// storm — the G2 punishes those by dropping the BLE link (same failure
2262
+ /// family as the mic-session incidents). While a frame is being applied,
2263
+ /// structural changes only mark this flag; applySceneFrame does ONE rebuild
2264
+ /// at the end.
2265
+ private var sceneBatchDepth = 0
2266
+ private var sceneStructuralPending = false
2267
+
2268
+ /// Rebuild now — or, inside an applySceneFrame batch, once at frame end.
2269
+ private func requestPageRebuild() async {
2270
+ if sceneBatchDepth > 0 {
2271
+ sceneStructuralPending = true
2272
+ return
2273
+ }
2274
+ await coalescedPageRebuild()
2275
+ }
2276
+
2277
+ /// Structural change: tear down + rebuild ONLY when the page is actually
2278
+ /// live. When the page is already down (mid-recovery, or a prior frame's
2279
+ /// rebuild in flight), sending another SHUTDOWN_PAGE restarts the firmware
2280
+ /// recovery cycle — frames arriving faster than recovery completes then
2281
+ /// keep the page down FOREVER (nothing renders, image fragments all fail).
2282
+ /// A down page just needs the dirty signal: the reconcile loop resurrects
2283
+ /// it once, with the full current container list, which already includes
2284
+ /// every structural change accumulated while it was down.
2285
+ private func coalescedPageRebuild() async {
2286
+ if pageCreated {
2287
+ await rebuildPage()
2288
+ } else {
2289
+ Bridge.log("G2: structural change while page down — deferring to reconcile rebuild (no extra shutdown)")
2290
+ signalDisplayDirty()
2291
+ }
2292
+ }
2293
+
2294
+ /// G2 override of the default paint-then-sweep: identical walk, but
2295
+ /// structural rebuilds are coalesced to a single shutdown/rebuild per frame.
2296
+ func applySceneFrame(_ frame: SceneFrame) async {
2297
+ if frame.replay {
2298
+ await onSceneReplay(frame.appId)
2299
+ }
2300
+ sceneBatchDepth += 1
2301
+ sceneStructuralPending = false
2302
+ // Type-changed ids (removed AND re-painted this frame) must be removed
2303
+ // BEFORE the paint — post-paint removal would delete the just-painted
2304
+ // replacement (registries key by id).
2305
+ let paintedIds = Set(frame.elements.map { $0.id })
2306
+ for id in frame.removed where paintedIds.contains(id) {
2307
+ await removeLayoutElement(id, layoutId: frame.appId)
2308
+ }
2309
+ for el in frame.elements {
2310
+ if !frame.replay, el.change == "unchanged" { continue }
2311
+ switch el.type {
2312
+ case "text":
2313
+ await drawLayoutText(
2314
+ el.text ?? "", x: el.x, y: el.y, width: el.w, height: el.h,
2315
+ borderWidth: el.border, borderRadius: el.radius,
2316
+ elementId: el.id, layoutId: frame.appId
2317
+ )
2318
+ case "rect":
2319
+ await drawLayoutText(
2320
+ "", x: el.x, y: el.y, width: el.w, height: el.h,
2321
+ borderWidth: max(1, el.border), borderRadius: el.radius,
2322
+ elementId: el.id, layoutId: frame.appId
2323
+ )
2324
+ case "image":
2325
+ if let data = el.data {
2326
+ _ = await drawLayoutBitmap(
2327
+ base64ImageData: data, x: el.x, y: el.y, width: el.w, height: el.h,
2328
+ elementId: el.id, layoutId: frame.appId
2329
+ )
2330
+ }
2331
+ default:
2332
+ Bridge.log("G2: applySceneFrame: unknown element type \(el.type)")
2333
+ }
2334
+ }
2335
+ for id in frame.removed where !paintedIds.contains(id) {
2336
+ await removeLayoutElement(id, layoutId: frame.appId)
2337
+ }
2338
+ sceneBatchDepth -= 1
2339
+ if sceneStructuralPending {
2340
+ sceneStructuralPending = false
2341
+ Bridge.log("G2: applySceneFrame — structural changes, ONE coalesced rebuild for the whole frame")
2342
+ await coalescedPageRebuild()
2343
+ }
2344
+ }
2345
+
2346
+ func onSceneReplay(_: String) async {
2347
+ // A replay frame repaints from scratch through the create path; forget
2348
+ // the element mapping so creates re-match/re-register cleanly.
2349
+ sceneTextByElement.removeAll()
2350
+ sceneImageByElement.removeAll()
2351
+ }
2352
+
2353
+ func drawLayoutText(
2354
+ _ text: String, x: Int32, y: Int32, width: Int32, height: Int32,
2355
+ borderWidth: Int32, borderRadius: Int32, elementId: String, layoutId _: String?
2356
+ ) async {
2357
+ // Same firmware min-height guard as sendTextAt, applied before the
2358
+ // registry rect checks so grown rects stay consistent across calls.
2359
+ // y is clamped first so the height term can't go negative.
2360
+ let y = min(max(y, 0), 288 - G2.minTextContainerHeight)
2361
+ let height = min(max(height, G2.minTextContainerHeight), 288 - y)
2362
+ let content = text.isEmpty ? " " : text
2363
+ if let existingId = sceneTextByElement[elementId] {
2364
+ if let i = textContainers.firstIndex(where: { $0.id == existingId }) {
2365
+ let c = textContainers[i]
2366
+ if c.x == x, c.y == y, c.width == width, c.height == height,
2367
+ c.borderWidth == borderWidth, c.borderRadius == borderRadius
2368
+ {
2369
+ // Content-only change: update in place — NEVER a page
2370
+ // rebuild. On G2 this is correctness, not perf: page
2371
+ // teardown couples to mic state and firmware recovery
2372
+ // storms (design doc §3.4.1 hard rule).
2373
+ textContainers[i].content = content
2374
+ textContainers[i].pendingSends = 1 + EVEN_HUB_RESEND_COUNT
2375
+ signalDisplayDirty()
2376
+ return
2377
+ }
2378
+ // Moved/restyled: recreate at the SAME container id (structural).
2379
+ textContainers[i] = TextContainer(
2380
+ id: existingId, x: x, y: y, width: width, height: height, content: content,
2381
+ borderWidth: borderWidth, borderColor: G2.defaultTextContainer.borderColor,
2382
+ borderRadius: borderRadius, paddingLength: G2.defaultTextContainer.paddingLength,
2383
+ pendingSends: 1 + EVEN_HUB_RESEND_COUNT
2384
+ )
2385
+ signalDisplayDirty()
2386
+ await requestPageRebuild()
2387
+ return
2388
+ }
2389
+ // Container got evicted underneath us — fall through to create.
2390
+ sceneTextByElement.removeValue(forKey: elementId)
2391
+ }
2392
+
2393
+ await sendTextAt(
2394
+ content, x: x, y: y, width: width, height: height,
2395
+ borderWidth: borderWidth, borderRadius: borderRadius
2396
+ )
2397
+ if let i = textContainers.firstIndex(where: {
2398
+ $0.matches(
2399
+ x: x, y: y, width: width, height: height, borderWidth: borderWidth,
2400
+ borderColor: G2.defaultTextContainer.borderColor, borderRadius: borderRadius,
2401
+ paddingLength: G2.defaultTextContainer.paddingLength)
2402
+ }) {
2403
+ let cid = textContainers[i].id
2404
+ // The container id may have been LRU-recycled from another element.
2405
+ sceneTextByElement = sceneTextByElement.filter { $0.value != cid }
2406
+ sceneTextByElement[elementId] = cid
2407
+ }
2408
+ }
2409
+
2410
+ func drawLayoutBitmap(
2411
+ base64ImageData: String, x: Int32, y: Int32, width: Int32, height: Int32,
2412
+ elementId: String, layoutId _: String?
2413
+ ) async -> Bool {
2414
+ let tiles = G2.imageTileRects(w: width, h: height)
2415
+ guard tiles.count <= imageContainerIDPool.count else {
2416
+ Bridge.log(
2417
+ "G2: drawLayoutBitmap '\(elementId)' \(width)x\(height) needs \(tiles.count) tiles — exceeds the \(imageContainerIDPool.count)-container pool, dropping"
2418
+ )
2419
+ return false
2420
+ }
2421
+
2422
+ // Element moved or re-tiled: blacken any containers whose rects no
2423
+ // longer belong to this element's tile set, then let the tile upserts
2424
+ // reuse/place the rest.
2425
+ if let existing = sceneImageByElement[elementId] {
2426
+ let wantedRects = tiles.map { t in (x + t.dx, y + t.dy, t.w, t.h) }
2427
+ for cid in existing {
2428
+ if let i = imageContainers.firstIndex(where: { $0.id == cid }) {
2429
+ let c = imageContainers[i]
2430
+ let stillWanted = wantedRects.contains { $0 == (c.x, c.y, c.width, c.height) }
2431
+ if !stillWanted, !c.bmpData.isEmpty {
2432
+ imageContainers[i].bmpData = Data()
2433
+ imageContainers[i].dirty = true
2434
+ signalDisplayDirty()
2435
+ }
2436
+ }
2437
+ }
2438
+ }
2439
+ sceneImageByElement.removeValue(forKey: elementId)
2440
+
2441
+ var cids: [Int32] = []
2442
+ if tiles.count == 1 {
2443
+ // Single container — the existing path (decode, aspect-fit, encode).
2444
+ let ok = await displayBitmap(
2445
+ base64ImageData: base64ImageData, x: x, y: y, width: width, height: height
2446
+ )
2447
+ guard ok,
2448
+ let i = imageContainers.firstIndex(where: {
2449
+ $0.matches(x: x, y: y, width: width, height: height)
2450
+ })
2451
+ else { return false }
2452
+ cids = [imageContainers[i].id]
2453
+ } else {
2454
+ // Tiled: render the whole image to grayscale ONCE at the element
2455
+ // size, then slice per-tile rows into their own 4-bit BMPs, one
2456
+ // firmware container per tile.
2457
+ guard let rawData = Data(base64Encoded: base64ImageData) else {
2458
+ Bridge.log("G2: drawLayoutBitmap - failed to decode base64")
2459
+ return false
2460
+ }
2461
+ guard let gray = renderG2Grayscale(rawData, targetWidth: Int(width), targetHeight: Int(height)) else {
2462
+ Bridge.log("G2: drawLayoutBitmap - failed to render grayscale")
2463
+ return false
2464
+ }
2465
+ Bridge.log("G2: drawLayoutBitmap '\(elementId)' \(width)x\(height) → \(tiles.count) tiles")
2466
+ for t in tiles {
2467
+ var tilePixels = Data(capacity: Int(t.w * t.h))
2468
+ for row in 0 ..< Int(t.h) {
2469
+ let start = (Int(t.dy) + row) * Int(width) + Int(t.dx)
2470
+ tilePixels.append(gray.subdata(in: (gray.startIndex + start)..<(gray.startIndex + start + Int(t.w))))
2471
+ }
2472
+ guard let bmp = build4BitBmp(grayscalePixels: tilePixels, width: Int(t.w), height: Int(t.h)) else {
2473
+ Bridge.log("G2: drawLayoutBitmap - tile encode failed")
2474
+ return false
2475
+ }
2476
+ let tx = x + t.dx
2477
+ let ty = y + t.dy
2478
+ if let i = imageContainers.firstIndex(where: { $0.matches(x: tx, y: ty, width: t.w, height: t.h) }) {
2479
+ imageContainers[i].bmpData = bmp
2480
+ imageContainers[i].dirty = true
2481
+ cids.append(imageContainers[i].id)
2482
+ } else {
2483
+ let container = addImageContainer(x: tx, y: ty, width: t.w, height: t.h, bmpData: bmp)
2484
+ if let j = imageContainers.firstIndex(where: { $0.id == container.id }) {
2485
+ imageContainers[j].dirty = true
2486
+ }
2487
+ cids.append(container.id)
2488
+ await requestPageRebuild()
2489
+ }
2490
+ }
2491
+ signalDisplayDirty()
2492
+ }
2493
+
2494
+ // Container ids may have been LRU-recycled from other elements.
2495
+ let taken = Set(cids)
2496
+ for (key, value) in sceneImageByElement where value.contains(where: taken.contains) {
2497
+ sceneImageByElement[key] = value.filter { !taken.contains($0) }
2498
+ if sceneImageByElement[key]?.isEmpty == true {
2499
+ sceneImageByElement.removeValue(forKey: key)
2500
+ }
2501
+ }
2502
+ sceneImageByElement[elementId] = cids
2503
+ return true
2504
+ }
2505
+
2506
+ func removeLayoutElement(_ elementId: String, layoutId _: String?) async {
2507
+ // STRUCTURAL removal: blanked-in-place containers still RENDER (the
2508
+ // firmware draws a cursor-like mark at a whitespace container's content
2509
+ // origin — the long-hunted stray tick; legacy never saw it because its
2510
+ // only container's origin sat above the visible eyebox). So a removed
2511
+ // element leaves the page entirely: blank it for the live page, drop it
2512
+ // from the tracked list (freeing the pool id), and mark the frame
2513
+ // structural — the batched frame-end rebuild recreates the page without
2514
+ // it. Never a per-remove shutdown (mic coupling).
2515
+ if let id = sceneTextByElement.removeValue(forKey: elementId),
2516
+ let i = textContainers.firstIndex(where: { $0.id == id })
2517
+ {
2518
+ textContainers.remove(at: i)
2519
+ await requestPageRebuild()
2520
+ }
2521
+ if let ids = sceneImageByElement.removeValue(forKey: elementId) {
2522
+ for id in ids {
2523
+ if let i = imageContainers.firstIndex(where: { $0.id == id }),
2524
+ !imageContainers[i].bmpData.isEmpty
2525
+ {
2526
+ imageContainers[i].bmpData = Data()
2527
+ imageContainers[i].dirty = true
2528
+ }
2529
+ }
2530
+ signalDisplayDirty()
2531
+ await requestPageRebuild()
2532
+ }
2533
+ }
2534
+
2535
+ /// Sweep a set of scene elements as ONE batched structural change (called
2536
+ /// by DeviceManager on scene→legacy and cross-app transitions, outside any
2537
+ /// applySceneFrame batch — without batching, each remove would rebuild).
2538
+ func clearSceneElements(_ elementIds: [String]) async {
2539
+ sceneBatchDepth += 1
2540
+ for id in elementIds {
2541
+ await removeLayoutElement(id, layoutId: nil)
2542
+ }
2543
+ sceneBatchDepth -= 1
2544
+ if sceneStructuralPending {
2545
+ sceneStructuralPending = false
2546
+ await coalescedPageRebuild()
2547
+ }
2548
+ }
2549
+
2550
+ /// Decode an image and render it to raw 8-bit grayscale at target size
2551
+ /// (aspect-fit, centered on black) — the front half of `convertToG2Bmp`,
2552
+ /// exposed for the tiler which encodes per-tile BMPs from one render.
2553
+ private func renderG2Grayscale(_ data: Data, targetWidth: Int, targetHeight: Int) -> Data? {
2554
+ guard let image = UIImage(data: data), let cgImage = image.cgImage else { return nil }
2555
+ let scale = min(
2556
+ Double(targetWidth) / Double(cgImage.width), Double(targetHeight) / Double(cgImage.height)
2557
+ )
2558
+ let scaledW = max(1, Int(Double(cgImage.width) * scale))
2559
+ let scaledH = max(1, Int(Double(cgImage.height) * scale))
2560
+ let offsetX = (targetWidth - scaledW) / 2
2561
+ let offsetY = (targetHeight - scaledH) / 2
2562
+
2563
+ var pixels = Data(count: targetWidth * targetHeight)
2564
+ let drawn: Bool = pixels.withUnsafeMutableBytes { buf in
2565
+ guard
2566
+ let ctx = CGContext(
2567
+ data: buf.baseAddress,
2568
+ width: targetWidth,
2569
+ height: targetHeight,
2570
+ bitsPerComponent: 8,
2571
+ bytesPerRow: targetWidth,
2572
+ space: CGColorSpaceCreateDeviceGray(),
2573
+ bitmapInfo: CGImageAlphaInfo.none.rawValue
2574
+ )
2575
+ else { return false }
2576
+ ctx.setFillColor(gray: 0, alpha: 1)
2577
+ ctx.fill(CGRect(x: 0, y: 0, width: targetWidth, height: targetHeight))
2578
+ ctx.interpolationQuality = .high
2579
+ ctx.draw(cgImage, in: CGRect(x: offsetX, y: offsetY, width: scaledW, height: scaledH))
2580
+ return true
2581
+ }
2582
+ return drawn ? pixels : nil
2583
+ }
2584
+
2212
2585
  func clearDisplay() {
2213
2586
  Bridge.log("G2: clearDisplay()")
2214
2587
  // Blank the text in place — do NOT shut down + rebuild the page. A teardown kills audio
2215
2588
  // and triggers a firmware systemExit→recovery→rebuild; the cloud sends clearDisplay in
2216
2589
  // bursts, so that turned into a rebuild storm. The reconcile loop pushes the blanked text.
2217
2590
  for i in textContainers.indices {
2218
- textContainers[i].content = " "
2591
+ textContainers[i].content = "\n"
2219
2592
  textContainers[i].pendingSends = 1 + EVEN_HUB_RESEND_COUNT
2220
2593
  }
2221
2594
  for i in imageContainers.indices where !imageContainers[i].bmpData.isEmpty {
@@ -2227,6 +2600,27 @@ class G2: NSObject, SGCManager {
2227
2600
  imageContainers[i].dirty = true
2228
2601
  }
2229
2602
  signalDisplayDirty()
2603
+
2604
+ // Purge scene HUD containers structurally: a blanked small box still
2605
+ // renders the firmware's overflow tick (a "\n" husk is two empty lines
2606
+ // in a one-line box) and corrupts whatever app draws next. Full-canvas
2607
+ // containers stay blanked-in-place — the shipped caption-gap behavior,
2608
+ // storm-safe (no rebuild on ordinary clears). Only when positioned HUD
2609
+ // husks exist (app exit) do we drop them + rebuild ONCE.
2610
+ let isFullCanvas: (TextContainer) -> Bool = {
2611
+ $0.x == 0 && $0.y == 0 && $0.width >= G2.defaultTextContainer.width
2612
+ && $0.height >= G2.defaultTextContainer.height
2613
+ }
2614
+ let huskIds = Set(textContainers.filter { !isFullCanvas($0) }.map { $0.id })
2615
+ if !huskIds.isEmpty {
2616
+ textContainers.removeAll { huskIds.contains($0.id) }
2617
+ sceneTextByElement = sceneTextByElement.filter { !huskIds.contains($0.value) }
2618
+ sceneImageByElement.removeAll()
2619
+ Bridge.log("G2: clearDisplay() — purging \(huskIds.count) positioned husk container(s), one rebuild")
2620
+ Task { [weak self] in
2621
+ await self?.coalescedPageRebuild()
2622
+ }
2623
+ }
2230
2624
  }
2231
2625
 
2232
2626
  /// Send a bitmap to an image container as fragmented updateImageRawData packets.
@@ -2390,7 +2784,7 @@ class G2: NSObject, SGCManager {
2390
2784
  // A brand-new page needs its structure built before the loop can push pixels; the dirty
2391
2785
  // flag stays set so the reconcile loop sends the image once the page exists.
2392
2786
  if !pageCreated {
2393
- await rebuildPage()
2787
+ await requestPageRebuild()
2394
2788
  }
2395
2789
  } else {
2396
2790
  let container = addImageContainer(x: rx, y: ry, width: rw, height: rh, bmpData: bmpData)
@@ -2402,7 +2796,7 @@ class G2: NSObject, SGCManager {
2402
2796
  "G2: displayBitmap() - added container \(container.id) for rect \(rx),\(ry) \(rw)x\(rh), rebuilding page"
2403
2797
  )
2404
2798
  // New container changes page structure: rebuild it, then the loop sends the pixels.
2405
- await rebuildPage()
2799
+ await requestPageRebuild()
2406
2800
  }
2407
2801
  return true
2408
2802
  }
@@ -2438,6 +2832,11 @@ class G2: NSObject, SGCManager {
2438
2832
  // Text: synchronous, no ACK. Send one update per container with pending sends and decrement.
2439
2833
  for i in textContainers.indices where textContainers[i].pendingSends > 0 {
2440
2834
  let container = textContainers[i]
2835
+ // Byte-exact content log (debugDescription escapes \n and non-ASCII)
2836
+ // — artifact forensics: match on-glass rendering to what was sent.
2837
+ Bridge.log(
2838
+ "G2: updateText id=\(container.id) rect=\(container.x),\(container.y) \(container.width)x\(container.height) bytes=\(container.content.utf8.count) content=\(container.content.debugDescription)"
2839
+ )
2441
2840
  let msg = EvenHubProto.updateTextMessage(
2442
2841
  containerID: container.id,
2443
2842
  contentOffset: 0,
@@ -2996,21 +3395,47 @@ class G2: NSObject, SGCManager {
2996
3395
  // MARK: - Private Display Helpers
2997
3396
 
2998
3397
  private func createPageWithContainers() {
2999
- // build the page's text containers from the live tracked list.
3000
- // iterate by index not using map:
3001
- var textContainerProps: [Data] = []
3002
- for (index, c) in textContainers.enumerated() {
3398
+ // Dedicated event-capture container: id 0, 1x1, borderless, empty the
3399
+ // designated event-capture slot per the RE demos ("container 0 is
3400
+ // event-capture"). Touch events keep flowing through it, and no REAL
3401
+ // container carries the flag: marking whichever container happened to
3402
+ // be first (the old behavior) painted a visible artifact once pages
3403
+ // stopped being one full-screen box — the stray line Alex saw
3404
+ // persisting across miniapps.
3405
+ var textContainerProps: [Data] = [
3406
+ EvenHubProto.textContainerProperty(
3407
+ x: 0, y: 0, width: 1, height: 1,
3408
+ borderWidth: 0, borderColor: 0, borderRadius: 0,
3409
+ paddingLength: 0, containerID: 0,
3410
+ containerName: "evt-0", isEventCapture: true,
3411
+ content: ""
3412
+ ),
3413
+ ]
3414
+ for c in textContainers {
3003
3415
  textContainerProps.append(
3004
3416
  EvenHubProto.textContainerProperty(
3005
3417
  x: c.x, y: c.y, width: c.width, height: c.height,
3006
3418
  borderWidth: c.borderWidth, borderColor: c.borderColor,
3007
3419
  borderRadius: c.borderRadius,
3008
3420
  paddingLength: c.paddingLength, containerID: c.id,
3009
- containerName: c.name, isEventCapture: index == 0, // the first container is the event capture container
3421
+ containerName: c.name, isEventCapture: false,
3010
3422
  content: c.content
3011
3423
  ))
3012
3424
  }
3013
3425
 
3426
+ // Page-composition dump: one line per container on every page create,
3427
+ // so an on-glass artifact can be matched to exactly what we sent.
3428
+ for c in textContainers {
3429
+ Bridge.log(
3430
+ "G2: page-comp text id=\(c.id) rect=\(c.x),\(c.y) \(c.width)x\(c.height) border=\(c.borderWidth)/\(c.borderRadius) pad=\(c.paddingLength) contentLen=\(c.content.count)"
3431
+ )
3432
+ }
3433
+ for c in imageContainers {
3434
+ Bridge.log(
3435
+ "G2: page-comp image id=\(c.id) rect=\(c.x),\(c.y) \(c.width)x\(c.height) bytes=\(c.bmpData.count)"
3436
+ )
3437
+ }
3438
+
3014
3439
  // iterate all image containers, remove any entrys with duplicate id's, and ensure the ids in the imageContainerIDPool is up-to-date:
3015
3440
  var seenIDs = Set<Int32>()
3016
3441
  imageContainers = imageContainers.filter { c in
@@ -3761,6 +4186,8 @@ class G2: NSObject, SGCManager {
3761
4186
  handleEvenAIResponse(result.payload)
3762
4187
  case ServiceID.evenHubCtrl.rawValue:
3763
4188
  handleEvenHubCtrlResponse(result.payload)
4189
+ case ServiceID.notification.rawValue:
4190
+ handleNotificationResponse(result.payload)
3764
4191
  default:
3765
4192
  Bridge.log(
3766
4193
  "G2: Unhandled service \(result.serviceId) (\(result.payload.count) bytes): \(result.payload.map { String(format: "%02X", $0) }.joined())"
@@ -3768,6 +4195,86 @@ class G2: NSObject, SGCManager {
3768
4195
  }
3769
4196
  }
3770
4197
 
4198
+ /// Notification service (0x04 — UI_FOREGROUND_NOTIFICATION_ID).
4199
+ ///
4200
+ /// On iOS the G2 reads notifications straight from the phone via ANCS (the
4201
+ /// system BLE notification service — no app code involved), and reports
4202
+ /// notification activity to us on this channel as a NotificationDataPackage
4203
+ /// (notification.proto from the RE work):
4204
+ /// field 1: commandId (1=CTRL, 2=NOTIFICATION_IOS, 3=WHITELIST_CTRL, 161=COMM_RSP)
4205
+ /// field 3: NotificationControl {notifEnable, autoDispEnable, dispTime, avoidDisturbEnable}
4206
+ /// field 4: NotificationIOS {appID, displayName} — the source app of a notification
4207
+ /// field 6: NotificationWhitelistCtrl {whitelistDisable}
4208
+ ///
4209
+ /// Log-only for now: this is the observability hook for the notification
4210
+ /// relay work (the CTRL/WHITELIST commands are also how the app would
4211
+ /// control notification behavior on-glass).
4212
+ private func handleNotificationResponse(_ payload: Data) {
4213
+ var reader = ProtobufReader(payload)
4214
+ let fields = reader.parseFields()
4215
+ let cmd = fields[1] as? Int32 ?? 0
4216
+
4217
+ var detail = ""
4218
+ if let iosData = fields[4] as? Data {
4219
+ var ios = ProtobufReader(iosData)
4220
+ let iosFields = ios.parseFields()
4221
+ let appID = (iosFields[1] as? Data).flatMap { String(data: $0, encoding: .utf8) }?
4222
+ .trimmingCharacters(in: CharacterSet(charactersIn: "\0")) ?? "?"
4223
+ let name = (iosFields[2] as? Data).flatMap { String(data: $0, encoding: .utf8) }?
4224
+ .trimmingCharacters(in: CharacterSet(charactersIn: "\0")) ?? "?"
4225
+ detail = " ios={app: \(appID), name: \(name)}"
4226
+ }
4227
+ if let ctrlData = fields[3] as? Data {
4228
+ var ctrl = ProtobufReader(ctrlData)
4229
+ let ctrlFields = ctrl.parseFields()
4230
+ detail += " ctrl={enable: \(ctrlFields[1] as? Int32 ?? -1), autoDisp: \(ctrlFields[2] as? Int32 ?? -1), dispTime: \(ctrlFields[3] as? Int32 ?? -1), dnd: \(ctrlFields[5] as? Int32 ?? -1)}"
4231
+ }
4232
+ if let wlData = fields[6] as? Data {
4233
+ var wl = ProtobufReader(wlData)
4234
+ let wlFields = wl.parseFields()
4235
+ detail += " whitelist={disable: \(wlFields[1] as? Int32 ?? -1)}"
4236
+ }
4237
+
4238
+ let cmdName: String
4239
+ switch cmd {
4240
+ case 1: cmdName = "CTRL"
4241
+ case 2: cmdName = "NOTIFICATION_IOS"
4242
+ case 3: cmdName = "WHITELIST_CTRL"
4243
+ case 161: cmdName = "COMM_RSP"
4244
+ default: cmdName = "cmd_\(cmd)"
4245
+ }
4246
+ Bridge.log("G2: NOTIFICATION service — \(cmdName)\(detail)")
4247
+
4248
+ // Pipe NOTIFICATION_IOS up as a phone_notification event — the same
4249
+ // path Android's NotificationListenerService feeds. iOS can't read
4250
+ // other apps' notifications, but the glasses CAN (ANCS) and report the
4251
+ // source app here; title/content are empty because this packet only
4252
+ // carries app identity.
4253
+ if cmd == 2, let iosData = fields[4] as? Data {
4254
+ var ios = ProtobufReader(iosData)
4255
+ let iosFields = ios.parseFields()
4256
+ let appID = (iosFields[1] as? Data).flatMap { String(data: $0, encoding: .utf8) }?
4257
+ .trimmingCharacters(in: CharacterSet(charactersIn: "\0")) ?? ""
4258
+ let name = (iosFields[2] as? Data).flatMap { String(data: $0, encoding: .utf8) }?
4259
+ .trimmingCharacters(in: CharacterSet(charactersIn: "\0")) ?? ""
4260
+ if !appID.isEmpty {
4261
+ let now = Int64(Date().timeIntervalSince1970 * 1000)
4262
+ Bridge.sendTypedMessage(
4263
+ "phone_notification",
4264
+ body: [
4265
+ "notificationId": "ancs-\(appID)-\(now)",
4266
+ "app": name.isEmpty ? appID : name,
4267
+ "title": "",
4268
+ "content": "",
4269
+ "priority": "0",
4270
+ "timestamp": now,
4271
+ "packageName": appID,
4272
+ ]
4273
+ )
4274
+ }
4275
+ }
4276
+ }
4277
+
3771
4278
  /// EvenAI service (0x07). Logs the decoded EvenAIDataPackage so we can read the
3772
4279
  /// CONFIG (Hey Even) echo: commandId=10 (CONFIG), config sub-message in field 13.
3773
4280
  private func handleEvenAIResponse(_ payload: Data) {