diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/DisplayMirrorManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/DisplayMirrorManagerImpl.kt
index 6c273a2caf..edb51a8da9 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/DisplayMirrorManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/DisplayMirrorManagerImpl.kt
@@ -27,9 +27,9 @@ import org.meshtastic.proto.DisplayFrame
/**
* Display frame reassembly state machine.
*
- * Chunks arrive in offset order within a frame (the firmware drains one snapshot before capturing the next), so a
- * chunk with a new `frame_id` or `offset == 0` starts a new frame and an out-of-sequence chunk drops the partial
- * frame. Called sequentially from [FromRadioPacketHandlerImpl] on a single IO coroutine.
+ * Chunks of one frame arrive contiguously and in offset order (FromRadio is a reliable ordered stream), so a chunk with
+ * a new `frame_id` or `offset == 0` starts a new frame and an out-of-sequence chunk drops the partial frame. Calls are
+ * serialized by the single receive-loop collector in MeshServiceOrchestrator (recreated per session).
*/
@Single
class DisplayMirrorManagerImpl : DisplayMirrorManager {
@@ -45,10 +45,7 @@ class DisplayMirrorManagerImpl : DisplayMirrorManager {
val data = chunk.data_.toByteArray()
val total = chunk.total_size
- if (total <= 0 || total > MAX_FRAME_BYTES || chunk.offset + data.size > total) {
- Logger.w { "DisplayMirror: dropping malformed chunk (offset=${chunk.offset} size=${data.size} total=$total)" }
- return
- }
+ if (!isAcceptableChunk(chunk, data.size)) return
if (chunk.offset == 0) {
buffer = ByteArray(total)
@@ -58,7 +55,7 @@ class DisplayMirrorManagerImpl : DisplayMirrorManager {
val buf = buffer
if (buf == null || chunk.frame_id != frameId || chunk.offset != received || buf.size != total) {
- Logger.w { "DisplayMirror: out-of-sequence chunk (frame=${chunk.frame_id}/$frameId offset=${chunk.offset}/$received)" }
+ Logger.w { "DisplayMirror: bad chunk ${chunk.frame_id}@${chunk.offset}, expected $frameId@$received" }
buffer = null
return
}
@@ -72,8 +69,34 @@ class DisplayMirrorManagerImpl : DisplayMirrorManager {
}
}
+ /**
+ * Format must be MONO_VLSB and width/height must describe exactly total_size bytes; a zero or lying dimension would
+ * otherwise reach the renderer (aspectRatio requires > 0).
+ */
+ private fun isAcceptableChunk(chunk: DisplayFrame, dataSize: Int): Boolean {
+ val total = chunk.total_size
+ val expectedSize = chunk.width * ((chunk.height + PIXELS_PER_PAGE - 1) / PIXELS_PER_PAGE)
+ val acceptable =
+ chunk.format == DisplayFrame.Format.MONO_VLSB &&
+ chunk.width > 0 &&
+ chunk.height > 0 &&
+ expectedSize == total &&
+ total <= MAX_FRAME_BYTES &&
+ chunk.offset + dataSize <= total
+ if (!acceptable) {
+ Logger.w {
+ "DisplayMirror: dropping bad chunk (format=${chunk.format} ${chunk.width}x${chunk.height} " +
+ "offset=${chunk.offset} size=$dataSize total=$total)"
+ }
+ }
+ return acceptable
+ }
+
private companion object {
// Generous sanity cap: 320x240 at 1bpp is 9600 bytes.
const val MAX_FRAME_BYTES = 16384
+
+ // MONO_VLSB packs 8 vertically adjacent pixels per byte (one "page" row).
+ const val PIXELS_PER_PAGE = 8
}
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt
index 72eca2766d..755b20f59f 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt
@@ -46,6 +46,7 @@ import org.meshtastic.core.resources.key_verification_request_title
import org.meshtastic.core.resources.key_verification_title
import org.meshtastic.core.resources.low_entropy_key_title
import org.meshtastic.proto.ClientNotification
+import org.meshtastic.proto.DisplayFrame
import org.meshtastic.proto.FromRadio
/** Implementation of [FromRadioPacketHandler] that dispatches [FromRadio] variants to specialized handlers. */
@@ -134,10 +135,7 @@ class FromRadioPacketHandlerImpl(
xmodemPacket != null ->
runIfSessionActive(session, "XModem packet") { xmodemManager.value.handleIncomingXModem(xmodemPacket) }
- displayFrame != null ->
- runIfSessionActive(session, "display frame") {
- displayMirrorManager.value.handleIncomingFrame(displayFrame)
- }
+ displayFrame != null -> handleDisplayFrame(displayFrame, session)
lockdownStatus != null ->
runIfSessionActive(session, "lockdown status") {
@@ -155,6 +153,9 @@ class FromRadioPacketHandlerImpl(
}
}
+ private fun handleDisplayFrame(frame: DisplayFrame, session: RadioSessionContext) =
+ runIfSessionActive(session, "display frame") { displayMirrorManager.value.handleIncomingFrame(frame) }
+
private fun runIfSessionActive(session: RadioSessionContext, operation: String, block: () -> Unit) {
if (!radioInterfaceService.runIfSessionActive(session, block)) {
Logger.d { "Discarding $operation from stale transport session" }
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/DisplayMirrorManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/DisplayMirrorManagerImplTest.kt
new file mode 100644
index 0000000000..d50da08a2f
--- /dev/null
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/DisplayMirrorManagerImplTest.kt
@@ -0,0 +1,134 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.data.manager
+
+import okio.ByteString.Companion.toByteString
+import org.meshtastic.proto.DisplayFrame
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class DisplayMirrorManagerImplTest {
+
+ private val manager = DisplayMirrorManagerImpl()
+
+ // 128x64 MONO_VLSB frame = 1024 bytes; the firmware chunks at 384.
+ private fun chunk(
+ frameId: Int = 1,
+ offset: Int = 0,
+ data: ByteArray,
+ total: Int = FRAME_BYTES,
+ width: Int = WIDTH,
+ height: Int = HEIGHT,
+ format: DisplayFrame.Format = DisplayFrame.Format.MONO_VLSB,
+ ) = DisplayFrame(
+ width = width,
+ height = height,
+ format = format,
+ frame_id = frameId,
+ offset = offset,
+ total_size = total,
+ data_ = data.toByteString(),
+ )
+
+ private fun bytes(size: Int, fill: Int) = ByteArray(size) { fill.toByte() }
+
+ @Test
+ fun `reassembles a three-chunk frame in order`() {
+ manager.handleIncomingFrame(chunk(offset = 0, data = bytes(384, 1)))
+ manager.handleIncomingFrame(chunk(offset = 384, data = bytes(384, 2)))
+ assertNull(manager.frame.value)
+
+ manager.handleIncomingFrame(chunk(offset = 768, data = bytes(256, 3)))
+
+ val frame = manager.frame.value!!
+ assertEquals(WIDTH, frame.width)
+ assertEquals(HEIGHT, frame.height)
+ assertEquals(1, frame.frameId)
+ assertContentEquals(bytes(384, 1) + bytes(384, 2) + bytes(256, 3), frame.pixels)
+ }
+
+ @Test
+ fun `single-chunk frame completes immediately`() {
+ // 64x32 = 256 bytes fits one chunk
+ manager.handleIncomingFrame(chunk(width = 64, height = 32, total = 256, data = bytes(256, 7)))
+
+ assertEquals(256, manager.frame.value?.pixels?.size)
+ }
+
+ @Test
+ fun `out-of-sequence chunk drops the partial frame`() {
+ manager.handleIncomingFrame(chunk(offset = 0, data = bytes(384, 1)))
+ manager.handleIncomingFrame(chunk(offset = 768, data = bytes(256, 3))) // gap: 384 missing
+
+ manager.handleIncomingFrame(chunk(offset = 384, data = bytes(384, 2))) // too late
+ assertNull(manager.frame.value)
+ }
+
+ @Test
+ fun `offset zero restarts mid-frame on device reboot or torn capture`() {
+ manager.handleIncomingFrame(chunk(frameId = 5, offset = 0, data = bytes(384, 1)))
+
+ // New frame id starting over at offset 0 wins
+ manager.handleIncomingFrame(chunk(frameId = 6, offset = 0, data = bytes(384, 4)))
+ manager.handleIncomingFrame(chunk(frameId = 6, offset = 384, data = bytes(384, 5)))
+ manager.handleIncomingFrame(chunk(frameId = 6, offset = 768, data = bytes(256, 6)))
+
+ assertEquals(6, manager.frame.value?.frameId)
+ }
+
+ @Test
+ fun `frame id change mid-frame without offset zero is dropped`() {
+ manager.handleIncomingFrame(chunk(frameId = 1, offset = 0, data = bytes(384, 1)))
+ manager.handleIncomingFrame(chunk(frameId = 2, offset = 384, data = bytes(384, 2)))
+
+ assertNull(manager.frame.value)
+ }
+
+ @Test
+ fun `rejects geometry that does not match total size`() {
+ manager.handleIncomingFrame(
+ chunk(width = 64, height = 64, total = 256, data = bytes(256, 1)),
+ ) // 64x64 needs 512
+ manager.handleIncomingFrame(chunk(width = 0, height = 64, total = 0, data = ByteArray(0)))
+ manager.handleIncomingFrame(chunk(width = 128, height = 0, total = 0, data = ByteArray(0)))
+
+ assertNull(manager.frame.value)
+ }
+
+ @Test
+ fun `rejects unsupported format`() {
+ manager.handleIncomingFrame(
+ chunk(
+ width = 64,
+ height = 32,
+ total = 256,
+ data = bytes(256, 1),
+ format = DisplayFrame.Format.FORMAT_UNSPECIFIED,
+ ),
+ )
+
+ assertNull(manager.frame.value)
+ }
+
+ private companion object {
+ const val WIDTH = 128
+ const val HEIGHT = 64
+ const val FRAME_BYTES = 1024
+ }
+}
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/DisplayMirrorManagerImplTest.kt.bak b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/DisplayMirrorManagerImplTest.kt.bak
new file mode 100644
index 0000000000..f3fe7f4a66
--- /dev/null
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/DisplayMirrorManagerImplTest.kt.bak
@@ -0,0 +1,134 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.data.manager
+
+import okio.ByteString.Companion.toByteString
+import org.meshtastic.proto.DisplayFrame
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class DisplayMirrorManagerImplTest {
+
+ private val manager = DisplayMirrorManagerImpl()
+
+ // 128x64 MONO_VLSB frame = 1024 bytes; the firmware chunks at 384.
+ private fun chunk(
+ frameId: Int = 1,
+ offset: Int = 0,
+ data: ByteArray,
+ total: Int = FRAME_BYTES,
+ width: Int = WIDTH,
+ height: Int = HEIGHT,
+ format: DisplayFrame.Format = DisplayFrame.Format.MONO_VLSB,
+ ) = DisplayFrame(
+ width = width,
+ height = height,
+ format = format,
+ frame_id = frameId,
+ offset = offset,
+ total_size = total,
+ data_ = data.toByteString(),
+ )
+
+ private fun bytes(size: Int, fill: Int) = ByteArray(size) { fill.toByte() }
+
+ @Test
+ fun `reassembles a three-chunk frame in order`() {
+ manager.handleIncomingFrame(chunk(offset = 0, data = bytes(384, 1)))
+ manager.handleIncomingFrame(chunk(offset = 384, data = bytes(384, 2)))
+ assertNull(manager.frame.value)
+
+ manager.handleIncomingFrame(chunk(offset = 768, data = bytes(256, 3)))
+
+ val frame = manager.frame.value!!
+ assertEquals(WIDTH, frame.width)
+ assertEquals(HEIGHT, frame.height)
+ assertEquals(1, frame.frameId)
+ assertContentEquals(bytes(384, 1) + bytes(384, 2) + bytes(256, 3), frame.pixels)
+ }
+
+ @Test
+ fun `single-chunk frame completes immediately`() {
+ // 64x32 = 256 bytes fits one chunk
+ manager.handleIncomingFrame(chunk(width = 64, height = 32, total = 256, data = bytes(256, 7)))
+
+ assertEquals(256, manager.frame.value?.pixels?.size)
+ }
+
+ @Test
+ fun `out-of-sequence chunk drops the partial frame`() {
+ manager.handleIncomingFrame(chunk(offset = 0, data = bytes(384, 1)))
+ manager.handleIncomingFrame(chunk(offset = 768, data = bytes(256, 3))) // gap: 384 missing
+
+ manager.handleIncomingFrame(chunk(offset = 384, data = bytes(384, 2))) // too late
+ assertNull(manager.frame.value)
+ }
+
+ @Test
+ fun `offset zero restarts mid-frame (device reboot or torn capture)`() {
+ manager.handleIncomingFrame(chunk(frameId = 5, offset = 0, data = bytes(384, 1)))
+
+ // New frame id starting over at offset 0 wins
+ manager.handleIncomingFrame(chunk(frameId = 6, offset = 0, data = bytes(384, 4)))
+ manager.handleIncomingFrame(chunk(frameId = 6, offset = 384, data = bytes(384, 5)))
+ manager.handleIncomingFrame(chunk(frameId = 6, offset = 768, data = bytes(256, 6)))
+
+ assertEquals(6, manager.frame.value?.frameId)
+ }
+
+ @Test
+ fun `frame id change mid-frame without offset zero is dropped`() {
+ manager.handleIncomingFrame(chunk(frameId = 1, offset = 0, data = bytes(384, 1)))
+ manager.handleIncomingFrame(chunk(frameId = 2, offset = 384, data = bytes(384, 2)))
+
+ assertNull(manager.frame.value)
+ }
+
+ @Test
+ fun `rejects geometry that does not match total size`() {
+ manager.handleIncomingFrame(
+ chunk(width = 64, height = 64, total = 256, data = bytes(256, 1)),
+ ) // 64x64 needs 512
+ manager.handleIncomingFrame(chunk(width = 0, height = 64, total = 0, data = ByteArray(0)))
+ manager.handleIncomingFrame(chunk(width = 128, height = 0, total = 0, data = ByteArray(0)))
+
+ assertNull(manager.frame.value)
+ }
+
+ @Test
+ fun `rejects unsupported format`() {
+ manager.handleIncomingFrame(
+ chunk(
+ width = 64,
+ height = 32,
+ total = 256,
+ data = bytes(256, 1),
+ format = DisplayFrame.Format.FORMAT_UNSPECIFIED,
+ ),
+ )
+
+ assertNull(manager.frame.value)
+ }
+
+ private companion object {
+ const val WIDTH = 128
+ const val HEIGHT = 64
+ const val FRAME_BYTES = 1024
+ }
+}
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt
index 4587358bb0..7ad8cdcb53 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt
@@ -27,6 +27,7 @@ import dev.mokkery.verify
import dev.mokkery.verify.VerifyMode
import dev.mokkery.verifySuspend
import org.meshtastic.core.model.util.isOtaStatusNotification
+import org.meshtastic.core.repository.DisplayMirrorManager
import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
@@ -43,6 +44,7 @@ import org.meshtastic.proto.Channel
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceMetadata
+import org.meshtastic.proto.DisplayFrame
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.LoRaRegionPresetMap
import org.meshtastic.proto.LockdownStatus
@@ -68,6 +70,7 @@ class FromRadioPacketHandlerImplTest {
private val configFlowManager: MeshConfigFlowManager = mock(MockMode.autofill)
private val configHandler: MeshConfigHandler = mock(MockMode.autofill)
private val xmodemManager: XModemManager = mock(MockMode.autofill)
+ private val displayMirrorManager: DisplayMirrorManager = mock(MockMode.autofill)
private val lockdownCoordinator = FakeLockdownCoordinator()
private val firmwareUpdateStatusRepository = FirmwareUpdateStatusRepository()
private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill)
@@ -105,6 +108,7 @@ class FromRadioPacketHandlerImplTest {
lazy { configFlowManager },
lazy { configHandler },
lazy { xmodemManager },
+ lazy { displayMirrorManager },
mqttManager,
packetHandler,
notificationManager,
@@ -220,13 +224,24 @@ class FromRadioPacketHandlerImplTest {
handle(FromRadio(queueStatus = queueStatus))
handle(FromRadio(xmodemPacket = xmodemPacket))
handle(FromRadio(lockdown_status = lockdownStatus))
+ handle(FromRadio(display_frame = DisplayFrame(width = 128, height = 64)))
verify(mode = VerifyMode.exactly(0)) { mqttManager.handleMqttProxyMessage(any()) }
verify(mode = VerifyMode.exactly(0)) { packetHandler.handleQueueStatus(any()) }
verify(mode = VerifyMode.exactly(0)) { xmodemManager.handleIncomingXModem(any()) }
+ verify(mode = VerifyMode.exactly(0)) { displayMirrorManager.handleIncomingFrame(any()) }
assertEquals(null, lockdownCoordinator.lastStatus)
}
+ @Test
+ fun `handleFromRadio routes DISPLAY_FRAME to displayMirrorManager`() {
+ val frame = DisplayFrame(width = 128, height = 64, frame_id = 1, total_size = 1024)
+
+ handle(FromRadio(display_frame = frame))
+
+ verify { displayMirrorManager.handleIncomingFrame(frame) }
+ }
+
@Test
fun `handleFromRadio routes LOCKDOWN_STATUS to lockdownCoordinator`() {
val lockdownStatus = LockdownStatus(state = LockdownStatus.State.LOCKED, lock_reason = "token_missing")
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt
index 0b63234f2b..e0df46cf93 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt
@@ -53,15 +53,20 @@ interface AdminController {
/**
* Enables or disables live display mirroring on the locally connected node. While enabled, the device streams its
- * framebuffer as `FromRadio.display_frame` chunks — collect them via [DisplayMirrorManager].
+ * framebuffer as `FromRadio.display_frame` chunks — collect them via [DisplayMirrorManager]. Non-suspend (immediate
+ * send, bypassing the outbound FIFO) so lifecycle teardown can reliably disable the stream.
*/
- suspend fun setDisplayMirror(enabled: Boolean)
+ fun setDisplayMirror(enabled: Boolean)
+
+ /** Requests a single framebuffer frame from the locally connected node, delivered like mirrored frames. */
+ fun requestDisplayFrame()
/**
* Injects a physical input event (button/key/touch) into the locally connected node's UI via the firmware
- * InputBroker. Event codes are the firmware's `input_broker_event` values.
+ * InputBroker. Event codes are the firmware's `input_broker_event` values. Non-suspend immediate send: input must
+ * not queue behind bulk admin traffic.
*/
- suspend fun sendInputEvent(eventCode: Int, kbChar: Int = 0, touchX: Int = 0, touchY: Int = 0)
+ fun sendInputEvent(eventCode: Int, kbChar: Int = 0, touchX: Int = 0, touchY: Int = 0)
// ── Remote configuration ────────────────────────────────────────────────
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DisplayMirrorManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DisplayMirrorManager.kt
index aaabf8a87e..3cd303118d 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DisplayMirrorManager.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DisplayMirrorManager.kt
@@ -22,9 +22,9 @@ import org.meshtastic.proto.DisplayFrame
/**
* Reassembles the chunked [DisplayFrame] stream from the connected device into complete framebuffer snapshots.
*
- * The device streams its 1bpp framebuffer as `FromRadio.display_frame` chunks while display mirroring is enabled
- * (see `AdminMessage.set_display_mirror`). Chunks of one frame share a `frame_id`; a frame is complete when
- * `offset + data.size == total_size`.
+ * The device streams its 1bpp framebuffer as `FromRadio.display_frame` chunks while display mirroring is enabled (see
+ * `AdminMessage.set_display_mirror`). Chunks of one frame share a `frame_id`; a frame is complete when `offset +
+ * data.size == total_size`.
*/
interface DisplayMirrorManager {
/** Latest completely reassembled frame, or null before the first one arrives. */
@@ -37,16 +37,15 @@ interface DisplayMirrorManager {
/**
* One complete device framebuffer snapshot.
*
- * [pixels] is MONO_VLSB: 1 bit per pixel in vertical LSB-first pages — byte index = `x + (y / 8) * width`,
- * bit index = `y % 8`.
+ * [pixels] is MONO_VLSB: 1 bit per pixel in vertical LSB-first pages — byte index = `x + (y / 8) * width`, bit index =
+ * `y % 8`.
*/
data class MirrorFrame(val width: Int, val height: Int, val frameId: Int, val pixels: ByteArray) {
- override fun equals(other: Any?): Boolean =
- other is MirrorFrame &&
- other.width == width &&
- other.height == height &&
- other.frameId == frameId &&
- other.pixels.contentEquals(pixels)
+ override fun equals(other: Any?): Boolean = other is MirrorFrame &&
+ other.width == width &&
+ other.height == height &&
+ other.frameId == frameId &&
+ other.pixels.contentEquals(pixels)
override fun hashCode(): Int {
var result = width
diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt
index 9fa9efadad..38802fdacf 100644
--- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt
+++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt
@@ -125,20 +125,24 @@ internal class AdminControllerImpl(
scope.handledLaunch { radioConfigRepository.setLocalConfig(config) }
}
- override suspend fun setDisplayMirror(enabled: Boolean) {
- commandSender.sendAdmin(myNodeNum) { AdminMessage(set_display_mirror = enabled) }
+ override fun setDisplayMirror(enabled: Boolean) {
+ commandSender.sendAdminImmediate(myNodeNum) { AdminMessage(set_display_mirror = enabled) }
}
- override suspend fun sendInputEvent(eventCode: Int, kbChar: Int, touchX: Int, touchY: Int) {
- commandSender.sendAdmin(myNodeNum) {
+ override fun requestDisplayFrame() {
+ commandSender.sendAdminImmediate(myNodeNum) { AdminMessage(get_display_frame_request = true) }
+ }
+
+ override fun sendInputEvent(eventCode: Int, kbChar: Int, touchX: Int, touchY: Int) {
+ commandSender.sendAdminImmediate(myNodeNum) {
AdminMessage(
send_input_event =
- AdminMessage.InputEvent(
- event_code = eventCode,
- kb_char = kbChar,
- touch_x = touchX,
- touch_y = touchY,
- ),
+ AdminMessage.InputEvent(
+ event_code = eventCode,
+ kb_char = kbChar,
+ touch_x = touchX,
+ touch_y = touchY,
+ ),
)
}
}
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt
index 1cc1bcbd04..e27899e3c3 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt
@@ -252,6 +252,12 @@ class FakeRadioController :
override suspend fun refreshMetadata(destNum: Int) {}
+ override fun setDisplayMirror(enabled: Boolean) {}
+
+ override fun requestDisplayFrame() {}
+
+ override fun sendInputEvent(eventCode: Int, kbChar: Int, touchX: Int, touchY: Int) {}
+
override suspend fun setLocalConfig(config: Config) {
if (throwOnSetLocalConfig) error("Fake local config write failure")
if (rejectLocalConfigWritesRemaining > 0) {
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DisplayMirror.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DisplayMirror.kt
index 6428e86040..7436852f62 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DisplayMirror.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DisplayMirror.kt
@@ -16,8 +16,7 @@
*/
package org.meshtastic.feature.settings.debugging
-import androidx.compose.foundation.Canvas
-import androidx.compose.foundation.background
+import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -30,32 +29,49 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.FilterQuality
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.KoinViewModel
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.repository.AdminController
+import org.meshtastic.core.repository.ConnectionStateProvider
import org.meshtastic.core.repository.DisplayMirrorManager
import org.meshtastic.core.repository.MirrorFrame
-import org.meshtastic.core.repository.RadioController
-// 4x scale for the common 128px-wide OLED; caps the canvas so the D-pad stays above the fold on desktop.
+// 4x scale for the common 128px-wide OLED; caps the image so the D-pad stays above the fold on desktop.
private val MAX_CANVAS_WIDTH = 512.dp
+// MONO_VLSB packs 8 vertically adjacent pixels per byte (one "page" row).
+private const val PIXELS_PER_PAGE = 8
+
// Firmware input_broker_event codes (src/input/InputBroker.h).
private const val INPUT_SELECT = 10
private const val INPUT_UP = 17
@@ -67,24 +83,41 @@ private const val INPUT_BACK = 27
@KoinViewModel
class DisplayMirrorViewModel(
displayMirrorManager: DisplayMirrorManager,
- private val radioController: RadioController,
+ connectionStateProvider: ConnectionStateProvider,
+ private val adminController: AdminController,
) : ViewModel() {
val frame: StateFlow = displayMirrorManager.frame
+ val connected: StateFlow =
+ connectionStateProvider.connectionState
+ .map { it is ConnectionState.Connected }
+ .stateIn(viewModelScope, SharingStarted.Eagerly, false)
+
private val _mirroring = MutableStateFlow(false)
val mirroring: StateFlow = _mirroring.asStateFlow()
- fun setMirror(enabled: Boolean) {
- viewModelScope.launch {
- radioController.setDisplayMirror(enabled)
- _mirroring.value = enabled
- }
+ init {
+ // The device forgets the (non-persisted) mirror setting on disconnect/reboot;
+ // mirror the reset locally so the toggle never claims a dead stream is live.
+ viewModelScope.launch { connected.collect { if (!it) _mirroring.value = false } }
}
- fun sendKey(eventCode: Int) {
- viewModelScope.launch { radioController.sendInputEvent(eventCode) }
+ fun setMirror(enabled: Boolean) {
+ adminController.setDisplayMirror(enabled)
+ _mirroring.value = enabled
}
+
+ fun requestFrame() = adminController.requestDisplayFrame()
+
+ fun sendKey(eventCode: Int) = adminController.sendInputEvent(eventCode)
+
+ /** Stops a live stream when the mirror UI goes away; safe to call redundantly. */
+ fun stopMirroring() {
+ if (_mirroring.value) setMirror(false)
+ }
+
+ override fun onCleared() = stopMirroring()
}
/** PoC live view of the connected device's screen, with remote D-pad control. Strings are deliberately unlocalized. */
@@ -92,6 +125,10 @@ class DisplayMirrorViewModel(
fun DisplayMirrorContent(modifier: Modifier = Modifier, viewModel: DisplayMirrorViewModel = koinViewModel()) {
val frame by viewModel.frame.collectAsStateWithLifecycle()
val mirroring by viewModel.mirroring.collectAsStateWithLifecycle()
+ val connected by viewModel.connected.collectAsStateWithLifecycle()
+
+ // Don't leave the device streaming to a hidden tab or abandoned screen.
+ DisposableEffect(Unit) { onDispose { viewModel.stopMirroring() } }
Column(
modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
@@ -100,8 +137,9 @@ fun DisplayMirrorContent(modifier: Modifier = Modifier, viewModel: DisplayMirror
) {
val currentFrame = frame
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- Switch(checked = mirroring, onCheckedChange = viewModel::setMirror)
+ Switch(checked = mirroring, onCheckedChange = viewModel::setMirror, enabled = connected)
Text(text = if (mirroring) "Mirroring" else "Mirror off", style = MaterialTheme.typography.titleMedium)
+ OutlinedButton(onClick = viewModel::requestFrame, enabled = connected) { Text("Refresh") }
if (currentFrame != null) {
Text(
text = "${currentFrame.width}x${currentFrame.height} frame #${currentFrame.frameId}",
@@ -110,46 +148,62 @@ fun DisplayMirrorContent(modifier: Modifier = Modifier, viewModel: DisplayMirror
}
}
- if (currentFrame != null) {
- MirrorFrameCanvas(currentFrame)
- } else {
- Text(text = "No frame received yet — enable mirroring above.")
+ when {
+ !connected -> Text(text = "Not connected to a device.")
+ currentFrame != null -> MirrorFrameImage(currentFrame)
+ else -> Text(text = "No frame received yet — enable mirroring or tap Refresh.")
}
// Remote D-pad
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- FilledTonalButton(onClick = { viewModel.sendKey(INPUT_UP) }) { Text("Up") }
+ FilledTonalButton(onClick = { viewModel.sendKey(INPUT_UP) }, enabled = connected) { Text("Up") }
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- FilledTonalButton(onClick = { viewModel.sendKey(INPUT_LEFT) }) { Text("Left") }
- FilledTonalButton(onClick = { viewModel.sendKey(INPUT_SELECT) }) { Text("OK") }
- FilledTonalButton(onClick = { viewModel.sendKey(INPUT_RIGHT) }) { Text("Right") }
+ FilledTonalButton(onClick = { viewModel.sendKey(INPUT_LEFT) }, enabled = connected) { Text("Left") }
+ FilledTonalButton(onClick = { viewModel.sendKey(INPUT_SELECT) }, enabled = connected) { Text("OK") }
+ FilledTonalButton(onClick = { viewModel.sendKey(INPUT_RIGHT) }, enabled = connected) { Text("Right") }
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- FilledTonalButton(onClick = { viewModel.sendKey(INPUT_DOWN) }) { Text("Down") }
- FilledTonalButton(onClick = { viewModel.sendKey(INPUT_BACK) }) { Text("Back") }
+ FilledTonalButton(onClick = { viewModel.sendKey(INPUT_DOWN) }, enabled = connected) { Text("Down") }
+ FilledTonalButton(onClick = { viewModel.sendKey(INPUT_BACK) }, enabled = connected) { Text("Back") }
}
}
}
-/** Draws a MONO_VLSB 1bpp framebuffer scaled up, one filled rect per lit pixel. Width-capped so the controls stay in view on desktop. */
+/**
+ * Renders a MONO_VLSB 1bpp framebuffer once per frame into a 1:1 [ImageBitmap] and scales it up with nearest-neighbor
+ * filtering — crisp device pixels, no fractional-scale seams, one pixel walk per frame instead of per recomposition.
+ */
@Composable
-private fun MirrorFrameCanvas(frame: MirrorFrame) {
- val aspect = frame.width.toFloat() / frame.height.toFloat()
- Canvas(
- modifier = Modifier.widthIn(max = MAX_CANVAS_WIDTH).fillMaxWidth().aspectRatio(aspect).background(Color.Black),
- ) {
- val scale = size.width / frame.width
- val pixel = Size(scale, scale)
+private fun MirrorFrameImage(frame: MirrorFrame, modifier: Modifier = Modifier) {
+ val bitmap = remember(frame) { renderFrame(frame) }
+ Image(
+ bitmap = bitmap,
+ contentDescription = "Device screen",
+ modifier =
+ modifier
+ .widthIn(max = MAX_CANVAS_WIDTH)
+ .fillMaxWidth()
+ .aspectRatio(frame.width.toFloat() / frame.height.toFloat()),
+ filterQuality = FilterQuality.None,
+ )
+}
+
+private fun renderFrame(frame: MirrorFrame): ImageBitmap {
+ val bitmap = ImageBitmap(frame.width, frame.height)
+ val size = Size(frame.width.toFloat(), frame.height.toFloat())
+ CanvasDrawScope().draw(Density(1f), LayoutDirection.Ltr, Canvas(bitmap), size) {
+ drawRect(color = Color.Black)
+ val pixel = Size(1f, 1f)
for (y in 0 until frame.height) {
- val page = (y / 8) * frame.width
- val bit = 1 shl (y % 8)
+ val page = (y / PIXELS_PER_PAGE) * frame.width
+ val bit = 1 shl (y % PIXELS_PER_PAGE)
for (x in 0 until frame.width) {
- val index = page + x
- if (index < frame.pixels.size && frame.pixels[index].toInt() and bit != 0) {
- drawRect(color = Color.White, topLeft = Offset(x * scale, y * scale), size = pixel)
+ if (frame.pixels[page + x].toInt() and bit != 0) {
+ drawRect(color = Color.White, topLeft = Offset(x.toFloat(), y.toFloat()), size = pixel)
}
}
}
}
+ return bitmap
}