feat(settings): view & export app debug logs in-app (#6055)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-07-01 19:46:14 -05:00
committed by GitHub
parent 6a2dfc898b
commit 4ea8736c3a
17 changed files with 567 additions and 66 deletions

View File

@@ -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

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String>()
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") }
}

View File

@@ -312,13 +312,18 @@
<string name="debug_filter_preset_title">Preset Filters</string>
<string name="debug_filters">Filters</string>
<string name="debug_log_api_enabled">Debug log API enabled</string>
<string name="debug_logcat_empty">No app logs to show</string>
<string name="debug_logcat_refresh">Refresh</string>
<string name="debug_logs_export">Export Logs</string>
<string name="debug_logs_exported">Logs exported</string>
<string name="debug_panel">Debug Panel</string>
<string name="debug_search_clear">Clear search</string>
<string name="debug_search_next">Next match</string>
<string name="debug_search_prev">Previous match</string>
<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_packets">Packets</string>
<string name="default_">Default</string>
<string name="default_mqtt_address" translatable="false">mqtt.meshtastic.org</string>
<!-- DELETE -->
@@ -468,6 +473,7 @@
<string name="doc_keywords_android_auto">android auto,car,head unit,driving,hands free,messaging</string>
<string name="doc_keywords_app_functions">system ai,gemini,assistant,functions,automation,voice</string>
<string name="doc_keywords_connections">bluetooth,usb,tcp,pairing,serial,wifi</string>
<string name="doc_keywords_debug_logs">debug,logs,logcat,export,bug report,issue,troubleshooting,diagnostics</string>
<string name="doc_keywords_desktop">desktop,linux,macos,windows,serial</string>
<string name="doc_keywords_discovery">discovery,topology,network,scan,neighbor</string>
<string name="doc_keywords_firmware">firmware,update,ota,flash,version,recovery</string>
@@ -493,6 +499,7 @@
<string name="doc_title_android_auto">Android Auto</string>
<string name="doc_title_app_functions">App Functions</string>
<string name="doc_title_connections">Connections</string>
<string name="doc_title_debug_logs">Debug Logs</string>
<string name="doc_title_desktop">Desktop App</string>
<string name="doc_title_discovery">Discovery</string>
<string name="doc_title_firmware">Firmware Updates</string>

View File

@@ -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<String>) = 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()) }
}

View File

@@ -19,6 +19,8 @@ Documentation for using the Meshtastic Android and Desktop app.
Keep the last 58 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.

View File

@@ -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
---

View File

@@ -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<KeywordIndexEntry> = userPages.map { def ->

View File

@@ -89,6 +89,8 @@ internal fun DocPage.resolveIcon(): ImageVector = when (iconId) {
"help" -> MeshtasticIcons.Notes
"debug-logs" -> MeshtasticIcons.BugReport
// Developer Guide
"architecture" -> MeshtasticIcons.ForkLeft

View File

@@ -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<DebugViewModel.UiMeshLog>): (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<DebugViewModel.UiMeshLog>) =
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

View File

@@ -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)

View File

@@ -18,5 +18,11 @@ package org.meshtastic.feature.settings.debugging
import androidx.compose.runtime.Composable
@Composable
expect fun rememberLogExporter(logsProvider: suspend () -> List<DebugViewModel.UiMeshLog>): (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

View File

@@ -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<DebugViewModel.UiMeshLog>)
}
}
/**
* 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) + "<redacted>" 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]}<redacted>" }

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
@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<LogLevel>, query: String): List<String> = 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<String?>(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)
}
}
}
}
}
}

View File

@@ -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:<redacted>"))
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=<redacted>"))
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:<redacted>"))
}
@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=<redacted>"))
assertTrue(result.contains("private_key:<redacted>"))
assertTrue(result.contains("region=US"))
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View File

@@ -18,7 +18,7 @@ package org.meshtastic.feature.settings.debugging
import androidx.compose.runtime.Composable
@Composable
actual fun rememberLogExporter(logsProvider: suspend () -> List<DebugViewModel.UiMeshLog>): (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 = ""

View File

@@ -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<DebugViewModel.UiMeshLog>): (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<DebugViewModel.U
val exportFile = File(directory, selectedFile)
try {
FileOutputStream(exportFile).use { fos ->
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()