From 14e893b32c13ea677ec5097a8beaffc0676eed76 Mon Sep 17 00:00:00 2001 From: DaneEvans Date: Sat, 16 Aug 2025 10:33:27 +1000 Subject: [PATCH] Feat/debug log export filtering & redaction of keys (#2739) Signed-off-by: DaneEvans --- .../com/geeksville/mesh/ui/debug/Debug.kt | 41 +++-- .../geeksville/mesh/ui/debug/DebugSearch.kt | 151 ++++++++---------- 2 files changed, 93 insertions(+), 99 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/ui/debug/Debug.kt b/app/src/main/java/com/geeksville/mesh/ui/debug/Debug.kt index 1577b30dd..62c486d84 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/debug/Debug.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/debug/Debug.kt @@ -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 = 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) = 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) = 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 += "" + } + } + 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) { diff --git a/app/src/main/java/com/geeksville/mesh/ui/debug/DebugSearch.kt b/app/src/main/java/com/geeksville/mesh/ui/debug/DebugSearch.kt index 7c3771bf9..00f9a9d28 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/debug/DebugSearch.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/debug/DebugSearch.kt @@ -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) -> 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, 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 = {}, ) } }