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:
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
AnimatedVisibility(
|
||||
visible = uiState.isSearchActive,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut(),
|
||||
) {
|
||||
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)
|
||||
}
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.FilterList, contentDescription = null)
|
||||
},
|
||||
)
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
},
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = uiState.searchText,
|
||||
onValueChange = { viewModel.setSearchText(it) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.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,
|
||||
)
|
||||
}
|
||||
|
||||
when {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user