From 9ad564c868f4740051198aa870ec967ec3ac43a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 28 Dec 2025 18:23:48 +0800 Subject: [PATCH] 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 --- .../sfa/compose/ComposeActivity.kt | 141 ++++++++++++++++- .../screen/connections/ConnectionsScreen.kt | 144 +++++------------- .../connections/ConnectionsViewModel.kt | 21 +++ .../sfa/ui/connections/Connection.kt | 39 +++++ app/src/main/res/values/strings.xml | 1 + 5 files changed, 234 insertions(+), 112 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/ComposeActivity.kt b/app/src/main/java/io/nekohasekai/sfa/compose/ComposeActivity.kt index 5c1ec1e..1fa9d05 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/ComposeActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/ComposeActivity.kt @@ -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 }, ) } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsScreen.kt index 85d9637..3e05c66 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsScreen.kt @@ -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 { diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsViewModel.kt index 86be10e..14f16a2 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsViewModel.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsViewModel.kt @@ -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 { diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/connections/Connection.kt b/app/src/main/java/io/nekohasekai/sfa/ui/connections/Connection.kt index 69e1402..7c89920 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ui/connections/Connection.kt +++ b/app/src/main/java/io/nekohasekai/sfa/ui/connections/Connection.kt @@ -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( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 13e4a22..054bc01 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -24,6 +24,7 @@ Date Traffic Total Traffic + Search connections… Close Close All State