Skip to content

Commit 8683c78

Browse files
authored
android/src/main: show exit node information in the permanent notification (#642)
* android/src: ktfmt Signed-off-by: Jakub Meysner <[email protected]> * 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 <[email protected]> * docker: fix invalid instruction in Dockerfile not using trailing slash for files destination directory > If the source is a file, and the destination doesn't end with a trailing slash, the source file will be written to the destination path as a file. ~ https://docs.docker.com/reference/dockerfile/#destination Signed-off-by: Jakub Meysner <[email protected]> --------- Signed-off-by: Jakub Meysner <[email protected]>
1 parent ff4a49a commit 8683c78

38 files changed

+840
-859
lines changed

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

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,27 +30,29 @@ import com.tailscale.ipn.mdm.MDMSettingsChangedReceiver
3030
import com.tailscale.ipn.ui.localapi.Client
3131
import com.tailscale.ipn.ui.localapi.Request
3232
import com.tailscale.ipn.ui.model.Ipn
33+
import com.tailscale.ipn.ui.model.Netmap
3334
import com.tailscale.ipn.ui.notifier.HealthNotifier
3435
import com.tailscale.ipn.ui.notifier.Notifier
3536
import com.tailscale.ipn.ui.viewModel.VpnViewModel
3637
import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory
3738
import com.tailscale.ipn.util.FeatureFlags
3839
import com.tailscale.ipn.util.TSLog
40+
import java.io.File
41+
import java.io.IOException
42+
import java.net.NetworkInterface
43+
import java.security.GeneralSecurityException
44+
import java.util.Locale
3945
import kotlinx.coroutines.CoroutineScope
4046
import kotlinx.coroutines.Dispatchers
4147
import kotlinx.coroutines.SupervisorJob
4248
import kotlinx.coroutines.cancel
4349
import kotlinx.coroutines.flow.combine
50+
import kotlinx.coroutines.flow.distinctUntilChanged
4451
import kotlinx.coroutines.flow.first
4552
import kotlinx.coroutines.launch
4653
import kotlinx.serialization.encodeToString
4754
import kotlinx.serialization.json.Json
4855
import libtailscale.Libtailscale
49-
import java.io.File
50-
import java.io.IOException
51-
import java.net.NetworkInterface
52-
import java.security.GeneralSecurityException
53-
import java.util.Locale
5456

5557
class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
5658
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -165,10 +167,15 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
165167
initViewModels()
166168
applicationScope.launch {
167169
Notifier.state.collect { _ ->
168-
combine(Notifier.state, MDMSettings.forceEnabled.flow) { state, forceEnabled ->
169-
Pair(state, forceEnabled)
170+
combine(Notifier.state, MDMSettings.forceEnabled.flow, Notifier.prefs, Notifier.netmap) {
171+
state,
172+
forceEnabled,
173+
prefs,
174+
netmap ->
175+
Triple(state, forceEnabled, getExitNodeName(prefs, netmap))
170176
}
171-
.collect { (state, hideDisconnectAction) ->
177+
.distinctUntilChanged()
178+
.collect { (state, hideDisconnectAction, exitNodeName) ->
172179
val ableToStartVPN = state > Ipn.State.NeedsMachineAuth
173180
// If VPN is stopped, show a disconnected notification. If it is running as a
174181
// foreground
@@ -183,7 +190,10 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
183190

184191
// Update notification status when VPN is running
185192
if (vpnRunning) {
186-
notifyStatus(vpnRunning = true, hideDisconnectAction = hideDisconnectAction.value)
193+
notifyStatus(
194+
vpnRunning = true,
195+
hideDisconnectAction = hideDisconnectAction.value,
196+
exitNodeName = exitNodeName)
187197
}
188198
}
189199
}
@@ -391,6 +401,18 @@ open class UninitializedApp : Application() {
391401
fun get(): UninitializedApp {
392402
return appInstance
393403
}
404+
405+
/**
406+
* Return the name of the active (but not the selected/prior one) exit node based on the
407+
* provided [Ipn.Prefs] and [Netmap.NetworkMap].
408+
*
409+
* @return The name of the exit node or `null` if there isn't one.
410+
*/
411+
fun getExitNodeName(prefs: Ipn.Prefs?, netmap: Netmap.NetworkMap?): String? {
412+
return prefs?.activeExitNodeID?.let { exitNodeID ->
413+
netmap?.Peers?.find { it.StableID == exitNodeID }?.exitNodeName
414+
}
415+
}
394416
}
395417

396418
protected fun setUnprotectedInstance(instance: UninitializedApp) {
@@ -476,8 +498,12 @@ open class UninitializedApp : Application() {
476498
notificationManager.createNotificationChannel(channel)
477499
}
478500

479-
fun notifyStatus(vpnRunning: Boolean, hideDisconnectAction: Boolean) {
480-
notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction))
501+
fun notifyStatus(
502+
vpnRunning: Boolean,
503+
hideDisconnectAction: Boolean,
504+
exitNodeName: String? = null
505+
) {
506+
notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction, exitNodeName))
481507
}
482508

483509
fun notifyStatus(notification: Notification) {
@@ -495,8 +521,16 @@ open class UninitializedApp : Application() {
495521
notificationManager.notify(STATUS_NOTIFICATION_ID, notification)
496522
}
497523

498-
fun buildStatusNotification(vpnRunning: Boolean, hideDisconnectAction: Boolean): Notification {
499-
val message = getString(if (vpnRunning) R.string.connected else R.string.not_connected)
524+
fun buildStatusNotification(
525+
vpnRunning: Boolean,
526+
hideDisconnectAction: Boolean,
527+
exitNodeName: String? = null
528+
): Notification {
529+
val title = getString(if (vpnRunning) R.string.connected else R.string.not_connected)
530+
val message =
531+
if (vpnRunning && exitNodeName != null) {
532+
getString(R.string.using_exit_node, exitNodeName)
533+
} else null
500534
val icon = if (vpnRunning) R.drawable.ic_notification else R.drawable.ic_notification_disabled
501535
val action =
502536
if (vpnRunning) IPNReceiver.INTENT_DISCONNECT_VPN else IPNReceiver.INTENT_CONNECT_VPN
@@ -520,7 +554,7 @@ open class UninitializedApp : Application() {
520554
val builder =
521555
NotificationCompat.Builder(this, STATUS_CHANNEL_ID)
522556
.setSmallIcon(icon)
523-
.setContentTitle(getString(R.string.app_name))
557+
.setContentTitle(title)
524558
.setContentText(message)
525559
.setAutoCancel(!vpnRunning)
526560
.setOnlyAlertOnce(!vpnRunning)

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

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@ import com.tailscale.ipn.mdm.MDMSettings
1212
import com.tailscale.ipn.ui.model.Ipn
1313
import com.tailscale.ipn.ui.notifier.Notifier
1414
import com.tailscale.ipn.util.TSLog
15+
import java.util.UUID
1516
import kotlinx.coroutines.CoroutineScope
1617
import kotlinx.coroutines.Dispatchers
1718
import kotlinx.coroutines.flow.first
1819
import kotlinx.coroutines.launch
1920
import libtailscale.Libtailscale
20-
import java.util.UUID
2121

2222
open class IPNService : VpnService(), libtailscale.IPNService {
2323
private val TAG = "IPNService"
@@ -47,11 +47,7 @@ open class IPNService : VpnService(), libtailscale.IPNService {
4747
START_NOT_STICKY
4848
}
4949
ACTION_START_VPN -> {
50-
scope.launch {
51-
// Collect the first value of hideDisconnectAction asynchronously.
52-
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
53-
showForegroundNotification(hideDisconnectAction.value)
54-
}
50+
scope.launch { showForegroundNotification() }
5551
app.setWantRunning(true)
5652
Libtailscale.requestVPN(this)
5753
START_STICKY
@@ -63,7 +59,9 @@ open class IPNService : VpnService(), libtailscale.IPNService {
6359
scope.launch {
6460
// Collect the first value of hideDisconnectAction asynchronously.
6561
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
66-
app.notifyStatus(true, hideDisconnectAction.value)
62+
val exitNodeName =
63+
UninitializedApp.getExitNodeName(Notifier.prefs.value, Notifier.netmap.value)
64+
app.notifyStatus(true, hideDisconnectAction.value, exitNodeName)
6765
}
6866
app.setWantRunning(true)
6967
Libtailscale.requestVPN(this)
@@ -73,11 +71,7 @@ open class IPNService : VpnService(), libtailscale.IPNService {
7371
// This means that we were restarted after the service was killed
7472
// (potentially due to OOM).
7573
if (UninitializedApp.get().isAbleToStartVPN()) {
76-
scope.launch {
77-
// Collect the first value of hideDisconnectAction asynchronously.
78-
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
79-
showForegroundNotification(hideDisconnectAction.value)
80-
}
74+
scope.launch { showForegroundNotification() }
8175
App.get()
8276
Libtailscale.requestVPN(this)
8377
START_STICKY
@@ -114,16 +108,25 @@ open class IPNService : VpnService(), libtailscale.IPNService {
114108
app.getAppScopedViewModel().setVpnPrepared(isPrepared)
115109
}
116110

117-
private fun showForegroundNotification(hideDisconnectAction: Boolean) {
111+
private fun showForegroundNotification(
112+
hideDisconnectAction: Boolean,
113+
exitNodeName: String? = null
114+
) {
118115
try {
119116
startForeground(
120117
UninitializedApp.STATUS_NOTIFICATION_ID,
121-
UninitializedApp.get().buildStatusNotification(true, hideDisconnectAction))
118+
UninitializedApp.get().buildStatusNotification(true, hideDisconnectAction, exitNodeName))
122119
} catch (e: Exception) {
123120
TSLog.e(TAG, "Failed to start foreground service: $e")
124121
}
125122
}
126123

124+
private fun showForegroundNotification() {
125+
val hideDisconnectAction = MDMSettings.forceEnabled.flow.value.value
126+
val exitNodeName = UninitializedApp.getExitNodeName(Notifier.prefs.value, Notifier.netmap.value)
127+
showForegroundNotification(hideDisconnectAction, exitNodeName)
128+
}
129+
127130
private fun configIntent(): PendingIntent {
128131
return PendingIntent.getActivity(
129132
this,

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

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import android.net.NetworkCapabilities
1717
import android.os.Build
1818
import android.os.Bundle
1919
import android.provider.Settings
20-
import android.util.Log
2120
import androidx.activity.ComponentActivity
2221
import androidx.activity.compose.setContent
2322
import androidx.activity.result.ActivityResultLauncher
@@ -198,7 +197,7 @@ class MainActivity : ComponentActivity() {
198197
onNavigateToSearch = {
199198
viewModel.enableSearchAutoFocus()
200199
navController.navigate("search")
201-
})
200+
})
202201

203202
val settingsNav =
204203
SettingsNav(
@@ -245,9 +244,8 @@ class MainActivity : ComponentActivity() {
245244
viewModel = viewModel,
246245
navController = navController,
247246
onNavigateBack = { navController.popBackStack() },
248-
autoFocus = autoFocus
249-
)
250-
}
247+
autoFocus = autoFocus)
248+
}
251249
composable("settings") { SettingsView(settingsNav) }
252250
composable("exitNodes") { ExitNodePicker(exitNodePickerNav) }
253251
composable("health") { HealthView(backTo("main")) }
@@ -365,23 +363,21 @@ class MainActivity : ComponentActivity() {
365363
override fun onNewIntent(intent: Intent) {
366364
super.onNewIntent(intent)
367365
if (intent.getBooleanExtra(START_AT_ROOT, false)) {
368-
if (this::navController.isInitialized) {
369-
val previousEntry = navController.previousBackStackEntry
370-
TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry")
371-
372-
if (previousEntry != null) {
373-
navController.popBackStack(route = "main", inclusive = false)
374-
} else {
375-
TSLog.e("MainActivity", "onNewIntent: No previous back stack entry, navigating directly to 'main'")
376-
navController.navigate("main") {
377-
popUpTo("main") { inclusive = true }
378-
}
379-
}
366+
if (this::navController.isInitialized) {
367+
val previousEntry = navController.previousBackStackEntry
368+
TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry")
369+
370+
if (previousEntry != null) {
371+
navController.popBackStack(route = "main", inclusive = false)
372+
} else {
373+
TSLog.e(
374+
"MainActivity",
375+
"onNewIntent: No previous back stack entry, navigating directly to 'main'")
376+
navController.navigate("main") { popUpTo("main") { inclusive = true } }
380377
}
378+
}
381379
}
382-
}
383-
384-
380+
}
385381

386382
private fun login(urlString: String) {
387383
// Launch coroutine to listen for state changes. When the user completes login, relaunch

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ import android.net.NetworkCapabilities
99
import android.net.NetworkRequest
1010
import android.util.Log
1111
import com.tailscale.ipn.util.TSLog
12-
import libtailscale.Libtailscale
1312
import java.util.concurrent.locks.ReentrantLock
1413
import kotlin.concurrent.withLock
14+
import libtailscale.Libtailscale
1515

1616
object NetworkChangeCallback {
1717

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

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,12 @@ import com.tailscale.ipn.ui.util.set
2121
import com.tailscale.ipn.ui.util.universalFit
2222
import com.tailscale.ipn.ui.view.TaildropView
2323
import com.tailscale.ipn.util.TSLog
24+
import kotlin.random.Random
2425
import kotlinx.coroutines.Dispatchers
2526
import kotlinx.coroutines.flow.MutableStateFlow
2627
import kotlinx.coroutines.flow.StateFlow
2728
import kotlinx.coroutines.launch
2829
import kotlinx.coroutines.withContext
29-
import kotlin.random.Random
3030

3131
// ShareActivity is the entry point for Taildrop share intents
3232
class ShareActivity : ComponentActivity() {
@@ -92,25 +92,22 @@ class ShareActivity : ComponentActivity() {
9292
}
9393
}
9494

95-
val pendingFiles: List<Ipn.OutgoingFile> =
95+
val pendingFiles: List<Ipn.OutgoingFile> =
9696
uris?.filterNotNull()?.mapNotNull { uri ->
97-
contentResolver?.query(uri, null, null, null, null)?.use { cursor ->
98-
val nameCol = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
99-
val sizeCol = cursor.getColumnIndex(OpenableColumns.SIZE)
100-
101-
if (cursor.moveToFirst()) {
102-
val name: String = cursor.getString(nameCol)
103-
?: generateFallbackName(uri)
104-
val size: Long = cursor.getLong(sizeCol)
105-
Ipn.OutgoingFile(Name = name, DeclaredSize = size).apply {
106-
this.uri = uri
107-
}
108-
} else {
109-
TSLog.e(TAG, "Cursor is empty for URI: $uri")
110-
null
111-
}
97+
contentResolver?.query(uri, null, null, null, null)?.use { cursor ->
98+
val nameCol = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
99+
val sizeCol = cursor.getColumnIndex(OpenableColumns.SIZE)
100+
101+
if (cursor.moveToFirst()) {
102+
val name: String = cursor.getString(nameCol) ?: generateFallbackName(uri)
103+
val size: Long = cursor.getLong(sizeCol)
104+
Ipn.OutgoingFile(Name = name, DeclaredSize = size).apply { this.uri = uri }
105+
} else {
106+
TSLog.e(TAG, "Cursor is empty for URI: $uri")
107+
null
112108
}
113-
} ?: emptyList()
109+
}
110+
} ?: emptyList()
114111

115112
if (pendingFiles.isEmpty()) {
116113
TSLog.e(TAG, "Share failure - no files extracted from intent")
@@ -124,5 +121,5 @@ class ShareActivity : ComponentActivity() {
124121
val mimeType = contentResolver?.getType(uri)
125122
val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) }
126123
return if (extension != null) "$randomId.$extension" else randomId.toString()
127-
}
124+
}
128125
}

0 commit comments

Comments
 (0)