mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-02 09:26:01 -04:00
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:
7
.skills/compose-ui/strings-index.txt
generated
7
.skills/compose-ui/strings-index.txt
generated
@@ -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
|
||||
|
||||
@@ -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") }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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()) }
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
58
docs/en/user/debug-logs.md
Normal file
58
docs/en/user/debug-logs.md
Normal 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
|
||||
|
||||
---
|
||||
@@ -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 ->
|
||||
|
||||
@@ -89,6 +89,8 @@ internal fun DocPage.resolveIcon(): ImageVector = when (iconId) {
|
||||
|
||||
"help" -> MeshtasticIcons.Notes
|
||||
|
||||
"debug-logs" -> MeshtasticIcons.BugReport
|
||||
|
||||
// Developer Guide
|
||||
"architecture" -> MeshtasticIcons.ForkLeft
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>" }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user