From ea928ca971238c0cbe022788aa232e8fdbb8ee50 Mon Sep 17 00:00:00 2001 From: Andrea Gottardo Date: Wed, 19 Jun 2024 12:21:13 -0700 Subject: [PATCH] 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 --- .../src/main/java/com/tailscale/ipn/App.kt | 15 ++- .../java/com/tailscale/ipn/MainActivity.kt | 2 - .../java/com/tailscale/ipn/ui/model/Health.kt | 38 ++++++ .../java/com/tailscale/ipn/ui/model/Ipn.kt | 1 + .../ipn/ui/notifier/HealthNotifier.kt | 117 ++++++++++++++++++ .../com/tailscale/ipn/ui/notifier/Notifier.kt | 10 +- android/src/main/res/values/strings.xml | 2 + 7 files changed, 178 insertions(+), 7 deletions(-) create mode 100644 android/src/main/java/com/tailscale/ipn/ui/model/Health.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/notifier/HealthNotifier.kt diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index 8f37e47..1eb7c31 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -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) { diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index a4b9631..0c1bb7b 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -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 private lateinit var navController: NavHostController private lateinit var vpnPermissionLauncher: ActivityResultLauncher private val viewModel: MainViewModel by viewModels() 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 new file mode 100644 index 0000000..9044058 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Health.kt @@ -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? = null, + ) + + @Serializable + data class UnhealthyState( + var WarnableCode: String, + var Severity: Severity, + var Title: String, + var Text: String, + var BrokenSince: String? = null, + var Args: Map? = null, + var DependsOn: List? = null, // an array of WarnableCodes this depends on + ) { + fun hiddenByDependencies(currentWarnableCodes: Set): Boolean { + return this.DependsOn?.let { + it.any { depWarnableCode -> currentWarnableCodes.contains(depWarnableCode) } + } == true + } + } + + @Serializable + enum class Severity { + high, + medium, + low + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt index 883fa98..9fceead 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt @@ -46,6 +46,7 @@ class Ipn { var IncomingFiles: List? = null, var ClientVersion: Tailcfg.ClientVersion? = null, var TailFSShares: List? = null, + var Health: Health.State? = null, ) @Serializable 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 new file mode 100644 index 0000000..6fa61c9 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/notifier/HealthNotifier.kt @@ -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, + scope: CoroutineScope, +) { + companion object { + const val HEALTH_CHANNEL_ID = "tailscale-health" + } + + private val TAG = "Health" + private val ignoredWarnableCodes: Set = + 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 = mutableSetOf() + + private fun notifyHealthUpdated(warnings: Array) { + val warningsBeforeAdd = currentWarnings + val currentWarnableCodes = warnings.map { it.WarnableCode }.toSet() + + val addedWarnings: MutableSet = 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) { + Log.d(TAG, "Removing notifications for $codes") + for (code in codes) { + notificationManager.cancel(code.hashCode()) + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt b/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt index 4184a22..c01a142 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt @@ -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 = MutableStateFlow(null) val loginFinished: StateFlow = MutableStateFlow(null) val version: StateFlow = MutableStateFlow(null) + val health: StateFlow = MutableStateFlow(null) // Taildrop-specific State val outgoingFiles: StateFlow?> = 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(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), } } diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 701f9c6..01739d2 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -263,4 +263,6 @@ Notifications delivered when user interaction is required to establish the VPN tunnel. Optional notifications which display the status of the VPN tunnel. Notifications delivered when a file is received using Taildrop. + Errors and warnings + 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.