tools: Tailscale status

This commit is contained in:
世界
2026-04-10 11:35:56 +08:00
parent 696976c4a0
commit 76772e20f8
19 changed files with 3179 additions and 7 deletions

View File

@@ -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.GroupsCard
import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel
import io.nekohasekai.sfa.compose.screen.log.LogViewModel import io.nekohasekai.sfa.compose.screen.log.LogViewModel
import io.nekohasekai.sfa.compose.screen.tools.TailscaleStatusViewModel
import io.nekohasekai.sfa.compose.theme.SFATheme import io.nekohasekai.sfa.compose.theme.SFATheme
import io.nekohasekai.sfa.compose.topbar.LocalTopBarController import io.nekohasekai.sfa.compose.topbar.LocalTopBarController
import io.nekohasekai.sfa.compose.topbar.TopBarController import io.nekohasekai.sfa.compose.topbar.TopBarController
@@ -869,6 +870,7 @@ class MainActivity :
logViewModel = logViewModel, logViewModel = logViewModel,
groupsViewModel = groupsViewModel, groupsViewModel = groupsViewModel,
connectionsViewModel = connectionsViewModel, connectionsViewModel = connectionsViewModel,
tailscaleStatusViewModel = tailscaleStatusViewModel,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
if (!useNavigationRail) { if (!useNavigationRail) {

View File

@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost 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.CrashReportFileContentScreen
import io.nekohasekai.sfa.compose.screen.tools.CrashReportListScreen import io.nekohasekai.sfa.compose.screen.tools.CrashReportListScreen
import io.nekohasekai.sfa.compose.screen.tools.CrashReportMetadataScreen 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.OOMReportDetailScreen
import io.nekohasekai.sfa.compose.screen.tools.OOMReportFileContentScreen import io.nekohasekai.sfa.compose.screen.tools.OOMReportFileContentScreen
import io.nekohasekai.sfa.compose.screen.tools.OOMReportListScreen import io.nekohasekai.sfa.compose.screen.tools.OOMReportListScreen
import io.nekohasekai.sfa.compose.screen.tools.OOMReportMetadataScreen 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.compose.screen.tools.ToolsScreen
import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.constant.Status
@@ -73,6 +80,7 @@ fun SFANavHost(
logViewModel: LogViewModel? = null, logViewModel: LogViewModel? = null,
groupsViewModel: GroupsViewModel? = null, groupsViewModel: GroupsViewModel? = null,
connectionsViewModel: ConnectionsViewModel? = null, connectionsViewModel: ConnectionsViewModel? = null,
tailscaleStatusViewModel: TailscaleStatusViewModel? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
NavHost( NavHost(
@@ -220,10 +228,73 @@ fun SFANavHost(
} }
composable(Screen.Tools.route) { composable(Screen.Tools.route) {
ToolsScreen(navController = navController) val tailscaleViewModel: TailscaleStatusViewModel = tailscaleStatusViewModel ?: viewModel()
ToolsScreen(navController = navController, serviceStatus = serviceStatus, tailscaleViewModel = tailscaleViewModel)
} }
// Tools subscreens with slide animations // 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( composable(
route = "tools/crash_report", route = "tools/crash_report",
enterTransition = slideInFromRight, enterTransition = slideInFromRight,

View File

@@ -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))
}
},
)
}

View File

@@ -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)
}
}
}
}
}
}

View File

@@ -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
}
}

View File

@@ -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,
)
}
}
}
}

View File

@@ -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))
}
},
)
}

View File

@@ -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)
}
}
}
}
}
}

View File

@@ -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
}

View File

@@ -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,
)
}
}
}
}

View File

@@ -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()
}
}

View File

@@ -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,
)
}
}

View File

@@ -1,5 +1,6 @@
package io.nekohasekai.sfa.compose.screen.tools package io.nekohasekai.sfa.compose.screen.tools
import android.net.Uri
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -11,7 +12,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.BugReport 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.Memory
import androidx.compose.material.icons.outlined.NetworkCheck
import androidx.compose.material3.Badge import androidx.compose.material3.Badge
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@@ -23,6 +26,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier 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.CrashReportManager
import io.nekohasekai.sfa.bg.OOMReportManager import io.nekohasekai.sfa.bg.OOMReportManager
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.constant.Status
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ToolsScreen(navController: NavController) { fun ToolsScreen(
navController: NavController,
serviceStatus: Status = Status.Stopped,
tailscaleViewModel: TailscaleStatusViewModel,
) {
OverrideTopBar { OverrideTopBar {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.title_tools)) }, title = { Text(stringResource(R.string.title_tools)) },
@@ -47,6 +56,15 @@ fun ToolsScreen(navController: NavController) {
val crashUnreadCount by CrashReportManager.unreadCount.collectAsState() val crashUnreadCount by CrashReportManager.unreadCount.collectAsState()
val oomUnreadCount by OOMReportManager.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( Column(
modifier = Modifier modifier = Modifier
@@ -55,6 +73,114 @@ fun ToolsScreen(navController: NavController) {
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(vertical = 8.dp), .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(
text = stringResource(R.string.title_debug), text = stringResource(R.string.title_debug),
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,

View File

@@ -10,6 +10,7 @@ import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.LogEntry import io.nekohasekai.libbox.LogEntry
import io.nekohasekai.libbox.LogIterator import io.nekohasekai.libbox.LogIterator
import io.nekohasekai.libbox.OutboundGroup import io.nekohasekai.libbox.OutboundGroup
import io.nekohasekai.libbox.OutboundGroupItemIterator
import io.nekohasekai.libbox.OutboundGroupIterator import io.nekohasekai.libbox.OutboundGroupIterator
import io.nekohasekai.libbox.StatusMessage import io.nekohasekai.libbox.StatusMessage
import io.nekohasekai.libbox.StringIterator import io.nekohasekai.libbox.StringIterator
@@ -29,6 +30,7 @@ open class CommandClient(
private val additionalHandlers = mutableListOf<Handler>() private val additionalHandlers = mutableListOf<Handler>()
private var cachedGroups: MutableList<OutboundGroup>? = null private var cachedGroups: MutableList<OutboundGroup>? = null
private var cachedOutbounds: List<io.nekohasekai.libbox.OutboundGroupItem>? = null
fun addHandler(handler: Handler) { fun addHandler(handler: Handler) {
synchronized(additionalHandlers) { synchronized(additionalHandlers) {
@@ -37,6 +39,9 @@ open class CommandClient(
cachedGroups?.let { groups -> cachedGroups?.let { groups ->
handler.updateGroups(groups) handler.updateGroups(groups)
} }
cachedOutbounds?.let { outbounds ->
handler.updateOutbounds(outbounds)
}
} }
} }
} }
@@ -57,6 +62,7 @@ open class CommandClient(
Log, Log,
ClashMode, ClashMode,
Connections, Connections,
Outbounds,
} }
interface Handler { interface Handler {
@@ -74,6 +80,8 @@ open class CommandClient(
fun updateGroups(newGroups: MutableList<OutboundGroup>) {} fun updateGroups(newGroups: MutableList<OutboundGroup>) {}
fun updateOutbounds(outbounds: List<io.nekohasekai.libbox.OutboundGroupItem>) {}
fun initializeClashMode(modeList: List<String>, currentMode: String) {} fun initializeClashMode(modeList: List<String>, currentMode: String) {}
fun updateClashMode(newMode: String) {} fun updateClashMode(newMode: String) {}
@@ -95,6 +103,7 @@ open class CommandClient(
ConnectionType.Log -> Libbox.CommandLog ConnectionType.Log -> Libbox.CommandLog
ConnectionType.ClashMode -> Libbox.CommandClashMode ConnectionType.ClashMode -> Libbox.CommandClashMode
ConnectionType.Connections -> Libbox.CommandConnections ConnectionType.Connections -> Libbox.CommandConnections
ConnectionType.Outbounds -> Libbox.CommandOutbounds
} }
options.addCommand(command) options.addCommand(command)
} }
@@ -142,6 +151,18 @@ open class CommandClient(
getAllHandlers().forEach { it.updateGroups(groups) } 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) { override fun setDefaultLogLevel(level: Int) {
getAllHandlers().forEach { it.setDefaultLogLevel(level) } getAllHandlers().forEach { it.setDefaultLogLevel(level) }
} }

View File

@@ -23,6 +23,8 @@
<string name="action">اقدام</string> <string name="action">اقدام</string>
<string name="action_start">شروع</string> <string name="action_start">شروع</string>
<string name="action_deselect">لغو انتخاب</string> <string name="action_deselect">لغو انتخاب</string>
<string name="action_reload">بارگذاری مجدد</string>
<string name="action_restart">راه‌اندازی مجدد</string>
<string name="expand">باز کردن</string> <string name="expand">باز کردن</string>
<string name="collapse">جمع کردن</string> <string name="collapse">جمع کردن</string>
<string name="expand_all">باز کردن همه</string> <string name="expand_all">باز کردن همه</string>
@@ -200,6 +202,8 @@
<string name="working_directory">پوشه کاری</string> <string name="working_directory">پوشه کاری</string>
<string name="beta_settings">تنظیمات بتا</string> <string name="beta_settings">تنظیمات بتا</string>
<string name="disable_deprecated_warnings">غیرفعال‌کردن هشدارهای منسوخ</string> <string name="disable_deprecated_warnings">غیرفعال‌کردن هشدارهای منسوخ</string>
<string name="cache_size">اندازه حافظه پنهان</string>
<string name="clear_cache">پاک‌سازی حافظه پنهان</string>
<string name="notification_settings">اعلان‌ها</string> <string name="notification_settings">اعلان‌ها</string>
<string name="enable_notification">فعال‌کردن اعلان</string> <string name="enable_notification">فعال‌کردن اعلان</string>
<string name="dynamic_notification">نمایش سرعت بلادرنگ در اعلان</string> <string name="dynamic_notification">نمایش سرعت بلادرنگ در اعلان</string>
@@ -279,6 +283,22 @@
<string name="new_version_available">نسخه جدید موجود است: %s</string> <string name="new_version_available">نسخه جدید موجود است: %s</string>
<string name="auto_update">به‌روزرسانی خودکار</string> <string name="auto_update">به‌روزرسانی خودکار</string>
<string name="auto_update_description">دانلود و نصب خودکار به‌روزرسانی‌ها در پس‌زمینه</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 --> <!-- Silent Install -->
<string name="silent_install">نصب بی‌صدا</string> <string name="silent_install">نصب بی‌صدا</string>
@@ -408,6 +428,60 @@
<!-- Tools --> <!-- Tools -->
<string name="title_tools">ابزارها</string> <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 --> <!-- Shared Report -->
<string name="report_empty">خالی</string> <string name="report_empty">خالی</string>
@@ -421,11 +495,14 @@
<string name="report_configuration">پیکربندی</string> <string name="report_configuration">پیکربندی</string>
<string name="report_origin_local">محلی</string> <string name="report_origin_local">محلی</string>
<string name="service_not_started">سرویس شروع نشده است</string> <string name="service_not_started">سرویس شروع نشده است</string>
<string name="service_reload_required">برای اعمال تغییرات، بارگذاری مجدد سرویس لازم است</string>
<string name="service_restart_required">برای اعمال تغییرات، راه‌اندازی مجدد سرویس لازم است</string>
<!-- Crash Report --> <!-- Crash Report -->
<string name="crash_report">گزارش خرابی</string> <string name="crash_report">گزارش خرابی</string>
<string name="crash_report_go_log">Go Crash Log</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_jvm_log">JVM Crash Log</string>
<string name="crash_report_description">هنگام بروز خرابی گزارشی دریافت خواهید کرد.</string>
<!-- OOM Report --> <!-- OOM Report -->
<string name="oom_report">گزارش کمبود حافظه</string> <string name="oom_report">گزارش کمبود حافظه</string>

View File

@@ -23,6 +23,8 @@
<string name="action">Действие</string> <string name="action">Действие</string>
<string name="action_start">Начать</string> <string name="action_start">Начать</string>
<string name="action_deselect">Отменить выбор</string> <string name="action_deselect">Отменить выбор</string>
<string name="action_reload">Перезагрузить</string>
<string name="action_restart">Перезапустить</string>
<string name="expand">Развернуть</string> <string name="expand">Развернуть</string>
<string name="collapse">Свернуть</string> <string name="collapse">Свернуть</string>
<string name="expand_all">Развернуть все</string> <string name="expand_all">Развернуть все</string>
@@ -200,6 +202,8 @@
<string name="working_directory">Рабочая директория</string> <string name="working_directory">Рабочая директория</string>
<string name="beta_settings">Бета-настройки</string> <string name="beta_settings">Бета-настройки</string>
<string name="disable_deprecated_warnings">Отключить предупреждения об устаревании</string> <string name="disable_deprecated_warnings">Отключить предупреждения об устаревании</string>
<string name="cache_size">Размер кэша</string>
<string name="clear_cache">Очистить кэш</string>
<string name="notification_settings">Уведомления</string> <string name="notification_settings">Уведомления</string>
<string name="enable_notification">Включить уведомления</string> <string name="enable_notification">Включить уведомления</string>
<string name="dynamic_notification">Отображать скорость в реальном времени в уведомлении</string> <string name="dynamic_notification">Отображать скорость в реальном времени в уведомлении</string>
@@ -279,6 +283,22 @@
<string name="new_version_available">Доступна новая версия: %s</string> <string name="new_version_available">Доступна новая версия: %s</string>
<string name="auto_update">Автообновление</string> <string name="auto_update">Автообновление</string>
<string name="auto_update_description">Автоматически загружать и устанавливать обновления в фоне</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 --> <!-- Silent Install -->
<string name="silent_install">Тихая установка</string> <string name="silent_install">Тихая установка</string>
@@ -414,6 +434,60 @@
<!-- Tools --> <!-- Tools -->
<string name="title_tools">Инструменты</string> <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 --> <!-- Shared Report -->
<string name="report_empty">Пусто</string> <string name="report_empty">Пусто</string>
@@ -427,11 +501,14 @@
<string name="report_configuration">Конфигурация</string> <string name="report_configuration">Конфигурация</string>
<string name="report_origin_local">Локальный</string> <string name="report_origin_local">Локальный</string>
<string name="service_not_started">Служба не запущена</string> <string name="service_not_started">Служба не запущена</string>
<string name="service_reload_required">Для применения изменений необходимо перезагрузить сервис</string>
<string name="service_restart_required">Для применения изменений необходимо перезапустить сервис</string>
<!-- Crash Report --> <!-- Crash Report -->
<string name="crash_report">Отчёт о сбое</string> <string name="crash_report">Отчёт о сбое</string>
<string name="crash_report_go_log">Go Crash Log</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_jvm_log">JVM Crash Log</string>
<string name="crash_report_description">Вы получите отчёт при возникновении сбоя.</string>
<!-- OOM Report --> <!-- OOM Report -->
<string name="oom_report">Отчёт о нехватке памяти</string> <string name="oom_report">Отчёт о нехватке памяти</string>

View File

@@ -68,7 +68,7 @@
<string name="status_started">已启动</string> <string name="status_started">已启动</string>
<!-- Dashboard --> <!-- Dashboard -->
<string name="dashboard_items">仪表项</string> <string name="dashboard_items">仪表项</string>
<string name="memory">内存</string> <string name="memory">内存</string>
<string name="goroutines">协程</string> <string name="goroutines">协程</string>
<string name="upload">上传</string> <string name="upload">上传</string>
@@ -88,7 +88,7 @@
<string name="search_connections">搜索连接…</string> <string name="search_connections">搜索连接…</string>
<string name="close_connections_confirm">关闭所有连接?</string> <string name="close_connections_confirm">关闭所有连接?</string>
<string name="connection_state_all">全部</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_state_closed">已关闭</string>
<string name="connection_sort_date">日期</string> <string name="connection_sort_date">日期</string>
<string name="connection_sort_traffic">流量</string> <string name="connection_sort_traffic">流量</string>
@@ -425,6 +425,60 @@
<!-- Tools --> <!-- Tools -->
<string name="title_tools">工具</string> <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 --> <!-- Shared Report -->
<string name="report_empty"></string> <string name="report_empty"></string>

View File

@@ -23,6 +23,8 @@
<string name="action">操作</string> <string name="action">操作</string>
<string name="action_start">啟動</string> <string name="action_start">啟動</string>
<string name="action_deselect">取消選擇</string> <string name="action_deselect">取消選擇</string>
<string name="action_reload">重新載入</string>
<string name="action_restart">重新啟動</string>
<string name="expand">展開</string> <string name="expand">展開</string>
<string name="collapse">收合</string> <string name="collapse">收合</string>
<string name="expand_all">全部展開</string> <string name="expand_all">全部展開</string>
@@ -45,7 +47,7 @@
<string name="default_text">預設</string> <string name="default_text">預設</string>
<!-- Navigation Titles --> <!-- Navigation Titles -->
<string name="title_dashboard">儀表</string> <string name="title_dashboard">儀表</string>
<string name="title_configuration">設定檔</string> <string name="title_configuration">設定檔</string>
<string name="title_log">日誌</string> <string name="title_log">日誌</string>
<string name="title_settings">設定</string> <string name="title_settings">設定</string>
@@ -66,7 +68,7 @@
<string name="status_started">已啟動</string> <string name="status_started">已啟動</string>
<!-- Dashboard --> <!-- Dashboard -->
<string name="dashboard_items">儀表板項目</string> <string name="dashboard_items">儀表</string>
<string name="memory">記憶體</string> <string name="memory">記憶體</string>
<string name="goroutines">協程</string> <string name="goroutines">協程</string>
<string name="upload">上傳</string> <string name="upload">上傳</string>
@@ -86,7 +88,7 @@
<string name="search_connections">搜尋連線…</string> <string name="search_connections">搜尋連線…</string>
<string name="close_connections_confirm">關閉所有連線?</string> <string name="close_connections_confirm">關閉所有連線?</string>
<string name="connection_state_all">全部</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_state_closed">已關閉</string>
<string name="connection_sort_date">日期</string> <string name="connection_sort_date">日期</string>
<string name="connection_sort_traffic">流量</string> <string name="connection_sort_traffic">流量</string>
@@ -426,6 +428,60 @@
<!-- Tools --> <!-- Tools -->
<string name="title_tools">工具</string> <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 --> <!-- Shared Report -->
<string name="report_empty"></string> <string name="report_empty"></string>
@@ -439,11 +495,14 @@
<string name="report_configuration">配置</string> <string name="report_configuration">配置</string>
<string name="report_origin_local">本地</string> <string name="report_origin_local">本地</string>
<string name="service_not_started">服務未啟動</string> <string name="service_not_started">服務未啟動</string>
<string name="service_reload_required">需要重新載入服務以套用變更</string>
<string name="service_restart_required">需要重新啟動服務以套用變更</string>
<!-- Crash Report --> <!-- Crash Report -->
<string name="crash_report">當機報告</string> <string name="crash_report">當機報告</string>
<string name="crash_report_go_log">Go Crash Log</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_jvm_log">JVM Crash Log</string>
<string name="crash_report_description">當發生當機時,您將會收到報告。</string>
<!-- OOM Report --> <!-- OOM Report -->
<string name="oom_report">記憶體不足報告</string> <string name="oom_report">記憶體不足報告</string>

View File

@@ -428,6 +428,71 @@
<!-- Tools --> <!-- Tools -->
<string name="title_tools">Tools</string> <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 --> <!-- Shared Report -->
<string name="report_empty">Empty</string> <string name="report_empty">Empty</string>