From 8a43a7520940ae302250f5617bd1b566953acc07 Mon Sep 17 00:00:00 2001 From: Jakub Meysner Date: Sun, 20 Apr 2025 23:40:42 +0200 Subject: [PATCH] android/src/main: show exit node information in the permanent notification Displays exit node status (including the name of the exit node) in the permanent connection notification's content (moving the overall connected/disconnected status to the title). Fixes tailscale/tailscale#14438 Signed-off-by: Jakub Meysner --- .../src/main/java/com/tailscale/ipn/App.kt | 52 +++++++++++++++---- .../main/java/com/tailscale/ipn/IPNService.kt | 29 ++++++----- android/src/main/res/values/strings.xml | 1 + 3 files changed, 60 insertions(+), 22 deletions(-) diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index 041bdd9..a91325e 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -30,6 +30,7 @@ import com.tailscale.ipn.mdm.MDMSettingsChangedReceiver 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.model.Netmap import com.tailscale.ipn.ui.notifier.HealthNotifier import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.viewModel.VpnViewModel @@ -46,6 +47,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString @@ -165,10 +167,15 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { initViewModels() applicationScope.launch { Notifier.state.collect { _ -> - combine(Notifier.state, MDMSettings.forceEnabled.flow) { state, forceEnabled -> - Pair(state, forceEnabled) + combine(Notifier.state, MDMSettings.forceEnabled.flow, Notifier.prefs, Notifier.netmap) { + state, + forceEnabled, + prefs, + netmap -> + Triple(state, forceEnabled, getExitNodeName(prefs, netmap)) } - .collect { (state, hideDisconnectAction) -> + .distinctUntilChanged() + .collect { (state, hideDisconnectAction, exitNodeName) -> val ableToStartVPN = state > Ipn.State.NeedsMachineAuth // If VPN is stopped, show a disconnected notification. If it is running as a // foreground @@ -183,7 +190,10 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { // Update notification status when VPN is running if (vpnRunning) { - notifyStatus(vpnRunning = true, hideDisconnectAction = hideDisconnectAction.value) + notifyStatus( + vpnRunning = true, + hideDisconnectAction = hideDisconnectAction.value, + exitNodeName = exitNodeName) } } } @@ -391,6 +401,18 @@ open class UninitializedApp : Application() { fun get(): UninitializedApp { return appInstance } + + /** + * Return the name of the active (but not the selected/prior one) exit node based on the + * provided [Ipn.Prefs] and [Netmap.NetworkMap]. + * + * @return The name of the exit node or `null` if there isn't one. + */ + fun getExitNodeName(prefs: Ipn.Prefs?, netmap: Netmap.NetworkMap?): String? { + return prefs?.activeExitNodeID?.let { exitNodeID -> + netmap?.Peers?.find { it.StableID == exitNodeID }?.exitNodeName + } + } } protected fun setUnprotectedInstance(instance: UninitializedApp) { @@ -476,8 +498,12 @@ open class UninitializedApp : Application() { notificationManager.createNotificationChannel(channel) } - fun notifyStatus(vpnRunning: Boolean, hideDisconnectAction: Boolean) { - notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction)) + fun notifyStatus( + vpnRunning: Boolean, + hideDisconnectAction: Boolean, + exitNodeName: String? = null + ) { + notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction, exitNodeName)) } fun notifyStatus(notification: Notification) { @@ -495,8 +521,16 @@ open class UninitializedApp : Application() { notificationManager.notify(STATUS_NOTIFICATION_ID, notification) } - fun buildStatusNotification(vpnRunning: Boolean, hideDisconnectAction: Boolean): Notification { - val message = getString(if (vpnRunning) R.string.connected else R.string.not_connected) + fun buildStatusNotification( + vpnRunning: Boolean, + hideDisconnectAction: Boolean, + exitNodeName: String? = null + ): Notification { + val title = getString(if (vpnRunning) R.string.connected else R.string.not_connected) + val message = + if (vpnRunning && exitNodeName != null) { + getString(R.string.using_exit_node, exitNodeName) + } else null val icon = if (vpnRunning) R.drawable.ic_notification else R.drawable.ic_notification_disabled val action = if (vpnRunning) IPNReceiver.INTENT_DISCONNECT_VPN else IPNReceiver.INTENT_CONNECT_VPN @@ -520,7 +554,7 @@ open class UninitializedApp : Application() { val builder = NotificationCompat.Builder(this, STATUS_CHANNEL_ID) .setSmallIcon(icon) - .setContentTitle(getString(R.string.app_name)) + .setContentTitle(title) .setContentText(message) .setAutoCancel(!vpnRunning) .setOnlyAlertOnce(!vpnRunning) diff --git a/android/src/main/java/com/tailscale/ipn/IPNService.kt b/android/src/main/java/com/tailscale/ipn/IPNService.kt index 120e469..917b405 100644 --- a/android/src/main/java/com/tailscale/ipn/IPNService.kt +++ b/android/src/main/java/com/tailscale/ipn/IPNService.kt @@ -47,11 +47,7 @@ open class IPNService : VpnService(), libtailscale.IPNService { START_NOT_STICKY } ACTION_START_VPN -> { - scope.launch { - // Collect the first value of hideDisconnectAction asynchronously. - val hideDisconnectAction = MDMSettings.forceEnabled.flow.first() - showForegroundNotification(hideDisconnectAction.value) - } + scope.launch { showForegroundNotification() } app.setWantRunning(true) Libtailscale.requestVPN(this) START_STICKY @@ -63,7 +59,9 @@ open class IPNService : VpnService(), libtailscale.IPNService { scope.launch { // Collect the first value of hideDisconnectAction asynchronously. val hideDisconnectAction = MDMSettings.forceEnabled.flow.first() - app.notifyStatus(true, hideDisconnectAction.value) + val exitNodeName = + UninitializedApp.getExitNodeName(Notifier.prefs.value, Notifier.netmap.value) + app.notifyStatus(true, hideDisconnectAction.value, exitNodeName) } app.setWantRunning(true) Libtailscale.requestVPN(this) @@ -73,11 +71,7 @@ open class IPNService : VpnService(), libtailscale.IPNService { // This means that we were restarted after the service was killed // (potentially due to OOM). if (UninitializedApp.get().isAbleToStartVPN()) { - scope.launch { - // Collect the first value of hideDisconnectAction asynchronously. - val hideDisconnectAction = MDMSettings.forceEnabled.flow.first() - showForegroundNotification(hideDisconnectAction.value) - } + scope.launch { showForegroundNotification() } App.get() Libtailscale.requestVPN(this) START_STICKY @@ -114,16 +108,25 @@ open class IPNService : VpnService(), libtailscale.IPNService { app.getAppScopedViewModel().setVpnPrepared(isPrepared) } - private fun showForegroundNotification(hideDisconnectAction: Boolean) { + private fun showForegroundNotification( + hideDisconnectAction: Boolean, + exitNodeName: String? = null + ) { try { startForeground( UninitializedApp.STATUS_NOTIFICATION_ID, - UninitializedApp.get().buildStatusNotification(true, hideDisconnectAction)) + UninitializedApp.get().buildStatusNotification(true, hideDisconnectAction, exitNodeName)) } catch (e: Exception) { TSLog.e(TAG, "Failed to start foreground service: $e") } } + private fun showForegroundNotification() { + val hideDisconnectAction = MDMSettings.forceEnabled.flow.value.value + val exitNodeName = UninitializedApp.getExitNodeName(Notifier.prefs.value, Notifier.netmap.value) + showForegroundNotification(hideDisconnectAction, exitNodeName) + } + private fun configIntent(): PendingIntent { return PendingIntent.getActivity( this, diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index c1c539a..5626e58 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -9,6 +9,7 @@ Disconnect Unknown user Connected + Using exit node (%s) Not connected %s