tools: Tailscale status
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String, Color> = 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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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<NetworkQualityState, Nothing>() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<List<GroupItem>>(emptyList())
|
||||
val outbounds: StateFlow<List<GroupItem>> = _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<io.nekohasekai.libbox.OutboundGroupItem>) {
|
||||
_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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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<STUNTestState, Nothing>() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<String?>(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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Float> = emptyList(),
|
||||
)
|
||||
|
||||
class TailscalePingViewModel : BaseViewModel<TailscalePingState, Nothing>() {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
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<TailscalePeerData>,
|
||||
)
|
||||
|
||||
data class TailscaleEndpointData(
|
||||
val endpointTag: String,
|
||||
val backendState: String,
|
||||
val authURL: String,
|
||||
val networkName: String,
|
||||
val magicDNSSuffix: String,
|
||||
val selfPeer: TailscalePeerData?,
|
||||
val userGroups: List<TailscaleUserGroupData>,
|
||||
)
|
||||
|
||||
data class TailscaleStatusState(
|
||||
val endpoints: List<TailscaleEndpointData> = emptyList(),
|
||||
val isSubscribed: Boolean = false,
|
||||
)
|
||||
|
||||
class TailscaleStatusViewModel : BaseViewModel<TailscaleStatusState, Nothing>() {
|
||||
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<TailscaleEndpointData> {
|
||||
val endpoints = mutableListOf<TailscaleEndpointData>()
|
||||
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<TailscaleUserGroupData>()
|
||||
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<TailscalePeerData>()
|
||||
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<String>()
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Handler>()
|
||||
private var cachedGroups: MutableList<OutboundGroup>? = null
|
||||
private var cachedOutbounds: List<io.nekohasekai.libbox.OutboundGroupItem>? = 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<OutboundGroup>) {}
|
||||
|
||||
fun updateOutbounds(outbounds: List<io.nekohasekai.libbox.OutboundGroupItem>) {}
|
||||
|
||||
fun initializeClashMode(modeList: List<String>, 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<io.nekohasekai.libbox.OutboundGroupItem>()
|
||||
while (message.hasNext()) {
|
||||
outbounds.add(message.next())
|
||||
}
|
||||
cachedOutbounds = outbounds
|
||||
getAllHandlers().forEach { it.updateOutbounds(outbounds) }
|
||||
}
|
||||
|
||||
override fun setDefaultLogLevel(level: Int) {
|
||||
getAllHandlers().forEach { it.setDefaultLogLevel(level) }
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
<string name="action">اقدام</string>
|
||||
<string name="action_start">شروع</string>
|
||||
<string name="action_deselect">لغو انتخاب</string>
|
||||
<string name="action_reload">بارگذاری مجدد</string>
|
||||
<string name="action_restart">راهاندازی مجدد</string>
|
||||
<string name="expand">باز کردن</string>
|
||||
<string name="collapse">جمع کردن</string>
|
||||
<string name="expand_all">باز کردن همه</string>
|
||||
@@ -200,6 +202,8 @@
|
||||
<string name="working_directory">پوشه کاری</string>
|
||||
<string name="beta_settings">تنظیمات بتا</string>
|
||||
<string name="disable_deprecated_warnings">غیرفعالکردن هشدارهای منسوخ</string>
|
||||
<string name="cache_size">اندازه حافظه پنهان</string>
|
||||
<string name="clear_cache">پاکسازی حافظه پنهان</string>
|
||||
<string name="notification_settings">اعلانها</string>
|
||||
<string name="enable_notification">فعالکردن اعلان</string>
|
||||
<string name="dynamic_notification">نمایش سرعت بلادرنگ در اعلان</string>
|
||||
@@ -279,6 +283,22 @@
|
||||
<string name="new_version_available">نسخه جدید موجود است: %s</string>
|
||||
<string name="auto_update">بهروزرسانی خودکار</string>
|
||||
<string name="auto_update_description">دانلود و نصب خودکار بهروزرسانیها در پسزمینه</string>
|
||||
<string name="update_source">منبع بهروزرسانی</string>
|
||||
<string name="update_source_github">GitHub</string>
|
||||
<string name="update_source_fdroid">F-Droid</string>
|
||||
<string name="fdroid_mirror">آینه F-Droid</string>
|
||||
<string name="fdroid_mirror_test_all">انتخاب خودکار بر اساس تأخیر</string>
|
||||
<string name="fdroid_mirror_testing">در حال تست…</string>
|
||||
<string name="fdroid_mirror_latency">%d ms</string>
|
||||
<string name="fdroid_mirror_failed">ناموفق</string>
|
||||
<string name="fdroid_mirror_untested">—</string>
|
||||
<string name="fdroid_mirror_add">افزودن آینه</string>
|
||||
<string name="fdroid_mirror_name_hint">نام</string>
|
||||
<string name="fdroid_mirror_url_hint">URL</string>
|
||||
<string name="fdroid_mirror_custom">سفارشی</string>
|
||||
<string name="fdroid_mirror_invalid_url">URL نامعتبر</string>
|
||||
<string name="fdroid_mirror_add_action">افزودن</string>
|
||||
<string name="fdroid_mirror_delete">حذف</string>
|
||||
|
||||
<!-- Silent Install -->
|
||||
<string name="silent_install">نصب بیصدا</string>
|
||||
@@ -408,6 +428,60 @@
|
||||
|
||||
<!-- Tools -->
|
||||
<string name="title_tools">ابزارها</string>
|
||||
<string name="title_network">شبکه</string>
|
||||
<string name="network_quality">کیفیت شبکه</string>
|
||||
<string name="network_quality_url">URL</string>
|
||||
<string name="network_quality_serial">ترتیبی</string>
|
||||
<string name="network_quality_http3">HTTP/3</string>
|
||||
<string name="network_quality_max_runtime">حداکثر زمان اجرا</string>
|
||||
<string name="network_quality_max_runtime_30s">30s</string>
|
||||
<string name="network_quality_max_runtime_60s">60s</string>
|
||||
<string name="network_quality_start">شروع تست</string>
|
||||
<string name="network_quality_cancel">لغو تست</string>
|
||||
<string name="network_quality_idle_latency">تأخیر بیکاری</string>
|
||||
<string name="network_quality_download">دانلود</string>
|
||||
<string name="network_quality_upload">آپلود</string>
|
||||
<string name="network_quality_download_rpm">RPM دانلود</string>
|
||||
<string name="network_quality_upload_rpm">RPM آپلود</string>
|
||||
<string name="network_quality_confidence_high">اطمینان بالا</string>
|
||||
<string name="network_quality_confidence_medium">اطمینان متوسط</string>
|
||||
<string name="network_quality_confidence_low">اطمینان پایین</string>
|
||||
<string name="network_quality_metered_title">اتصال محدود</string>
|
||||
<string name="network_quality_metered_message">شما از اتصال محدود استفاده میکنید. این تست حجم قابل توجهی داده مصرف خواهد کرد.</string>
|
||||
<string name="network_quality_metered_continue">ادامه</string>
|
||||
<string name="tool_configuration">پیکربندی</string>
|
||||
<string name="tool_results">نتایج</string>
|
||||
<string name="tool_outbound">خروجی</string>
|
||||
<string name="tool_default_outbound">پیشفرض</string>
|
||||
|
||||
<!-- STUN Test -->
|
||||
<!-- Tailscale -->
|
||||
<string name="tailscale_endpoints">نقاط اتصال</string>
|
||||
|
||||
<string name="tailscale_status">وضعیت</string>
|
||||
<string name="tailscale_state">وضعیت</string>
|
||||
<string name="tailscale_network">شبکه</string>
|
||||
<string name="tailscale_open_auth_url">باز کردن لینک احراز هویت</string>
|
||||
<string name="tailscale_open_auth_url_qr_code">نمایش QR کد لینک احراز هویت</string>
|
||||
<string name="tailscale_this_device">این دستگاه</string>
|
||||
<string name="tailscale_connected">متصل</string>
|
||||
<string name="tailscale_not_connected">متصل نیست</string>
|
||||
<string name="tailscale_addresses">آدرسهای Tailscale</string>
|
||||
<string name="tailscale_details">جزئیات</string>
|
||||
<string name="tailscale_key_expiry">انقضای کلید</string>
|
||||
<string name="tailscale_exit_node">گره خروجی</string>
|
||||
<string name="tailscale_active">فعال</string>
|
||||
|
||||
<string name="stun_test">تست STUN</string>
|
||||
<string name="stun_server">سرور</string>
|
||||
<string name="stun_start">شروع تست</string>
|
||||
<string name="stun_cancel">لغو تست</string>
|
||||
<string name="stun_external_address">آدرس خارجی</string>
|
||||
<string name="stun_latency">تأخیر</string>
|
||||
<string name="stun_nat_mapping">نگاشت NAT</string>
|
||||
<string name="stun_nat_filtering">فیلتر NAT</string>
|
||||
<string name="stun_nat_type_detection">تشخیص نوع NAT</string>
|
||||
<string name="stun_nat_not_supported">پشتیبانی نمیشود توسط سرور</string>
|
||||
|
||||
<!-- Shared Report -->
|
||||
<string name="report_empty">خالی</string>
|
||||
@@ -421,11 +495,14 @@
|
||||
<string name="report_configuration">پیکربندی</string>
|
||||
<string name="report_origin_local">محلی</string>
|
||||
<string name="service_not_started">سرویس شروع نشده است</string>
|
||||
<string name="service_reload_required">برای اعمال تغییرات، بارگذاری مجدد سرویس لازم است</string>
|
||||
<string name="service_restart_required">برای اعمال تغییرات، راهاندازی مجدد سرویس لازم است</string>
|
||||
|
||||
<!-- Crash Report -->
|
||||
<string name="crash_report">گزارش خرابی</string>
|
||||
<string name="crash_report_go_log">Go Crash Log</string>
|
||||
<string name="crash_report_jvm_log">JVM Crash Log</string>
|
||||
<string name="crash_report_description">هنگام بروز خرابی گزارشی دریافت خواهید کرد.</string>
|
||||
|
||||
<!-- OOM Report -->
|
||||
<string name="oom_report">گزارش کمبود حافظه</string>
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
<string name="action">Действие</string>
|
||||
<string name="action_start">Начать</string>
|
||||
<string name="action_deselect">Отменить выбор</string>
|
||||
<string name="action_reload">Перезагрузить</string>
|
||||
<string name="action_restart">Перезапустить</string>
|
||||
<string name="expand">Развернуть</string>
|
||||
<string name="collapse">Свернуть</string>
|
||||
<string name="expand_all">Развернуть все</string>
|
||||
@@ -200,6 +202,8 @@
|
||||
<string name="working_directory">Рабочая директория</string>
|
||||
<string name="beta_settings">Бета-настройки</string>
|
||||
<string name="disable_deprecated_warnings">Отключить предупреждения об устаревании</string>
|
||||
<string name="cache_size">Размер кэша</string>
|
||||
<string name="clear_cache">Очистить кэш</string>
|
||||
<string name="notification_settings">Уведомления</string>
|
||||
<string name="enable_notification">Включить уведомления</string>
|
||||
<string name="dynamic_notification">Отображать скорость в реальном времени в уведомлении</string>
|
||||
@@ -279,6 +283,22 @@
|
||||
<string name="new_version_available">Доступна новая версия: %s</string>
|
||||
<string name="auto_update">Автообновление</string>
|
||||
<string name="auto_update_description">Автоматически загружать и устанавливать обновления в фоне</string>
|
||||
<string name="update_source">Источник обновлений</string>
|
||||
<string name="update_source_github">GitHub</string>
|
||||
<string name="update_source_fdroid">F-Droid</string>
|
||||
<string name="fdroid_mirror">Зеркало F-Droid</string>
|
||||
<string name="fdroid_mirror_test_all">Автовыбор по задержке</string>
|
||||
<string name="fdroid_mirror_testing">Тестирование…</string>
|
||||
<string name="fdroid_mirror_latency">%d мс</string>
|
||||
<string name="fdroid_mirror_failed">Ошибка</string>
|
||||
<string name="fdroid_mirror_untested">—</string>
|
||||
<string name="fdroid_mirror_add">Добавить зеркало</string>
|
||||
<string name="fdroid_mirror_name_hint">Имя</string>
|
||||
<string name="fdroid_mirror_url_hint">URL</string>
|
||||
<string name="fdroid_mirror_custom">Пользовательское</string>
|
||||
<string name="fdroid_mirror_invalid_url">Недопустимый URL</string>
|
||||
<string name="fdroid_mirror_add_action">Добавить</string>
|
||||
<string name="fdroid_mirror_delete">Удалить</string>
|
||||
|
||||
<!-- Silent Install -->
|
||||
<string name="silent_install">Тихая установка</string>
|
||||
@@ -414,6 +434,60 @@
|
||||
|
||||
<!-- Tools -->
|
||||
<string name="title_tools">Инструменты</string>
|
||||
<string name="title_network">Сеть</string>
|
||||
<string name="network_quality">Качество сети</string>
|
||||
<string name="network_quality_url">URL</string>
|
||||
<string name="network_quality_serial">Последовательно</string>
|
||||
<string name="network_quality_http3">HTTP/3</string>
|
||||
<string name="network_quality_max_runtime">Макс. время</string>
|
||||
<string name="network_quality_max_runtime_30s">30s</string>
|
||||
<string name="network_quality_max_runtime_60s">60s</string>
|
||||
<string name="network_quality_start">Начать тест</string>
|
||||
<string name="network_quality_cancel">Остановить тест</string>
|
||||
<string name="network_quality_idle_latency">Задержка в простое</string>
|
||||
<string name="network_quality_download">Загрузка</string>
|
||||
<string name="network_quality_upload">Отправка</string>
|
||||
<string name="network_quality_download_rpm">Загрузка RPM</string>
|
||||
<string name="network_quality_upload_rpm">Отправка RPM</string>
|
||||
<string name="network_quality_confidence_high">Высокая уверенность</string>
|
||||
<string name="network_quality_confidence_medium">Средняя уверенность</string>
|
||||
<string name="network_quality_confidence_low">Низкая уверенность</string>
|
||||
<string name="network_quality_metered_title">Лимитное подключение</string>
|
||||
<string name="network_quality_metered_message">Вы используете лимитное подключение. Этот тест потребует значительного объёма трафика.</string>
|
||||
<string name="network_quality_metered_continue">Продолжить</string>
|
||||
<string name="tool_configuration">Конфигурация</string>
|
||||
<string name="tool_results">Результаты</string>
|
||||
<string name="tool_outbound">Исходящий</string>
|
||||
<string name="tool_default_outbound">По умолчанию</string>
|
||||
|
||||
<!-- STUN Test -->
|
||||
<!-- Tailscale -->
|
||||
<string name="tailscale_endpoints">Точки подключения</string>
|
||||
|
||||
<string name="tailscale_status">Статус</string>
|
||||
<string name="tailscale_state">Состояние</string>
|
||||
<string name="tailscale_network">Сеть</string>
|
||||
<string name="tailscale_open_auth_url">Открыть URL авторизации</string>
|
||||
<string name="tailscale_open_auth_url_qr_code">Показать QR-код авторизации</string>
|
||||
<string name="tailscale_this_device">Это устройство</string>
|
||||
<string name="tailscale_connected">Подключено</string>
|
||||
<string name="tailscale_not_connected">Не подключено</string>
|
||||
<string name="tailscale_addresses">Адреса Tailscale</string>
|
||||
<string name="tailscale_details">Подробности</string>
|
||||
<string name="tailscale_key_expiry">Срок действия ключа</string>
|
||||
<string name="tailscale_exit_node">Выходной узел</string>
|
||||
<string name="tailscale_active">Активен</string>
|
||||
|
||||
<string name="stun_test">STUN-тест</string>
|
||||
<string name="stun_server">Сервер</string>
|
||||
<string name="stun_start">Начать тест</string>
|
||||
<string name="stun_cancel">Остановить тест</string>
|
||||
<string name="stun_external_address">Внешний адрес</string>
|
||||
<string name="stun_latency">Задержка</string>
|
||||
<string name="stun_nat_mapping">NAT-отображение</string>
|
||||
<string name="stun_nat_filtering">NAT-фильтрация</string>
|
||||
<string name="stun_nat_type_detection">Определение типа NAT</string>
|
||||
<string name="stun_nat_not_supported">Не поддерживается сервером</string>
|
||||
|
||||
<!-- Shared Report -->
|
||||
<string name="report_empty">Пусто</string>
|
||||
@@ -427,11 +501,14 @@
|
||||
<string name="report_configuration">Конфигурация</string>
|
||||
<string name="report_origin_local">Локальный</string>
|
||||
<string name="service_not_started">Служба не запущена</string>
|
||||
<string name="service_reload_required">Для применения изменений необходимо перезагрузить сервис</string>
|
||||
<string name="service_restart_required">Для применения изменений необходимо перезапустить сервис</string>
|
||||
|
||||
<!-- Crash Report -->
|
||||
<string name="crash_report">Отчёт о сбое</string>
|
||||
<string name="crash_report_go_log">Go Crash Log</string>
|
||||
<string name="crash_report_jvm_log">JVM Crash Log</string>
|
||||
<string name="crash_report_description">Вы получите отчёт при возникновении сбоя.</string>
|
||||
|
||||
<!-- OOM Report -->
|
||||
<string name="oom_report">Отчёт о нехватке памяти</string>
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
<string name="status_started">已启动</string>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<string name="dashboard_items">仪表项目</string>
|
||||
<string name="dashboard_items">仪表项</string>
|
||||
<string name="memory">内存</string>
|
||||
<string name="goroutines">协程</string>
|
||||
<string name="upload">上传</string>
|
||||
@@ -88,7 +88,7 @@
|
||||
<string name="search_connections">搜索连接…</string>
|
||||
<string name="close_connections_confirm">关闭所有连接?</string>
|
||||
<string name="connection_state_all">全部</string>
|
||||
<string name="connection_state_active">活跃</string>
|
||||
<string name="connection_state_active">活动</string>
|
||||
<string name="connection_state_closed">已关闭</string>
|
||||
<string name="connection_sort_date">日期</string>
|
||||
<string name="connection_sort_traffic">流量</string>
|
||||
@@ -425,6 +425,60 @@
|
||||
|
||||
<!-- Tools -->
|
||||
<string name="title_tools">工具</string>
|
||||
<string name="title_network">网络</string>
|
||||
<string name="network_quality">网络质量</string>
|
||||
<string name="network_quality_url">URL</string>
|
||||
<string name="network_quality_serial">串行</string>
|
||||
<string name="network_quality_http3">HTTP/3</string>
|
||||
<string name="network_quality_max_runtime">最大运行时间</string>
|
||||
<string name="network_quality_max_runtime_30s">30s</string>
|
||||
<string name="network_quality_max_runtime_60s">60s</string>
|
||||
<string name="network_quality_start">开始测试</string>
|
||||
<string name="network_quality_cancel">取消测试</string>
|
||||
<string name="network_quality_idle_latency">空闲延迟</string>
|
||||
<string name="network_quality_download">下载</string>
|
||||
<string name="network_quality_upload">上传</string>
|
||||
<string name="network_quality_download_rpm">下载 RPM</string>
|
||||
<string name="network_quality_upload_rpm">上传 RPM</string>
|
||||
<string name="network_quality_confidence_high">置信度高</string>
|
||||
<string name="network_quality_confidence_medium">置信度中</string>
|
||||
<string name="network_quality_confidence_low">置信度低</string>
|
||||
<string name="network_quality_metered_title">按流量计费连接</string>
|
||||
<string name="network_quality_metered_message">您正在使用按流量计费的连接。此测试将消耗大量数据。</string>
|
||||
<string name="network_quality_metered_continue">继续</string>
|
||||
<string name="tool_configuration">配置</string>
|
||||
<string name="tool_results">结果</string>
|
||||
<string name="tool_outbound">出站</string>
|
||||
<string name="tool_default_outbound">默认</string>
|
||||
|
||||
<!-- STUN Test -->
|
||||
<!-- Tailscale -->
|
||||
<string name="tailscale_endpoints">端点</string>
|
||||
|
||||
<string name="tailscale_status">状态</string>
|
||||
<string name="tailscale_state">状态</string>
|
||||
<string name="tailscale_network">网络</string>
|
||||
<string name="tailscale_open_auth_url">打开认证链接</string>
|
||||
<string name="tailscale_open_auth_url_qr_code">显示认证链接二维码</string>
|
||||
<string name="tailscale_this_device">此设备</string>
|
||||
<string name="tailscale_connected">已连接</string>
|
||||
<string name="tailscale_not_connected">未连接</string>
|
||||
<string name="tailscale_addresses">Tailscale 地址</string>
|
||||
<string name="tailscale_details">详情</string>
|
||||
<string name="tailscale_key_expiry">密钥过期</string>
|
||||
<string name="tailscale_exit_node">出口节点</string>
|
||||
<string name="tailscale_active">活跃</string>
|
||||
|
||||
<string name="stun_test">STUN 测试</string>
|
||||
<string name="stun_server">服务器</string>
|
||||
<string name="stun_start">开始测试</string>
|
||||
<string name="stun_cancel">取消测试</string>
|
||||
<string name="stun_external_address">外部地址</string>
|
||||
<string name="stun_latency">延迟</string>
|
||||
<string name="stun_nat_mapping">NAT 映射</string>
|
||||
<string name="stun_nat_filtering">NAT 过滤</string>
|
||||
<string name="stun_nat_type_detection">NAT 类型检测</string>
|
||||
<string name="stun_nat_not_supported">服务器不支持</string>
|
||||
|
||||
<!-- Shared Report -->
|
||||
<string name="report_empty">空</string>
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
<string name="action">操作</string>
|
||||
<string name="action_start">啟動</string>
|
||||
<string name="action_deselect">取消選擇</string>
|
||||
<string name="action_reload">重新載入</string>
|
||||
<string name="action_restart">重新啟動</string>
|
||||
<string name="expand">展開</string>
|
||||
<string name="collapse">收合</string>
|
||||
<string name="expand_all">全部展開</string>
|
||||
@@ -45,7 +47,7 @@
|
||||
<string name="default_text">預設</string>
|
||||
|
||||
<!-- Navigation Titles -->
|
||||
<string name="title_dashboard">儀表板</string>
|
||||
<string name="title_dashboard">儀表</string>
|
||||
<string name="title_configuration">設定檔</string>
|
||||
<string name="title_log">日誌</string>
|
||||
<string name="title_settings">設定</string>
|
||||
@@ -66,7 +68,7 @@
|
||||
<string name="status_started">已啟動</string>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<string name="dashboard_items">儀表板項目</string>
|
||||
<string name="dashboard_items">儀表項</string>
|
||||
<string name="memory">記憶體</string>
|
||||
<string name="goroutines">協程</string>
|
||||
<string name="upload">上傳</string>
|
||||
@@ -86,7 +88,7 @@
|
||||
<string name="search_connections">搜尋連線…</string>
|
||||
<string name="close_connections_confirm">關閉所有連線?</string>
|
||||
<string name="connection_state_all">全部</string>
|
||||
<string name="connection_state_active">活躍</string>
|
||||
<string name="connection_state_active">活動</string>
|
||||
<string name="connection_state_closed">已關閉</string>
|
||||
<string name="connection_sort_date">日期</string>
|
||||
<string name="connection_sort_traffic">流量</string>
|
||||
@@ -426,6 +428,60 @@
|
||||
|
||||
<!-- Tools -->
|
||||
<string name="title_tools">工具</string>
|
||||
<string name="title_network">網路</string>
|
||||
<string name="network_quality">網路品質</string>
|
||||
<string name="network_quality_url">URL</string>
|
||||
<string name="network_quality_serial">序列</string>
|
||||
<string name="network_quality_http3">HTTP/3</string>
|
||||
<string name="network_quality_max_runtime">最大執行時間</string>
|
||||
<string name="network_quality_max_runtime_30s">30s</string>
|
||||
<string name="network_quality_max_runtime_60s">60s</string>
|
||||
<string name="network_quality_start">開始測試</string>
|
||||
<string name="network_quality_cancel">取消測試</string>
|
||||
<string name="network_quality_idle_latency">閒置延遲</string>
|
||||
<string name="network_quality_download">下載</string>
|
||||
<string name="network_quality_upload">上傳</string>
|
||||
<string name="network_quality_download_rpm">下載 RPM</string>
|
||||
<string name="network_quality_upload_rpm">上傳 RPM</string>
|
||||
<string name="network_quality_confidence_high">置信度高</string>
|
||||
<string name="network_quality_confidence_medium">置信度中</string>
|
||||
<string name="network_quality_confidence_low">置信度低</string>
|
||||
<string name="network_quality_metered_title">按流量計費連線</string>
|
||||
<string name="network_quality_metered_message">您正在使用按流量計費的連線。此測試將消耗大量數據。</string>
|
||||
<string name="network_quality_metered_continue">繼續</string>
|
||||
<string name="tool_configuration">配置</string>
|
||||
<string name="tool_results">結果</string>
|
||||
<string name="tool_outbound">出站</string>
|
||||
<string name="tool_default_outbound">默認</string>
|
||||
|
||||
<!-- STUN Test -->
|
||||
<!-- Tailscale -->
|
||||
<string name="tailscale_endpoints">端點</string>
|
||||
|
||||
<string name="tailscale_status">狀態</string>
|
||||
<string name="tailscale_state">狀態</string>
|
||||
<string name="tailscale_network">網路</string>
|
||||
<string name="tailscale_open_auth_url">開啟認證連結</string>
|
||||
<string name="tailscale_open_auth_url_qr_code">顯示認證連結 QR 碼</string>
|
||||
<string name="tailscale_this_device">此裝置</string>
|
||||
<string name="tailscale_connected">已連線</string>
|
||||
<string name="tailscale_not_connected">未連線</string>
|
||||
<string name="tailscale_addresses">Tailscale 位址</string>
|
||||
<string name="tailscale_details">詳情</string>
|
||||
<string name="tailscale_key_expiry">金鑰到期</string>
|
||||
<string name="tailscale_exit_node">出口節點</string>
|
||||
<string name="tailscale_active">活躍</string>
|
||||
|
||||
<string name="stun_test">STUN 測試</string>
|
||||
<string name="stun_server">伺服器</string>
|
||||
<string name="stun_start">開始測試</string>
|
||||
<string name="stun_cancel">取消測試</string>
|
||||
<string name="stun_external_address">外部地址</string>
|
||||
<string name="stun_latency">延遲</string>
|
||||
<string name="stun_nat_mapping">NAT 映射</string>
|
||||
<string name="stun_nat_filtering">NAT 過濾</string>
|
||||
<string name="stun_nat_type_detection">NAT 類型偵測</string>
|
||||
<string name="stun_nat_not_supported">伺服器不支援</string>
|
||||
|
||||
<!-- Shared Report -->
|
||||
<string name="report_empty">空</string>
|
||||
@@ -439,11 +495,14 @@
|
||||
<string name="report_configuration">配置</string>
|
||||
<string name="report_origin_local">本地</string>
|
||||
<string name="service_not_started">服務未啟動</string>
|
||||
<string name="service_reload_required">需要重新載入服務以套用變更</string>
|
||||
<string name="service_restart_required">需要重新啟動服務以套用變更</string>
|
||||
|
||||
<!-- Crash Report -->
|
||||
<string name="crash_report">當機報告</string>
|
||||
<string name="crash_report_go_log">Go Crash Log</string>
|
||||
<string name="crash_report_jvm_log">JVM Crash Log</string>
|
||||
<string name="crash_report_description">當發生當機時,您將會收到報告。</string>
|
||||
|
||||
<!-- OOM Report -->
|
||||
<string name="oom_report">記憶體不足報告</string>
|
||||
|
||||
@@ -428,6 +428,71 @@
|
||||
|
||||
<!-- Tools -->
|
||||
<string name="title_tools">Tools</string>
|
||||
<string name="title_network">Network</string>
|
||||
<string name="network_quality">Network Quality</string>
|
||||
<string name="network_quality_url">URL</string>
|
||||
<string name="network_quality_serial">Serial</string>
|
||||
<string name="network_quality_http3">HTTP/3</string>
|
||||
<string name="network_quality_max_runtime">Max Runtime</string>
|
||||
<string name="network_quality_max_runtime_30s">30s</string>
|
||||
<string name="network_quality_max_runtime_60s">60s</string>
|
||||
<string name="network_quality_start">Start Test</string>
|
||||
<string name="network_quality_cancel">Cancel Test</string>
|
||||
<string name="network_quality_idle_latency">Idle Latency</string>
|
||||
<string name="network_quality_download">Download</string>
|
||||
<string name="network_quality_upload">Upload</string>
|
||||
<string name="network_quality_download_rpm">Download RPM</string>
|
||||
<string name="network_quality_upload_rpm">Upload RPM</string>
|
||||
<string name="network_quality_confidence_high">Confidence High</string>
|
||||
<string name="network_quality_confidence_medium">Confidence Medium</string>
|
||||
<string name="network_quality_confidence_low">Confidence Low</string>
|
||||
<string name="network_quality_metered_title">Metered Connection</string>
|
||||
<string name="network_quality_metered_message">You\'re on a metered connection. This test will use a significant amount of data.</string>
|
||||
<string name="network_quality_metered_continue">Continue</string>
|
||||
<string name="tool_configuration">Configuration</string>
|
||||
<string name="tool_results">Results</string>
|
||||
<string name="tool_outbound">Outbound</string>
|
||||
<string name="tool_default_outbound">Default</string>
|
||||
|
||||
<!-- Tailscale -->
|
||||
<string name="tailscale" translatable="false">Tailscale</string>
|
||||
<string name="tailscale_with_tag" translatable="false">Tailscale: %s</string>
|
||||
<string name="tailscale_endpoints">Endpoints</string>
|
||||
|
||||
<string name="tailscale_status">Status</string>
|
||||
<string name="tailscale_state">State</string>
|
||||
<string name="tailscale_network">Network</string>
|
||||
<string name="tailscale_magic_dns" translatable="false">MagicDNS</string>
|
||||
<string name="tailscale_open_auth_url">Open Auth URL</string>
|
||||
<string name="tailscale_open_auth_url_qr_code">Show Auth URL QR Code</string>
|
||||
<string name="tailscale_this_device">This Device</string>
|
||||
<string name="tailscale_connected">Connected</string>
|
||||
<string name="tailscale_not_connected">Not Connected</string>
|
||||
<string name="tailscale_addresses">Tailscale Addresses</string>
|
||||
<string name="tailscale_details">Details</string>
|
||||
<string name="tailscale_key_expiry">Key Expiry</string>
|
||||
<string name="tailscale_os" translatable="false">OS</string>
|
||||
<string name="tailscale_exit_node">Exit Node</string>
|
||||
<string name="tailscale_active">Active</string>
|
||||
<string name="tailscale_ipv4" translatable="false">IPv4</string>
|
||||
<string name="tailscale_ipv6" translatable="false">IPv6</string>
|
||||
<string name="tailscale_ping">Ping</string>
|
||||
<string name="tailscale_ping_start">Start</string>
|
||||
<string name="tailscale_ping_stop">Stop</string>
|
||||
<string name="tailscale_ping_direct">Direct connection</string>
|
||||
<string name="tailscale_ping_derp">DERP-relayed connection</string>
|
||||
|
||||
<!-- STUN Test -->
|
||||
<string name="stun_test">STUN Test</string>
|
||||
<string name="stun_server">Server</string>
|
||||
<string name="stun_start">Start Test</string>
|
||||
<string name="stun_cancel">Cancel Test</string>
|
||||
<string name="stun_external_address">External Address</string>
|
||||
<string name="stun_latency">Latency</string>
|
||||
<string name="stun_nat_mapping">NAT Mapping</string>
|
||||
<string name="stun_nat_filtering">NAT Filtering</string>
|
||||
<string name="stun_nat_type_detection">NAT Type Detection</string>
|
||||
<string name="stun_nat_not_supported">Not supported by server</string>
|
||||
|
||||
<!-- Shared Report -->
|
||||
<string name="report_empty">Empty</string>
|
||||
|
||||
Reference in New Issue
Block a user