Add search functionality to connections

- Add performSearch() method to Connection with support for plain text
  and typed search (network:, inbound:, outbound:, rule:, package:, etc.)
- Add search state management in ConnectionsViewModel
- Move filter/sort/search controls to header in ComposeActivity
- Add collapsible search bar with AnimatedVisibility
This commit is contained in:
世界
2025-12-28 18:23:48 +08:00
parent 87be81e673
commit 9ad564c868
5 changed files with 234 additions and 112 deletions

View File

@@ -22,12 +22,16 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SwapVert
import androidx.compose.material.icons.filled.UnfoldLess
import androidx.compose.material.icons.filled.UnfoldMore
import androidx.compose.foundation.layout.Column
@@ -40,6 +44,7 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilterChip
import dev.jeziellago.compose.markdowntext.MarkdownText
import androidx.compose.material3.Badge
import androidx.compose.ui.Alignment
@@ -101,6 +106,8 @@ import io.nekohasekai.sfa.compose.screen.connections.ConnectionDetailsScreen
import io.nekohasekai.sfa.compose.screen.connections.ConnectionsScreen
import io.nekohasekai.sfa.compose.screen.connections.ConnectionsViewModel
import io.nekohasekai.sfa.ui.connections.Connection
import io.nekohasekai.sfa.ui.connections.ConnectionSort
import io.nekohasekai.sfa.ui.connections.ConnectionStateFilter
import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel
import io.nekohasekai.sfa.compose.screen.log.LogViewModel
import io.nekohasekai.sfa.compose.theme.SFATheme
@@ -856,22 +863,124 @@ class ComposeActivity : ComponentActivity(), ServiceConnection.Callback {
},
)
} else {
var showStateMenu by remember { mutableStateOf(false) }
var showSortMenu by remember { mutableStateOf(false) }
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
.padding(horizontal = 16.dp)
.padding(bottom = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.title_connections),
style = MaterialTheme.typography.headlineSmall,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.weight(1f))
// State Filter
Box {
FilterChip(
selected = connectionsUiState.stateFilter != ConnectionStateFilter.Active,
onClick = { showStateMenu = true },
label = {
Text(
when (connectionsUiState.stateFilter) {
ConnectionStateFilter.All -> stringResource(R.string.connection_state_all)
ConnectionStateFilter.Active -> stringResource(R.string.connection_state_active)
ConnectionStateFilter.Closed -> stringResource(R.string.connection_state_closed)
}
)
},
leadingIcon = {
Icon(Icons.Default.FilterList, contentDescription = null)
},
)
DropdownMenu(
expanded = showStateMenu,
onDismissRequest = { showStateMenu = false },
) {
ConnectionStateFilter.entries.forEach { filter ->
DropdownMenuItem(
text = {
Text(
when (filter) {
ConnectionStateFilter.All -> stringResource(R.string.connection_state_all)
ConnectionStateFilter.Active -> stringResource(R.string.connection_state_active)
ConnectionStateFilter.Closed -> stringResource(R.string.connection_state_closed)
}
)
},
onClick = {
connectionsViewModel.setStateFilter(filter)
showStateMenu = false
},
leadingIcon = {
if (connectionsUiState.stateFilter == filter) {
Icon(Icons.Default.Check, contentDescription = null)
}
},
)
}
}
}
// Sort
Box {
FilterChip(
selected = connectionsUiState.sort != ConnectionSort.ByDate,
onClick = { showSortMenu = true },
label = {
Text(
when (connectionsUiState.sort) {
ConnectionSort.ByDate -> stringResource(R.string.connection_sort_date)
ConnectionSort.ByTraffic -> stringResource(R.string.connection_sort_traffic)
ConnectionSort.ByTrafficTotal -> stringResource(R.string.connection_sort_traffic_total)
}
)
},
leadingIcon = {
Icon(Icons.Default.SwapVert, contentDescription = null)
},
)
DropdownMenu(
expanded = showSortMenu,
onDismissRequest = { showSortMenu = false },
) {
ConnectionSort.entries.forEach { sort ->
DropdownMenuItem(
text = {
Text(
when (sort) {
ConnectionSort.ByDate -> stringResource(R.string.connection_sort_date)
ConnectionSort.ByTraffic -> stringResource(R.string.connection_sort_traffic)
ConnectionSort.ByTrafficTotal -> stringResource(R.string.connection_sort_traffic_total)
}
)
},
onClick = {
connectionsViewModel.setSort(sort)
showSortMenu = false
},
leadingIcon = {
if (connectionsUiState.sort == sort) {
Icon(Icons.Default.Check, contentDescription = null)
}
},
)
}
}
}
// Menu
Box {
IconButton(onClick = { showConnectionsMenu = true }) {
Icon(Icons.Default.MoreVert, contentDescription = null)
@@ -881,12 +990,36 @@ class ComposeActivity : ComponentActivity(), ServiceConnection.Callback {
expanded = showConnectionsMenu,
onDismissRequest = { showConnectionsMenu = false },
) {
DropdownMenuItem(
text = {
Text(
if (connectionsUiState.isSearchActive) {
stringResource(R.string.close_search)
} else {
stringResource(R.string.search)
}
)
},
onClick = {
connectionsViewModel.toggleSearch()
showConnectionsMenu = false
},
leadingIcon = {
Icon(
imageVector = if (connectionsUiState.isSearchActive) Icons.Default.Close else Icons.Default.Search,
contentDescription = null,
)
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.connection_close_all)) },
onClick = {
connectionsViewModel.closeAllConnections()
showConnectionsMenu = false
},
leadingIcon = {
Icon(Icons.Default.Close, contentDescription = null)
},
enabled = connectionsUiState.connections.any { it.isActive },
)
}

View File

@@ -1,10 +1,14 @@
package io.nekohasekai.sfa.compose.screen.connections
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -18,33 +22,29 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.Velocity
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.SwapVert
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
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.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.ui.connections.Connection
import io.nekohasekai.sfa.ui.connections.ConnectionSort
import io.nekohasekai.sfa.ui.connections.ConnectionStateFilter
@Composable
fun ConnectionsScreen(
@@ -54,114 +54,42 @@ fun ConnectionsScreen(
modifier: Modifier = Modifier,
) {
val uiState by viewModel.uiState.collectAsState()
var showStateMenu by remember { mutableStateOf(false) }
var showSortMenu by remember { mutableStateOf(false) }
LaunchedEffect(serviceStatus) {
viewModel.updateServiceStatus(serviceStatus)
}
Column(modifier = modifier.fillMaxSize()) {
Row(
AnimatedVisibility(
visible = uiState.isSearchActive,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
OutlinedTextField(
value = uiState.searchText,
onValueChange = { viewModel.setSearchText(it) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box {
FilterChip(
selected = uiState.stateFilter != ConnectionStateFilter.All,
onClick = { showStateMenu = true },
label = {
Text(
when (uiState.stateFilter) {
ConnectionStateFilter.All -> stringResource(R.string.connection_state_all)
ConnectionStateFilter.Active -> stringResource(R.string.connection_state_active)
ConnectionStateFilter.Closed -> stringResource(R.string.connection_state_closed)
.padding(horizontal = 16.dp)
.padding(bottom = 8.dp)
.focusRequester(focusRequester),
placeholder = { Text(stringResource(R.string.search_connections)) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
trailingIcon = {
if (uiState.searchText.isNotEmpty()) {
IconButton(onClick = { viewModel.setSearchText("") }) {
Icon(Icons.Default.Clear, contentDescription = null)
}
}
},
singleLine = true,
)
},
leadingIcon = {
Icon(Icons.Default.FilterList, contentDescription = null)
},
)
DropdownMenu(
expanded = showStateMenu,
onDismissRequest = { showStateMenu = false },
) {
ConnectionStateFilter.entries.forEach { filter ->
DropdownMenuItem(
text = {
Text(
when (filter) {
ConnectionStateFilter.All -> stringResource(R.string.connection_state_all)
ConnectionStateFilter.Active -> stringResource(R.string.connection_state_active)
ConnectionStateFilter.Closed -> stringResource(R.string.connection_state_closed)
}
)
},
onClick = {
viewModel.setStateFilter(filter)
showStateMenu = false
},
leadingIcon = {
if (uiState.stateFilter == filter) {
Icon(Icons.Default.Check, contentDescription = null)
}
},
)
}
}
}
Box {
FilterChip(
selected = uiState.sort != ConnectionSort.ByDate,
onClick = { showSortMenu = true },
label = {
Text(
when (uiState.sort) {
ConnectionSort.ByDate -> stringResource(R.string.connection_sort_date)
ConnectionSort.ByTraffic -> stringResource(R.string.connection_sort_traffic)
ConnectionSort.ByTrafficTotal -> stringResource(R.string.connection_sort_traffic_total)
}
)
},
leadingIcon = {
Icon(Icons.Default.SwapVert, contentDescription = null)
},
)
DropdownMenu(
expanded = showSortMenu,
onDismissRequest = { showSortMenu = false },
) {
ConnectionSort.entries.forEach { sort ->
DropdownMenuItem(
text = {
Text(
when (sort) {
ConnectionSort.ByDate -> stringResource(R.string.connection_sort_date)
ConnectionSort.ByTraffic -> stringResource(R.string.connection_sort_traffic)
ConnectionSort.ByTrafficTotal -> stringResource(R.string.connection_sort_traffic_total)
}
)
},
onClick = {
viewModel.setSort(sort)
showSortMenu = false
},
leadingIcon = {
if (uiState.sort == sort) {
Icon(Icons.Default.Check, contentDescription = null)
}
},
)
}
}
}
}
when {

View File

@@ -26,6 +26,8 @@ data class ConnectionsUiState(
val isLoading: Boolean = false,
val stateFilter: ConnectionStateFilter = ConnectionStateFilter.Active,
val sort: ConnectionSort = ConnectionSort.ByDate,
val searchText: String = "",
val isSearchActive: Boolean = false,
)
sealed class ConnectionsEvent : ScreenEvent {
@@ -122,6 +124,24 @@ class ConnectionsViewModel(
rawConnections?.let { processConnections(it) }
}
fun setSearchText(text: String) {
updateState { copy(searchText = text) }
rawConnections?.let { processConnections(it) }
}
fun toggleSearch() {
val newSearchActive = !currentState.isSearchActive
updateState {
copy(
isSearchActive = newSearchActive,
searchText = if (newSearchActive) searchText else "",
)
}
if (!newSearchActive) {
rawConnections?.let { processConnections(it) }
}
}
fun closeConnection(connectionId: String) {
viewModelScope.launch(Dispatchers.IO) {
try {
@@ -188,6 +208,7 @@ class ConnectionsViewModel(
val connectionList = connections.iterator().toList()
.filter { it.outboundType != "dns" }
.map { Connection.from(it) }
.filter { it.performSearch(currentState.searchText) }
withContext(Dispatchers.Main) {
updateState {

View File

@@ -55,6 +55,45 @@ data class Connection(
) {
val isActive: Boolean get() = closedAt == null || closedAt == 0L
fun performSearch(content: String): Boolean {
if (content.isBlank()) return true
for (item in content.trim().split(" ").filter { it.isNotEmpty() }) {
val parts = item.split(":", limit = 2)
if (parts.size == 2) {
if (!performSearchType(parts[0], parts[1])) return false
} else {
if (!performSearchPlain(item)) return false
}
}
return true
}
private fun performSearchPlain(content: String): Boolean {
return destination.contains(content, ignoreCase = true) ||
domain.contains(content, ignoreCase = true) ||
outbound.contains(content, ignoreCase = true) ||
rule.contains(content, ignoreCase = true) ||
processInfo?.packageName?.contains(content, ignoreCase = true) == true
}
private fun performSearchType(type: String, value: String): Boolean {
return when (type) {
"network" -> network.equals(value, ignoreCase = true)
"inbound" -> inbound.contains(value, ignoreCase = true)
"inbound.type" -> inboundType.equals(value, ignoreCase = true)
"source" -> source.contains(value, ignoreCase = true)
"destination" -> destination.contains(value, ignoreCase = true)
"outbound" -> outbound.contains(value, ignoreCase = true)
"outbound.type" -> outboundType.equals(value, ignoreCase = true)
"rule" -> rule.contains(value, ignoreCase = true)
"protocol" -> protocolName.equals(value, ignoreCase = true)
"user" -> user.contains(value, ignoreCase = true)
"package" -> processInfo?.packageName?.contains(value, ignoreCase = true) == true
"chain" -> chain.any { it.contains(value, ignoreCase = true) }
else -> false
}
}
companion object {
fun from(connection: LibboxConnection): Connection {
return Connection(

View File

@@ -24,6 +24,7 @@
<string name="connection_sort_date">Date</string>
<string name="connection_sort_traffic">Traffic</string>
<string name="connection_sort_traffic_total">Total Traffic</string>
<string name="search_connections">Search connections…</string>
<string name="connection_close">Close</string>
<string name="connection_close_all">Close All</string>
<string name="connection_state">State</string>