From 4df18951a6831b535c6f56d37afec3929082b6ad Mon Sep 17 00:00:00 2001 From: Jonathan Nobels Date: Thu, 14 Mar 2024 17:34:41 -0400 Subject: [PATCH] android/ui: fix time formatting strings and main view states (#204) * android: fix time display localizations and show magic dns name Updates tailscale/corp#18202 Localizations and some simplifications of the "in x time" conversion strings for node expiry. We'll also now render the magicDNS name in the list of addresses. Signed-off-by: Jonathan Nobels * android: move the composablestringformatter to it's own file Updates tailscale/corp#18202 This class deserves it's own file and some documentation Signed-off-by: Jonathan Nobels * android: show selfNode as connected only when it is connected Updates tailscale/corp#18202 The selfNode connected state is now properly shown in the nodes list now that we're showing the nodes even when you're not connected to your tailnet. Signed-off-by: Jonathan Nobels --------- Signed-off-by: Jonathan Nobels --- .../ipn/ui/util/ComposableStringFormatter.kt | 21 ++++++++++++ .../tailscale/ipn/ui/util/DisplayAddress.kt | 18 +++++++++-- .../java/com/tailscale/ipn/ui/util/Styles.kt | 4 ++- .../com/tailscale/ipn/ui/util/TimeUtil.kt | 26 ++++++++------- .../com/tailscale/ipn/ui/view/MainView.kt | 32 ++++++++++++------- .../com/tailscale/ipn/ui/view/PeerDetails.kt | 6 ++-- .../ipn/ui/viewModel/MainViewModel.kt | 9 ++---- .../ipn/ui/viewModel/PeerDetailsViewModel.kt | 11 +++++-- android/src/main/res/values/strings.xml | 12 ++++--- 9 files changed, 98 insertions(+), 41 deletions(-) create mode 100644 android/src/main/java/com/tailscale/ipn/ui/util/ComposableStringFormatter.kt diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/ComposableStringFormatter.kt b/android/src/main/java/com/tailscale/ipn/ui/util/ComposableStringFormatter.kt new file mode 100644 index 0000000..d6eed6d --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/util/ComposableStringFormatter.kt @@ -0,0 +1,21 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.util + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.tailscale.ipn.R + + +// Convenience wrapper for passing formatted strings to Composables +class ComposableStringFormatter(@StringRes val stringRes: Int = R.string.template, private vararg val params: Any) { + + // Convenience constructor for passing a non-formatted string directly + constructor(string: String) : this(stringRes = R.string.template, string) + + // Returns the fully formatted string + @Composable + fun getString(): String = stringResource(id = stringRes, *params) +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/DisplayAddress.kt b/android/src/main/java/com/tailscale/ipn/ui/util/DisplayAddress.kt index 5177806..4eae891 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/DisplayAddress.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/DisplayAddress.kt @@ -10,8 +10,8 @@ class DisplayAddress(val ip: String) { } val type: addrType = when { - ip.contains(":") -> addrType.V6 - ip.contains(".") -> addrType.V4 + ip.isIPV6() -> addrType.V6 + ip.isIPV4() -> addrType.V4 else -> addrType.MagicDNS } @@ -25,4 +25,18 @@ class DisplayAddress(val ip: String) { addrType.MagicDNS -> ip else -> ip.split("/").first() } +} + +fun String.isIPV6(): Boolean { + return this.contains(":") +} + +fun String.isIPV4(): Boolean { + val parts = this.split("/").first().split(".") + if (parts.size != 4) return false + for (part in parts) { + val value = part.toIntOrNull() ?: return false + if (value !in 0..255) return false + } + return true } \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/Styles.kt b/android/src/main/java/com/tailscale/ipn/ui/util/Styles.kt index 17f8be2..64db621 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/Styles.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/Styles.kt @@ -3,6 +3,7 @@ package com.tailscale.ipn.ui.util +import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -11,8 +12,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp - +import com.tailscale.ipn.R @Composable fun settingsRowModifier(): Modifier { diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/TimeUtil.kt b/android/src/main/java/com/tailscale/ipn/ui/util/TimeUtil.kt index b999a36..c864a4a 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/TimeUtil.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/TimeUtil.kt @@ -3,34 +3,36 @@ package com.tailscale.ipn.ui.util +import com.tailscale.ipn.R import java.time.Instant import java.time.format.DateTimeFormatter import java.util.Date class TimeUtil { - fun keyExpiryFromGoTime(goTime: String?): String { - val time = goTime ?: return "" + fun keyExpiryFromGoTime(goTime: String?): ComposableStringFormatter { + + val time = goTime ?: return ComposableStringFormatter(R.string.empty) val expTime = epochMillisFromGoTime(time) val now = Instant.now().toEpochMilli() val diff = (expTime - now) / 1000 if (diff < 0) { - return "expired" + return ComposableStringFormatter(R.string.expired) } - // (jonathan) TODO: This is incorrect in a couple of ways - // - It needs to be in a composable so we can use stringResource - // - The string resources need to be proper plurals + // Rather than use plurals here, we'll just use the singular form for everything and + // double the minimum. "in 70 minutes" instead of "in 1 hour". 121 minutes becomes + // 2 hours, as does 179 minutes... Close enough for what this is used for. return when (diff) { - in 0..60 -> "under a minute" - in 61..3600 -> "in ${diff / 60} minutes" - in 3601..86400 -> "in ${diff / 3600} hours" - in 86401..2592000 -> "in ${diff / 86400} days" - in 2592001..31536000 -> "in ${diff / 2592000} months" - else -> "in ${diff / 31536000} years" + in 0..60 -> ComposableStringFormatter(R.string.under_a_minute) + in 61..7200 -> ComposableStringFormatter(R.string.in_x_minutes, diff / 60) // 1 minute to 1 hour + in 7201..172800 -> ComposableStringFormatter(R.string.in_x_hours, diff / 3600) // 2 hours to 24 hours + in 172801..5184000 -> ComposableStringFormatter(R.string.in_x_days, diff / 86400) // 2 Days to 60 days + in 5184001..124416000 -> ComposableStringFormatter(R.string.in_x_months, diff / 2592000) // ~2 months to 2 years + else -> ComposableStringFormatter(R.string.in_x_years, diff.toDouble() / 31536000.0) // 2 years to n years (in decimal) } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt index d56d0c6..4cedb91 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.unit.dp import com.tailscale.ipn.R import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.IpnLocal +import com.tailscale.ipn.ui.model.StableNodeID import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.viewModel.MainViewModel @@ -83,17 +84,18 @@ fun MainView(viewModel: MainViewModel, navigation: MainViewNavigation) { } } - // (jonathan) TODO: Show the selected exit node name here. - if (state.value == Ipn.State.Running) { - ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, stringResource(id = R.string.none)) - } when (state.value) { - Ipn.State.Running -> PeerList( - searchTerm = viewModel.searchTerm, - peers = viewModel.peers, - onNavigateToPeerDetails = navigation.onNavigateToPeerDetails, - onSearch = { viewModel.searchPeers(it) }) + Ipn.State.Running -> { + ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, stringResource(id = R.string.none)) + PeerList( + searchTerm = viewModel.searchTerm, + state = viewModel.ipnState, + peers = viewModel.peers, + selfPeer = viewModel.selfPeerId, + onNavigateToPeerDetails = navigation.onNavigateToPeerDetails, + onSearch = { viewModel.searchPeers(it) }) + } Ipn.State.Starting -> StartingView() else -> @@ -101,6 +103,7 @@ fun MainView(viewModel: MainViewModel, navigation: MainViewNavigation) { user.value, { viewModel.toggleVpn() }, { viewModel.login() } + ) } } @@ -199,10 +202,16 @@ fun ConnectView(user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAc @OptIn(ExperimentalMaterial3Api::class) @Composable -fun PeerList(searchTerm: StateFlow, peers: StateFlow>, onNavigateToPeerDetails: (Tailcfg.Node) -> Unit, onSearch: (String) -> Unit) { +fun PeerList(searchTerm: StateFlow, + peers: StateFlow>, + state: StateFlow, + selfPeer: StableNodeID, + onNavigateToPeerDetails: (Tailcfg.Node) -> Unit, + onSearch: (String) -> Unit) { val peerList = peers.collectAsState(initial = emptyList()) var searching = false val searchTermStr by searchTerm.collectAsState(initial = "") + val stateVal = state.collectAsState(initial = Ipn.State.NoState) SearchBar( query = searchTermStr, @@ -239,7 +248,8 @@ fun PeerList(searchTerm: StateFlow, peers: StateFlow>, onN headlineContent = { Row(verticalAlignment = Alignment.CenterVertically) { // By definition, SelfPeer is online since we will not show the peer list unless you're connected. - val color: Color = if ((peer.Online == true)) { + val isSelfAndRunning = (peer.StableID == selfPeer && stateVal.value == Ipn.State.Running) + val color: Color = if ((peer.Online == true) || isSelfAndRunning) { Color.Green } else { Color.Gray diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt b/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt index 21efb82..503c39e 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt @@ -37,7 +37,9 @@ import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel fun PeerDetails(viewModel: PeerDetailsViewModel) { Surface(color = MaterialTheme.colorScheme.surface) { - Column(modifier = Modifier.padding(horizontal = 8.dp).fillMaxHeight()) { + Column(modifier = Modifier + .padding(horizontal = 8.dp) + .fillMaxHeight()) { Column(modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp), @@ -75,7 +77,7 @@ fun PeerDetails(viewModel: PeerDetailsViewModel) { Column(modifier = settingsRowModifier()) { viewModel.info.forEach { - ValueRow(title = stringResource(id = it.titleRes), value = it.value) + ValueRow(title = stringResource(id = it.titleRes), value = it.value.getString()) } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt index a05ed0c..69231e0 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt @@ -8,7 +8,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.tailscale.ipn.R import com.tailscale.ipn.ui.model.Ipn.State -import com.tailscale.ipn.ui.model.StableNodeID import com.tailscale.ipn.ui.service.IpnActions import com.tailscale.ipn.ui.service.IpnModel import com.tailscale.ipn.ui.service.set @@ -38,11 +37,9 @@ class MainViewModel(val model: IpnModel, val actions: IpnActions) : ViewModel() // The active search term for filtering peers val searchTerm: StateFlow = MutableStateFlow("") - - // The current peer ID - val selfPeerId: StableNodeID - get() = model.netmap.value?.SelfNode?.StableID ?: "" - + // The peerID of the local node + val selfPeerId = model.netmap.value?.SelfNode?.StableID ?: "" + val peerCategorizer = PeerCategorizer(model, viewModelScope) init { diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt index 4d0431b..2651dcf 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt @@ -8,10 +8,12 @@ import androidx.lifecycle.ViewModel import com.tailscale.ipn.R import com.tailscale.ipn.ui.model.StableNodeID import com.tailscale.ipn.ui.service.IpnModel +import com.tailscale.ipn.ui.util.ComposableStringFormatter import com.tailscale.ipn.ui.util.DisplayAddress import com.tailscale.ipn.ui.util.TimeUtil -data class PeerSettingInfo(val titleRes: Int, val value: String) +data class PeerSettingInfo(val titleRes: Int, val value: ComposableStringFormatter) + class PeerDetailsViewModel(val model: IpnModel, val nodeId: StableNodeID) : ViewModel() { @@ -30,9 +32,14 @@ class PeerDetailsViewModel(val model: IpnModel, val nodeId: StableNodeID) : View } } + peer?.Name?.let { + addresses = listOf(DisplayAddress(it)) + addresses + } + + peer?.let { p -> info = listOf( - PeerSettingInfo(R.string.os, p.Hostinfo?.OS ?: ""), + PeerSettingInfo(R.string.os, ComposableStringFormatter(p.Hostinfo.OS ?: "")), PeerSettingInfo(R.string.key_expiry, TimeUtil().keyExpiryFromGoTime(p.KeyExpiry)) ) } diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index f4d265f..17e67a5 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -9,6 +9,8 @@ Unknown User Connected Not Connected + + %s Tailscale @@ -62,10 +64,10 @@ expired under a minute - in %1$s minutes - in %1$s hours - in %1$s days - in %1$s months - in %1$s years + in %d minutes + in %d hours + in %d days + in %d months + in %.1f years