From 338c13b6b519e375b2047ac2f8e80293e57bf429 Mon Sep 17 00:00:00 2001 From: Andrea Gottardo Date: Thu, 8 Aug 2024 10:32:05 -0700 Subject: [PATCH] android: add HealthView (#458) Updates tailscale/tailscale#4136 This PR adds a proper health warnings viewer for the Android client, like we already do on iOS and macOS. A subtile info.circle or exclamation mark icon is displayed next to the connection status when one or more warnings are found. A detail view provides visibility into the full list. Signed-off-by: Andrea Gottardo --- .../java/com/tailscale/ipn/MainActivity.kt | 4 +- .../java/com/tailscale/ipn/ui/model/Health.kt | 1 + .../ipn/ui/notifier/HealthNotifier.kt | 16 +++ .../com/tailscale/ipn/ui/view/HealthView.kt | 99 +++++++++++++++++++ .../com/tailscale/ipn/ui/view/MainView.kt | 54 +++++----- .../ipn/ui/viewModel/HealthViewModel.kt | 23 +++++ .../ipn/ui/viewModel/MainViewModel.kt | 11 +-- android/src/main/res/drawable/info.xml | 5 + .../src/main/res/drawable/warning_rounded.xml | 5 + android/src/main/res/values/strings.xml | 3 + 10 files changed, 180 insertions(+), 41 deletions(-) create mode 100644 android/src/main/java/com/tailscale/ipn/ui/view/HealthView.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/viewModel/HealthViewModel.kt create mode 100644 android/src/main/res/drawable/info.xml create mode 100644 android/src/main/res/drawable/warning_rounded.xml diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index 9a34051..70d8491 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -49,6 +49,7 @@ import com.tailscale.ipn.ui.view.AboutView import com.tailscale.ipn.ui.view.BugReportView import com.tailscale.ipn.ui.view.DNSSettingsView import com.tailscale.ipn.ui.view.ExitNodePicker +import com.tailscale.ipn.ui.view.HealthView import com.tailscale.ipn.ui.view.IntroView import com.tailscale.ipn.ui.view.LoginQRView import com.tailscale.ipn.ui.view.LoginWithAuthKeyView @@ -157,7 +158,7 @@ class MainActivity : ComponentActivity() { navController.navigate("peerDetails/${it.StableID}") }, onNavigateToExitNodes = { navController.navigate("exitNodes") }, - ) + onNavigateToHealth = { navController.navigate("health") }) val settingsNav = SettingsNav( @@ -199,6 +200,7 @@ class MainActivity : ComponentActivity() { } composable("settings") { SettingsView(settingsNav) } composable("exitNodes") { ExitNodePicker(exitNodePickerNav) } + composable("health") { HealthView(backTo("main")) } composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) } composable("mullvad_info") { MullvadInfoView(exitNodePickerNav) } composable( diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Health.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Health.kt index 523d764..827be20 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/Health.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Health.kt @@ -25,6 +25,7 @@ class Health { var Text: String, var BrokenSince: String? = null, var Args: Map? = null, + var ImpactsConnectivity: Boolean? = false, var DependsOn: List? = null, // an array of WarnableCodes this depends on ) : Comparable { fun hiddenByDependencies(currentWarnableCodes: Set): Boolean { diff --git a/android/src/main/java/com/tailscale/ipn/ui/notifier/HealthNotifier.kt b/android/src/main/java/com/tailscale/ipn/ui/notifier/HealthNotifier.kt index f3614eb..c9ed6db 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/notifier/HealthNotifier.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/notifier/HealthNotifier.kt @@ -56,6 +56,7 @@ class HealthNotifier( } val currentWarnings: StateFlow> = MutableStateFlow(setOf()) + val currentIcon: StateFlow = MutableStateFlow(null) private fun notifyHealthUpdated(warnings: Array) { val warningsBeforeAdd = currentWarnings.value @@ -94,6 +95,21 @@ class HealthNotifier( this.removeNotifications(warningsToDrop) } currentWarnings.set(this.currentWarnings.value.subtract(warningsToDrop)) + this.updateIcon() + } + + private fun updateIcon() { + if (currentWarnings.value.isEmpty()) { + this.currentIcon.set(null) + return + } + if (currentWarnings.value.any { + (it.Severity == Health.Severity.high || it.ImpactsConnectivity == true) + }) { + this.currentIcon.set(R.drawable.warning_rounded) + } else { + this.currentIcon.set(R.drawable.info) + } } private fun sendNotification(title: String, text: String, code: String) { diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/HealthView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/HealthView.kt new file mode 100644 index 0000000..94f6bb0 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/HealthView.kt @@ -0,0 +1,99 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.view + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.tailscale.ipn.R +import com.tailscale.ipn.ui.model.Health +import com.tailscale.ipn.ui.theme.success +import com.tailscale.ipn.ui.viewModel.HealthViewModel + +@Composable +fun HealthView(backToSettings: BackNavigation, model: HealthViewModel = viewModel()) { + val warnings by model.warnings.collectAsState() + + Scaffold(topBar = { Header(titleRes = R.string.health_warnings, onBack = backToSettings) }) { + innerPadding -> + LazyColumn(modifier = Modifier.padding(innerPadding)) { + if (warnings.isEmpty()) { + item("allGood") { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.Top), + modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)) { + Icon( + painter = painterResource(id = R.drawable.check_circle), + modifier = Modifier.size(48.dp), + contentDescription = "A green checkmark", + tint = MaterialTheme.colorScheme.success) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = + Arrangement.spacedBy(2.dp, alignment = Alignment.CenterVertically), + modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.no_issues_found), + fontSize = MaterialTheme.typography.titleMedium.fontSize, + fontWeight = MaterialTheme.typography.titleMedium.fontWeight) + Text( + text = stringResource(R.string.tailscale_is_operating_normally), + color = MaterialTheme.colorScheme.secondary) + } + } + } + } + + items(warnings) { HealthWarningView(it) } + } + } +} + +@Composable +fun HealthWarningView(warning: Health.UnhealthyState) { + Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainerLow)) { + 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( + colors = warning.Severity.listItemColors(), + headlineContent = { + if (warning.Title.isNotEmpty()) { + Text( + warning.Title, + style = MaterialTheme.typography.titleMedium, + ) + } + }, + supportingContent = { + Text(warning.Text, style = MaterialTheme.typography.bodyMedium) + }) + } + } +} 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 8ba74e9..eead8ab 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 @@ -73,7 +73,6 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.tailscale.ipn.R import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.ShowHide -import com.tailscale.ipn.ui.model.Health import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.Netmap @@ -107,7 +106,8 @@ import com.tailscale.ipn.ui.viewModel.MainViewModel data class MainViewNavigation( val onNavigateToSettings: () -> Unit, val onNavigateToPeerDetails: (Tailcfg.Node) -> Unit, - val onNavigateToExitNodes: () -> Unit + val onNavigateToExitNodes: () -> Unit, + val onNavigateToHealth: () -> Unit ) @OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class) @@ -118,7 +118,7 @@ fun MainView( viewModel: MainViewModel ) { val currentPingDevice by viewModel.pingViewModel.peer.collectAsState() - val healthWarnings by viewModel.healthWarnings.collectAsState() + val healthIcon by viewModel.healthIcon.collectAsState() LoadingIndicator.Wrap { Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets -> @@ -167,7 +167,21 @@ fun MainView( }, supportingContent = { if (!hideHeader) { - Text(text = stateStr, style = MaterialTheme.typography.bodyMedium.short) + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = stateStr, style = MaterialTheme.typography.bodyMedium.short) + healthIcon?.let { + Spacer(modifier = Modifier.size(4.dp)) + IconButton( + onClick = { navigation.onNavigateToHealth() }, + modifier = Modifier.size(16.dp)) { + Icon( + painterResource(id = it), + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.error) + } + } + } } }, trailingContent = { @@ -200,10 +214,6 @@ fun MainView( ExpiryNotification(netmap = netmap, action = { viewModel.login() }) } - for (warning in healthWarnings) { - HealthNotification(warning = warning) - } - if (showExitNodePicker.value == ShowHide.Show) { ExitNodeStatus( navAction = navigation.onNavigateToExitNodes, viewModel = viewModel) @@ -709,29 +719,6 @@ fun ExpiryNotification(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) { } } -@Composable -fun HealthNotification(warning: Health.UnhealthyState) { - Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainerLow)) { - 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( - colors = warning.Severity.listItemColors(), - headlineContent = { - Text( - warning.Title, - style = MaterialTheme.typography.titleMedium, - ) - }, - supportingContent = { - Text(warning.Text, style = MaterialTheme.typography.bodyMedium) - }) - } - } -} - @OptIn(ExperimentalPermissionsApi::class) @Composable fun PromptPermissionsIfNecessary() { @@ -752,6 +739,9 @@ fun MainViewPreview() { MainView( {}, MainViewNavigation( - onNavigateToSettings = {}, onNavigateToPeerDetails = {}, onNavigateToExitNodes = {}), + onNavigateToSettings = {}, + onNavigateToPeerDetails = {}, + onNavigateToExitNodes = {}, + onNavigateToHealth = {}), vm) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/HealthViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/HealthViewModel.kt new file mode 100644 index 0000000..d7241a1 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/HealthViewModel.kt @@ -0,0 +1,23 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.viewModel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.tailscale.ipn.App +import com.tailscale.ipn.ui.model.Health +import com.tailscale.ipn.ui.util.set +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class HealthViewModel : ViewModel() { + val warnings: StateFlow> = MutableStateFlow(listOf()) + + init { + viewModelScope.launch { + App.get().healthNotifier?.currentWarnings?.collect { set -> warnings.set(set.sorted()) } + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt index c263567..d4a6e03 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt @@ -12,7 +12,6 @@ import androidx.lifecycle.viewModelScope import com.tailscale.ipn.App import com.tailscale.ipn.R import com.tailscale.ipn.mdm.MDMSettings -import com.tailscale.ipn.ui.model.Health import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn.State import com.tailscale.ipn.ui.model.Tailcfg @@ -57,8 +56,8 @@ class MainViewModel : IpnViewModel() { var pingViewModel: PingViewModel = PingViewModel() - // Health warnings displayed in the UI, if any - val healthWarnings: StateFlow> = MutableStateFlow(listOf()) + // Icon displayed in the button to present the health view + val healthIcon: StateFlow = MutableStateFlow(null) fun hidePeerDropdownMenu() { expandedMenuPeer.set(null) @@ -124,11 +123,7 @@ class MainViewModel : IpnViewModel() { } viewModelScope.launch { - App.get().healthNotifier?.currentWarnings?.collect { warnings -> - healthWarnings.set(warnings - .filter { it.Severity == Health.Severity.high } - .sorted()) - } + App.get().healthNotifier?.currentIcon?.collect { icon -> healthIcon.set(icon) } } } diff --git a/android/src/main/res/drawable/info.xml b/android/src/main/res/drawable/info.xml new file mode 100644 index 0000000..270273c --- /dev/null +++ b/android/src/main/res/drawable/info.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/src/main/res/drawable/warning_rounded.xml b/android/src/main/res/drawable/warning_rounded.xml new file mode 100644 index 0000000..c54377b --- /dev/null +++ b/android/src/main/res/drawable/warning_rounded.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 572c4eb..c16006a 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -289,4 +289,7 @@ Once you enable Mullvad VPN on the admin console, you\'ll be able to encrypt and route your traffic using Mullvad’s global network of servers as exit nodes. Mullvad VPN is not configured The Mullvad VPN logo + Health warnings + No issues found + Tailscale is operating normally.