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.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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.ClashMode,
|
||||
CommandClient.ConnectionType.Groups,
|
||||
CommandClient.ConnectionType.Connections,
|
||||
),
|
||||
this,
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user