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 <andrea@gottardo.me>
pull/464/head
Andrea Gottardo 4 months ago committed by GitHub
parent 403aa092c4
commit 338c13b6b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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(

@ -25,6 +25,7 @@ class Health {
var Text: String,
var BrokenSince: String? = null,
var Args: Map<String, String>? = null,
var ImpactsConnectivity: Boolean? = false,
var DependsOn: List<String>? = null, // an array of WarnableCodes this depends on
) : Comparable<UnhealthyState> {
fun hiddenByDependencies(currentWarnableCodes: Set<String>): Boolean {

@ -56,6 +56,7 @@ class HealthNotifier(
}
val currentWarnings: StateFlow<Set<UnhealthyState>> = MutableStateFlow(setOf())
val currentIcon: StateFlow<Int?> = MutableStateFlow(null)
private fun notifyHealthUpdated(warnings: Array<UnhealthyState>) {
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) {

@ -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)
})
}
}
}

@ -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)
}

@ -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<List<Health.UnhealthyState>> = MutableStateFlow(listOf())
init {
viewModelScope.launch {
App.get().healthNotifier?.currentWarnings?.collect { set -> warnings.set(set.sorted()) }
}
}
}

@ -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<List<Health.UnhealthyState>> = MutableStateFlow(listOf())
// Icon displayed in the button to present the health view
val healthIcon: StateFlow<Int?> = 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) }
}
}

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M440,680L520,680L520,440L440,440L440,680ZM480,360Q497,360 508.5,348.5Q520,337 520,320Q520,303 508.5,291.5Q497,280 480,280Q463,280 451.5,291.5Q440,303 440,320Q440,337 451.5,348.5Q463,360 480,360ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
</vector>

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M4.47,21h15.06c1.54,0 2.5,-1.67 1.73,-3L13.73,4.99c-0.77,-1.33 -2.69,-1.33 -3.46,0L2.74,18c-0.77,1.33 0.19,3 1.73,3zM12,14c-0.55,0 -1,-0.45 -1,-1v-2c0,-0.55 0.45,-1 1,-1s1,0.45 1,1v2c0,0.55 -0.45,1 -1,1zM13,18h-2v-2h2v2z"/>
</vector>

@ -289,4 +289,7 @@
<string name="mullvad_info_explainer">Once you enable Mullvad VPN on the admin console, you\'ll be able to encrypt and route your traffic using Mullvads global network of servers as exit nodes.</string>
<string name="mullvad_info_title">Mullvad VPN is not configured</string>
<string name="the_mullvad_vpn_logo">The Mullvad VPN logo</string>
<string name="health_warnings">Health warnings</string>
<string name="no_issues_found">No issues found</string>
<string name="tailscale_is_operating_normally">Tailscale is operating normally.</string>
</resources>

Loading…
Cancel
Save