From 21bc10affb3d833c187d5d529859958f8cf0ba21 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:01:37 -0500 Subject: [PATCH] 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 --- .skills/compose-ui/strings-index.txt | 19 + .../data/manager/DisplayMirrorManagerImpl.kt | 50 ++- .../manager/DisplayMirrorManagerImplTest.kt | 33 ++ .../DisplayMirrorManagerImplTest.kt.bak | 134 ------- .../manager/FromRadioPacketHandlerImplTest.kt | 12 + .../core/repository/DisplayMirrorManager.kt | 6 + .../composeResources/values/strings.xml | 19 + .../feature/settings/debugging/Debug.kt | 4 +- .../settings/debugging/DisplayMirror.kt | 345 ++++++++---------- .../debugging/DisplayMirrorViewModel.kt | 95 +++++ .../feature/settings/debugging/DpadRing.kt | 276 ++++++++++++++ .../settings/debugging/MirrorFrameRenderer.kt | 103 ++++-- .../debugging/MirrorColorResolverTest.kt | 87 +++++ 13 files changed, 810 insertions(+), 373 deletions(-) delete mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/DisplayMirrorManagerImplTest.kt.bak create mode 100644 feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DisplayMirrorViewModel.kt create mode 100644 feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DpadRing.kt create mode 100644 feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/MirrorColorResolverTest.kt diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index ed3ce5e859..0f686531c9 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -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 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 e100865cb2..61be808a11 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 @@ -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() 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 } 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 index 1af13b31cc..652b89a442 100644 --- 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 @@ -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 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 deleted file mode 100644 index f3fe7f4a66..0000000000 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/DisplayMirrorManagerImplTest.kt.bak +++ /dev/null @@ -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 . - */ -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 7ad8cdcb53..8153c0240e 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 @@ -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") 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 058802d9d5..7f075df293 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 @@ -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() } /** diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 1d2a59ed30..ee9ad87931 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -383,6 +383,7 @@ Disable to skip writing mesh logs to disk Store mesh logs App logs + Mirror Packets A link is asking this app to connect to the device at %1$s. Only continue if you trust where the link came from. Connect to this device? @@ -1221,6 +1222,24 @@ Smart Distance Smart Interval Minimum wake time + + Mirroring + Back + Device screen + E-ink display: refresh is slow — prefer one-shot Refresh over continuous mirroring. + %1$d×%2$d · frame #%3$d + Arrows · Enter = OK · Esc = back + Click the screen for keyboard control, or swipe it to navigate + Tap the screen to touch the device, or swipe to navigate + Down + Left + OK + Right + Up + Keyboard + No frame received yet — enable mirroring or tap Refresh. + Not connected to a device. + Mirror off Presets Module configuration Modules already unlocked diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt index 6490a5733e..14b7b1b558 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt @@ -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) { 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 e715d32aa0..fc57d1a7df 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,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 = displayMirrorManager.frame - - val palette: StateFlow = displayMirrorManager.palette - - val connected: StateFlow = - 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 = - nodeRepository.ourNodeInfo.map { it?.metadata?.display }.stateIn(viewModelScope, SharingStarted.Eagerly, null) - - private val _mirroring = MutableStateFlow(false) - val mirroring: StateFlow = _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), + ) } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DisplayMirrorViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DisplayMirrorViewModel.kt new file mode 100644 index 0000000000..cc1e72c846 --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DisplayMirrorViewModel.kt @@ -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 . + */ +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 = displayMirrorManager.frame + + val palette: StateFlow = displayMirrorManager.palette + + val connected: StateFlow = + 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 = + nodeRepository.ourNodeInfo.map { it?.metadata?.display }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + private val _mirroring = MutableStateFlow(false) + val mirroring: StateFlow = _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() +} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DpadRing.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DpadRing.kt new file mode 100644 index 0000000000..3895d7650d --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DpadRing.kt @@ -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 . + */ +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 = 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.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(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, + 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, 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, 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, + ) + } +} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/MirrorFrameRenderer.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/MirrorFrameRenderer.kt index 7bff3ef718..14272d637d 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/MirrorFrameRenderer.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/MirrorFrameRenderer.kt @@ -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, + 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 = 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(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 diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/MirrorColorResolverTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/MirrorColorResolverTest.kt new file mode 100644 index 0000000000..0561e5da7e --- /dev/null +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/MirrorColorResolverTest.kt @@ -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 . + */ +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, + ), + ) + } +}