tools: Tailscale status

This commit is contained in:
世界
2026-07-06 21:15:23 +08:00
parent a6c5e11756
commit 7c41b4bea8
19 changed files with 3179 additions and 7 deletions
@@ -114,6 +114,7 @@ import io.nekohasekai.sfa.compose.screen.dashboard.DashboardViewModel
import io.nekohasekai.sfa.compose.screen.dashboard.GroupsCard
import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel
import io.nekohasekai.sfa.compose.screen.log.LogViewModel
import io.nekohasekai.sfa.compose.screen.tools.TailscaleStatusViewModel
import io.nekohasekai.sfa.compose.theme.SFATheme
import io.nekohasekai.sfa.compose.topbar.LocalTopBarController
import io.nekohasekai.sfa.compose.topbar.TopBarController
@@ -869,6 +870,7 @@ class MainActivity :
logViewModel = logViewModel,
groupsViewModel = groupsViewModel,
connectionsViewModel = connectionsViewModel,
tailscaleStatusViewModel = tailscaleStatusViewModel,
modifier = Modifier.fillMaxSize(),
)
if (!useNavigationRail) {
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
@@ -37,10 +38,16 @@ import io.nekohasekai.sfa.compose.screen.tools.CrashReportDetailScreen
import io.nekohasekai.sfa.compose.screen.tools.CrashReportFileContentScreen
import io.nekohasekai.sfa.compose.screen.tools.CrashReportListScreen
import io.nekohasekai.sfa.compose.screen.tools.CrashReportMetadataScreen
import io.nekohasekai.sfa.compose.screen.tools.NetworkQualityScreen
import io.nekohasekai.sfa.compose.screen.tools.OOMReportDetailScreen
import io.nekohasekai.sfa.compose.screen.tools.OOMReportFileContentScreen
import io.nekohasekai.sfa.compose.screen.tools.OOMReportListScreen
import io.nekohasekai.sfa.compose.screen.tools.OOMReportMetadataScreen
import io.nekohasekai.sfa.compose.screen.tools.OutboundPickerScreen
import io.nekohasekai.sfa.compose.screen.tools.STUNTestScreen
import io.nekohasekai.sfa.compose.screen.tools.TailscaleEndpointScreen
import io.nekohasekai.sfa.compose.screen.tools.TailscalePeerScreen
import io.nekohasekai.sfa.compose.screen.tools.TailscaleStatusViewModel
import io.nekohasekai.sfa.compose.screen.tools.ToolsScreen
import io.nekohasekai.sfa.constant.Status
@@ -73,6 +80,7 @@ fun SFANavHost(
logViewModel: LogViewModel? = null,
groupsViewModel: GroupsViewModel? = null,
connectionsViewModel: ConnectionsViewModel? = null,
tailscaleStatusViewModel: TailscaleStatusViewModel? = null,
modifier: Modifier = Modifier,
) {
NavHost(
@@ -220,10 +228,73 @@ fun SFANavHost(
}
composable(Screen.Tools.route) {
ToolsScreen(navController = navController)
val tailscaleViewModel: TailscaleStatusViewModel = tailscaleStatusViewModel ?: viewModel()
ToolsScreen(navController = navController, serviceStatus = serviceStatus, tailscaleViewModel = tailscaleViewModel)
}
// Tools subscreens with slide animations
composable(
route = "tools/network_quality",
enterTransition = slideInFromRight,
exitTransition = slideOutToLeft,
popEnterTransition = slideInFromLeft,
popExitTransition = slideOutToRight,
) {
NetworkQualityScreen(navController = navController, serviceStatus = serviceStatus)
}
composable(
route = "tools/stun_test",
enterTransition = slideInFromRight,
exitTransition = slideOutToLeft,
popEnterTransition = slideInFromLeft,
popExitTransition = slideOutToRight,
) {
STUNTestScreen(navController = navController, serviceStatus = serviceStatus)
}
composable(
route = "tools/outbound_picker/{selectedOutbound}",
arguments = listOf(navArgument("selectedOutbound") { type = NavType.StringType }),
enterTransition = slideInFromRight,
exitTransition = slideOutToLeft,
popEnterTransition = slideInFromLeft,
popExitTransition = slideOutToRight,
) { backStackEntry ->
val selectedOutbound = Uri.decode(backStackEntry.arguments?.getString("selectedOutbound") ?: "")
OutboundPickerScreen(navController = navController, selectedOutbound = selectedOutbound)
}
composable(
route = "tools/tailscale/{endpointTag}",
arguments = listOf(navArgument("endpointTag") { type = NavType.StringType }),
enterTransition = slideInFromRight,
exitTransition = slideOutToLeft,
popEnterTransition = slideInFromLeft,
popExitTransition = slideOutToRight,
) { backStackEntry ->
val endpointTag = Uri.decode(backStackEntry.arguments?.getString("endpointTag") ?: return@composable)
val tailscaleViewModel: TailscaleStatusViewModel = tailscaleStatusViewModel ?: viewModel()
TailscaleEndpointScreen(navController = navController, viewModel = tailscaleViewModel, endpointTag = endpointTag)
}
composable(
route = "tools/tailscale/{endpointTag}/peer/{peerId}",
arguments = listOf(
navArgument("endpointTag") { type = NavType.StringType },
navArgument("peerId") { type = NavType.StringType },
),
enterTransition = slideInFromRight,
exitTransition = slideOutToLeft,
popEnterTransition = slideInFromLeft,
popExitTransition = slideOutToRight,
) { backStackEntry ->
val endpointTag = Uri.decode(backStackEntry.arguments?.getString("endpointTag") ?: return@composable)
val peerId = Uri.decode(backStackEntry.arguments?.getString("peerId") ?: return@composable)
val tailscaleViewModel: TailscaleStatusViewModel = tailscaleStatusViewModel ?: viewModel()
TailscalePeerScreen(navController = navController, viewModel = tailscaleViewModel, endpointTag = endpointTag, peerId = peerId)
}
composable(
route = "tools/crash_report",
enterTransition = slideInFromRight,
@@ -0,0 +1,470 @@
package io.nekohasekai.sfa.compose.screen.tools
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.constant.Status
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NetworkQualityScreen(
navController: NavController,
serviceStatus: Status = Status.Stopped,
viewModel: NetworkQualityViewModel = viewModel(),
) {
val state by viewModel.uiState.collectAsState()
val vpnRunning = serviceStatus == Status.Started
val context = LocalContext.current
var showConfigURLDialog by remember { mutableStateOf(false) }
var showMaxRuntimeDialog by remember { mutableStateOf(false) }
OverrideTopBar {
TopAppBar(
title = { Text(stringResource(R.string.network_quality)) },
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
)
}
LaunchedEffect(vpnRunning) {
if (!vpnRunning) {
viewModel.onVpnDisconnected()
}
}
val selectedOutboundResult = navController.currentBackStackEntry
?.savedStateHandle
?.getStateFlow("selected_outbound", state.selectedOutbound)
?.collectAsState()
LaunchedEffect(selectedOutboundResult?.value) {
selectedOutboundResult?.value?.let { viewModel.selectOutbound(it) }
}
DisposableEffect(Unit) {
onDispose {
if (state.isRunning) {
viewModel.cancelTest()
}
}
}
if (state.showMeteredWarning) {
AlertDialog(
onDismissRequest = { viewModel.dismissMeteredWarning() },
title = { Text(stringResource(R.string.network_quality_metered_title)) },
text = { Text(stringResource(R.string.network_quality_metered_message)) },
confirmButton = {
TextButton(onClick = { viewModel.confirmMeteredStart(vpnRunning) }) {
Text(stringResource(R.string.network_quality_metered_continue))
}
},
dismissButton = {
TextButton(onClick = { viewModel.dismissMeteredWarning() }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
if (showConfigURLDialog) {
ConfigURLDialog(
currentURL = state.configURL,
onURLChanged = { viewModel.updateConfigURL(it) },
onDismiss = { showConfigURLDialog = false },
)
}
if (showMaxRuntimeDialog) {
MaxRuntimeDialog(
currentOption = state.maxRuntime,
onOptionSelected = {
viewModel.setMaxRuntime(it)
showMaxRuntimeDialog = false
},
onDismiss = { showMaxRuntimeDialog = false },
)
}
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(R.string.tool_configuration),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
Surface(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable { showConfigURLDialog = true },
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
shape = RoundedCornerShape(12.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(R.string.network_quality_url),
style = MaterialTheme.typography.bodyLarge,
)
Text(
state.configURL,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
stringResource(R.string.network_quality_serial),
style = MaterialTheme.typography.bodyLarge,
)
Switch(
checked = state.serial,
onCheckedChange = { viewModel.setSerial(it) },
enabled = !state.isRunning,
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
stringResource(R.string.network_quality_http3),
style = MaterialTheme.typography.bodyLarge,
)
Switch(
checked = state.http3,
onCheckedChange = { viewModel.setHttp3(it) },
enabled = !state.isRunning,
)
}
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
Surface(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(enabled = !state.isRunning) { showMaxRuntimeDialog = true },
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
shape = RoundedCornerShape(12.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
stringResource(R.string.network_quality_max_runtime),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
)
Text(
stringResource(state.maxRuntime.labelRes),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (vpnRunning) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
OutboundPickerRow(
selectedOutbound = state.selectedOutbound,
onClick = {
navController.navigate(
"tools/outbound_picker/${android.net.Uri.encode(state.selectedOutbound)}",
)
},
)
}
}
}
if (state.isRunning) {
Button(
onClick = { viewModel.cancelTest() },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
),
) {
Text(stringResource(R.string.network_quality_cancel))
}
} else {
Button(
onClick = { viewModel.requestStartTest(context, vpnRunning) },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.network_quality_start))
}
}
if (state.phase >= 0) {
val phaseDownload = Libbox.NetworkQualityPhaseDownload.toInt()
val phaseUpload = Libbox.NetworkQualityPhaseUpload.toInt()
val downloadActive =
(state.isRunning && !state.serial && state.phase in phaseDownload..phaseUpload) || state.phase == phaseDownload
val uploadActive =
(state.isRunning && !state.serial && state.phase in phaseDownload..phaseUpload) || state.phase == phaseUpload
val done = state.phase == Libbox.NetworkQualityPhaseDone.toInt()
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f),
),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = stringResource(R.string.tool_results),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(bottom = 8.dp),
)
ResultItem(
label = stringResource(R.string.network_quality_idle_latency),
value = if (state.idleLatencyMs > 0) "${state.idleLatencyMs} ms" else null,
isActive = state.phase == Libbox.NetworkQualityPhaseIdle.toInt(),
isRunning = state.isRunning,
)
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
ResultItem(
label = stringResource(R.string.network_quality_download),
value = if (state.downloadCapacity > 0) Libbox.formatBitrate(state.downloadCapacity) else null,
isActive = downloadActive,
isRunning = state.isRunning,
accuracy = if (done) accuracyLabel(state.downloadCapacityAccuracy).first else null,
accuracyColor = if (done) accuracyLabel(state.downloadCapacityAccuracy).second else null,
)
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
ResultItem(
label = stringResource(R.string.network_quality_download_rpm),
value = if (state.downloadRPM > 0) "${state.downloadRPM}" else null,
isActive = downloadActive,
isRunning = state.isRunning,
accuracy = if (done) accuracyLabel(state.downloadRPMAccuracy).first else null,
accuracyColor = if (done) accuracyLabel(state.downloadRPMAccuracy).second else null,
)
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
ResultItem(
label = stringResource(R.string.network_quality_upload),
value = if (state.uploadCapacity > 0) Libbox.formatBitrate(state.uploadCapacity) else null,
isActive = uploadActive,
isRunning = state.isRunning,
accuracy = if (done) accuracyLabel(state.uploadCapacityAccuracy).first else null,
accuracyColor = if (done) accuracyLabel(state.uploadCapacityAccuracy).second else null,
)
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
ResultItem(
label = stringResource(R.string.network_quality_upload_rpm),
value = if (state.uploadRPM > 0) "${state.uploadRPM}" else null,
isActive = uploadActive,
isRunning = state.isRunning,
accuracy = if (done) accuracyLabel(state.uploadRPMAccuracy).first else null,
accuracyColor = if (done) accuracyLabel(state.uploadRPMAccuracy).second else null,
)
}
}
}
}
}
@Composable
private fun accuracyLabel(value: Int): Pair<String, Color> = when (value) {
Libbox.NetworkQualityAccuracyHigh -> stringResource(R.string.network_quality_confidence_high) to Color.Green
Libbox.NetworkQualityAccuracyMedium -> stringResource(R.string.network_quality_confidence_medium) to Color.Yellow
else -> stringResource(R.string.network_quality_confidence_low) to Color.Red
}
@Composable
private fun ConfigURLDialog(
currentURL: String,
onURLChanged: (String) -> Unit,
onDismiss: () -> Unit,
) {
var text by remember { mutableStateOf(currentURL) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.network_quality_url)) },
text = {
OutlinedTextField(
value = text,
onValueChange = { text = it },
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = {
TextButton(onClick = {
onURLChanged(text)
onDismiss()
}) {
Text(stringResource(android.R.string.ok))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
@Composable
private fun MaxRuntimeDialog(
currentOption: MaxRuntimeOption,
onOptionSelected: (MaxRuntimeOption) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.network_quality_max_runtime)) },
text = {
Column {
MaxRuntimeOption.entries.forEach { option ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable { onOptionSelected(option) }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = currentOption == option,
onClick = { onOptionSelected(option) },
)
Text(
text = stringResource(option.labelRes),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 8.dp),
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
@@ -0,0 +1,216 @@
package io.nekohasekai.sfa.compose.screen.tools
import android.content.Context
import android.net.ConnectivityManager
import androidx.lifecycle.viewModelScope
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.NetworkQualityProgress
import io.nekohasekai.libbox.NetworkQualityResult
import io.nekohasekai.libbox.NetworkQualityTestHandler
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.base.BaseViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
enum class MaxRuntimeOption(val seconds: Int, val labelRes: Int) {
THIRTY(30, R.string.network_quality_max_runtime_30s),
SIXTY(60, R.string.network_quality_max_runtime_60s),
}
data class NetworkQualityState(
val phase: Int = -1,
val idleLatencyMs: Int = 0,
val downloadCapacity: Long = 0,
val uploadCapacity: Long = 0,
val downloadRPM: Int = 0,
val uploadRPM: Int = 0,
val downloadCapacityAccuracy: Int = 0,
val uploadCapacityAccuracy: Int = 0,
val downloadRPMAccuracy: Int = 0,
val uploadRPMAccuracy: Int = 0,
val isRunning: Boolean = false,
val configURL: String = Libbox.NetworkQualityDefaultConfigURL,
val serial: Boolean = false,
val http3: Boolean = false,
val maxRuntime: MaxRuntimeOption = MaxRuntimeOption.THIRTY,
val selectedOutbound: String = "",
val showMeteredWarning: Boolean = false,
)
class NetworkQualityViewModel : BaseViewModel<NetworkQualityState, Nothing>() {
private var standaloneTest: io.nekohasekai.libbox.NetworkQualityTest? = null
private var grpcJob: Job? = null
override fun createInitialState() = NetworkQualityState()
fun updateConfigURL(url: String) {
updateState { copy(configURL = url) }
}
fun selectOutbound(tag: String) {
updateState { copy(selectedOutbound = tag) }
}
fun setSerial(value: Boolean) {
updateState { copy(serial = value) }
}
fun setHttp3(value: Boolean) {
updateState { copy(http3 = value) }
}
fun setMaxRuntime(option: MaxRuntimeOption) {
updateState { copy(maxRuntime = option) }
}
fun onVpnDisconnected() {
cancelTest()
updateState { copy(selectedOutbound = "") }
}
fun requestStartTest(context: Context, vpnRunning: Boolean) {
val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
if (connectivityManager.isActiveNetworkMetered) {
updateState { copy(showMeteredWarning = true) }
} else {
startTest(vpnRunning)
}
}
fun dismissMeteredWarning() {
updateState { copy(showMeteredWarning = false) }
}
fun confirmMeteredStart(vpnRunning: Boolean) {
updateState { copy(showMeteredWarning = false) }
startTest(vpnRunning)
}
private fun startTest(vpnRunning: Boolean) {
updateState {
copy(
phase = -1,
idleLatencyMs = 0,
downloadCapacity = 0,
uploadCapacity = 0,
downloadRPM = 0,
uploadRPM = 0,
downloadCapacityAccuracy = 0,
uploadCapacityAccuracy = 0,
downloadRPMAccuracy = 0,
uploadRPMAccuracy = 0,
isRunning = true,
)
}
val configURL = currentState.configURL
val outboundTag = currentState.selectedOutbound
val serial = currentState.serial
val http3 = currentState.http3
val maxRuntimeSeconds = currentState.maxRuntime.seconds
val handler = createHandler()
if (vpnRunning) {
grpcJob = viewModelScope.launch(Dispatchers.IO) {
try {
Libbox.newStandaloneCommandClient()
.startNetworkQualityTest(
configURL,
outboundTag,
serial,
maxRuntimeSeconds,
http3,
handler,
)
} catch (e: Exception) {
withContext(Dispatchers.Main) {
if (!currentState.isRunning) return@withContext
updateState { copy(isRunning = false) }
grpcJob = null
sendError(e)
}
}
}
} else {
val test = Libbox.newNetworkQualityTest()
standaloneTest = test
launch {
withContext(Dispatchers.IO) {
test.start(configURL, serial, maxRuntimeSeconds, http3, handler)
}
}
}
}
fun cancelTest() {
grpcJob?.cancel()
grpcJob = null
standaloneTest?.cancel()
standaloneTest = null
updateState { copy(isRunning = false) }
}
private fun createHandler(): NetworkQualityTestHandler {
return object : NetworkQualityTestHandler {
override fun onProgress(progress: NetworkQualityProgress?) {
progress ?: return
viewModelScope.launch {
if (!currentState.isRunning) return@launch
updateState {
copy(
phase = progress.phase.toInt(),
idleLatencyMs = progress.idleLatencyMs.toInt(),
downloadCapacity = progress.downloadCapacity,
uploadCapacity = progress.uploadCapacity,
downloadRPM = progress.downloadRPM.toInt(),
uploadRPM = progress.uploadRPM.toInt(),
downloadCapacityAccuracy = progress.downloadCapacityAccuracy.toInt(),
uploadCapacityAccuracy = progress.uploadCapacityAccuracy.toInt(),
downloadRPMAccuracy = progress.downloadRPMAccuracy.toInt(),
uploadRPMAccuracy = progress.uploadRPMAccuracy.toInt(),
)
}
}
}
override fun onResult(result: NetworkQualityResult?) {
result ?: return
viewModelScope.launch {
if (!currentState.isRunning) return@launch
updateState {
copy(
phase = Libbox.NetworkQualityPhaseDone.toInt(),
idleLatencyMs = result.idleLatencyMs.toInt(),
downloadCapacity = result.downloadCapacity,
uploadCapacity = result.uploadCapacity,
downloadRPM = result.downloadRPM.toInt(),
uploadRPM = result.uploadRPM.toInt(),
downloadCapacityAccuracy = result.downloadCapacityAccuracy.toInt(),
uploadCapacityAccuracy = result.uploadCapacityAccuracy.toInt(),
downloadRPMAccuracy = result.downloadRPMAccuracy.toInt(),
uploadRPMAccuracy = result.uploadRPMAccuracy.toInt(),
isRunning = false,
)
}
standaloneTest = null
grpcJob = null
}
}
override fun onError(message: String?) {
viewModelScope.launch {
if (!currentState.isRunning) return@launch
updateState { copy(isRunning = false) }
standaloneTest = null
grpcJob = null
if (message != null) {
sendErrorMessage(message)
}
}
}
}
}
}
@@ -0,0 +1,283 @@
package io.nekohasekai.sfa.compose.screen.tools
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.model.GroupItem
import io.nekohasekai.sfa.utils.CommandClient
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class OutboundPickerViewModel :
ViewModel(),
CommandClient.Handler {
private val _outbounds = MutableStateFlow<List<GroupItem>>(emptyList())
val outbounds: StateFlow<List<GroupItem>> = _outbounds.asStateFlow()
private var commandClient: CommandClient? = null
fun connect() {
disconnect()
commandClient = CommandClient(
viewModelScope,
CommandClient.ConnectionType.Outbounds,
this,
)
commandClient?.connect()
}
fun disconnect() {
commandClient?.disconnect()
commandClient = null
}
override fun updateOutbounds(outbounds: List<io.nekohasekai.libbox.OutboundGroupItem>) {
_outbounds.value = outbounds.map { GroupItem(it) }
}
override fun onCleared() {
disconnect()
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OutboundPickerScreen(
navController: NavController,
selectedOutbound: String,
) {
val viewModel: OutboundPickerViewModel = viewModel()
val outbounds by viewModel.outbounds.collectAsState()
var searchText by rememberSaveable { mutableStateOf("") }
DisposableEffect(Unit) {
viewModel.connect()
onDispose {
viewModel.disconnect()
}
}
val filteredOutbounds = if (searchText.isEmpty()) {
outbounds
} else {
outbounds.filter { it.tag.contains(searchText, ignoreCase = true) }
}
fun selectOutbound(tag: String) {
navController.previousBackStackEntry?.savedStateHandle?.set("selected_outbound", tag)
navController.navigateUp()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.tool_outbound)) },
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
)
}
},
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
OutlinedTextField(
value = searchText,
onValueChange = { searchText = it },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
placeholder = { Text(stringResource(android.R.string.search_go)) },
leadingIcon = {
Icon(
imageVector = Icons.Default.Search,
contentDescription = null,
)
},
singleLine = true,
shape = RoundedCornerShape(12.dp),
)
LazyColumn(modifier = Modifier.fillMaxSize()) {
item {
OutboundPickerItem(
tag = stringResource(R.string.tool_default_outbound),
isSelected = selectedOutbound.isEmpty(),
onClick = { selectOutbound("") },
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
items(filteredOutbounds, key = { it.tag }) { item ->
OutboundPickerItem(
tag = item.tag,
type = Libbox.proxyDisplayType(item.type),
urlTestDelay = item.urlTestDelay,
isSelected = selectedOutbound == item.tag,
onClick = { selectOutbound(item.tag) },
)
}
}
}
}
}
@Composable
private fun OutboundPickerItem(
tag: String,
type: String? = null,
urlTestDelay: Int = 0,
isSelected: Boolean,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = tag,
style = MaterialTheme.typography.bodyLarge,
)
if (type != null) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = type,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (urlTestDelay > 0) {
Text(
text = "${urlTestDelay}ms",
style = MaterialTheme.typography.bodySmall,
color = outboundDelayColor(urlTestDelay),
)
}
}
}
}
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
@Composable
fun OutboundPickerRow(
selectedOutbound: String,
onClick: () -> Unit,
) {
val displayText = if (selectedOutbound.isEmpty()) {
stringResource(R.string.tool_default_outbound)
} else {
selectedOutbound
}
Surface(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = onClick),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
shape = RoundedCornerShape(12.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
stringResource(R.string.tool_outbound),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
)
Text(
displayText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
fun outboundDelayColor(delay: Int): Color {
val colorScheme = MaterialTheme.colorScheme
return when {
delay < 100 -> colorScheme.tertiary
delay < 300 -> colorScheme.primary
delay < 500 -> colorScheme.secondary
else -> colorScheme.error
}
}
@@ -0,0 +1,78 @@
package io.nekohasekai.sfa.compose.screen.tools
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
@Composable
fun ResultItem(
label: String,
value: String?,
isActive: Boolean,
isRunning: Boolean,
accuracy: String? = null,
valueColor: Color? = null,
accuracyColor: Color? = null,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(label, style = MaterialTheme.typography.bodyLarge)
when {
value != null -> {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (isRunning && isActive) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
}
Text(
value,
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
color = valueColor ?: Color.Unspecified,
)
if (accuracy != null) {
Text(
accuracy,
style = MaterialTheme.typography.labelSmall,
color = accuracyColor ?: MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
isRunning && isActive -> {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
}
else -> {
Text(
"-",
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
)
}
}
}
}
@@ -0,0 +1,317 @@
package io.nekohasekai.sfa.compose.screen.tools
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.constant.Status
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun STUNTestScreen(
navController: NavController,
serviceStatus: Status = Status.Stopped,
viewModel: STUNTestViewModel = viewModel(),
) {
val state by viewModel.uiState.collectAsState()
val vpnRunning = serviceStatus == Status.Started
var showServerDialog by remember { mutableStateOf(false) }
OverrideTopBar {
TopAppBar(
title = { Text(stringResource(R.string.stun_test)) },
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
)
}
LaunchedEffect(vpnRunning) {
if (!vpnRunning) {
viewModel.onVpnDisconnected()
}
}
val selectedOutboundResult = navController.currentBackStackEntry
?.savedStateHandle
?.getStateFlow("selected_outbound", state.selectedOutbound)
?.collectAsState()
LaunchedEffect(selectedOutboundResult?.value) {
selectedOutboundResult?.value?.let { viewModel.selectOutbound(it) }
}
DisposableEffect(Unit) {
onDispose {
if (state.isRunning) {
viewModel.cancelTest()
}
}
}
if (showServerDialog) {
ServerEditDialog(
currentServer = state.server,
onServerChanged = { viewModel.updateServer(it) },
onDismiss = { showServerDialog = false },
)
}
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(R.string.tool_configuration),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
Surface(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable { showServerDialog = true },
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
shape = RoundedCornerShape(12.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(R.string.stun_server),
style = MaterialTheme.typography.bodyLarge,
)
Text(
state.server,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (vpnRunning) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
OutboundPickerRow(
selectedOutbound = state.selectedOutbound,
onClick = {
navController.navigate(
"tools/outbound_picker/${android.net.Uri.encode(state.selectedOutbound)}",
)
},
)
}
}
}
if (state.isRunning) {
Button(
onClick = { viewModel.cancelTest() },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
),
) {
Text(stringResource(R.string.stun_cancel))
}
} else {
Button(
onClick = { viewModel.startTest(vpnRunning) },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.stun_start))
}
}
if (state.phase >= 0) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f),
),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = stringResource(R.string.tool_results),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(bottom = 8.dp),
)
ResultItem(
label = stringResource(R.string.stun_external_address),
value = state.externalAddr.ifEmpty { null },
isActive = state.phase == Libbox.STUNPhaseBinding.toInt(),
isRunning = state.isRunning,
)
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
ResultItem(
label = stringResource(R.string.stun_latency),
value = if (state.latencyMs > 0) "${state.latencyMs} ms" else null,
isActive = state.phase == Libbox.STUNPhaseBinding.toInt(),
isRunning = state.isRunning,
)
if (state.phase == Libbox.STUNPhaseDone.toInt() && !state.natTypeSupported) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
ResultItem(
label = stringResource(R.string.stun_nat_type_detection),
value = stringResource(R.string.stun_nat_not_supported),
isActive = false,
isRunning = false,
)
} else {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
ResultItem(
label = stringResource(R.string.stun_nat_mapping),
value = if (state.natMapping > 0) Libbox.formatNATMapping(state.natMapping) else null,
isActive = state.phase == Libbox.STUNPhaseNATMapping.toInt(),
isRunning = state.isRunning,
valueColor = if (state.natMapping > 0) natMappingColor(state.natMapping) else null,
)
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
ResultItem(
label = stringResource(R.string.stun_nat_filtering),
value = if (state.natFiltering > 0) Libbox.formatNATFiltering(state.natFiltering) else null,
isActive = state.phase == Libbox.STUNPhaseNATFiltering.toInt(),
isRunning = state.isRunning,
valueColor = if (state.natFiltering > 0) natFilteringColor(state.natFiltering) else null,
)
}
}
}
}
}
}
private fun natMappingColor(value: Int): Color = when (value) {
Libbox.NATMappingEndpointIndependent.toInt() -> Color.Green
Libbox.NATMappingAddressDependent.toInt() -> Color.Yellow
Libbox.NATMappingAddressAndPortDependent.toInt() -> Color.Red
else -> Color.Unspecified
}
private fun natFilteringColor(value: Int): Color = when (value) {
Libbox.NATFilteringEndpointIndependent.toInt() -> Color.Green
Libbox.NATFilteringAddressDependent.toInt() -> Color.Yellow
Libbox.NATFilteringAddressAndPortDependent.toInt() -> Color.Red
else -> Color.Unspecified
}
@Composable
private fun ServerEditDialog(
currentServer: String,
onServerChanged: (String) -> Unit,
onDismiss: () -> Unit,
) {
var text by remember { mutableStateOf(currentServer) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.stun_server)) },
text = {
OutlinedTextField(
value = text,
onValueChange = { text = it },
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = {
TextButton(onClick = {
onServerChanged(text)
onDismiss()
}) {
Text(stringResource(android.R.string.ok))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
@@ -0,0 +1,146 @@
package io.nekohasekai.sfa.compose.screen.tools
import androidx.lifecycle.viewModelScope
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.STUNTestHandler
import io.nekohasekai.libbox.STUNTestProgress
import io.nekohasekai.libbox.STUNTestResult
import io.nekohasekai.sfa.compose.base.BaseViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class STUNTestState(
val phase: Int = -1,
val externalAddr: String = "",
val latencyMs: Int = 0,
val natMapping: Int = 0,
val natFiltering: Int = 0,
val natTypeSupported: Boolean = false,
val isRunning: Boolean = false,
val server: String = Libbox.STUNDefaultServer,
val selectedOutbound: String = "",
)
class STUNTestViewModel : BaseViewModel<STUNTestState, Nothing>() {
private var standaloneTest: io.nekohasekai.libbox.STUNTest? = null
private var grpcJob: Job? = null
override fun createInitialState() = STUNTestState()
fun updateServer(server: String) {
updateState { copy(server = server) }
}
fun selectOutbound(tag: String) {
updateState { copy(selectedOutbound = tag) }
}
fun onVpnDisconnected() {
cancelTest()
updateState { copy(selectedOutbound = "") }
}
fun startTest(vpnRunning: Boolean) {
updateState {
copy(
phase = -1,
externalAddr = "",
latencyMs = 0,
natMapping = 0,
natFiltering = 0,
natTypeSupported = false,
isRunning = true,
)
}
val server = currentState.server
val outboundTag = currentState.selectedOutbound
val handler = createHandler()
if (vpnRunning) {
grpcJob = viewModelScope.launch(Dispatchers.IO) {
try {
Libbox.newStandaloneCommandClient()
.startSTUNTest(server, outboundTag, handler)
} catch (e: Exception) {
withContext(Dispatchers.Main) {
if (!currentState.isRunning) return@withContext
updateState { copy(isRunning = false) }
grpcJob = null
sendError(e)
}
}
}
} else {
val test = Libbox.newSTUNTest()
standaloneTest = test
launch {
withContext(Dispatchers.IO) {
test.start(server, handler)
}
}
}
}
fun cancelTest() {
grpcJob?.cancel()
grpcJob = null
standaloneTest?.cancel()
standaloneTest = null
updateState { copy(isRunning = false) }
}
private fun createHandler(): STUNTestHandler {
return object : STUNTestHandler {
override fun onProgress(progress: STUNTestProgress?) {
progress ?: return
viewModelScope.launch {
if (!currentState.isRunning) return@launch
updateState {
copy(
phase = progress.phase.toInt(),
externalAddr = progress.externalAddr,
latencyMs = progress.latencyMs.toInt(),
natMapping = progress.natMapping.toInt(),
natFiltering = progress.natFiltering.toInt(),
)
}
}
}
override fun onResult(result: STUNTestResult?) {
result ?: return
viewModelScope.launch {
if (!currentState.isRunning) return@launch
updateState {
copy(
phase = Libbox.STUNPhaseDone.toInt(),
isRunning = false,
externalAddr = result.externalAddr,
latencyMs = result.latencyMs.toInt(),
natMapping = result.natMapping.toInt(),
natFiltering = result.natFiltering.toInt(),
natTypeSupported = result.natTypeSupported,
)
}
standaloneTest = null
grpcJob = null
}
}
override fun onError(message: String?) {
viewModelScope.launch {
if (!currentState.isRunning) return@launch
updateState { copy(isRunning = false) }
standaloneTest = null
grpcJob = null
if (message != null) {
sendErrorMessage(message)
}
}
}
}
}
}
@@ -0,0 +1,362 @@
package io.nekohasekai.sfa.compose.screen.tools
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.outlined.OpenInNew
import androidx.compose.material.icons.filled.QrCode2
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.component.qr.QRCodeDialog
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.compose.util.QRCodeGenerator
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TailscaleEndpointScreen(
navController: NavController,
viewModel: TailscaleStatusViewModel,
endpointTag: String,
) {
OverrideTopBar {
TopAppBar(
title = { Text(endpointTag) },
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.content_description_back))
}
},
)
}
val state by viewModel.uiState.collectAsState()
val endpoint = state.endpoints.firstOrNull { it.endpointTag == endpointTag }
if (endpoint == null) {
LaunchedEffect(Unit) {
navController.navigateUp()
}
return
}
val context = LocalContext.current
var showAuthQRCode by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp),
) {
val hasNetwork = endpoint.networkName.isNotEmpty()
val hasMagicDNS = endpoint.magicDNSSuffix.isNotEmpty()
val hasAuth = endpoint.authURL.isNotEmpty()
// Status section
SectionHeader(stringResource(R.string.tailscale_status))
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column {
val stateIsLast = !hasNetwork && !hasMagicDNS && !hasAuth
ListItem(
headlineContent = {
Text(
stringResource(R.string.tailscale_state),
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(stateColor(endpoint.backendState)),
)
Text(
endpoint.backendState,
style = MaterialTheme.typography.bodyMedium,
color = stateColor(endpoint.backendState),
)
}
},
modifier = Modifier.clip(
if (stateIsLast) {
RoundedCornerShape(12.dp)
} else {
RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
},
),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
if (hasNetwork) {
val networkIsLast = !hasMagicDNS && !hasAuth
ListItem(
headlineContent = {
Text(
stringResource(R.string.tailscale_network),
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
Text(
endpoint.networkName,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
modifier = if (networkIsLast) {
Modifier.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
} else {
Modifier
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
if (hasMagicDNS) {
val magicDNSIsLast = !hasAuth
ListItem(
headlineContent = {
Text(
stringResource(R.string.tailscale_magic_dns),
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
Text(
endpoint.magicDNSSuffix,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
modifier = if (magicDNSIsLast) {
Modifier.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
} else {
Modifier
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
if (hasAuth) {
ListItem(
headlineContent = {
Text(
stringResource(R.string.tailscale_open_auth_url),
style = MaterialTheme.typography.bodyLarge,
)
},
leadingContent = {
Icon(
Icons.AutoMirrored.Outlined.OpenInNew,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
modifier = Modifier.clickable {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(endpoint.authURL)))
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
ListItem(
headlineContent = {
Text(
stringResource(R.string.tailscale_open_auth_url_qr_code),
style = MaterialTheme.typography.bodyLarge,
)
},
leadingContent = {
Icon(
Icons.Default.QrCode2,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
modifier = Modifier
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
.clickable { showAuthQRCode = true },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
}
}
// This Device section
if (endpoint.backendState == "Running" && endpoint.selfPeer != null) {
Spacer(modifier = Modifier.height(16.dp))
SectionHeader(stringResource(R.string.tailscale_this_device))
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
PeerItem(
peer = endpoint.selfPeer,
onClick = {
navController.navigate(
"tools/tailscale/${Uri.encode(endpointTag)}/peer/${Uri.encode(endpoint.selfPeer.id)}",
)
},
modifier = Modifier.clip(RoundedCornerShape(12.dp)),
)
}
}
// User group sections
for (group in endpoint.userGroups) {
Spacer(modifier = Modifier.height(16.dp))
SectionHeader(group.displayName.ifEmpty { group.loginName })
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column {
group.peers.forEachIndexed { index, peer ->
if (index > 0) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
PeerItem(
peer = peer,
onClick = {
navController.navigate(
"tools/tailscale/${Uri.encode(endpointTag)}/peer/${Uri.encode(peer.id)}",
)
},
modifier = when {
group.peers.size == 1 -> Modifier.clip(RoundedCornerShape(12.dp))
index == 0 -> Modifier.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
index == group.peers.lastIndex -> Modifier.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
else -> Modifier
},
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
}
if (showAuthQRCode && endpoint.authURL.isNotEmpty()) {
val qrBitmap = QRCodeGenerator.rememberBitmap(endpoint.authURL)
QRCodeDialog(
bitmap = qrBitmap,
onDismiss = { showAuthQRCode = false },
)
}
}
@Composable
private fun SectionHeader(title: String) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
)
}
@Composable
private fun PeerItem(
peer: TailscalePeerData,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
ListItem(
headlineContent = {
Text(
peer.hostName,
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = if (peer.tailscaleIPs.isNotEmpty()) {
{
Text(
peer.tailscaleIPs.first(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
null
},
leadingContent = {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(if (peer.online) Color(0xFF4CAF50) else Color.Gray),
)
},
modifier = modifier.clickable(onClick = onClick),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
private fun stateColor(state: String): Color = when (state) {
"Running" -> Color(0xFF4CAF50)
"NeedsLogin", "NeedsMachineAuth" -> Color(0xFFFF9800)
"Starting" -> Color(0xFFFFEB3B)
else -> Color.Gray
}
@@ -0,0 +1,460 @@
package io.nekohasekai.sfa.compose.screen.tools
import android.text.format.DateUtils
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.LineChart
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.ktx.clipboardText
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TailscalePeerScreen(
navController: NavController,
viewModel: TailscaleStatusViewModel,
endpointTag: String,
peerId: String,
) {
val state by viewModel.uiState.collectAsState()
val peer = viewModel.peer(endpointTag, peerId)
val isSelf = viewModel.endpoint(endpointTag)?.selfPeer?.id == peerId
val pingViewModel: TailscalePingViewModel = viewModel()
val pingState by pingViewModel.uiState.collectAsState()
DisposableEffect(Unit) {
onDispose {
if (pingState.isRunning) {
pingViewModel.stopPing()
}
}
}
OverrideTopBar {
TopAppBar(
title = {
Column {
Text(
peer?.hostName ?: "",
style = MaterialTheme.typography.titleMedium,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Box(
modifier = Modifier
.size(6.dp)
.clip(CircleShape)
.background(
if (peer?.online == true) Color(0xFF4CAF50) else Color.Gray,
),
)
Text(
if (peer?.online == true) {
stringResource(R.string.tailscale_connected)
} else {
stringResource(R.string.tailscale_not_connected)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.content_description_back))
}
},
)
}
if (peer == null) {
LaunchedEffect(Unit) {
navController.navigateUp()
}
return
}
var copiedAddress by remember { mutableStateOf<String?>(null) }
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp),
) {
// Tailscale Addresses section
SectionHeader(stringResource(R.string.tailscale_addresses))
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (peer.dnsName.isNotEmpty()) {
AddressRow(
address = Libbox.formatFQDN(peer.dnsName),
label = stringResource(R.string.tailscale_magic_dns),
copied = copiedAddress,
onCopy = { copiedAddress = it },
)
}
for (ip in peer.tailscaleIPs) {
AddressRow(
address = ip,
label = if (ip.contains(":")) {
stringResource(R.string.tailscale_ipv6)
} else {
stringResource(R.string.tailscale_ipv4)
},
copied = copiedAddress,
onCopy = { copiedAddress = it },
)
}
}
}
// Ping section (not for self peer)
if (!isSelf && peer.online && peer.tailscaleIPs.isNotEmpty()) {
val peerIP = peer.tailscaleIPs.first()
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.tailscale_ping),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
Surface(
onClick = {
if (pingState.isRunning) {
pingViewModel.stopPing()
} else {
pingViewModel.startPing(endpointTag, peerIP)
}
},
shape = RoundedCornerShape(12.dp),
color = if (isSystemInDarkTheme()) {
lerp(
MaterialTheme.colorScheme.surfaceContainerHighest,
MaterialTheme.colorScheme.surfaceContainerHigh,
0.5f,
)
} else {
MaterialTheme.colorScheme.surfaceDim
},
modifier = Modifier.size(width = 44.dp, height = 32.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = if (pingState.isRunning) Icons.Default.Stop else Icons.Default.PlayArrow,
contentDescription = if (pingState.isRunning) {
stringResource(R.string.tailscale_ping_stop)
} else {
stringResource(R.string.tailscale_ping_start)
},
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
),
) {
Column(
modifier = Modifier.padding(16.dp),
) {
if (pingState.hasResult) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
if (pingState.isDirect) {
Text(
text = "\u2192 ",
color = Color(0xFF4CAF50),
)
Text(
text = stringResource(R.string.tailscale_ping_direct),
color = Color(0xFF4CAF50),
)
} else {
Text(
text = "\u21BB ",
color = Color(0xFFFF9800),
)
Text(
text = stringResource(R.string.tailscale_ping_derp),
color = Color(0xFFFF9800),
)
}
Spacer(modifier = Modifier.weight(1f))
Text(
text = "${pingState.latencyMs.toInt()} ms",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
}
if (pingState.isRunning && pingState.latencyHistory.size > 1) {
Spacer(modifier = Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
) {
LineChart(
data = pingState.latencyHistory,
lineColor = if (pingState.isDirect) {
Color(0xFF4CAF50)
} else {
Color(0xFF2196F3)
},
animate = false,
modifier = Modifier.weight(1f),
)
Spacer(modifier = Modifier.width(8.dp))
val maxMs = (
(
pingState.latencyHistory.maxOrNull()
?: 1f
) * 1.2f
).toInt().coerceAtLeast(1)
Column(
modifier = Modifier.height(80.dp),
verticalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = "${maxMs}ms",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "${maxMs * 2 / 3}ms",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "${maxMs / 3}ms",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "0ms",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
} else {
Text(
text = "No data",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
// Details section
val showDetails = peer.keyExpiry > 0 || peer.os.isNotEmpty() || peer.exitNode
if (showDetails) {
Spacer(modifier = Modifier.height(16.dp))
SectionHeader(stringResource(R.string.tailscale_details))
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
),
) {
val context = LocalContext.current
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (peer.keyExpiry > 0) {
val expiryText = DateUtils.getRelativeTimeSpanString(
peer.keyExpiry * 1000,
System.currentTimeMillis(),
DateUtils.MINUTE_IN_MILLIS,
).toString()
DetailRow(
label = stringResource(R.string.tailscale_key_expiry),
value = expiryText,
)
}
if (peer.os.isNotEmpty()) {
DetailRow(
label = stringResource(R.string.tailscale_os),
value = peer.os,
)
}
if (peer.exitNode) {
DetailRow(
label = stringResource(R.string.tailscale_exit_node),
value = stringResource(R.string.tailscale_active),
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
}
LaunchedEffect(copiedAddress) {
if (copiedAddress != null) {
delay(2000)
copiedAddress = null
}
}
}
@Composable
private fun SectionHeader(title: String) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
)
}
@Composable
private fun DetailRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 16.dp),
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.End,
)
}
}
@Composable
private fun AddressRow(
address: String,
label: String,
copied: String?,
onCopy: (String) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
address,
style = MaterialTheme.typography.bodyMedium,
)
Text(
label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = {
clipboardText = address
onCopy(address)
}) {
if (copied == address) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Icon(
Icons.Outlined.ContentCopy,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
@@ -0,0 +1,108 @@
package io.nekohasekai.sfa.compose.screen.tools
import androidx.lifecycle.viewModelScope
import io.nekohasekai.libbox.CommandClient
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.TailscalePingHandler
import io.nekohasekai.libbox.TailscalePingResult
import io.nekohasekai.sfa.compose.base.BaseViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class TailscalePingState(
val isRunning: Boolean = false,
val hasResult: Boolean = false,
val latencyMs: Double = 0.0,
val isDirect: Boolean = false,
val derpRegionCode: String = "",
val endpoint: String = "",
val latencyHistory: List<Float> = emptyList(),
)
class TailscalePingViewModel : BaseViewModel<TailscalePingState, Nothing>() {
private val maxHistorySize = 30
private var commandClient: CommandClient? = null
private var grpcJob: Job? = null
override fun createInitialState() = TailscalePingState()
fun startPing(endpointTag: String, peerIP: String) {
updateState {
copy(
isRunning = true,
hasResult = false,
latencyHistory = emptyList(),
)
}
val client = Libbox.newStandaloneCommandClient()
commandClient = client
grpcJob = viewModelScope.launch(Dispatchers.IO) {
try {
client.startTailscalePing(
endpointTag,
peerIP,
object : TailscalePingHandler {
override fun onPingResult(result: TailscalePingResult?) {
result ?: return
viewModelScope.launch {
if (!currentState.isRunning) return@launch
if (result.error.isNotEmpty()) return@launch
val newHistory = currentState.latencyHistory.toMutableList()
newHistory.add(result.latencyMs.toFloat())
if (newHistory.size > maxHistorySize) {
newHistory.removeFirstOrNull()
}
updateState {
copy(
hasResult = true,
latencyMs = result.latencyMs,
isDirect = result.isDirect,
derpRegionCode = result.derpRegionCode,
endpoint = result.endpoint,
latencyHistory = newHistory,
)
}
}
}
override fun onError(message: String?) {
viewModelScope.launch {
if (!currentState.isRunning) return@launch
updateState { copy(isRunning = false) }
commandClient = null
grpcJob = null
}
}
},
)
} catch (e: Exception) {
withContext(Dispatchers.Main) {
if (!currentState.isRunning) return@withContext
updateState { copy(isRunning = false) }
commandClient = null
grpcJob = null
}
}
}
}
fun stopPing() {
grpcJob?.cancel()
grpcJob = null
try {
commandClient?.disconnect()
} catch (_: Exception) {
}
commandClient = null
updateState { copy(isRunning = false) }
}
override fun onCleared() {
super.onCleared()
stopPing()
}
}
@@ -0,0 +1,180 @@
package io.nekohasekai.sfa.compose.screen.tools
import androidx.lifecycle.viewModelScope
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.TailscaleStatusHandler
import io.nekohasekai.libbox.TailscaleStatusUpdate
import io.nekohasekai.sfa.compose.base.BaseViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
data class TailscalePeerData(
val id: String,
val hostName: String,
val dnsName: String,
val os: String,
val tailscaleIPs: List<String>,
val online: Boolean,
val exitNode: Boolean,
val exitNodeOption: Boolean,
val active: Boolean,
val rxBytes: Long,
val txBytes: Long,
val keyExpiry: Long,
)
data class TailscaleUserGroupData(
val id: Long,
val loginName: String,
val displayName: String,
val profilePicURL: String,
val peers: List<TailscalePeerData>,
)
data class TailscaleEndpointData(
val endpointTag: String,
val backendState: String,
val authURL: String,
val networkName: String,
val magicDNSSuffix: String,
val selfPeer: TailscalePeerData?,
val userGroups: List<TailscaleUserGroupData>,
)
data class TailscaleStatusState(
val endpoints: List<TailscaleEndpointData> = emptyList(),
val isSubscribed: Boolean = false,
)
class TailscaleStatusViewModel : BaseViewModel<TailscaleStatusState, Nothing>() {
private var grpcJob: Job? = null
override fun createInitialState() = TailscaleStatusState()
fun subscribe() {
if (currentState.isSubscribed) return
updateState { copy(isSubscribed = true) }
grpcJob = viewModelScope.launch(Dispatchers.IO) {
try {
Libbox.newStandaloneCommandClient()
.subscribeTailscaleStatus(object : TailscaleStatusHandler {
override fun onStatusUpdate(status: TailscaleStatusUpdate) {
val endpoints = convertUpdate(status)
viewModelScope.launch {
if (!currentState.isSubscribed) return@launch
updateState { copy(endpoints = endpoints) }
}
}
override fun onError(message: String) {
viewModelScope.launch {
if (!currentState.isSubscribed) return@launch
updateState { copy(endpoints = emptyList(), isSubscribed = false) }
grpcJob = null
sendErrorMessage(message)
}
}
})
} catch (_: Exception) {
viewModelScope.launch {
updateState { copy(endpoints = emptyList(), isSubscribed = false) }
grpcJob = null
}
}
}
}
fun cancel() {
grpcJob?.cancel()
grpcJob = null
updateState { copy(endpoints = emptyList(), isSubscribed = false) }
}
fun endpoint(tag: String): TailscaleEndpointData? = currentState.endpoints.firstOrNull { it.endpointTag == tag }
fun peer(endpointTag: String, peerId: String): TailscalePeerData? {
val ep = endpoint(endpointTag) ?: return null
if (ep.selfPeer?.id == peerId) return ep.selfPeer
for (group in ep.userGroups) {
val found = group.peers.firstOrNull { it.id == peerId }
if (found != null) return found
}
return null
}
override fun onCleared() {
cancel()
super.onCleared()
}
private fun convertUpdate(status: TailscaleStatusUpdate): List<TailscaleEndpointData> {
val endpoints = mutableListOf<TailscaleEndpointData>()
val iterator = status.endpoints()
while (iterator.hasNext()) {
endpoints.add(convertEndpoint(iterator.next()))
}
return endpoints
}
private fun convertEndpoint(
endpoint: io.nekohasekai.libbox.TailscaleEndpointStatus,
): TailscaleEndpointData {
val userGroups = mutableListOf<TailscaleUserGroupData>()
val groupIterator = endpoint.userGroups()
while (groupIterator.hasNext()) {
userGroups.add(convertUserGroup(groupIterator.next()))
}
val self = endpoint.getSelf()
return TailscaleEndpointData(
endpointTag = endpoint.endpointTag,
backendState = endpoint.backendState,
authURL = endpoint.authURL,
networkName = endpoint.networkName,
magicDNSSuffix = endpoint.magicDNSSuffix,
selfPeer = if (self != null) convertPeer(self) else null,
userGroups = userGroups,
)
}
private fun convertUserGroup(
group: io.nekohasekai.libbox.TailscaleUserGroup,
): TailscaleUserGroupData {
val peers = mutableListOf<TailscalePeerData>()
val peerIterator = group.peers()
while (peerIterator.hasNext()) {
peers.add(convertPeer(peerIterator.next()))
}
return TailscaleUserGroupData(
id = group.userID,
loginName = group.loginName,
displayName = group.displayName,
profilePicURL = group.profilePicURL,
peers = peers,
)
}
private fun convertPeer(peer: io.nekohasekai.libbox.TailscalePeer): TailscalePeerData {
val ips = mutableListOf<String>()
val ipIterator = peer.tailscaleIPs()
while (ipIterator.hasNext()) {
ips.add(ipIterator.next())
}
val dnsName = peer.getDNSName()
return TailscalePeerData(
id = if (dnsName.isNotEmpty()) dnsName else peer.hostName,
hostName = peer.hostName,
dnsName = dnsName,
os = peer.getOS(),
tailscaleIPs = ips,
online = peer.online,
exitNode = peer.exitNode,
exitNodeOption = peer.exitNodeOption,
active = peer.active,
rxBytes = peer.rxBytes,
txBytes = peer.txBytes,
keyExpiry = peer.keyExpiry,
)
}
}
@@ -1,5 +1,6 @@
package io.nekohasekai.sfa.compose.screen.tools
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
@@ -11,7 +12,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.Hub
import androidx.compose.material.icons.outlined.Memory
import androidx.compose.material.icons.outlined.NetworkCheck
import androidx.compose.material3.Badge
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -23,6 +26,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
@@ -35,10 +39,15 @@ import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.CrashReportManager
import io.nekohasekai.sfa.bg.OOMReportManager
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.constant.Status
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ToolsScreen(navController: NavController) {
fun ToolsScreen(
navController: NavController,
serviceStatus: Status = Status.Stopped,
tailscaleViewModel: TailscaleStatusViewModel,
) {
OverrideTopBar {
TopAppBar(
title = { Text(stringResource(R.string.title_tools)) },
@@ -47,6 +56,15 @@ fun ToolsScreen(navController: NavController) {
val crashUnreadCount by CrashReportManager.unreadCount.collectAsState()
val oomUnreadCount by OOMReportManager.unreadCount.collectAsState()
val tailscaleState by tailscaleViewModel.uiState.collectAsState()
LaunchedEffect(serviceStatus) {
if (serviceStatus == Status.Started) {
tailscaleViewModel.subscribe()
} else {
tailscaleViewModel.cancel()
}
}
Column(
modifier = Modifier
@@ -55,6 +73,114 @@ fun ToolsScreen(navController: NavController) {
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp),
) {
if (tailscaleState.endpoints.isNotEmpty()) {
Text(
text = stringResource(R.string.tailscale_endpoints),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
val endpoints = tailscaleState.endpoints
endpoints.forEachIndexed { index, endpoint ->
val shape = when {
endpoints.size == 1 -> RoundedCornerShape(12.dp)
index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
index == endpoints.size - 1 -> RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)
else -> RoundedCornerShape(0.dp)
}
ListItem(
headlineContent = {
Text(
if (endpoints.size == 1) {
stringResource(R.string.tailscale)
} else {
stringResource(R.string.tailscale_with_tag, endpoint.endpointTag)
},
style = MaterialTheme.typography.bodyLarge,
)
},
leadingContent = {
Icon(
imageVector = Icons.Outlined.Hub,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
modifier = Modifier
.clip(shape)
.clickable {
navController.navigate("tools/tailscale/${Uri.encode(endpoint.endpointTag)}")
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
}
}
Text(
text = stringResource(R.string.title_network),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
ListItem(
headlineContent = {
Text(
stringResource(R.string.network_quality),
style = MaterialTheme.typography.bodyLarge,
)
},
leadingContent = {
Icon(
imageVector = Icons.Outlined.NetworkCheck,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
modifier = Modifier
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
.clickable { navController.navigate("tools/network_quality") },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
ListItem(
headlineContent = {
Text(
stringResource(R.string.stun_test),
style = MaterialTheme.typography.bodyLarge,
)
},
leadingContent = {
Icon(
imageVector = Icons.Outlined.NetworkCheck,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
modifier = Modifier
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
.clickable { navController.navigate("tools/stun_test") },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
Text(
text = stringResource(R.string.title_debug),
style = MaterialTheme.typography.labelLarge,
@@ -10,6 +10,7 @@ import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.LogEntry
import io.nekohasekai.libbox.LogIterator
import io.nekohasekai.libbox.OutboundGroup
import io.nekohasekai.libbox.OutboundGroupItemIterator
import io.nekohasekai.libbox.OutboundGroupIterator
import io.nekohasekai.libbox.StatusMessage
import io.nekohasekai.libbox.StringIterator
@@ -29,6 +30,7 @@ open class CommandClient(
private val additionalHandlers = mutableListOf<Handler>()
private var cachedGroups: MutableList<OutboundGroup>? = null
private var cachedOutbounds: List<io.nekohasekai.libbox.OutboundGroupItem>? = null
fun addHandler(handler: Handler) {
synchronized(additionalHandlers) {
@@ -37,6 +39,9 @@ open class CommandClient(
cachedGroups?.let { groups ->
handler.updateGroups(groups)
}
cachedOutbounds?.let { outbounds ->
handler.updateOutbounds(outbounds)
}
}
}
}
@@ -57,6 +62,7 @@ open class CommandClient(
Log,
ClashMode,
Connections,
Outbounds,
}
interface Handler {
@@ -74,6 +80,8 @@ open class CommandClient(
fun updateGroups(newGroups: MutableList<OutboundGroup>) {}
fun updateOutbounds(outbounds: List<io.nekohasekai.libbox.OutboundGroupItem>) {}
fun initializeClashMode(modeList: List<String>, currentMode: String) {}
fun updateClashMode(newMode: String) {}
@@ -95,6 +103,7 @@ open class CommandClient(
ConnectionType.Log -> Libbox.CommandLog
ConnectionType.ClashMode -> Libbox.CommandClashMode
ConnectionType.Connections -> Libbox.CommandConnections
ConnectionType.Outbounds -> Libbox.CommandOutbounds
}
options.addCommand(command)
}
@@ -142,6 +151,18 @@ open class CommandClient(
getAllHandlers().forEach { it.updateGroups(groups) }
}
override fun writeOutbounds(message: OutboundGroupItemIterator?) {
if (message == null) {
return
}
val outbounds = mutableListOf<io.nekohasekai.libbox.OutboundGroupItem>()
while (message.hasNext()) {
outbounds.add(message.next())
}
cachedOutbounds = outbounds
getAllHandlers().forEach { it.updateOutbounds(outbounds) }
}
override fun setDefaultLogLevel(level: Int) {
getAllHandlers().forEach { it.setDefaultLogLevel(level) }
}