android/src/main: show exit node information in the permanent notification (#642)

* android/src: ktfmt

Signed-off-by: Jakub Meysner <git@jakubmeysner.com>

* 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 <git@jakubmeysner.com>

* 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 <git@jakubmeysner.com>

---------

Signed-off-by: Jakub Meysner <git@jakubmeysner.com>
pull/644/head
Jakub Meysner 7 months ago committed by GitHub
parent ff4a49a076
commit 8683c789fd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -30,27 +30,29 @@ import com.tailscale.ipn.mdm.MDMSettingsChangedReceiver
import com.tailscale.ipn.ui.localapi.Client import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.localapi.Request import com.tailscale.ipn.ui.localapi.Request
import com.tailscale.ipn.ui.model.Ipn 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.HealthNotifier
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.viewModel.VpnViewModel import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory
import com.tailscale.ipn.util.FeatureFlags import com.tailscale.ipn.util.FeatureFlags
import com.tailscale.ipn.util.TSLog import com.tailscale.ipn.util.TSLog
import java.io.File
import java.io.IOException
import java.net.NetworkInterface
import java.security.GeneralSecurityException
import java.util.Locale
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import libtailscale.Libtailscale import libtailscale.Libtailscale
import java.io.File
import java.io.IOException
import java.net.NetworkInterface
import java.security.GeneralSecurityException
import java.util.Locale
class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@ -165,10 +167,15 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
initViewModels() initViewModels()
applicationScope.launch { applicationScope.launch {
Notifier.state.collect { _ -> Notifier.state.collect { _ ->
combine(Notifier.state, MDMSettings.forceEnabled.flow) { state, forceEnabled -> combine(Notifier.state, MDMSettings.forceEnabled.flow, Notifier.prefs, Notifier.netmap) {
Pair(state, forceEnabled) state,
forceEnabled,
prefs,
netmap ->
Triple(state, forceEnabled, getExitNodeName(prefs, netmap))
} }
.collect { (state, hideDisconnectAction) -> .distinctUntilChanged()
.collect { (state, hideDisconnectAction, exitNodeName) ->
val ableToStartVPN = state > Ipn.State.NeedsMachineAuth val ableToStartVPN = state > Ipn.State.NeedsMachineAuth
// If VPN is stopped, show a disconnected notification. If it is running as a // If VPN is stopped, show a disconnected notification. If it is running as a
// foreground // foreground
@ -183,7 +190,10 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
// Update notification status when VPN is running // Update notification status when VPN is running
if (vpnRunning) { 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 { fun get(): UninitializedApp {
return appInstance 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) { protected fun setUnprotectedInstance(instance: UninitializedApp) {
@ -476,8 +498,12 @@ open class UninitializedApp : Application() {
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
} }
fun notifyStatus(vpnRunning: Boolean, hideDisconnectAction: Boolean) { fun notifyStatus(
notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction)) vpnRunning: Boolean,
hideDisconnectAction: Boolean,
exitNodeName: String? = null
) {
notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction, exitNodeName))
} }
fun notifyStatus(notification: Notification) { fun notifyStatus(notification: Notification) {
@ -495,8 +521,16 @@ open class UninitializedApp : Application() {
notificationManager.notify(STATUS_NOTIFICATION_ID, notification) notificationManager.notify(STATUS_NOTIFICATION_ID, notification)
} }
fun buildStatusNotification(vpnRunning: Boolean, hideDisconnectAction: Boolean): Notification { fun buildStatusNotification(
val message = getString(if (vpnRunning) R.string.connected else R.string.not_connected) 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 icon = if (vpnRunning) R.drawable.ic_notification else R.drawable.ic_notification_disabled
val action = val action =
if (vpnRunning) IPNReceiver.INTENT_DISCONNECT_VPN else IPNReceiver.INTENT_CONNECT_VPN if (vpnRunning) IPNReceiver.INTENT_DISCONNECT_VPN else IPNReceiver.INTENT_CONNECT_VPN
@ -520,7 +554,7 @@ open class UninitializedApp : Application() {
val builder = val builder =
NotificationCompat.Builder(this, STATUS_CHANNEL_ID) NotificationCompat.Builder(this, STATUS_CHANNEL_ID)
.setSmallIcon(icon) .setSmallIcon(icon)
.setContentTitle(getString(R.string.app_name)) .setContentTitle(title)
.setContentText(message) .setContentText(message)
.setAutoCancel(!vpnRunning) .setAutoCancel(!vpnRunning)
.setOnlyAlertOnce(!vpnRunning) .setOnlyAlertOnce(!vpnRunning)

@ -12,12 +12,12 @@ import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.util.TSLog import com.tailscale.ipn.util.TSLog
import java.util.UUID
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import libtailscale.Libtailscale import libtailscale.Libtailscale
import java.util.UUID
open class IPNService : VpnService(), libtailscale.IPNService { open class IPNService : VpnService(), libtailscale.IPNService {
private val TAG = "IPNService" private val TAG = "IPNService"
@ -47,11 +47,7 @@ open class IPNService : VpnService(), libtailscale.IPNService {
START_NOT_STICKY START_NOT_STICKY
} }
ACTION_START_VPN -> { ACTION_START_VPN -> {
scope.launch { scope.launch { showForegroundNotification() }
// Collect the first value of hideDisconnectAction asynchronously.
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
showForegroundNotification(hideDisconnectAction.value)
}
app.setWantRunning(true) app.setWantRunning(true)
Libtailscale.requestVPN(this) Libtailscale.requestVPN(this)
START_STICKY START_STICKY
@ -63,7 +59,9 @@ open class IPNService : VpnService(), libtailscale.IPNService {
scope.launch { scope.launch {
// Collect the first value of hideDisconnectAction asynchronously. // Collect the first value of hideDisconnectAction asynchronously.
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first() 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) app.setWantRunning(true)
Libtailscale.requestVPN(this) Libtailscale.requestVPN(this)
@ -73,11 +71,7 @@ open class IPNService : VpnService(), libtailscale.IPNService {
// This means that we were restarted after the service was killed // This means that we were restarted after the service was killed
// (potentially due to OOM). // (potentially due to OOM).
if (UninitializedApp.get().isAbleToStartVPN()) { if (UninitializedApp.get().isAbleToStartVPN()) {
scope.launch { scope.launch { showForegroundNotification() }
// Collect the first value of hideDisconnectAction asynchronously.
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
showForegroundNotification(hideDisconnectAction.value)
}
App.get() App.get()
Libtailscale.requestVPN(this) Libtailscale.requestVPN(this)
START_STICKY START_STICKY
@ -114,16 +108,25 @@ open class IPNService : VpnService(), libtailscale.IPNService {
app.getAppScopedViewModel().setVpnPrepared(isPrepared) app.getAppScopedViewModel().setVpnPrepared(isPrepared)
} }
private fun showForegroundNotification(hideDisconnectAction: Boolean) { private fun showForegroundNotification(
hideDisconnectAction: Boolean,
exitNodeName: String? = null
) {
try { try {
startForeground( startForeground(
UninitializedApp.STATUS_NOTIFICATION_ID, UninitializedApp.STATUS_NOTIFICATION_ID,
UninitializedApp.get().buildStatusNotification(true, hideDisconnectAction)) UninitializedApp.get().buildStatusNotification(true, hideDisconnectAction, exitNodeName))
} catch (e: Exception) { } catch (e: Exception) {
TSLog.e(TAG, "Failed to start foreground service: $e") 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 { private fun configIntent(): PendingIntent {
return PendingIntent.getActivity( return PendingIntent.getActivity(
this, this,

@ -17,7 +17,6 @@ import android.net.NetworkCapabilities
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
@ -198,7 +197,7 @@ class MainActivity : ComponentActivity() {
onNavigateToSearch = { onNavigateToSearch = {
viewModel.enableSearchAutoFocus() viewModel.enableSearchAutoFocus()
navController.navigate("search") navController.navigate("search")
}) })
val settingsNav = val settingsNav =
SettingsNav( SettingsNav(
@ -245,9 +244,8 @@ class MainActivity : ComponentActivity() {
viewModel = viewModel, viewModel = viewModel,
navController = navController, navController = navController,
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
autoFocus = autoFocus autoFocus = autoFocus)
) }
}
composable("settings") { SettingsView(settingsNav) } composable("settings") { SettingsView(settingsNav) }
composable("exitNodes") { ExitNodePicker(exitNodePickerNav) } composable("exitNodes") { ExitNodePicker(exitNodePickerNav) }
composable("health") { HealthView(backTo("main")) } composable("health") { HealthView(backTo("main")) }
@ -365,23 +363,21 @@ class MainActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
if (intent.getBooleanExtra(START_AT_ROOT, false)) { if (intent.getBooleanExtra(START_AT_ROOT, false)) {
if (this::navController.isInitialized) { if (this::navController.isInitialized) {
val previousEntry = navController.previousBackStackEntry val previousEntry = navController.previousBackStackEntry
TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry") TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry")
if (previousEntry != null) { if (previousEntry != null) {
navController.popBackStack(route = "main", inclusive = false) navController.popBackStack(route = "main", inclusive = false)
} else { } else {
TSLog.e("MainActivity", "onNewIntent: No previous back stack entry, navigating directly to 'main'") TSLog.e(
navController.navigate("main") { "MainActivity",
popUpTo("main") { inclusive = true } "onNewIntent: No previous back stack entry, navigating directly to 'main'")
} navController.navigate("main") { popUpTo("main") { inclusive = true } }
}
} }
}
} }
} }
private fun login(urlString: String) { private fun login(urlString: String) {
// Launch coroutine to listen for state changes. When the user completes login, relaunch // Launch coroutine to listen for state changes. When the user completes login, relaunch

@ -9,9 +9,9 @@ import android.net.NetworkCapabilities
import android.net.NetworkRequest import android.net.NetworkRequest
import android.util.Log import android.util.Log
import com.tailscale.ipn.util.TSLog import com.tailscale.ipn.util.TSLog
import libtailscale.Libtailscale
import java.util.concurrent.locks.ReentrantLock import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock import kotlin.concurrent.withLock
import libtailscale.Libtailscale
object NetworkChangeCallback { object NetworkChangeCallback {

@ -21,12 +21,12 @@ import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.util.universalFit import com.tailscale.ipn.ui.util.universalFit
import com.tailscale.ipn.ui.view.TaildropView import com.tailscale.ipn.ui.view.TaildropView
import com.tailscale.ipn.util.TSLog import com.tailscale.ipn.util.TSLog
import kotlin.random.Random
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlin.random.Random
// ShareActivity is the entry point for Taildrop share intents // ShareActivity is the entry point for Taildrop share intents
class ShareActivity : ComponentActivity() { class ShareActivity : ComponentActivity() {
@ -92,24 +92,21 @@ class ShareActivity : ComponentActivity() {
} }
} }
val pendingFiles: List<Ipn.OutgoingFile> = val pendingFiles: List<Ipn.OutgoingFile> =
uris?.filterNotNull()?.mapNotNull { uri -> uris?.filterNotNull()?.mapNotNull { uri ->
contentResolver?.query(uri, null, null, null, null)?.use { cursor -> contentResolver?.query(uri, null, null, null, null)?.use { cursor ->
val nameCol = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) val nameCol = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeCol = cursor.getColumnIndex(OpenableColumns.SIZE) val sizeCol = cursor.getColumnIndex(OpenableColumns.SIZE)
if (cursor.moveToFirst()) { if (cursor.moveToFirst()) {
val name: String = cursor.getString(nameCol) val name: String = cursor.getString(nameCol) ?: generateFallbackName(uri)
?: generateFallbackName(uri) val size: Long = cursor.getLong(sizeCol)
val size: Long = cursor.getLong(sizeCol) Ipn.OutgoingFile(Name = name, DeclaredSize = size).apply { this.uri = uri }
Ipn.OutgoingFile(Name = name, DeclaredSize = size).apply { } else {
this.uri = uri TSLog.e(TAG, "Cursor is empty for URI: $uri")
} null
} else {
TSLog.e(TAG, "Cursor is empty for URI: $uri")
null
}
} }
}
} ?: emptyList() } ?: emptyList()
if (pendingFiles.isEmpty()) { if (pendingFiles.isEmpty()) {
@ -124,5 +121,5 @@ class ShareActivity : ComponentActivity() {
val mimeType = contentResolver?.getType(uri) val mimeType = contentResolver?.getType(uri)
val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) }
return if (extension != null) "$randomId.$extension" else randomId.toString() return if (extension != null) "$randomId.$extension" else randomId.toString()
} }
} }

@ -10,7 +10,6 @@ import androidx.work.CoroutineWorker
import androidx.work.Data import androidx.work.Data
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.tailscale.ipn.UninitializedApp.Companion.STATUS_CHANNEL_ID import com.tailscale.ipn.UninitializedApp.Companion.STATUS_CHANNEL_ID
import com.tailscale.ipn.UninitializedApp.Companion.STATUS_EXIT_NODE_FAILURE_NOTIFICATION_ID
import com.tailscale.ipn.ui.localapi.Client import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
@ -18,95 +17,96 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
class UseExitNodeWorker( class UseExitNodeWorker(appContext: Context, workerParams: WorkerParameters) :
appContext: Context, CoroutineWorker(appContext, workerParams) {
workerParams: WorkerParameters override suspend fun doWork(): Result {
) : CoroutineWorker(appContext, workerParams) { val app = UninitializedApp.get()
override suspend fun doWork(): Result { suspend fun runAndGetResult(): String? {
val app = UninitializedApp.get() val exitNodeName = inputData.getString(EXIT_NODE_NAME)
suspend fun runAndGetResult(): String? {
val exitNodeName = inputData.getString(EXIT_NODE_NAME) val exitNodeId =
if (exitNodeName.isNullOrEmpty()) {
val exitNodeId = if (exitNodeName.isNullOrEmpty()) { null
null } else {
} else { if (!app.isAbleToStartVPN()) {
if (!app.isAbleToStartVPN()) { return app.getString(R.string.vpn_is_not_ready_to_start)
return app.getString(R.string.vpn_is_not_ready_to_start)
}
val peers =
(Notifier.netmap.value
?: run { return@runAndGetResult app.getString(R.string.tailscale_is_not_setup) })
.Peers ?: run { return@runAndGetResult app.getString(R.string.no_peers_found) }
val filteredPeers = peers.filter {
it.displayName == exitNodeName
}.toList()
if (filteredPeers.isEmpty()) {
return app.getString(R.string.no_peers_with_name_found, exitNodeName)
} else if (filteredPeers.size > 1) {
return app.getString(R.string.multiple_peers_with_name_found, exitNodeName)
} else if (!filteredPeers[0].isExitNode) {
return app.getString(
R.string.peer_with_name_is_not_an_exit_node,
exitNodeName
)
}
filteredPeers[0].StableID
} }
val allowLanAccess = inputData.getBoolean(ALLOW_LAN_ACCESS, false) val peers =
val prefsOut = Ipn.MaskedPrefs() (Notifier.netmap.value
prefsOut.ExitNodeID = exitNodeId ?: run {
prefsOut.ExitNodeAllowLANAccess = allowLanAccess return@runAndGetResult app.getString(R.string.tailscale_is_not_setup)
})
val scope = CoroutineScope(Dispatchers.Default + Job()) .Peers
var result: String? = null ?: run {
Client(scope).editPrefs(prefsOut) { return@runAndGetResult app.getString(R.string.no_peers_found)
result = if (it.isFailure) { }
it.exceptionOrNull()?.message
} else { val filteredPeers = peers.filter { it.displayName == exitNodeName }.toList()
null
} if (filteredPeers.isEmpty()) {
return app.getString(R.string.no_peers_with_name_found, exitNodeName)
} else if (filteredPeers.size > 1) {
return app.getString(R.string.multiple_peers_with_name_found, exitNodeName)
} else if (!filteredPeers[0].isExitNode) {
return app.getString(R.string.peer_with_name_is_not_an_exit_node, exitNodeName)
} }
scope.coroutineContext[Job]?.join() filteredPeers[0].StableID
}
return result
}
val result = runAndGetResult()
return if (result != null) { val allowLanAccess = inputData.getBoolean(ALLOW_LAN_ACCESS, false)
val intent = val prefsOut = Ipn.MaskedPrefs()
Intent(app, MainActivity::class.java).apply { prefsOut.ExitNodeID = exitNodeId
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK prefsOut.ExitNodeAllowLANAccess = allowLanAccess
}
val pendingIntent: PendingIntent =
PendingIntent.getActivity(
app, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val notification = NotificationCompat.Builder(app, STATUS_CHANNEL_ID) val scope = CoroutineScope(Dispatchers.Default + Job())
.setSmallIcon(R.drawable.ic_notification) var result: String? = null
.setContentTitle(app.getString(R.string.use_exit_node_intent_failed)) Client(scope).editPrefs(prefsOut) {
.setContentText(result) result =
.setPriority(NotificationCompat.PRIORITY_DEFAULT) if (it.isFailure) {
.setContentIntent(pendingIntent) it.exceptionOrNull()?.message
.build() } else {
null
}
}
app.notifyStatus(notification) scope.coroutineContext[Job]?.join()
Result.failure(Data.Builder().putString(ERROR_KEY, result).build()) return result
} else {
Result.success()
}
} }
companion object { val result = runAndGetResult()
const val EXIT_NODE_NAME = "EXIT_NODE_NAME"
const val ALLOW_LAN_ACCESS = "ALLOW_LAN_ACCESS" return if (result != null) {
const val ERROR_KEY = "error" val intent =
Intent(app, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent: PendingIntent =
PendingIntent.getActivity(
app, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val notification =
NotificationCompat.Builder(app, STATUS_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(app.getString(R.string.use_exit_node_intent_failed))
.setContentText(result)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.build()
app.notifyStatus(notification)
Result.failure(Data.Builder().putString(ERROR_KEY, result).build())
} else {
Result.success()
} }
}
companion object {
const val EXIT_NODE_NAME = "EXIT_NODE_NAME"
const val ALLOW_LAN_ACCESS = "ALLOW_LAN_ACCESS"
const val ERROR_KEY = "error"
}
} }

@ -11,11 +11,12 @@ import com.tailscale.ipn.App
import com.tailscale.ipn.util.TSLog import com.tailscale.ipn.util.TSLog
class MDMSettingsChangedReceiver : BroadcastReceiver() { class MDMSettingsChangedReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED) { if (intent?.action == Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED) {
TSLog.d("syspolicy", "MDM settings changed") TSLog.d("syspolicy", "MDM settings changed")
val restrictionsManager = context?.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager val restrictionsManager =
MDMSettings.update(App.get(), restrictionsManager) context?.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
} MDMSettings.update(App.get(), restrictionsManager)
} }
}
} }

@ -5,10 +5,8 @@ package com.tailscale.ipn.mdm
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import com.tailscale.ipn.App
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
data class SettingState<T>(val value: T, val isSet: Boolean) data class SettingState<T>(val value: T, val isSet: Boolean)
@ -29,18 +27,21 @@ abstract class MDMSetting<T>(defaultValue: T, val key: String, val localizedTitl
} }
protected abstract fun getFromBundle(bundle: Bundle): T protected abstract fun getFromBundle(bundle: Bundle): T
protected abstract fun getFromPrefs(prefs: SharedPreferences): T protected abstract fun getFromPrefs(prefs: SharedPreferences): T
} }
class BooleanMDMSetting(key: String, localizedTitle: String) : class BooleanMDMSetting(key: String, localizedTitle: String) :
MDMSetting<Boolean>(false, key, localizedTitle) { MDMSetting<Boolean>(false, key, localizedTitle) {
override fun getFromBundle(bundle: Bundle) = bundle.getBoolean(key) override fun getFromBundle(bundle: Bundle) = bundle.getBoolean(key)
override fun getFromPrefs(prefs: SharedPreferences) = prefs.getBoolean(key, false) override fun getFromPrefs(prefs: SharedPreferences) = prefs.getBoolean(key, false)
} }
class StringMDMSetting(key: String, localizedTitle: String) : class StringMDMSetting(key: String, localizedTitle: String) :
MDMSetting<String?>(null, key, localizedTitle) { MDMSetting<String?>(null, key, localizedTitle) {
override fun getFromBundle(bundle: Bundle) = bundle.getString(key) override fun getFromBundle(bundle: Bundle) = bundle.getString(key)
override fun getFromPrefs(prefs: SharedPreferences) = prefs.getString(key, null) override fun getFromPrefs(prefs: SharedPreferences) = prefs.getString(key, null)
} }
@ -50,13 +51,13 @@ class StringArrayListMDMSetting(key: String, localizedTitle: String) :
// Try to retrieve the value as a String[] first // Try to retrieve the value as a String[] first
val stringArray = bundle.getStringArray(key) val stringArray = bundle.getStringArray(key)
if (stringArray != null) { if (stringArray != null) {
return stringArray.toList() return stringArray.toList()
} }
// Optionally, handle other types if necessary // Optionally, handle other types if necessary
val stringArrayList = bundle.getStringArrayList(key) val stringArrayList = bundle.getStringArrayList(key)
if (stringArrayList != null) { if (stringArrayList != null) {
return stringArrayList return stringArrayList
} }
// If neither String[] nor ArrayList<String> is found, return null // If neither String[] nor ArrayList<String> is found, return null
@ -64,7 +65,7 @@ class StringArrayListMDMSetting(key: String, localizedTitle: String) :
} }
override fun getFromPrefs(prefs: SharedPreferences): List<String>? { override fun getFromPrefs(prefs: SharedPreferences): List<String>? {
return prefs.getStringSet(key, HashSet<String>())?.toList() return prefs.getStringSet(key, HashSet<String>())?.toList()
} }
} }
@ -72,14 +73,15 @@ class AlwaysNeverUserDecidesMDMSetting(key: String, localizedTitle: String) :
MDMSetting<AlwaysNeverUserDecides>(AlwaysNeverUserDecides.UserDecides, key, localizedTitle) { MDMSetting<AlwaysNeverUserDecides>(AlwaysNeverUserDecides.UserDecides, key, localizedTitle) {
override fun getFromBundle(bundle: Bundle) = override fun getFromBundle(bundle: Bundle) =
AlwaysNeverUserDecides.fromString(bundle.getString(key)) AlwaysNeverUserDecides.fromString(bundle.getString(key))
override fun getFromPrefs(prefs: SharedPreferences) = override fun getFromPrefs(prefs: SharedPreferences) =
AlwaysNeverUserDecides.fromString(prefs.getString(key, null)) AlwaysNeverUserDecides.fromString(prefs.getString(key, null))
} }
class ShowHideMDMSetting(key: String, localizedTitle: String) : class ShowHideMDMSetting(key: String, localizedTitle: String) :
MDMSetting<ShowHide>(ShowHide.Show, key, localizedTitle) { MDMSetting<ShowHide>(ShowHide.Show, key, localizedTitle) {
override fun getFromBundle(bundle: Bundle) = override fun getFromBundle(bundle: Bundle) = ShowHide.fromString(bundle.getString(key))
ShowHide.fromString(bundle.getString(key))
override fun getFromPrefs(prefs: SharedPreferences) = override fun getFromPrefs(prefs: SharedPreferences) =
ShowHide.fromString(prefs.getString(key, null)) ShowHide.fromString(prefs.getString(key, null))
} }

@ -14,6 +14,9 @@ import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.util.InputStreamAdapter import com.tailscale.ipn.ui.util.InputStreamAdapter
import com.tailscale.ipn.util.TSLog import com.tailscale.ipn.util.TSLog
import java.nio.charset.Charset
import kotlin.reflect.KType
import kotlin.reflect.typeOf
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -23,9 +26,6 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.serializer import kotlinx.serialization.serializer
import libtailscale.FilePart import libtailscale.FilePart
import java.nio.charset.Charset
import kotlin.reflect.KType
import kotlin.reflect.typeOf
private object Endpoint { private object Endpoint {
const val DEBUG = "debug" const val DEBUG = "debug"

@ -4,9 +4,9 @@
package com.tailscale.ipn.ui.model package com.tailscale.ipn.ui.model
import android.net.Uri import android.net.Uri
import java.util.UUID
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient import kotlinx.serialization.Transient
import java.util.UUID
class Ipn { class Ipn {

@ -3,8 +3,8 @@
package com.tailscale.ipn.ui.model package com.tailscale.ipn.ui.model
import kotlinx.serialization.Serializable
import java.net.URL import java.net.URL
import kotlinx.serialization.Serializable
class IpnState { class IpnState {
@Serializable @Serializable

@ -15,9 +15,9 @@ import com.tailscale.ipn.ui.util.DisplayAddress
import com.tailscale.ipn.ui.util.TimeUtil import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.ui.util.flag import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.viewModel.PeerSettingInfo import com.tailscale.ipn.ui.viewModel.PeerSettingInfo
import java.util.Date
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import java.util.Date
class Tailcfg { class Tailcfg {
@Serializable @Serializable
@ -116,9 +116,9 @@ class Tailcfg {
val exitNodeName: String val exitNodeName: String
get() { get() {
if (isMullvadNode && if (isMullvadNode &&
Hostinfo.Location?.Country != null && Hostinfo.Location?.Country != null &&
Hostinfo.Location?.City != null && Hostinfo.Location?.City != null &&
Hostinfo.Location?.CountryCode != null) { Hostinfo.Location?.CountryCode != null) {
return "${Hostinfo.Location!!.CountryCode!!.flag()} ${Hostinfo.Location!!.Country!!}: ${Hostinfo.Location!!.City!!}" return "${Hostinfo.Location!!.CountryCode!!.flag()} ${Hostinfo.Location!!.Country!!}: ${Hostinfo.Location!!.City!!}"
} }
return displayName return displayName

@ -56,7 +56,9 @@ class HealthNotifier(
// When the client is Stopped, no warnings should get added, and any warnings added // When the client is Stopped, no warnings should get added, and any warnings added
// previously should be removed. // previously should be removed.
if (ipnState == Ipn.State.Stopped) { if (ipnState == Ipn.State.Stopped) {
TSLog.d(TAG, "Ignoring and dropping all pre-existing health messages in the Stopped state") TSLog.d(
TAG,
"Ignoring and dropping all pre-existing health messages in the Stopped state")
dropAllWarnings() dropAllWarnings()
return@collect return@collect
} else { } else {
@ -131,9 +133,8 @@ class HealthNotifier(
/** /**
* Sets the icon displayed to represent the overall health state. * Sets the icon displayed to represent the overall health state.
* * - If there are any high severity warnings, or warnings that affect internet connectivity, a
* - If there are any high severity warnings, or warnings that affect internet connectivity, * warning icon is displayed.
* a warning icon is displayed.
* - If there are any other kind of warnings, an info icon is displayed. * - If there are any other kind of warnings, an info icon is displayed.
* - If there are no warnings at all, no icon is set. * - If there are no warnings at all, no icon is set.
*/ */
@ -171,8 +172,8 @@ class HealthNotifier(
} }
/** /**
* Removes all warnings currently displayed, including any system notifications, and * Removes all warnings currently displayed, including any system notifications, and updates the
* updates the icon (causing it to be set to null since the set of warnings is empty). * icon (causing it to be set to null since the set of warnings is empty).
*/ */
private fun dropAllWarnings() { private fun dropAllWarnings() {
removeNotifications(this.currentWarnings.value) removeNotifications(this.currentWarnings.value)

@ -6,19 +6,19 @@ package com.tailscale.ipn.ui.util
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
class AdvertisedRoutesHelper { class AdvertisedRoutesHelper {
companion object { companion object {
fun exitNodeOnFromPrefs(prefs: Ipn.Prefs): Boolean { fun exitNodeOnFromPrefs(prefs: Ipn.Prefs): Boolean {
var v4 = false var v4 = false
var v6 = false var v6 = false
prefs.AdvertiseRoutes?.forEach { prefs.AdvertiseRoutes?.forEach {
if (it == "0.0.0.0/0") { if (it == "0.0.0.0/0") {
v4 = true v4 = true
}
if (it == "::/0") {
v6 = true
}
}
return v4 && v6
} }
if (it == "::/0") {
v6 = true
}
}
return v4 && v6
} }
}
} }

@ -29,33 +29,36 @@ import com.tailscale.ipn.R
@Composable @Composable
fun ClipboardValueView(value: String, title: String? = null, subtitle: String? = null) { fun ClipboardValueView(value: String, title: String? = null, subtitle: String? = null) {
val isFocused = remember { mutableStateOf(false) } val isFocused = remember { mutableStateOf(false) }
val localClipboardManager = LocalClipboardManager.current val localClipboardManager = LocalClipboardManager.current
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
ListItem( ListItem(
modifier = Modifier modifier =
.focusable(interactionSource = interactionSource) Modifier.focusable(interactionSource = interactionSource)
.onFocusChanged { focusState -> isFocused.value = focusState.isFocused } .onFocusChanged { focusState -> isFocused.value = focusState.isFocused }
.clickable( .clickable(
interactionSource = interactionSource, interactionSource = interactionSource, indication = LocalIndication.current) {
indication = LocalIndication.current localClipboardManager.setText(AnnotatedString(value))
) { localClipboardManager.setText(AnnotatedString(value)) } }
.background( .background(
if (isFocused.value) MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) if (isFocused.value) MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
else Color.Transparent else Color.Transparent),
), overlineContent = title?.let { { Text(it, style = MaterialTheme.typography.titleMedium) } },
overlineContent = title?.let { { Text(it, style = MaterialTheme.typography.titleMedium) } }, headlineContent = { Text(text = value, style = MaterialTheme.typography.bodyMedium) },
headlineContent = { Text(text = value, style = MaterialTheme.typography.bodyMedium) }, supportingContent =
supportingContent = subtitle?.let { subtitle?.let {
{ Text(it, modifier = Modifier.padding(top = 8.dp), style = MaterialTheme.typography.bodyMedium) } {
}, Text(
trailingContent = { it,
Icon( modifier = Modifier.padding(top = 8.dp),
painterResource(R.drawable.clipboard), style = MaterialTheme.typography.bodyMedium)
contentDescription = stringResource(R.string.copy_to_clipboard), }
modifier = Modifier.size(24.dp) },
) trailingContent = {
} Icon(
) painterResource(R.drawable.clipboard),
contentDescription = stringResource(R.string.copy_to_clipboard),
modifier = Modifier.size(24.dp))
})
} }

@ -11,43 +11,43 @@ import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.on import com.tailscale.ipn.ui.theme.on
sealed class ConnectionMode { sealed class ConnectionMode {
class NotConnected : ConnectionMode() class NotConnected : ConnectionMode()
class Derp(val relayName: String) : ConnectionMode() class Derp(val relayName: String) : ConnectionMode()
class Direct : ConnectionMode() class Direct : ConnectionMode()
@Composable @Composable
fun titleString(): String { fun titleString(): String {
return when (this) { return when (this) {
is NotConnected -> stringResource(id = R.string.not_connected) is NotConnected -> stringResource(id = R.string.not_connected)
is Derp -> stringResource(R.string.relayed_connection, relayName) is Derp -> stringResource(R.string.relayed_connection, relayName)
is Direct -> stringResource(R.string.direct_connection) is Direct -> stringResource(R.string.direct_connection)
}
} }
}
fun contentKey(): String { fun contentKey(): String {
return when (this) { return when (this) {
is NotConnected -> "NotConnected" is NotConnected -> "NotConnected"
is Derp -> "Derp($relayName)" is Derp -> "Derp($relayName)"
is Direct -> "Direct" is Direct -> "Direct"
}
} }
}
fun iconDrawable(): Int { fun iconDrawable(): Int {
return when (this) { return when (this) {
is NotConnected -> R.drawable.xmark_circle is NotConnected -> R.drawable.xmark_circle
is Derp -> R.drawable.link_off is Derp -> R.drawable.link_off
is Direct -> R.drawable.link is Direct -> R.drawable.link
}
} }
}
@Composable
fun color(): Color { @Composable
return when (this) { fun color(): Color {
is NotConnected -> MaterialTheme.colorScheme.onPrimary return when (this) {
is Derp -> MaterialTheme.colorScheme.error is NotConnected -> MaterialTheme.colorScheme.onPrimary
is Direct -> MaterialTheme.colorScheme.on is Derp -> MaterialTheme.colorScheme.error
} is Direct -> MaterialTheme.colorScheme.on
} }
}
} }

@ -49,32 +49,28 @@ object Lists {
backgroundColor: Color = MaterialTheme.colorScheme.surface, backgroundColor: Color = MaterialTheme.colorScheme.surface,
fontColor: Color? = null fontColor: Color? = null
) { ) {
Box( Box(
modifier = Modifier modifier =
.fillMaxWidth() Modifier.fillMaxWidth().background(color = backgroundColor, shape = RectangleShape)) {
.background(color = backgroundColor, shape = RectangleShape)
) {
if (fontColor != null) { if (fontColor != null) {
Text( Text(
text = title, text = title,
modifier = Modifier modifier =
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottomPadding) Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottomPadding)
.focusable(focusable), .focusable(focusable),
style = style, style = style,
fontWeight = fontWeight, fontWeight = fontWeight,
color = fontColor color = fontColor)
)
} else { } else {
Text( Text(
text = title, text = title,
modifier = Modifier modifier =
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottomPadding) Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottomPadding)
.focusable(focusable), .focusable(focusable),
style = style, style = style,
fontWeight = fontWeight fontWeight = fontWeight)
)
} }
} }
} }
@Composable @Composable

@ -51,7 +51,7 @@ fun Avatar(
modifier = modifier =
Modifier.conditional(AndroidTVUtil.isAndroidTV(), { padding(4.dp) }) Modifier.conditional(AndroidTVUtil.isAndroidTV(), { padding(4.dp) })
.conditional( .conditional(
AndroidTVUtil.isAndroidTV() && isFocusable, AndroidTVUtil.isAndroidTV() && isFocusable,
{ {
size((size * 1.5f).dp) // Focusable area is larger than the avatar size((size * 1.5f).dp) // Focusable area is larger than the avatar
}) })

@ -140,8 +140,7 @@ fun LoginView(
placeholder = { placeholder = {
Text(strings.placeholder, style = MaterialTheme.typography.bodySmall) Text(strings.placeholder, style = MaterialTheme.typography.bodySmall)
}, },
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None) keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None))
)
}) })
ListItem( ListItem(

@ -9,12 +9,8 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CheckCircle
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
@ -36,7 +32,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
/** /**
* EditSubnetRouteDialogView is the content of the dialog that allows the user to add or edit a subnet route. * EditSubnetRouteDialogView is the content of the dialog that allows the user to add or edit a
* subnet route.
*/ */
@Composable @Composable
fun EditSubnetRouteDialogView( fun EditSubnetRouteDialogView(
@ -46,59 +43,52 @@ fun EditSubnetRouteDialogView(
onCommit: (String) -> Unit, onCommit: (String) -> Unit,
onCancel: () -> Unit onCancel: () -> Unit
) { ) {
val value by valueFlow.collectAsState() val value by valueFlow.collectAsState()
val isValueValid by isValueValidFlow.collectAsState() val isValueValid by isValueValidFlow.collectAsState()
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
) { ) {
Text(text = stringResource(R.string.enter_valid_route)) Text(text = stringResource(R.string.enter_valid_route))
Text( Text(
text = stringResource(R.string.route_help_text), text = stringResource(R.string.route_help_text),
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
fontSize = MaterialTheme.typography.bodySmall.fontSize fontSize = MaterialTheme.typography.bodySmall.fontSize)
)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
TextField( TextField(
value = value, value = value,
onValueChange = { onValueChange(it) }, onValueChange = { onValueChange(it) },
singleLine = true, singleLine = true,
isError = !isValueValid, isError = !isValueValid,
modifier = Modifier.focusRequester(focusRequester) modifier = Modifier.focusRequester(focusRequester))
)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Row( Row(modifier = Modifier.align(Alignment.End)) {
modifier = Modifier.align(Alignment.End) Button(colors = ButtonDefaults.outlinedButtonColors(), onClick = { onCancel() }) {
) { Text(stringResource(R.string.cancel))
Button(colors = ButtonDefaults.outlinedButtonColors(), onClick = { }
onCancel()
}) {
Text(stringResource(R.string.cancel))
}
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Button(onClick = { Button(onClick = { onCommit(value) }, enabled = value.isNotEmpty() && isValueValid) {
onCommit(value) Text(stringResource(R.string.ok))
}, enabled = value.isNotEmpty() && isValueValid) { }
Text(stringResource(R.string.ok))
}
}
} }
}
// When the dialog is opened, focus on the text field to present the keyboard auto-magically. // When the dialog is opened, focus on the text field to present the keyboard auto-magically.
val windowInfo = LocalWindowInfo.current val windowInfo = LocalWindowInfo.current
LaunchedEffect(windowInfo) { LaunchedEffect(windowInfo) {
snapshotFlow { windowInfo.isWindowFocused }.collect { isWindowFocused -> snapshotFlow { windowInfo.isWindowFocused }
if (isWindowFocused) { .collect { isWindowFocused ->
focusRequester.requestFocus() if (isWindowFocused) {
} focusRequester.requestFocus()
}
} }
} }
} }

@ -12,7 +12,6 @@ import androidx.compose.ui.tooling.preview.Preview
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.AppTheme import com.tailscale.ipn.ui.theme.AppTheme
enum class ErrorDialogType { enum class ErrorDialogType {
INVALID_CUSTOM_URL, INVALID_CUSTOM_URL,
LOGOUT_FAILED, LOGOUT_FAILED,
@ -54,11 +53,10 @@ enum class ErrorDialogType {
@Composable @Composable
fun ErrorDialog(type: ErrorDialogType, action: () -> Unit = {}) { fun ErrorDialog(type: ErrorDialogType, action: () -> Unit = {}) {
ErrorDialog( ErrorDialog(
title = type.title, title = type.title,
message = stringResource(id = type.message), message = stringResource(id = type.message),
buttonText = type.buttonText, buttonText = type.buttonText,
onDismiss = action onDismiss = action)
)
} }
@Composable @Composable
@ -69,11 +67,10 @@ fun ErrorDialog(
onDismiss: () -> Unit = {} onDismiss: () -> Unit = {}
) { ) {
ErrorDialog( ErrorDialog(
title = title, title = title,
message = stringResource(id = message), message = stringResource(id = message),
buttonText = buttonText, buttonText = buttonText,
onDismiss = onDismiss onDismiss = onDismiss)
)
} }
@Composable @Composable
@ -83,15 +80,15 @@ fun ErrorDialog(
@StringRes buttonText: Int = R.string.ok, @StringRes buttonText: Int = R.string.ok,
onDismiss: () -> Unit = {} onDismiss: () -> Unit = {}
) { ) {
AppTheme { AppTheme {
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text(text = stringResource(id = title)) }, title = { Text(text = stringResource(id = title)) },
text = { Text(text = message) }, text = { Text(text = message) },
confirmButton = { confirmButton = {
PrimaryActionButton(onClick = onDismiss) { Text(text = stringResource(id = buttonText)) } PrimaryActionButton(onClick = onDismiss) { Text(text = stringResource(id = buttonText)) }
}) })
} }
} }
@Preview @Preview

@ -3,6 +3,7 @@
package com.tailscale.ipn.ui.view package com.tailscale.ipn.ui.view
import android.os.Build
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -36,7 +37,6 @@ import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory
import com.tailscale.ipn.ui.viewModel.selected import com.tailscale.ipn.ui.viewModel.selected
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import android.os.Build
@Composable @Composable
fun ExitNodePicker( fun ExitNodePicker(
@ -101,7 +101,8 @@ fun ExitNodePicker(
} }
// https://developer.android.com/reference/android/net/VpnService.Builder#excludeRoute(android.net.IpPrefix) - excludeRoute is only supported in API 33+, so don't show the option if allow LAN access is not enabled. // https://developer.android.com/reference/android/net/VpnService.Builder#excludeRoute(android.net.IpPrefix) - excludeRoute is only supported in API 33+, so don't show the option if allow LAN access is not enabled.
if (!allowLanAccessMDMDisposition.value.hiddenFromUser && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (!allowLanAccessMDMDisposition.value.hiddenFromUser &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
item(key = "allowLANAccess") { item(key = "allowLANAccess") {
Lists.SectionDivider() Lists.SectionDivider()

@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -57,9 +56,7 @@ fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel(
textAlign = TextAlign.Center) textAlign = TextAlign.Center)
Box( Box(
modifier = modifier = Modifier.size(200.dp).background(MaterialTheme.colorScheme.onSurface),
Modifier.size(200.dp)
.background(MaterialTheme.colorScheme.onSurface),
contentAlignment = Alignment.Center) { contentAlignment = Alignment.Center) {
image?.let { image?.let {
Image( Image(
@ -76,12 +73,11 @@ fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel(
numCode?.let { numCode?.let {
Box( Box(
modifier = modifier =
Modifier Modifier.clip(RoundedCornerShape(6.dp))
.clip(RoundedCornerShape(6.dp))
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)), .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center) { contentAlignment = Alignment.Center) {
Text( Text(
text =it, text = it,
style = style =
MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold), MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onSurface) color = MaterialTheme.colorScheme.onSurface)

@ -45,9 +45,7 @@ fun MullvadExitNodePickerList(
LazyColumn(modifier = Modifier.padding(innerPadding)) { LazyColumn(modifier = Modifier.padding(innerPadding)) {
val sortedCountries = val sortedCountries =
mullvadExitNodes.entries.toList().sortedBy { mullvadExitNodes.entries.toList().sortedBy { it.value.first().country.lowercase() }
it.value.first().country.lowercase()
}
itemsWithDividers(sortedCountries) { (countryCode, nodes) -> itemsWithDividers(sortedCountries) { (countryCode, nodes) ->
val first = nodes.first() val first = nodes.first()

@ -47,7 +47,7 @@ import com.tailscale.ipn.ui.viewModel.PingViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PeerDetails( fun PeerDetails(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
nodeId: String, nodeId: String,
pingViewModel: PingViewModel, pingViewModel: PingViewModel,
model: PeerDetailsViewModel = model: PeerDetailsViewModel =

@ -5,7 +5,6 @@ package com.tailscale.ipn.ui.view
import android.app.Activity import android.app.Activity
import android.os.Build import android.os.Build
import android.util.Log
import android.window.OnBackInvokedCallback import android.window.OnBackInvokedCallback
import android.window.OnBackInvokedDispatcher import android.window.OnBackInvokedDispatcher
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi

@ -33,15 +33,14 @@ import com.tailscale.ipn.mdm.ShowHide
import com.tailscale.ipn.ui.Links import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.theme.link import com.tailscale.ipn.ui.theme.link
import com.tailscale.ipn.ui.theme.listItem import com.tailscale.ipn.ui.theme.listItem
import com.tailscale.ipn.ui.util.AndroidTVUtil
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
import com.tailscale.ipn.ui.util.AppVersion
import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.SettingsNav import com.tailscale.ipn.ui.viewModel.SettingsNav
import com.tailscale.ipn.ui.viewModel.SettingsViewModel import com.tailscale.ipn.ui.viewModel.SettingsViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModel import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.AndroidTVUtil
import com.tailscale.ipn.ui.util.AppVersion
@Composable @Composable
fun SettingsView( fun SettingsView(
@ -49,180 +48,176 @@ fun SettingsView(
viewModel: SettingsViewModel = viewModel(), viewModel: SettingsViewModel = viewModel(),
vpnViewModel: VpnViewModel = viewModel() vpnViewModel: VpnViewModel = viewModel()
) { ) {
val handler = LocalUriHandler.current val handler = LocalUriHandler.current
val user by viewModel.loggedInUser.collectAsState() val user by viewModel.loggedInUser.collectAsState()
val isAdmin by viewModel.isAdmin.collectAsState() val isAdmin by viewModel.isAdmin.collectAsState()
val managedByOrganization by viewModel.managedByOrganization.collectAsState() val managedByOrganization by viewModel.managedByOrganization.collectAsState()
val tailnetLockEnabled by viewModel.tailNetLockEnabled.collectAsState() val tailnetLockEnabled by viewModel.tailNetLockEnabled.collectAsState()
val corpDNSEnabled by viewModel.corpDNSEnabled.collectAsState() val corpDNSEnabled by viewModel.corpDNSEnabled.collectAsState()
val isVPNPrepared by vpnViewModel.vpnPrepared.collectAsState() val isVPNPrepared by vpnViewModel.vpnPrepared.collectAsState()
val showTailnetLock by MDMSettings.manageTailnetLock.flow.collectAsState() val showTailnetLock by MDMSettings.manageTailnetLock.flow.collectAsState()
val useTailscaleSubnets by MDMSettings.useTailscaleSubnets.flow.collectAsState() val useTailscaleSubnets by MDMSettings.useTailscaleSubnets.flow.collectAsState()
Scaffold(topBar = { Scaffold(
topBar = {
Header(titleRes = R.string.settings_title, onBack = settingsNav.onNavigateBackHome) Header(titleRes = R.string.settings_title, onBack = settingsNav.onNavigateBackHome)
}) { innerPadding -> }) { innerPadding ->
Column( Column(modifier = Modifier.padding(innerPadding).verticalScroll(rememberScrollState())) {
modifier = Modifier if (isVPNPrepared) {
.padding(innerPadding) UserView(
.verticalScroll(rememberScrollState()) profile = user,
) { actionState = UserActionState.NAV,
if (isVPNPrepared) { onClick = settingsNav.onNavigateToUserSwitcher)
UserView( }
profile = user,
actionState = UserActionState.NAV, if (isAdmin && !isAndroidTV()) {
onClick = settingsNav.onNavigateToUserSwitcher Lists.ItemDivider()
) AdminTextView { handler.openUri(Links.ADMIN_URL) }
} }
if (isAdmin && !isAndroidTV()) { Lists.SectionDivider()
Lists.ItemDivider() Setting.Text(
AdminTextView { handler.openUri(Links.ADMIN_URL) } R.string.dns_settings,
} subtitle =
corpDNSEnabled?.let {
Lists.SectionDivider()
Setting.Text(
R.string.dns_settings, subtitle = corpDNSEnabled?.let {
stringResource( stringResource(
if (it) R.string.using_tailscale_dns else R.string.not_using_tailscale_dns if (it) R.string.using_tailscale_dns else R.string.not_using_tailscale_dns)
) },
}, onClick = settingsNav.onNavigateToDNSSettings onClick = settingsNav.onNavigateToDNSSettings)
)
Lists.ItemDivider()
Setting.Text(
R.string.split_tunneling,
subtitle = stringResource(R.string.exclude_certain_apps_from_using_tailscale),
onClick = settingsNav.onNavigateToSplitTunneling)
if (showTailnetLock.value == ShowHide.Show) {
Lists.ItemDivider() Lists.ItemDivider()
Setting.Text( Setting.Text(
R.string.split_tunneling, R.string.tailnet_lock,
subtitle = stringResource(R.string.exclude_certain_apps_from_using_tailscale), subtitle =
onClick = settingsNav.onNavigateToSplitTunneling tailnetLockEnabled?.let {
) stringResource(if (it) R.string.enabled else R.string.disabled)
},
if (showTailnetLock.value == ShowHide.Show) { onClick = settingsNav.onNavigateToTailnetLock)
Lists.ItemDivider() }
Setting.Text( if (useTailscaleSubnets.value == AlwaysNeverUserDecides.UserDecides) {
R.string.tailnet_lock, subtitle = tailnetLockEnabled?.let { Lists.ItemDivider()
stringResource(if (it) R.string.enabled else R.string.disabled) Setting.Text(R.string.subnet_routing, onClick = settingsNav.onNavigateToSubnetRouting)
}, onClick = settingsNav.onNavigateToTailnetLock }
) if (!AndroidTVUtil.isAndroidTV()) {
} Lists.ItemDivider()
if (useTailscaleSubnets.value == AlwaysNeverUserDecides.UserDecides) { Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions)
Lists.ItemDivider() }
Setting.Text(
R.string.subnet_routing,
onClick = settingsNav.onNavigateToSubnetRouting
)
}
if (!AndroidTVUtil.isAndroidTV()) {
Lists.ItemDivider()
Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions)
}
managedByOrganization.value?.let {
Lists.ItemDivider()
Setting.Text(
title = stringResource(R.string.managed_by_orgName, it),
onClick = settingsNav.onNavigateToManagedBy
)
}
Lists.SectionDivider()
Setting.Text(R.string.bug_report, onClick = settingsNav.onNavigateToBugReport)
managedByOrganization.value?.let {
Lists.ItemDivider() Lists.ItemDivider()
Setting.Text( Setting.Text(
R.string.about_tailscale, title = stringResource(R.string.managed_by_orgName, it),
subtitle = "${stringResource(id = R.string.version)} ${AppVersion.Short()}", onClick = settingsNav.onNavigateToManagedBy)
onClick = settingsNav.onNavigateToAbout }
)
Lists.SectionDivider()
// TODO: put a heading for the debug section Setting.Text(R.string.bug_report, onClick = settingsNav.onNavigateToBugReport)
if (BuildConfig.DEBUG) {
Lists.SectionDivider() Lists.ItemDivider()
Lists.MutedHeader(text = stringResource(R.string.internal_debug_options)) Setting.Text(
Setting.Text(R.string.mdm_settings, onClick = settingsNav.onNavigateToMDMSettings) R.string.about_tailscale,
} subtitle = "${stringResource(id = R.string.version)} ${AppVersion.Short()}",
onClick = settingsNav.onNavigateToAbout)
// TODO: put a heading for the debug section
if (BuildConfig.DEBUG) {
Lists.SectionDivider()
Lists.MutedHeader(text = stringResource(R.string.internal_debug_options))
Setting.Text(R.string.mdm_settings, onClick = settingsNav.onNavigateToMDMSettings)
}
} }
} }
} }
object Setting { object Setting {
@Composable @Composable
fun Text( fun Text(
titleRes: Int = 0, titleRes: Int = 0,
title: String? = null, title: String? = null,
subtitle: String? = null, subtitle: String? = null,
destructive: Boolean = false, destructive: Boolean = false,
enabled: Boolean = true, enabled: Boolean = true,
onClick: (() -> Unit)? = null onClick: (() -> Unit)? = null
) { ) {
var modifier: Modifier = Modifier var modifier: Modifier = Modifier
if (enabled) { if (enabled) {
onClick?.let { modifier = modifier.clickable(onClick = it) } onClick?.let { modifier = modifier.clickable(onClick = it) }
} }
ListItem(modifier = modifier, ListItem(
colors = MaterialTheme.colorScheme.listItem, modifier = modifier,
headlineContent = { colors = MaterialTheme.colorScheme.listItem,
headlineContent = {
Text(
title ?: stringResource(titleRes),
style = MaterialTheme.typography.bodyMedium,
color = if (destructive) MaterialTheme.colorScheme.error else Color.Unspecified)
},
supportingContent =
subtitle?.let {
{
Text( Text(
title ?: stringResource(titleRes), it,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodySmall,
color = if (destructive) MaterialTheme.colorScheme.error else Color.Unspecified color = MaterialTheme.colorScheme.onSurfaceVariant)
) }
},
supportingContent = subtitle?.let {
{
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}) })
} }
@Composable @Composable
fun Switch( fun Switch(
titleRes: Int = 0, titleRes: Int = 0,
title: String? = null, title: String? = null,
isOn: Boolean, isOn: Boolean,
enabled: Boolean = true, enabled: Boolean = true,
onToggle: (Boolean) -> Unit = {} onToggle: (Boolean) -> Unit = {}
) { ) {
ListItem(colors = MaterialTheme.colorScheme.listItem, headlineContent = { ListItem(
Text( colors = MaterialTheme.colorScheme.listItem,
title ?: stringResource(titleRes), headlineContent = {
style = MaterialTheme.typography.bodyMedium, Text(
) title ?: stringResource(titleRes),
}, trailingContent = { style = MaterialTheme.typography.bodyMedium,
TintedSwitch(checked = isOn, onCheckedChange = onToggle, enabled = enabled) )
},
trailingContent = {
TintedSwitch(checked = isOn, onCheckedChange = onToggle, enabled = enabled)
}) })
} }
} }
@Composable @Composable
fun AdminTextView(onNavigateToAdminConsole: () -> Unit) { fun AdminTextView(onNavigateToAdminConsole: () -> Unit) {
val adminStr = buildAnnotatedString { val adminStr = buildAnnotatedString {
append(stringResource(id = R.string.settings_admin_prefix)) append(stringResource(id = R.string.settings_admin_prefix))
pushStringAnnotation(tag = "link", annotation = Links.ADMIN_URL) pushStringAnnotation(tag = "link", annotation = Links.ADMIN_URL)
withStyle( withStyle(
style = SpanStyle( style =
color = MaterialTheme.colorScheme.link, textDecoration = TextDecoration.Underline SpanStyle(
) color = MaterialTheme.colorScheme.link,
) { textDecoration = TextDecoration.Underline)) {
append(stringResource(id = R.string.settings_admin_link)) append(stringResource(id = R.string.settings_admin_link))
} }
} }
Lists.InfoItem(adminStr, onClick = onNavigateToAdminConsole) Lists.InfoItem(adminStr, onClick = onNavigateToAdminConsole)
} }
@Preview @Preview
@Composable @Composable
fun SettingsPreview() { fun SettingsPreview() {
val vm = SettingsViewModel() val vm = SettingsViewModel()
vm.corpDNSEnabled.set(true) vm.corpDNSEnabled.set(true)
vm.tailNetLockEnabled.set(true) vm.tailNetLockEnabled.set(true)
vm.isAdmin.set(true) vm.isAdmin.set(true)
vm.managedByOrganization.set("Tails and Scales Inc.") vm.managedByOrganization.set("Tails and Scales Inc.")
SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm) SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm)
} }

@ -19,8 +19,8 @@ import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R import com.tailscale.ipn.R
/** /**
* SubnetRouteRowView is a row in RunSubnetRouterView, representing a subnet route. * SubnetRouteRowView is a row in RunSubnetRouterView, representing a subnet route. It provides
* It provides options to edit or delete the route. * options to edit or delete the route.
* *
* @param route The subnet route itself (e.g., "192.168.1.0/24"). * @param route The subnet route itself (e.g., "192.168.1.0/24").
* @param onEdit A callback invoked when the edit icon is clicked. * @param onEdit A callback invoked when the edit icon is clicked.
@ -28,31 +28,32 @@ import com.tailscale.ipn.R
*/ */
@Composable @Composable
fun SubnetRouteRowView( fun SubnetRouteRowView(
route: String, onEdit: () -> Unit, onDelete: () -> Unit, modifier: Modifier = Modifier route: String,
onEdit: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier
) { ) {
ListItem( ListItem(
headlineContent = { Text(text = route, style = MaterialTheme.typography.bodyMedium) }, headlineContent = { Text(text = route, style = MaterialTheme.typography.bodyMedium) },
trailingContent = { trailingContent = {
Row { Row {
IconButton(onClick = onEdit) { IconButton(onClick = onEdit) {
Icon( Icon(
painterResource(R.drawable.pencil), painterResource(R.drawable.pencil),
contentDescription = stringResource(R.string.edit_route), contentDescription = stringResource(R.string.edit_route),
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp))
) }
} IconButton(
IconButton( onClick = onDelete,
onClick = onDelete, colors =
colors = IconButtonDefaults.iconButtonColors(contentColor = MaterialTheme.colorScheme.error) IconButtonDefaults.iconButtonColors(
) { contentColor = MaterialTheme.colorScheme.error)) {
Icon( Icon(
painterResource(R.drawable.xmark), painterResource(R.drawable.xmark),
contentDescription = stringResource(R.string.delete_route), contentDescription = stringResource(R.string.delete_route),
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp))
) }
} }
} },
}, modifier = modifier)
modifier = modifier
)
} }

@ -22,7 +22,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -63,12 +62,9 @@ fun TailnetLockSetupView(
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
ListItem( ListItem(
modifier = modifier =
Modifier.focusable( Modifier.focusable(interactionSource = interactionSource).clickable(
interactionSource = interactionSource) interactionSource = interactionSource,
.clickable( indication = LocalIndication.current) {},
interactionSource = interactionSource,
indication = LocalIndication.current
) {},
leadingContent = { leadingContent = {
Icon( Icon(
painter = painterResource(id = statusItem.icon), painter = painterResource(id = statusItem.icon),

@ -19,9 +19,9 @@ import com.tailscale.ipn.ui.theme.onBackgroundLogoDotEnabled
import com.tailscale.ipn.ui.theme.standaloneLogoDotDisabled import com.tailscale.ipn.ui.theme.standaloneLogoDotDisabled
import com.tailscale.ipn.ui.theme.standaloneLogoDotEnabled import com.tailscale.ipn.ui.theme.standaloneLogoDotEnabled
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import kotlin.concurrent.timer
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlin.concurrent.timer
// DotsMatrix represents the state of the progress indicator. // DotsMatrix represents the state of the progress indicator.
typealias DotsMatrix = List<List<Boolean>> typealias DotsMatrix = List<List<Boolean>>

@ -18,12 +18,12 @@ import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.theme.off import com.tailscale.ipn.ui.theme.off
import com.tailscale.ipn.ui.theme.success import com.tailscale.ipn.ui.theme.success
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import com.tailscale.ipn.util.TSLog
class DNSSettingsViewModelFactory : ViewModelProvider.Factory { class DNSSettingsViewModelFactory : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")

@ -12,12 +12,12 @@ import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import java.util.TreeMap
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.TreeMap
data class ExitNodePickerNav( data class ExitNodePickerNav(
val onNavigateBackHome: () -> Unit, val onNavigateBackHome: () -> Unit,

@ -25,6 +25,7 @@ import com.tailscale.ipn.ui.util.PeerCategorizer
import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.TimeUtil import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import java.time.Duration
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -33,7 +34,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Duration
class MainViewModelFactory(private val vpnViewModel: VpnViewModel) : ViewModelProvider.Factory { class MainViewModelFactory(private val vpnViewModel: VpnViewModel) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")

@ -11,10 +11,10 @@ import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.ComposableStringFormatter import com.tailscale.ipn.ui.util.ComposableStringFormatter
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import java.io.File
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File
data class PeerSettingInfo(val titleRes: Int, val value: ComposableStringFormatter) data class PeerSettingInfo(val titleRes: Int, val value: ComposableStringFormatter)

@ -19,251 +19,243 @@ import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
* SubnetRoutingViewModel is responsible for managing the content of the subnet router management view. * SubnetRoutingViewModel is responsible for managing the content of the subnet router management
* This class watches the backend preferences and updates the UI accordingly whenever the advertised routes * view. This class watches the backend preferences and updates the UI accordingly whenever the
* change. It also handles the state of the editing dialog, and updates the preferences stored in * advertised routes change. It also handles the state of the editing dialog, and updates the
* the backend when the routes are edited in the UI. * preferences stored in the backend when the routes are edited in the UI.
*/ */
class SubnetRoutingViewModel : ViewModel() { class SubnetRoutingViewModel : ViewModel() {
private val TAG = "SubnetRoutingViewModel" private val TAG = "SubnetRoutingViewModel"
/** /** Matches the value of the "RouteAll" backend preference. */
* Matches the value of the "RouteAll" backend preference. val routeAll: StateFlow<Boolean> = MutableStateFlow(true)
*/
val routeAll: StateFlow<Boolean> = MutableStateFlow(true)
/** /**
* The advertised routes displayed at any point in time in the UI. The class observes * The advertised routes displayed at any point in time in the UI. The class observes this value
* this value for changes, and updates the backend preferences accordingly. * for changes, and updates the backend preferences accordingly.
*/ */
val advertisedRoutes: StateFlow<List<String>> = MutableStateFlow(listOf()) val advertisedRoutes: StateFlow<List<String>> = MutableStateFlow(listOf())
/** /** Whether we are presenting the add/edit dialog to set/change the value of a route. */
* Whether we are presenting the add/edit dialog to set/change the value of a route. val isPresentingDialog: StateFlow<Boolean> = MutableStateFlow(false)
*/
val isPresentingDialog: StateFlow<Boolean> = MutableStateFlow(false)
/** /**
* When editing a route, this stores the initial value. It is used to determine which * When editing a route, this stores the initial value. It is used to determine which of the
* of the previously existing routes needs to be updated. This starts as empty, and dismissing * previously existing routes needs to be updated. This starts as empty, and dismissing the edit
* the edit dialog should reset it to empty as well. * dialog should reset it to empty as well. If the user is adding a new route, this will be empty
* If the user is adding a new route, this will be empty despite isPresentingDialog being true. * despite isPresentingDialog being true.
*/ */
private val editingRoute: StateFlow<String> = MutableStateFlow("") private val editingRoute: StateFlow<String> = MutableStateFlow("")
/** /** The value currently entered in the add/edit dialog text field. */
* The value currently entered in the add/edit dialog text field. val dialogTextFieldValue: MutableStateFlow<String> = MutableStateFlow("")
*/
val dialogTextFieldValue: MutableStateFlow<String> = MutableStateFlow("")
/** /**
* True if the value currently entered in the dialog text field is valid, false otherwise. * True if the value currently entered in the dialog text field is valid, false otherwise. If the
* If the text field is empty, this returns true as we don't want to display an error state * text field is empty, this returns true as we don't want to display an error state when the user
* when the user hasn't entered anything. * hasn't entered anything.
*/ */
val isTextFieldValueValid: StateFlow<Boolean> = MutableStateFlow(true) val isTextFieldValueValid: StateFlow<Boolean> = MutableStateFlow(true)
/** /**
* If an error occurred while saving the ipn.Prefs to the backend this value is * If an error occurred while saving the ipn.Prefs to the backend this value is non-null.
* non-null. Subsequent successful attempts to save will clear it. * Subsequent successful attempts to save will clear it.
*/ */
val currentError: MutableStateFlow<String?> = MutableStateFlow(null) val currentError: MutableStateFlow<String?> = MutableStateFlow(null)
init { init {
viewModelScope.launch { viewModelScope.launch {
// Any time the value entered by the user in the add/edit dialog changes, we determine // Any time the value entered by the user in the add/edit dialog changes, we determine
// whether it is valid or invalid, and set isTextFieldValueValid accordingly. // whether it is valid or invalid, and set isTextFieldValueValid accordingly.
dialogTextFieldValue dialogTextFieldValue.collect { newValue ->
.collect { newValue -> if (newValue.isEmpty()) {
if (newValue.isEmpty()) { isTextFieldValueValid.set(true)
isTextFieldValueValid.set(true) return@collect
return@collect
}
val isValid = isValidCIDR(newValue)
Log.v(TAG, "isValidCIDR($newValue): $isValid")
isTextFieldValueValid.set(isValid)
return@collect
}
} }
val isValid = isValidCIDR(newValue)
Log.v(TAG, "isValidCIDR($newValue): $isValid")
isTextFieldValueValid.set(isValid)
return@collect
}
}
viewModelScope.launch { viewModelScope.launch {
// Similarly, if the routes change in the backend at any time, we should also reflect // Similarly, if the routes change in the backend at any time, we should also reflect
// that change in the UI. // that change in the UI.
Notifier.prefs Notifier.prefs
// Ignore any prefs updates without AdvertiseRoutes // Ignore any prefs updates without AdvertiseRoutes
.mapNotNull { it?.AdvertiseRoutes } .mapNotNull { it?.AdvertiseRoutes }
// Ignore duplicate values to prevent an unnecessary UI update // Ignore duplicate values to prevent an unnecessary UI update
.distinctUntilChanged() .distinctUntilChanged()
// Ignore any value that matches the current value in UI, // Ignore any value that matches the current value in UI,
// to prevent an unnecessary UI update // to prevent an unnecessary UI update
.filter { it != advertisedRoutes }.collect { newRoutesFromBackend -> .filter { it != advertisedRoutes }
Log.d( .collect { newRoutesFromBackend ->
TAG, "AdvertiseRoutes changed in the backend: $newRoutesFromBackend" Log.d(TAG, "AdvertiseRoutes changed in the backend: $newRoutesFromBackend")
) advertisedRoutes.set(newRoutesFromBackend)
advertisedRoutes.set(newRoutesFromBackend) }
} }
}
viewModelScope.launch { viewModelScope.launch {
Notifier.prefs.map { it?.RouteAll }.distinctUntilChanged().collect { Notifier.prefs
Log.d(TAG, "RouteAll changed in the backend: $it") .map { it?.RouteAll }
routeAll.set(it) .distinctUntilChanged()
} .collect {
} Log.d(TAG, "RouteAll changed in the backend: $it")
routeAll.set(it)
}
}
viewModelScope.launch { viewModelScope.launch {
routeAll.collect { routeAll.collect {
val prefsOut = Ipn.MaskedPrefs() val prefsOut = Ipn.MaskedPrefs()
prefsOut.RouteAll = it prefsOut.RouteAll = it
Log.d(TAG, "Will save RouteAll in the backend: $it") Log.d(TAG, "Will save RouteAll in the backend: $it")
Client(viewModelScope).editPrefs(prefsOut, responseHandler = { result -> Client(viewModelScope)
if (result.isFailure) { .editPrefs(
Log.e(TAG, "Error saving RouteAll: ${result.exceptionOrNull()}") prefsOut,
currentError.set(result.exceptionOrNull()?.localizedMessage) responseHandler = { result ->
return@editPrefs if (result.isFailure) {
} else { Log.e(TAG, "Error saving RouteAll: ${result.exceptionOrNull()}")
Log.d( currentError.set(result.exceptionOrNull()?.localizedMessage)
TAG, return@editPrefs
"RouteAll set in backend. New value: ${result.getOrNull()?.RouteAll}" } else {
) Log.d(
currentError.set(null) TAG, "RouteAll set in backend. New value: ${result.getOrNull()?.RouteAll}")
} currentError.set(null)
}
}) })
} }
}
} }
}
// Public functions // Public functions
fun toggleUseSubnets(onDone: () -> Unit) { fun toggleUseSubnets(onDone: () -> Unit) {
routeAll.set(!routeAll.value) routeAll.set(!routeAll.value)
onDone() onDone()
} }
/** /**
* Deletes the given subnet route from the list of advertised routes. * Deletes the given subnet route from the list of advertised routes. Calling this function will
* Calling this function will cause the backend preferences to be updated in the background. * cause the backend preferences to be updated in the background.
* *
* @param route The route string to be deleted from the list of advertised routes. * @param route The route string to be deleted from the list of advertised routes. If the route
* If the route does not exist in the list, no changes are made. * does not exist in the list, no changes are made.
*/ */
fun deleteRoute(route: String) { fun deleteRoute(route: String) {
val currentRoutes = advertisedRoutes.value.toMutableList() val currentRoutes = advertisedRoutes.value.toMutableList()
if (!currentRoutes.contains(route)) { if (!currentRoutes.contains(route)) {
Log.e(TAG, "Attempted to delete route, but it does not exist: $route") Log.e(TAG, "Attempted to delete route, but it does not exist: $route")
return return
}
currentRoutes.remove(route)
advertisedRoutes.set(currentRoutes)
saveRoutesToPrefs()
} }
currentRoutes.remove(route)
advertisedRoutes.set(currentRoutes)
saveRoutesToPrefs()
}
/** /**
* Starts editing the given subnet route. Called when the user taps the 'pencil' button * Starts editing the given subnet route. Called when the user taps the 'pencil' button on a route
* on a route in the list. * in the list.
*/ */
fun startEditingRoute(route: String) { fun startEditingRoute(route: String) {
Log.d(TAG, "startEditingRoute: $route") Log.d(TAG, "startEditingRoute: $route")
editingRoute.set(route) editingRoute.set(route)
dialogTextFieldValue.set(route) dialogTextFieldValue.set(route)
isPresentingDialog.set(true) isPresentingDialog.set(true)
} }
/** /** Commits the changes made so far in the editing dialog. */
* Commits the changes made so far in the editing dialog. fun doneEditingRoute(newValue: String) {
*/ Log.d(TAG, "doneEditingRoute: $newValue")
fun doneEditingRoute(newValue: String) { editRoute(editingRoute.value, newValue)
Log.d(TAG, "doneEditingRoute: $newValue") stopEditingRoute()
editRoute(editingRoute.value, newValue) }
stopEditingRoute()
}
/** /** Cancels any current editing session and closes the dialog. */
* Cancels any current editing session and closes the dialog. fun stopEditingRoute() {
*/ Log.d(TAG, "stopEditingRoute")
fun stopEditingRoute() { isPresentingDialog.set(false)
Log.d(TAG, "stopEditingRoute") dialogTextFieldValue.set("")
isPresentingDialog.set(false) editingRoute.set("")
dialogTextFieldValue.set("") }
editingRoute.set("")
}
/** /**
* This makes the actual changes whenever adding or editing a route. * This makes the actual changes whenever adding or editing a route. If adding a new route,
* If adding a new route, oldRoute will be empty. * oldRoute will be empty. This function validates the input before making any changes. If
* This function validates the input before making any changes. If newRoute * newRoute is not a valid CIDR IPv4/IPv6 range, this function does nothing.
* is not a valid CIDR IPv4/IPv6 range, this function does nothing. */
*/ private fun editRoute(oldRoute: String, newRoute: String) {
private fun editRoute(oldRoute: String, newRoute: String) { val currentRoutes = advertisedRoutes.value.toMutableList()
val currentRoutes = advertisedRoutes.value.toMutableList() if (oldRoute == newRoute) {
if (oldRoute == newRoute) { Log.v(TAG, "Attempted to call editRoute with the same route: $newRoute")
Log.v(TAG, "Attempted to call editRoute with the same route: $newRoute") return
return }
} if (currentRoutes.contains(newRoute)) {
if (currentRoutes.contains(newRoute)) { Log.e(TAG, "Attempted to call editRoute with a duplicate route: $newRoute")
Log.e(TAG, "Attempted to call editRoute with a duplicate route: $newRoute") return
return }
} // Verify the newRoute is a valid IPv4 or IPv6 CIDR range.
// Verify the newRoute is a valid IPv4 or IPv6 CIDR range. val isValid = isValidCIDR(newRoute)
val isValid = isValidCIDR(newRoute) if (!isValid) {
if (!isValid) { Log.e(TAG, "Attempted to call editRoute with an invalid route: $newRoute")
Log.e(TAG, "Attempted to call editRoute with an invalid route: $newRoute") return
return
}
val index = currentRoutes.indexOf(oldRoute)
if (index == -1) {
Log.v(TAG, "Adding new route: $newRoute")
currentRoutes.add(newRoute)
} else {
Log.v(TAG, "Updating route at index $index: $newRoute")
currentRoutes[index] = newRoute
}
advertisedRoutes.set(currentRoutes)
saveRoutesToPrefs()
} }
val index = currentRoutes.indexOf(oldRoute)
if (index == -1) {
Log.v(TAG, "Adding new route: $newRoute")
currentRoutes.add(newRoute)
} else {
Log.v(TAG, "Updating route at index $index: $newRoute")
currentRoutes[index] = newRoute
}
advertisedRoutes.set(currentRoutes)
saveRoutesToPrefs()
}
private fun saveRoutesToPrefs() { private fun saveRoutesToPrefs() {
val prefsOut = Ipn.MaskedPrefs() val prefsOut = Ipn.MaskedPrefs()
prefsOut.AdvertiseRoutes = advertisedRoutes.value prefsOut.AdvertiseRoutes = advertisedRoutes.value
Log.d(TAG, "Will save AdvertiseRoutes in the backend: $(advertisedRoutes.value)") Log.d(TAG, "Will save AdvertiseRoutes in the backend: $(advertisedRoutes.value)")
Client(viewModelScope).editPrefs(prefsOut, responseHandler = { result -> Client(viewModelScope)
if (result.isFailure) { .editPrefs(
prefsOut,
responseHandler = { result ->
if (result.isFailure) {
Log.e(TAG, "Error saving AdvertiseRoutes: ${result.exceptionOrNull()}") Log.e(TAG, "Error saving AdvertiseRoutes: ${result.exceptionOrNull()}")
currentError.set(result.exceptionOrNull()?.localizedMessage) currentError.set(result.exceptionOrNull()?.localizedMessage)
return@editPrefs return@editPrefs
} else { } else {
Log.d( Log.d(
TAG, TAG,
"AdvertiseRoutes set in backend. New value: ${result.getOrNull()?.AdvertiseRoutes}" "AdvertiseRoutes set in backend. New value: ${result.getOrNull()?.AdvertiseRoutes}")
)
currentError.set(null) currentError.set(null)
} }
}) })
} }
/** /**
* Clears the current error message and reloads the routes currently saved in the backend * Clears the current error message and reloads the routes currently saved in the backend to the
* to the UI. We call this when dismissing an error upon saving the routes. * UI. We call this when dismissing an error upon saving the routes.
*/ */
fun onErrorDismissed() { fun onErrorDismissed() {
currentError.set(null) currentError.set(null)
Client(viewModelScope).prefs { response -> Client(viewModelScope).prefs { response ->
Log.d(TAG, "Reloading routes from backend due to failed save: $response") Log.d(TAG, "Reloading routes from backend due to failed save: $response")
this.advertisedRoutes.set(response.getOrNull()?.AdvertiseRoutes ?: emptyList()) this.advertisedRoutes.set(response.getOrNull()?.AdvertiseRoutes ?: emptyList())
}
} }
}
companion object RouteValidation { companion object RouteValidation {
/** /** Returns true if the given String is a valid IPv4 or IPv6 CIDR range, false otherwise. */
* Returns true if the given String is a valid IPv4 or IPv6 CIDR range, false otherwise. fun isValidCIDR(newRoute: String): Boolean {
*/ val cidrPattern =
fun isValidCIDR(newRoute: String): Boolean { Regex(
val cidrPattern = "(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])/(\\d+)") // IPv4 CIDR
Regex("(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])/(\\d+)") // IPv4 CIDR val ipv6CidrPattern =
val ipv6CidrPattern = Regex(
Regex("(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))/(\\d+)") // IPv6 CIDR "(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))/(\\d+)") // IPv6 CIDR
return cidrPattern.matches(newRoute) || ipv6CidrPattern.matches(newRoute) return cidrPattern.matches(newRoute) || ipv6CidrPattern.matches(newRoute)
}
} }
}
} }

@ -9,6 +9,7 @@
<string name="disconnect">Disconnect</string> <string name="disconnect">Disconnect</string>
<string name="unknown_user">Unknown user</string> <string name="unknown_user">Unknown user</string>
<string name="connected">Connected</string> <string name="connected">Connected</string>
<string name="using_exit_node">Using exit node (%s)</string>
<string name="not_connected">Not connected</string> <string name="not_connected">Not connected</string>
<string name="empty" translatable="false"> </string> <string name="empty" translatable="false"> </string>
<string name="template" translatable="false">%s</string> <string name="template" translatable="false">%s</string>

@ -1,13 +1,12 @@
// Copyright (c) Tailscale Inc & AUTHORS // Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
package com.tailcale.ipn.ui.util package com.tailcale.ipn.ui.util
import com.tailscale.ipn.ui.util.TimeUtil import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.util.TSLog import com.tailscale.ipn.util.TSLog
import com.tailscale.ipn.util.TSLog.LibtailscaleWrapper import com.tailscale.ipn.util.TSLog.LibtailscaleWrapper
import java.time.Duration
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
@ -16,92 +15,76 @@ import org.junit.Test
import org.mockito.ArgumentMatchers.anyString import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mockito.doNothing import org.mockito.Mockito.doNothing
import org.mockito.Mockito.mock import org.mockito.Mockito.mock
import java.time.Duration
class TimeUtilTest { class TimeUtilTest {
private lateinit var libtailscaleWrapperMock: LibtailscaleWrapper
private lateinit var libtailscaleWrapperMock: LibtailscaleWrapper private lateinit var originalWrapper: LibtailscaleWrapper
private lateinit var originalWrapper: LibtailscaleWrapper
@Before
fun setUp() {
@Before libtailscaleWrapperMock = mock(LibtailscaleWrapper::class.java)
fun setUp() { doNothing().`when`(libtailscaleWrapperMock).sendLog(anyString(), anyString())
libtailscaleWrapperMock = mock(LibtailscaleWrapper::class.java)
doNothing().`when`(libtailscaleWrapperMock).sendLog(anyString(), anyString()) // Store the original wrapper so we can reset it later
originalWrapper = TSLog.libtailscaleWrapper
// Inject mock into TSLog
// Store the original wrapper so we can reset it later TSLog.libtailscaleWrapper = libtailscaleWrapperMock
originalWrapper = TSLog.libtailscaleWrapper }
// Inject mock into TSLog
TSLog.libtailscaleWrapper = libtailscaleWrapperMock @After
} fun tearDown() {
// Reset TSLog after each test to avoid side effects
TSLog.libtailscaleWrapper = originalWrapper
@After }
fun tearDown() {
// Reset TSLog after each test to avoid side effects @Test
TSLog.libtailscaleWrapper = originalWrapper fun durationInvalidMsUnits() {
} val input = "5s10ms"
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
@Test }
fun durationInvalidMsUnits() {
val input = "5s10ms" @Test
val actual = TimeUtil.duration(input) fun durationInvalidUsUnits() {
assertNull("Should return null", actual) val input = "5s10us"
} val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
}
@Test
fun durationInvalidUsUnits() { @Test
val input = "5s10us" fun durationTestHappyPath() {
val actual = TimeUtil.duration(input) val input = arrayOf("1.0y1.0w1.0d1.0h1.0m1.0s", "1s", "1m", "1h", "1d", "1w", "1y")
assertNull("Should return null", actual) val expectedSeconds =
} arrayOf((31536000 + 604800 + 86400 + 3600 + 60 + 1), 1, 60, 3600, 86400, 604800, 31536000)
val expected = expectedSeconds.map { Duration.ofSeconds(it.toLong()) }
val actual = input.map { TimeUtil.duration(it) }
@Test assertEquals("Incorrect conversion", expected, actual)
fun durationTestHappyPath() { }
val input = arrayOf("1.0y1.0w1.0d1.0h1.0m1.0s", "1s", "1m", "1h", "1d", "1w", "1y")
val expectedSeconds = @Test
arrayOf((31536000 + 604800 + 86400 + 3600 + 60 + 1), 1, 60, 3600, 86400, 604800, 31536000) fun testBadDurationString() {
val expected = expectedSeconds.map { Duration.ofSeconds(it.toLong()) } val input = "1..0y1.0w1.0d1.0h1.0m1.0s"
val actual = input.map { TimeUtil.duration(it) } val actual = TimeUtil.duration(input)
assertEquals("Incorrect conversion", expected, actual) assertNull("Should return null", actual)
} }
@Test
@Test fun testBadDInputString() {
fun testBadDurationString() { val libtailscaleWrapperMock = mock(LibtailscaleWrapper::class.java)
val input = "1..0y1.0w1.0d1.0h1.0m1.0s" doNothing().`when`(libtailscaleWrapperMock).sendLog(anyString(), anyString())
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual) val input = "1.0yy1.0w1.0d1.0h1.0m1.0s"
} val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
}
@Test
fun testBadDInputString() { @Test
val libtailscaleWrapperMock = mock(LibtailscaleWrapper::class.java) fun testIgnoreFractionalSeconds() {
doNothing().`when`(libtailscaleWrapperMock).sendLog(anyString(), anyString()) val input = "10.9s"
val expectedSeconds = 10
val expected = Duration.ofSeconds(expectedSeconds.toLong())
val input = "1.0yy1.0w1.0d1.0h1.0m1.0s" val actual = TimeUtil.duration(input)
val actual = TimeUtil.duration(input) assertEquals("Should return $expectedSeconds seconds", expected, actual)
assertNull("Should return null", actual) }
}
@Test
fun testIgnoreFractionalSeconds() {
val input = "10.9s"
val expectedSeconds = 10
val expected = Duration.ofSeconds(expectedSeconds.toLong())
val actual = TimeUtil.duration(input)
assertEquals("Should return $expectedSeconds seconds", expected, actual)
}
} }

@ -36,7 +36,7 @@ COPY Makefile Makefile
RUN make androidsdk RUN make androidsdk
# Preload Gradle # Preload Gradle
COPY android/gradlew android/build.gradle android COPY android/gradlew android/build.gradle android/
COPY android/gradle android/gradle COPY android/gradle android/gradle
RUN chmod 755 android/gradlew && \ RUN chmod 755 android/gradlew && \

Loading…
Cancel
Save