From 76772e20f86c872fd6bd0bb8a1933a07f4dd6480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 10 Apr 2026 11:35:56 +0800 Subject: [PATCH] tools: Tailscale status --- .../nekohasekai/sfa/compose/MainActivity.kt | 2 + .../sfa/compose/navigation/SFANavigation.kt | 73 ++- .../screen/tools/NetworkQualityScreen.kt | 470 ++++++++++++++++++ .../screen/tools/NetworkQualityViewModel.kt | 216 ++++++++ .../screen/tools/OutboundPickerScreen.kt | 283 +++++++++++ .../sfa/compose/screen/tools/ResultItem.kt | 78 +++ .../compose/screen/tools/STUNTestScreen.kt | 317 ++++++++++++ .../compose/screen/tools/STUNTestViewModel.kt | 146 ++++++ .../screen/tools/TailscaleEndpointScreen.kt | 362 ++++++++++++++ .../screen/tools/TailscalePeerScreen.kt | 460 +++++++++++++++++ .../screen/tools/TailscalePingViewModel.kt | 108 ++++ .../screen/tools/TailscaleStatusViewModel.kt | 180 +++++++ .../sfa/compose/screen/tools/ToolsScreen.kt | 128 ++++- .../io/nekohasekai/sfa/utils/CommandClient.kt | 21 + app/src/main/res/values-fa/strings.xml | 77 +++ app/src/main/res/values-ru-rRU/strings.xml | 77 +++ app/src/main/res/values-zh-rCN/strings.xml | 58 ++- app/src/main/res/values-zh-rTW/strings.xml | 65 ++- app/src/main/res/values/strings.xml | 65 +++ 19 files changed, 3179 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityScreen.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityViewModel.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OutboundPickerScreen.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ResultItem.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestScreen.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestViewModel.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleEndpointScreen.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePeerScreen.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePingViewModel.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleStatusViewModel.kt diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt b/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt index 059c64d..80f12bf 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt @@ -114,6 +114,7 @@ import io.nekohasekai.sfa.compose.screen.dashboard.DashboardViewModel import io.nekohasekai.sfa.compose.screen.dashboard.GroupsCard import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel import io.nekohasekai.sfa.compose.screen.log.LogViewModel +import io.nekohasekai.sfa.compose.screen.tools.TailscaleStatusViewModel import io.nekohasekai.sfa.compose.theme.SFATheme import io.nekohasekai.sfa.compose.topbar.LocalTopBarController import io.nekohasekai.sfa.compose.topbar.TopBarController @@ -869,6 +870,7 @@ class MainActivity : logViewModel = logViewModel, groupsViewModel = groupsViewModel, connectionsViewModel = connectionsViewModel, + tailscaleStatusViewModel = tailscaleStatusViewModel, modifier = Modifier.fillMaxSize(), ) if (!useNavigationRail) { diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt index e52ee71..6357e5c 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -37,10 +38,16 @@ import io.nekohasekai.sfa.compose.screen.tools.CrashReportDetailScreen import io.nekohasekai.sfa.compose.screen.tools.CrashReportFileContentScreen import io.nekohasekai.sfa.compose.screen.tools.CrashReportListScreen import io.nekohasekai.sfa.compose.screen.tools.CrashReportMetadataScreen +import io.nekohasekai.sfa.compose.screen.tools.NetworkQualityScreen import io.nekohasekai.sfa.compose.screen.tools.OOMReportDetailScreen import io.nekohasekai.sfa.compose.screen.tools.OOMReportFileContentScreen import io.nekohasekai.sfa.compose.screen.tools.OOMReportListScreen import io.nekohasekai.sfa.compose.screen.tools.OOMReportMetadataScreen +import io.nekohasekai.sfa.compose.screen.tools.OutboundPickerScreen +import io.nekohasekai.sfa.compose.screen.tools.STUNTestScreen +import io.nekohasekai.sfa.compose.screen.tools.TailscaleEndpointScreen +import io.nekohasekai.sfa.compose.screen.tools.TailscalePeerScreen +import io.nekohasekai.sfa.compose.screen.tools.TailscaleStatusViewModel import io.nekohasekai.sfa.compose.screen.tools.ToolsScreen import io.nekohasekai.sfa.constant.Status @@ -73,6 +80,7 @@ fun SFANavHost( logViewModel: LogViewModel? = null, groupsViewModel: GroupsViewModel? = null, connectionsViewModel: ConnectionsViewModel? = null, + tailscaleStatusViewModel: TailscaleStatusViewModel? = null, modifier: Modifier = Modifier, ) { NavHost( @@ -220,10 +228,73 @@ fun SFANavHost( } composable(Screen.Tools.route) { - ToolsScreen(navController = navController) + val tailscaleViewModel: TailscaleStatusViewModel = tailscaleStatusViewModel ?: viewModel() + ToolsScreen(navController = navController, serviceStatus = serviceStatus, tailscaleViewModel = tailscaleViewModel) } // Tools subscreens with slide animations + composable( + route = "tools/network_quality", + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { + NetworkQualityScreen(navController = navController, serviceStatus = serviceStatus) + } + + composable( + route = "tools/stun_test", + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { + STUNTestScreen(navController = navController, serviceStatus = serviceStatus) + } + + composable( + route = "tools/outbound_picker/{selectedOutbound}", + arguments = listOf(navArgument("selectedOutbound") { type = NavType.StringType }), + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { backStackEntry -> + val selectedOutbound = Uri.decode(backStackEntry.arguments?.getString("selectedOutbound") ?: "") + OutboundPickerScreen(navController = navController, selectedOutbound = selectedOutbound) + } + + composable( + route = "tools/tailscale/{endpointTag}", + arguments = listOf(navArgument("endpointTag") { type = NavType.StringType }), + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { backStackEntry -> + val endpointTag = Uri.decode(backStackEntry.arguments?.getString("endpointTag") ?: return@composable) + val tailscaleViewModel: TailscaleStatusViewModel = tailscaleStatusViewModel ?: viewModel() + TailscaleEndpointScreen(navController = navController, viewModel = tailscaleViewModel, endpointTag = endpointTag) + } + + composable( + route = "tools/tailscale/{endpointTag}/peer/{peerId}", + arguments = listOf( + navArgument("endpointTag") { type = NavType.StringType }, + navArgument("peerId") { type = NavType.StringType }, + ), + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { backStackEntry -> + val endpointTag = Uri.decode(backStackEntry.arguments?.getString("endpointTag") ?: return@composable) + val peerId = Uri.decode(backStackEntry.arguments?.getString("peerId") ?: return@composable) + val tailscaleViewModel: TailscaleStatusViewModel = tailscaleStatusViewModel ?: viewModel() + TailscalePeerScreen(navController = navController, viewModel = tailscaleViewModel, endpointTag = endpointTag, peerId = peerId) + } + composable( route = "tools/crash_report", enterTransition = slideInFromRight, diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityScreen.kt new file mode 100644 index 0000000..63d8963 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityScreen.kt @@ -0,0 +1,470 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NetworkQualityScreen( + navController: NavController, + serviceStatus: Status = Status.Stopped, + viewModel: NetworkQualityViewModel = viewModel(), +) { + val state by viewModel.uiState.collectAsState() + val vpnRunning = serviceStatus == Status.Started + val context = LocalContext.current + + var showConfigURLDialog by remember { mutableStateOf(false) } + var showMaxRuntimeDialog by remember { mutableStateOf(false) } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.network_quality)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + ) + } + + LaunchedEffect(vpnRunning) { + if (!vpnRunning) { + viewModel.onVpnDisconnected() + } + } + + val selectedOutboundResult = navController.currentBackStackEntry + ?.savedStateHandle + ?.getStateFlow("selected_outbound", state.selectedOutbound) + ?.collectAsState() + LaunchedEffect(selectedOutboundResult?.value) { + selectedOutboundResult?.value?.let { viewModel.selectOutbound(it) } + } + + DisposableEffect(Unit) { + onDispose { + if (state.isRunning) { + viewModel.cancelTest() + } + } + } + + if (state.showMeteredWarning) { + AlertDialog( + onDismissRequest = { viewModel.dismissMeteredWarning() }, + title = { Text(stringResource(R.string.network_quality_metered_title)) }, + text = { Text(stringResource(R.string.network_quality_metered_message)) }, + confirmButton = { + TextButton(onClick = { viewModel.confirmMeteredStart(vpnRunning) }) { + Text(stringResource(R.string.network_quality_metered_continue)) + } + }, + dismissButton = { + TextButton(onClick = { viewModel.dismissMeteredWarning() }) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) + } + + if (showConfigURLDialog) { + ConfigURLDialog( + currentURL = state.configURL, + onURLChanged = { viewModel.updateConfigURL(it) }, + onDismiss = { showConfigURLDialog = false }, + ) + } + + if (showMaxRuntimeDialog) { + MaxRuntimeDialog( + currentOption = state.maxRuntime, + onOptionSelected = { + viewModel.setMaxRuntime(it) + showMaxRuntimeDialog = false + }, + onDismiss = { showMaxRuntimeDialog = false }, + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.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), + ) { + Text( + text = stringResource(R.string.tool_configuration), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { showConfigURLDialog = true }, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(12.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.network_quality_url), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + state.configURL, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + stringResource(R.string.network_quality_serial), + style = MaterialTheme.typography.bodyLarge, + ) + Switch( + checked = state.serial, + onCheckedChange = { viewModel.setSerial(it) }, + enabled = !state.isRunning, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + stringResource(R.string.network_quality_http3), + style = MaterialTheme.typography.bodyLarge, + ) + Switch( + checked = state.http3, + onCheckedChange = { viewModel.setHttp3(it) }, + enabled = !state.isRunning, + ) + } + + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(enabled = !state.isRunning) { showMaxRuntimeDialog = true }, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(12.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + stringResource(R.string.network_quality_max_runtime), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + Text( + stringResource(state.maxRuntime.labelRes), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (vpnRunning) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + OutboundPickerRow( + selectedOutbound = state.selectedOutbound, + onClick = { + navController.navigate( + "tools/outbound_picker/${android.net.Uri.encode(state.selectedOutbound)}", + ) + }, + ) + } + } + } + + if (state.isRunning) { + Button( + onClick = { viewModel.cancelTest() }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.network_quality_cancel)) + } + } else { + Button( + onClick = { viewModel.requestStartTest(context, vpnRunning) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.network_quality_start)) + } + } + + if (state.phase >= 0) { + val phaseDownload = Libbox.NetworkQualityPhaseDownload.toInt() + val phaseUpload = Libbox.NetworkQualityPhaseUpload.toInt() + val downloadActive = + (state.isRunning && !state.serial && state.phase in phaseDownload..phaseUpload) || state.phase == phaseDownload + val uploadActive = + (state.isRunning && !state.serial && state.phase in phaseDownload..phaseUpload) || state.phase == phaseUpload + val done = state.phase == Libbox.NetworkQualityPhaseDone.toInt() + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringResource(R.string.tool_results), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(bottom = 8.dp), + ) + + ResultItem( + label = stringResource(R.string.network_quality_idle_latency), + value = if (state.idleLatencyMs > 0) "${state.idleLatencyMs} ms" else null, + isActive = state.phase == Libbox.NetworkQualityPhaseIdle.toInt(), + isRunning = state.isRunning, + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.network_quality_download), + value = if (state.downloadCapacity > 0) Libbox.formatBitrate(state.downloadCapacity) else null, + isActive = downloadActive, + isRunning = state.isRunning, + accuracy = if (done) accuracyLabel(state.downloadCapacityAccuracy).first else null, + accuracyColor = if (done) accuracyLabel(state.downloadCapacityAccuracy).second else null, + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.network_quality_download_rpm), + value = if (state.downloadRPM > 0) "${state.downloadRPM}" else null, + isActive = downloadActive, + isRunning = state.isRunning, + accuracy = if (done) accuracyLabel(state.downloadRPMAccuracy).first else null, + accuracyColor = if (done) accuracyLabel(state.downloadRPMAccuracy).second else null, + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.network_quality_upload), + value = if (state.uploadCapacity > 0) Libbox.formatBitrate(state.uploadCapacity) else null, + isActive = uploadActive, + isRunning = state.isRunning, + accuracy = if (done) accuracyLabel(state.uploadCapacityAccuracy).first else null, + accuracyColor = if (done) accuracyLabel(state.uploadCapacityAccuracy).second else null, + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.network_quality_upload_rpm), + value = if (state.uploadRPM > 0) "${state.uploadRPM}" else null, + isActive = uploadActive, + isRunning = state.isRunning, + accuracy = if (done) accuracyLabel(state.uploadRPMAccuracy).first else null, + accuracyColor = if (done) accuracyLabel(state.uploadRPMAccuracy).second else null, + ) + } + } + } + } +} + +@Composable +private fun accuracyLabel(value: Int): Pair = when (value) { + Libbox.NetworkQualityAccuracyHigh -> stringResource(R.string.network_quality_confidence_high) to Color.Green + Libbox.NetworkQualityAccuracyMedium -> stringResource(R.string.network_quality_confidence_medium) to Color.Yellow + else -> stringResource(R.string.network_quality_confidence_low) to Color.Red +} + +@Composable +private fun ConfigURLDialog( + currentURL: String, + onURLChanged: (String) -> Unit, + onDismiss: () -> Unit, +) { + var text by remember { mutableStateOf(currentURL) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.network_quality_url)) }, + text = { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + TextButton(onClick = { + onURLChanged(text) + onDismiss() + }) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) +} + +@Composable +private fun MaxRuntimeDialog( + currentOption: MaxRuntimeOption, + onOptionSelected: (MaxRuntimeOption) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.network_quality_max_runtime)) }, + text = { + Column { + MaxRuntimeOption.entries.forEach { option -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { onOptionSelected(option) } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = currentOption == option, + onClick = { onOptionSelected(option) }, + ) + Text( + text = stringResource(option.labelRes), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityViewModel.kt new file mode 100644 index 0000000..30b0fbc --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityViewModel.kt @@ -0,0 +1,216 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import android.content.Context +import android.net.ConnectivityManager +import androidx.lifecycle.viewModelScope +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.NetworkQualityProgress +import io.nekohasekai.libbox.NetworkQualityResult +import io.nekohasekai.libbox.NetworkQualityTestHandler +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.base.BaseViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +enum class MaxRuntimeOption(val seconds: Int, val labelRes: Int) { + THIRTY(30, R.string.network_quality_max_runtime_30s), + SIXTY(60, R.string.network_quality_max_runtime_60s), +} + +data class NetworkQualityState( + val phase: Int = -1, + val idleLatencyMs: Int = 0, + val downloadCapacity: Long = 0, + val uploadCapacity: Long = 0, + val downloadRPM: Int = 0, + val uploadRPM: Int = 0, + val downloadCapacityAccuracy: Int = 0, + val uploadCapacityAccuracy: Int = 0, + val downloadRPMAccuracy: Int = 0, + val uploadRPMAccuracy: Int = 0, + val isRunning: Boolean = false, + val configURL: String = Libbox.NetworkQualityDefaultConfigURL, + val serial: Boolean = false, + val http3: Boolean = false, + val maxRuntime: MaxRuntimeOption = MaxRuntimeOption.THIRTY, + val selectedOutbound: String = "", + val showMeteredWarning: Boolean = false, +) + +class NetworkQualityViewModel : BaseViewModel() { + private var standaloneTest: io.nekohasekai.libbox.NetworkQualityTest? = null + private var grpcJob: Job? = null + + override fun createInitialState() = NetworkQualityState() + + fun updateConfigURL(url: String) { + updateState { copy(configURL = url) } + } + + fun selectOutbound(tag: String) { + updateState { copy(selectedOutbound = tag) } + } + + fun setSerial(value: Boolean) { + updateState { copy(serial = value) } + } + + fun setHttp3(value: Boolean) { + updateState { copy(http3 = value) } + } + + fun setMaxRuntime(option: MaxRuntimeOption) { + updateState { copy(maxRuntime = option) } + } + + fun onVpnDisconnected() { + cancelTest() + updateState { copy(selectedOutbound = "") } + } + + fun requestStartTest(context: Context, vpnRunning: Boolean) { + val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + if (connectivityManager.isActiveNetworkMetered) { + updateState { copy(showMeteredWarning = true) } + } else { + startTest(vpnRunning) + } + } + + fun dismissMeteredWarning() { + updateState { copy(showMeteredWarning = false) } + } + + fun confirmMeteredStart(vpnRunning: Boolean) { + updateState { copy(showMeteredWarning = false) } + startTest(vpnRunning) + } + + private fun startTest(vpnRunning: Boolean) { + updateState { + copy( + phase = -1, + idleLatencyMs = 0, + downloadCapacity = 0, + uploadCapacity = 0, + downloadRPM = 0, + uploadRPM = 0, + downloadCapacityAccuracy = 0, + uploadCapacityAccuracy = 0, + downloadRPMAccuracy = 0, + uploadRPMAccuracy = 0, + isRunning = true, + ) + } + + val configURL = currentState.configURL + val outboundTag = currentState.selectedOutbound + val serial = currentState.serial + val http3 = currentState.http3 + val maxRuntimeSeconds = currentState.maxRuntime.seconds + val handler = createHandler() + + if (vpnRunning) { + grpcJob = viewModelScope.launch(Dispatchers.IO) { + try { + Libbox.newStandaloneCommandClient() + .startNetworkQualityTest( + configURL, + outboundTag, + serial, + maxRuntimeSeconds, + http3, + handler, + ) + } catch (e: Exception) { + withContext(Dispatchers.Main) { + if (!currentState.isRunning) return@withContext + updateState { copy(isRunning = false) } + grpcJob = null + sendError(e) + } + } + } + } else { + val test = Libbox.newNetworkQualityTest() + standaloneTest = test + launch { + withContext(Dispatchers.IO) { + test.start(configURL, serial, maxRuntimeSeconds, http3, handler) + } + } + } + } + + fun cancelTest() { + grpcJob?.cancel() + grpcJob = null + standaloneTest?.cancel() + standaloneTest = null + updateState { copy(isRunning = false) } + } + + private fun createHandler(): NetworkQualityTestHandler { + return object : NetworkQualityTestHandler { + override fun onProgress(progress: NetworkQualityProgress?) { + progress ?: return + viewModelScope.launch { + if (!currentState.isRunning) return@launch + updateState { + copy( + phase = progress.phase.toInt(), + idleLatencyMs = progress.idleLatencyMs.toInt(), + downloadCapacity = progress.downloadCapacity, + uploadCapacity = progress.uploadCapacity, + downloadRPM = progress.downloadRPM.toInt(), + uploadRPM = progress.uploadRPM.toInt(), + downloadCapacityAccuracy = progress.downloadCapacityAccuracy.toInt(), + uploadCapacityAccuracy = progress.uploadCapacityAccuracy.toInt(), + downloadRPMAccuracy = progress.downloadRPMAccuracy.toInt(), + uploadRPMAccuracy = progress.uploadRPMAccuracy.toInt(), + ) + } + } + } + + override fun onResult(result: NetworkQualityResult?) { + result ?: return + viewModelScope.launch { + if (!currentState.isRunning) return@launch + updateState { + copy( + phase = Libbox.NetworkQualityPhaseDone.toInt(), + idleLatencyMs = result.idleLatencyMs.toInt(), + downloadCapacity = result.downloadCapacity, + uploadCapacity = result.uploadCapacity, + downloadRPM = result.downloadRPM.toInt(), + uploadRPM = result.uploadRPM.toInt(), + downloadCapacityAccuracy = result.downloadCapacityAccuracy.toInt(), + uploadCapacityAccuracy = result.uploadCapacityAccuracy.toInt(), + downloadRPMAccuracy = result.downloadRPMAccuracy.toInt(), + uploadRPMAccuracy = result.uploadRPMAccuracy.toInt(), + isRunning = false, + ) + } + standaloneTest = null + grpcJob = null + } + } + + override fun onError(message: String?) { + viewModelScope.launch { + if (!currentState.isRunning) return@launch + updateState { copy(isRunning = false) } + standaloneTest = null + grpcJob = null + if (message != null) { + sendErrorMessage(message) + } + } + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OutboundPickerScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OutboundPickerScreen.kt new file mode 100644 index 0000000..f14f504 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OutboundPickerScreen.kt @@ -0,0 +1,283 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.model.GroupItem +import io.nekohasekai.sfa.utils.CommandClient +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class OutboundPickerViewModel : + ViewModel(), + CommandClient.Handler { + private val _outbounds = MutableStateFlow>(emptyList()) + val outbounds: StateFlow> = _outbounds.asStateFlow() + + private var commandClient: CommandClient? = null + + fun connect() { + disconnect() + commandClient = CommandClient( + viewModelScope, + CommandClient.ConnectionType.Outbounds, + this, + ) + commandClient?.connect() + } + + fun disconnect() { + commandClient?.disconnect() + commandClient = null + } + + override fun updateOutbounds(outbounds: List) { + _outbounds.value = outbounds.map { GroupItem(it) } + } + + override fun onCleared() { + disconnect() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OutboundPickerScreen( + navController: NavController, + selectedOutbound: String, +) { + val viewModel: OutboundPickerViewModel = viewModel() + val outbounds by viewModel.outbounds.collectAsState() + var searchText by rememberSaveable { mutableStateOf("") } + + DisposableEffect(Unit) { + viewModel.connect() + onDispose { + viewModel.disconnect() + } + } + + val filteredOutbounds = if (searchText.isEmpty()) { + outbounds + } else { + outbounds.filter { it.tag.contains(searchText, ignoreCase = true) } + } + + fun selectOutbound(tag: String) { + navController.previousBackStackEntry?.savedStateHandle?.set("selected_outbound", tag) + navController.navigateUp() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.tool_outbound)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + ) + } + }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + OutlinedTextField( + value = searchText, + onValueChange = { searchText = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + placeholder = { Text(stringResource(android.R.string.search_go)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + ) + }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + ) + + LazyColumn(modifier = Modifier.fillMaxSize()) { + item { + OutboundPickerItem( + tag = stringResource(R.string.tool_default_outbound), + isSelected = selectedOutbound.isEmpty(), + onClick = { selectOutbound("") }, + ) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + items(filteredOutbounds, key = { it.tag }) { item -> + OutboundPickerItem( + tag = item.tag, + type = Libbox.proxyDisplayType(item.type), + urlTestDelay = item.urlTestDelay, + isSelected = selectedOutbound == item.tag, + onClick = { selectOutbound(item.tag) }, + ) + } + } + } + } +} + +@Composable +private fun OutboundPickerItem( + tag: String, + type: String? = null, + urlTestDelay: Int = 0, + isSelected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = tag, + style = MaterialTheme.typography.bodyLarge, + ) + if (type != null) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = type, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (urlTestDelay > 0) { + Text( + text = "${urlTestDelay}ms", + style = MaterialTheme.typography.bodySmall, + color = outboundDelayColor(urlTestDelay), + ) + } + } + } + } + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} + +@Composable +fun OutboundPickerRow( + selectedOutbound: String, + onClick: () -> Unit, +) { + val displayText = if (selectedOutbound.isEmpty()) { + stringResource(R.string.tool_default_outbound) + } else { + selectedOutbound + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(12.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + stringResource(R.string.tool_outbound), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + Text( + displayText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +fun outboundDelayColor(delay: Int): Color { + val colorScheme = MaterialTheme.colorScheme + return when { + delay < 100 -> colorScheme.tertiary + delay < 300 -> colorScheme.primary + delay < 500 -> colorScheme.secondary + else -> colorScheme.error + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ResultItem.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ResultItem.kt new file mode 100644 index 0000000..24c02e4 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ResultItem.kt @@ -0,0 +1,78 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp + +@Composable +fun ResultItem( + label: String, + value: String?, + isActive: Boolean, + isRunning: Boolean, + accuracy: String? = null, + valueColor: Color? = null, + accuracyColor: Color? = null, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, style = MaterialTheme.typography.bodyLarge) + when { + value != null -> { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isRunning && isActive) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + } + Text( + value, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + color = valueColor ?: Color.Unspecified, + ) + if (accuracy != null) { + Text( + accuracy, + style = MaterialTheme.typography.labelSmall, + color = accuracyColor ?: MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + isRunning && isActive -> { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + } + else -> { + Text( + "-", + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + ) + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestScreen.kt new file mode 100644 index 0000000..9681283 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestScreen.kt @@ -0,0 +1,317 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun STUNTestScreen( + navController: NavController, + serviceStatus: Status = Status.Stopped, + viewModel: STUNTestViewModel = viewModel(), +) { + val state by viewModel.uiState.collectAsState() + val vpnRunning = serviceStatus == Status.Started + + var showServerDialog by remember { mutableStateOf(false) } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.stun_test)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + ) + } + + LaunchedEffect(vpnRunning) { + if (!vpnRunning) { + viewModel.onVpnDisconnected() + } + } + + val selectedOutboundResult = navController.currentBackStackEntry + ?.savedStateHandle + ?.getStateFlow("selected_outbound", state.selectedOutbound) + ?.collectAsState() + LaunchedEffect(selectedOutboundResult?.value) { + selectedOutboundResult?.value?.let { viewModel.selectOutbound(it) } + } + + DisposableEffect(Unit) { + onDispose { + if (state.isRunning) { + viewModel.cancelTest() + } + } + } + + if (showServerDialog) { + ServerEditDialog( + currentServer = state.server, + onServerChanged = { viewModel.updateServer(it) }, + onDismiss = { showServerDialog = false }, + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.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), + ) { + Text( + text = stringResource(R.string.tool_configuration), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { showServerDialog = true }, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(12.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.stun_server), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + state.server, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (vpnRunning) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + OutboundPickerRow( + selectedOutbound = state.selectedOutbound, + onClick = { + navController.navigate( + "tools/outbound_picker/${android.net.Uri.encode(state.selectedOutbound)}", + ) + }, + ) + } + } + } + + if (state.isRunning) { + Button( + onClick = { viewModel.cancelTest() }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.stun_cancel)) + } + } else { + Button( + onClick = { viewModel.startTest(vpnRunning) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.stun_start)) + } + } + + if (state.phase >= 0) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringResource(R.string.tool_results), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(bottom = 8.dp), + ) + + ResultItem( + label = stringResource(R.string.stun_external_address), + value = state.externalAddr.ifEmpty { null }, + isActive = state.phase == Libbox.STUNPhaseBinding.toInt(), + isRunning = state.isRunning, + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.stun_latency), + value = if (state.latencyMs > 0) "${state.latencyMs} ms" else null, + isActive = state.phase == Libbox.STUNPhaseBinding.toInt(), + isRunning = state.isRunning, + ) + if (state.phase == Libbox.STUNPhaseDone.toInt() && !state.natTypeSupported) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.stun_nat_type_detection), + value = stringResource(R.string.stun_nat_not_supported), + isActive = false, + isRunning = false, + ) + } else { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.stun_nat_mapping), + value = if (state.natMapping > 0) Libbox.formatNATMapping(state.natMapping) else null, + isActive = state.phase == Libbox.STUNPhaseNATMapping.toInt(), + isRunning = state.isRunning, + valueColor = if (state.natMapping > 0) natMappingColor(state.natMapping) else null, + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.stun_nat_filtering), + value = if (state.natFiltering > 0) Libbox.formatNATFiltering(state.natFiltering) else null, + isActive = state.phase == Libbox.STUNPhaseNATFiltering.toInt(), + isRunning = state.isRunning, + valueColor = if (state.natFiltering > 0) natFilteringColor(state.natFiltering) else null, + ) + } + } + } + } + } +} + +private fun natMappingColor(value: Int): Color = when (value) { + Libbox.NATMappingEndpointIndependent.toInt() -> Color.Green + Libbox.NATMappingAddressDependent.toInt() -> Color.Yellow + Libbox.NATMappingAddressAndPortDependent.toInt() -> Color.Red + else -> Color.Unspecified +} + +private fun natFilteringColor(value: Int): Color = when (value) { + Libbox.NATFilteringEndpointIndependent.toInt() -> Color.Green + Libbox.NATFilteringAddressDependent.toInt() -> Color.Yellow + Libbox.NATFilteringAddressAndPortDependent.toInt() -> Color.Red + else -> Color.Unspecified +} + +@Composable +private fun ServerEditDialog( + currentServer: String, + onServerChanged: (String) -> Unit, + onDismiss: () -> Unit, +) { + var text by remember { mutableStateOf(currentServer) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.stun_server)) }, + text = { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + TextButton(onClick = { + onServerChanged(text) + onDismiss() + }) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestViewModel.kt new file mode 100644 index 0000000..0b7405c --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestViewModel.kt @@ -0,0 +1,146 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.lifecycle.viewModelScope +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.STUNTestHandler +import io.nekohasekai.libbox.STUNTestProgress +import io.nekohasekai.libbox.STUNTestResult +import io.nekohasekai.sfa.compose.base.BaseViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class STUNTestState( + val phase: Int = -1, + val externalAddr: String = "", + val latencyMs: Int = 0, + val natMapping: Int = 0, + val natFiltering: Int = 0, + val natTypeSupported: Boolean = false, + val isRunning: Boolean = false, + val server: String = Libbox.STUNDefaultServer, + val selectedOutbound: String = "", +) + +class STUNTestViewModel : BaseViewModel() { + private var standaloneTest: io.nekohasekai.libbox.STUNTest? = null + private var grpcJob: Job? = null + + override fun createInitialState() = STUNTestState() + + fun updateServer(server: String) { + updateState { copy(server = server) } + } + + fun selectOutbound(tag: String) { + updateState { copy(selectedOutbound = tag) } + } + + fun onVpnDisconnected() { + cancelTest() + updateState { copy(selectedOutbound = "") } + } + + fun startTest(vpnRunning: Boolean) { + updateState { + copy( + phase = -1, + externalAddr = "", + latencyMs = 0, + natMapping = 0, + natFiltering = 0, + natTypeSupported = false, + isRunning = true, + ) + } + + val server = currentState.server + val outboundTag = currentState.selectedOutbound + val handler = createHandler() + + if (vpnRunning) { + grpcJob = viewModelScope.launch(Dispatchers.IO) { + try { + Libbox.newStandaloneCommandClient() + .startSTUNTest(server, outboundTag, handler) + } catch (e: Exception) { + withContext(Dispatchers.Main) { + if (!currentState.isRunning) return@withContext + updateState { copy(isRunning = false) } + grpcJob = null + sendError(e) + } + } + } + } else { + val test = Libbox.newSTUNTest() + standaloneTest = test + launch { + withContext(Dispatchers.IO) { + test.start(server, handler) + } + } + } + } + + fun cancelTest() { + grpcJob?.cancel() + grpcJob = null + standaloneTest?.cancel() + standaloneTest = null + updateState { copy(isRunning = false) } + } + + private fun createHandler(): STUNTestHandler { + return object : STUNTestHandler { + override fun onProgress(progress: STUNTestProgress?) { + progress ?: return + viewModelScope.launch { + if (!currentState.isRunning) return@launch + updateState { + copy( + phase = progress.phase.toInt(), + externalAddr = progress.externalAddr, + latencyMs = progress.latencyMs.toInt(), + natMapping = progress.natMapping.toInt(), + natFiltering = progress.natFiltering.toInt(), + ) + } + } + } + + override fun onResult(result: STUNTestResult?) { + result ?: return + viewModelScope.launch { + if (!currentState.isRunning) return@launch + updateState { + copy( + phase = Libbox.STUNPhaseDone.toInt(), + isRunning = false, + externalAddr = result.externalAddr, + latencyMs = result.latencyMs.toInt(), + natMapping = result.natMapping.toInt(), + natFiltering = result.natFiltering.toInt(), + natTypeSupported = result.natTypeSupported, + ) + } + standaloneTest = null + grpcJob = null + } + } + + override fun onError(message: String?) { + viewModelScope.launch { + if (!currentState.isRunning) return@launch + updateState { copy(isRunning = false) } + standaloneTest = null + grpcJob = null + if (message != null) { + sendErrorMessage(message) + } + } + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleEndpointScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleEndpointScreen.kt new file mode 100644 index 0000000..60e3090 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleEndpointScreen.kt @@ -0,0 +1,362 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.fillMaxSize +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.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.filled.QrCode2 +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.component.qr.QRCodeDialog +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.compose.util.QRCodeGenerator + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TailscaleEndpointScreen( + navController: NavController, + viewModel: TailscaleStatusViewModel, + endpointTag: String, +) { + OverrideTopBar { + TopAppBar( + title = { Text(endpointTag) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.content_description_back)) + } + }, + ) + } + + val state by viewModel.uiState.collectAsState() + val endpoint = state.endpoints.firstOrNull { it.endpointTag == endpointTag } + + if (endpoint == null) { + LaunchedEffect(Unit) { + navController.navigateUp() + } + return + } + + val context = LocalContext.current + var showAuthQRCode by remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + val hasNetwork = endpoint.networkName.isNotEmpty() + val hasMagicDNS = endpoint.magicDNSSuffix.isNotEmpty() + val hasAuth = endpoint.authURL.isNotEmpty() + + // Status section + SectionHeader(stringResource(R.string.tailscale_status)) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + val stateIsLast = !hasNetwork && !hasMagicDNS && !hasAuth + ListItem( + headlineContent = { + Text( + stringResource(R.string.tailscale_state), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(stateColor(endpoint.backendState)), + ) + Text( + endpoint.backendState, + style = MaterialTheme.typography.bodyMedium, + color = stateColor(endpoint.backendState), + ) + } + }, + modifier = Modifier.clip( + if (stateIsLast) { + RoundedCornerShape(12.dp) + } else { + RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + }, + ), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + if (hasNetwork) { + val networkIsLast = !hasMagicDNS && !hasAuth + ListItem( + headlineContent = { + Text( + stringResource(R.string.tailscale_network), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + endpoint.networkName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + modifier = if (networkIsLast) { + Modifier.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + } else { + Modifier + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + if (hasMagicDNS) { + val magicDNSIsLast = !hasAuth + ListItem( + headlineContent = { + Text( + stringResource(R.string.tailscale_magic_dns), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + endpoint.magicDNSSuffix, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + modifier = if (magicDNSIsLast) { + Modifier.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + } else { + Modifier + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + if (hasAuth) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.tailscale_open_auth_url), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + Icons.AutoMirrored.Outlined.OpenInNew, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier.clickable { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(endpoint.authURL))) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + ListItem( + headlineContent = { + Text( + stringResource(R.string.tailscale_open_auth_url_qr_code), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + Icons.Default.QrCode2, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + .clickable { showAuthQRCode = true }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + + // This Device section + if (endpoint.backendState == "Running" && endpoint.selfPeer != null) { + Spacer(modifier = Modifier.height(16.dp)) + SectionHeader(stringResource(R.string.tailscale_this_device)) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + PeerItem( + peer = endpoint.selfPeer, + onClick = { + navController.navigate( + "tools/tailscale/${Uri.encode(endpointTag)}/peer/${Uri.encode(endpoint.selfPeer.id)}", + ) + }, + modifier = Modifier.clip(RoundedCornerShape(12.dp)), + ) + } + } + + // User group sections + for (group in endpoint.userGroups) { + Spacer(modifier = Modifier.height(16.dp)) + SectionHeader(group.displayName.ifEmpty { group.loginName }) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + group.peers.forEachIndexed { index, peer -> + if (index > 0) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + PeerItem( + peer = peer, + onClick = { + navController.navigate( + "tools/tailscale/${Uri.encode(endpointTag)}/peer/${Uri.encode(peer.id)}", + ) + }, + modifier = when { + group.peers.size == 1 -> Modifier.clip(RoundedCornerShape(12.dp)) + index == 0 -> Modifier.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + index == group.peers.lastIndex -> Modifier.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + else -> Modifier + }, + ) + } + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + } + + if (showAuthQRCode && endpoint.authURL.isNotEmpty()) { + val qrBitmap = QRCodeGenerator.rememberBitmap(endpoint.authURL) + QRCodeDialog( + bitmap = qrBitmap, + onDismiss = { showAuthQRCode = false }, + ) + } +} + +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) +} + +@Composable +private fun PeerItem( + peer: TailscalePeerData, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ListItem( + headlineContent = { + Text( + peer.hostName, + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = if (peer.tailscaleIPs.isNotEmpty()) { + { + Text( + peer.tailscaleIPs.first(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + null + }, + leadingContent = { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(if (peer.online) Color(0xFF4CAF50) else Color.Gray), + ) + }, + modifier = modifier.clickable(onClick = onClick), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) +} + +private fun stateColor(state: String): Color = when (state) { + "Running" -> Color(0xFF4CAF50) + "NeedsLogin", "NeedsMachineAuth" -> Color(0xFFFF9800) + "Starting" -> Color(0xFFFFEB3B) + else -> Color.Gray +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePeerScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePeerScreen.kt new file mode 100644 index 0000000..c821186 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePeerScreen.kt @@ -0,0 +1,460 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import android.text.format.DateUtils +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +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.fillMaxSize +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.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +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.Check +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +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.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.LineChart +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.ktx.clipboardText +import kotlinx.coroutines.delay + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TailscalePeerScreen( + navController: NavController, + viewModel: TailscaleStatusViewModel, + endpointTag: String, + peerId: String, +) { + val state by viewModel.uiState.collectAsState() + val peer = viewModel.peer(endpointTag, peerId) + val isSelf = viewModel.endpoint(endpointTag)?.selfPeer?.id == peerId + val pingViewModel: TailscalePingViewModel = viewModel() + val pingState by pingViewModel.uiState.collectAsState() + + DisposableEffect(Unit) { + onDispose { + if (pingState.isRunning) { + pingViewModel.stopPing() + } + } + } + + OverrideTopBar { + TopAppBar( + title = { + Column { + Text( + peer?.hostName ?: "", + style = MaterialTheme.typography.titleMedium, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Box( + modifier = Modifier + .size(6.dp) + .clip(CircleShape) + .background( + if (peer?.online == true) Color(0xFF4CAF50) else Color.Gray, + ), + ) + Text( + if (peer?.online == true) { + stringResource(R.string.tailscale_connected) + } else { + stringResource(R.string.tailscale_not_connected) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.content_description_back)) + } + }, + ) + } + + if (peer == null) { + LaunchedEffect(Unit) { + navController.navigateUp() + } + return + } + + var copiedAddress by remember { mutableStateOf(null) } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + // Tailscale Addresses section + SectionHeader(stringResource(R.string.tailscale_addresses)) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (peer.dnsName.isNotEmpty()) { + AddressRow( + address = Libbox.formatFQDN(peer.dnsName), + label = stringResource(R.string.tailscale_magic_dns), + copied = copiedAddress, + onCopy = { copiedAddress = it }, + ) + } + for (ip in peer.tailscaleIPs) { + AddressRow( + address = ip, + label = if (ip.contains(":")) { + stringResource(R.string.tailscale_ipv6) + } else { + stringResource(R.string.tailscale_ipv4) + }, + copied = copiedAddress, + onCopy = { copiedAddress = it }, + ) + } + } + } + + // Ping section (not for self peer) + if (!isSelf && peer.online && peer.tailscaleIPs.isNotEmpty()) { + val peerIP = peer.tailscaleIPs.first() + + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.tailscale_ping), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + Surface( + onClick = { + if (pingState.isRunning) { + pingViewModel.stopPing() + } else { + pingViewModel.startPing(endpointTag, peerIP) + } + }, + shape = RoundedCornerShape(12.dp), + color = if (isSystemInDarkTheme()) { + lerp( + MaterialTheme.colorScheme.surfaceContainerHighest, + MaterialTheme.colorScheme.surfaceContainerHigh, + 0.5f, + ) + } else { + MaterialTheme.colorScheme.surfaceDim + }, + modifier = Modifier.size(width = 44.dp, height = 32.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = if (pingState.isRunning) Icons.Default.Stop else Icons.Default.PlayArrow, + contentDescription = if (pingState.isRunning) { + stringResource(R.string.tailscale_ping_stop) + } else { + stringResource(R.string.tailscale_ping_start) + }, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + if (pingState.hasResult) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + if (pingState.isDirect) { + Text( + text = "\u2192 ", + color = Color(0xFF4CAF50), + ) + Text( + text = stringResource(R.string.tailscale_ping_direct), + color = Color(0xFF4CAF50), + ) + } else { + Text( + text = "\u21BB ", + color = Color(0xFFFF9800), + ) + Text( + text = stringResource(R.string.tailscale_ping_derp), + color = Color(0xFFFF9800), + ) + } + Spacer(modifier = Modifier.weight(1f)) + Text( + text = "${pingState.latencyMs.toInt()} ms", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + if (pingState.isRunning && pingState.latencyHistory.size > 1) { + Spacer(modifier = Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + LineChart( + data = pingState.latencyHistory, + lineColor = if (pingState.isDirect) { + Color(0xFF4CAF50) + } else { + Color(0xFF2196F3) + }, + animate = false, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(8.dp)) + val maxMs = ( + ( + pingState.latencyHistory.maxOrNull() + ?: 1f + ) * 1.2f + ).toInt().coerceAtLeast(1) + Column( + modifier = Modifier.height(80.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "${maxMs}ms", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "${maxMs * 2 / 3}ms", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "${maxMs / 3}ms", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "0ms", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } else { + Text( + text = "No data", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + // Details section + val showDetails = peer.keyExpiry > 0 || peer.os.isNotEmpty() || peer.exitNode + if (showDetails) { + Spacer(modifier = Modifier.height(16.dp)) + SectionHeader(stringResource(R.string.tailscale_details)) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ), + ) { + val context = LocalContext.current + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (peer.keyExpiry > 0) { + val expiryText = DateUtils.getRelativeTimeSpanString( + peer.keyExpiry * 1000, + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + ).toString() + DetailRow( + label = stringResource(R.string.tailscale_key_expiry), + value = expiryText, + ) + } + if (peer.os.isNotEmpty()) { + DetailRow( + label = stringResource(R.string.tailscale_os), + value = peer.os, + ) + } + if (peer.exitNode) { + DetailRow( + label = stringResource(R.string.tailscale_exit_node), + value = stringResource(R.string.tailscale_active), + ) + } + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + } + + LaunchedEffect(copiedAddress) { + if (copiedAddress != null) { + delay(2000) + copiedAddress = null + } + } +} + +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) +} + +@Composable +private fun DetailRow(label: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 16.dp), + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.End, + ) + } +} + +@Composable +private fun AddressRow( + address: String, + label: String, + copied: String?, + onCopy: (String) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + address, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = { + clipboardText = address + onCopy(address) + }) { + if (copied == address) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Icon( + Icons.Outlined.ContentCopy, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePingViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePingViewModel.kt new file mode 100644 index 0000000..9109e26 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePingViewModel.kt @@ -0,0 +1,108 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.lifecycle.viewModelScope +import io.nekohasekai.libbox.CommandClient +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.TailscalePingHandler +import io.nekohasekai.libbox.TailscalePingResult +import io.nekohasekai.sfa.compose.base.BaseViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class TailscalePingState( + val isRunning: Boolean = false, + val hasResult: Boolean = false, + val latencyMs: Double = 0.0, + val isDirect: Boolean = false, + val derpRegionCode: String = "", + val endpoint: String = "", + val latencyHistory: List = emptyList(), +) + +class TailscalePingViewModel : BaseViewModel() { + private val maxHistorySize = 30 + private var commandClient: CommandClient? = null + private var grpcJob: Job? = null + + override fun createInitialState() = TailscalePingState() + + fun startPing(endpointTag: String, peerIP: String) { + updateState { + copy( + isRunning = true, + hasResult = false, + latencyHistory = emptyList(), + ) + } + + val client = Libbox.newStandaloneCommandClient() + commandClient = client + + grpcJob = viewModelScope.launch(Dispatchers.IO) { + try { + client.startTailscalePing( + endpointTag, + peerIP, + object : TailscalePingHandler { + override fun onPingResult(result: TailscalePingResult?) { + result ?: return + viewModelScope.launch { + if (!currentState.isRunning) return@launch + if (result.error.isNotEmpty()) return@launch + val newHistory = currentState.latencyHistory.toMutableList() + newHistory.add(result.latencyMs.toFloat()) + if (newHistory.size > maxHistorySize) { + newHistory.removeFirst() + } + updateState { + copy( + hasResult = true, + latencyMs = result.latencyMs, + isDirect = result.isDirect, + derpRegionCode = result.derpRegionCode, + endpoint = result.endpoint, + latencyHistory = newHistory, + ) + } + } + } + + override fun onError(message: String?) { + viewModelScope.launch { + if (!currentState.isRunning) return@launch + updateState { copy(isRunning = false) } + commandClient = null + grpcJob = null + } + } + }, + ) + } catch (e: Exception) { + withContext(Dispatchers.Main) { + if (!currentState.isRunning) return@withContext + updateState { copy(isRunning = false) } + commandClient = null + grpcJob = null + } + } + } + } + + fun stopPing() { + grpcJob?.cancel() + grpcJob = null + try { + commandClient?.disconnect() + } catch (_: Exception) { + } + commandClient = null + updateState { copy(isRunning = false) } + } + + override fun onCleared() { + super.onCleared() + stopPing() + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleStatusViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleStatusViewModel.kt new file mode 100644 index 0000000..1de43e4 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleStatusViewModel.kt @@ -0,0 +1,180 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.lifecycle.viewModelScope +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.TailscaleStatusHandler +import io.nekohasekai.libbox.TailscaleStatusUpdate +import io.nekohasekai.sfa.compose.base.BaseViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +data class TailscalePeerData( + val id: String, + val hostName: String, + val dnsName: String, + val os: String, + val tailscaleIPs: List, + val online: Boolean, + val exitNode: Boolean, + val exitNodeOption: Boolean, + val active: Boolean, + val rxBytes: Long, + val txBytes: Long, + val keyExpiry: Long, +) + +data class TailscaleUserGroupData( + val id: Long, + val loginName: String, + val displayName: String, + val profilePicURL: String, + val peers: List, +) + +data class TailscaleEndpointData( + val endpointTag: String, + val backendState: String, + val authURL: String, + val networkName: String, + val magicDNSSuffix: String, + val selfPeer: TailscalePeerData?, + val userGroups: List, +) + +data class TailscaleStatusState( + val endpoints: List = emptyList(), + val isSubscribed: Boolean = false, +) + +class TailscaleStatusViewModel : BaseViewModel() { + private var grpcJob: Job? = null + + override fun createInitialState() = TailscaleStatusState() + + fun subscribe() { + if (currentState.isSubscribed) return + updateState { copy(isSubscribed = true) } + + grpcJob = viewModelScope.launch(Dispatchers.IO) { + try { + Libbox.newStandaloneCommandClient() + .subscribeTailscaleStatus(object : TailscaleStatusHandler { + override fun onStatusUpdate(status: TailscaleStatusUpdate) { + val endpoints = convertUpdate(status) + viewModelScope.launch { + if (!currentState.isSubscribed) return@launch + updateState { copy(endpoints = endpoints) } + } + } + + override fun onError(message: String) { + viewModelScope.launch { + if (!currentState.isSubscribed) return@launch + updateState { copy(endpoints = emptyList(), isSubscribed = false) } + grpcJob = null + sendErrorMessage(message) + } + } + }) + } catch (_: Exception) { + viewModelScope.launch { + updateState { copy(endpoints = emptyList(), isSubscribed = false) } + grpcJob = null + } + } + } + } + + fun cancel() { + grpcJob?.cancel() + grpcJob = null + updateState { copy(endpoints = emptyList(), isSubscribed = false) } + } + + fun endpoint(tag: String): TailscaleEndpointData? = currentState.endpoints.firstOrNull { it.endpointTag == tag } + + fun peer(endpointTag: String, peerId: String): TailscalePeerData? { + val ep = endpoint(endpointTag) ?: return null + if (ep.selfPeer?.id == peerId) return ep.selfPeer + for (group in ep.userGroups) { + val found = group.peers.firstOrNull { it.id == peerId } + if (found != null) return found + } + return null + } + + override fun onCleared() { + cancel() + super.onCleared() + } + + private fun convertUpdate(status: TailscaleStatusUpdate): List { + val endpoints = mutableListOf() + val iterator = status.endpoints() + while (iterator.hasNext()) { + endpoints.add(convertEndpoint(iterator.next())) + } + return endpoints + } + + private fun convertEndpoint( + endpoint: io.nekohasekai.libbox.TailscaleEndpointStatus, + ): TailscaleEndpointData { + val userGroups = mutableListOf() + val groupIterator = endpoint.userGroups() + while (groupIterator.hasNext()) { + userGroups.add(convertUserGroup(groupIterator.next())) + } + val self = endpoint.getSelf() + return TailscaleEndpointData( + endpointTag = endpoint.endpointTag, + backendState = endpoint.backendState, + authURL = endpoint.authURL, + networkName = endpoint.networkName, + magicDNSSuffix = endpoint.magicDNSSuffix, + selfPeer = if (self != null) convertPeer(self) else null, + userGroups = userGroups, + ) + } + + private fun convertUserGroup( + group: io.nekohasekai.libbox.TailscaleUserGroup, + ): TailscaleUserGroupData { + val peers = mutableListOf() + val peerIterator = group.peers() + while (peerIterator.hasNext()) { + peers.add(convertPeer(peerIterator.next())) + } + return TailscaleUserGroupData( + id = group.userID, + loginName = group.loginName, + displayName = group.displayName, + profilePicURL = group.profilePicURL, + peers = peers, + ) + } + + private fun convertPeer(peer: io.nekohasekai.libbox.TailscalePeer): TailscalePeerData { + val ips = mutableListOf() + val ipIterator = peer.tailscaleIPs() + while (ipIterator.hasNext()) { + ips.add(ipIterator.next()) + } + val dnsName = peer.getDNSName() + return TailscalePeerData( + id = if (dnsName.isNotEmpty()) dnsName else peer.hostName, + hostName = peer.hostName, + dnsName = dnsName, + os = peer.getOS(), + tailscaleIPs = ips, + online = peer.online, + exitNode = peer.exitNode, + exitNodeOption = peer.exitNodeOption, + active = peer.active, + rxBytes = peer.rxBytes, + txBytes = peer.txBytes, + keyExpiry = peer.keyExpiry, + ) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ToolsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ToolsScreen.kt index fc3f081..b0e0207 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ToolsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ToolsScreen.kt @@ -1,5 +1,6 @@ package io.nekohasekai.sfa.compose.screen.tools +import android.net.Uri import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column @@ -11,7 +12,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.BugReport +import androidx.compose.material.icons.outlined.Hub import androidx.compose.material.icons.outlined.Memory +import androidx.compose.material.icons.outlined.NetworkCheck import androidx.compose.material3.Badge import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -23,6 +26,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier @@ -35,10 +39,15 @@ import io.nekohasekai.sfa.R import io.nekohasekai.sfa.bg.CrashReportManager import io.nekohasekai.sfa.bg.OOMReportManager import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ToolsScreen(navController: NavController) { +fun ToolsScreen( + navController: NavController, + serviceStatus: Status = Status.Stopped, + tailscaleViewModel: TailscaleStatusViewModel, +) { OverrideTopBar { TopAppBar( title = { Text(stringResource(R.string.title_tools)) }, @@ -47,6 +56,15 @@ fun ToolsScreen(navController: NavController) { val crashUnreadCount by CrashReportManager.unreadCount.collectAsState() val oomUnreadCount by OOMReportManager.unreadCount.collectAsState() + val tailscaleState by tailscaleViewModel.uiState.collectAsState() + + LaunchedEffect(serviceStatus) { + if (serviceStatus == Status.Started) { + tailscaleViewModel.subscribe() + } else { + tailscaleViewModel.cancel() + } + } Column( modifier = Modifier @@ -55,6 +73,114 @@ fun ToolsScreen(navController: NavController) { .verticalScroll(rememberScrollState()) .padding(vertical = 8.dp), ) { + if (tailscaleState.endpoints.isNotEmpty()) { + Text( + text = stringResource(R.string.tailscale_endpoints), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + val endpoints = tailscaleState.endpoints + endpoints.forEachIndexed { index, endpoint -> + val shape = when { + endpoints.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + index == endpoints.size - 1 -> RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp) + else -> RoundedCornerShape(0.dp) + } + ListItem( + headlineContent = { + Text( + if (endpoints.size == 1) { + stringResource(R.string.tailscale) + } else { + stringResource(R.string.tailscale_with_tag, endpoint.endpointTag) + }, + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Hub, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier + .clip(shape) + .clickable { + navController.navigate("tools/tailscale/${Uri.encode(endpoint.endpointTag)}") + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + + Text( + text = stringResource(R.string.title_network), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.network_quality), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.NetworkCheck, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + .clickable { navController.navigate("tools/network_quality") }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + ListItem( + headlineContent = { + Text( + stringResource(R.string.stun_test), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.NetworkCheck, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + .clickable { navController.navigate("tools/stun_test") }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + Text( text = stringResource(R.string.title_debug), style = MaterialTheme.typography.labelLarge, 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 3d5abf9..b70bdd8 100644 --- a/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt +++ b/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt @@ -10,6 +10,7 @@ import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.LogEntry import io.nekohasekai.libbox.LogIterator import io.nekohasekai.libbox.OutboundGroup +import io.nekohasekai.libbox.OutboundGroupItemIterator import io.nekohasekai.libbox.OutboundGroupIterator import io.nekohasekai.libbox.StatusMessage import io.nekohasekai.libbox.StringIterator @@ -29,6 +30,7 @@ open class CommandClient( private val additionalHandlers = mutableListOf() private var cachedGroups: MutableList? = null + private var cachedOutbounds: List? = null fun addHandler(handler: Handler) { synchronized(additionalHandlers) { @@ -37,6 +39,9 @@ open class CommandClient( cachedGroups?.let { groups -> handler.updateGroups(groups) } + cachedOutbounds?.let { outbounds -> + handler.updateOutbounds(outbounds) + } } } } @@ -57,6 +62,7 @@ open class CommandClient( Log, ClashMode, Connections, + Outbounds, } interface Handler { @@ -74,6 +80,8 @@ open class CommandClient( fun updateGroups(newGroups: MutableList) {} + fun updateOutbounds(outbounds: List) {} + fun initializeClashMode(modeList: List, currentMode: String) {} fun updateClashMode(newMode: String) {} @@ -95,6 +103,7 @@ open class CommandClient( ConnectionType.Log -> Libbox.CommandLog ConnectionType.ClashMode -> Libbox.CommandClashMode ConnectionType.Connections -> Libbox.CommandConnections + ConnectionType.Outbounds -> Libbox.CommandOutbounds } options.addCommand(command) } @@ -142,6 +151,18 @@ open class CommandClient( getAllHandlers().forEach { it.updateGroups(groups) } } + override fun writeOutbounds(message: OutboundGroupItemIterator?) { + if (message == null) { + return + } + val outbounds = mutableListOf() + while (message.hasNext()) { + outbounds.add(message.next()) + } + cachedOutbounds = outbounds + getAllHandlers().forEach { it.updateOutbounds(outbounds) } + } + override fun setDefaultLogLevel(level: Int) { getAllHandlers().forEach { it.setDefaultLogLevel(level) } } diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index d97cf2e..7c0215f 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -23,6 +23,8 @@ اقدام شروع لغو انتخاب + بارگذاری مجدد + راه‌اندازی مجدد باز کردن جمع کردن باز کردن همه @@ -200,6 +202,8 @@ پوشه کاری تنظیمات بتا غیرفعال‌کردن هشدارهای منسوخ + اندازه حافظه پنهان + پاک‌سازی حافظه پنهان اعلان‌ها فعال‌کردن اعلان نمایش سرعت بلادرنگ در اعلان @@ -279,6 +283,22 @@ نسخه جدید موجود است: %s به‌روزرسانی خودکار دانلود و نصب خودکار به‌روزرسانی‌ها در پس‌زمینه + منبع به‌روزرسانی + GitHub + F-Droid + آینه F-Droid + انتخاب خودکار بر اساس تأخیر + در حال تست… + %d ms + ناموفق + + افزودن آینه + نام + URL + سفارشی + URL نامعتبر + افزودن + حذف نصب بی‌صدا @@ -408,6 +428,60 @@ ابزارها + شبکه + کیفیت شبکه + URL + ترتیبی + HTTP/3 + حداکثر زمان اجرا + 30s + 60s + شروع تست + لغو تست + تأخیر بیکاری + دانلود + آپلود + RPM دانلود + RPM آپلود + اطمینان بالا + اطمینان متوسط + اطمینان پایین + اتصال محدود + شما از اتصال محدود استفاده می‌کنید. این تست حجم قابل توجهی داده مصرف خواهد کرد. + ادامه + پیکربندی + نتایج + خروجی + پیش‌فرض + + + + نقاط اتصال + + وضعیت + وضعیت + شبکه + باز کردن لینک احراز هویت + نمایش QR کد لینک احراز هویت + این دستگاه + متصل + متصل نیست + آدرس‌های Tailscale + جزئیات + انقضای کلید + گره خروجی + فعال + + تست STUN + سرور + شروع تست + لغو تست + آدرس خارجی + تأخیر + نگاشت NAT + فیلتر NAT + تشخیص نوع NAT + پشتیبانی نمی‌شود توسط سرور خالی @@ -421,11 +495,14 @@ پیکربندی محلی سرویس شروع نشده است + برای اعمال تغییرات، بارگذاری مجدد سرویس لازم است + برای اعمال تغییرات، راه‌اندازی مجدد سرویس لازم است گزارش خرابی Go Crash Log JVM Crash Log + هنگام بروز خرابی گزارشی دریافت خواهید کرد. گزارش کمبود حافظه diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 15ec972..50fc5e4 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -23,6 +23,8 @@ Действие Начать Отменить выбор + Перезагрузить + Перезапустить Развернуть Свернуть Развернуть все @@ -200,6 +202,8 @@ Рабочая директория Бета-настройки Отключить предупреждения об устаревании + Размер кэша + Очистить кэш Уведомления Включить уведомления Отображать скорость в реальном времени в уведомлении @@ -279,6 +283,22 @@ Доступна новая версия: %s Автообновление Автоматически загружать и устанавливать обновления в фоне + Источник обновлений + GitHub + F-Droid + Зеркало F-Droid + Автовыбор по задержке + Тестирование… + %d мс + Ошибка + + Добавить зеркало + Имя + URL + Пользовательское + Недопустимый URL + Добавить + Удалить Тихая установка @@ -414,6 +434,60 @@ Инструменты + Сеть + Качество сети + URL + Последовательно + HTTP/3 + Макс. время + 30s + 60s + Начать тест + Остановить тест + Задержка в простое + Загрузка + Отправка + Загрузка RPM + Отправка RPM + Высокая уверенность + Средняя уверенность + Низкая уверенность + Лимитное подключение + Вы используете лимитное подключение. Этот тест потребует значительного объёма трафика. + Продолжить + Конфигурация + Результаты + Исходящий + По умолчанию + + + + Точки подключения + + Статус + Состояние + Сеть + Открыть URL авторизации + Показать QR-код авторизации + Это устройство + Подключено + Не подключено + Адреса Tailscale + Подробности + Срок действия ключа + Выходной узел + Активен + + STUN-тест + Сервер + Начать тест + Остановить тест + Внешний адрес + Задержка + NAT-отображение + NAT-фильтрация + Определение типа NAT + Не поддерживается сервером Пусто @@ -427,11 +501,14 @@ Конфигурация Локальный Служба не запущена + Для применения изменений необходимо перезагрузить сервис + Для применения изменений необходимо перезапустить сервис Отчёт о сбое Go Crash Log JVM Crash Log + Вы получите отчёт при возникновении сбоя. Отчёт о нехватке памяти diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index ff99bd0..a6ad323 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -68,7 +68,7 @@ 已启动 - 仪表项目 + 仪表项 内存 协程 上传 @@ -88,7 +88,7 @@ 搜索连接… 关闭所有连接? 全部 - 活跃 + 活动 已关闭 日期 流量 @@ -425,6 +425,60 @@ 工具 + 网络 + 网络质量 + URL + 串行 + HTTP/3 + 最大运行时间 + 30s + 60s + 开始测试 + 取消测试 + 空闲延迟 + 下载 + 上传 + 下载 RPM + 上传 RPM + 置信度高 + 置信度中 + 置信度低 + 按流量计费连接 + 您正在使用按流量计费的连接。此测试将消耗大量数据。 + 继续 + 配置 + 结果 + 出站 + 默认 + + + + 端点 + + 状态 + 状态 + 网络 + 打开认证链接 + 显示认证链接二维码 + 此设备 + 已连接 + 未连接 + Tailscale 地址 + 详情 + 密钥过期 + 出口节点 + 活跃 + + STUN 测试 + 服务器 + 开始测试 + 取消测试 + 外部地址 + 延迟 + NAT 映射 + NAT 过滤 + NAT 类型检测 + 服务器不支持 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 7ebd5a1..f3208e7 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -23,6 +23,8 @@ 操作 啟動 取消選擇 + 重新載入 + 重新啟動 展開 收合 全部展開 @@ -45,7 +47,7 @@ 預設 - 儀表板 + 儀表 設定檔 日誌 設定 @@ -66,7 +68,7 @@ 已啟動 - 儀表板項目 + 儀表項 記憶體 協程 上傳 @@ -86,7 +88,7 @@ 搜尋連線… 關閉所有連線? 全部 - 活躍 + 活動 已關閉 日期 流量 @@ -426,6 +428,60 @@ 工具 + 網路 + 網路品質 + URL + 序列 + HTTP/3 + 最大執行時間 + 30s + 60s + 開始測試 + 取消測試 + 閒置延遲 + 下載 + 上傳 + 下載 RPM + 上傳 RPM + 置信度高 + 置信度中 + 置信度低 + 按流量計費連線 + 您正在使用按流量計費的連線。此測試將消耗大量數據。 + 繼續 + 配置 + 結果 + 出站 + 默認 + + + + 端點 + + 狀態 + 狀態 + 網路 + 開啟認證連結 + 顯示認證連結 QR 碼 + 此裝置 + 已連線 + 未連線 + Tailscale 位址 + 詳情 + 金鑰到期 + 出口節點 + 活躍 + + STUN 測試 + 伺服器 + 開始測試 + 取消測試 + 外部地址 + 延遲 + NAT 映射 + NAT 過濾 + NAT 類型偵測 + 伺服器不支援 @@ -439,11 +495,14 @@ 配置 本地 服務未啟動 + 需要重新載入服務以套用變更 + 需要重新啟動服務以套用變更 當機報告 Go Crash Log JVM Crash Log + 當發生當機時,您將會收到報告。 記憶體不足報告 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 633755b..70e31fe 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -428,6 +428,71 @@ Tools + Network + Network Quality + URL + Serial + HTTP/3 + Max Runtime + 30s + 60s + Start Test + Cancel Test + Idle Latency + Download + Upload + Download RPM + Upload RPM + Confidence High + Confidence Medium + Confidence Low + Metered Connection + You\'re on a metered connection. This test will use a significant amount of data. + Continue + Configuration + Results + Outbound + Default + + + Tailscale + Tailscale: %s + Endpoints + + Status + State + Network + MagicDNS + Open Auth URL + Show Auth URL QR Code + This Device + Connected + Not Connected + Tailscale Addresses + Details + Key Expiry + OS + Exit Node + Active + IPv4 + IPv6 + Ping + Start + Stop + Direct connection + DERP-relayed connection + + + STUN Test + Server + Start Test + Cancel Test + External Address + Latency + NAT Mapping + NAT Filtering + NAT Type Detection + Not supported by server Empty