Add connections management feature

Implement connections panel accessible from status bar showing active
and closed connections with filtering/sorting options, traffic stats,
and the ability to close individual or all connections.
This commit is contained in:
世界
2025-12-28 15:30:20 +08:00
parent 60ce084862
commit 87be81e673
12 changed files with 1291 additions and 0 deletions

View File

@@ -38,6 +38,8 @@ import androidx.compose.foundation.layout.width
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import dev.jeziellago.compose.markdowntext.MarkdownText
import androidx.compose.material3.Badge
import androidx.compose.ui.Alignment
@@ -95,6 +97,10 @@ import io.nekohasekai.sfa.compose.navigation.bottomNavigationScreens
import io.nekohasekai.sfa.compose.screen.dashboard.CardGroup
import io.nekohasekai.sfa.compose.screen.dashboard.DashboardViewModel
import io.nekohasekai.sfa.compose.screen.dashboard.GroupsCard
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.compose.screen.dashboard.groups.GroupsViewModel
import io.nekohasekai.sfa.compose.screen.log.LogViewModel
import io.nekohasekai.sfa.compose.theme.SFATheme
@@ -263,6 +269,9 @@ class ComposeActivity : ComponentActivity(), ServiceConnection.Callback {
// Groups Sheet state
var showGroupsSheet by remember { mutableStateOf(false) }
// Connections Sheet state
var showConnectionsSheet by remember { mutableStateOf(false) }
// Error dialog state for UiEvent.ShowError
var showErrorDialog by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf("") }
@@ -713,6 +722,8 @@ class ComposeActivity : ComponentActivity(), ServiceConnection.Callback {
groupsCount = dashboardUiState.groupsCount,
hasGroups = dashboardUiState.hasGroups,
onGroupsClick = { showGroupsSheet = true },
connectionsCount = dashboardUiState.connectionsOut.toIntOrNull() ?: 0,
onConnectionsClick = { showConnectionsSheet = true },
onStopClick = { dashboardViewModel.toggleService() },
modifier = Modifier.align(Alignment.BottomCenter),
)
@@ -804,6 +815,95 @@ class ComposeActivity : ComponentActivity(), ServiceConnection.Callback {
}
}
}
// Connections ModalBottomSheet
if (showConnectionsSheet) {
val connectionsSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val connectionsViewModel: ConnectionsViewModel = viewModel(
factory = object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return ConnectionsViewModel(dashboardViewModel.commandClient) as T
}
}
)
val connectionsUiState by connectionsViewModel.uiState.collectAsState()
var selectedConnectionId by remember { mutableStateOf<String?>(null) }
val selectedConnection = connectionsUiState.allConnections.find { it.id == selectedConnectionId }
var showConnectionsMenu by remember { mutableStateOf(false) }
ModalBottomSheet(
onDismissRequest = {
showConnectionsSheet = false
selectedConnectionId = null
},
sheetState = connectionsSheetState,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.9f),
) {
if (selectedConnection != null) {
ConnectionDetailsScreen(
connection = selectedConnection,
onBack = { selectedConnectionId = null },
onClose = {
selectedConnectionId?.let { connectionsViewModel.closeConnection(it) }
selectedConnectionId = null
},
)
} else {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.title_connections),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
)
Box {
IconButton(onClick = { showConnectionsMenu = true }) {
Icon(Icons.Default.MoreVert, contentDescription = null)
}
DropdownMenu(
expanded = showConnectionsMenu,
onDismissRequest = { showConnectionsMenu = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.connection_close_all)) },
onClick = {
connectionsViewModel.closeAllConnections()
showConnectionsMenu = false
},
enabled = connectionsUiState.connections.any { it.isActive },
)
}
}
}
// Connections content
ConnectionsScreen(
serviceStatus = currentServiceStatus,
viewModel = connectionsViewModel,
onConnectionClick = { selectedConnectionId = it.id },
modifier = Modifier.fillMaxSize(),
)
}
}
}
}
}
override fun onServiceStatusChanged(status: Status) {

View File

@@ -18,6 +18,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.outlined.Cable
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -46,6 +47,8 @@ fun ServiceStatusBar(
groupsCount: Int,
hasGroups: Boolean,
onGroupsClick: () -> Unit,
connectionsCount: Int,
onConnectionsClick: () -> Unit,
onStopClick: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -79,6 +82,32 @@ fun ServiceStatusBar(
modifier = Modifier.weight(1f),
)
// Connections button
Row(
modifier =
Modifier
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.secondaryContainer)
.clickable(onClick = onConnectionsClick)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Text(
text = connectionsCount.toString(),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.Outlined.Cable,
contentDescription = stringResource(R.string.title_connections),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
// Groups button (only show if hasGroups)
if (hasGroups) {
Row(

View File

@@ -0,0 +1,334 @@
package io.nekohasekai.sfa.compose.screen.connections
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.ui.connections.Connection
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun ConnectionDetailsScreen(
connection: Connection,
onBack: () -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
val dateTimeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
var showMenu by remember { mutableStateOf(false) }
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.content_description_back),
)
}
Text(
text = stringResource(R.string.connection_details),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
if (connection.isActive) {
Box {
IconButton(onClick = { showMenu = true }) {
Icon(Icons.Default.MoreVert, contentDescription = null)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.connection_close)) },
onClick = {
onClose()
showMenu = false
},
)
}
}
}
}
DetailSection(title = stringResource(R.string.connection_section_basic)) {
DetailRow(
label = stringResource(R.string.connection_state),
value = if (connection.isActive) {
stringResource(R.string.connection_state_active)
} else {
stringResource(R.string.connection_state_closed)
},
valueColor = if (connection.isActive) {
MaterialTheme.colorScheme.tertiary
} else {
MaterialTheme.colorScheme.error
},
)
DetailRow(
label = stringResource(R.string.connection_created_at),
value = dateTimeFormat.format(Date(connection.createdAt)),
)
if (!connection.isActive && connection.closedAt != null) {
DetailRow(
label = stringResource(R.string.connection_closed_at),
value = dateTimeFormat.format(Date(connection.closedAt)),
)
DetailRow(
label = stringResource(R.string.connection_duration),
value = Libbox.formatDuration(connection.closedAt - connection.createdAt),
)
}
DetailRow(
label = stringResource(R.string.connection_uplink),
value = Libbox.formatBytes(connection.uploadTotal),
)
DetailRow(
label = stringResource(R.string.connection_downlink),
value = Libbox.formatBytes(connection.downloadTotal),
)
}
Spacer(modifier = Modifier.height(16.dp))
DetailSection(title = stringResource(R.string.connection_section_metadata)) {
DetailRow(
label = stringResource(R.string.connection_inbound),
value = connection.inbound,
monospace = true,
)
DetailRow(
label = stringResource(R.string.connection_inbound_type),
value = connection.inboundType,
monospace = true,
)
DetailRow(
label = stringResource(R.string.connection_ip_version),
value = "IPv${connection.ipVersion}",
)
DetailRow(
label = stringResource(R.string.connection_network),
value = connection.network.uppercase(),
)
DetailRow(
label = stringResource(R.string.connection_source),
value = connection.source,
monospace = true,
)
DetailRow(
label = stringResource(R.string.connection_destination),
value = connection.destination,
monospace = true,
)
if (connection.domain.isNotEmpty()) {
DetailRow(
label = stringResource(R.string.connection_domain),
value = connection.domain,
monospace = true,
)
}
if (connection.protocolName.isNotEmpty()) {
DetailRow(
label = stringResource(R.string.connection_protocol),
value = connection.protocolName,
monospace = true,
)
}
if (connection.user.isNotEmpty()) {
DetailRow(
label = stringResource(R.string.connection_user),
value = connection.user,
monospace = true,
)
}
if (connection.fromOutbound.isNotEmpty()) {
DetailRow(
label = stringResource(R.string.connection_from_outbound),
value = connection.fromOutbound,
monospace = true,
)
}
if (connection.rule.isNotEmpty()) {
DetailRow(
label = stringResource(R.string.connection_match_rule),
value = connection.rule,
monospace = true,
)
}
DetailRow(
label = stringResource(R.string.connection_outbound),
value = connection.outbound,
monospace = true,
)
DetailRow(
label = stringResource(R.string.connection_outbound_type),
value = connection.outboundType,
monospace = true,
)
if (connection.chain.size > 1) {
DetailRow(
label = stringResource(R.string.connection_chain),
value = connection.chain.joinToString(""),
monospace = true,
)
}
}
connection.processInfo?.let { processInfo ->
if (processInfo.packageName.isNotEmpty() ||
processInfo.processPath.isNotEmpty() ||
processInfo.processId > 0
) {
Spacer(modifier = Modifier.height(16.dp))
DetailSection(title = stringResource(R.string.connection_section_process)) {
if (processInfo.processId > 0) {
DetailRow(
label = stringResource(R.string.connection_process_id),
value = processInfo.processId.toString(),
monospace = true,
)
}
if (processInfo.userId >= 0) {
DetailRow(
label = stringResource(R.string.connection_user_id),
value = processInfo.userId.toString(),
monospace = true,
)
}
if (processInfo.userName.isNotEmpty()) {
DetailRow(
label = stringResource(R.string.connection_user_name),
value = processInfo.userName,
monospace = true,
)
}
if (processInfo.processPath.isNotEmpty()) {
DetailRow(
label = stringResource(R.string.connection_process_path),
value = processInfo.processPath,
monospace = true,
)
}
if (processInfo.packageName.isNotEmpty()) {
DetailRow(
label = stringResource(R.string.connection_package_name),
value = processInfo.packageName,
monospace = true,
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
@Composable
private fun DetailSection(
title: String,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 8.dp),
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
content = content,
)
}
}
}
@Composable
private fun DetailRow(
label: String,
value: String,
monospace: Boolean = false,
valueColor: Color = MaterialTheme.colorScheme.onSurface,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = value,
style = if (monospace) {
MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace)
} else {
MaterialTheme.typography.bodyMedium
},
color = valueColor,
)
}
}

View File

@@ -0,0 +1,232 @@
package io.nekohasekai.sfa.compose.screen.connections
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.combinedClickable
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.outlined.Circle
import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.ui.connections.Connection
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private fun Drawable.toBitmap(): Bitmap {
if (this is BitmapDrawable) return bitmap
val bitmap = Bitmap.createBitmap(
intrinsicWidth.coerceAtLeast(1),
intrinsicHeight.coerceAtLeast(1),
Bitmap.Config.ARGB_8888
)
val canvas = Canvas(bitmap)
setBounds(0, 0, canvas.width, canvas.height)
draw(canvas)
return bitmap
}
data class AppInfo(
val icon: ImageBitmap,
val label: String,
)
@Composable
private fun rememberAppInfo(packageName: String): AppInfo? {
val context = LocalContext.current
return remember(packageName) {
try {
val pm = context.packageManager
val appInfo = pm.getApplicationInfo(packageName, 0)
AppInfo(
icon = appInfo.loadIcon(pm).toBitmap().asImageBitmap(),
label = appInfo.loadLabel(pm).toString(),
)
} catch (e: PackageManager.NameNotFoundException) {
null
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ConnectionItem(
connection: Connection,
onClick: () -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
var showContextMenu by remember { mutableStateOf(false) }
val packageName = connection.processInfo?.packageName?.takeIf { it.isNotEmpty() }
val appInfo = packageName?.let { rememberAppInfo(it) }
Box(modifier = modifier) {
Card(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = onClick,
onLongClick = { showContextMenu = true },
),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
// Column 1: App icon
if (appInfo != null) {
Image(
bitmap = appInfo.icon,
contentDescription = null,
modifier = Modifier.size(32.dp),
)
} else {
Icon(
imageVector = Icons.Outlined.Circle,
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Content column
Column(modifier = Modifier.weight(1f)) {
// Row 1: Title (destination + status)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "${connection.network.uppercase()} ${connection.displayDestination}",
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
),
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = if (connection.isActive) {
stringResource(R.string.connection_state_active)
} else {
stringResource(R.string.connection_state_closed)
},
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold),
color = if (connection.isActive) {
MaterialTheme.colorScheme.tertiary
} else {
MaterialTheme.colorScheme.error
},
)
}
Spacer(modifier = Modifier.height(4.dp))
// Row 2: Upload stats + inbound tag
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "${Libbox.formatBytes(connection.upload)}/s | ${Libbox.formatBytes(connection.uploadTotal)}",
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "${connection.inboundType}/${connection.inbound}",
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Row 3: Download stats + outbound tag
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "${Libbox.formatBytes(connection.download)}/s | ${Libbox.formatBytes(connection.downloadTotal)}",
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = connection.chain.firstOrNull() ?: connection.outbound,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
DropdownMenu(
expanded = showContextMenu,
onDismissRequest = { showContextMenu = false },
) {
if (connection.isActive) {
DropdownMenuItem(
text = {
Text(
stringResource(R.string.connection_close),
color = MaterialTheme.colorScheme.error,
)
},
leadingIcon = {
Icon(
Icons.Default.Close,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
onClick = {
showContextMenu = false
onClose()
},
)
}
}
}
}

View File

@@ -0,0 +1,234 @@
package io.nekohasekai.sfa.compose.screen.connections
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
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
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.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.MaterialTheme
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.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(
serviceStatus: Status,
viewModel: ConnectionsViewModel = viewModel(),
onConnectionClick: (Connection) -> Unit = {},
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,
) {
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)
},
)
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 {
uiState.isLoading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
uiState.connections.isEmpty() -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.empty_connections),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
else -> {
val lazyListState = rememberLazyListState()
val bounceBlockingConnection = rememberBounceBlockingNestedScrollConnection(lazyListState)
LazyColumn(
modifier = Modifier
.fillMaxSize()
.nestedScroll(bounceBlockingConnection),
state = lazyListState,
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(
items = uiState.connections,
key = { it.id },
) { connection ->
ConnectionItem(
connection = connection,
onClick = { onConnectionClick(connection) },
onClose = { viewModel.closeConnection(connection.id) },
)
}
}
}
}
}
}
@Composable
private fun rememberBounceBlockingNestedScrollConnection(
lazyListState: LazyListState
): NestedScrollConnection = remember(lazyListState) {
object : NestedScrollConnection {
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
return if (available.y < 0) available else Offset.Zero
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
return if (available.y < 0) available else Velocity.Zero
}
}
}

View File

@@ -0,0 +1,203 @@
package io.nekohasekai.sfa.compose.screen.connections
import androidx.lifecycle.viewModelScope
import io.nekohasekai.libbox.Connections
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.compose.base.BaseViewModel
import io.nekohasekai.sfa.compose.base.ScreenEvent
import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.ktx.toList
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.utils.CommandClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class ConnectionsUiState(
val connections: List<Connection> = emptyList(),
val allConnections: List<Connection> = emptyList(),
val isLoading: Boolean = false,
val stateFilter: ConnectionStateFilter = ConnectionStateFilter.Active,
val sort: ConnectionSort = ConnectionSort.ByDate,
)
sealed class ConnectionsEvent : ScreenEvent {
data class ConnectionClosed(val id: String) : ConnectionsEvent()
data object AllConnectionsClosed : ConnectionsEvent()
}
class ConnectionsViewModel(
private val sharedCommandClient: CommandClient? = null,
) : BaseViewModel<ConnectionsUiState, ConnectionsEvent>(), CommandClient.Handler {
private val commandClient: CommandClient
private val isUsingSharedClient: Boolean
private val _serviceStatus = MutableStateFlow(Status.Stopped)
val serviceStatus = _serviceStatus.asStateFlow()
private var lastServiceStatus: Status = Status.Stopped
private var connectionJob: Job? = null
private var rawConnections: Connections? = null
init {
if (sharedCommandClient != null) {
commandClient = sharedCommandClient
isUsingSharedClient = true
commandClient.addHandler(this)
} else {
commandClient = CommandClient(
viewModelScope,
CommandClient.ConnectionType.Connections,
this,
)
isUsingSharedClient = false
}
}
override fun createInitialState() = ConnectionsUiState()
override fun onCleared() {
super.onCleared()
connectionJob?.cancel()
connectionJob = null
if (isUsingSharedClient) {
commandClient.removeHandler(this)
} else {
commandClient.disconnect()
}
}
private fun handleServiceStatusChange(status: Status) {
if (status == Status.Started) {
if (!isUsingSharedClient) {
updateState { copy(isLoading = true) }
connectionJob?.cancel()
connectionJob = viewModelScope.launch(Dispatchers.IO) {
while (isActive) {
try {
commandClient.connect()
break
} catch (e: Exception) {
delay(100)
}
}
}
}
} else {
connectionJob?.cancel()
connectionJob = null
if (!isUsingSharedClient) {
commandClient.disconnect()
}
rawConnections = null
updateState {
copy(connections = emptyList(), allConnections = emptyList(), isLoading = false)
}
}
}
fun updateServiceStatus(status: Status) {
if (status == lastServiceStatus) return
lastServiceStatus = status
viewModelScope.launch {
_serviceStatus.emit(status)
handleServiceStatusChange(status)
}
}
fun setStateFilter(filter: ConnectionStateFilter) {
updateState { copy(stateFilter = filter) }
rawConnections?.let { processConnections(it) }
}
fun setSort(sort: ConnectionSort) {
updateState { copy(sort = sort) }
rawConnections?.let { processConnections(it) }
}
fun closeConnection(connectionId: String) {
viewModelScope.launch(Dispatchers.IO) {
try {
Libbox.newStandaloneCommandClient().closeConnection(connectionId)
withContext(Dispatchers.Main) {
sendEvent(ConnectionsEvent.ConnectionClosed(connectionId))
}
} catch (e: Exception) {
sendError(e)
}
}
}
fun closeAllConnections() {
viewModelScope.launch(Dispatchers.IO) {
try {
Libbox.newStandaloneCommandClient().closeConnections()
withContext(Dispatchers.Main) {
sendEvent(ConnectionsEvent.AllConnectionsClosed)
}
} catch (e: Exception) {
sendError(e)
}
}
}
override fun onConnected() {
viewModelScope.launch(Dispatchers.Main) {
updateState { copy(isLoading = false) }
}
}
override fun onDisconnected() {
viewModelScope.launch(Dispatchers.Main) {
rawConnections = null
updateState {
copy(connections = emptyList(), allConnections = emptyList(), isLoading = false)
}
}
}
override fun updateConnections(connections: Connections) {
rawConnections = connections
processConnections(connections)
}
private fun processConnections(connections: Connections) {
connectionJob?.cancel()
connectionJob = viewModelScope.launch(Dispatchers.Default) {
val currentState = uiState.value
val allConnectionList = connections.iterator().toList()
.filter { it.outboundType != "dns" }
.map { Connection.from(it) }
connections.filterState(currentState.stateFilter.libboxValue)
when (currentState.sort) {
ConnectionSort.ByDate -> connections.sortByDate()
ConnectionSort.ByTraffic -> connections.sortByTraffic()
ConnectionSort.ByTrafficTotal -> connections.sortByTrafficTotal()
}
val connectionList = connections.iterator().toList()
.filter { it.outboundType != "dns" }
.map { Connection.from(it) }
withContext(Dispatchers.Main) {
updateState {
copy(
connections = connectionList,
allConnections = allConnectionList,
isLoading = false,
)
}
}
}
}
}

View File

@@ -132,6 +132,7 @@ class DashboardViewModel : BaseViewModel<DashboardUiState, UiEvent>(), CommandCl
CommandClient.ConnectionType.Status,
CommandClient.ConnectionType.ClashMode,
CommandClient.ConnectionType.Groups,
CommandClient.ConnectionType.Connections,
),
this,
)

View File

@@ -3,11 +3,13 @@ package io.nekohasekai.sfa.ktx
import android.net.IpPrefix
import android.os.Build
import androidx.annotation.RequiresApi
import io.nekohasekai.libbox.ConnectionIterator
import io.nekohasekai.libbox.LogEntry
import io.nekohasekai.libbox.LogIterator
import io.nekohasekai.libbox.RoutePrefix
import io.nekohasekai.libbox.StringBox
import io.nekohasekai.libbox.StringIterator
import io.nekohasekai.libbox.Connection as LibboxConnection
import java.net.InetAddress
val StringBox?.unwrap: String
@@ -53,3 +55,11 @@ fun LogIterator.toList(): List<LogEntry> {
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
fun RoutePrefix.toIpPrefix() = IpPrefix(InetAddress.getByName(address()), prefix())
fun ConnectionIterator.toList(): List<LibboxConnection> {
return mutableListOf<LibboxConnection>().apply {
while (hasNext()) {
add(next())
}
}
}

View File

@@ -0,0 +1,87 @@
package io.nekohasekai.sfa.ui.connections
import androidx.compose.runtime.Immutable
import io.nekohasekai.libbox.Connection as LibboxConnection
import io.nekohasekai.libbox.ProcessInfo as LibboxProcessInfo
import io.nekohasekai.sfa.ktx.toList
@Immutable
data class ProcessInfo(
val processId: Long,
val userId: Int,
val userName: String,
val processPath: String,
val packageName: String,
) {
companion object {
fun from(processInfo: LibboxProcessInfo?): ProcessInfo? {
if (processInfo == null) return null
return ProcessInfo(
processId = processInfo.processID,
userId = processInfo.userID,
userName = processInfo.userName ?: "",
processPath = processInfo.processPath ?: "",
packageName = processInfo.packageName ?: "",
)
}
}
}
@Immutable
data class Connection(
val id: String,
val inbound: String,
val inboundType: String,
val ipVersion: Int,
val network: String,
val source: String,
val destination: String,
val domain: String,
val displayDestination: String,
val protocolName: String,
val user: String,
val fromOutbound: String,
val createdAt: Long,
val closedAt: Long?,
val upload: Long,
val download: Long,
val uploadTotal: Long,
val downloadTotal: Long,
val rule: String,
val outbound: String,
val outboundType: String,
val chain: List<String>,
val processInfo: ProcessInfo?,
) {
val isActive: Boolean get() = closedAt == null || closedAt == 0L
companion object {
fun from(connection: LibboxConnection): Connection {
return Connection(
id = connection.id,
inbound = connection.inbound,
inboundType = connection.inboundType,
ipVersion = connection.ipVersion,
network = connection.network,
source = connection.source,
destination = connection.destination,
domain = connection.domain,
displayDestination = connection.displayDestination(),
protocolName = connection.protocol,
user = connection.user,
fromOutbound = connection.fromOutbound,
createdAt = connection.createdAt,
closedAt = if (connection.closedAt > 0) connection.closedAt else null,
upload = connection.uplink,
download = connection.downlink,
uploadTotal = connection.uplinkTotal,
downloadTotal = connection.downlinkTotal,
rule = connection.rule,
outbound = connection.outbound,
outboundType = connection.outboundType,
chain = connection.chain().toList(),
processInfo = ProcessInfo.from(connection.processInfo),
)
}
}
}

View File

@@ -0,0 +1,15 @@
package io.nekohasekai.sfa.ui.connections
import io.nekohasekai.libbox.Libbox
enum class ConnectionStateFilter(val libboxValue: Int) {
All(Libbox.ConnectionStateAll.toInt()),
Active(Libbox.ConnectionStateActive.toInt()),
Closed(Libbox.ConnectionStateClosed.toInt()),
}
enum class ConnectionSort {
ByDate,
ByTraffic,
ByTrafficTotal,
}

View File

@@ -62,6 +62,7 @@ open class CommandClient(
Groups,
Log,
ClashMode,
Connections,
}
interface Handler {
@@ -85,6 +86,8 @@ open class CommandClient(
) {}
fun updateClashMode(newMode: String) {}
fun updateConnections(connections: Connections) {}
}
private var commandClient: CommandClient? = null
@@ -100,6 +103,7 @@ open class CommandClient(
ConnectionType.Groups -> Libbox.CommandGroup
ConnectionType.Log -> Libbox.CommandLog
ConnectionType.ClashMode -> Libbox.CommandClashMode
ConnectionType.Connections -> Libbox.CommandConnections
}
options.addCommand(command)
}
@@ -194,6 +198,8 @@ open class CommandClient(
}
override fun writeConnections(message: Connections?) {
if (message == null) return
getAllHandlers().forEach { it.updateConnections(message) }
}
}
}

View File

@@ -15,6 +15,46 @@
<string name="title_groups">Groups</string>
<string name="title_debug">Debug</string>
<string name="title_connections">Connections</string>
<string name="empty_connections">No connections</string>
<string name="connection_state_all">All</string>
<string name="connection_state_active">Active</string>
<string name="connection_state_closed">Closed</string>
<string name="connection_filter_state">State</string>
<string name="connection_filter_sort">Sort By</string>
<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="connection_close">Close</string>
<string name="connection_close_all">Close All</string>
<string name="connection_state">State</string>
<string name="connection_details">Connection Details</string>
<string name="connection_section_basic">Basic Information</string>
<string name="connection_section_metadata">Metadata</string>
<string name="connection_created_at">Created At</string>
<string name="connection_closed_at">Closed At</string>
<string name="connection_duration">Duration</string>
<string name="connection_uplink">Uplink</string>
<string name="connection_downlink">Downlink</string>
<string name="connection_inbound">Inbound</string>
<string name="connection_inbound_type">Inbound Type</string>
<string name="connection_ip_version">IP Version</string>
<string name="connection_network">Network</string>
<string name="connection_source">Source</string>
<string name="connection_destination">Destination</string>
<string name="connection_domain">Domain</string>
<string name="connection_protocol">Protocol</string>
<string name="connection_user">User</string>
<string name="connection_from_outbound">From Outbound</string>
<string name="connection_match_rule">Match Rule</string>
<string name="connection_outbound">Outbound</string>
<string name="connection_outbound_type">Outbound Type</string>
<string name="connection_chain">Chain</string>
<string name="connection_section_process">Process Information</string>
<string name="connection_process_id">Process ID</string>
<string name="connection_user_id">User ID</string>
<string name="connection_user_name">User Name</string>
<string name="connection_process_path">Process Path</string>
<string name="connection_package_name">Package Name</string>
<string name="title_others">Others</string>
<string name="title_experimental_features">Experimental Features</string>
<string name="message_experimental_features">Try out new features that are still in development</string>