Skip to content

Commit

Permalink
ui: deliver health notifications to user
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
agottardo committed Jun 19, 2024
1 parent 196944d commit 6144e53
Show file tree
Hide file tree
Showing 7 changed files with 178 additions and 7 deletions.
15 changes: 12 additions & 3 deletions android/src/main/java/com/tailscale/ipn/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -389,7 +398,7 @@ open class UninitializedApp : Application() {
}

fun notifyStatus(vpnRunning: Boolean) {
notifyStatus(buildStatusNotification(vpnRunning))
notifyStatus(buildStatusNotification(vpnRunning))
}

fun notifyStatus(notification: Notification) {
Expand Down
2 changes: 0 additions & 2 deletions android/src/main/java/com/tailscale/ipn/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
38 changes: 38 additions & 0 deletions android/src/main/java/com/tailscale/ipn/ui/model/Health.kt
Original file line number Diff line number Diff line change
@@ -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
}
}
1 change: 1 addition & 0 deletions android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
117 changes: 117 additions & 0 deletions android/src/main/java/com/tailscale/ipn/ui/notifier/HealthNotifier.kt
Original file line number Diff line number Diff line change
@@ -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())
}
}
}
10 changes: 8 additions & 2 deletions android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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())
Expand All @@ -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)
}
}
}
Expand All @@ -99,6 +103,8 @@ object Notifier {
Prefs(4),
Netmap(8),
NoPrivateKey(16),
InitialTailFSShares(32)
InitialTailFSShares(32),
InitialOutgoingFiles(64),
InitialHealthState(128),
}
}
2 changes: 2 additions & 0 deletions android/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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>

0 comments on commit 6144e53

Please sign in to comment.