android/ui: add key expiry banner (#276)

fixes ENG-2912

copies and adds the a key expiry banner identical to the one on iOS.

fixes a couple of small layout issues with the search bar

fixes a potential json issue where ComputedName is optional in goland but it was not marked as so in Kotlin.  Switched to node.displayName everywhere, which uses ComputedName otherwise, Name.

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

@ -5,6 +5,7 @@ package com.tailscale.ipn.ui.model
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.off import com.tailscale.ipn.ui.theme.off
import com.tailscale.ipn.ui.theme.on import com.tailscale.ipn.ui.theme.on
@ -14,6 +15,7 @@ import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.ui.viewModel.PeerSettingInfo import com.tailscale.ipn.ui.viewModel.PeerSettingInfo
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import java.util.Date
class Tailcfg { class Tailcfg {
@Serializable @Serializable
@ -81,8 +83,8 @@ class Tailcfg {
var Online: Boolean? = null, var Online: Boolean? = null,
var Capabilities: List<String>? = null, var Capabilities: List<String>? = null,
var CapMap: Map<String, JsonElement?>? = null, var CapMap: Map<String, JsonElement?>? = null,
var ComputedName: String, var ComputedName: String?,
var ComputedNameWithHost: String var ComputedNameWithHost: String?
) { ) {
val isAdmin: Boolean val isAdmin: Boolean
get() = get() =
@ -97,7 +99,7 @@ class Tailcfg {
get() = Name.endsWith(".mullvad.ts.net.") get() = Name.endsWith(".mullvad.ts.net.")
val displayName: String val displayName: String
get() = ComputedName ?: "" get() = ComputedName ?: Name
fun connectedOrSelfNode(nm: Netmap.NetworkMap?) = fun connectedOrSelfNode(nm: Netmap.NetworkMap?) =
Online == true || StableID == nm?.SelfNode?.StableID Online == true || StableID == nm?.SelfNode?.StableID
@ -127,9 +129,20 @@ class Tailcfg {
PeerSettingInfo(R.string.os, ComposableStringFormatter(Hostinfo.OS!!)), PeerSettingInfo(R.string.os, ComposableStringFormatter(Hostinfo.OS!!)),
) )
} }
result.add(PeerSettingInfo(R.string.key_expiry, TimeUtil().keyExpiryFromGoTime(KeyExpiry))) result.add(PeerSettingInfo(R.string.key_expiry, TimeUtil.keyExpiryFromGoTime(KeyExpiry)))
return result return result
} }
@Composable
fun expiryLabel(): String {
if (KeyExpiry == GoZeroTimeString) {
return stringResource(R.string.deviceKeyNeverExpires)
}
val expDate = TimeUtil.dateFromGoString(KeyExpiry)
val template = if (expDate > Date()) R.string.deviceKeyExpires else R.string.deviceKeyExpired
return stringResource(template, TimeUtil.keyExpiryFromGoTime(KeyExpiry).getString())
}
} }
@Serializable @Serializable

@ -23,6 +23,8 @@ typealias StableNodeID = String
typealias BugReportID = String typealias BugReportID = String
val GoZeroTimeString = "0001-01-01T00:00:00Z"
// Represents and empty message with a single 'property' field. // Represents and empty message with a single 'property' field.
class Empty { class Empty {
@Serializable data class Message(val property: String = "") @Serializable data class Message(val property: String = "")

@ -200,6 +200,23 @@ val ColorScheme.primaryListItem: ListItemColors
disabledTrailingIconColor = default.disabledTrailingIconColor) disabledTrailingIconColor = default.disabledTrailingIconColor)
} }
/** Color scheme for list items that should be styled as a warning item. */
val ColorScheme.warningListItem: ListItemColors
@Composable
get() {
val default = ListItemDefaults.colors()
return ListItemColors(
containerColor = MaterialTheme.colorScheme.warning,
headlineColor = MaterialTheme.colorScheme.onPrimary,
leadingIconColor = MaterialTheme.colorScheme.onPrimary,
overlineColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
supportingTextColor = MaterialTheme.colorScheme.onPrimary,
trailingIconColor = MaterialTheme.colorScheme.onPrimary,
disabledHeadlineColor = default.disabledHeadlineColor,
disabledLeadingIconColor = default.disabledLeadingIconColor,
disabledTrailingIconColor = default.disabledTrailingIconColor)
}
/** Main color scheme for top app bar, styles it as a surface container. */ /** Main color scheme for top app bar, styles it as a surface container. */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
val ColorScheme.topAppBar: TopAppBarColors val ColorScheme.topAppBar: TopAppBarColors

@ -79,7 +79,7 @@ class PeerCategorizer {
} }
val matchingPeers = val matchingPeers =
peers.filter { it.ComputedName.contains(searchTerm, ignoreCase = true) } peers.filter { it.displayName.contains(searchTerm, ignoreCase = true) }
if (matchingPeers.isNotEmpty()) { if (matchingPeers.isNotEmpty()) {
PeerSet(user, matchingPeers) PeerSet(user, matchingPeers)
} else { } else {

@ -8,23 +8,40 @@ import java.time.Instant
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Date import java.util.Date
class TimeUtil { object TimeUtil {
fun keyExpiryFromGoTime(goTime: String?): ComposableStringFormatter { fun keyExpiryFromGoTime(goTime: String?): ComposableStringFormatter {
val time = goTime ?: return ComposableStringFormatter(R.string.empty) val time = goTime ?: return ComposableStringFormatter(R.string.empty)
val expTime = epochMillisFromGoTime(time) val expTime = epochMillisFromGoTime(time)
val now = Instant.now().toEpochMilli() val now = Instant.now().toEpochMilli()
val diff = (expTime - now) / 1000 var diff = (expTime - now) / 1000
if (diff < 0) {
return ComposableStringFormatter(R.string.expired)
}
// Rather than use plurals here, we'll just use the singular form for everything and // 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 // 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. // 2 hours, as does 179 minutes... Close enough for what this is used for.
// Key is already expired (x minutes ago)
if (diff < 0) {
diff = -diff
return when (diff) {
in 0..60 -> ComposableStringFormatter(R.string.under_a_minute)
in 61..7200 ->
ComposableStringFormatter(R.string.ago_x_minutes, diff / 60) // 1 minute to 1 hour
in 7201..172800 ->
ComposableStringFormatter(R.string.ago_x_hours, diff / 3600) // 2 hours to 24 hours
in 172801..5184000 ->
ComposableStringFormatter(R.string.ago_x_days, diff / 86400) // 2 Days to 60 days
in 5184001..124416000 ->
ComposableStringFormatter(R.string.ago_x_months, diff / 2592000) // ~2 months to 2 years
else ->
ComposableStringFormatter(
R.string.ago_x_years,
diff.toDouble() / 31536000.0) // 2 years to n years (in decimal)
}
}
// Key is not expired (in x minutes)
return when (diff) { return when (diff) {
in 0..60 -> ComposableStringFormatter(R.string.under_a_minute) in 0..60 -> ComposableStringFormatter(R.string.under_a_minute)
in 61..7200 -> in 61..7200 ->
@ -52,4 +69,11 @@ class TimeUtil {
val i = Instant.from(ta) val i = Instant.from(ta)
return Date.from(i) return Date.from(i)
} }
// Returns true if the given time is within 24 hours from now or in the past.
fun isWithin24Hours(goTime: String): Boolean {
val expTime = epochMillisFromGoTime(goTime)
val now = Instant.now().toEpochMilli()
return (expTime - now) / 1000 < 86400
}
} }

@ -64,6 +64,7 @@ import com.google.accompanist.permissions.shouldShowRationale
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.model.Permission import com.tailscale.ipn.ui.model.Permission
import com.tailscale.ipn.ui.model.Permissions import com.tailscale.ipn.ui.model.Permissions
import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.model.Tailcfg
@ -75,10 +76,12 @@ import com.tailscale.ipn.ui.theme.searchBarColors
import com.tailscale.ipn.ui.theme.secondaryButton import com.tailscale.ipn.ui.theme.secondaryButton
import com.tailscale.ipn.ui.theme.short import com.tailscale.ipn.ui.theme.short
import com.tailscale.ipn.ui.theme.surfaceContainerListItem import com.tailscale.ipn.ui.theme.surfaceContainerListItem
import com.tailscale.ipn.ui.theme.warningListItem
import com.tailscale.ipn.ui.util.AutoResizingText import com.tailscale.ipn.ui.util.AutoResizingText
import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.ui.util.flag import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.util.itemsWithDividers import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.MainViewModel import com.tailscale.ipn.ui.viewModel.MainViewModel
@ -102,6 +105,7 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode
val user = viewModel.loggedInUser.collectAsState(initial = null).value val user = viewModel.loggedInUser.collectAsState(initial = null).value
val stateVal = viewModel.stateRes.collectAsState(initial = R.string.placeholder).value val stateVal = viewModel.stateRes.collectAsState(initial = R.string.placeholder).value
val stateStr = stringResource(id = stateVal) val stateStr = stringResource(id = stateVal)
val netmap = viewModel.netmap.collectAsState(initial = null)
ListItem( ListItem(
colors = MaterialTheme.colorScheme.surfaceContainerListItem, colors = MaterialTheme.colorScheme.surfaceContainerListItem,
@ -139,6 +143,9 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode
PromptPermissionsIfNecessary(permissions = Permissions.all) PromptPermissionsIfNecessary(permissions = Permissions.all)
ExpiryNotificationIfNeccessary(
netmap = netmap.value, action = { viewModel.login {} })
ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, viewModel = viewModel) ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
PeerList( PeerList(
@ -322,10 +329,10 @@ fun PeerList(
OutlinedTextField( OutlinedTextField(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 0.dp) .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 0.dp)
.onFocusChanged { isFocussed = it.isFocused }, .onFocusChanged { isFocussed = it.isFocused },
singleLine = true, singleLine = true,
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.extraLarge,
colors = MaterialTheme.colorScheme.searchBarColors, colors = MaterialTheme.colorScheme.searchBarColors,
leadingIcon = { Icon(imageVector = Icons.Outlined.Search, contentDescription = "search") }, leadingIcon = { Icon(imageVector = Icons.Outlined.Search, contentDescription = "search") },
trailingIcon = { trailingIcon = {
@ -390,7 +397,7 @@ fun PeerList(
color = peer.connectedColor(netmap.value), color = peer.connectedColor(netmap.value),
shape = RoundedCornerShape(percent = 50))) {} shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(8.dp))
Text(text = peer.ComputedName, style = MaterialTheme.typography.titleMedium) Text(text = peer.displayName, style = MaterialTheme.typography.titleMedium)
} }
}, },
supportingContent = { supportingContent = {
@ -405,6 +412,38 @@ fun PeerList(
} }
} }
@Composable
fun ExpiryNotificationIfNeccessary(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) {
// Key expiry warning shown only if the key is expiring within 24 hours (or has already expired)
val networkMap = netmap ?: return
if (!TimeUtil.isWithin24Hours(networkMap.SelfNode.KeyExpiry)) {
return
}
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainer)) {
Box(
modifier =
Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp)
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
.fillMaxWidth()) {
ListItem(
modifier = Modifier.clickable { action() },
colors = MaterialTheme.colorScheme.warningListItem,
headlineContent = {
Text(
networkMap.SelfNode.expiryLabel(),
style = MaterialTheme.typography.titleMedium,
)
},
supportingContent = {
Text(
stringResource(id = R.string.keyExpiryExplainer),
style = MaterialTheme.typography.bodyMedium)
})
}
}
}
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun PromptPermissionsIfNecessary(permissions: List<Permission>) { fun PromptPermissionsIfNecessary(permissions: List<Permission>) {

@ -54,7 +54,7 @@ fun PeerView(
.background(color = color, shape = RoundedCornerShape(percent = 50))) {} .background(color = color, shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(8.dp))
Text( Text(
text = peer.ComputedName, text = peer.displayName,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = textColor) color = textColor)
} }

@ -83,7 +83,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
.map { .map {
ExitNode( ExitNode(
id = it.StableID, id = it.StableID,
label = it.ComputedName, label = it.displayName,
online = it.Online ?: false, online = it.Online ?: false,
selected = it.StableID == exitNodeId, selected = it.StableID == exitNodeId,
mullvad = it.Name.endsWith(".mullvad.ts.net."), mullvad = it.Name.endsWith(".mullvad.ts.net."),

@ -52,6 +52,11 @@
<string name="connect_to_tailnet_suffix">" tailnet."</string> <string name="connect_to_tailnet_suffix">" tailnet."</string>
<string name="welcome_to_tailscale">Welcome to Tailscale</string> <string name="welcome_to_tailscale">Welcome to Tailscale</string>
<string name="login_to_join_your_tailnet">Log in to join your tailnet and connect your devices.</string> <string name="login_to_join_your_tailnet">Log in to join your tailnet and connect your devices.</string>
<string name="keyExpiryExplainer">Reauthenticate to remain connected to Tailscale.</string>
<string name="deviceKeyNeverExpires">Device key does not expire</string>
<string name="deviceKeyExpires">Device key expires %s</string>
<string name="deviceKeyExpired">Device key expired %s</string>
<!-- Strings for peer details --> <!-- Strings for peer details -->
<string name="os">OS</string> <string name="os">OS</string>
@ -81,6 +86,12 @@
<string name="in_x_months">in %d months</string> <string name="in_x_months">in %d months</string>
<string name="in_x_years">in %.1f years</string> <string name="in_x_years">in %.1f years</string>
<string name="ago_x_minutes">%d minutes ago</string>
<string name="ago_x_hours">%d hours ago</string>
<string name="ago_x_days">%d days ago</string>
<string name="ago_x_months">%d months ago</string>
<string name="ago_x_years">.1f years ago</string>
<!-- Strings for the user switcher --> <!-- Strings for the user switcher -->
<string name="user_switcher">Accounts</string> <string name="user_switcher">Accounts</string>
<string name="logout_failed">Unable to logout at this time. Please try again.</string> <string name="logout_failed">Unable to logout at this time. Please try again.</string>
@ -211,5 +222,4 @@
<string name="getStarted">Get started</string> <string name="getStarted">Get started</string>
<string name="welcome1">Tailscale is a mesh VPN for securely connecting your devices. All connections are device-to-device, so we never see your data.\n\nWe collect and use your email address and name, as well as your device name, OS version, and IP address in order to help you to connect your devices and manage your settings. We log when you are connected to your network.\n</string> <string name="welcome1">Tailscale is a mesh VPN for securely connecting your devices. All connections are device-to-device, so we never see your data.\n\nWe collect and use your email address and name, as well as your device name, OS version, and IP address in order to help you to connect your devices and manage your settings. We log when you are connected to your network.\n</string>
</resources> </resources>

Loading…
Cancel
Save