ui: add sheet to ping devices and see relay status (#431)

This PR adds the ability to ping other devices in your tailnet from the Android app, similarly to the current functionality on iOS. The ping view displays the current latency value, a chart with latency over time, and whether you are using a direct/relayed connection.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
pull/433/head
Andrea Gottardo 3 months ago committed by GitHub
parent 811641f538
commit d702d2dab8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -120,6 +120,8 @@ dependencies {
// Supporting libraries.
implementation("io.coil-kt:coil-compose:2.6.0")
implementation("com.google.zxing:core:3.5.1")
implementation("com.patrykandpatrick.vico:compose:1.15.0")
implementation("com.patrykandpatrick.vico:compose-m3:1.15.0")
// Tailscale dependencies.
implementation ':libtailscale@aar'

@ -68,6 +68,7 @@ import com.tailscale.ipn.ui.view.UserSwitcherNav
import com.tailscale.ipn.ui.view.UserSwitcherView
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.MainViewModel
import com.tailscale.ipn.ui.viewModel.PingViewModel
import com.tailscale.ipn.ui.viewModel.SettingsNav
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
@ -206,7 +207,10 @@ class MainActivity : ComponentActivity() {
composable(
"peerDetails/{nodeId}",
arguments = listOf(navArgument("nodeId") { type = NavType.StringType })) {
PeerDetails(backTo("main"), it.arguments?.getString("nodeId") ?: "")
PeerDetails(
backTo("main"),
it.arguments?.getString("nodeId") ?: "",
PingViewModel())
}
composable("bugReport") { BugReportView(backTo("settings")) }
composable("dnsSettings") { DNSSettingsView(backTo("settings")) }

@ -11,6 +11,7 @@ import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.IpnState
import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.util.InputStreamAdapter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -57,6 +58,8 @@ typealias BugReportIdHandler = (Result<BugReportID>) -> Unit
typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit
typealias PingResultHandler = (Result<IpnState.PingResult>) -> Unit
/**
* Client provides a mechanism for calling Go's LocalAPIClient. Every LocalAPI endpoint has a
* corresponding method on this Client.
@ -73,6 +76,17 @@ class Client(private val scope: CoroutineScope) {
get(Endpoint.STATUS, responseHandler = responseHandler)
}
fun ping(peer: Tailcfg.Node, responseHandler: PingResultHandler) {
val ip = peer.primaryIPv4Address.orEmpty()
if (ip.isEmpty()) {
responseHandler(Result.failure(Exception("No IP address for peer $peer")))
return
}
val path = "${Endpoint.PING}?ip=${ip}&type=disco"
post(path, timeoutMillis = 2000L, responseHandler = responseHandler)
}
fun bugReportId(responseHandler: BugReportIdHandler) {
post(Endpoint.BUG_REPORT, responseHandler = responseHandler)
}
@ -206,6 +220,7 @@ class Client(private val scope: CoroutineScope) {
private inline fun <reified T> post(
path: String,
body: ByteArray? = null,
timeoutMillis: Long = 30000,
noinline responseHandler: (Result<T>) -> Unit
) {
Request(
@ -213,6 +228,7 @@ class Client(private val scope: CoroutineScope) {
method = "POST",
path = path,
body = body,
timeoutMillis = timeoutMillis,
responseType = typeOf<T>(),
responseHandler = responseHandler)
.execute()

@ -23,6 +23,8 @@ class IpnState {
val Tags: List<String>? = null,
val PrimaryRoutes: List<String>? = null,
val Addrs: List<String>? = null,
val CurAddr: String? = null,
val Relay: String? = null,
val Online: Boolean,
val ExitNode: Boolean,
val ExitNodeOption: Boolean,

@ -168,8 +168,8 @@ val ColorScheme.customError: Color
}
val ColorScheme.customErrorContainer: Color
@Composable
get() =
@Composable
get() =
if (isSystemInDarkTheme()) {
Color(0xFF760012) // red-700
} else {
@ -334,7 +334,7 @@ val ColorScheme.errorButton: ButtonColors
val defaults = ButtonDefaults.buttonColors()
if (isSystemInDarkTheme()) {
return ButtonColors(
containerColor = Color(0xFFB22D30), // red-500
containerColor = Color(0xFFB22D30), // red-500
contentColor = Color(0xFFFFFFFF), // white
disabledContainerColor = defaults.disabledContainerColor,
disabledContentColor = defaults.disabledContentColor)

@ -0,0 +1,53 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.on
sealed class ConnectionMode {
class NotConnected : ConnectionMode()
class Derp(val relayName: String) : ConnectionMode()
class Direct : ConnectionMode()
@Composable
fun titleString(): String {
return when (this) {
is NotConnected -> stringResource(id = R.string.not_connected)
is Derp -> stringResource(R.string.relayed_connection, relayName)
is Direct -> stringResource(R.string.direct_connection)
}
}
fun contentKey(): String {
return when (this) {
is NotConnected -> "NotConnected"
is Derp -> "Derp($relayName)"
is Direct -> "Direct"
}
}
fun iconDrawable(): Int {
return when (this) {
is NotConnected -> R.drawable.xmark_circle
is Derp -> R.drawable.link_off
is Direct -> R.drawable.link
}
}
@Composable
fun color(): Color {
return when (this) {
is NotConnected -> MaterialTheme.colorScheme.onPrimary
is Derp -> MaterialTheme.colorScheme.error
is Direct -> MaterialTheme.colorScheme.on
}
}
}

@ -6,6 +6,7 @@ package com.tailscale.ipn.ui.view
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -31,12 +32,15 @@ import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
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.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@ -54,6 +58,7 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@ -93,6 +98,7 @@ import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.IpnViewModel.NodeState
import com.tailscale.ipn.ui.viewModel.MainViewModel
@ -103,13 +109,15 @@ data class MainViewNavigation(
val onNavigateToExitNodes: () -> Unit
)
@OptIn(ExperimentalPermissionsApi::class)
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@Composable
fun MainView(
loginAtUrl: (String) -> Unit,
navigation: MainViewNavigation,
viewModel: MainViewModel
) {
val currentPingDevice by viewModel.pingViewModel.peer.collectAsState()
LoadingIndicator.Wrap {
Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets ->
Column(
@ -215,6 +223,10 @@ fun MainView(
}
}
}
currentPingDevice?.let { peer ->
ModalBottomSheet(onDismissRequest = { viewModel.onPingDismissal() }) { PingView(model = viewModel.pingViewModel) }
}
}
}
}
@ -505,6 +517,9 @@ fun PeerList(
var isListFocussed by remember { mutableStateOf(false) }
val expandedPeer = viewModel.expandedMenuPeer.collectAsState()
val localClipboardManager = LocalClipboardManager.current
val enableSearch = !isAndroidTV()
if (enableSearch) {
@ -584,7 +599,10 @@ fun PeerList(
itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer ->
ListItem(
modifier = Modifier.clickable { onNavigateToPeerDetails(peer) },
modifier =
Modifier.combinedClickable(
onClick = { onNavigateToPeerDetails(peer) },
onLongClick = { viewModel.expandedMenuPeer.set(peer) }),
colors = MaterialTheme.colorScheme.listItem,
headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
@ -597,6 +615,38 @@ fun PeerList(
shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(8.dp))
Text(text = peer.displayName, style = MaterialTheme.typography.titleMedium)
DropdownMenu(
expanded = expandedPeer.value?.StableID == peer.StableID,
onDismissRequest = { viewModel.hidePeerDropdownMenu() }) {
DropdownMenuItem(
leadingIcon = {
Icon(
painter = painterResource(R.drawable.clipboard),
contentDescription = null)
},
text = { Text(text = stringResource(R.string.copy_ip_address)) },
onClick = {
viewModel.copyIpAddress(peer, localClipboardManager)
viewModel.hidePeerDropdownMenu()
})
netmap.value?.let { netMap ->
if (!peer.isSelfNode(netMap)) {
// Don't show the ping item for the self-node
DropdownMenuItem(
leadingIcon = {
Icon(
painter = painterResource(R.drawable.timer),
contentDescription = null)
},
text = { Text(text = stringResource(R.string.ping)) },
onClick = {
viewModel.hidePeerDropdownMenu()
viewModel.startPing(peer)
})
}
}
}
}
},
supportingContent = {

@ -16,12 +16,15 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
@ -39,15 +42,21 @@ import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModelFactory
import com.tailscale.ipn.ui.viewModel.PingViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PeerDetails(
backToHome: BackNavigation,
nodeId: String,
pingViewModel: PingViewModel,
model: PeerDetailsViewModel =
viewModel(factory = PeerDetailsViewModelFactory(nodeId, LocalContext.current.filesDir))
viewModel(
factory =
PeerDetailsViewModelFactory(nodeId, LocalContext.current.filesDir, pingViewModel))
) {
val isPinging by model.isPinging.collectAsState()
model.netmap.collectAsState().value?.let { netmap ->
model.node.collectAsState().value?.let { node ->
Scaffold(
@ -74,6 +83,13 @@ fun PeerDetails(
}
}
},
actions = {
IconButton(onClick = { model.startPing() }) {
Icon(
painter = painterResource(R.drawable.timer),
contentDescription = "Ping device")
}
},
onBack = backToHome)
},
) { innerPadding ->
@ -94,6 +110,11 @@ fun PeerDetails(
ValueRow(title = stringResource(id = it.titleRes), value = it.value.getString())
}
}
if (isPinging) {
ModalBottomSheet(onDismissRequest = { model.onPingDismissal() }) {
PingView(model = model.pingViewModel)
}
}
}
}
}

@ -0,0 +1,204 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
// TODO(angott): must mention usage of com.patrykandpatrick.vico library in LICENSES
import android.graphics.Typeface
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
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 com.patrykandpatrick.vico.compose.axis.axisGuidelineComponent
import com.patrykandpatrick.vico.compose.axis.horizontal.rememberBottomAxis
import com.patrykandpatrick.vico.compose.axis.vertical.rememberStartAxis
import com.patrykandpatrick.vico.compose.chart.Chart
import com.patrykandpatrick.vico.compose.chart.line.lineChart
import com.patrykandpatrick.vico.compose.component.shapeComponent
import com.patrykandpatrick.vico.compose.component.textComponent
import com.patrykandpatrick.vico.compose.dimensions.dimensionsOf
import com.patrykandpatrick.vico.compose.m3.style.m3ChartStyle
import com.patrykandpatrick.vico.compose.style.ProvideChartStyle
import com.patrykandpatrick.vico.compose.style.currentChartStyle
import com.patrykandpatrick.vico.core.axis.AxisItemPlacer
import com.patrykandpatrick.vico.core.chart.copy
import com.patrykandpatrick.vico.core.entry.FloatEntry
import com.patrykandpatrick.vico.core.entry.entryModelOf
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.util.ConnectionMode
import com.tailscale.ipn.ui.viewModel.PingViewModel
import java.text.DecimalFormat
@Composable
fun PingView(model: PingViewModel = viewModel()) {
val connectionMode: ConnectionMode by
model.connectionMode.collectAsState(initial = ConnectionMode.NotConnected())
val peer: Tailcfg.Node? by model.peer.collectAsState()
val lastLatencyValue: String by model.lastLatencyValue.collectAsState()
val pingValues: List<Double> by model.latencyValues.collectAsState()
val chartEntryModel =
entryModelOf(
pingValues.withIndex().map { FloatEntry((it.index + 1).toFloat(), it.value.toFloat()) })
val errorMessage: String? by model.errorMessage.collectAsState()
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(bottom = 36.dp)) {
Row {
Column {
Text(
stringResource(R.string.pinging_node_name, peer?.ComputedName ?: "???"),
fontStyle = MaterialTheme.typography.titleLarge.fontStyle,
fontWeight = FontWeight.Bold)
if (pingValues.isNotEmpty()) {
AnimatedContent(targetState = connectionMode, contentKey = { it.contentKey() }) {
targetConnectionMode ->
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Icon(
painter = painterResource(id = targetConnectionMode.iconDrawable()),
contentDescription = null,
tint = targetConnectionMode.color())
Text(
targetConnectionMode.titleString(),
fontSize = MaterialTheme.typography.bodySmall.fontSize,
color = targetConnectionMode.color())
}
}
}
}
AnimatedContent(
targetState = lastLatencyValue,
transitionSpec = {
// The new value slides down and fades in, while the previous value slides down
// and fades out.
(slideInVertically { height -> -height } + fadeIn())
.togetherWith(slideOutVertically { height -> height } + fadeOut())
.using(SizeTransform(clip = false))
}) { latency ->
Text(
latency,
fontFamily = FontFamily.Monospace,
fontSize = MaterialTheme.typography.titleLarge.fontSize,
textAlign = TextAlign.Right,
modifier = Modifier.fillMaxWidth())
}
}
if (pingValues.isNotEmpty()) {
ProvideChartStyle(chartStyle = m3ChartStyle()) {
val defaultLines = currentChartStyle.lineChart.lines
val circlePoint =
shapeComponent(
shape = CircleShape,
color = MaterialTheme.colorScheme.background,
strokeColor = MaterialTheme.colorScheme.surfaceTint,
strokeWidth = 2.dp)
Chart(
chart =
lineChart(
remember(defaultLines) {
defaultLines.map { defaultLine ->
defaultLine.copy(point = circlePoint, pointSizeDp = 10.0F)
}
},
spacing = 0.dp,
),
model = chartEntryModel,
startAxis =
rememberStartAxis(
valueFormatter = { value, _ ->
DecimalFormat("#;#").format(value) + " ms"
},
itemPlacer = remember { AxisItemPlacer.Vertical.default(maxItemCount = 5) },
label =
textComponent(
color = MaterialTheme.colorScheme.secondary,
typeface = Typeface.MONOSPACE,
padding = dimensionsOf(end = 8.dp)),
guideline =
axisGuidelineComponent(
color = MaterialTheme.colorScheme.secondaryContainer)),
bottomAxis =
rememberBottomAxis(
itemPlacer = remember { AxisItemPlacer.Horizontal.default(spacing = 1) },
label =
textComponent(
color = MaterialTheme.colorScheme.secondary,
typeface = Typeface.MONOSPACE,
),
guideline =
axisGuidelineComponent(
color = MaterialTheme.colorScheme.secondaryContainer)),
)
}
} else {
errorMessage?.also { error ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement =
Arrangement.spacedBy(6.dp, alignment = Alignment.CenterVertically),
modifier = Modifier.fillMaxWidth().height(200.dp)) {
Icon(
painter = painterResource(id = R.drawable.warning),
modifier = Modifier.size(48.dp),
contentDescription = null,
tint = Color.Red)
Text(
stringResource(id = R.string.pingFailed),
fontSize = MaterialTheme.typography.titleMedium.fontSize,
fontFamily = MaterialTheme.typography.titleMedium.fontFamily,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = Color.Red)
Text(
error,
textAlign = TextAlign.Center,
color = Color.Red,
)
}
}
?: run {
Column(
modifier = Modifier.fillMaxWidth().height(200.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) {
TailscaleLogoView(
true, usesOnBackgroundColors = false, Modifier.size(36.dp).alpha(0.4f))
}
}
}
}
}
fun Double.roundedString(decimals: Int): String = "%.${decimals}f".format(this)

@ -6,12 +6,15 @@ package com.tailscale.ipn.ui.viewModel
import android.content.Intent
import android.net.VpnService
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App
import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Ipn.State
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
import com.tailscale.ipn.ui.util.PeerCategorizer
@ -48,6 +51,27 @@ class MainViewModel : IpnViewModel() {
// True if we should render the key expiry bannder
val showExpiry: StateFlow<Boolean> = MutableStateFlow(false)
// The peer for which the dropdown menu is currently expanded. Null if no menu is expanded
var expandedMenuPeer: StateFlow<Tailcfg.Node?> = MutableStateFlow(null)
var pingViewModel: PingViewModel = PingViewModel()
fun hidePeerDropdownMenu() {
expandedMenuPeer.set(null)
}
fun copyIpAddress(peer: Tailcfg.Node, clipboardManager: ClipboardManager) {
clipboardManager.setText(AnnotatedString(peer.primaryIPv4Address ?: ""))
}
fun startPing(peer: Tailcfg.Node) {
this.pingViewModel.startPing(peer)
}
fun onPingDismissal() {
this.pingViewModel.handleDismissal()
}
private val peerCategorizer = PeerCategorizer()
init {

@ -6,7 +6,6 @@ package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.notifier.Notifier
@ -19,15 +18,23 @@ import java.io.File
data class PeerSettingInfo(val titleRes: Int, val value: ComposableStringFormatter)
class PeerDetailsViewModelFactory(private val nodeId: StableNodeID, private val filesDir: File) :
ViewModelProvider.Factory {
class PeerDetailsViewModelFactory(
private val nodeId: StableNodeID,
private val filesDir: File,
private val pingViewModel: PingViewModel
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return PeerDetailsViewModel(nodeId, filesDir) as T
return PeerDetailsViewModel(nodeId, filesDir, pingViewModel) as T
}
}
class PeerDetailsViewModel(val nodeId: StableNodeID, val filesDir: File) : IpnViewModel() {
class PeerDetailsViewModel(
val nodeId: StableNodeID,
val filesDir: File,
val pingViewModel: PingViewModel
) : IpnViewModel() {
val node: StateFlow<Tailcfg.Node?> = MutableStateFlow(null)
val isPinging: StateFlow<Boolean> = MutableStateFlow(false)
init {
viewModelScope.launch {
@ -37,4 +44,14 @@ class PeerDetailsViewModel(val nodeId: StableNodeID, val filesDir: File) : IpnVi
}
}
}
fun startPing() {
isPinging.set(true)
node.value?.let { this.pingViewModel.startPing(it) }
}
fun onPingDismissal() {
isPinging.set(false)
this.pingViewModel.handleDismissal()
}
}

@ -0,0 +1,130 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import android.content.Context
import android.os.CountDownTimer
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.util.ConnectionMode
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.view.roundedString
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class PingViewModelFactory(private val peer: Tailcfg.Node) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return PingViewModel() as T
}
}
class PingViewModel : ViewModel() {
private val TAG = PingViewModel::class.simpleName
// The timer ticks every second, for a maximum of 10 seconds, hence triggering 10 ping
// requests.
private val timer =
object : CountDownTimer(1000 * 10, 1000) {
override fun onTick(millisUntilFinished: Long) {
sendPing()
fetchStatusAndUpdateConnectionMode()
}
override fun onFinish() {
Log.d(TAG, "Ping timer terminated")
}
}
// The peer to ping.
var peer: StateFlow<Tailcfg.Node?> = MutableStateFlow(null)
// Whether we are using a relayed or direct connection. Will be NotConnected until the first
// PeerStatus value has been fetched. NotConnected is not surfaced to the user.
val connectionMode: StateFlow<ConnectionMode> = MutableStateFlow(ConnectionMode.NotConnected())
// An error message to display if any request fails. Non-null if an error message must be surfaced
// to the user. If a subsequent request succeeds, this property should be set to null again.
val errorMessage: StateFlow<String?> = MutableStateFlow(null)
// The last latency value in a human-readable format (e.g. "14.5 ms").
val lastLatencyValue: StateFlow<String> = MutableStateFlow("")
// A list of latency values over time in milliseconds. These are used to plot the latency
// values in the chart.
var latencyValues: StateFlow<List<Double>> = MutableStateFlow(emptyList())
fun startPing(peer: Tailcfg.Node) {
this.peer.set(peer)
timer.start()
}
fun handleDismissal() {
timer.cancel()
this.peer.set(null)
this.connectionMode.set(ConnectionMode.NotConnected())
this.lastLatencyValue.set("")
this.latencyValues.set(emptyList())
this.errorMessage.set(null)
}
// sendPing asks the backend to send one ping to the peer and handles the response.
// It checks for any errors in the response Err field. If an error is present, it sets the
// errorMessage property to a non-null value and returns. If there is no error, it updates the
// lastLatencyValue property with the formatted latency, and adds the latency value to the
// latencyValues list.
private fun sendPing() {
peer.value?.let { peer ->
Client(viewModelScope).ping(peer) { response ->
response.onSuccess { pingResult ->
val error = pingResult.Err
if (error.isNotEmpty()) {
this.errorMessage.set(error.replaceFirstChar { it.uppercase() })
return@onSuccess
} else {
this.errorMessage.set(null)
val latency: Double = pingResult.LatencySeconds * 1000
this.lastLatencyValue.set("${latency.roundedString(1)} ms")
this.latencyValues.set(this.latencyValues.value + latency)
}
}
response.onFailure { error ->
val context: Context = App.get().applicationContext
val stringError = error.toString()
Log.d(TAG, "Ping request failed: $stringError")
if (stringError.contains("timeout")) {
this.errorMessage.set(
context.getString(
R.string.request_timed_out_make_sure_that_is_online, peer.ComputedName))
} else {
this.errorMessage.set(
context.getString(R.string.an_unknown_error_occurred_please_try_again))
}
}
}
}
}
// fetchStatusAndUpdateConnectionMode fetches the PeerStatus for the peer and updates the
// connectionMode property as soon as a direct connection is finally established.
private fun fetchStatusAndUpdateConnectionMode() {
Client(viewModelScope).status { statusResult ->
statusResult.onSuccess { result ->
result.Peer?.let { map ->
map[peer.value?.Key]?.let { peerStatus ->
val curAddr = peerStatus.CurAddr.orEmpty()
val relay = peerStatus.Relay.orEmpty()
if (curAddr.isNotEmpty()) {
this.connectionMode.set(ConnectionMode.Direct())
} else if (relay.isNotEmpty()) {
this.connectionMode.set(ConnectionMode.Derp(relayName = relay.uppercase()))
}
}
}
}
statusResult.onFailure { Log.d(TAG, "Failed to fetch status: $it") }
}
}
}

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M3.9,12c0,-1.71 1.39,-3.1 3.1,-3.1h4L11,7L7,7c-2.76,0 -5,2.24 -5,5s2.24,5 5,5h4v-1.9L7,15.1c-1.71,0 -3.1,-1.39 -3.1,-3.1zM8,13h8v-2L8,11v2zM17,7h-4v1.9h4c1.71,0 3.1,1.39 3.1,3.1s-1.39,3.1 -3.1,3.1h-4L13,17h4c2.76,0 5,-2.24 5,-5s-2.24,-5 -5,-5z"/>
</vector>

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M17,7h-4v1.9h4c1.71,0 3.1,1.39 3.1,3.1 0,1.43 -0.98,2.63 -2.31,2.98l1.46,1.46C20.88,15.61 22,13.95 22,12c0,-2.76 -2.24,-5 -5,-5zM16,11h-2.19l2,2L16,13zM2,4.27l3.11,3.11C3.29,8.12 2,9.91 2,12c0,2.76 2.24,5 5,5h4v-1.9L7,15.1c-1.71,0 -3.1,-1.39 -3.1,-3.1 0,-1.59 1.21,-2.9 2.76,-3.07L8.73,11L8,11v2h2.73L13,15.27L13,17h1.73l4.01,4L20,19.74 3.27,3 2,4.27z"/>
</vector>

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M480,840Q406,840 340.5,811.5Q275,783 226,734Q177,685 148.5,619.5Q120,554 120,480Q120,402 150,334Q180,266 234,217Q246,206 262.5,206.5Q279,207 290,218L508,436Q519,447 519,464Q519,481 508,492Q497,503 480,503Q463,503 452,492L264,304Q234,340 217,384.5Q200,429 200,480Q200,596 282,678Q364,760 480,760Q596,760 678,678Q760,596 760,480Q760,373 691.5,295.5Q623,218 520,204L520,240Q520,257 508.5,268.5Q497,280 480,280Q463,280 451.5,268.5Q440,257 440,240L440,160Q440,143 451.5,131.5Q463,120 480,120Q554,120 619.5,148.5Q685,177 734,226Q783,275 811.5,340.5Q840,406 840,480Q840,554 811.5,619.5Q783,685 734,734Q685,783 619.5,811.5Q554,840 480,840ZM280,520Q263,520 251.5,508.5Q240,497 240,480Q240,463 251.5,451.5Q263,440 280,440Q297,440 308.5,451.5Q320,463 320,480Q320,497 308.5,508.5Q297,520 280,520ZM480,720Q463,720 451.5,708.5Q440,697 440,680Q440,663 451.5,651.5Q463,640 480,640Q497,640 508.5,651.5Q520,663 520,680Q520,697 508.5,708.5Q497,720 480,720ZM680,520Q663,520 651.5,508.5Q640,497 640,480Q640,463 651.5,451.5Q663,440 680,440Q697,440 708.5,451.5Q720,463 720,480Q720,497 708.5,508.5Q697,520 680,520Z"/>
</vector>

@ -265,4 +265,12 @@
<string name="notifications_delivered_when_a_file_is_received_using_taildrop">Notifications delivered when a file is received using Taildrop.</string>
<string name="health_channel_name">Errors and warnings</string>
<string name="health_channel_description">This notification category is used to deliver important status notifications and should be left enabled. For instance, it is used to notify you about errors or warnings that affect Internet connectivity.</string>
<string name="copy_ip_address">Copy IP Address</string>
<string name="ping">Ping</string>
<string name="relayed_connection">Relayed connection (%1$s)</string>
<string name="direct_connection">Direct connection</string>
<string name="pinging_node_name">Pinging %1$s</string>
<string name="pingFailed">Ping failed</string>
<string name="an_unknown_error_occurred_please_try_again">An unknown error occurred. Please try again.</string>
<string name="request_timed_out_make_sure_that_is_online">Request timed out. Make sure that \'%1$s\' is online.</string>
</resources>

Loading…
Cancel
Save