feat: KMP Debug Panel Migration and Update Documentation (#4859)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2026-03-19 14:07:03 -05:00
committed by GitHub
parent e36176bbf7
commit 00697cc3c1
37 changed files with 316 additions and 470 deletions

View File

@@ -1,7 +1,7 @@
{
"track_id": "android_kable_migration_20260314",
"type": "feature",
"status": "new",
"status": "completed",
"created_at": "2026-03-14T17:15:00Z",
"updated_at": "2026-03-14T17:15:00Z",
"description": "Replace Nordic with Kable on Android"

View File

@@ -1,7 +1,7 @@
{
"track_id": "deep_dive_docs_20260316",
"type": "chore",
"status": "new",
"status": "completed",
"created_at": "2026-03-16T12:00:00Z",
"updated_at": "2026-03-16T12:00:00Z",
"description": "do a deep dive of project docs and plans in /docs - verify against actual project/codebase state, then validate against modern best practices for android, kotlin, kmp, and the dependencies used. be thorough - check all the major dependencies. Update docs and plans accordingly."

View File

@@ -1,7 +1,7 @@
{
"track_id": "desktop_ble_kable_20260314",
"type": "feature",
"status": "new",
"status": "completed",
"created_at": "2026-03-14T12:00:00Z",
"updated_at": "2026-03-14T12:00:00Z",
"description": "Kable swap Keep Nordic on Android short-term. Add Kable backend only for jvmMain in core:ble first (desktop BLE enablement). Introduce a MeshtasticRadioProfile abstraction in core:ble/commonMain so NordicBleInterface no longer depends on Android/Nordic classes. Once that seam is clean, decide whether Android should stay Nordic or move to Kable."

View File

@@ -1,7 +1,7 @@
{
"track_id": "desktop_di_autowiring_20260313",
"type": "chore",
"status": "new",
"status": "completed",
"created_at": "2026-03-13T12:00:00Z",
"updated_at": "2026-03-13T12:00:00Z",
"description": "Architecture Health & DI (Immediate Priority) * Desktop Koin checkModules() test: Add a test to ensure Desktop DI bindings are validated at compile-time/test-time so we catch missing interfaces early. * Auto-wire Desktop ViewModels: Configure KSP so we can eliminate the manual ViewModel wiring in DesktopKoinModule and rely on @KoinViewModel annotations like Android does."

View File

@@ -1,7 +1,7 @@
{
"track_id": "desktop_parity_20260311",
"type": "feature",
"status": "new",
"status": "completed",
"created_at": "2026-03-11T12:00:00Z",
"updated_at": "2026-03-11T12:00:00Z",
"description": "continue bringing desktop up to parity with android"

View File

@@ -1,7 +1,7 @@
{
"track_id": "desktop_serial_transport_20260317",
"type": "feature",
"status": "new",
"status": "completed",
"created_at": "2026-03-17T12:00:00Z",
"updated_at": "2026-03-17T12:00:00Z",
"description": "Implement Serial/USB transport for the Desktop target using jSerialComm. This fulfills the medium-term priority for direct radio connections on JVM and uses the shared RadioTransport interface."

View File

@@ -1,7 +1,7 @@
{
"id": "desktop_ux_enhancements_20260316",
"name": "Desktop UX Enhancements",
"status": "in-progress",
"status": "completed",
"priority": "medium",
"tags": ["desktop", "ux", "compose"]
}

View File

@@ -1,7 +1,7 @@
{
"track_id": "doc_consolidation_20260311",
"type": "feature",
"status": "new",
"status": "completed",
"created_at": "2026-03-11T00:00:00Z",
"updated_at": "2026-03-11T00:00:00Z",
"description": "Implement document consolidation plan"

View File

@@ -1,7 +1,7 @@
{
"track_id": "expand_testing_20260318",
"type": "chore",
"status": "new",
"status": "completed",
"created_at": "2026-03-18T10:00:00Z",
"updated_at": "2026-03-18T10:00:00Z",
"description": "Expand Testing Coverage"

View File

@@ -1,7 +1,7 @@
{
"track_id": "extract_android_navigation_20260318",
"type": "refactor",
"status": "new",
"status": "completed",
"created_at": "2026-03-18T00:00:00Z",
"updated_at": "2026-03-18T00:00:00Z",
"description": "Extract Android Navigation graphs to feature modules for app thinning"

View File

@@ -1,7 +1,7 @@
{
"track_id": "extract_hardware_transport_20260311",
"type": "feature",
"status": "new",
"status": "completed",
"created_at": "2026-03-11T00:00:00Z",
"updated_at": "2026-03-11T00:00:00Z",
"description": "extract hardware/transport layers out of :app into dedicated :core modules"

View File

@@ -1,7 +1,7 @@
{
"track_id": "extract_remaining_background_20260318",
"type": "refactor",
"status": "new",
"status": "completed",
"created_at": "2026-03-18T14:55:00Z",
"updated_at": "2026-03-18T14:55:00Z",
"description": "Extract remaining background services and workers from app module"

View File

@@ -1,7 +1,7 @@
{
"track_id": "extract_services_20260317",
"type": "refactor",
"status": "new",
"status": "completed",
"created_at": "2026-03-17T00:00:00Z",
"updated_at": "2026-03-17T00:00:00Z",
"description": "Extract service/worker/radio files from `app` to `core:service/androidMain` and `core:network/androidMain`"

View File

@@ -1,7 +1,7 @@
{
"track_id": "extract_viewmodels_20260316",
"type": "refactor",
"status": "new",
"status": "completed",
"created_at": "2026-03-16T12:00:00Z",
"updated_at": "2026-03-16T12:00:00Z",
"description": "Extract remaining 5 App-Only ViewModels (AndroidSettingsViewModel, AndroidRadioConfigViewModel, AndroidDebugViewModel, AndroidMetricsViewModel, UIViewModel) to shared KMP feature/core modules by isolating Android-specific dependencies (Uri, Location, Locale) behind abstractions."

View File

@@ -1,7 +1,7 @@
{
"track_id": "fix_android_animations_20260313",
"type": "bug",
"status": "new",
"status": "completed",
"created_at": "2026-03-13T12:00:00Z",
"updated_at": "2026-03-13T12:00:00Z",
"description": "Android animations broken - mainly noticeable on Connections screen, the indescriminate circular and linear progress bars don't move, and the MeshActivity animation is not firing, investigate recomposition and threading strangely enough they're working on Desktop"

View File

@@ -1,7 +1,7 @@
{
"track_id": "kmp_doc_review_20260313",
"type": "chore",
"status": "new",
"status": "completed",
"created_at": "2026-03-13T12:00:00Z",
"updated_at": "2026-03-13T12:00:00Z",
"description": "do a thorough review of the project docs for quality and veracity against the current codebase and recent changes - use tooling as needed. Evaluate updating project documentation for clarity and context. Synthesize and condense documentation and plans as needed. Be sure to thoroughly investigate the current state of the codebase and it's migration to kmp."

View File

@@ -1,7 +1,7 @@
{
"track_id": "kmp_test_migration_20260318",
"type": "chore",
"status": "new",
"status": "completed",
"created_at": "2026-03-18T10:00:00Z",
"updated_at": "2026-03-18T10:00:00Z",
"description": "Migrate tests to KMP best practices and expand coverage"

View File

@@ -0,0 +1,5 @@
# Track migrate_debug_panel_20260319 Context
- [Specification](./spec.md)
- [Implementation Plan](./plan.md)
- [Metadata](./metadata.json)

View File

@@ -0,0 +1,8 @@
{
"track_id": "migrate_debug_panel_20260319",
"type": "feature",
"status": "completed",
"created_at": "2026-03-19T00:00:00Z",
"updated_at": "2026-03-19T10:00:00Z",
"description": "migrate the fully featured debug panel to common source for use in other targets, wire it up in desktop"
}

View File

@@ -0,0 +1,23 @@
# Implementation Plan: Debug Panel KMP Migration
## Phase 1: Analysis and Relocation [checkpoint: a2e83eb]
- [x] Task: Locate all source files for the Android Debug Panel (UI, ViewModels, States).
- [x] Task: Move these files from the Android-specific source sets (e.g., `feature/settings/src/androidMain`) into `feature/settings/src/commonMain`.
- [x] Task: Conductor - User Manual Verification 'Phase 1: Analysis and Relocation' (Protocol in workflow.md)
## Phase 2: Adaptation to KMP [checkpoint: 834f42c]
- [x] Task: Resolve compilation errors by removing Android-specific imports (`android.*`, `java.*`).
- [x] Task: Migrate Android Jetpack Compose imports (`androidx.compose`) to Compose Multiplatform equivalents (`org.jetbrains.compose.*` or ensuring the standard Multiplatform aliases are used).
- [x] Task: Ensure the Debug Panel ViewModel uses the multiplatform `androidx.lifecycle.ViewModel`.
- [x] Task: Abstract any necessary platform-specific logging or hardware interactions using `expect`/`actual` or KMP interfaces.
- [x] Task: Write or migrate corresponding unit tests to `commonTest`.
- [x] Task: Conductor - User Manual Verification 'Phase 2: Adaptation to KMP' (Protocol in workflow.md)
## Phase 3: Desktop Integration [checkpoint: de2ae06]
- [x] Task: Wire the Debug Panel into the Desktop target's settings menu (`DesktopSettingsNavigation.kt`).
- [x] Task: Add DI bindings for the Desktop module if the Debug Panel requires specific dependencies.
- [x] Task: Verify the Debug Panel screen can be opened and navigated to from the Desktop app.
- [x] Task: Conductor - User Manual Verification 'Phase 3: Desktop Integration' (Protocol in workflow.md)
## Phase: Review Fixes
- [x] Task: Apply review suggestions ac69e73

View File

@@ -0,0 +1,24 @@
# Specification: Debug Panel KMP Migration
## Overview
Migrate the existing Android-specific Debug Panel to `commonMain` to enable its use across all Kotlin Multiplatform targets, specifically wiring it up for the Desktop target.
## Functional Requirements
- The complete Android debug panel implementation will be moved and adapted to `commonMain`.
- All capabilities from the existing Android debug panel should be preserved and made functional on the Desktop target if possible.
- The Debug Panel will be accessible within the Desktop Settings menu, mirroring the Android application's navigation structure.
- Any platform-specific system logging (e.g., Logcat) that cannot be migrated will be appropriately abstracted or gracefully degraded.
## Non-Functional Requirements
- **Architecture:** Follow the project's MVI/UDF architecture.
- **UI:** Leverage Compose Multiplatform for the shared UI, removing any Android-specific Jetpack Compose dependencies from the core shared UI logic.
- **Testing:** Add `commonTest` coverage for the migrated ViewModels and presentation logic.
## Acceptance Criteria
- [ ] The Debug Panel source code resides in a `commonMain` module (e.g., `feature/settings/src/commonMain`).
- [ ] The Debug Panel compiles and runs successfully on both the Android and Desktop targets.
- [ ] The Desktop application can navigate to the Debug Panel from the Settings menu.
- [ ] Essential debug features (transport logs, packet inspection, etc.) function on the Desktop.
## Out of Scope
- Creating new debug capabilities that do not already exist in the Android implementation.

View File

@@ -1,7 +1,7 @@
{
"track_id": "mqtt_transport_20260318",
"type": "feature",
"status": "new",
"status": "completed",
"created_at": "2026-03-18T00:00:00Z",
"updated_at": "2026-03-18T00:00:00Z",
"description": "MQTT transport"

View File

@@ -1,7 +1,7 @@
{
"track_id": "wire_up_notifs_20260316",
"type": "feature",
"status": "new",
"status": "completed",
"created_at": "2026-03-16T00:00:00Z",
"updated_at": "2026-03-16T00:00:00Z",
"description": "wire up notifs"

View File

@@ -18,6 +18,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application designed to facil
- Adaptive node and contact management
- Offline map rendering and device positioning
- Device configuration and firmware updates
- Unified cross-platform debugging and packet inspection
## Key Architecture Goals
- Provide a robust, shared KMP core (`core:model`, `core:ble`, `core:repository`, `core:domain`, `core:data`, `core:network`, `core:service`) to support multiple platforms (Android, Desktop, iOS)

View File

@@ -1,4 +1,3 @@
# Project Tracks
This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder.

View File

@@ -59,7 +59,6 @@ The module depends on the JVM variants of KMP modules:
| `ui/settings/DesktopNetworkConfigScreen.kt` | Network config without QR/NFC scanning |
| `ui/settings/DesktopSecurityConfigScreen.kt` | Security config with JVM `SecureRandom` (omits file export) |
| `ui/settings/DesktopExternalNotificationConfigScreen.kt` | External notification config without MediaPlayer/file import |
| `ui/settings/DesktopDebugScreen.kt` | Desktop-specific debug info screen |
| `ui/nodes/DesktopAdaptiveNodeListScreen.kt` | Adaptive node list-detail using JetBrains `ListDetailPaneScaffold` |
| `ui/messaging/DesktopAdaptiveContactsScreen.kt` | Adaptive contacts list-detail using JetBrains `ListDetailPaneScaffold` |
| `ui/messaging/DesktopMessageContent.kt` | Desktop message content with send, reactions, and selection |

View File

@@ -139,10 +139,10 @@ fun EntryProviderScope<NavKey>.desktopSettingsGraph(backStack: NavBackStack<NavK
CleanNodeDatabaseScreen(viewModel = viewModel)
}
// Debug Panel — Desktop-specific basic log viewer
// Debug Panel — shared commonMain composable
entry<SettingsRoutes.DebugPanel> {
val viewModel: org.meshtastic.feature.settings.debugging.DebugViewModel = koinViewModel()
org.meshtastic.desktop.ui.settings.DesktopDebugScreen(
org.meshtastic.feature.settings.debugging.DebugScreen(
viewModel = viewModel,
onNavigateUp = { backStack.removeLastOrNull() },
)

View File

@@ -1,78 +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.desktop.ui.settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.debug_panel
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.feature.settings.debugging.DebugViewModel
/**
* A basic Desktop implementation of the Debug Panel. Allows viewing the raw mesh logs without the Android-specific
* export/sharing intents.
*/
@Composable
fun DesktopDebugScreen(viewModel: DebugViewModel, onNavigateUp: () -> Unit) {
val logs by viewModel.meshLog.collectAsStateWithLifecycle()
Scaffold(
topBar = {
MainAppBar(
title = stringResource(Res.string.debug_panel),
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
},
) { paddingValues ->
LazyColumn(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
items(logs, key = { it.uuid }) { log ->
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "${log.formattedReceivedDate} - ${log.messageType}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = log.logMessage,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp),
)
}
HorizontalDivider()
}
}
}
}

View File

@@ -63,7 +63,7 @@ Working Compose Desktop application with:
- **Desktop language picker** backed by `UiPreferencesDataSource.locale`, with immediate Compose Multiplatform resource updates
- **Navigation-preserving locale switching** via `Main.kt` `staticCompositionLocalOf` recomposition instead of recreating the Nav3 backstack
- Node detail metrics screens (Device, Environment, Signal, Power, Pax) wired with shared KMP + Vico charts
- 7 desktop-specific screens (Settings, Device, Position, Network, Security, ExternalNotification, Debug)
- 6 desktop-specific screens (Settings, Device, Position, Network, Security, ExternalNotification)
- **Native notifications and system tray icon** wired via `DesktopNotificationManager`
- **Native release pipeline** generating `.dmg` (macOS), `.msi` (Windows), and `.deb` (Linux) installers in CI

View File

@@ -63,7 +63,7 @@ here| **Migrate to JetBrains Compose Multiplatform dependencies** | High | Low |
| Feature | Status |
|---|---|
| Settings | ✅ ~35 real screens (7 desktop-specific) + desktop locale picker with in-place recomposition |
| Settings | ✅ ~35 real screens (6 desktop-specific) + desktop locale picker with in-place recomposition |
| Node list | ✅ Adaptive list-detail with real `NodeDetailContent` |
| Messaging | ✅ Adaptive contacts with real message view + send |
| Connections | ✅ Unified shared UI with dynamic transport detection |

View File

@@ -76,6 +76,17 @@ kotlin {
implementation(project(":core:datastore"))
}
val androidHostTest by getting { dependencies { implementation(project(":core:datastore")) } }
val androidHostTest by getting {
dependencies {
implementation(project(":core:datastore"))
implementation(libs.junit)
implementation(libs.robolectric)
implementation(libs.turbine)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.androidx.compose.ui.test.junit4)
implementation(libs.androidx.compose.ui.test.manifest)
implementation(libs.androidx.test.ext.junit)
}
}
}
}

View File

@@ -40,8 +40,10 @@ import org.meshtastic.core.resources.debug_active_filters
import org.meshtastic.core.resources.debug_filters
import org.meshtastic.core.resources.getString
import org.meshtastic.feature.settings.debugging.DebugViewModel.UiMeshLog
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
@Config(sdk = [34])
class DebugFiltersTest {
@get:Rule val composeTestRule = createComposeRule()

View File

@@ -42,8 +42,10 @@ import org.meshtastic.core.resources.getString
import org.meshtastic.feature.settings.debugging.DebugViewModel.UiMeshLog
import org.meshtastic.feature.settings.debugging.LogSearchManager.SearchMatch
import org.meshtastic.feature.settings.debugging.LogSearchManager.SearchState
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
@Config(sdk = [34])
class DebugSearchTest {
@get:Rule val composeTestRule = createComposeRule()

View File

@@ -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 android.content.Context
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import co.touchlab.kermit.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.debug_export_failed
import org.meshtastic.core.resources.debug_export_success
import org.meshtastic.core.ui.util.showToast
import java.io.OutputStreamWriter
import java.nio.charset.StandardCharsets
@Composable
actual fun rememberLogExporter(logsProvider: suspend () -> List<DebugViewModel.UiMeshLog>): (fileName: String) -> Unit {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val exportLogsLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { createdUri ->
if (createdUri != null) {
scope.launch { exportAllLogsToUri(context, createdUri, logsProvider()) }
}
}
return { fileName -> exportLogsLauncher.launch(fileName) }
}
private suspend fun exportAllLogsToUri(context: Context, targetUri: Uri, logs: List<DebugViewModel.UiMeshLog>) =
withContext(Dispatchers.IO) {
try {
if (logs.isEmpty()) {
withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, "No logs to export") }
Logger.w { "MeshLog export aborted: no logs available" }
return@withContext
}
context.contentResolver.openOutputStream(targetUri)?.use { os ->
OutputStreamWriter(os, StandardCharsets.UTF_8).use { writer ->
logs.forEach { log ->
writer.write("${log.formattedReceivedDate} [${log.messageType}]\n")
writer.write(log.logMessage)
log.decodedPayload?.let { decodedPayload ->
if (decodedPayload.isNotBlank()) {
writer.write("\n\nDecoded Payload:\n{\n")
// Redact Decoded keys.
decodedPayload.lineSequence().forEach { line ->
var outputLine = line
val redacted = redactedKeys.firstOrNull { line.contains(it) }
if (redacted != null) {
val idx = line.indexOf(':')
if (idx != -1) {
outputLine = line.take(idx + 1)
outputLine += "<redacted>"
}
}
writer.write(outputLine)
writer.write("\n")
}
writer.write("}\n\n")
}
}
}
}
}
Logger.i { "MeshLog exported successfully to $targetUri" }
withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_success, logs.size) }
} catch (e: java.io.IOException) {
Logger.e(e) { "Failed to export logs to URI: $targetUri" }
withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, e.message ?: "") }
}
}
private val redactedKeys = listOf("session_passkey", "private_key", "admin_key")

View File

@@ -16,16 +16,11 @@
*/
package org.meshtastic.feature.settings.debugging
import android.content.Context
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
@@ -36,34 +31,26 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FileDownload
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.twotone.FilterAltOff
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
@@ -71,15 +58,10 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.touchlab.kermit.Logger
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.format
@@ -91,11 +73,6 @@ import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.debug_clear
import org.meshtastic.core.resources.debug_decoded_payload
import org.meshtastic.core.resources.debug_default_search
import org.meshtastic.core.resources.debug_export_failed
import org.meshtastic.core.resources.debug_export_success
import org.meshtastic.core.resources.debug_filters
import org.meshtastic.core.resources.debug_logs_export
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
@@ -109,19 +86,11 @@ import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.theme.AnnotationColor
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.settings.debugging.DebugViewModel.UiMeshLog
import java.io.IOException
import java.io.OutputStreamWriter
import java.nio.charset.StandardCharsets
import kotlin.time.Instant.Companion.fromEpochMilliseconds
private val REGEX_ANNOTATED_NODE_ID = Regex("\\(![0-9a-fA-F]{8}\\)$", RegexOption.MULTILINE)
// list of dict keys to redact when exporting logs. These are evaluated as line.contains, so partials are fine.
private var redactedKeys: List<String> = listOf("session_passkey", "private_key", "admin_key")
@Suppress("LongMethod")
@Composable
fun DebugScreen(onNavigateUp: () -> Unit, viewModel: DebugViewModel) {
@@ -130,8 +99,6 @@ fun DebugScreen(onNavigateUp: () -> Unit, viewModel: DebugViewModel) {
val searchState by viewModel.searchState.collectAsStateWithLifecycle()
val filterTexts by viewModel.filterTexts.collectAsStateWithLifecycle()
val selectedLogId by viewModel.selectedLogId.collectAsStateWithLifecycle()
val context = LocalContext.current
val scope = rememberCoroutineScope()
var filterMode by remember { mutableStateOf(FilterMode.OR) }
@@ -157,13 +124,8 @@ fun DebugScreen(onNavigateUp: () -> Unit, viewModel: DebugViewModel) {
listState.requestScrollToItem(searchState.allMatches[searchState.currentMatchIndex].logIndex)
}
}
// Prepare a document creator for exporting logs via SAF
val exportLogsLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { createdUri ->
if (createdUri != null) {
scope.launch { exportAllLogsToUri(context, createdUri, viewModel.loadLogsForExport()) }
}
}
// Prepare a document creator for exporting logs
val exportLogsLauncher = rememberLogExporter { viewModel.loadLogsForExport() }
var showSettings by remember { mutableStateOf(false) }
@@ -216,7 +178,7 @@ fun DebugScreen(onNavigateUp: () -> Unit, viewModel: DebugViewModel) {
val timestamp =
fromEpochMilliseconds(nowMillis).toLocalDateTime(TimeZone.UTC).format(format)
val fileName = "meshtastic_debug_$timestamp.txt"
exportLogsLauncher.launch(fileName)
exportLogsLauncher(fileName)
},
)
if (showSettings) {
@@ -431,53 +393,6 @@ fun DebugMenuActions(deleteLogs: () -> Unit, modifier: Modifier = Modifier) {
}
}
private suspend fun exportAllLogsToUri(context: Context, targetUri: Uri, logs: List<UiMeshLog>) =
withContext(Dispatchers.IO) {
try {
if (logs.isEmpty()) {
withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, "No logs to export") }
Logger.w { "MeshLog export aborted: no logs available" }
return@withContext
}
context.contentResolver.openOutputStream(targetUri)?.use { os ->
OutputStreamWriter(os, StandardCharsets.UTF_8).use { writer ->
logs.forEach { log ->
writer.write("${log.formattedReceivedDate} [${log.messageType}]\n")
writer.write(log.logMessage)
log.decodedPayload?.let { decodedPayload ->
if (decodedPayload.isNotBlank()) {
writer.write("\n\nDecoded Payload:\n{")
writer.write("\n")
// Redact Decoded keys.
decodedPayload.lineSequence().forEach { line ->
var outputLine = line
val redacted = redactedKeys.firstOrNull { line.contains(it) }
if (redacted != null) {
val idx = line.indexOf(':')
if (idx != -1) {
outputLine = line.take(idx + 1)
outputLine += "<redacted>"
}
}
writer.write(outputLine)
writer.write("\n")
}
writer.write("\n}")
}
}
writer.write("\n\n")
}
}
} ?: run { throw IOException("Unable to open output stream for URI: $targetUri") }
withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_success, logs.size) }
} catch (e: IOException) {
withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, e.message ?: "") }
Logger.w(e) { "MeshLog export failed" }
}
}
@Composable
private fun DecodedPayloadBlock(
decodedPayload: String,
@@ -541,281 +456,3 @@ private fun rememberAnnotatedDecodedPayload(
}
}
}
@PreviewLightDark
@Composable
private fun DebugPacketPreview() {
AppTheme {
DebugItem(
UiMeshLog(
uuid = "",
messageType = "NodeInfo",
formattedReceivedDate = "9/27/20, 8:00:58 PM",
logMessage =
"from: 2885173132\n" +
"decoded {\n" +
" position {\n" +
" altitude: 60\n" +
" battery_level: 81\n" +
" latitude_i: 411111136\n" +
" longitude_i: -711111805\n" +
" time: 1600390966\n" +
" }\n" +
"}\n" +
"hop_limit: 3\n" +
"id: 1737414295\n" +
"rx_snr: 9.5\n" +
"rx_time: 316400569\n" +
"to: -1409790708",
),
)
}
}
@PreviewLightDark
@Composable
private fun DebugItemWithSearchHighlightPreview() {
AppTheme {
DebugItem(
UiMeshLog(
uuid = "1",
messageType = "TextMessage",
formattedReceivedDate = "9/27/20, 8:00:58 PM",
logMessage = "Hello world! This is a test message with some keywords to search for.",
),
searchText = "test message",
)
}
}
@PreviewLightDark
@Composable
private fun DebugItemPositionPreview() {
AppTheme {
DebugItem(
UiMeshLog(
uuid = "2",
messageType = "Position",
formattedReceivedDate = "9/27/20, 8:01:15 PM",
logMessage = "Position update from node (!a1b2c3d4) at coordinates 40.7128, -74.0060",
),
)
}
}
@PreviewLightDark
@Composable
private fun DebugItemErrorPreview() {
AppTheme {
DebugItem(
UiMeshLog(
uuid = "3",
messageType = "Error",
formattedReceivedDate = "9/27/20, 8:02:30 PM",
logMessage =
"Connection failed: timeout after 30 seconds\n" +
"Retry attempt: 3/5\n" +
"Last known position: 40.7128, -74.0060",
),
)
}
}
@PreviewLightDark
@Composable
private fun DebugItemLongMessagePreview() {
AppTheme {
DebugItem(
UiMeshLog(
uuid = "4",
messageType = "Waypoint",
formattedReceivedDate = "9/27/20, 8:03:45 PM",
logMessage =
"Waypoint created:\n" +
" Name: Home Base\n" +
" Description: Primary meeting location\n" +
" Latitude: 40.7128\n" +
" Longitude: -74.0060\n" +
" Altitude: 100m\n" +
" Icon: 🏠\n" +
" Created by: (!a1b2c3d4)\n" +
" Expires: 2025-12-31 23:59:59",
),
)
}
}
@PreviewLightDark
@Composable
private fun DebugItemSelectedPreview() {
AppTheme {
DebugItem(
UiMeshLog(
uuid = "5",
messageType = "TextMessage",
formattedReceivedDate = "9/27/20, 8:04:20 PM",
logMessage = "This is a selected log item with larger font sizes for better readability.",
),
isSelected = true,
)
}
}
@PreviewLightDark
@Composable
private fun DebugMenuActionsPreview() {
AppTheme {
Row(modifier = Modifier.padding(16.dp)) {
IconButton(onClick = { /* Preview only */ }, modifier = Modifier.padding(4.dp)) {
Icon(
imageVector = Icons.Outlined.FileDownload,
contentDescription = stringResource(Res.string.debug_logs_export),
)
}
IconButton(onClick = { /* Preview only */ }, modifier = Modifier.padding(4.dp)) {
Icon(imageVector = Icons.Rounded.Delete, contentDescription = stringResource(Res.string.debug_clear))
}
}
}
}
@PreviewLightDark
@Composable
@Suppress("detekt:LongMethod") // big preview
private fun DebugScreenEmptyPreview() {
AppTheme {
Surface {
LazyColumn(modifier = Modifier.fillMaxSize()) {
stickyHeader {
Surface(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Column(modifier = Modifier.padding(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = "",
onValueChange = {},
modifier = Modifier.weight(1f).padding(end = 8.dp),
placeholder = { Text(stringResource(Res.string.debug_default_search)) },
singleLine = true,
)
TextButton(onClick = {}) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = "Filters", style = TextStyle(fontWeight = FontWeight.Bold))
Icon(
imageVector = Icons.TwoTone.FilterAltOff,
contentDescription = stringResource(Res.string.debug_filters),
)
}
}
}
}
}
}
}
// Empty state
item {
Box(modifier = Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "No Debug Logs",
style = TextStyle(fontSize = 18.sp, fontWeight = FontWeight.Bold),
)
Text(
text = "Debug logs will appear here when available",
style = TextStyle(fontSize = 14.sp, color = Color.Gray),
modifier = Modifier.padding(top = 8.dp),
)
}
}
}
}
}
}
}
@PreviewLightDark
@Composable
@Suppress("detekt:LongMethod") // big preview
private fun DebugScreenWithSampleDataPreview() {
AppTheme {
val sampleLogs =
listOf(
UiMeshLog(
uuid = "1",
messageType = "NodeInfo",
formattedReceivedDate = "9/27/20, 8:00:58 PM",
logMessage =
"from: 2885173132\n" +
"decoded {\n" +
" position {\n" +
" altitude: 60\n" +
" battery_level: 81\n" +
" latitude_i: 411111136\n" +
" longitude_i: -711111805\n" +
" time: 1600390966\n" +
" }\n" +
"}\n" +
"hop_limit: 3\n" +
"id: 1737414295\n" +
"rx_snr: 9.5\n" +
"rx_time: 316400569\n" +
"to: -1409790708",
),
UiMeshLog(
uuid = "2",
messageType = "TextMessage",
formattedReceivedDate = "9/27/20, 8:01:15 PM",
logMessage = "Hello from node (!a1b2c3d4)! How's the weather today?",
),
UiMeshLog(
uuid = "3",
messageType = "Position",
formattedReceivedDate = "9/27/20, 8:02:30 PM",
logMessage = "Position update: 40.7128, -74.0060, altitude: 100m, battery: 85%",
),
UiMeshLog(
uuid = "4",
messageType = "Waypoint",
formattedReceivedDate = "9/27/20, 8:03:45 PM",
logMessage = "New waypoint created: 'Meeting Point' at 40.7589, -73.9851",
),
UiMeshLog(
uuid = "5",
messageType = "Error",
formattedReceivedDate = "9/27/20, 8:04:20 PM",
logMessage = "Connection timeout - retrying in 5 seconds...",
),
)
// Note: This preview shows the UI structure but won't have actual data
// since the ViewModel isn't injected in previews
Surface {
LazyColumn(modifier = Modifier.fillMaxSize()) {
stickyHeader {
Surface(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Column(modifier = Modifier.padding(8.dp)) {
Text(
text = "Debug Screen Preview",
style = TextStyle(fontWeight = FontWeight.Bold),
modifier = Modifier.padding(bottom = 8.dp),
)
Text(
text = "Search and filter controls would appear here",
style = TextStyle(fontSize = 12.sp, color = Color.Gray),
)
}
}
}
items(sampleLogs) { log -> DebugItem(log = log) }
}
}
}
}

View File

@@ -0,0 +1,22 @@
/*
* 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.runtime.Composable
@Composable
expect fun rememberLogExporter(logsProvider: suspend () -> List<DebugViewModel.UiMeshLog>): (fileName: String) -> Unit

View File

@@ -0,0 +1,96 @@
/*
* 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.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import co.touchlab.kermit.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
import java.io.FileOutputStream
import java.io.OutputStreamWriter
import java.nio.charset.StandardCharsets
@Composable
actual fun rememberLogExporter(logsProvider: suspend () -> List<DebugViewModel.UiMeshLog>): (fileName: String) -> Unit {
val scope = rememberCoroutineScope()
return { fileName ->
scope.launch {
val logs = logsProvider()
if (logs.isEmpty()) {
Logger.w { "MeshLog export aborted: no logs available" }
return@launch
}
withContext(Dispatchers.IO) {
// Run file dialog to ask user where to save
val fileDialog = FileDialog(null as Frame?, "Export Logs", FileDialog.SAVE)
fileDialog.file = fileName
fileDialog.isVisible = true
val directory = fileDialog.directory
val selectedFile = fileDialog.file
if (directory != null && selectedFile != null) {
val exportFile = File(directory, selectedFile)
try {
FileOutputStream(exportFile).use { fos ->
OutputStreamWriter(fos, StandardCharsets.UTF_8).use { writer ->
logs.forEach { log ->
writer.write("${log.formattedReceivedDate} [${log.messageType}]\n")
writer.write(log.logMessage)
log.decodedPayload?.let { decodedPayload ->
if (decodedPayload.isNotBlank()) {
writer.write("\n\nDecoded Payload:\n{\n")
// Redact Decoded keys.
decodedPayload.lineSequence().forEach { line ->
var outputLine = line
val redacted = redactedKeys.firstOrNull { line.contains(it) }
if (redacted != null) {
val idx = line.indexOf(':')
if (idx != -1) {
outputLine = line.take(idx + 1)
outputLine += "<redacted>"
}
}
writer.write(outputLine)
writer.write("\n")
}
writer.write("}\n\n")
}
}
}
}
}
Logger.i { "MeshLog exported successfully to ${exportFile.absolutePath}" }
} catch (e: java.io.IOException) {
Logger.e(e) { "Failed to export logs to file: ${exportFile.absolutePath}" }
}
} else {
Logger.w { "MeshLog export aborted: user canceled file dialog" }
}
}
}
}
}
private val redactedKeys = listOf("session_passkey", "private_key", "admin_key")