health: only display system notifications for high severity warnings,… (#436)

health: only display system notifications for high severity warnings, show low severity notifications in-app

Updates tailscale/tailscale#4136

This PR brings the Android health system in line with recent macOS/iOS changes. Only high severity notifications will now trigger a system notification; meanwhile all notifications are now displayed in the app home screen, like we do on iOS. The "warming-up" Warnable is observed to prevent spurious notifications from appearing while the app has just launched.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
pull/437/head
Andrea Gottardo 1 year ago committed by GitHub
parent 840a31d74e
commit b3a74986ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -72,7 +72,7 @@ class App : UninitializedApp(), libtailscale.AppContext {
val dns = DnsConfig() val dns = DnsConfig()
private lateinit var connectivityManager: ConnectivityManager private lateinit var connectivityManager: ConnectivityManager
private lateinit var app: libtailscale.Application private lateinit var app: libtailscale.Application
private var healthNotifier: HealthNotifier? = null var healthNotifier: HealthNotifier? = null
override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString

@ -3,6 +3,11 @@
package com.tailscale.ipn.ui.model package com.tailscale.ipn.ui.model
import androidx.compose.material3.ListItemColors
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import com.tailscale.ipn.ui.theme.warning
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
class Health { class Health {
@ -21,18 +26,59 @@ class Health {
var BrokenSince: String? = null, var BrokenSince: String? = null,
var Args: Map<String, String>? = null, var Args: Map<String, String>? = null,
var DependsOn: List<String>? = null, // an array of WarnableCodes this depends on var DependsOn: List<String>? = null, // an array of WarnableCodes this depends on
) { ) : Comparable<UnhealthyState> {
fun hiddenByDependencies(currentWarnableCodes: Set<String>): Boolean { fun hiddenByDependencies(currentWarnableCodes: Set<String>): Boolean {
return this.DependsOn?.let { return this.DependsOn?.let {
it.any { depWarnableCode -> currentWarnableCodes.contains(depWarnableCode) } it.any { depWarnableCode -> currentWarnableCodes.contains(depWarnableCode) }
} == true } == true
} }
override fun compareTo(other: UnhealthyState): Int {
// Compare by severity first
val severityComparison = Severity.compareTo(other.Severity)
if (severityComparison != 0) {
return severityComparison
}
// If severities are equal, compare by warnableCode
return WarnableCode.compareTo(other.WarnableCode)
}
} }
@Serializable @Serializable
enum class Severity { enum class Severity : Comparable<Severity> {
high, low,
medium, medium,
low high;
@Composable
fun listItemColors(): ListItemColors {
val default = ListItemDefaults.colors()
return when (this) {
Severity.low ->
ListItemColors(
containerColor = MaterialTheme.colorScheme.surface,
headlineColor = MaterialTheme.colorScheme.secondary,
leadingIconColor = MaterialTheme.colorScheme.secondary,
overlineColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.8f),
supportingTextColor = MaterialTheme.colorScheme.secondary,
trailingIconColor = MaterialTheme.colorScheme.secondary,
disabledHeadlineColor = default.disabledHeadlineColor,
disabledLeadingIconColor = default.disabledLeadingIconColor,
disabledTrailingIconColor = default.disabledTrailingIconColor)
Severity.medium,
Severity.high ->
ListItemColors(
containerColor = MaterialTheme.colorScheme.warning,
headlineColor = MaterialTheme.colorScheme.onPrimary,
leadingIconColor = MaterialTheme.colorScheme.onPrimary,
overlineColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f),
supportingTextColor = MaterialTheme.colorScheme.onPrimary,
trailingIconColor = MaterialTheme.colorScheme.onPrimary,
disabledHeadlineColor = default.disabledHeadlineColor,
disabledLeadingIconColor = default.disabledLeadingIconColor,
disabledTrailingIconColor = default.disabledTrailingIconColor)
}
}
} }
} }

@ -13,8 +13,10 @@ import com.tailscale.ipn.R
import com.tailscale.ipn.UninitializedApp.Companion.notificationManager import com.tailscale.ipn.UninitializedApp.Companion.notificationManager
import com.tailscale.ipn.ui.model.Health import com.tailscale.ipn.ui.model.Health
import com.tailscale.ipn.ui.model.Health.UnhealthyState import com.tailscale.ipn.ui.model.Health.UnhealthyState
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@ -53,31 +55,36 @@ class HealthNotifier(
} }
} }
private val currentWarnings: MutableSet<String> = mutableSetOf() val currentWarnings: StateFlow<Set<UnhealthyState>> = MutableStateFlow(setOf())
private fun notifyHealthUpdated(warnings: Array<UnhealthyState>) { private fun notifyHealthUpdated(warnings: Array<UnhealthyState>) {
val warningsBeforeAdd = currentWarnings val warningsBeforeAdd = currentWarnings.value
val currentWarnableCodes = warnings.map { it.WarnableCode }.toSet() val currentWarnableCodes = warnings.map { it.WarnableCode }.toSet()
val addedWarnings: MutableSet<UnhealthyState> = mutableSetOf()
val isWarmingUp = warnings.any { it.WarnableCode == "warming-up" }
val addedWarnings: MutableSet<String> = mutableSetOf()
for (warning in warnings) { for (warning in warnings) {
if (ignoredWarnableCodes.contains(warning.WarnableCode)) { if (ignoredWarnableCodes.contains(warning.WarnableCode)) {
continue continue
} }
addedWarnings.add(warning.WarnableCode) addedWarnings.add(warning)
if (this.currentWarnings.contains(warning.WarnableCode)) { if (this.currentWarnings.value.contains(warning)) {
// Already notified, skip // Already notified, skip
continue continue
} else if (warning.hiddenByDependencies(currentWarnableCodes)) { } else if (warning.hiddenByDependencies(currentWarnableCodes)) {
// Ignore this warning because a dependency is also unhealthy // Ignore this warning because a dependency is also unhealthy
Log.d(TAG, "Ignoring ${warning.WarnableCode} because of dependency") Log.d(TAG, "Ignoring ${warning.WarnableCode} because of dependency")
continue continue
} else { } else if (!isWarmingUp) {
Log.d(TAG, "Adding health warning: ${warning.WarnableCode}") Log.d(TAG, "Adding health warning: ${warning.WarnableCode}")
this.currentWarnings.add(warning.WarnableCode) this.currentWarnings.set(this.currentWarnings.value + warning)
this.sendNotification(warning.Title, warning.Text, warning.WarnableCode) if (warning.Severity == Health.Severity.high) {
this.sendNotification(warning.Title, warning.Text, warning.WarnableCode)
}
} else {
Log.d(TAG, "Ignoring ${warning.WarnableCode} because warming up")
} }
} }
@ -86,7 +93,7 @@ class HealthNotifier(
Log.d(TAG, "Dropping health warnings with codes $warningsToDrop") Log.d(TAG, "Dropping health warnings with codes $warningsToDrop")
this.removeNotifications(warningsToDrop) this.removeNotifications(warningsToDrop)
} }
currentWarnings.subtract(warningsToDrop) currentWarnings.set(this.currentWarnings.value.subtract(warningsToDrop))
} }
private fun sendNotification(title: String, text: String, code: String) { private fun sendNotification(title: String, text: String, code: String) {
@ -108,10 +115,10 @@ class HealthNotifier(
notificationManager.notify(code.hashCode(), notification) notificationManager.notify(code.hashCode(), notification)
} }
private fun removeNotifications(codes: Set<String>) { private fun removeNotifications(warnings: Set<UnhealthyState>) {
Log.d(TAG, "Removing notifications for $codes") Log.d(TAG, "Removing notifications for $warnings")
for (code in codes) { for (warning in warnings) {
notificationManager.cancel(code.hashCode()) notificationManager.cancel(warning.WarnableCode.hashCode())
} }
} }
} }

@ -73,6 +73,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.ShowHide 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.Ipn
import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.model.Netmap
@ -117,6 +118,7 @@ fun MainView(
viewModel: MainViewModel viewModel: MainViewModel
) { ) {
val currentPingDevice by viewModel.pingViewModel.peer.collectAsState() val currentPingDevice by viewModel.pingViewModel.peer.collectAsState()
val healthWarnings by viewModel.healthWarnings.collectAsState()
LoadingIndicator.Wrap { LoadingIndicator.Wrap {
Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets -> Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets ->
@ -198,6 +200,10 @@ fun MainView(
ExpiryNotification(netmap = netmap, action = { viewModel.login() }) ExpiryNotification(netmap = netmap, action = { viewModel.login() })
} }
for (warning in healthWarnings) {
HealthNotification(warning = warning)
}
if (showExitNodePicker == ShowHide.Show) { if (showExitNodePicker == ShowHide.Show) {
ExitNodeStatus( ExitNodeStatus(
navAction = navigation.onNavigateToExitNodes, viewModel = viewModel) navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
@ -225,7 +231,9 @@ fun MainView(
} }
currentPingDevice?.let { peer -> currentPingDevice?.let { peer ->
ModalBottomSheet(onDismissRequest = { viewModel.onPingDismissal() }) { PingView(model = viewModel.pingViewModel) } ModalBottomSheet(onDismissRequest = { viewModel.onPingDismissal() }) {
PingView(model = viewModel.pingViewModel)
}
} }
} }
} }
@ -701,6 +709,29 @@ 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) @OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun PromptPermissionsIfNecessary() { fun PromptPermissionsIfNecessary() {

@ -12,6 +12,7 @@ import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App import com.tailscale.ipn.App
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings 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
import com.tailscale.ipn.ui.model.Ipn.State import com.tailscale.ipn.ui.model.Ipn.State
import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.model.Tailcfg
@ -56,6 +57,9 @@ class MainViewModel : IpnViewModel() {
var pingViewModel: PingViewModel = PingViewModel() var pingViewModel: PingViewModel = PingViewModel()
// Health warnings displayed in the UI, if any
val healthWarnings: StateFlow<List<Health.UnhealthyState>> = MutableStateFlow(listOf())
fun hidePeerDropdownMenu() { fun hidePeerDropdownMenu() {
expandedMenuPeer.set(null) expandedMenuPeer.set(null)
} }
@ -118,6 +122,12 @@ class MainViewModel : IpnViewModel() {
viewModelScope.launch { viewModelScope.launch {
searchTerm.collect { term -> peers.set(peerCategorizer.groupedAndFilteredPeers(term)) } searchTerm.collect { term -> peers.set(peerCategorizer.groupedAndFilteredPeers(term)) }
} }
viewModelScope.launch {
App.get().healthNotifier?.currentWarnings?.collect { warnings ->
healthWarnings.set(warnings.sorted())
}
}
} }
fun showVPNPermissionLauncherIfUnauthorized() { fun showVPNPermissionLauncherIfUnauthorized() {

Loading…
Cancel
Save