ui: deliver health notifications to user (#426)

Updates tailscale/tailscale#4136

This PR adds support for notifying the user when health warnings are sent down coming from LocalAPI. We remove duplicates and debounce updates; then deliver a notification for each health warning are they are sent down. Just like on macOS, notifications are removed when a Warnable becomes healthy again.

Notifications are delivered on a separate notification channel, so they can be disabled if needed.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
pull/429/head
Andrea Gottardo 1 week ago committed by GitHub
parent 8dc1a13f77
commit ea928ca971
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -28,6 +28,7 @@ import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.localapi.Request
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.HealthNotifier
import com.tailscale.ipn.ui.notifier.Notifier
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -71,6 +72,7 @@ class App : UninitializedApp(), libtailscale.AppContext {
val dns = DnsConfig()
private lateinit var connectivityManager: ConnectivityManager
private lateinit var app: libtailscale.Application
private var healthNotifier: HealthNotifier? = null
override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString
@ -92,6 +94,11 @@ class App : UninitializedApp(), libtailscale.AppContext {
getString(R.string.taildrop_file_transfers),
getString(R.string.notifications_delivered_when_a_file_is_received_using_taildrop),
NotificationManagerCompat.IMPORTANCE_DEFAULT)
createNotificationChannel(
HealthNotifier.HEALTH_CHANNEL_ID,
getString(R.string.health_channel_name),
getString(R.string.health_channel_description),
NotificationManagerCompat.IMPORTANCE_HIGH)
appInstance = this
setUnprotectedInstance(this)
}
@ -123,13 +130,15 @@ class App : UninitializedApp(), libtailscale.AppContext {
Request.setApp(app)
Notifier.setApp(app)
Notifier.start(applicationScope)
healthNotifier = HealthNotifier(Notifier.health, applicationScope)
connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
setAndRegisterNetworkCallbacks()
applicationScope.launch {
Notifier.state.collect { state ->
val ableToStartVPN = state > Ipn.State.NeedsMachineAuth
// If VPN is stopped, show a disconnected notification. If it is running as a foregrround service, IPNService will show a connected notification.
if (state == Ipn.State.Stopped){
// If VPN is stopped, show a disconnected notification. If it is running as a foregrround
// service, IPNService will show a connected notification.
if (state == Ipn.State.Stopped) {
notifyStatus(false)
}
val vpnRunning = state == Ipn.State.Starting || state == Ipn.State.Running
@ -389,7 +398,7 @@ open class UninitializedApp : Application() {
}
fun notifyStatus(vpnRunning: Boolean) {
notifyStatus(buildStatusNotification(vpnRunning))
notifyStatus(buildStatusNotification(vpnRunning))
}
fun notifyStatus(notification: Notification) {

@ -11,7 +11,6 @@ import android.content.RestrictionsManager
import android.content.pm.ActivityInfo
import android.content.res.Configuration.SCREENLAYOUT_SIZE_LARGE
import android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK
import android.net.VpnService
import android.os.Bundle
import android.provider.Settings
import android.util.Log
@ -77,7 +76,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
private lateinit var requestVpnPermission: ActivityResultLauncher<Unit>
private lateinit var navController: NavHostController
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
private val viewModel: MainViewModel by viewModels()

@ -0,0 +1,38 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.model
import kotlinx.serialization.Serializable
class Health {
@Serializable
data class State(
// WarnableCode -> UnhealthyState or null
var Warnings: Map<String, UnhealthyState?>? = null,
)
@Serializable
data class UnhealthyState(
var WarnableCode: String,
var Severity: Severity,
var Title: String,
var Text: String,
var BrokenSince: String? = null,
var Args: Map<String, String>? = null,
var DependsOn: List<String>? = null, // an array of WarnableCodes this depends on
) {
fun hiddenByDependencies(currentWarnableCodes: Set<String>): Boolean {
return this.DependsOn?.let {
it.any { depWarnableCode -> currentWarnableCodes.contains(depWarnableCode) }
} == true
}
}
@Serializable
enum class Severity {
high,
medium,
low
}
}

@ -46,6 +46,7 @@ class Ipn {
var IncomingFiles: List<PartialFile>? = null,
var ClientVersion: Tailcfg.ClientVersion? = null,
var TailFSShares: List<String>? = null,
var Health: Health.State? = null,
)
@Serializable

@ -0,0 +1,117 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.notifier
import android.Manifest
import android.content.pm.PackageManager
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import com.tailscale.ipn.App
import com.tailscale.ipn.R
import com.tailscale.ipn.UninitializedApp.Companion.notificationManager
import com.tailscale.ipn.ui.model.Health
import com.tailscale.ipn.ui.model.Health.UnhealthyState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
@OptIn(FlowPreview::class)
class HealthNotifier(
healthStateFlow: StateFlow<Health.State?>,
scope: CoroutineScope,
) {
companion object {
const val HEALTH_CHANNEL_ID = "tailscale-health"
}
private val TAG = "Health"
private val ignoredWarnableCodes: Set<String> =
setOf(
// Ignored on Android because installing unstable takes quite some effort
"is-using-unstable-version",
// Ignored on Android because we already have a dedicated connected/not connected
// notification
"wantrunning-false")
init {
scope.launch {
healthStateFlow
.distinctUntilChanged { old, new -> old?.Warnings?.count() == new?.Warnings?.count() }
.debounce(5000)
.collect { health ->
Log.d(TAG, "Health updated: ${health?.Warnings?.keys?.sorted()}")
health?.Warnings?.let {
notifyHealthUpdated(it.values.mapNotNull { it }.toTypedArray())
}
}
}
}
private val currentWarnings: MutableSet<String> = mutableSetOf()
private fun notifyHealthUpdated(warnings: Array<UnhealthyState>) {
val warningsBeforeAdd = currentWarnings
val currentWarnableCodes = warnings.map { it.WarnableCode }.toSet()
val addedWarnings: MutableSet<String> = mutableSetOf()
for (warning in warnings) {
if (ignoredWarnableCodes.contains(warning.WarnableCode)) {
continue
}
addedWarnings.add(warning.WarnableCode)
if (this.currentWarnings.contains(warning.WarnableCode)) {
// Already notified, skip
continue
} else if (warning.hiddenByDependencies(currentWarnableCodes)) {
// Ignore this warning because a dependency is also unhealthy
Log.d(TAG, "Ignoring ${warning.WarnableCode} because of dependency")
continue
} else {
Log.d(TAG, "Adding health warning: ${warning.WarnableCode}")
this.currentWarnings.add(warning.WarnableCode)
this.sendNotification(warning.Title, warning.Text, warning.WarnableCode)
}
}
val warningsToDrop = warningsBeforeAdd.minus(addedWarnings)
if (warningsToDrop.isNotEmpty()) {
Log.d(TAG, "Dropping health warnings with codes $warningsToDrop")
this.removeNotifications(warningsToDrop)
}
currentWarnings.subtract(warningsToDrop)
}
private fun sendNotification(title: String, text: String, code: String) {
Log.d(TAG, "Sending notification for $code")
val notification =
NotificationCompat.Builder(App.get().applicationContext, HEALTH_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(text)
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
if (ActivityCompat.checkSelfPermission(
App.get().applicationContext, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED) {
Log.d(TAG, "Notification permission not granted")
return
}
notificationManager.notify(code.hashCode(), notification)
}
private fun removeNotifications(codes: Set<String>) {
Log.d(TAG, "Removing notifications for $codes")
for (code in codes) {
notificationManager.cancel(code.hashCode())
}
}
}

@ -6,6 +6,7 @@ package com.tailscale.ipn.ui.notifier
import android.util.Log
import com.tailscale.ipn.App
import com.tailscale.ipn.ui.model.Empty
import com.tailscale.ipn.ui.model.Health
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Ipn.Notify
import com.tailscale.ipn.ui.model.Netmap
@ -40,6 +41,7 @@ object Notifier {
val browseToURL: StateFlow<String?> = MutableStateFlow(null)
val loginFinished: StateFlow<String?> = MutableStateFlow(null)
val version: StateFlow<String?> = MutableStateFlow(null)
val health: StateFlow<Health.State?> = MutableStateFlow(null)
// Taildrop-specific State
val outgoingFiles: StateFlow<List<Ipn.OutgoingFile>?> = MutableStateFlow(null)
@ -64,7 +66,8 @@ object Notifier {
val mask =
NotifyWatchOpt.Netmap.value or
NotifyWatchOpt.Prefs.value or
NotifyWatchOpt.InitialState.value
NotifyWatchOpt.InitialState.value or
NotifyWatchOpt.InitialHealthState.value
manager =
app.watchNotifications(mask.toLong()) { notification ->
val notify = decoder.decodeFromStream<Notify>(notification.inputStream())
@ -79,6 +82,7 @@ object Notifier {
notify.OutgoingFiles?.let(outgoingFiles::set)
notify.FilesWaiting?.let(filesWaiting::set)
notify.IncomingFiles?.let(incomingFiles::set)
notify.Health?.let(health::set)
}
}
}
@ -99,6 +103,8 @@ object Notifier {
Prefs(4),
Netmap(8),
NoPrivateKey(16),
InitialTailFSShares(32)
InitialTailFSShares(32),
InitialOutgoingFiles(64),
InitialHealthState(128),
}
}

@ -263,4 +263,6 @@
<string name="notifications_delivered_when_user_interaction_is_required_to_establish_the_vpn_tunnel">Notifications delivered when user interaction is required to establish the VPN tunnel.</string>
<string name="optional_notifications_which_display_the_status_of_the_vpn_tunnel">Optional notifications which display the status of the VPN tunnel.</string>
<string name="notifications_delivered_when_a_file_is_received_using_taildrop">Notifications delivered when a file is received using Taildrop.</string>
<string name="health_channel_name">Errors and warnings</string>
<string name="health_channel_description">This notification category is used to deliver important status notifications and should be left enabled. For instance, it is used to notify you about errors or warnings that affect Internet connectivity.</string>
</resources>

Loading…
Cancel
Save