@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.
- package/README.md +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +135 -51
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +120 -17
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +5 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +34 -21
- package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +13 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +38 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +488 -15
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +551 -159
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveL2capChannel.kt +274 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +361 -67
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +7501 -3704
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +134 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +2 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +493 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +65 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleWireProtocol.java +108 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/IncidentLogBleUploadService.java +4 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900LengthCodec.java +115 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +109 -73
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +106 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +93 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +111 -11
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkExceptionTest.kt +18 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +23 -9
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +31 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +17 -1
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BinaryMessageChunkerTest.java +84 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +158 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +9 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsEndiannessTest.java +172 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsTest.java +24 -0
- package/build/BluetoothSdk.types.d.ts +37 -1
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +4 -0
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/photoRequestPayload.d.ts.map +1 -1
- package/build/_private/photoRequestPayload.js +1 -0
- package/build/_private/photoRequestPayload.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +5 -0
- package/build/index.js.map +1 -1
- package/build/types/index.d.ts +3 -0
- package/build/types/index.d.ts.map +1 -0
- package/build/types/index.js +2 -0
- package/build/types/index.js.map +1 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +136 -30
- package/ios/Source/DeviceStore.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +40 -24
- package/ios/Source/OtaManifest.swift +1 -1
- package/ios/Source/camera/CameraModels.swift +17 -3
- package/ios/Source/sgcs/G1.swift +1 -1
- package/ios/Source/sgcs/G2.swift +518 -11
- package/ios/Source/sgcs/MentraLive.swift +443 -42
- package/ios/Source/sgcs/MentraLiveL2capChannel.swift +183 -0
- package/ios/Source/sgcs/MentraNex.swift +416 -32
- package/ios/Source/sgcs/SGCManager.swift +155 -0
- package/ios/Source/sgcs/mentraos_ble.pb.swift +726 -122
- package/ios/Source/status/DeviceStatus.swift +0 -8
- package/ios/Source/utils/BleJsonCompact.swift +395 -0
- package/ios/Source/utils/BleWireProtocol.swift +92 -0
- package/ios/Source/utils/G1Text.swift +30 -1
- package/ios/Source/utils/MessageChunkReassembler.swift +82 -0
- package/ios/Source/utils/MessageChunker.swift +73 -0
- package/package.json +14 -7
- package/src/BluetoothSdk.types.ts +39 -1
- package/src/_private/BluetoothSdkModule.ts +4 -0
- package/src/_private/photoRequestPayload.ts +1 -0
- package/src/index.ts +6 -0
- 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
|
-
|
|
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
|
-
|
|
2044
|
-
|
|
2045
|
-
val
|
|
2046
|
-
|
|
2047
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
//
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
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 =
|
|
3176
|
+
isEventCapture = false,
|
|
2704
3177
|
content = c.content
|
|
2705
3178
|
)
|
|
2706
3179
|
)
|