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