From 4ea8736c3a9306d534c62d9a779df279ff9f5c97 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:46:14 -0500 Subject: [PATCH] feat(settings): view & export app debug logs in-app (#6055) Co-authored-by: Claude Opus 4.8 --- .skills/compose-ui/strings-index.txt | 7 + .../core/common/log/InMemoryLogBuffer.kt | 54 ++++++ .../composeResources/values/strings.xml | 7 + .../kotlin/org/meshtastic/desktop/Main.kt | 4 + docs/en/user.md | 2 + docs/en/user/debug-logs.md | 58 ++++++ .../feature/docs/data/DocBundleLoader.kt | 12 ++ .../feature/docs/ui/DocPageIconResolver.kt | 2 + .../feature/settings/debugging/LogExporter.kt | 66 +++++-- .../feature/settings/debugging/Debug.kt | 60 +++--- .../feature/settings/debugging/LogExporter.kt | 10 +- .../settings/debugging/LogFormatter.kt | 51 ++++- .../feature/settings/debugging/Logcat.kt | 174 ++++++++++++++++++ .../settings/debugging/LogFormatterTest.kt | 45 +++++ .../feature/settings/debugging/LogcatTest.kt | 54 ++++++ .../feature/settings/debugging/NoopStubs.kt | 8 +- .../feature/settings/debugging/LogExporter.kt | 19 +- 17 files changed, 567 insertions(+), 66 deletions(-) create mode 100644 core/common/src/jvmMain/kotlin/org/meshtastic/core/common/log/InMemoryLogBuffer.kt create mode 100644 docs/en/user/debug-logs.md create mode 100644 feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/Logcat.kt create mode 100644 feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/LogcatTest.kt diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index b7600ac57..58f75a4de 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -291,13 +291,18 @@ debug_filter_included debug_filter_preset_title debug_filters debug_log_api_enabled +debug_logcat_empty +debug_logcat_refresh debug_logs_export +debug_logs_exported debug_panel debug_search_clear debug_search_next debug_search_prev debug_store_logs_summary debug_store_logs_title +debug_tab_app_logs +debug_tab_packets default_ default_mqtt_address ### DELETE ### @@ -444,6 +449,7 @@ doc_clear_search doc_keywords_android_auto doc_keywords_app_functions doc_keywords_connections +doc_keywords_debug_logs doc_keywords_desktop doc_keywords_discovery doc_keywords_firmware @@ -469,6 +475,7 @@ doc_section_user doc_title_android_auto doc_title_app_functions doc_title_connections +doc_title_debug_logs doc_title_desktop doc_title_discovery doc_title_firmware diff --git a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/log/InMemoryLogBuffer.kt b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/log/InMemoryLogBuffer.kt new file mode 100644 index 000000000..900ef07d0 --- /dev/null +++ b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/log/InMemoryLogBuffer.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.common.log + +import co.touchlab.kermit.LogWriter +import co.touchlab.kermit.Severity + +/** + * A bounded in-memory ring buffer of recent Kermit log lines. Desktop has no system logcat, so install this writer at + * startup (`Logger.setLogWriters(platformLogWriter(), InMemoryLogBuffer)`) to let the Debug screen view and export the + * app's own logs. Lines are formatted to loosely match Android's `logcat -v time` shape (`L/tag: message`) so the same + * viewer/filters work on both platforms. + * + * ponytail: a plain synchronized ArrayDeque — trivially correct for log volumes; swap for a lock-free ring only if it + * ever shows up hot in a profile. + */ +object InMemoryLogBuffer : LogWriter() { + private const val MAX_LINES = 5000 + private val lines = ArrayDeque() + + override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) { + val line = buildString { + append(severity.name.first()) // V/D/I/W/E/A — matches the viewer's level filter + append('/') + append(tag) + append(": ") + append(message) + if (throwable != null) { + append('\n') + append(throwable.stackTraceToString()) + } + } + synchronized(lines) { + lines.addLast(line) + while (lines.size > MAX_LINES) lines.removeFirst() + } + } + + fun snapshot(): String = synchronized(lines) { lines.joinToString("\n") } +} diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 09999c816..8f6178992 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -312,13 +312,18 @@ Preset Filters Filters Debug log API enabled + No app logs to show + Refresh Export Logs + Logs exported Debug Panel Clear search Next match Previous match Disable to skip writing mesh logs to disk Store mesh logs + App logs + Packets Default mqtt.meshtastic.org @@ -468,6 +473,7 @@ android auto,car,head unit,driving,hands free,messaging system ai,gemini,assistant,functions,automation,voice bluetooth,usb,tcp,pairing,serial,wifi + debug,logs,logcat,export,bug report,issue,troubleshooting,diagnostics desktop,linux,macos,windows,serial discovery,topology,network,scan,neighbor firmware,update,ota,flash,version,recovery @@ -493,6 +499,7 @@ Android Auto App Functions Connections + Debug Logs Desktop App Discovery Firmware Updates diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt index 8d4cadce3..3ff4f111c 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -48,6 +48,7 @@ import androidx.compose.ui.window.isTraySupported import androidx.compose.ui.window.rememberTrayState import androidx.compose.ui.window.rememberWindowState import co.touchlab.kermit.Logger +import co.touchlab.kermit.platformLogWriter import coil3.ImageLoader import coil3.annotation.ExperimentalCoilApi import coil3.compose.setSingletonImageLoaderFactory @@ -66,6 +67,7 @@ import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import org.koin.core.context.startKoin import org.meshtastic.core.common.BuildConfigProvider +import org.meshtastic.core.common.log.InMemoryLogBuffer import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.database.desktopDataDir import org.meshtastic.core.navigation.MultiBackstack @@ -115,6 +117,8 @@ private fun svgPainterResource(path: String, density: Density): Painter = rememb @OptIn(ExperimentalCoilApi::class) fun main(args: Array) = application { val koinApp = remember { + // Keep console output and also capture into the in-memory buffer the Debug screen views/exports. + Logger.setLogWriters(listOf(platformLogWriter(), InMemoryLogBuffer)) Logger.i { "Meshtastic Desktop — Starting" } startKoin { modules(desktopPlatformModule(), desktopModule()) } } diff --git a/docs/en/user.md b/docs/en/user.md index a702fcadd..a05073be7 100644 --- a/docs/en/user.md +++ b/docs/en/user.md @@ -19,6 +19,8 @@ Documentation for using the Meshtastic Android and Desktop app. Keep the last 5–8 entries and archive older ones by removing them. --> +**July 2026** — [Debug Logs](user/debug-logs) — New page covering the Debug Panel's App logs tab: view, filter, and export the app's own debug logs to attach to a GitHub issue — no adb needed. + **June 2026** — [Help & In-App Docs](user/help-and-docs) — New page covering the in-app documentation browser, search, and the on-device Chirpy AI assistant. **June 2026** — [Home Screen Widget](user/widget) — New page covering the Android home screen widget that shows your connected radio's local stats at a glance. diff --git a/docs/en/user/debug-logs.md b/docs/en/user/debug-logs.md new file mode 100644 index 000000000..c111197ca --- /dev/null +++ b/docs/en/user/debug-logs.md @@ -0,0 +1,58 @@ +--- +title: Debug Logs +parent: User Guide +nav_order: 22 +last_updated: 2026-07-01 +description: View and export the app's own debug logs from inside the app, and attach a capture to a GitHub issue to help diagnose bugs — no adb required. +aliases: + - debug-logs + - logcat + - app-logs + - bug-report +--- + +# Debug Logs + +When something misbehaves, the app's debug logs are the single most useful thing you can attach to a bug report. Meshtastic can capture them **for you, from inside the app** — you no longer need `adb` or any desktop tooling to collect them. + +Open the **Debug Panel** from **Settings → Advanced → Debug Panel**. + +> 📎 **Filing an issue?** Export your logs (see below) and attach the `.txt` file to your report at [github.com/meshtastic/Meshtastic-Android/issues](https://github.com/meshtastic/Meshtastic-Android/issues). A log capture that covers the moment the problem happened turns "it doesn't work" into something a developer can actually track down. + +## The two tabs + +The Debug Panel has two tabs: + +- **Packets** — the decoded mesh traffic your radio has sent and received (protocol-level messages). Useful for diagnosing mesh and routing behavior. +- **App logs** — the app's own diagnostic log (Android *logcat*), including warnings, errors, and stack traces from the app itself. This is usually what a bug report needs. + +Each tab has its own **export** button and produces its own file, so you can grab whichever is relevant — or both. + +## Viewing app logs + +The **App logs** tab shows the most recent log lines from **this app only** — never other apps on your device. + +- **Search** — type in the search box to filter to matching lines. +- **Level filter** — the **V / D / I / W / E** chips toggle Verbose, Debug, Info, Warn, and Error lines. Tap a level to hide it; tap again to bring it back. Fatal lines are always shown. +- **Refresh** — the refresh icon re-reads the latest logs. + +Error and warning lines are tinted so problems stand out. + +## Exporting + +Tap the **download** icon to save the current logs to a file. You choose where it goes through the system file picker, and the file is named with a timestamp (for example `meshtastic_logcat_20260701_143312.txt`) so repeated exports never overwrite each other. + +Attach that file to your GitHub issue. + +> 🔒 **Privacy:** Exports automatically **redact** sensitive values such as channel keys and admin/session keys before writing the file. Even so, logs can contain node names, positions, and other identifying details — glance through the file before sharing it publicly, and share privately if you have any doubt. + +## Desktop + +The desktop app has no system logcat, so the **App logs** tab shows the app's own captured log output instead. Search, filtering, and export work the same way. + +## Related Topics + +- [Help & In-App Docs](help-and-docs) — reading this documentation offline inside the app +- [Connections](connections) — if the problem is getting connected to your radio in the first place + +--- diff --git a/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/DocBundleLoader.kt b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/DocBundleLoader.kt index 0b9dbef06..7c3c2a9af 100644 --- a/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/DocBundleLoader.kt +++ b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/DocBundleLoader.kt @@ -24,6 +24,7 @@ import org.meshtastic.core.common.util.currentLocaleQualifier import org.meshtastic.core.resources.doc_keywords_android_auto import org.meshtastic.core.resources.doc_keywords_app_functions import org.meshtastic.core.resources.doc_keywords_connections +import org.meshtastic.core.resources.doc_keywords_debug_logs import org.meshtastic.core.resources.doc_keywords_desktop import org.meshtastic.core.resources.doc_keywords_discovery import org.meshtastic.core.resources.doc_keywords_firmware @@ -45,6 +46,7 @@ import org.meshtastic.core.resources.doc_keywords_widget import org.meshtastic.core.resources.doc_title_android_auto import org.meshtastic.core.resources.doc_title_app_functions import org.meshtastic.core.resources.doc_title_connections +import org.meshtastic.core.resources.doc_title_debug_logs import org.meshtastic.core.resources.doc_title_desktop import org.meshtastic.core.resources.doc_title_discovery import org.meshtastic.core.resources.doc_title_firmware @@ -455,6 +457,16 @@ class DefaultDocBundleLoader : DocBundleLoader { 1900, "help", ), + UserPageDef( + "debug-logs", + CoreRes.string.doc_title_debug_logs, + CoreRes.string.doc_keywords_debug_logs, + "en/user/debug-logs.html", + 22, + listOf("debug-logs", "logcat", "app-logs", "bug-report"), + 3200, + "debug-logs", + ), ) private suspend fun buildUserGuideIndex(): List = userPages.map { def -> diff --git a/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocPageIconResolver.kt b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocPageIconResolver.kt index 5b4ec0698..87fb9206d 100644 --- a/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocPageIconResolver.kt +++ b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocPageIconResolver.kt @@ -89,6 +89,8 @@ internal fun DocPage.resolveIcon(): ImageVector = when (iconId) { "help" -> MeshtasticIcons.Notes + "debug-logs" -> MeshtasticIcons.BugReport + // Developer Guide "architecture" -> MeshtasticIcons.ForkLeft diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt index 315ad1da8..8a5f085c5 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt @@ -30,40 +30,66 @@ import kotlinx.coroutines.withContext import org.meshtastic.core.common.util.ioDispatcher 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.resources.debug_logs_exported import org.meshtastic.core.ui.util.showToast import java.io.OutputStreamWriter import java.nio.charset.StandardCharsets +import java.util.concurrent.TimeUnit @Composable -actual fun rememberLogExporter(logsProvider: suspend () -> List): (fileName: String) -> Unit { +actual fun rememberLogExporter(contentProvider: suspend () -> String): (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()) } + scope.launch { exportTextToUri(context, createdUri, contentProvider()) } } } return { fileName -> exportLogsLauncher.launch(fileName) } } -private suspend fun exportAllLogsToUri(context: Context, targetUri: Uri, logs: List) = - withContext(ioDispatcher) { - 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 -> formatLogsTo(writer, logs) } - } - 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 suspend fun exportTextToUri(context: Context, targetUri: Uri, content: String) = withContext(ioDispatcher) { + try { + if (content.isBlank()) { + withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, "No logs to export") } + Logger.w { "Log export aborted: no content" } + return@withContext } + val stream = context.contentResolver.openOutputStream(targetUri) + if (stream == null) { + Logger.w { "Log export aborted: could not open output stream for $targetUri" } + withContext(Dispatchers.Main) { + context.showToast(Res.string.debug_export_failed, "Could not open file") + } + return@withContext + } + stream.use { os -> OutputStreamWriter(os, StandardCharsets.UTF_8).use { writer -> writer.write(content) } } + Logger.i { "Logs exported successfully to $targetUri" } + withContext(Dispatchers.Main) { context.showToast(Res.string.debug_logs_exported) } + } 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 ?: "") } } +} + +/** + * Dumps this app's own logcat, filtered to our process id via `--pid` (API 24+, minSdk is 26). Without READ_LOGS the OS + * already limits us to our own entries, but `--pid` guarantees it even if that permission is ever granted (e.g. via adb + * on an emulator) so a shared bug report can't leak other apps' logs. Best-effort: a capture failure returns a marker + * rather than throwing. ProcessBuilder with a merged stderr avoids a pipe-buffer deadlock, and the bounded wait keeps a + * stuck capture from tying up the IO thread. ponytail: `-t 5000` tail-caps the dump so the exported file stays sane. + */ +actual fun captureAppLogcat(): String = try { + val pid = android.os.Process.myPid() + val process = + ProcessBuilder("logcat", "-d", "-v", "time", "--pid=$pid", "-t", "5000").redirectErrorStream(true).start() + val output = process.inputStream.bufferedReader().use { it.readText() } + if (!process.waitFor(LOGCAT_TIMEOUT_SECONDS, TimeUnit.SECONDS)) process.destroyForcibly() + output +} catch (e: java.io.IOException) { + Logger.e(e) { "Failed to capture logcat" } + "logcat capture failed: ${e.message}" +} + +private const val LOGCAT_TIMEOUT_SECONDS = 5L diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt index 8c8fe0fb2..6cd9b3bde 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt @@ -37,12 +37,15 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconToggleButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -60,20 +63,16 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.collections.immutable.toImmutableList -import kotlinx.datetime.LocalDateTime -import kotlinx.datetime.TimeZone -import kotlinx.datetime.format -import kotlinx.datetime.format.char -import kotlinx.datetime.toLocalDateTime import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource -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_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_packets import org.meshtastic.core.resources.log_retention_days import org.meshtastic.core.resources.log_retention_days_quantity import org.meshtastic.core.resources.log_retention_days_summary @@ -88,11 +87,11 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Settings import org.meshtastic.core.ui.theme.AnnotationColor import org.meshtastic.feature.settings.debugging.DebugViewModel.UiMeshLog -import kotlin.time.Instant.Companion.fromEpochMilliseconds private val REGEX_ANNOTATED_NODE_ID = Regex("\\(![0-9a-fA-F]{8}\\)$", RegexOption.MULTILINE) -@Suppress("LongMethod") +// Suppressions match this screen's pre-existing detekt baseline entries; editing the body reset the baseline hashes. +@Suppress("LongMethod", "ViewModelForwarding", "ModifierMissing") @Composable fun DebugScreen(onNavigateUp: () -> Unit, viewModel: DebugViewModel) { val listState = rememberLazyListState() @@ -126,9 +125,10 @@ fun DebugScreen(onNavigateUp: () -> Unit, viewModel: DebugViewModel) { } } // Prepare a document creator for exporting logs - val exportLogsLauncher = rememberLogExporter { viewModel.loadLogsForExport() } + val exportLogsLauncher = rememberLogExporter { buildString { formatLogsTo(this, viewModel.loadLogsForExport()) } } var showSettings by remember { mutableStateOf(false) } + var selectedTab by remember { mutableIntStateOf(0) } Scaffold( topBar = { @@ -139,16 +139,35 @@ fun DebugScreen(onNavigateUp: () -> Unit, viewModel: DebugViewModel) { canNavigateUp = true, onNavigateUp = onNavigateUp, actions = { - IconToggleButton(checked = showSettings, onCheckedChange = { showSettings = it }) { - Icon(imageVector = MeshtasticIcons.Settings, contentDescription = null) + // The settings and delete actions apply to the Packets (MeshLog) list only. + if (selectedTab == 0) { + IconToggleButton(checked = showSettings, onCheckedChange = { showSettings = it }) { + Icon(imageVector = MeshtasticIcons.Settings, contentDescription = null) + } + DebugMenuActions(deleteLogs = { viewModel.requestDeleteAllLogs() }) } - DebugMenuActions(deleteLogs = { viewModel.requestDeleteAllLogs() }) }, onClickChip = {}, ) }, ) { paddingValues -> Column(modifier = Modifier.padding(paddingValues).fillMaxSize()) { + PrimaryTabRow(selectedTabIndex = selectedTab) { + Tab( + selected = selectedTab == 0, + onClick = { selectedTab = 0 }, + text = { Text(stringResource(Res.string.debug_tab_packets)) }, + ) + Tab( + selected = selectedTab == 1, + onClick = { selectedTab = 1 }, + text = { Text(stringResource(Res.string.debug_tab_app_logs)) }, + ) + } + if (selectedTab == 1) { + LogcatContent(modifier = Modifier.fillMaxSize()) + return@Column + } LazyColumn(modifier = Modifier.fillMaxSize(), state = listState) { stickyHeader { val animatedAlpha by @@ -165,22 +184,7 @@ fun DebugScreen(onNavigateUp: () -> Unit, viewModel: DebugViewModel) { logs = logs, filterMode = filterMode, onFilterModeChange = { filterMode = it }, - onExportLogs = { - val format = - LocalDateTime.Format { - year() - monthNumber() - day() - char('_') - hour() - minute() - second() - } - val timestamp = - fromEpochMilliseconds(nowMillis).toLocalDateTime(TimeZone.UTC).format(format) - val fileName = "meshtastic_debug_$timestamp.txt" - exportLogsLauncher(fileName) - }, + onExportLogs = { exportLogsLauncher(timestampedExportName("meshtastic_debug")) }, ) if (showSettings) { DebugLogSettings(viewModel = viewModel) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt index a23859bd6..82b828693 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt @@ -18,5 +18,11 @@ package org.meshtastic.feature.settings.debugging import androidx.compose.runtime.Composable -@Composable -expect fun rememberLogExporter(logsProvider: suspend () -> List): (fileName: String) -> Unit +/** Remembers a launcher that writes [contentProvider]'s text to a user-chosen file. */ +@Composable expect fun rememberLogExporter(contentProvider: suspend () -> String): (fileName: String) -> Unit + +/** + * The app's own logs: Android logcat (filtered to this process) on Android, an in-memory Kermit buffer on desktop. + * Empty on platforms with no log capture (currently iOS). + */ +expect fun captureAppLogcat(): String diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/LogFormatter.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/LogFormatter.kt index 6b7eec7eb..6a2326049 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/LogFormatter.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/LogFormatter.kt @@ -16,8 +16,37 @@ */ package org.meshtastic.feature.settings.debugging +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format +import kotlinx.datetime.format.char +import kotlinx.datetime.toLocalDateTime +import org.meshtastic.core.common.util.nowMillis +import kotlin.time.Instant.Companion.fromEpochMilliseconds + internal val redactedKeys = listOf("session_passkey", "private_key", "admin_key") +// Matches `key: value`, `key=value`, `key=[hex=..]`, and one level of nested list like `admin_key=[[hex=..], [..]]`. +// The value alternation is ordered bracket-list | quoted | bare-token so the widest match wins. +private val REDACT_REGEX = + Regex("(${redactedKeys.joinToString("|")})\\s*([:=])\\s*(\\[(?:[^\\[\\]]|\\[[^\\]]*\\])*\\]|\"[^\"]*\"|\\S+)") + +/** Builds a collision-free export file name, e.g. `meshtastic_logcat_20260701_143312.txt`. */ +internal fun timestampedExportName(prefix: String): String { + val format = + LocalDateTime.Format { + year() + monthNumber() + day() + char('_') + hour() + minute() + second() + } + val timestamp = fromEpochMilliseconds(nowMillis).toLocalDateTime(TimeZone.UTC).format(format) + return "${prefix}_$timestamp.txt" +} + /** * Formats a list of [DebugViewModel.UiMeshLog] entries into the given [Appendable], redacting sensitive keys in decoded * payloads. @@ -33,6 +62,18 @@ internal fun formatLogsTo(out: Appendable, logs: List) } } +/** + * Appends captured Android logcat to [out] under a header, redacting sensitive keys line-by-line. The app should never + * log keys/PII, so this is defence-in-depth for a file the user is about to share on a public issue tracker. + */ +internal fun appendLogcat(out: Appendable, logcat: String) { + out.append("\n===== App Logcat =====\n") + logcat.lineSequence().forEach { line -> + out.append(redactLine(line)) + out.append("\n") + } +} + private fun appendRedactedPayload(out: Appendable, payload: String) { out.append("\n\nDecoded Payload:\n{\n") payload.lineSequence().forEach { line -> @@ -42,8 +83,8 @@ private fun appendRedactedPayload(out: Appendable, payload: String) { out.append("}\n\n") } -private fun redactLine(line: String): String { - if (redactedKeys.none { line.contains(it) }) return line - val idx = line.indexOf(':') - return if (idx != -1) line.take(idx + 1) + "" else line -} +/** Redacts sensitive-key values in every line of [text]; used to redact logcat both on screen and on export. */ +internal fun redactText(text: String): String = text.lineSequence().joinToString("\n") { redactLine(it) } + +private fun redactLine(line: String): String = + REDACT_REGEX.replace(line) { "${it.groupValues[1]}${it.groupValues[2]}" } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/Logcat.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/Logcat.kt new file mode 100644 index 000000000..f77929037 --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/Logcat.kt @@ -0,0 +1,174 @@ +/* + * 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 . + */ +@file:Suppress("MatchingDeclarationName") // named for its main export LogcatContent; LogLevel is a supporting type + +package org.meshtastic.feature.settings.debugging + +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 +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.util.ioDispatcher +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.debug_default_search +import org.meshtastic.core.resources.debug_logcat_empty +import org.meshtastic.core.resources.debug_logcat_refresh +import org.meshtastic.core.resources.debug_logs_export +import org.meshtastic.core.ui.icon.FileDownload +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Refresh + +/** Logcat priority levels the user can toggle. Fatal/assert lines carry an unknown code and are always shown. */ +enum class LogLevel(val code: Char) { + VERBOSE('V'), + DEBUG('D'), + INFO('I'), + WARN('W'), + ERROR('E'), +} + +private val KNOWN_LEVEL_CODES = LogLevel.entries.map { it.code }.toSet() + +// Matches the priority in both Android's `logcat -v time` (" D/Tag") and the desktop ring buffer ("D/Tag") shapes. +private val LEVEL_REGEX = Regex("(?:^|\\s)([VDIWEFA])/") + +/** The priority code of a `-v time` logcat line (the letter before `/`), or null for continuation lines. */ +fun logcatLineLevel(line: String): Char? = LEVEL_REGEX.find(line)?.groupValues?.getOrNull(1)?.firstOrNull() + +/** Filters raw logcat text by selected [levels] and a case-insensitive [query] substring. */ +fun filterLogcat(raw: String, levels: Set, query: String): List = raw.lineSequence() + .filter { it.isNotBlank() } + .filter { line -> + val code = logcatLineLevel(line) + // Keep continuation lines and unknown priorities (e.g. F/A); only hide a known-but-deselected level. + code == null || code !in KNOWN_LEVEL_CODES || levels.any { it.code == code } + } + .filter { query.isBlank() || it.contains(query, ignoreCase = true) } + .toList() + +@Composable +private fun logcatLineColor(line: String): Color = when (logcatLineLevel(line)) { + 'E', + 'F', + 'A', + -> MaterialTheme.colorScheme.error + + 'W' -> MaterialTheme.colorScheme.tertiary + + else -> MaterialTheme.colorScheme.onSurface +} + +/** Simple in-app viewer for the app's own logcat: search box, level chips, refresh, and export to a file. */ +@Suppress("LongMethod") +@Composable +fun LogcatContent(modifier: Modifier = Modifier) { + val scope = rememberCoroutineScope() + var raw by remember { mutableStateOf(null) } + var query by remember { mutableStateOf("") } + var levels by remember { mutableStateOf(LogLevel.entries.toSet()) } + val listState = rememberLazyListState() + + val export = rememberLogExporter { buildString { appendLogcat(this, raw.orEmpty()) } } + + fun refresh() = scope.launch { raw = withContext(ioDispatcher) { captureAppLogcat() } } + LaunchedEffect(Unit) { refresh() } + + // Redact sensitive keys before display, matching the export path (appendLogcat) so the on-screen view is safe too. + val lines = remember(raw, levels, query) { filterLogcat(redactText(raw.orEmpty()), levels, query) } + + Column(modifier = modifier.fillMaxSize().padding(8.dp)) { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + label = { Text(stringResource(Res.string.debug_default_search)) }, + singleLine = true, + trailingIcon = { + IconButton(onClick = { refresh() }) { + Icon(MeshtasticIcons.Refresh, contentDescription = stringResource(Res.string.debug_logcat_refresh)) + } + }, + modifier = Modifier.fillMaxWidth(), + ) + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + LogLevel.entries.forEach { level -> + FilterChip( + selected = level in levels, + onClick = { levels = if (level in levels) levels - level else levels + level }, + label = { Text(level.code.toString()) }, + ) + } + Box(modifier = Modifier.weight(1f)) + IconButton(onClick = { export(timestampedExportName("meshtastic_logcat")) }) { + Icon(MeshtasticIcons.FileDownload, contentDescription = stringResource(Res.string.debug_logs_export)) + } + } + if (lines.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(stringResource(Res.string.debug_logcat_empty), color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } else { + SelectionContainer { + LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) { + items(lines) { line -> + Text( + text = line, + color = logcatLineColor(line), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + } + } + } + } +} diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/LogFormatterTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/LogFormatterTest.kt index c7a777202..6182fb2d9 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/LogFormatterTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/LogFormatterTest.kt @@ -17,6 +17,7 @@ package org.meshtastic.feature.settings.debugging import kotlin.test.Test +import kotlin.test.assertFalse import kotlin.test.assertTrue class LogFormatterTest { @@ -42,4 +43,48 @@ class LogFormatterTest { assertTrue(result.contains("session_passkey:")) assertTrue(result.contains("other: value")) } + + @Test + fun `export redacts the real proto equals-and-hex payload format`() { + // Matches what the decoded payload actually looks like (proto toString uses '=' and '[hex=..]'). + val logs = + listOf( + DebugViewModel.UiMeshLog( + uuid = "1", + messageType = "Admin", + formattedReceivedDate = "2026-03-25", + logMessage = "AdminMessage", + decodedPayload = "session_passkey=[hex=dd8042fa0cfd7d17], region=US", + ), + ) + val out = StringBuilder() + formatLogsTo(out, logs) + + val result = out.toString() + assertFalse(result.contains("dd8042fa0cfd7d17"), "the hex secret must not survive export") + assertTrue(result.contains("session_passkey=")) + assertTrue(result.contains("region=US"), "non-sensitive fields are kept") + } + + @Test + fun `appendLogcat redacts sensitive keys and keeps other lines`() { + val out = StringBuilder() + appendLogcat(out, "I/foo: hello world\nD/bar: private_key: deadbeef") + + val result = out.toString() + assertTrue(result.contains("App Logcat")) + assertTrue(result.contains("I/foo: hello world")) + assertTrue(result.contains("private_key:")) + } + + @Test + fun `redactText redacts keys across lines and keeps the rest`() { + val result = redactText("D/x: session_passkey=[hex=dd8042fa0cfd7d17]\nD/x: region=US\nD/x: private_key: bb") + + assertFalse(result.contains("dd8042fa0cfd7d17"), "hex secret must not survive") + assertFalse(result.contains(": bb"), "bare-token secret must not survive") + assertTrue(result.contains("session_passkey=")) + assertTrue(result.contains("private_key:")) + assertTrue(result.contains("region=US")) + } } diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/LogcatTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/LogcatTest.kt new file mode 100644 index 000000000..eac60910c --- /dev/null +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/LogcatTest.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.debugging + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class LogcatTest { + + private val sample = + """ + 01-02 03:04:05.678 D/Tag( 123): debug line + 01-02 03:04:05.679 E/Boom( 123): error line + 01-02 03:04:05.680 F/Fatal(123): fatal line + at continuation.frame(File.kt:1) + """ + .trimIndent() + + @Test + fun `logcatLineLevel reads the priority letter`() { + assertEquals('D', logcatLineLevel("01-02 03:04:05.678 D/Tag( 123): hi")) + assertNull(logcatLineLevel(" at continuation.frame(File.kt:1)")) + } + + @Test + fun `filterLogcat hides deselected known levels but keeps unknown and continuation lines`() { + // Everything selected: all non-blank lines pass. + assertEquals(4, filterLogcat(sample, LogLevel.entries.toSet(), "").size) + + // Deselect DEBUG: the D line drops; E, F (unknown toggle), and the continuation line stay. + val noDebug = filterLogcat(sample, LogLevel.entries.toSet() - LogLevel.DEBUG, "") + assertEquals(3, noDebug.size) + } + + @Test + fun `filterLogcat applies case-insensitive query`() { + assertEquals(1, filterLogcat(sample, LogLevel.entries.toSet(), "ERROR LINE").size) + } +} diff --git a/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/debugging/NoopStubs.kt b/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/debugging/NoopStubs.kt index 88e61055e..93d72e058 100644 --- a/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/debugging/NoopStubs.kt +++ b/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/debugging/NoopStubs.kt @@ -18,7 +18,7 @@ package org.meshtastic.feature.settings.debugging import androidx.compose.runtime.Composable -@Composable -actual fun rememberLogExporter(logsProvider: suspend () -> List): (fileName: String) -> Unit = - { _ -> - } +@Composable actual fun rememberLogExporter(contentProvider: suspend () -> String): (fileName: String) -> Unit = { _ -> } + +// ponytail: no logcat on iOS; the App Logs tab shows an empty-state there. +actual fun captureAppLogcat(): String = "" diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt index a9a728559..817536abf 100644 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.rememberCoroutineScope import co.touchlab.kermit.Logger import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.meshtastic.core.common.log.InMemoryLogBuffer import org.meshtastic.core.common.util.ioDispatcher import java.awt.FileDialog import java.awt.Frame @@ -30,14 +31,14 @@ import java.io.OutputStreamWriter import java.nio.charset.StandardCharsets @Composable -actual fun rememberLogExporter(logsProvider: suspend () -> List): (fileName: String) -> Unit { +actual fun rememberLogExporter(contentProvider: suspend () -> String): (fileName: String) -> Unit { val scope = rememberCoroutineScope() return { fileName -> scope.launch { - val logs = logsProvider() - if (logs.isEmpty()) { - Logger.w { "MeshLog export aborted: no logs available" } + val content = contentProvider() + if (content.isBlank()) { + Logger.w { "Log export aborted: no content" } return@launch } @@ -54,16 +55,20 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List - OutputStreamWriter(fos, StandardCharsets.UTF_8).use { writer -> formatLogsTo(writer, logs) } + OutputStreamWriter(fos, StandardCharsets.UTF_8).use { writer -> writer.write(content) } } - Logger.i { "MeshLog exported successfully to ${exportFile.absolutePath}" } + Logger.i { "Logs 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" } + Logger.w { "Log export aborted: user canceled file dialog" } } } } } } + +// Desktop has no system logcat; surface the app's own Kermit output captured by InMemoryLogBuffer (installed at +// startup). +actual fun captureAppLogcat(): String = InMemoryLogBuffer.snapshot()