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
parent
8dc1a13f77
commit
ea928ca971
@ -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
|
||||||
|
}
|
||||||
|
}
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue