diff --git a/.gitignore b/.gitignore
index 610cbc7b6..60be31b37 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,6 @@ keystore.properties
# Kotlin compiler
.kotlin
+
+# VS code
+.vscode/settings.json
diff --git a/app/src/androidTest/java/com/geeksville/mesh/compose/DebugFiltersTest.kt b/app/src/androidTest/java/com/geeksville/mesh/compose/DebugFiltersTest.kt
new file mode 100644
index 000000000..71dc74269
--- /dev/null
+++ b/app/src/androidTest/java/com/geeksville/mesh/compose/DebugFiltersTest.kt
@@ -0,0 +1,108 @@
+/*
+ * Copyright (c) 2025 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 com.geeksville.mesh.compose
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTextInput
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.geeksville.mesh.R
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.setValue
+
+@RunWith(AndroidJUnit4::class)
+class DebugFiltersTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Test
+ fun debugFilterBar_showsFilterButtonAndMenu() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val filterLabel = context.getString(R.string.debug_filters)
+ composeTestRule.setContent {
+ var filterTexts by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(listOf()) }
+ var customFilterText by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf("") }
+ val presetFilters = listOf("Error", "Warning", "Info")
+ com.geeksville.mesh.ui.debug.DebugFilterBar(
+ filterTexts = filterTexts,
+ onFilterTextsChange = { filterTexts = it },
+ customFilterText = customFilterText,
+ onCustomFilterTextChange = { customFilterText = it },
+ presetFilters = presetFilters
+ )
+ }
+ // The filter button should be visible
+ composeTestRule.onNodeWithText(filterLabel).assertIsDisplayed()
+ }
+
+ @Test
+ fun debugFilterBar_addCustomFilter_displaysActiveFilter() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val activeFiltersLabel = context.getString(R.string.debug_active_filters)
+ composeTestRule.setContent {
+ var filterTexts by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(listOf()) }
+ var customFilterText by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf("") }
+ com.geeksville.mesh.ui.debug.DebugActiveFilters(
+ filterTexts = filterTexts,
+ onFilterTextsChange = { filterTexts = it }
+ )
+ com.geeksville.mesh.ui.debug.DebugCustomFilterInput(
+ customFilterText = customFilterText,
+ onCustomFilterTextChange = { customFilterText = it },
+ filterTexts = filterTexts,
+ onFilterTextsChange = { filterTexts = it }
+ )
+ }
+ // Add a custom filter
+ composeTestRule.onNodeWithText("Add custom filter").performTextInput("MyFilter")
+ composeTestRule.onNodeWithContentDescription("Add filter").performClick()
+ // The active filters label and the filter chip should be visible
+ composeTestRule.onNodeWithText(activeFiltersLabel).assertIsDisplayed()
+ composeTestRule.onNodeWithText("MyFilter").assertIsDisplayed()
+ }
+
+ @Test
+ fun debugActiveFilters_clearAllFilters_removesFilters() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val activeFiltersLabel = context.getString(R.string.debug_active_filters)
+ composeTestRule.setContent {
+ var filterTexts by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(listOf("A", "B")) }
+ com.geeksville.mesh.ui.debug.DebugActiveFilters(
+ filterTexts = filterTexts,
+ onFilterTextsChange = { filterTexts = it }
+ )
+ }
+ // The active filters label and chips should be visible
+ composeTestRule.onNodeWithText(activeFiltersLabel).assertIsDisplayed()
+ composeTestRule.onNodeWithText("A").assertIsDisplayed()
+ composeTestRule.onNodeWithText("B").assertIsDisplayed()
+ // Click the clear all filters button
+ composeTestRule.onNodeWithContentDescription("Clear all filters").performClick()
+ // The filter chips should no longer be visible
+ composeTestRule.onNodeWithText("A").assertDoesNotExist()
+ composeTestRule.onNodeWithText("B").assertDoesNotExist()
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/geeksville/mesh/compose/DebugSearchTest.kt b/app/src/androidTest/java/com/geeksville/mesh/compose/DebugSearchTest.kt
new file mode 100644
index 000000000..021312f83
--- /dev/null
+++ b/app/src/androidTest/java/com/geeksville/mesh/compose/DebugSearchTest.kt
@@ -0,0 +1,178 @@
+/*
+ * Copyright (c) 2025 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 com.geeksville.mesh.compose
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onRoot
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTextInput
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.printToLog
+import androidx.compose.ui.test.printToString
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.geeksville.mesh.R
+import com.geeksville.mesh.model.LogSearchManager.SearchState
+import com.geeksville.mesh.ui.debug.DebugSearchBar
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.setValue
+
+@RunWith(AndroidJUnit4::class)
+class DebugSearchTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Test
+ fun debugSearchBar_showsPlaceholder() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val placeholder = context.getString(R.string.debug_default_search)
+ composeTestRule.setContent {
+ DebugSearchBar(
+ searchState = SearchState(),
+ onSearchTextChange = {},
+ onNextMatch = {},
+ onPreviousMatch = {},
+ onClearSearch = {}
+ )
+ }
+ composeTestRule.onNodeWithText(placeholder).assertIsDisplayed()
+ }
+
+ @Test
+ fun debugSearchBar_showsClearButtonWhenTextEntered() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val placeholder = context.getString(R.string.debug_default_search)
+ composeTestRule.setContent {
+ var searchText by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf("test") }
+ DebugSearchBar(
+ searchState = SearchState(searchText = searchText),
+ onSearchTextChange = { searchText = it },
+ onNextMatch = {},
+ onPreviousMatch = {},
+ onClearSearch = { searchText = "" }
+ )
+ }
+ composeTestRule.onNodeWithContentDescription("Clear search").assertIsDisplayed()
+ .performClick()
+ composeTestRule.onNodeWithText(placeholder).assertIsDisplayed()
+ }
+
+ @Test
+ fun debugSearchBar_searchFor_showsArrowsClearAndValues() {
+ val searchText = "test"
+ val matchCount = 3
+ val currentMatchIndex = 1
+
+ composeTestRule.setContent {
+ DebugSearchBar(
+ searchState = SearchState(
+ searchText = searchText,
+ currentMatchIndex = currentMatchIndex,
+ allMatches = List(matchCount) { com.geeksville.mesh.model.LogSearchManager.SearchMatch(it, 0, 6, "Packet") },
+ hasMatches = true
+ ),
+ onSearchTextChange = {},
+ onNextMatch = {},
+ onPreviousMatch = {},
+ onClearSearch = {}
+ )
+ }
+ // Check the match count display (e.g., '2/3')
+ composeTestRule.onNodeWithText("${currentMatchIndex + 1}/$matchCount").assertIsDisplayed()
+ // Check the navigation arrows
+ composeTestRule.onNodeWithContentDescription("Previous match").assertIsDisplayed()
+ composeTestRule.onNodeWithContentDescription("Next match").assertIsDisplayed()
+ // Check the clear button
+ composeTestRule.onNodeWithContentDescription("Clear search").assertIsDisplayed()
+ }
+
+ @Test
+ fun debugFilterBar_showsFilterButtonAndMenu() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val filterLabel = context.getString(R.string.debug_filters)
+ composeTestRule.setContent {
+ var filterTexts by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(listOf()) }
+ var customFilterText by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf("") }
+ val presetFilters = listOf("Error", "Warning", "Info")
+ com.geeksville.mesh.ui.debug.DebugFilterBar(
+ filterTexts = filterTexts,
+ onFilterTextsChange = { filterTexts = it },
+ customFilterText = customFilterText,
+ onCustomFilterTextChange = { customFilterText = it },
+ presetFilters = presetFilters
+ )
+ }
+ // The filter button should be visible
+ composeTestRule.onNodeWithText(filterLabel).assertIsDisplayed()
+ }
+
+ @Test
+ fun debugFilterBar_addCustomFilter_displaysActiveFilter() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val activeFiltersLabel = context.getString(R.string.debug_active_filters)
+ composeTestRule.setContent {
+ var filterTexts by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(listOf()) }
+ var customFilterText by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf("") }
+ com.geeksville.mesh.ui.debug.DebugActiveFilters(
+ filterTexts = filterTexts,
+ onFilterTextsChange = { filterTexts = it }
+ )
+ com.geeksville.mesh.ui.debug.DebugCustomFilterInput(
+ customFilterText = customFilterText,
+ onCustomFilterTextChange = { customFilterText = it },
+ filterTexts = filterTexts,
+ onFilterTextsChange = { filterTexts = it }
+ )
+ }
+ // Add a custom filter
+ composeTestRule.onNodeWithText("Add custom filter").performTextInput("MyFilter")
+ composeTestRule.onNodeWithContentDescription("Add filter").performClick()
+ // The active filters label and the filter chip should be visible
+ composeTestRule.onNodeWithText(activeFiltersLabel).assertIsDisplayed()
+ composeTestRule.onNodeWithText("MyFilter").assertIsDisplayed()
+ }
+
+ @Test
+ fun debugActiveFilters_clearAllFilters_removesFilters() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val activeFiltersLabel = context.getString(R.string.debug_active_filters)
+ composeTestRule.setContent {
+ var filterTexts by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(listOf("A", "B")) }
+ com.geeksville.mesh.ui.debug.DebugActiveFilters(
+ filterTexts = filterTexts,
+ onFilterTextsChange = { filterTexts = it }
+ )
+ }
+ // The active filters label and chips should be visible
+ composeTestRule.onNodeWithText(activeFiltersLabel).assertIsDisplayed()
+ composeTestRule.onNodeWithText("A").assertIsDisplayed()
+ composeTestRule.onNodeWithText("B").assertIsDisplayed()
+ // Click the clear all filters button
+ composeTestRule.onNodeWithContentDescription("Clear all filters").performClick()
+ // The filter chips should no longer be visible
+ composeTestRule.onNodeWithText("A").assertDoesNotExist()
+ composeTestRule.onNodeWithText("B").assertDoesNotExist()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/geeksville/mesh/model/DebugViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/DebugViewModel.kt
index 801a1df6e..161d2b6dd 100644
--- a/app/src/main/java/com/geeksville/mesh/model/DebugViewModel.kt
+++ b/app/src/main/java/com/geeksville/mesh/model/DebugViewModel.kt
@@ -31,11 +31,126 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.text.DateFormat
import java.util.Locale
import javax.inject.Inject
+import com.geeksville.mesh.Portnums.PortNum
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+data class SearchMatch(
+ val logIndex: Int,
+ val start: Int,
+ val end: Int,
+ val field: String
+)
+
+data class SearchState(
+ val searchText: String = "",
+ val currentMatchIndex: Int = -1,
+ val allMatches: List = emptyList(),
+ val hasMatches: Boolean = false
+)
+
+// --- Search and Filter Managers ---
+class LogSearchManager {
+ data class SearchMatch(
+ val logIndex: Int,
+ val start: Int,
+ val end: Int,
+ val field: String
+ )
+
+ data class SearchState(
+ val searchText: String = "",
+ val currentMatchIndex: Int = -1,
+ val allMatches: List = emptyList(),
+ val hasMatches: Boolean = false
+ )
+
+ private val _searchText = MutableStateFlow("")
+ val searchText = _searchText.asStateFlow()
+
+ private val _currentMatchIndex = MutableStateFlow(-1)
+ val currentMatchIndex = _currentMatchIndex.asStateFlow()
+
+ private val _searchState = MutableStateFlow(SearchState())
+ val searchState = _searchState.asStateFlow()
+
+ fun setSearchText(text: String) {
+ _searchText.value = text
+ _currentMatchIndex.value = -1
+ }
+
+ fun goToNextMatch() {
+ val matches = _searchState.value.allMatches
+ if (matches.isNotEmpty()) {
+ val nextIndex = if (_currentMatchIndex.value < matches.lastIndex) _currentMatchIndex.value + 1 else 0
+ _currentMatchIndex.value = nextIndex
+ _searchState.value = _searchState.value.copy(currentMatchIndex = nextIndex)
+ }
+ }
+
+ fun goToPreviousMatch() {
+ val matches = _searchState.value.allMatches
+ if (matches.isNotEmpty()) {
+ val prevIndex = if (_currentMatchIndex.value > 0) _currentMatchIndex.value - 1 else matches.lastIndex
+ _currentMatchIndex.value = prevIndex
+ _searchState.value = _searchState.value.copy(currentMatchIndex = prevIndex)
+ }
+ }
+
+ fun clearSearch() {
+ setSearchText("")
+ }
+
+ fun updateMatches(searchText: String, filteredLogs: List) {
+ val matches = findSearchMatches(searchText, filteredLogs)
+ val hasMatches = matches.isNotEmpty()
+ _searchState.value = _searchState.value.copy(
+ searchText = searchText,
+ allMatches = matches,
+ hasMatches = hasMatches,
+ currentMatchIndex = if (hasMatches) _currentMatchIndex.value.coerceIn(0, matches.lastIndex) else -1
+ )
+ }
+
+ fun findSearchMatches(searchText: String, filteredLogs: List): List {
+ if (searchText.isEmpty()) {
+ return emptyList()
+ }
+ return filteredLogs.flatMapIndexed { logIndex, log ->
+ searchText.split(" ").flatMap { term ->
+ val messageMatches = term.toRegex(RegexOption.IGNORE_CASE).findAll(log.logMessage)
+ .map { match -> SearchMatch(logIndex, match.range.first, match.range.last, "message") }
+ val typeMatches = term.toRegex(RegexOption.IGNORE_CASE).findAll(log.messageType)
+ .map { match -> SearchMatch(logIndex, match.range.first, match.range.last, "type") }
+ val dateMatches = term.toRegex(RegexOption.IGNORE_CASE).findAll(log.formattedReceivedDate)
+ .map { match -> SearchMatch(logIndex, match.range.first, match.range.last, "date") }
+ messageMatches + typeMatches + dateMatches
+ }
+ }.sortedBy { it.start }
+ }
+}
+
+class LogFilterManager {
+ private val _filterTexts = MutableStateFlow>(emptyList())
+ val filterTexts = _filterTexts.asStateFlow()
+
+ private val _filteredLogs = MutableStateFlow>(emptyList())
+ val filteredLogs = _filteredLogs.asStateFlow()
+
+ fun setFilterTexts(filters: List) {
+ _filterTexts.value = filters
+ }
+
+ fun updateFilteredLogs(logs: List) {
+ _filteredLogs.value = logs
+ }
+}
@HiltViewModel
class DebugViewModel @Inject constructor(
@@ -46,8 +161,33 @@ class DebugViewModel @Inject constructor(
.map(::toUiState)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), persistentListOf())
+ // --- Managers ---
+ val searchManager = LogSearchManager()
+ val filterManager = LogFilterManager()
+
+ val searchText get() = searchManager.searchText
+ val currentMatchIndex get() = searchManager.currentMatchIndex
+ val searchState get() = searchManager.searchState
+ val filterTexts get() = filterManager.filterTexts
+ val filteredLogs get() = filterManager.filteredLogs
+
+ private val _selectedLogId = MutableStateFlow(null)
+ val selectedLogId = _selectedLogId.asStateFlow()
+
+ fun updateFilteredLogs(logs: List) {
+ filterManager.updateFilteredLogs(logs)
+ searchManager.updateMatches(searchManager.searchText.value, logs)
+ }
+
init {
debug("DebugViewModel created")
+ viewModelScope.launch {
+ combine(searchManager.searchText, filterManager.filteredLogs) { searchText, logs ->
+ searchManager.findSearchMatches(searchText, logs)
+ }.collect { matches ->
+ searchManager.updateMatches(searchManager.searchText.value, filterManager.filteredLogs.value)
+ }
+ }
}
override fun onCleared() {
@@ -134,4 +274,11 @@ class DebugViewModel @Inject constructor(
companion object {
private val TIME_FORMAT = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
}
+
+ val presetFilters = arrayOf(
+ // "!xxxxxxxx", // Dynamically determine the address of the connected node (i.e., messages to us).
+ "!ffffffff", // broadcast
+ ) + PortNum.entries.map { it.name } // all apps
+
+ fun setSelectedLogId(id: String?) { _selectedLogId.value = id }
}
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 86373e334..67cd85075 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
@@ -17,7 +17,19 @@
package com.geeksville.mesh.ui.debug
+import android.content.Context
+import android.os.Environment
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.FileOutputStream
+import java.io.OutputStreamWriter
+import java.nio.charset.StandardCharsets
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
@@ -27,12 +39,21 @@ 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.foundation.clickable
+import androidx.compose.foundation.BorderStroke
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CloudDownload
+import androidx.compose.material.icons.twotone.FilterAltOff
import androidx.compose.material3.Button
import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ColorScheme
import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
@@ -59,6 +80,14 @@ import com.geeksville.mesh.R
import com.geeksville.mesh.model.DebugViewModel
import com.geeksville.mesh.model.DebugViewModel.UiMeshLog
import com.geeksville.mesh.ui.common.theme.AppTheme
+import com.geeksville.mesh.ui.common.components.CopyIconButton
+import android.widget.Toast
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.datastore.core.IOException
+import com.geeksville.mesh.android.BuildUtils.warn
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.launch
private val REGEX_ANNOTATED_NODE_ID = Regex("\\(![0-9a-fA-F]{8}\\)$", RegexOption.MULTILINE)
@@ -68,24 +97,60 @@ internal fun DebugScreen(
) {
val listState = rememberLazyListState()
val logs by viewModel.meshLog.collectAsStateWithLifecycle()
+ val searchState by viewModel.searchState.collectAsStateWithLifecycle()
+ val filterTexts by viewModel.filterTexts.collectAsStateWithLifecycle()
+ val selectedLogId by viewModel.selectedLogId.collectAsStateWithLifecycle()
+
+ val filteredLogs = remember(logs, filterTexts) {
+ logs.filter { log ->
+ filterTexts.isEmpty() || filterTexts.any { filterText ->
+ log.logMessage.contains(filterText, ignoreCase = true) ||
+ log.messageType.contains(filterText, ignoreCase = true) ||
+ log.formattedReceivedDate.contains(filterText, ignoreCase = true)
+ }
+ }.toImmutableList()
+ }
+
+ LaunchedEffect(filteredLogs) {
+ viewModel.updateFilteredLogs(filteredLogs)
+ }
val shouldAutoScroll by remember { derivedStateOf { listState.firstVisibleItemIndex < 3 } }
if (shouldAutoScroll) {
- LaunchedEffect(logs) {
+ LaunchedEffect(filteredLogs) {
if (!listState.isScrollInProgress) {
listState.animateScrollToItem(0)
}
}
}
+ // Handle search result navigation
+ LaunchedEffect(searchState) {
+ if (searchState.currentMatchIndex >= 0 && searchState.currentMatchIndex < searchState.allMatches.size) {
+ listState.requestScrollToItem(searchState.allMatches[searchState.currentMatchIndex].logIndex)
+ }
+ }
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
) {
- items(logs, key = { it.uuid }) { log ->
+ stickyHeader {
+ DebugSearchStateviewModelDefaults(
+ searchState = searchState,
+ filterTexts = filterTexts,
+ presetFilters = viewModel.presetFilters.asList(),
+ )
+ }
+
+ items(filteredLogs, key = { it.uuid }) { log ->
DebugItem(
modifier = Modifier.animateItem(),
- log = log
+ log = log,
+ searchText = searchState.searchText,
+ isSelected = selectedLogId == log.uuid,
+ onLogClick = {
+ viewModel.setSelectedLogId(if (selectedLogId == log.uuid) null else log.uuid)
+ }
)
}
}
@@ -95,47 +160,50 @@ internal fun DebugScreen(
internal fun DebugItem(
log: UiMeshLog,
modifier: Modifier = Modifier,
+ searchText: String = "",
+ isSelected: Boolean = false,
+ onLogClick: () -> Unit = {}
) {
+ val colorScheme = MaterialTheme.colorScheme
+
Card(
modifier = modifier
.fillMaxWidth()
.padding(4.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = if (isSelected) {
+ colorScheme.primary.copy(alpha = 0.1f)
+ } else {
+ colorScheme.surface
+ }
+ ),
+ border = if (isSelected) {
+ BorderStroke(2.dp, colorScheme.primary)
+ } else {
+ null
+ }
) {
SelectionContainer {
Column(
- modifier = Modifier.padding(8.dp)
+ modifier = Modifier
+ .padding(if (isSelected) 12.dp else 8.dp)
+ .fillMaxWidth()
+ .clickable { onLogClick() }
) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(bottom = 8.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Text(
- text = log.messageType,
- modifier = Modifier.weight(1f),
- style = TextStyle(fontWeight = FontWeight.Bold),
- )
- Icon(
- imageVector = Icons.Outlined.CloudDownload,
- contentDescription = stringResource(id = R.string.logs),
- tint = Color.Gray.copy(alpha = 0.6f),
- modifier = Modifier.padding(end = 8.dp),
- )
- Text(
- text = log.formattedReceivedDate,
- style = TextStyle(fontWeight = FontWeight.Bold),
- )
- }
-
- val annotatedString = rememberAnnotatedLogMessage(log)
+ DebugItemHeader(
+ log = log,
+ searchText = searchText,
+ isSelected = isSelected,
+ theme = colorScheme
+ )
+ val messageAnnotatedString = rememberAnnotatedLogMessage(log, searchText)
Text(
- text = annotatedString,
+ text = messageAnnotatedString,
softWrap = false,
style = TextStyle(
- fontSize = 9.sp,
+ fontSize = if (isSelected) 12.sp else 9.sp,
fontFamily = FontFamily.Monospace,
+ color = colorScheme.onSurface
)
)
}
@@ -144,14 +212,103 @@ internal fun DebugItem(
}
@Composable
-private fun rememberAnnotatedLogMessage(log: UiMeshLog): AnnotatedString {
+private fun DebugItemHeader(
+ log: UiMeshLog,
+ searchText: String,
+ isSelected: Boolean,
+ theme: ColorScheme
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = if (isSelected) 12.dp else 8.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ val typeAnnotatedString = rememberAnnotatedString(
+ text = log.messageType,
+ searchText = searchText
+ )
+ Text(
+ text = typeAnnotatedString,
+ modifier = Modifier.weight(1f),
+ style = TextStyle(
+ fontWeight = FontWeight.Bold,
+ fontSize = if (isSelected) 16.sp else 14.sp,
+ color = theme.onSurface
+ ),
+ )
+ CopyIconButton(
+ valueToCopy = log.logMessage,
+ modifier = Modifier.padding(start = 8.dp)
+ )
+ Icon(
+ imageVector = Icons.Outlined.CloudDownload,
+ contentDescription = stringResource(id = R.string.logs),
+ tint = Color.Gray.copy(alpha = 0.6f),
+ modifier = Modifier.padding(end = 8.dp),
+ )
+ val dateAnnotatedString = rememberAnnotatedString(
+ text = log.formattedReceivedDate,
+ searchText = searchText
+ )
+ Text(
+ text = dateAnnotatedString,
+ style = TextStyle(
+ fontWeight = FontWeight.Bold,
+ fontSize = if (isSelected) 14.sp else 12.sp,
+ color = theme.onSurface
+ ),
+ )
+ }
+}
+
+@Composable
+private fun rememberAnnotatedString(
+ text: String,
+ searchText: String
+): AnnotatedString {
+ val theme = MaterialTheme.colorScheme
+ val highlightStyle = SpanStyle(
+ background = theme.primary.copy(alpha = 0.3f),
+ color = theme.onSurface
+ )
+
+ return remember(text, searchText) {
+ buildAnnotatedString {
+ append(text)
+ if (searchText.isNotEmpty()) {
+ searchText.split(" ").forEach { term ->
+ term.toRegex(RegexOption.IGNORE_CASE).findAll(text).forEach { match ->
+ addStyle(
+ style = highlightStyle,
+ start = match.range.first,
+ end = match.range.last + 1
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun rememberAnnotatedLogMessage(log: UiMeshLog, searchText: String): AnnotatedString {
+ val theme = MaterialTheme.colorScheme
val style = SpanStyle(
color = colorResource(id = R.color.colorAnnotation),
fontStyle = FontStyle.Italic,
)
- return remember(log.uuid) {
+ val highlightStyle = SpanStyle(
+ background = theme.primary.copy(alpha = 0.3f),
+ color = theme.onSurface
+ )
+
+ return remember(log.uuid, searchText) {
buildAnnotatedString {
append(log.logMessage)
+
+ // Add node ID annotations
REGEX_ANNOTATED_NODE_ID.findAll(log.logMessage).toList().reversed()
.forEach {
addStyle(
@@ -160,13 +317,26 @@ private fun rememberAnnotatedLogMessage(log: UiMeshLog): AnnotatedString {
end = it.range.last + 1
)
}
+
+ // Add search highlight annotations
+ if (searchText.isNotEmpty()) {
+ searchText.split(" ").forEach { term ->
+ term.toRegex(RegexOption.IGNORE_CASE).findAll(log.logMessage).forEach { match ->
+ addStyle(
+ style = highlightStyle,
+ start = match.range.first,
+ end = match.range.last + 1
+ )
+ }
+ }
+ }
}
}
}
@PreviewLightDark
@Composable
-private fun DebugScreenPreview() {
+private fun DebugPacketPreview() {
AppTheme {
DebugItem(
UiMeshLog(
@@ -193,15 +363,365 @@ private fun DebugScreenPreview() {
}
}
+@PreviewLightDark
+@Composable
+private fun DebugItemWithSearchHighlightPreview() {
+ AppTheme {
+ DebugItem(
+ UiMeshLog(
+ uuid = "1",
+ messageType = "TextMessage",
+ formattedReceivedDate = "9/27/20, 8:00:58 PM",
+ logMessage = "Hello world! This is a test message with some keywords to search for."
+ ),
+ searchText = "test message"
+ )
+ }
+}
+
+@PreviewLightDark
+@Composable
+private fun DebugItemPositionPreview() {
+ AppTheme {
+ DebugItem(
+ UiMeshLog(
+ uuid = "2",
+ messageType = "Position",
+ formattedReceivedDate = "9/27/20, 8:01:15 PM",
+ logMessage = "Position update from node (!a1b2c3d4) at coordinates 40.7128, -74.0060"
+ )
+ )
+ }
+}
+
+@PreviewLightDark
+@Composable
+private fun DebugItemErrorPreview() {
+ AppTheme {
+ DebugItem(
+ UiMeshLog(
+ uuid = "3",
+ messageType = "Error",
+ formattedReceivedDate = "9/27/20, 8:02:30 PM",
+ logMessage = "Connection failed: timeout after 30 seconds\n" +
+ "Retry attempt: 3/5\n" +
+ "Last known position: 40.7128, -74.0060"
+ )
+ )
+ }
+}
+
+@PreviewLightDark
+@Composable
+private fun DebugItemLongMessagePreview() {
+ AppTheme {
+ DebugItem(
+ UiMeshLog(
+ uuid = "4",
+ messageType = "Waypoint",
+ formattedReceivedDate = "9/27/20, 8:03:45 PM",
+ logMessage = "Waypoint created:\n" +
+ " Name: Home Base\n" +
+ " Description: Primary meeting location\n" +
+ " Latitude: 40.7128\n" +
+ " Longitude: -74.0060\n" +
+ " Altitude: 100m\n" +
+ " Icon: š \n" +
+ " Created by: (!a1b2c3d4)\n" +
+ " Expires: 2025-12-31 23:59:59"
+ )
+ )
+ }
+}
+
+@PreviewLightDark
+@Composable
+private fun DebugItemSelectedPreview() {
+ AppTheme {
+ DebugItem(
+ UiMeshLog(
+ uuid = "5",
+ messageType = "TextMessage",
+ formattedReceivedDate = "9/27/20, 8:04:20 PM",
+ logMessage = "This is a selected log item with larger font sizes for better readability."
+ ),
+ isSelected = true
+ )
+ }
+}
+
+@PreviewLightDark
+@Composable
+private fun DebugMenuActionsPreview() {
+ AppTheme {
+ Row(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Button(
+ onClick = { /* Preview only */ },
+ modifier = Modifier.padding(4.dp)
+ ) {
+ Text(text = "Export Logs")
+ }
+ Button(
+ onClick = { /* Preview only */ },
+ modifier = Modifier.padding(4.dp)
+ ) {
+ Text(text = "Clear All")
+ }
+ }
+ }
+}
+
+@PreviewLightDark
+@Composable
+@Suppress("detekt:LongMethod") // big preview
+private fun DebugScreenEmptyPreview() {
+ AppTheme {
+ Surface {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ stickyHeader {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(8.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ modifier = Modifier.weight(1f),
+ horizontalArrangement = Arrangement.Start,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ OutlinedTextField(
+ value = "",
+ onValueChange = { },
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = 8.dp),
+ placeholder = { Text("Search in logs...") },
+ singleLine = true
+ )
+ TextButton(
+ onClick = { }
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "Filters",
+ style = TextStyle(fontWeight = FontWeight.Bold)
+ )
+ Icon(
+ imageVector = Icons.TwoTone.FilterAltOff,
+ contentDescription = "Filter"
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ // Empty state
+ item {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(32.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = "No Debug Logs",
+ style = TextStyle(
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold
+ )
+ )
+ Text(
+ text = "Debug logs will appear here when available",
+ style = TextStyle(
+ fontSize = 14.sp,
+ color = Color.Gray
+ ),
+ modifier = Modifier.padding(top = 8.dp)
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@PreviewLightDark
+@Composable
+@Suppress("detekt:LongMethod") // big preview
+private fun DebugScreenWithSampleDataPreview() {
+ AppTheme {
+ val sampleLogs = listOf(
+ UiMeshLog(
+ uuid = "1",
+ messageType = "NodeInfo",
+ formattedReceivedDate = "9/27/20, 8:00:58 PM",
+ logMessage = "from: 2885173132\n" +
+ "decoded {\n" +
+ " position {\n" +
+ " altitude: 60\n" +
+ " battery_level: 81\n" +
+ " latitude_i: 411111136\n" +
+ " longitude_i: -711111805\n" +
+ " time: 1600390966\n" +
+ " }\n" +
+ "}\n" +
+ "hop_limit: 3\n" +
+ "id: 1737414295\n" +
+ "rx_snr: 9.5\n" +
+ "rx_time: 316400569\n" +
+ "to: -1409790708"
+ ),
+ UiMeshLog(
+ uuid = "2",
+ messageType = "TextMessage",
+ formattedReceivedDate = "9/27/20, 8:01:15 PM",
+ logMessage = "Hello from node (!a1b2c3d4)! How's the weather today?"
+ ),
+ UiMeshLog(
+ uuid = "3",
+ messageType = "Position",
+ formattedReceivedDate = "9/27/20, 8:02:30 PM",
+ logMessage = "Position update: 40.7128, -74.0060, altitude: 100m, battery: 85%"
+ ),
+ UiMeshLog(
+ uuid = "4",
+ messageType = "Waypoint",
+ formattedReceivedDate = "9/27/20, 8:03:45 PM",
+ logMessage = "New waypoint created: 'Meeting Point' at 40.7589, -73.9851"
+ ),
+ UiMeshLog(
+ uuid = "5",
+ messageType = "Error",
+ formattedReceivedDate = "9/27/20, 8:04:20 PM",
+ logMessage = "Connection timeout - retrying in 5 seconds..."
+ )
+ )
+
+ // Note: This preview shows the UI structure but won't have actual data
+ // since the ViewModel isn't injected in previews
+ Surface {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ stickyHeader {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(8.dp)
+ ) {
+ Text(
+ text = "Debug Screen Preview",
+ style = TextStyle(fontWeight = FontWeight.Bold),
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+ Text(
+ text = "Search and filter controls would appear here",
+ style = TextStyle(fontSize = 12.sp, color = Color.Gray)
+ )
+ }
+ }
+ }
+ items(sampleLogs) { log ->
+ DebugItem(log = log)
+ }
+ }
+ }
+ }
+}
+
@Composable
fun DebugMenuActions(
viewModel: DebugViewModel = hiltViewModel(),
modifier: Modifier = Modifier,
) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ val logs by viewModel.meshLog.collectAsStateWithLifecycle()
+
+ Button(
+ onClick = {
+ scope.launch {
+ exportAllLogs(context, logs)
+ }
+ },
+ modifier = modifier,
+ ) {
+ Text(text = stringResource(R.string.debug_logs_export))
+ }
Button(
onClick = viewModel::deleteAllLogs,
modifier = modifier,
) {
- Text(text = stringResource(R.string.clear))
+ Text(text = stringResource(R.string.debug_clear))
+ }
+}
+
+private suspend fun exportAllLogs(context: Context, logs: List) = withContext(Dispatchers.IO) {
+ try {
+ val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
+ val fileName = "meshtastic_debug_$timestamp.log"
+
+ // 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")
+ writer.write(log.logMessage)
+ writer.write("\n\n")
+ }
+ }
+
+ // Notify user of success
+ withContext(Dispatchers.Main) {
+ Toast.makeText(
+ context,
+ "Logs exported to ${logFile.absolutePath}",
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ } catch (e: SecurityException) {
+ withContext(Dispatchers.Main) {
+ Toast.makeText(
+ context,
+ "Permission denied: Cannot write to Downloads folder",
+ Toast.LENGTH_LONG
+ ).show()
+ warn("Error:SecurityException: " + e.toString())
+ }
+ } catch (e: IOException) {
+ withContext(Dispatchers.Main) {
+ Toast.makeText(
+ context,
+ "Failed to write log file: ${e.message}",
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ warn("Error:IOException: " + e.toString())
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/debug/DebugFilters.kt b/app/src/main/java/com/geeksville/mesh/ui/debug/DebugFilters.kt
new file mode 100644
index 000000000..cdadcc1a8
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/ui/debug/DebugFilters.kt
@@ -0,0 +1,280 @@
+/*
+ * Copyright (c) 2025 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 com.geeksville.mesh.ui.debug
+
+import androidx.compose.foundation.background
+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.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Clear
+import androidx.compose.material.icons.filled.Done
+import androidx.compose.material.icons.twotone.FilterAlt
+import androidx.compose.material.icons.twotone.FilterAltOff
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.FilterChip
+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.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.unit.DpOffset
+import androidx.compose.ui.unit.dp
+import com.geeksville.mesh.R
+
+@Composable
+fun DebugCustomFilterInput(
+ customFilterText: String,
+ onCustomFilterTextChange: (String) -> Unit,
+ filterTexts: List,
+ onFilterTextsChange: (List) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(bottom = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ OutlinedTextField(
+ value = customFilterText,
+ onValueChange = onCustomFilterTextChange,
+ modifier = Modifier.weight(1f),
+ placeholder = { Text("Add custom filter") },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ if (customFilterText.isNotBlank()) {
+ onFilterTextsChange(filterTexts + customFilterText)
+ onCustomFilterTextChange("")
+ }
+ }
+ )
+ )
+ Spacer(modifier = Modifier.padding(horizontal = 8.dp))
+ IconButton(
+ onClick = {
+ if (customFilterText.isNotBlank()) {
+ onFilterTextsChange(filterTexts + customFilterText)
+ onCustomFilterTextChange("")
+ }
+ },
+ enabled = customFilterText.isNotBlank()
+ ) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = "Add filter"
+ )
+ }
+ }
+}
+
+@Composable
+internal fun DebugPresetFilters(
+ presetFilters: List,
+ filterTexts: List,
+ onFilterTextsChange: (List) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Column(modifier = modifier) {
+ Text(
+ text = "Preset Filters",
+ style = TextStyle(fontWeight = FontWeight.Bold),
+ modifier = Modifier.padding(vertical = 4.dp)
+ )
+ FlowRow(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 0.dp),
+ horizontalArrangement = Arrangement.spacedBy(2.dp),
+ verticalArrangement = Arrangement.spacedBy(0.dp)
+ ) {
+ for (filter in presetFilters) {
+ FilterChip(
+ selected = filter in filterTexts,
+ onClick = {
+ onFilterTextsChange(
+ if (filter in filterTexts) {
+ filterTexts - filter
+ } else {
+ filterTexts + filter
+ }
+ )
+ },
+ label = { Text(filter) },
+ leadingIcon = { if (filter in filterTexts) {
+ Icon(
+ imageVector = Icons.Filled.Done,
+ contentDescription = "Done icon",
+ )
+ }
+ }
+ )
+ }
+ }
+ }
+}
+
+@Composable
+internal fun DebugFilterBar(
+ filterTexts: List,
+ onFilterTextsChange: (List) -> Unit,
+ customFilterText: String,
+ onCustomFilterTextChange: (String) -> Unit,
+ presetFilters: List,
+ modifier: Modifier = Modifier
+) {
+ var showFilterMenu by remember { mutableStateOf(false) }
+ Row(
+ modifier = modifier,
+ horizontalArrangement = Arrangement.Start,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box {
+ TextButton(
+ onClick = { showFilterMenu = !showFilterMenu }
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = stringResource(R.string.debug_filters),
+ style = TextStyle(fontWeight = FontWeight.Bold)
+ )
+ Icon(
+ imageVector = if (filterTexts.isNotEmpty()) {
+ Icons.TwoTone.FilterAlt
+ } else {
+ Icons.TwoTone.FilterAltOff
+ },
+ contentDescription = "Filter"
+ )
+ }
+ }
+ DropdownMenu(
+ expanded = showFilterMenu,
+ onDismissRequest = { showFilterMenu = false },
+ offset = DpOffset(0.dp, 8.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(8.dp)
+ .width(300.dp)
+ ) {
+ DebugCustomFilterInput(
+ customFilterText = customFilterText,
+ onCustomFilterTextChange = onCustomFilterTextChange,
+ filterTexts = filterTexts,
+ onFilterTextsChange = onFilterTextsChange
+ )
+ DebugPresetFilters(
+ presetFilters = presetFilters,
+ filterTexts = filterTexts,
+ onFilterTextsChange = onFilterTextsChange
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+internal fun DebugActiveFilters(
+ filterTexts: List,
+ onFilterTextsChange: (List) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val colorScheme = MaterialTheme.colorScheme
+
+ if (filterTexts.isNotEmpty()) {
+ Column(modifier = modifier) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 2.dp)
+ .background(colorScheme.background.copy(alpha = 1.0f)),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = stringResource(R.string.debug_active_filters),
+ style = TextStyle(fontWeight = FontWeight.Bold)
+ )
+ IconButton(
+ onClick = { onFilterTextsChange(emptyList()) }
+ ) {
+ Icon(
+ imageVector = Icons.Default.Clear,
+ contentDescription = "Clear all filters"
+ )
+ }
+ }
+ FlowRow(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 0.dp),
+ horizontalArrangement = Arrangement.spacedBy(2.dp),
+ verticalArrangement = Arrangement.spacedBy(0.dp)
+ ) {
+ for (filter in filterTexts) {
+ FilterChip(
+ selected = true,
+ onClick = {
+ onFilterTextsChange(filterTexts - filter)
+ },
+ label = { Text(filter) },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.TwoTone.FilterAlt,
+ contentDescription = null
+ )
+ },
+ trailingIcon = {
+ Icon(
+ imageVector = Icons.Filled.Clear,
+ contentDescription = null
+ )
+ }
+ )
+ }
+ }
+ }
+ }
+}
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
new file mode 100644
index 000000000..65420b739
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/ui/debug/DebugSearch.kt
@@ -0,0 +1,294 @@
+/*
+ * Copyright (c) 2025 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 com.geeksville.mesh.ui.debug
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+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.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.tooling.preview.PreviewLightDark
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.geeksville.mesh.R
+import com.geeksville.mesh.model.LogSearchManager.SearchMatch
+import com.geeksville.mesh.model.LogSearchManager.SearchState
+import com.geeksville.mesh.ui.common.theme.AppTheme
+import com.geeksville.mesh.model.DebugViewModel
+import androidx.hilt.navigation.compose.hiltViewModel
+
+@Composable
+internal fun DebugSearchNavigation(
+ searchState: SearchState,
+ onNextMatch: () -> Unit,
+ onPreviousMatch: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Row(
+ modifier = modifier.width(IntrinsicSize.Min),
+ horizontalArrangement = Arrangement.spacedBy(2.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "${searchState.currentMatchIndex + 1}/${searchState.allMatches.size}",
+ modifier = Modifier.padding(end = 4.dp),
+ style = TextStyle(fontSize = 12.sp)
+ )
+ IconButton(
+ onClick = onPreviousMatch,
+ enabled = searchState.hasMatches,
+ modifier = Modifier.size(32.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.KeyboardArrowUp,
+ contentDescription = "Previous match",
+ modifier = Modifier.size(16.dp)
+ )
+ }
+ IconButton(
+ onClick = onNextMatch,
+ enabled = searchState.hasMatches,
+ modifier = Modifier.size(32.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.KeyboardArrowDown,
+ contentDescription = "Next match",
+ modifier = Modifier.size(16.dp)
+ )
+ }
+ }
+}
+
+@Composable
+internal fun DebugSearchBar(
+ searchState: SearchState,
+ onSearchTextChange: (String) -> Unit,
+ onNextMatch: () -> Unit,
+ onPreviousMatch: () -> Unit,
+ onClearSearch: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ OutlinedTextField(
+ value = searchState.searchText,
+ onValueChange = onSearchTextChange,
+ modifier = modifier
+ .padding(end = 8.dp),
+ placeholder = { Text(stringResource(R.string.debug_default_search)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
+ keyboardActions = KeyboardActions(
+ onSearch = {
+ // Clear focus when search is performed
+ }
+ ),
+ trailingIcon = {
+ Row(
+ modifier = Modifier.width(IntrinsicSize.Min),
+ horizontalArrangement = Arrangement.spacedBy(2.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (searchState.hasMatches) {
+ DebugSearchNavigation(
+ searchState = searchState,
+ onNextMatch = onNextMatch,
+ onPreviousMatch = onPreviousMatch
+ )
+ }
+ if (searchState.searchText.isNotEmpty()) {
+ IconButton(
+ onClick = onClearSearch,
+ modifier = Modifier.size(32.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Clear,
+ contentDescription = "Clear search",
+ modifier = Modifier.size(16.dp)
+ )
+ }
+ }
+ }
+ }
+ )
+}
+
+@Composable
+internal fun DebugSearchState(
+ searchState: SearchState,
+ filterTexts: List,
+ presetFilters: List,
+ onSearchTextChange: (String) -> Unit,
+ onNextMatch: () -> Unit,
+ onPreviousMatch: () -> Unit,
+ onClearSearch: () -> Unit,
+ onFilterTextsChange: (List) -> Unit,
+) {
+ val colorScheme = MaterialTheme.colorScheme
+
+ Column(
+ modifier = Modifier.padding(8.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth()
+ .background(colorScheme.background.copy(alpha = 1.0f)),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ DebugSearchBar(
+ searchState = searchState,
+ onSearchTextChange = onSearchTextChange,
+ onNextMatch = onNextMatch,
+ onPreviousMatch = onPreviousMatch,
+ onClearSearch = onClearSearch
+ )
+ DebugFilterBar(
+ filterTexts = filterTexts,
+ onFilterTextsChange = onFilterTextsChange,
+ customFilterText = "",
+ onCustomFilterTextChange = {},
+ presetFilters = presetFilters
+ )
+ }
+ }
+
+ DebugActiveFilters(
+ filterTexts = filterTexts,
+ onFilterTextsChange = onFilterTextsChange
+ )
+}
+
+@Composable
+fun DebugSearchStateviewModelDefaults(
+ searchState: SearchState,
+ filterTexts: List,
+ presetFilters: List,
+) {
+ val viewModel: DebugViewModel = hiltViewModel()
+ DebugSearchState(
+ searchState = searchState,
+ filterTexts = filterTexts,
+ presetFilters = presetFilters,
+ onSearchTextChange = viewModel.searchManager::setSearchText,
+ onNextMatch = viewModel.searchManager::goToNextMatch,
+ onPreviousMatch = viewModel.searchManager::goToPreviousMatch,
+ onClearSearch = viewModel.searchManager::clearSearch,
+ onFilterTextsChange = viewModel.filterManager::setFilterTexts,
+ )
+}
+
+@PreviewLightDark
+@Composable
+private fun DebugSearchBarEmptyPreview() {
+ AppTheme {
+ Surface {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ DebugSearchBar(
+ searchState = SearchState(),
+ onSearchTextChange = { },
+ onNextMatch = { },
+ onPreviousMatch = { },
+ onClearSearch = { }
+ )
+ }
+ }
+ }
+}
+
+@PreviewLightDark
+@Composable
+@Suppress("detekt:MagicNumber") // fake data
+private fun DebugSearchBarWithTextPreview() {
+ AppTheme {
+ Surface {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ DebugSearchBar(
+ searchState = SearchState(
+ searchText = "test message",
+ currentMatchIndex = 2,
+ allMatches = List(5) { SearchMatch(it, 0, 10, "message") },
+ hasMatches = true
+ ),
+ onSearchTextChange = { },
+ onNextMatch = { },
+ onPreviousMatch = { },
+ onClearSearch = { }
+ )
+ }
+ }
+ }
+}
+
+@PreviewLightDark
+@Composable
+@Suppress("detekt:MagicNumber") // fake data
+private fun DebugSearchBarWithMatchesPreview() {
+ AppTheme {
+ Surface {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ DebugSearchBar(
+ searchState = SearchState(
+ searchText = "error",
+ currentMatchIndex = 0,
+ allMatches = List(3) { SearchMatch(it, 0, 5, "message") },
+ hasMatches = true
+ ),
+ onSearchTextChange = { },
+ onNextMatch = { },
+ onPreviousMatch = { },
+ onClearSearch = { }
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index e85a43ecd..75ac82e97 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -162,7 +162,12 @@
Text messages
This Channel URL is invalid and can not be used
Debug Panel
+ Export Logs
500 last messages
+ Filters
+ Active filters
+ Search in logs...
+ Clear Logs
Clear
Updating firmware, wait up to eight minutesā¦
Update successful
diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml
index b27dfe37e..9cc02f625 100644
--- a/config/detekt/detekt.yml
+++ b/config/detekt/detekt.yml
@@ -176,6 +176,7 @@ complexity:
ignorePrivate: false
ignoreOverridden: false
ignoreAnnotated: [ 'Preview', 'PreviewLightDark', 'PreviewScreenSizes' ]
+ ignoreAnnotatedFunctions: [ 'Preview', 'PreviewLightDark', 'PreviewScreenSizes' ]
coroutines:
active: true