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.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.off
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 kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import java.util.Date
class Tailcfg {
@Serializable
@ -81,8 +83,8 @@ class Tailcfg {
var Online: Boolean? = null,
var Capabilities: List<String>? = null,
var CapMap: Map<String, JsonElement?>? = null,
var ComputedName: String,
var ComputedNameWithHost: String
var ComputedName: String?,
var ComputedNameWithHost: String?
) {
val isAdmin: Boolean
get() =
@ -97,7 +99,7 @@ class Tailcfg {
get() = Name.endsWith(".mullvad.ts.net.")
val displayName: String
get() = ComputedName ?: ""
get() = ComputedName ?: Name
fun connectedOrSelfNode(nm: Netmap.NetworkMap?) =
Online == true || StableID == nm?.SelfNode?.StableID
@ -127,9 +129,20 @@ class Tailcfg {
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
}
@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

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

@ -200,6 +200,23 @@ val ColorScheme.primaryListItem: ListItemColors
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. */
@OptIn(ExperimentalMaterial3Api::class)
val ColorScheme.topAppBar: TopAppBarColors

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

@ -8,23 +8,40 @@ import java.time.Instant
import java.time.format.DateTimeFormatter
import java.util.Date
class TimeUtil {
object TimeUtil {
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 ComposableStringFormatter(R.string.expired)
}
var diff = (expTime - now) / 1000
// 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.
// 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) {
in 0..60 -> ComposableStringFormatter(R.string.under_a_minute)
in 61..7200 ->
@ -52,4 +69,11 @@ class TimeUtil {
val i = Instant.from(ta)
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.ui.model.Ipn
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.Permissions
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.short
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.Lists
import com.tailscale.ipn.ui.util.LoadingIndicator
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.itemsWithDividers
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 stateVal = viewModel.stateRes.collectAsState(initial = R.string.placeholder).value
val stateStr = stringResource(id = stateVal)
val netmap = viewModel.netmap.collectAsState(initial = null)
ListItem(
colors = MaterialTheme.colorScheme.surfaceContainerListItem,
@ -139,6 +143,9 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode
PromptPermissionsIfNecessary(permissions = Permissions.all)
ExpiryNotificationIfNeccessary(
netmap = netmap.value, action = { viewModel.login {} })
ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
PeerList(
@ -322,10 +329,10 @@ fun PeerList(
OutlinedTextField(
modifier =
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 },
singleLine = true,
shape = MaterialTheme.shapes.large,
shape = MaterialTheme.shapes.extraLarge,
colors = MaterialTheme.colorScheme.searchBarColors,
leadingIcon = { Icon(imageVector = Icons.Outlined.Search, contentDescription = "search") },
trailingIcon = {
@ -390,7 +397,7 @@ fun PeerList(
color = peer.connectedColor(netmap.value),
shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(8.dp))
Text(text = peer.ComputedName, style = MaterialTheme.typography.titleMedium)
Text(text = peer.displayName, style = MaterialTheme.typography.titleMedium)
}
},
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)
@Composable
fun PromptPermissionsIfNecessary(permissions: List<Permission>) {

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

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

@ -52,6 +52,11 @@
<string name="connect_to_tailnet_suffix">" tailnet."</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="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 -->
<string name="os">OS</string>
@ -81,6 +86,12 @@
<string name="in_x_months">in %d months</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 -->
<string name="user_switcher">Accounts</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="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>

Loading…
Cancel
Save