mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-10-08 11:31:24 -04:00
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:
1 parent
8753bdb47e
commit
21bc10affb
13 files changed
+810
-373
No files matched your search
Generated
+19
@@ -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
|
||||
|
||||
+44
-6
@@ -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
|
||||
}
|
||||
|
||||
+33
@@ -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
|
||||
|
||||
-134
@@ -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
|
||||
}
|
||||
}
|
||||
+12
@@ -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")
|
||||
|
||||
+6
@@ -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>
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
+147
-198
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+95
@@ -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()
|
||||
}
|
||||
+276
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+70
-33
@@ -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
|
||||
|
||||
+87
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user