diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/ComposeActivity.kt b/app/src/main/java/io/nekohasekai/sfa/compose/ComposeActivity.kt index 3d3e29b..5c1ec1e 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/ComposeActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/ComposeActivity.kt @@ -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 create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return ConnectionsViewModel(dashboardViewModel.commandClient) as T + } + } + ) + val connectionsUiState by connectionsViewModel.uiState.collectAsState() + var selectedConnectionId by remember { mutableStateOf(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) { diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/component/ServiceStatusBar.kt b/app/src/main/java/io/nekohasekai/sfa/compose/component/ServiceStatusBar.kt index 5492034..cc65c99 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/component/ServiceStatusBar.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/component/ServiceStatusBar.kt @@ -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( diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionDetailsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionDetailsScreen.kt new file mode 100644 index 0000000..2384d6d --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionDetailsScreen.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionItem.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionItem.kt new file mode 100644 index 0000000..1a02c34 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionItem.kt @@ -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() + }, + ) + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsScreen.kt new file mode 100644 index 0000000..85d9637 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsScreen.kt @@ -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 + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsViewModel.kt new file mode 100644 index 0000000..86be10e --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsViewModel.kt @@ -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 = emptyList(), + val allConnections: List = 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(), 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, + ) + } + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt index c9948c0..beaee52 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt @@ -132,6 +132,7 @@ class DashboardViewModel : BaseViewModel(), CommandCl CommandClient.ConnectionType.Status, CommandClient.ConnectionType.ClashMode, CommandClient.ConnectionType.Groups, + CommandClient.ConnectionType.Connections, ), this, ) diff --git a/app/src/main/java/io/nekohasekai/sfa/ktx/Wrappers.kt b/app/src/main/java/io/nekohasekai/sfa/ktx/Wrappers.kt index 8e97959..532754e 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ktx/Wrappers.kt +++ b/app/src/main/java/io/nekohasekai/sfa/ktx/Wrappers.kt @@ -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 { @RequiresApi(Build.VERSION_CODES.TIRAMISU) fun RoutePrefix.toIpPrefix() = IpPrefix(InetAddress.getByName(address()), prefix()) + +fun ConnectionIterator.toList(): List { + return mutableListOf().apply { + while (hasNext()) { + add(next()) + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/connections/Connection.kt b/app/src/main/java/io/nekohasekai/sfa/ui/connections/Connection.kt new file mode 100644 index 0000000..69e1402 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/ui/connections/Connection.kt @@ -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, + 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), + ) + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/connections/ConnectionFilters.kt b/app/src/main/java/io/nekohasekai/sfa/ui/connections/ConnectionFilters.kt new file mode 100644 index 0000000..6f1ec7e --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/ui/connections/ConnectionFilters.kt @@ -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, +} diff --git a/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt b/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt index 0586fc9..7a9d2d6 100644 --- a/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt +++ b/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt @@ -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) } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 845bc83..13e4a22 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -15,6 +15,46 @@ Groups Debug Connections + No connections + All + Active + Closed + State + Sort By + Date + Traffic + Total Traffic + Close + Close All + State + Connection Details + Basic Information + Metadata + Created At + Closed At + Duration + Uplink + Downlink + Inbound + Inbound Type + IP Version + Network + Source + Destination + Domain + Protocol + User + From Outbound + Match Rule + Outbound + Outbound Type + Chain + Process Information + Process ID + User ID + User Name + Process Path + Package Name Others Experimental Features Try out new features that are still in development