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 <jonathan@tailscale.com>

* 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 <jonathan@tailscale.com>

* 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 <jonathan@tailscale.com>

---------

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
pull/207/head
Jonathan Nobels 2 months ago committed by GitHub
parent 2c694b7159
commit 4df18951a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

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

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

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

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

@ -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<String>, peers: StateFlow<List<PeerSet>>, onNavigateToPeerDetails: (Tailcfg.Node) -> Unit, onSearch: (String) -> Unit) {
fun PeerList(searchTerm: StateFlow<String>,
peers: StateFlow<List<PeerSet>>,
state: StateFlow<Ipn.State>,
selfPeer: StableNodeID,
onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
onSearch: (String) -> Unit) {
val peerList = peers.collectAsState(initial = emptyList<PeerSet>())
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<String>, peers: StateFlow<List<PeerSet>>, 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

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

@ -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<String> = 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 {

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

@ -9,6 +9,8 @@
<string name="unknown_user">Unknown User</string>
<string name="connected">Connected</string>
<string name="not_connected">Not Connected</string>
<string name="empty"> </string>
<string name="template">%s</string>
<!-- Strings for the about screen -->
<string name="app_name">Tailscale</string>
@ -62,10 +64,10 @@
<!-- Time conversion templates -->
<string name="expired">expired</string>
<string name="under_a_minute">under a minute</string>
<string name="in_x_minutes">in %1$s minutes</string>
<string name="in_x_hours">in %1$s hours</string>
<string name="in_x_days">in %1$s days</string>
<string name="in_x_months">in %1$s months</string>
<string name="in_x_years">in %1$s years</string>
<string name="in_x_minutes">in %d minutes</string>
<string name="in_x_hours">in %d hours</string>
<string name="in_x_days">in %d days</string>
<string name="in_x_months">in %d months</string>
<string name="in_x_years">in %.1f years</string>
</resources>

Loading…
Cancel
Save