Skip to content

Commit ea928ca

Browse files
authored
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 <[email protected]>
1 parent 8dc1a13 commit ea928ca

File tree

7 files changed

+178
-7
lines changed

7 files changed

+178
-7
lines changed

android/src/main/java/com/tailscale/ipn/App.kt

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import com.tailscale.ipn.mdm.MDMSettings
2828
import com.tailscale.ipn.ui.localapi.Client
2929
import com.tailscale.ipn.ui.localapi.Request
3030
import com.tailscale.ipn.ui.model.Ipn
31+
import com.tailscale.ipn.ui.notifier.HealthNotifier
3132
import com.tailscale.ipn.ui.notifier.Notifier
3233
import kotlinx.coroutines.CoroutineScope
3334
import kotlinx.coroutines.Dispatchers
@@ -71,6 +72,7 @@ class App : UninitializedApp(), libtailscale.AppContext {
7172
val dns = DnsConfig()
7273
private lateinit var connectivityManager: ConnectivityManager
7374
private lateinit var app: libtailscale.Application
75+
private var healthNotifier: HealthNotifier? = null
7476

7577
override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString
7678

@@ -92,6 +94,11 @@ class App : UninitializedApp(), libtailscale.AppContext {
9294
getString(R.string.taildrop_file_transfers),
9395
getString(R.string.notifications_delivered_when_a_file_is_received_using_taildrop),
9496
NotificationManagerCompat.IMPORTANCE_DEFAULT)
97+
createNotificationChannel(
98+
HealthNotifier.HEALTH_CHANNEL_ID,
99+
getString(R.string.health_channel_name),
100+
getString(R.string.health_channel_description),
101+
NotificationManagerCompat.IMPORTANCE_HIGH)
95102
appInstance = this
96103
setUnprotectedInstance(this)
97104
}
@@ -123,13 +130,15 @@ class App : UninitializedApp(), libtailscale.AppContext {
123130
Request.setApp(app)
124131
Notifier.setApp(app)
125132
Notifier.start(applicationScope)
133+
healthNotifier = HealthNotifier(Notifier.health, applicationScope)
126134
connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
127135
setAndRegisterNetworkCallbacks()
128136
applicationScope.launch {
129137
Notifier.state.collect { state ->
130138
val ableToStartVPN = state > Ipn.State.NeedsMachineAuth
131-
// If VPN is stopped, show a disconnected notification. If it is running as a foregrround service, IPNService will show a connected notification.
132-
if (state == Ipn.State.Stopped){
139+
// If VPN is stopped, show a disconnected notification. If it is running as a foregrround
140+
// service, IPNService will show a connected notification.
141+
if (state == Ipn.State.Stopped) {
133142
notifyStatus(false)
134143
}
135144
val vpnRunning = state == Ipn.State.Starting || state == Ipn.State.Running
@@ -389,7 +398,7 @@ open class UninitializedApp : Application() {
389398
}
390399

391400
fun notifyStatus(vpnRunning: Boolean) {
392-
notifyStatus(buildStatusNotification(vpnRunning))
401+
notifyStatus(buildStatusNotification(vpnRunning))
393402
}
394403

395404
fun notifyStatus(notification: Notification) {

android/src/main/java/com/tailscale/ipn/MainActivity.kt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import android.content.RestrictionsManager
1111
import android.content.pm.ActivityInfo
1212
import android.content.res.Configuration.SCREENLAYOUT_SIZE_LARGE
1313
import android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK
14-
import android.net.VpnService
1514
import android.os.Bundle
1615
import android.provider.Settings
1716
import android.util.Log
@@ -77,7 +76,6 @@ import kotlinx.coroutines.flow.StateFlow
7776
import kotlinx.coroutines.launch
7877

7978
class MainActivity : ComponentActivity() {
80-
private lateinit var requestVpnPermission: ActivityResultLauncher<Unit>
8179
private lateinit var navController: NavHostController
8280
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
8381
private val viewModel: MainViewModel by viewModels()
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright (c) Tailscale Inc & AUTHORS
2+
// SPDX-License-Identifier: BSD-3-Clause
3+
4+
package com.tailscale.ipn.ui.model
5+
6+
import kotlinx.serialization.Serializable
7+
8+
class Health {
9+
@Serializable
10+
data class State(
11+
// WarnableCode -> UnhealthyState or null
12+
var Warnings: Map<String, UnhealthyState?>? = null,
13+
)
14+
15+
@Serializable
16+
data class UnhealthyState(
17+
var WarnableCode: String,
18+
var Severity: Severity,
19+
var Title: String,
20+
var Text: String,
21+
var BrokenSince: String? = null,
22+
var Args: Map<String, String>? = null,
23+
var DependsOn: List<String>? = null, // an array of WarnableCodes this depends on
24+
) {
25+
fun hiddenByDependencies(currentWarnableCodes: Set<String>): Boolean {
26+
return this.DependsOn?.let {
27+
it.any { depWarnableCode -> currentWarnableCodes.contains(depWarnableCode) }
28+
} == true
29+
}
30+
}
31+
32+
@Serializable
33+
enum class Severity {
34+
high,
35+
medium,
36+
low
37+
}
38+
}

android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ class Ipn {
4646
var IncomingFiles: List<PartialFile>? = null,
4747
var ClientVersion: Tailcfg.ClientVersion? = null,
4848
var TailFSShares: List<String>? = null,
49+
var Health: Health.State? = null,
4950
)
5051

5152
@Serializable
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Copyright (c) Tailscale Inc & AUTHORS
2+
// SPDX-License-Identifier: BSD-3-Clause
3+
4+
package com.tailscale.ipn.ui.notifier
5+
6+
import android.Manifest
7+
import android.content.pm.PackageManager
8+
import android.util.Log
9+
import androidx.core.app.ActivityCompat
10+
import androidx.core.app.NotificationCompat
11+
import com.tailscale.ipn.App
12+
import com.tailscale.ipn.R
13+
import com.tailscale.ipn.UninitializedApp.Companion.notificationManager
14+
import com.tailscale.ipn.ui.model.Health
15+
import com.tailscale.ipn.ui.model.Health.UnhealthyState
16+
import kotlinx.coroutines.CoroutineScope
17+
import kotlinx.coroutines.FlowPreview
18+
import kotlinx.coroutines.flow.StateFlow
19+
import kotlinx.coroutines.flow.debounce
20+
import kotlinx.coroutines.flow.distinctUntilChanged
21+
import kotlinx.coroutines.launch
22+
23+
@OptIn(FlowPreview::class)
24+
class HealthNotifier(
25+
healthStateFlow: StateFlow<Health.State?>,
26+
scope: CoroutineScope,
27+
) {
28+
companion object {
29+
const val HEALTH_CHANNEL_ID = "tailscale-health"
30+
}
31+
32+
private val TAG = "Health"
33+
private val ignoredWarnableCodes: Set<String> =
34+
setOf(
35+
// Ignored on Android because installing unstable takes quite some effort
36+
"is-using-unstable-version",
37+
38+
// Ignored on Android because we already have a dedicated connected/not connected
39+
// notification
40+
"wantrunning-false")
41+
42+
init {
43+
scope.launch {
44+
healthStateFlow
45+
.distinctUntilChanged { old, new -> old?.Warnings?.count() == new?.Warnings?.count() }
46+
.debounce(5000)
47+
.collect { health ->
48+
Log.d(TAG, "Health updated: ${health?.Warnings?.keys?.sorted()}")
49+
health?.Warnings?.let {
50+
notifyHealthUpdated(it.values.mapNotNull { it }.toTypedArray())
51+
}
52+
}
53+
}
54+
}
55+
56+
private val currentWarnings: MutableSet<String> = mutableSetOf()
57+
58+
private fun notifyHealthUpdated(warnings: Array<UnhealthyState>) {
59+
val warningsBeforeAdd = currentWarnings
60+
val currentWarnableCodes = warnings.map { it.WarnableCode }.toSet()
61+
62+
val addedWarnings: MutableSet<String> = mutableSetOf()
63+
for (warning in warnings) {
64+
if (ignoredWarnableCodes.contains(warning.WarnableCode)) {
65+
continue
66+
}
67+
68+
addedWarnings.add(warning.WarnableCode)
69+
70+
if (this.currentWarnings.contains(warning.WarnableCode)) {
71+
// Already notified, skip
72+
continue
73+
} else if (warning.hiddenByDependencies(currentWarnableCodes)) {
74+
// Ignore this warning because a dependency is also unhealthy
75+
Log.d(TAG, "Ignoring ${warning.WarnableCode} because of dependency")
76+
continue
77+
} else {
78+
Log.d(TAG, "Adding health warning: ${warning.WarnableCode}")
79+
this.currentWarnings.add(warning.WarnableCode)
80+
this.sendNotification(warning.Title, warning.Text, warning.WarnableCode)
81+
}
82+
}
83+
84+
val warningsToDrop = warningsBeforeAdd.minus(addedWarnings)
85+
if (warningsToDrop.isNotEmpty()) {
86+
Log.d(TAG, "Dropping health warnings with codes $warningsToDrop")
87+
this.removeNotifications(warningsToDrop)
88+
}
89+
currentWarnings.subtract(warningsToDrop)
90+
}
91+
92+
private fun sendNotification(title: String, text: String, code: String) {
93+
Log.d(TAG, "Sending notification for $code")
94+
val notification =
95+
NotificationCompat.Builder(App.get().applicationContext, HEALTH_CHANNEL_ID)
96+
.setSmallIcon(R.drawable.ic_notification)
97+
.setContentTitle(title)
98+
.setContentText(text)
99+
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
100+
.setPriority(NotificationCompat.PRIORITY_HIGH)
101+
.build()
102+
if (ActivityCompat.checkSelfPermission(
103+
App.get().applicationContext, Manifest.permission.POST_NOTIFICATIONS) !=
104+
PackageManager.PERMISSION_GRANTED) {
105+
Log.d(TAG, "Notification permission not granted")
106+
return
107+
}
108+
notificationManager.notify(code.hashCode(), notification)
109+
}
110+
111+
private fun removeNotifications(codes: Set<String>) {
112+
Log.d(TAG, "Removing notifications for $codes")
113+
for (code in codes) {
114+
notificationManager.cancel(code.hashCode())
115+
}
116+
}
117+
}

android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package com.tailscale.ipn.ui.notifier
66
import android.util.Log
77
import com.tailscale.ipn.App
88
import com.tailscale.ipn.ui.model.Empty
9+
import com.tailscale.ipn.ui.model.Health
910
import com.tailscale.ipn.ui.model.Ipn
1011
import com.tailscale.ipn.ui.model.Ipn.Notify
1112
import com.tailscale.ipn.ui.model.Netmap
@@ -40,6 +41,7 @@ object Notifier {
4041
val browseToURL: StateFlow<String?> = MutableStateFlow(null)
4142
val loginFinished: StateFlow<String?> = MutableStateFlow(null)
4243
val version: StateFlow<String?> = MutableStateFlow(null)
44+
val health: StateFlow<Health.State?> = MutableStateFlow(null)
4345

4446
// Taildrop-specific State
4547
val outgoingFiles: StateFlow<List<Ipn.OutgoingFile>?> = MutableStateFlow(null)
@@ -64,7 +66,8 @@ object Notifier {
6466
val mask =
6567
NotifyWatchOpt.Netmap.value or
6668
NotifyWatchOpt.Prefs.value or
67-
NotifyWatchOpt.InitialState.value
69+
NotifyWatchOpt.InitialState.value or
70+
NotifyWatchOpt.InitialHealthState.value
6871
manager =
6972
app.watchNotifications(mask.toLong()) { notification ->
7073
val notify = decoder.decodeFromStream<Notify>(notification.inputStream())
@@ -79,6 +82,7 @@ object Notifier {
7982
notify.OutgoingFiles?.let(outgoingFiles::set)
8083
notify.FilesWaiting?.let(filesWaiting::set)
8184
notify.IncomingFiles?.let(incomingFiles::set)
85+
notify.Health?.let(health::set)
8286
}
8387
}
8488
}
@@ -99,6 +103,8 @@ object Notifier {
99103
Prefs(4),
100104
Netmap(8),
101105
NoPrivateKey(16),
102-
InitialTailFSShares(32)
106+
InitialTailFSShares(32),
107+
InitialOutgoingFiles(64),
108+
InitialHealthState(128),
103109
}
104110
}

android/src/main/res/values/strings.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,4 +263,6 @@
263263
<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>
264264
<string name="optional_notifications_which_display_the_status_of_the_vpn_tunnel">Optional notifications which display the status of the VPN tunnel.</string>
265265
<string name="notifications_delivered_when_a_file_is_received_using_taildrop">Notifications delivered when a file is received using Taildrop.</string>
266+
<string name="health_channel_name">Errors and warnings</string>
267+
<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>
266268
</resources>

0 commit comments

Comments
 (0)