android/ui: add mdm key expiry notification window (#365)

Updates tailscale/corp#19743

Adjust the key expiry window and it's related notification based
on the keyExpiry MDM setting.  Default remains 24 hours.  Logic
moved to the viewModel.

unitTest package added.  It's a start!

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
pull/368/head
Jonathan Nobels 2 years ago committed by GitHub
parent a2471d38cb
commit 17ad0c8cc0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -85,6 +85,7 @@ dependencies {
// Kotlin dependencies. // Kotlin dependencies.
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0"
implementation 'junit:junit:4.12'
runtimeOnly "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0" runtimeOnly "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
@ -92,7 +93,6 @@ dependencies {
// Compose dependencies. // Compose dependencies.
def composeBom = platform('androidx.compose:compose-bom:2023.06.01') def composeBom = platform('androidx.compose:compose-bom:2023.06.01')
implementation composeBom implementation composeBom
androidTestImplementation composeBom
implementation 'androidx.compose.material3:material3:1.2.1' implementation 'androidx.compose.material3:material3:1.2.1'
implementation 'androidx.compose.material:material-icons-core:1.6.3' implementation 'androidx.compose.material:material-icons-core:1.6.3'
implementation "androidx.compose.ui:ui:1.6.3" implementation "androidx.compose.ui:ui:1.6.3"
@ -115,18 +115,22 @@ dependencies {
// Tailscale dependencies. // Tailscale dependencies.
implementation ':libtailscale@aar' implementation ':libtailscale@aar'
// Tests // Integration Tests
testImplementation 'junit:junit:4.13.2' androidTestImplementation composeBom
androidTestImplementation 'androidx.test:runner:1.5.2' androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5' androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
implementation 'androidx.test.uiautomator:uiautomator:2.3.0' implementation 'androidx.test.uiautomator:uiautomator:2.3.0'
// Authentication only for tests // Authentication only for tests
androidTestImplementation 'dev.turingcomplete:kotlin-onetimepassword:2.4.0' androidTestImplementation 'dev.turingcomplete:kotlin-onetimepassword:2.4.0'
androidTestImplementation 'commons-codec:commons-codec:1.16.1' androidTestImplementation 'commons-codec:commons-codec:1.16.1'
// Unit Tests
testImplementation 'junit:junit:4.13.2'
debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.ui:ui-tooling-preview")
} }
@ -135,8 +139,8 @@ def getLocalProperty(key) {
try { try {
Properties properties = new Properties() Properties properties = new Properties()
properties.load(project.file('local.properties').newDataInputStream()) properties.load(project.file('local.properties').newDataInputStream())
return properties.getProperty(key); return properties.getProperty(key)
} catch(Throwable ignored) { } catch(Throwable ignored) {
return "" return ""
} }
} }

@ -3,12 +3,16 @@
package com.tailscale.ipn.ui.util package com.tailscale.ipn.ui.util
import android.util.Log
import com.tailscale.ipn.R import com.tailscale.ipn.R
import java.time.Duration
import java.time.Instant import java.time.Instant
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Date import java.util.Date
object TimeUtil { object TimeUtil {
val TAG = "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)
@ -70,10 +74,51 @@ object TimeUtil {
return Date.from(i) return Date.from(i)
} }
// Returns true if the given time is within 24 hours from now or in the past. // Returns true if the given Go time string is in the past, or will occur within the given
fun isWithin24Hours(goTime: String): Boolean { // duration from now.
fun isWithinExpiryNotificationWindow(window: Duration, goTime: String): Boolean {
val expTime = epochMillisFromGoTime(goTime) val expTime = epochMillisFromGoTime(goTime)
val now = Instant.now().toEpochMilli() val now = Instant.now().toEpochMilli()
return (expTime - now) / 1000 < 86400 return (expTime - now) / 1000 < window.seconds
}
// Parses a Go duration string (e.g. "2h3.2m4s") and returns a Java Duration object.
// Returns null if the input string is not a valid Go duration or contains
// units other than y,w,d,h,m,s (ms and us are explicitly not supported).
fun duration(goDuration: String): Duration? {
if (goDuration.contains("ms") || goDuration.contains("us")) {
return null
}
var duration = 0.0
var valStr = ""
for (c in goDuration) {
// Scan digits and decimal points
if (c.isDigit() || c == '.') {
valStr += c
} else {
try {
val durationFragment = valStr.toDouble()
duration +=
when (c) {
'y' -> durationFragment * 31536000.0 // 365 days
'w' -> durationFragment * 604800.0
'd' -> durationFragment * 86400.0
'h' -> durationFragment * 3600.0
'm' -> durationFragment * 60.0
's' -> durationFragment
else -> {
Log.e(TAG, "Invalid duration string: $goDuration")
return null
}
}
} catch (e: NumberFormatException) {
Log.e(TAG, "Invalid duration string: $goDuration")
return null
}
valStr = ""
}
}
return Duration.ofSeconds(duration.toLong())
} }
} }

@ -85,7 +85,6 @@ 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
@ -110,24 +109,25 @@ fun MainView(
Column( Column(
modifier = Modifier.fillMaxWidth().padding(paddingInsets), modifier = Modifier.fillMaxWidth().padding(paddingInsets),
verticalArrangement = Arrangement.Center) { verticalArrangement = Arrangement.Center) {
val state = viewModel.ipnState.collectAsState(initial = Ipn.State.NoState).value val state by viewModel.ipnState.collectAsState(initial = Ipn.State.NoState)
val user = viewModel.loggedInUser.collectAsState(initial = null).value val user by viewModel.loggedInUser.collectAsState(initial = null)
val stateVal = viewModel.stateRes.collectAsState(initial = R.string.placeholder).value val stateVal by viewModel.stateRes.collectAsState(initial = R.string.placeholder)
val stateStr = stringResource(id = stateVal) val stateStr = stringResource(id = stateVal)
val netmap = viewModel.netmap.collectAsState(initial = null).value val netmap by viewModel.netmap.collectAsState(initial = null)
val showExitNodePicker = MDMSettings.exitNodesPicker.flow.collectAsState().value val showExitNodePicker by MDMSettings.exitNodesPicker.flow.collectAsState()
val allowToggle = MDMSettings.forceEnabled.flow.collectAsState().value val disableToggle by MDMSettings.forceEnabled.flow.collectAsState(initial = true)
val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false)
ListItem( ListItem(
colors = MaterialTheme.colorScheme.surfaceContainerListItem, colors = MaterialTheme.colorScheme.surfaceContainerListItem,
leadingContent = { leadingContent = {
TintedSwitch( TintedSwitch(
onCheckedChange = { onCheckedChange = {
if (allowToggle) { if (!disableToggle) {
viewModel.toggleVpn() viewModel.toggleVpn()
} }
}, },
enabled = !allowToggle, enabled = !disableToggle,
checked = isOn.value) checked = isOn.value)
}, },
headlineContent = { headlineContent = {
@ -166,7 +166,9 @@ fun MainView(
PromptPermissionsIfNecessary() PromptPermissionsIfNecessary()
ExpiryNotificationIfNecessary(netmap = netmap, action = { viewModel.login() }) if (showKeyExpiry) {
ExpiryNotification(netmap = netmap, action = { viewModel.login() })
}
if (showExitNodePicker == ShowHide.Show) { if (showExitNodePicker == ShowHide.Show) {
ExitNodeStatus( ExitNodeStatus(
@ -397,6 +399,7 @@ fun PeerList(
val searchTermStr by viewModel.searchTerm.collectAsState(initial = "") val searchTermStr by viewModel.searchTerm.collectAsState(initial = "")
val showNoResults = val showNoResults =
remember { derivedStateOf { searchTermStr.isNotEmpty() && peerList.value.isEmpty() } }.value remember { derivedStateOf { searchTermStr.isNotEmpty() && peerList.value.isEmpty() } }.value
val netmap = viewModel.netmap.collectAsState() val netmap = viewModel.netmap.collectAsState()
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
@ -505,13 +508,8 @@ fun PeerList(
} }
@Composable @Composable
fun ExpiryNotificationIfNecessary(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) { fun ExpiryNotification(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) {
// Key expiry warning shown only if the key is expiring within 24 hours (or has already expired) if (netmap == null) return
val networkMap = netmap ?: return
if (!TimeUtil.isWithin24Hours(networkMap.SelfNode.KeyExpiry) ||
networkMap.SelfNode.keyDoesNotExpire) {
return
}
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainer)) { Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainer)) {
Box( Box(
@ -524,7 +522,7 @@ fun ExpiryNotificationIfNecessary(netmap: Netmap.NetworkMap?, action: () -> Unit
colors = MaterialTheme.colorScheme.warningListItem, colors = MaterialTheme.colorScheme.warningListItem,
headlineContent = { headlineContent = {
Text( Text(
networkMap.SelfNode.expiryLabel(), netmap.SelfNode.expiryLabel(),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
) )
}, },

@ -6,14 +6,17 @@ package com.tailscale.ipn.ui.viewModel
import android.util.Log import android.util.Log
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.model.Ipn.State import com.tailscale.ipn.ui.model.Ipn.State
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.PeerCategorizer import com.tailscale.ipn.ui.util.PeerCategorizer
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.set import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Duration
class MainViewModel : IpnViewModel() { class MainViewModel : IpnViewModel() {
@ -35,6 +38,9 @@ class MainViewModel : IpnViewModel() {
// The active search term for filtering peers // The active search term for filtering peers
val searchTerm: StateFlow<String> = MutableStateFlow("") val searchTerm: StateFlow<String> = MutableStateFlow("")
// True if we should render the key expiry bannder
val showExpiry: StateFlow<Boolean> = MutableStateFlow(false)
private val peerCategorizer = PeerCategorizer() private val peerCategorizer = PeerCategorizer()
init { init {
@ -51,6 +57,18 @@ class MainViewModel : IpnViewModel() {
it?.let { netmap -> it?.let { netmap ->
peerCategorizer.regenerateGroupedPeers(netmap) peerCategorizer.regenerateGroupedPeers(netmap)
peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm.value)) peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm.value))
if (netmap.SelfNode.keyDoesNotExpire) {
showExpiry.set(false)
return@let
} else {
val expiryNotificationWindowMDM = MDMSettings.keyExpirationNotice.flow.value
val window =
expiryNotificationWindowMDM?.let { TimeUtil.duration(it) } ?: Duration.ofHours(24)
val expiresSoon =
TimeUtil.isWithinExpiryNotificationWindow(window, it.SelfNode.KeyExpiry)
showExpiry.set(expiresSoon)
}
} }
} }
} }

@ -0,0 +1,30 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package android.util;
/**
* This is a mock class for the android.util.Log class. It is used to print log messages to the console.
*/
public class Log {
public static int d(String tag, String msg) {
System.out.println("DEBUG: " + tag + ": " + msg);
return 0;
}
public static int i(String tag, String msg) {
System.out.println("INFO: " + tag + ": " + msg);
return 0;
}
public static int w(String tag, String msg) {
System.out.println("WARN: " + tag + ": " + msg);
return 0;
}
public static int e(String tag, String msg) {
System.out.println("ERROR: " + tag + ": " + msg);
return 0;
}
}

@ -0,0 +1,60 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailcale.ipn.ui.util
import com.tailscale.ipn.ui.util.TimeUtil
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.time.Duration
class TimeUtilTest {
@Test
fun durationInvalidMsUnits() {
val input = "5s10ms"
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
}
@Test
fun durationInvalidUsUnits() {
val input = "5s10us"
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
}
@Test
fun durationTestHappyPath() {
val input = arrayOf("1.0y1.0w1.0d1.0h1.0m1.0s", "1s", "1m", "1h", "1d", "1w", "1y")
val expectedSeconds =
arrayOf((31536000 + 604800 + 86400 + 3600 + 60 + 1), 1, 60, 3600, 86400, 604800, 31536000)
val expected = expectedSeconds.map { Duration.ofSeconds(it.toLong()) }
val actual = input.map { TimeUtil.duration(it) }
assertEquals("Incorrect conversion", expected, actual)
}
@Test
fun testBadDurationString() {
val input = "1..0y1.0w1.0d1.0h1.0m1.0s"
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
}
@Test
fun testBadDInputString() {
val input = "1.0yy1.0w1.0d1.0h1.0m1.0s"
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
}
@Test
fun testIgnoreFractionalSeconds() {
val input = "10.9s"
val expectedSeconds = 10
val expected = Duration.ofSeconds(expectedSeconds.toLong())
val actual = TimeUtil.duration(input)
assertEquals("Should return $expectedSeconds seconds", expected, actual)
}
}
Loading…
Cancel
Save