Feat/debug log export filtering & redaction of keys (#2739)

Signed-off-by: DaneEvans <dane@goneepic.com>
This commit is contained in:
DaneEvans
2025-08-16 10:33:27 +10:00
committed by GitHub
parent caafec861a
commit 14e893b32c
2 changed files with 93 additions and 99 deletions

View File

@@ -98,6 +98,9 @@ import java.util.Locale
private val REGEX_ANNOTATED_NODE_ID = Regex("\\(![0-9a-fA-F]{8}\\)$", RegexOption.MULTILINE)
// list of dict keys to redact when exporting logs. These are evaluated as line.contains, so partials are fine.
private var redactedKeys: List<String> = listOf("session_passkey", "private_key", "admin_key")
@Suppress("LongMethod")
@Composable
internal fun DebugScreen(viewModel: DebugViewModel = hiltViewModel()) {
@@ -106,14 +109,16 @@ internal fun DebugScreen(viewModel: DebugViewModel = hiltViewModel()) {
val searchState by viewModel.searchState.collectAsStateWithLifecycle()
val filterTexts by viewModel.filterTexts.collectAsStateWithLifecycle()
val selectedLogId by viewModel.selectedLogId.collectAsStateWithLifecycle()
val context = LocalContext.current
val scope = rememberCoroutineScope()
var filterMode by remember { mutableStateOf(FilterMode.OR) }
// Use the new filterLogs method to include decodedPayload in filtering
val filteredLogs =
val filteredLogsState by
remember(logs, filterTexts, filterMode) {
viewModel.filterManager.filterLogs(logs, filterTexts, filterMode).toImmutableList()
derivedStateOf { viewModel.filterManager.filterLogs(logs, filterTexts, filterMode).toImmutableList() }
}
val filteredLogs = filteredLogsState
LaunchedEffect(filteredLogs) { viewModel.updateFilteredLogs(filteredLogs) }
@@ -144,6 +149,7 @@ internal fun DebugScreen(viewModel: DebugViewModel = hiltViewModel()) {
logs = logs,
filterMode = filterMode,
onFilterModeChange = { filterMode = it },
onExportLogs = { scope.launch { exportAllLogs(context, filteredLogs) } },
)
}
items(filteredLogs, key = { it.uuid }) { log ->
@@ -313,15 +319,9 @@ private fun rememberAnnotatedLogMessage(log: UiMeshLog, searchText: String): Ann
fun DebugMenuActions(viewModel: DebugViewModel = hiltViewModel(), modifier: Modifier = Modifier) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val logs by viewModel.meshLog.collectAsStateWithLifecycle()
var showDeleteLogsDialog by remember { mutableStateOf(false) }
IconButton(onClick = { scope.launch { exportAllLogs(context, logs) } }, modifier = modifier.padding(4.dp)) {
Icon(
imageVector = Icons.Outlined.FileDownload,
contentDescription = stringResource(id = R.string.debug_logs_export),
)
}
IconButton(onClick = { showDeleteLogsDialog = true }, modifier = modifier.padding(4.dp)) {
Icon(imageVector = Icons.Default.Delete, contentDescription = stringResource(id = R.string.debug_clear))
}
@@ -343,11 +343,9 @@ private suspend fun exportAllLogs(context: Context, logs: List<UiMeshLog>) = wit
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val fileName = "meshtastic_debug_$timestamp.txt"
// Get the Downloads directory
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val logFile = File(downloadsDir, fileName)
// Create the file and write logs
OutputStreamWriter(FileOutputStream(logFile), StandardCharsets.UTF_8).use { writer ->
logs.forEach { log ->
writer.write("${log.formattedReceivedDate} [${log.messageType}]\n")
@@ -355,16 +353,29 @@ private suspend fun exportAllLogs(context: Context, logs: List<UiMeshLog>) = wit
if (!log.decodedPayload.isNullOrBlank()) {
writer.write("\n\nDecoded Payload:\n{")
writer.write("\n")
writer.write(log.decodedPayload)
// Redact Decoded keys.
log.decodedPayload.lineSequence().forEach { line ->
var outputLine = line
val redacted = redactedKeys.firstOrNull { line.contains(it) }
if (redacted != null) {
val idx = line.indexOf(':')
if (idx != -1) {
outputLine = line.substring(0, idx + 1)
outputLine += "<redacted>"
}
}
writer.write(outputLine)
writer.write("\n")
}
writer.write("\n}")
}
writer.write("\n\n")
}
}
// Notify user of success
withContext(Dispatchers.Main) {
Toast.makeText(context, "Logs exported to ${logFile.absolutePath}", Toast.LENGTH_LONG).show()
Toast.makeText(context, "${logs.size} logs exported to ${logFile.absolutePath}", Toast.LENGTH_LONG)
.show()
}
} catch (e: SecurityException) {
withContext(Dispatchers.Main) {

View File

@@ -32,6 +32,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.outlined.FileDownload
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -54,9 +55,9 @@ import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.geeksville.mesh.R
import com.geeksville.mesh.model.DebugViewModel
import com.geeksville.mesh.model.DebugViewModel.UiMeshLog
import com.geeksville.mesh.model.LogSearchManager.SearchMatch
import com.geeksville.mesh.model.LogSearchManager.SearchState
import com.geeksville.mesh.model.DebugViewModel.UiMeshLog
import com.geeksville.mesh.ui.common.theme.AppTheme
@Composable
@@ -64,38 +65,30 @@ internal fun DebugSearchNavigation(
searchState: SearchState,
onNextMatch: () -> Unit,
onPreviousMatch: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.width(IntrinsicSize.Min),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "${searchState.currentMatchIndex + 1}/${searchState.allMatches.size}",
modifier = Modifier.padding(end = 4.dp),
style = TextStyle(fontSize = 12.sp)
style = TextStyle(fontSize = 12.sp),
)
IconButton(
onClick = onPreviousMatch,
enabled = searchState.hasMatches,
modifier = Modifier.size(32.dp)
) {
IconButton(onClick = onPreviousMatch, enabled = searchState.hasMatches, modifier = Modifier.size(32.dp)) {
Icon(
imageVector = Icons.Default.KeyboardArrowUp,
contentDescription = stringResource(R.string.debug_search_prev),
modifier = Modifier.size(16.dp)
modifier = Modifier.size(16.dp),
)
}
IconButton(
onClick = onNextMatch,
enabled = searchState.hasMatches,
modifier = Modifier.size(32.dp)
) {
IconButton(onClick = onNextMatch, enabled = searchState.hasMatches, modifier = Modifier.size(32.dp)) {
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = stringResource(R.string.debug_search_next),
modifier = Modifier.size(16.dp)
modifier = Modifier.size(16.dp),
)
}
}
@@ -108,48 +101,45 @@ internal fun DebugSearchBar(
onNextMatch: () -> Unit,
onPreviousMatch: () -> Unit,
onClearSearch: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
) {
OutlinedTextField(
value = searchState.searchText,
onValueChange = onSearchTextChange,
modifier = modifier
.padding(end = 8.dp),
modifier = modifier.then(Modifier.padding(end = 8.dp)),
placeholder = { Text(stringResource(R.string.debug_default_search)) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(
keyboardActions =
KeyboardActions(
onSearch = {
// Clear focus when search is performed
}
},
),
trailingIcon = {
Row(
modifier = Modifier.width(IntrinsicSize.Min),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
if (searchState.hasMatches) {
DebugSearchNavigation(
searchState = searchState,
onNextMatch = onNextMatch,
onPreviousMatch = onPreviousMatch
onPreviousMatch = onPreviousMatch,
)
}
if (searchState.searchText.isNotEmpty()) {
IconButton(
onClick = onClearSearch,
modifier = Modifier.size(32.dp)
) {
IconButton(onClick = onClearSearch, modifier = Modifier.size(32.dp)) {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = stringResource(R.string.debug_search_clear),
modifier = Modifier.size(16.dp)
modifier = Modifier.size(16.dp),
)
}
}
}
}
},
)
}
@@ -167,30 +157,24 @@ internal fun DebugSearchState(
onFilterTextsChange: (List<String>) -> Unit,
filterMode: FilterMode,
onFilterModeChange: (FilterMode) -> Unit,
onExportLogs: (() -> Unit)? = null,
) {
val colorScheme = MaterialTheme.colorScheme
var customFilterText by remember { mutableStateOf("") }
Column(
modifier = modifier
.background(
color = colorScheme.background.copy(alpha = 1.0f)
)
.padding(8.dp)
) {
Column(modifier = modifier.background(color = colorScheme.background.copy(alpha = 1.0f)).padding(8.dp)) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(colorScheme.background.copy(alpha = 1.0f)),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
modifier = Modifier.fillMaxWidth().background(colorScheme.background.copy(alpha = 1.0f)),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
DebugSearchBar(
modifier = Modifier.weight(1f),
searchState = searchState,
onSearchTextChange = onSearchTextChange,
onNextMatch = onNextMatch,
onPreviousMatch = onPreviousMatch,
onClearSearch = onClearSearch
onClearSearch = onClearSearch,
)
DebugFilterBar(
filterTexts = filterTexts,
@@ -198,16 +182,26 @@ internal fun DebugSearchState(
customFilterText = customFilterText,
onCustomFilterTextChange = { customFilterText = it },
presetFilters = presetFilters,
logs = logs
logs = logs,
modifier = Modifier,
)
onExportLogs?.let { onExport ->
IconButton(onClick = onExport, modifier = Modifier) {
Icon(
imageVector = Icons.Outlined.FileDownload,
contentDescription = stringResource(id = com.geeksville.mesh.R.string.debug_logs_export),
modifier = Modifier.size(24.dp),
)
}
}
}
DebugActiveFilters(
filterTexts = filterTexts,
onFilterTextsChange = onFilterTextsChange,
filterMode = filterMode,
onFilterModeChange = onFilterModeChange
)
}
DebugActiveFilters(
filterTexts = filterTexts,
onFilterTextsChange = onFilterTextsChange,
filterMode = filterMode,
onFilterModeChange = onFilterModeChange,
)
}
@Composable
@@ -219,6 +213,7 @@ fun DebugSearchStateviewModelDefaults(
logs: List<UiMeshLog>,
filterMode: FilterMode,
onFilterModeChange: (FilterMode) -> Unit,
onExportLogs: (() -> Unit)? = null,
) {
val viewModel: DebugViewModel = hiltViewModel()
DebugSearchState(
@@ -233,7 +228,8 @@ fun DebugSearchStateviewModelDefaults(
onClearSearch = viewModel.searchManager::clearSearch,
onFilterTextsChange = viewModel.filterManager::setFilterTexts,
filterMode = filterMode,
onFilterModeChange = onFilterModeChange
onFilterModeChange = onFilterModeChange,
onExportLogs = onExportLogs,
)
}
@@ -242,18 +238,13 @@ fun DebugSearchStateviewModelDefaults(
private fun DebugSearchBarEmptyPreview() {
AppTheme {
Surface {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
DebugSearchBar(
searchState = SearchState(),
onSearchTextChange = { },
onNextMatch = { },
onPreviousMatch = { },
onClearSearch = { }
onSearchTextChange = {},
onNextMatch = {},
onPreviousMatch = {},
onClearSearch = {},
)
}
}
@@ -266,23 +257,19 @@ private fun DebugSearchBarEmptyPreview() {
private fun DebugSearchBarWithTextPreview() {
AppTheme {
Surface {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
DebugSearchBar(
searchState = SearchState(
searchState =
SearchState(
searchText = "test message",
currentMatchIndex = 2,
allMatches = List(5) { SearchMatch(it, 0, 10, "message") },
hasMatches = true
hasMatches = true,
),
onSearchTextChange = { },
onNextMatch = { },
onPreviousMatch = { },
onClearSearch = { }
onSearchTextChange = {},
onNextMatch = {},
onPreviousMatch = {},
onClearSearch = {},
)
}
}
@@ -295,23 +282,19 @@ private fun DebugSearchBarWithTextPreview() {
private fun DebugSearchBarWithMatchesPreview() {
AppTheme {
Surface {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
DebugSearchBar(
searchState = SearchState(
searchState =
SearchState(
searchText = "error",
currentMatchIndex = 0,
allMatches = List(3) { SearchMatch(it, 0, 5, "message") },
hasMatches = true
hasMatches = true,
),
onSearchTextChange = { },
onNextMatch = { },
onPreviousMatch = { },
onClearSearch = { }
onSearchTextChange = {},
onNextMatch = {},
onPreviousMatch = {},
onClearSearch = {},
)
}
}