refactor(debug): pre-PR review fixes for the mirror

Circular D-pad ring replaces the cross cluster (wedge hit-testing with a
radial dead band, hold-to-repeat, per-wedge semantics, haptics), keyboard
capture becomes an explicit chip with tap-to-toggle, and taps on touch
devices forward real panel coordinates (long-press = SELECT, matching the
physical touch driver). Manager gains reset() (wired to ViewModel creation
and disconnect), a palette region cap, and first-chunk geometry pinning;
renderer uses bit-replicated RGB565 and run-length row drawing with the
pixel resolver extracted pure and tested; KeyUp of captured keys is
consumed; ViewModel moved to its own file; all strings localized; the
stray .bak test file is gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
James RichandClaude Fable 5 committed 2026-09-09 11:19:43 -05:00
1 parent 8753bdb47e
commit 21bc10affb
13 files changed
+810 -373

No files matched your search

+19
View File
@@ -356,6 +356,7 @@ debug_search_prev
debug_store_logs_summary
debug_store_logs_title
debug_tab_app_logs
debug_tab_mirror
debug_tab_packets
deep_link_connect_message
deep_link_connect_title
@@ -1185,6 +1186,24 @@ minimum_broadcast_seconds
minimum_distance
minimum_interval
minimum_wake_time_seconds
### MIRROR ###
mirror_active
mirror_back
mirror_device_screen
mirror_eink_hint
mirror_frame_info
mirror_hint_keyboard_active
mirror_hint_tap_keyboard
mirror_hint_tap_touch
mirror_key_down
mirror_key_left
mirror_key_ok
mirror_key_right
mirror_key_up
mirror_keyboard
mirror_no_frame
mirror_not_connected
mirror_off
modem_preset
module_settings
modules_already_unlocked
@@ -31,7 +31,8 @@ import org.meshtastic.proto.DisplayPalette
*
* 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).
* serialized by the single receive-loop collector in MeshServiceOrchestrator; this singleton's state outlives a
* session, so [reset] must be called when one ends.
*/
@Single
class DisplayMirrorManagerImpl : DisplayMirrorManager {
@@ -45,6 +46,8 @@ class DisplayMirrorManagerImpl : DisplayMirrorManager {
private var buffer: ByteArray? = null
private var frameId = 0
private var received = 0
private var frameWidth = 0
private var frameHeight = 0
private var paletteRegions = mutableListOf<DisplayPalette.ColorRegion>()
private var paletteSignature = 0
@@ -62,10 +65,13 @@ class DisplayMirrorManagerImpl : DisplayMirrorManager {
buffer = ByteArray(total)
frameId = chunk.frame_id
received = 0
// Geometry is authoritative on the first chunk; a later chunk may not transpose it.
frameWidth = chunk.width
frameHeight = chunk.height
}
val buf = buffer
if (buf == null || chunk.frame_id != frameId || chunk.offset != received || buf.size != total) {
if (buf == null || !matchesCurrentFrame(chunk, buf.size)) {
Logger.w { "DisplayMirror: bad chunk ${chunk.frame_id}@${chunk.offset}, expected $frameId@$received" }
buffer = null
return
@@ -77,8 +83,8 @@ class DisplayMirrorManagerImpl : DisplayMirrorManager {
if (received == total) {
_frame.value =
MirrorFrame(
width = chunk.width,
height = chunk.height,
width = frameWidth,
height = frameHeight,
frameId = frameId,
paletteSignature = chunk.palette_signature,
pixels = buf,
@@ -96,8 +102,7 @@ class DisplayMirrorManagerImpl : DisplayMirrorManager {
paletteDefaultOn = chunk.default_on_color
paletteDefaultOff = chunk.default_off_color
}
if (chunk.signature != paletteSignature || chunk.region_offset != paletteReceived) {
Logger.w { "DisplayMirror: bad palette chunk ${chunk.signature}@${chunk.region_offset}" }
if (!isAcceptablePaletteChunk(chunk)) {
paletteRegions.clear()
paletteReceived = -1 // poison until the next offset-0 chunk restarts
return
@@ -116,6 +121,26 @@ class DisplayMirrorManagerImpl : DisplayMirrorManager {
}
}
private fun matchesCurrentFrame(chunk: DisplayFrame, bufSize: Int): Boolean {
val inSequence = chunk.frame_id == frameId && chunk.offset == received && bufSize == chunk.total_size
val geometryStable = chunk.width == frameWidth && chunk.height == frameHeight
return inSequence && geometryStable
}
/** Sequenced continuation of the current palette, within the region cap; logs and rejects anything else. */
private fun isAcceptablePaletteChunk(chunk: DisplayPalette): Boolean {
val withinCap =
chunk.region_total <= MAX_PALETTE_REGIONS &&
paletteReceived >= 0 &&
paletteReceived + chunk.regions.size <= MAX_PALETTE_REGIONS
val inSequence = chunk.signature == paletteSignature && chunk.region_offset == paletteReceived
val acceptable = withinCap && inSequence
if (!acceptable) {
Logger.w { "DisplayMirror: bad palette chunk ${chunk.signature}@${chunk.region_offset}" }
}
return acceptable
}
/**
* 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).
@@ -139,10 +164,23 @@ class DisplayMirrorManagerImpl : DisplayMirrorManager {
return acceptable
}
override fun reset() {
buffer = null
received = 0
paletteRegions = mutableListOf()
paletteReceived = 0
paletteSignature = 0
_frame.value = null
_palette.value = null
}
private companion object {
// Generous sanity cap: 320x240 at 1bpp is 9600 bytes.
const val MAX_FRAME_BYTES = 16384
// The firmware's region table caps at 48; anything past this is a bug or an attack.
const val MAX_PALETTE_REGIONS = 512
// MONO_VLSB packs 8 vertically adjacent pixels per byte (one "page" row).
const val PIXELS_PER_PAGE = 8
}
@@ -171,6 +171,39 @@ class DisplayMirrorManagerImplTest {
assertEquals(8, manager.palette.value?.signature)
}
@Test
fun `reset clears frames palettes and partial state`() {
manager.handleIncomingFrame(chunk(width = 64, height = 32, total = 256, data = bytes(256, 7)))
manager.handleIncomingPalette(
DisplayPalette(signature = 9, region_offset = 0, region_total = 0, regions = emptyList()),
)
manager.reset()
assertNull(manager.frame.value)
assertNull(manager.palette.value)
}
@Test
fun `rejects palettes past the region cap`() {
val region = DisplayPalette.ColorRegion(x = 0, y = 0, width = 8, height = 8)
manager.handleIncomingPalette(
DisplayPalette(signature = 3, region_offset = 0, region_total = 100_000, regions = listOf(region)),
)
assertNull(manager.palette.value)
}
@Test
fun `chunk that changes geometry mid-frame is dropped`() {
manager.handleIncomingFrame(chunk(offset = 0, data = bytes(384, 1)))
// Transposed geometry with the same total size must not complete the frame
manager.handleIncomingFrame(chunk(width = 64, height = 128, offset = 384, data = bytes(384, 2)))
manager.handleIncomingFrame(chunk(offset = 768, data = bytes(256, 3)))
assertNull(manager.frame.value)
}
private companion object {
const val WIDTH = 128
const val HEIGHT = 64
@@ -1,134 +0,0 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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
}
}
@@ -45,6 +45,7 @@ import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.DisplayFrame
import org.meshtastic.proto.DisplayPalette
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.LoRaRegionPresetMap
import org.meshtastic.proto.LockdownStatus
@@ -225,11 +226,13 @@ class FromRadioPacketHandlerImplTest {
handle(FromRadio(xmodemPacket = xmodemPacket))
handle(FromRadio(lockdown_status = lockdownStatus))
handle(FromRadio(display_frame = DisplayFrame(width = 128, height = 64)))
handle(FromRadio(display_palette = DisplayPalette(signature = 1)))
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()) }
verify(mode = VerifyMode.exactly(0)) { displayMirrorManager.handleIncomingPalette(any()) }
assertEquals(null, lockdownCoordinator.lastStatus)
}
@@ -242,6 +245,15 @@ class FromRadioPacketHandlerImplTest {
verify { displayMirrorManager.handleIncomingFrame(frame) }
}
@Test
fun `handleFromRadio routes DISPLAY_PALETTE to displayMirrorManager`() {
val palette = DisplayPalette(signature = 7, region_total = 0)
handle(FromRadio(display_palette = palette))
verify { displayMirrorManager.handleIncomingPalette(palette) }
}
@Test
fun `handleFromRadio routes LOCKDOWN_STATUS to lockdownCoordinator`() {
val lockdownStatus = LockdownStatus(state = LockdownStatus.State.LOCKED, lock_reason = "token_missing")
@@ -39,6 +39,12 @@ interface DisplayMirrorManager {
/** Routes an incoming palette chunk from the device to the reassembly state machine. */
fun handleIncomingPalette(chunk: DisplayPalette)
/**
* Clears all held state — latest frame, latest palette, and partial reassembly buffers. Call when a session ends or
* a fresh mirror UI opens, so a previous device's final screen is never shown as if live.
*/
fun reset()
}
/**
@@ -383,6 +383,7 @@
<string name="debug_store_logs_summary">Disable to skip writing mesh logs to disk</string>
<string name="debug_store_logs_title">Store mesh logs</string>
<string name="debug_tab_app_logs">App logs</string>
<string name="debug_tab_mirror">Mirror</string>
<string name="debug_tab_packets">Packets</string>
<string name="deep_link_connect_message">A link is asking this app to connect to the device at %1$s. Only continue if you trust where the link came from.</string>
<string name="deep_link_connect_title">Connect to this device?</string>
@@ -1221,6 +1222,24 @@
<string name="minimum_distance">Smart Distance</string>
<string name="minimum_interval">Smart Interval</string>
<string name="minimum_wake_time_seconds">Minimum wake time</string>
<!-- MIRROR -->
<string name="mirror_active">Mirroring</string>
<string name="mirror_back">Back</string>
<string name="mirror_device_screen">Device screen</string>
<string name="mirror_eink_hint">E-ink display: refresh is slow — prefer one-shot Refresh over continuous mirroring.</string>
<string name="mirror_frame_info">%1$d×%2$d · frame #%3$d</string>
<string name="mirror_hint_keyboard_active">Arrows · Enter = OK · Esc = back</string>
<string name="mirror_hint_tap_keyboard">Click the screen for keyboard control, or swipe it to navigate</string>
<string name="mirror_hint_tap_touch">Tap the screen to touch the device, or swipe to navigate</string>
<string name="mirror_key_down">Down</string>
<string name="mirror_key_left">Left</string>
<string name="mirror_key_ok">OK</string>
<string name="mirror_key_right">Right</string>
<string name="mirror_key_up">Up</string>
<string name="mirror_keyboard">Keyboard</string>
<string name="mirror_no_frame">No frame received yet — enable mirroring or tap Refresh.</string>
<string name="mirror_not_connected">Not connected to a device.</string>
<string name="mirror_off">Mirror off</string>
<string name="modem_preset">Presets</string>
<string name="module_settings">Module configuration</string>
<string name="modules_already_unlocked">Modules already unlocked</string>
@@ -77,6 +77,7 @@ import org.meshtastic.core.resources.debug_panel
import org.meshtastic.core.resources.debug_store_logs_summary
import org.meshtastic.core.resources.debug_store_logs_title
import org.meshtastic.core.resources.debug_tab_app_logs
import org.meshtastic.core.resources.debug_tab_mirror
import org.meshtastic.core.resources.debug_tab_packets
import org.meshtastic.core.resources.log_retention_days
import org.meshtastic.core.resources.log_retention_days_quantity
@@ -187,8 +188,7 @@ fun DebugScreen(onNavigateUp: () -> Unit, viewModel: DebugViewModel) {
Tab(
selected = selectedTab == 2,
onClick = { selectedTab = 2 },
// PoC tab; deliberately unlocalized.
text = { Text("Mirror") },
text = { Text(stringResource(Res.string.debug_tab_mirror)) },
)
}
if (selectedTab == 1) {
@@ -16,7 +16,6 @@
*/
package org.meshtastic.feature.settings.debugging
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
@@ -32,11 +31,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Switch
@@ -56,7 +54,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
@@ -64,43 +62,34 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
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.isActive
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
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.MirrorPalette
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.ui.icon.KeyboardArrowDown
import org.meshtastic.core.ui.icon.KeyboardArrowLeft
import org.meshtastic.core.ui.icon.KeyboardArrowRight
import org.meshtastic.core.ui.icon.KeyboardArrowUp
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.mirror_active
import org.meshtastic.core.resources.mirror_back
import org.meshtastic.core.resources.mirror_eink_hint
import org.meshtastic.core.resources.mirror_frame_info
import org.meshtastic.core.resources.mirror_hint_keyboard_active
import org.meshtastic.core.resources.mirror_hint_tap_keyboard
import org.meshtastic.core.resources.mirror_hint_tap_touch
import org.meshtastic.core.resources.mirror_keyboard
import org.meshtastic.core.resources.mirror_no_frame
import org.meshtastic.core.resources.mirror_not_connected
import org.meshtastic.core.resources.mirror_off
import org.meshtastic.core.resources.refresh
import org.meshtastic.proto.DisplayInfo
// M3 comfortable target for remote-control keys (48dp minimum + breathing room).
private val DPAD_KEY_SIZE = 56.dp
private val DPAD_GAP = 8.dp
// OS-typematic-style auto-repeat, slowed to what a LoRa radio UI can render.
private const val REPEAT_INITIAL_DELAY_MS = 500L
private const val REPEAT_INTERVAL_MS = 100L
internal const val REPEAT_INITIAL_DELAY_MS = 500L
internal const val REPEAT_INTERVAL_MS = 100L
// Swipes on the mirror shorter than this are ignored as accidental.
private val SWIPE_THRESHOLD = 48.dp
@@ -108,63 +97,18 @@ private val SWIPE_THRESHOLD = 48.dp
// Mirror + D-pad fit comfortably side by side above this content width.
private val SIDE_BY_SIDE_MIN_WIDTH = 760.dp
// Firmware input_broker_event codes (src/input/InputBroker.h).
private const val INPUT_SELECT = 10
private const val INPUT_SELECT_LONG = 11
private const val INPUT_UP = 17
private const val INPUT_DOWN = 18
private const val INPUT_LEFT = 19
private const val INPUT_RIGHT = 20
private const val INPUT_BACK = 27
// Firmware input_broker_event codes (src/input/InputBroker.h). USER_PRESS is
// what physical touch drivers emit for a tap, with touch coordinates attached.
internal const val INPUT_SELECT = 10
internal const val INPUT_SELECT_LONG = 11
internal const val INPUT_UP = 17
internal const val INPUT_DOWN = 18
internal const val INPUT_LEFT = 19
internal const val INPUT_RIGHT = 20
internal const val INPUT_BACK = 27
internal const val INPUT_USER_PRESS = 28
@KoinViewModel
class DisplayMirrorViewModel(
displayMirrorManager: DisplayMirrorManager,
connectionStateProvider: ConnectionStateProvider,
nodeRepository: NodeRepository,
private val adminController: AdminController,
) : ViewModel() {
val frame: StateFlow<MirrorFrame?> = displayMirrorManager.frame
val palette: StateFlow<MirrorPalette?> = displayMirrorManager.palette
val connected: StateFlow<Boolean> =
connectionStateProvider.connectionState
.map { it is ConnectionState.Connected }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
/** Panel description from the connect handshake; null on display-less nodes and pre-DisplayInfo firmware. */
val displayInfo: StateFlow<DisplayInfo?> =
nodeRepository.ourNodeInfo.map { it?.metadata?.display }.stateIn(viewModelScope, SharingStarted.Eagerly, null)
private val _mirroring = MutableStateFlow(false)
val mirroring: StateFlow<Boolean> = _mirroring.asStateFlow()
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 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. */
/** Live view of the connected device's screen, with remote D-pad, keyboard, and touch control. */
@Composable
fun DisplayMirrorContent(modifier: Modifier = Modifier, viewModel: DisplayMirrorViewModel = koinViewModel()) {
val frame by viewModel.frame.collectAsStateWithLifecycle()
@@ -184,42 +128,66 @@ 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, enabled = connected)
Text(text = if (mirroring) "Mirroring" else "Mirror off", style = MaterialTheme.typography.titleMedium)
OutlinedButton(onClick = viewModel::requestFrame, enabled = connected) { Text("Refresh") }
Text(
text = stringResource(if (mirroring) Res.string.mirror_active else Res.string.mirror_off),
style = MaterialTheme.typography.titleMedium,
)
OutlinedButton(onClick = viewModel::requestFrame, enabled = connected) {
Text(stringResource(Res.string.refresh))
}
if (currentFrame != null) {
Text(
text = "${currentFrame.width}x${currentFrame.height} frame #${currentFrame.frameId}",
text =
stringResource(
Res.string.mirror_frame_info,
currentFrame.width,
currentFrame.height,
currentFrame.frameId,
),
style = MaterialTheme.typography.labelSmall,
)
}
}
if (displayInfo?.panel_class == DisplayInfo.PanelClass.EINK) {
Text(
text = "E-ink display: refresh is slow — prefer one-shot Refresh over continuous mirroring.",
style = MaterialTheme.typography.labelSmall,
)
Text(text = stringResource(Res.string.mirror_eink_hint), style = MaterialTheme.typography.labelSmall)
}
when {
!connected -> {
Text(text = "Not connected to a device.")
Text(text = stringResource(Res.string.mirror_not_connected))
DpadCluster(enabled = false, onEvent = viewModel::sendKey)
}
currentFrame == null -> {
Text(text = "No frame received yet — enable mirroring or tap Refresh.")
Text(text = stringResource(Res.string.mirror_no_frame))
DpadCluster(enabled = connected, onEvent = viewModel::sendKey)
}
else -> MirrorWithControls(currentFrame, palette, enabled = connected, onEvent = viewModel::sendKey)
else ->
MirrorWithControls(
currentFrame,
palette,
enabled = connected,
hasTouch = displayInfo?.has_touch == true,
onEvent = viewModel::sendKey,
onTouch = viewModel::sendTouch,
)
}
}
}
/** Controls sit beside the mirror when the window is wide enough, below it otherwise. */
@Suppress("LongParameterList")
@Composable
private fun MirrorWithControls(frame: MirrorFrame, palette: MirrorPalette?, enabled: Boolean, onEvent: (Int) -> Unit) {
private fun MirrorWithControls(
frame: MirrorFrame,
palette: MirrorPalette?,
enabled: Boolean,
hasTouch: Boolean,
onEvent: (Int) -> Unit,
onTouch: (Int, Int, Int) -> Unit,
) {
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
if (maxWidth >= SIDE_BY_SIDE_MIN_WIDTH) {
Row(
@@ -227,7 +195,7 @@ private fun MirrorWithControls(frame: MirrorFrame, palette: MirrorPalette?, enab
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
MirrorSurface(frame, palette, onEvent = onEvent, modifier = Modifier.weight(1f, fill = false))
MirrorSurface(frame, palette, hasTouch, onEvent, onTouch, modifier = Modifier.weight(1f, fill = false))
DpadCluster(enabled = enabled, onEvent = onEvent)
}
} else {
@@ -236,7 +204,7 @@ private fun MirrorWithControls(frame: MirrorFrame, palette: MirrorPalette?, enab
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
MirrorSurface(frame, palette, onEvent = onEvent)
MirrorSurface(frame, palette, hasTouch, onEvent, onTouch)
DpadCluster(enabled = enabled, onEvent = onEvent)
}
}
@@ -244,22 +212,28 @@ private fun MirrorWithControls(frame: MirrorFrame, palette: MirrorPalette?, enab
}
/**
* The live mirror plus its direct input affordances: click to focus (keyboard capture — arrows, Enter/Space = OK,
* Esc/Backspace = Back), swipe in a cardinal direction for a single direction event.
* The live mirror plus its direct input affordances: a Keyboard chip toggles key capture (arrows, Enter/Space = OK,
* Esc/Backspace = Back), swiping in a cardinal direction sends one direction event, and on touch-capable devices
* tapping or long-pressing the image forwards real touch coordinates; elsewhere a tap toggles capture.
*/
@Composable
@Suppress("LongParameterList")
private fun MirrorSurface(
frame: MirrorFrame,
palette: MirrorPalette?,
hasTouch: Boolean,
onEvent: (Int) -> Unit,
onTouch: (Int, Int, Int) -> Unit,
modifier: Modifier = Modifier,
) {
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
var focused by remember { mutableStateOf(false) }
val isFocused by rememberUpdatedState(focused)
val currentFrame by rememberUpdatedState(frame)
val focusColor = MaterialTheme.colorScheme.primary
fun toggleKeyboard() = if (focused) focusManager.clearFocus() else focusRequester.requestFocus()
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
@@ -271,17 +245,34 @@ private fun MirrorSurface(
.onFocusChanged { focused = it.isFocused }
.focusable()
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
keyToInputEvent(event.key)?.let {
onEvent(it)
true
} ?: false
val mapped = keyToInputEvent(event.key) ?: return@onPreviewKeyEvent false
// Consume KeyUp of mapped keys too, or Space/arrows leak to the scroll container.
if (event.type == KeyEventType.KeyDown) onEvent(mapped)
true
}
.pointerInput(Unit) {
.pointerInput(hasTouch) {
detectTapGestures(
onTap = {
// Tap toggles keyboard control so there is always a way out of capture.
if (isFocused) focusManager.clearFocus() else focusRequester.requestFocus()
onTap = { offset ->
if (hasTouch) {
onTouch(
INPUT_USER_PRESS,
offset.toDeviceX(size.width, currentFrame),
offset.toDeviceY(size.height, currentFrame),
)
} else {
// Tap toggles keyboard control so there is always a way out of capture.
toggleKeyboard()
}
},
onLongPress = { offset ->
// Physical touch drivers map a long-press to SELECT with coordinates.
if (hasTouch) {
onTouch(
INPUT_SELECT,
offset.toDeviceX(size.width, currentFrame),
offset.toDeviceY(size.height, currentFrame),
)
}
},
)
}
@@ -290,18 +281,39 @@ private fun MirrorSurface(
) {
MirrorFrameImage(frame, palette)
}
MirrorControlHints(focused = focused, hasTouch = hasTouch, onToggleKeyboard = { toggleKeyboard() })
}
}
@Composable
private fun MirrorControlHints(focused: Boolean, hasTouch: Boolean, onToggleKeyboard: () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = focused,
onClick = onToggleKeyboard,
label = { Text(stringResource(Res.string.mirror_keyboard)) },
)
Text(
text =
if (focused) {
"Keyboard active: arrows · Enter = OK · Esc = back — click the screen again to exit"
} else {
"Click the screen for keyboard control, or swipe it to navigate"
},
stringResource(
when {
focused -> Res.string.mirror_hint_keyboard_active
hasTouch -> Res.string.mirror_hint_tap_touch
else -> Res.string.mirror_hint_tap_keyboard
},
),
style = MaterialTheme.typography.labelSmall,
)
}
}
/** Scales a tap position on the scaled-up mirror image back to panel pixel coordinates. */
private fun Offset.toDeviceX(boxWidthPx: Int, frame: MirrorFrame): Int =
(x / boxWidthPx * frame.width).toInt().coerceIn(0, frame.width - 1)
private fun Offset.toDeviceY(boxHeightPx: Int, frame: MirrorFrame): Int =
(y / boxHeightPx * frame.height).toInt().coerceIn(0, frame.height - 1)
private fun keyToInputEvent(key: Key): Int? = when (key) {
Key.DirectionUp -> INPUT_UP
@@ -348,97 +360,27 @@ private fun Modifier.swipeToDirection(onEvent: (Int) -> Unit): Modifier = pointe
)
}
/**
* Cross-shaped 5-way cluster with Back below-left, following the TV-remote convention: directions auto-repeat on hold
* (500ms delay, then 10Hz — slow enough for the radio to render), OK long-press sends the firmware's SELECT_LONG.
*/
/** The remote cluster: circular 5-way ring (see [DpadRing]) with Back below-left, per TV-remote convention. */
@Composable
private fun DpadCluster(enabled: Boolean, onEvent: (Int) -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(DPAD_GAP),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
RepeatingKey(MeshtasticIcons.KeyboardArrowUp, "Up", enabled) { onEvent(INPUT_UP) }
Row(horizontalArrangement = Arrangement.spacedBy(DPAD_GAP)) {
RepeatingKey(MeshtasticIcons.KeyboardArrowLeft, "Left", enabled) { onEvent(INPUT_LEFT) }
OkKey(
enabled = enabled,
onSelect = { onEvent(INPUT_SELECT) },
onSelectLong = { onEvent(INPUT_SELECT_LONG) },
)
RepeatingKey(MeshtasticIcons.KeyboardArrowRight, "Right", enabled) { onEvent(INPUT_RIGHT) }
}
RepeatingKey(MeshtasticIcons.KeyboardArrowDown, "Down", enabled) { onEvent(INPUT_DOWN) }
Row(modifier = Modifier.widthIn(min = DPAD_KEY_SIZE * 3 + DPAD_GAP * 2)) {
BackKey(enabled = enabled) { onEvent(INPUT_BACK) }
}
}
}
/** A direction key: fires on press, then auto-repeats while held. */
@Composable
private fun RepeatingKey(icon: ImageVector, label: String, enabled: Boolean, onEvent: () -> Unit) {
var pressed by remember { mutableStateOf(false) }
val background =
when {
!enabled -> MaterialTheme.colorScheme.surfaceVariant
pressed -> MaterialTheme.colorScheme.primaryContainer
else -> MaterialTheme.colorScheme.secondaryContainer
}
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier.size(DPAD_KEY_SIZE).clip(CircleShape).background(background).pointerInput(enabled) {
if (!enabled) return@pointerInput
coroutineScope {
detectTapGestures(
onPress = {
pressed = true
onEvent()
val repeater = launch {
delay(REPEAT_INITIAL_DELAY_MS)
while (isActive) {
onEvent()
delay(REPEAT_INTERVAL_MS)
}
}
tryAwaitRelease()
repeater.cancel()
pressed = false
},
)
}
},
) {
Icon(imageVector = icon, contentDescription = label, tint = contentColorFor(enabled))
}
}
/** Center OK: tap selects, long-press sends SELECT_LONG. */
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun OkKey(enabled: Boolean, onSelect: () -> Unit, onSelectLong: () -> Unit) {
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier.size(DPAD_KEY_SIZE)
.clip(CircleShape)
.background(
if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
)
.combinedClickable(enabled = enabled, onLongClick = onSelectLong, onClick = onSelect),
) {
Text(
text = "OK",
style = MaterialTheme.typography.titleSmall,
color = if (enabled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
DpadRing(
enabled = enabled,
onEvent = onEvent,
onSelect = { onEvent(INPUT_SELECT) },
onSelectLong = { onEvent(INPUT_SELECT_LONG) },
)
BackKey(enabled = enabled) { onEvent(INPUT_BACK) }
}
}
@Composable
private fun BackKey(enabled: Boolean, onBack: () -> Unit) {
val haptics = LocalHapticFeedback.current
Box(
contentAlignment = Alignment.Center,
modifier =
@@ -451,9 +393,16 @@ private fun BackKey(enabled: Boolean, onBack: () -> Unit) {
MaterialTheme.colorScheme.surfaceVariant
},
)
.combinedClickable(enabled = enabled, onClick = onBack),
.combinedClickable(enabled = enabled) {
haptics.performHapticFeedback(HapticFeedbackType.VirtualKey)
onBack()
},
) {
Text(text = "Back", style = MaterialTheme.typography.labelMedium, color = contentColorFor(enabled))
Text(
text = stringResource(Res.string.mirror_back),
style = MaterialTheme.typography.labelMedium,
color = contentColorFor(enabled),
)
}
}
@@ -0,0 +1,95 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.debugging
import androidx.lifecycle.ViewModel
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.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.MirrorPalette
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.proto.DisplayInfo
@KoinViewModel
class DisplayMirrorViewModel(
private val displayMirrorManager: DisplayMirrorManager,
connectionStateProvider: ConnectionStateProvider,
nodeRepository: NodeRepository,
private val adminController: AdminController,
) : ViewModel() {
val frame: StateFlow<MirrorFrame?> = displayMirrorManager.frame
val palette: StateFlow<MirrorPalette?> = displayMirrorManager.palette
val connected: StateFlow<Boolean> =
connectionStateProvider.connectionState
.map { it is ConnectionState.Connected }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
/** Panel description from the connect handshake; null on display-less nodes and pre-DisplayInfo firmware. */
val displayInfo: StateFlow<DisplayInfo?> =
nodeRepository.ourNodeInfo.map { it?.metadata?.display }.stateIn(viewModelScope, SharingStarted.Eagerly, null)
private val _mirroring = MutableStateFlow(false)
val mirroring: StateFlow<Boolean> = _mirroring.asStateFlow()
init {
// A fresh mirror UI must never show a previous device's final screen as if live.
displayMirrorManager.reset()
// 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
displayMirrorManager.reset()
}
}
}
}
fun setMirror(enabled: Boolean) {
adminController.setDisplayMirror(enabled)
_mirroring.value = enabled
}
fun requestFrame() = adminController.requestDisplayFrame()
fun sendKey(eventCode: Int) = adminController.sendInputEvent(eventCode)
/** Forwards a tap/long-press on the mirrored image as a device touch event with panel coordinates. */
fun sendTouch(eventCode: Int, x: Int, y: Int) = adminController.sendInputEvent(eventCode, touchX = x, touchY = y)
/** 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()
}
@@ -0,0 +1,276 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.debugging
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.mirror_key_down
import org.meshtastic.core.resources.mirror_key_left
import org.meshtastic.core.resources.mirror_key_ok
import org.meshtastic.core.resources.mirror_key_right
import org.meshtastic.core.resources.mirror_key_up
import org.meshtastic.core.ui.icon.KeyboardArrowDown
import org.meshtastic.core.ui.icon.KeyboardArrowLeft
import org.meshtastic.core.ui.icon.KeyboardArrowRight
import org.meshtastic.core.ui.icon.KeyboardArrowUp
import org.meshtastic.core.ui.icon.MeshtasticIcons
import kotlin.math.PI
import kotlin.math.atan2
import kotlin.math.hypot
// Geometry per the TV-remote research: ~220dp ring, 72-80dp center disc,
// a radial dead band between disc and ring so ambiguous presses do nothing.
private val RING_DIAMETER = 220.dp
private val OK_DISC_DIAMETER = 76.dp
private val DEAD_ZONE = 12.dp
private const val WEDGE_SWEEP_DEG = 90f
private const val FULL_CIRCLE_DEG = 360f
private const val RAD_TO_DEG = (180.0 / PI).toFloat()
// Minimum comfortable assistive-tech target (M3).
private val SEMANTICS_TARGET = 48.dp
// One direction wedge of the ring: hit-test bounds, highlight arc, icon placement.
private class Wedge(
val eventCode: Int,
val label: StringResource,
val startAngleDeg: Float,
val iconOffsetX: Dp,
val iconOffsetY: Dp,
)
@Suppress("MagicNumber")
private fun buildWedges(iconRadius: Dp): List<Wedge> = listOf(
Wedge(INPUT_RIGHT, Res.string.mirror_key_right, -45f, iconRadius, 0.dp),
Wedge(INPUT_DOWN, Res.string.mirror_key_down, 45f, 0.dp, iconRadius),
Wedge(INPUT_LEFT, Res.string.mirror_key_left, 135f, -iconRadius, 0.dp),
Wedge(INPUT_UP, Res.string.mirror_key_up, 225f, 0.dp, -iconRadius),
)
@Composable
private fun wedgeIcon(eventCode: Int) = when (eventCode) {
INPUT_UP -> MeshtasticIcons.KeyboardArrowUp
INPUT_DOWN -> MeshtasticIcons.KeyboardArrowDown
INPUT_LEFT -> MeshtasticIcons.KeyboardArrowLeft
else -> MeshtasticIcons.KeyboardArrowRight
}
private fun List<Wedge>.at(angleDeg: Float): Wedge = first { wedge ->
val start = (wedge.startAngleDeg + FULL_CIRCLE_DEG) % FULL_CIRCLE_DEG
val normalized = if (angleDeg < start) angleDeg + FULL_CIRCLE_DEG else angleDeg
normalized >= start && normalized < start + WEDGE_SWEEP_DEG
}
/**
* Circular 5-way remote cluster: four direction wedges around a center OK disc, the layout every TV remote converged
* on. Directions fire on press and auto-repeat while held; OK taps SELECT and long-presses SELECT_LONG. Wedge
* hit-testing is by angle with a dead band around the disc; each wedge also carries its own semantics node so screen
* readers see four buttons, not one blob.
*/
@Composable
internal fun DpadRing(
enabled: Boolean,
onEvent: (Int) -> Unit,
onSelect: () -> Unit,
onSelectLong: () -> Unit,
modifier: Modifier = Modifier,
) {
val haptics = LocalHapticFeedback.current
var pressedWedge by remember { mutableStateOf<Int?>(null) }
// Icons and semantics targets sit on the ring stroke's center line.
val wedges = remember { buildWedges((RING_DIAMETER / 2 + OK_DISC_DIAMETER / 2 + DEAD_ZONE) / 2) }
val ringColor =
if (enabled) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceVariant
val pressedColor = MaterialTheme.colorScheme.primaryContainer
Box(modifier = modifier.size(RING_DIAMETER), contentAlignment = Alignment.Center) {
Canvas(
modifier =
Modifier.size(RING_DIAMETER)
.ringPresses(enabled, wedges, haptics, onPressChange = { pressedWedge = it }, onEvent = onEvent),
) {
drawRing(ringColor, pressedColor, wedges, pressedWedge)
}
WedgeDecorations(wedges = wedges, enabled = enabled, onEvent = onEvent)
OkDisc(enabled = enabled, haptics = haptics, onSelect = onSelect, onSelectLong = onSelectLong)
}
}
/** Press handling for the ring: angle hit-testing outside the dead band, then fire + auto-repeat until release. */
private fun Modifier.ringPresses(
enabled: Boolean,
wedges: List<Wedge>,
haptics: HapticFeedback,
onPressChange: (Int?) -> Unit,
onEvent: (Int) -> Unit,
): Modifier = pointerInput(enabled) {
if (!enabled) return@pointerInput
val outerR = size.width / 2f
val innerR = (OK_DISC_DIAMETER.toPx() / 2f) + DEAD_ZONE.toPx()
val center = Offset(outerR, outerR)
coroutineScope {
awaitEachGesture {
val down = awaitFirstDown()
val d = down.position - center
val r = hypot(d.x, d.y)
if (r < innerR || r > outerR) return@awaitEachGesture
down.consume()
val angle = (atan2(d.y, d.x) * RAD_TO_DEG + FULL_CIRCLE_DEG) % FULL_CIRCLE_DEG
val wedge = wedges.at(angle)
onPressChange(wedge.eventCode)
haptics.performHapticFeedback(HapticFeedbackType.VirtualKey)
onEvent(wedge.eventCode)
val repeater = launch {
delay(REPEAT_INITIAL_DELAY_MS)
while (isActive) {
haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
onEvent(wedge.eventCode)
delay(REPEAT_INTERVAL_MS)
}
}
try {
waitForUpOrCancellation()
} finally {
// The pointerInput coroutine can be cancelled mid-press (enabled
// flip, node removal); never leave a wedge stuck highlighted.
repeater.cancel()
onPressChange(null)
}
}
}
}
private fun DrawScope.drawRing(ringColor: Color, pressedColor: Color, wedges: List<Wedge>, pressedWedge: Int?) {
val outerR = size.width / 2f
val visualInnerR = (OK_DISC_DIAMETER.toPx() / 2f) + DEAD_ZONE.toPx()
val strokeWidth = outerR - visualInnerR
val strokeRadius = (outerR + visualInnerR) / 2f
drawCircle(color = ringColor, radius = strokeRadius, style = Stroke(width = strokeWidth))
pressedWedge?.let { pressed ->
val wedge = wedges.first { it.eventCode == pressed }
drawArc(
color = pressedColor,
startAngle = wedge.startAngleDeg,
sweepAngle = WEDGE_SWEEP_DEG,
useCenter = false,
topLeft = Offset(outerR - strokeRadius, outerR - strokeRadius),
size = Size(strokeRadius * 2f, strokeRadius * 2f),
style = Stroke(width = strokeWidth),
)
}
}
/** Chevron icons plus one invisible semantics button per wedge so assistive tech sees four discrete controls. */
@Composable
private fun WedgeDecorations(wedges: List<Wedge>, enabled: Boolean, onEvent: (Int) -> Unit) {
val iconColor =
if (enabled) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurfaceVariant
wedges.forEach { wedge ->
val label = stringResource(wedge.label)
Icon(
imageVector = wedgeIcon(wedge.eventCode),
contentDescription = null,
tint = iconColor,
modifier = Modifier.offset(x = wedge.iconOffsetX, y = wedge.iconOffsetY),
)
Box(
modifier =
Modifier.offset(x = wedge.iconOffsetX, y = wedge.iconOffsetY).size(SEMANTICS_TARGET).semantics {
role = Role.Button
contentDescription = label
onClick(label = label) {
if (enabled) onEvent(wedge.eventCode)
enabled
}
},
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun OkDisc(enabled: Boolean, haptics: HapticFeedback, onSelect: () -> Unit, onSelectLong: () -> Unit) {
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier.size(OK_DISC_DIAMETER)
.clip(CircleShape)
.background(
if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
)
.combinedClickable(
enabled = enabled,
onLongClick = onSelectLong,
onClick = {
haptics.performHapticFeedback(HapticFeedbackType.VirtualKey)
onSelect()
},
),
) {
val okColor = if (enabled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant
Text(
text = stringResource(Res.string.mirror_key_ok),
style = MaterialTheme.typography.titleMedium,
color = okColor,
)
}
}
@@ -33,8 +33,11 @@ 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 org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.repository.MirrorFrame
import org.meshtastic.core.repository.MirrorPalette
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.mirror_device_screen
// 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
@@ -53,7 +56,7 @@ internal fun MirrorFrameImage(frame: MirrorFrame, palette: MirrorPalette?, modif
val bitmap = remember(frame, activePalette) { renderFrame(frame, activePalette) }
Image(
bitmap = bitmap,
contentDescription = "Device screen",
contentDescription = stringResource(Res.string.mirror_device_screen),
modifier =
modifier
.widthIn(max = MAX_CANVAS_WIDTH)
@@ -63,7 +66,8 @@ internal fun MirrorFrameImage(frame: MirrorFrame, palette: MirrorPalette?, modif
)
}
private class ResolvedRegion(
/** One colorized rectangle with palette colors pre-resolved to Compose colors. */
internal class ResolvedRegion(
val left: Int,
val top: Int,
val right: Int,
@@ -72,51 +76,84 @@ private class ResolvedRegion(
val off: Color,
)
private fun rgb565ToColor(v: Int): Color = Color(
red = ((v ushr 11) and 0x1F) * 255 / 31,
green = ((v ushr 5) and 0x3F) * 255 / 63,
blue = (v and 0x1F) * 255 / 31,
)
/** Expands RGB565 to 8-bit channels by bit replication (the canonical expansion; plain scaling floors). */
@Suppress("MagicNumber")
internal fun rgb565ToColor(v: Int): Color {
val r5 = (v ushr 11) and 0x1F
val g6 = (v ushr 5) and 0x3F
val b5 = v and 0x1F
return Color(red = (r5 shl 3) or (r5 ushr 2), green = (g6 shl 2) or (g6 ushr 4), blue = (b5 shl 3) or (b5 ushr 2))
}
/**
* Resolves one pixel against the regions overlapping its row, highest table index winning — the firmware's precedence.
* [rowRegions] must already be culled to the pixel's row.
*/
internal fun resolvePixelColor(
x: Int,
set: Boolean,
rowRegions: List<ResolvedRegion>,
defaultOn: Color,
defaultOff: Color,
): Color {
for (i in rowRegions.indices.reversed()) {
val region = rowRegions[i]
if (x >= region.left && x < region.right) return if (set) region.on else region.off
}
return if (set) defaultOn else defaultOff
}
internal fun MirrorPalette.resolveRegions(): List<ResolvedRegion> = regions.map {
ResolvedRegion(
left = it.x,
top = it.y,
right = it.x + it.width,
bottom = it.y + it.height,
on = rgb565ToColor(it.on_color),
off = rgb565ToColor(it.off_color),
)
}
private fun renderFrame(frame: MirrorFrame, palette: MirrorPalette?): ImageBitmap {
val defaultOn = palette?.let { rgb565ToColor(it.defaultOnColor) } ?: Color.White
val defaultOff = palette?.let { rgb565ToColor(it.defaultOffColor) } ?: Color.Black
val regions =
palette?.regions.orEmpty().map {
ResolvedRegion(
left = it.x,
top = it.y,
right = it.x + it.width,
bottom = it.y + it.height,
on = rgb565ToColor(it.on_color),
off = rgb565ToColor(it.off_color),
)
}
val regions = palette?.resolveRegions().orEmpty()
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 = defaultOff)
val pixel = Size(1f, 1f)
val rowRegions = ArrayList<ResolvedRegion>(regions.size)
for (y in 0 until frame.height) {
rowRegions.clear()
for (region in regions) if (y >= region.top && y < region.bottom) rowRegions.add(region)
val page = (y / PIXELS_PER_PAGE) * frame.width
val bit = 1 shl (y % PIXELS_PER_PAGE)
// Cull to the regions overlapping this row; the last matching region wins,
// matching the firmware's precedence.
val rowRegions = regions.filter { y >= it.top && y < it.bottom }
for (x in 0 until frame.width) {
val set = frame.pixels[page + x].toInt() and bit != 0
val region = rowRegions.lastOrNull { x >= it.left && x < it.right }
val color =
when {
region != null -> if (set) region.on else region.off
set -> defaultOn
else -> defaultOff
}
if (color != defaultOff) {
drawRect(color = color, topLeft = Offset(x.toFloat(), y.toFloat()), size = pixel)
// Emit horizontal runs of identical color; framebuffers are run-heavy,
// and one draw per run beats one draw per pixel by orders of magnitude.
var runStart = 0
var runColor: Color? = null
fun flush(endExclusive: Int) {
val color = runColor
if (color != null && color != defaultOff) {
drawRect(
color = color,
topLeft = Offset(runStart.toFloat(), y.toFloat()),
size = Size((endExclusive - runStart).toFloat(), 1f),
)
}
}
for (x in 0 until frame.width) {
val set = frame.pixels[page + x].toInt() and bit != 0
val color = resolvePixelColor(x, set, rowRegions, defaultOn, defaultOff)
if (color != runColor) {
flush(x)
runStart = x
runColor = color
}
}
flush(frame.width)
}
}
return bitmap
@@ -0,0 +1,87 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.debugging
import androidx.compose.ui.graphics.Color
import kotlin.test.Test
import kotlin.test.assertEquals
class MirrorColorResolverTest {
@Test
fun `rgb565 expands by bit replication`() {
assertEquals(Color(255, 255, 255), rgb565ToColor(0xFFFF))
assertEquals(Color(0, 0, 0), rgb565ToColor(0x0000))
assertEquals(Color(255, 0, 0), rgb565ToColor(0xF800))
assertEquals(Color(0, 255, 0), rgb565ToColor(0x07E0))
assertEquals(Color(0, 0, 255), rgb565ToColor(0x001F))
// 5-bit 16 replicates to 10000100b = 132, where plain scaling floors to 131
assertEquals(132, (rgb565ToColor(16 shl 11).red * 255).toInt())
}
@Test
fun `highest-index region wins where regions overlap`() {
val under = ResolvedRegion(left = 0, top = 0, right = 100, bottom = 10, on = Color.Red, off = Color.Black)
val over = ResolvedRegion(left = 50, top = 0, right = 100, bottom = 10, on = Color.Green, off = Color.Blue)
val rows = listOf(under, over)
assertEquals(
Color.Red,
resolvePixelColor(x = 10, set = true, rowRegions = rows, defaultOn = Color.White, defaultOff = Color.Black),
)
assertEquals(
Color.Green,
resolvePixelColor(x = 60, set = true, rowRegions = rows, defaultOn = Color.White, defaultOff = Color.Black),
)
assertEquals(
Color.Blue,
resolvePixelColor(
x = 60,
set = false,
rowRegions = rows,
defaultOn = Color.White,
defaultOff = Color.Black,
),
)
}
@Test
fun `pixels outside all regions use the defaults`() {
val region = ResolvedRegion(left = 0, top = 0, right = 10, bottom = 10, on = Color.Red, off = Color.Black)
assertEquals(
Color.White,
resolvePixelColor(
x = 50,
set = true,
rowRegions = listOf(region),
defaultOn = Color.White,
defaultOff = Color.Gray,
),
)
assertEquals(
Color.Gray,
resolvePixelColor(
x = 50,
set = false,
rowRegions = listOf(region),
defaultOn = Color.White,
defaultOff = Color.Gray,
),
)
}
}