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