diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt b/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt index 77add10..778c59f 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt @@ -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? = null, var CapMap: Map? = 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 diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt index 284d264..fa9fbf2 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt @@ -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 = "") diff --git a/android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt b/android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt index 1b1fef8..5676eb4 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt @@ -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 diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt b/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt index e65568d..7b279d2 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt @@ -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 { 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 a9c56dd..db83bbf 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 @@ -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 + } } 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 4996486..2d09ce1 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 @@ -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) { diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/PeerView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/PeerView.kt index 3d6ace1..54587a1 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/PeerView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/PeerView.kt @@ -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) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt index 91c0ced..04c1e20 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt @@ -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."), diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 404bc12..42aa685 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -52,6 +52,11 @@ " tailnet." Welcome to Tailscale Log in to join your tailnet and connect your devices. + Reauthenticate to remain connected to Tailscale. + Device key does not expire + Device key expires %s + Device key expired %s + OS @@ -81,6 +86,12 @@ in %d months in %.1f years + %d minutes ago + %d hours ago + %d days ago + %d months ago + .1f years ago + Accounts Unable to logout at this time. Please try again. @@ -211,5 +222,4 @@ Get started 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 -