mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-13 03:17:48 -04:00
Feat/debug log export filtering & redaction of keys (#2739)
Signed-off-by: DaneEvans <dane@goneepic.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user