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:
@@ -38,6 +38,8 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import dev.jeziellago.compose.markdowntext.MarkdownText
|
import dev.jeziellago.compose.markdowntext.MarkdownText
|
||||||
import androidx.compose.material3.Badge
|
import androidx.compose.material3.Badge
|
||||||
import androidx.compose.ui.Alignment
|
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.CardGroup
|
||||||
import io.nekohasekai.sfa.compose.screen.dashboard.DashboardViewModel
|
import io.nekohasekai.sfa.compose.screen.dashboard.DashboardViewModel
|
||||||
import io.nekohasekai.sfa.compose.screen.dashboard.GroupsCard
|
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.dashboard.groups.GroupsViewModel
|
||||||
import io.nekohasekai.sfa.compose.screen.log.LogViewModel
|
import io.nekohasekai.sfa.compose.screen.log.LogViewModel
|
||||||
import io.nekohasekai.sfa.compose.theme.SFATheme
|
import io.nekohasekai.sfa.compose.theme.SFATheme
|
||||||
@@ -263,6 +269,9 @@ class ComposeActivity : ComponentActivity(), ServiceConnection.Callback {
|
|||||||
// Groups Sheet state
|
// Groups Sheet state
|
||||||
var showGroupsSheet by remember { mutableStateOf(false) }
|
var showGroupsSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Connections Sheet state
|
||||||
|
var showConnectionsSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Error dialog state for UiEvent.ShowError
|
// Error dialog state for UiEvent.ShowError
|
||||||
var showErrorDialog by remember { mutableStateOf(false) }
|
var showErrorDialog by remember { mutableStateOf(false) }
|
||||||
var errorMessage by remember { mutableStateOf("") }
|
var errorMessage by remember { mutableStateOf("") }
|
||||||
@@ -713,6 +722,8 @@ class ComposeActivity : ComponentActivity(), ServiceConnection.Callback {
|
|||||||
groupsCount = dashboardUiState.groupsCount,
|
groupsCount = dashboardUiState.groupsCount,
|
||||||
hasGroups = dashboardUiState.hasGroups,
|
hasGroups = dashboardUiState.hasGroups,
|
||||||
onGroupsClick = { showGroupsSheet = true },
|
onGroupsClick = { showGroupsSheet = true },
|
||||||
|
connectionsCount = dashboardUiState.connectionsOut.toIntOrNull() ?: 0,
|
||||||
|
onConnectionsClick = { showConnectionsSheet = true },
|
||||||
onStopClick = { dashboardViewModel.toggleService() },
|
onStopClick = { dashboardViewModel.toggleService() },
|
||||||
modifier = Modifier.align(Alignment.BottomCenter),
|
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) {
|
override fun onServiceStatusChanged(status: Status) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Folder
|
import androidx.compose.material.icons.filled.Folder
|
||||||
import androidx.compose.material.icons.filled.Stop
|
import androidx.compose.material.icons.filled.Stop
|
||||||
|
import androidx.compose.material.icons.outlined.Cable
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
@@ -46,6 +47,8 @@ fun ServiceStatusBar(
|
|||||||
groupsCount: Int,
|
groupsCount: Int,
|
||||||
hasGroups: Boolean,
|
hasGroups: Boolean,
|
||||||
onGroupsClick: () -> Unit,
|
onGroupsClick: () -> Unit,
|
||||||
|
connectionsCount: Int,
|
||||||
|
onConnectionsClick: () -> Unit,
|
||||||
onStopClick: () -> Unit,
|
onStopClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -79,6 +82,32 @@ fun ServiceStatusBar(
|
|||||||
modifier = Modifier.weight(1f),
|
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)
|
// Groups button (only show if hasGroups)
|
||||||
if (hasGroups) {
|
if (hasGroups) {
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -132,6 +132,7 @@ class DashboardViewModel : BaseViewModel<DashboardUiState, UiEvent>(), CommandCl
|
|||||||
CommandClient.ConnectionType.Status,
|
CommandClient.ConnectionType.Status,
|
||||||
CommandClient.ConnectionType.ClashMode,
|
CommandClient.ConnectionType.ClashMode,
|
||||||
CommandClient.ConnectionType.Groups,
|
CommandClient.ConnectionType.Groups,
|
||||||
|
CommandClient.ConnectionType.Connections,
|
||||||
),
|
),
|
||||||
this,
|
this,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ package io.nekohasekai.sfa.ktx
|
|||||||
import android.net.IpPrefix
|
import android.net.IpPrefix
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
|
import io.nekohasekai.libbox.ConnectionIterator
|
||||||
import io.nekohasekai.libbox.LogEntry
|
import io.nekohasekai.libbox.LogEntry
|
||||||
import io.nekohasekai.libbox.LogIterator
|
import io.nekohasekai.libbox.LogIterator
|
||||||
import io.nekohasekai.libbox.RoutePrefix
|
import io.nekohasekai.libbox.RoutePrefix
|
||||||
import io.nekohasekai.libbox.StringBox
|
import io.nekohasekai.libbox.StringBox
|
||||||
import io.nekohasekai.libbox.StringIterator
|
import io.nekohasekai.libbox.StringIterator
|
||||||
|
import io.nekohasekai.libbox.Connection as LibboxConnection
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
|
|
||||||
val StringBox?.unwrap: String
|
val StringBox?.unwrap: String
|
||||||
@@ -53,3 +55,11 @@ fun LogIterator.toList(): List<LogEntry> {
|
|||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||||
fun RoutePrefix.toIpPrefix() = IpPrefix(InetAddress.getByName(address()), prefix())
|
fun RoutePrefix.toIpPrefix() = IpPrefix(InetAddress.getByName(address()), prefix())
|
||||||
|
|
||||||
|
fun ConnectionIterator.toList(): List<LibboxConnection> {
|
||||||
|
return mutableListOf<LibboxConnection>().apply {
|
||||||
|
while (hasNext()) {
|
||||||
|
add(next())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@ open class CommandClient(
|
|||||||
Groups,
|
Groups,
|
||||||
Log,
|
Log,
|
||||||
ClashMode,
|
ClashMode,
|
||||||
|
Connections,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Handler {
|
interface Handler {
|
||||||
@@ -85,6 +86,8 @@ open class CommandClient(
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
fun updateClashMode(newMode: String) {}
|
fun updateClashMode(newMode: String) {}
|
||||||
|
|
||||||
|
fun updateConnections(connections: Connections) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var commandClient: CommandClient? = null
|
private var commandClient: CommandClient? = null
|
||||||
@@ -100,6 +103,7 @@ open class CommandClient(
|
|||||||
ConnectionType.Groups -> Libbox.CommandGroup
|
ConnectionType.Groups -> Libbox.CommandGroup
|
||||||
ConnectionType.Log -> Libbox.CommandLog
|
ConnectionType.Log -> Libbox.CommandLog
|
||||||
ConnectionType.ClashMode -> Libbox.CommandClashMode
|
ConnectionType.ClashMode -> Libbox.CommandClashMode
|
||||||
|
ConnectionType.Connections -> Libbox.CommandConnections
|
||||||
}
|
}
|
||||||
options.addCommand(command)
|
options.addCommand(command)
|
||||||
}
|
}
|
||||||
@@ -194,6 +198,8 @@ open class CommandClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun writeConnections(message: Connections?) {
|
override fun writeConnections(message: Connections?) {
|
||||||
|
if (message == null) return
|
||||||
|
getAllHandlers().forEach { it.updateConnections(message) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,46 @@
|
|||||||
<string name="title_groups">Groups</string>
|
<string name="title_groups">Groups</string>
|
||||||
<string name="title_debug">Debug</string>
|
<string name="title_debug">Debug</string>
|
||||||
<string name="title_connections">Connections</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_others">Others</string>
|
||||||
<string name="title_experimental_features">Experimental Features</string>
|
<string name="title_experimental_features">Experimental Features</string>
|
||||||
<string name="message_experimental_features">Try out new features that are still in development</string>
|
<string name="message_experimental_features">Try out new features that are still in development</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user