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,
.collect { (state, hideDisconnectAction) -> prefs,
netmap ->
Triple(state, forceEnabled, getExitNodeName(prefs, netmap))
}
.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
@ -245,8 +244,7 @@ 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) }
@ -372,16 +370,14 @@ class MainActivity : ComponentActivity() {
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() {
@ -99,12 +99,9 @@ class ShareActivity : ComponentActivity() {
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 { Ipn.OutgoingFile(Name = name, DeclaredSize = size).apply { this.uri = uri }
this.uri = uri
}
} else { } else {
TSLog.e(TAG, "Cursor is empty for URI: $uri") TSLog.e(TAG, "Cursor is empty for URI: $uri")
null null
@ -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,16 +17,15 @@ 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
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
val app = UninitializedApp.get() val app = UninitializedApp.get()
suspend fun runAndGetResult(): String? { suspend fun runAndGetResult(): String? {
val exitNodeName = inputData.getString(EXIT_NODE_NAME) 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()) {
@ -36,22 +34,22 @@ class UseExitNodeWorker(
val peers = val peers =
(Notifier.netmap.value (Notifier.netmap.value
?: run { return@runAndGetResult app.getString(R.string.tailscale_is_not_setup) }) ?: run {
.Peers ?: run { return@runAndGetResult app.getString(R.string.no_peers_found) } 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 { val filteredPeers = peers.filter { it.displayName == exitNodeName }.toList()
it.displayName == exitNodeName
}.toList()
if (filteredPeers.isEmpty()) { if (filteredPeers.isEmpty()) {
return app.getString(R.string.no_peers_with_name_found, exitNodeName) return app.getString(R.string.no_peers_with_name_found, exitNodeName)
} else if (filteredPeers.size > 1) { } else if (filteredPeers.size > 1) {
return app.getString(R.string.multiple_peers_with_name_found, exitNodeName) return app.getString(R.string.multiple_peers_with_name_found, exitNodeName)
} else if (!filteredPeers[0].isExitNode) { } else if (!filteredPeers[0].isExitNode) {
return app.getString( return app.getString(R.string.peer_with_name_is_not_an_exit_node, exitNodeName)
R.string.peer_with_name_is_not_an_exit_node,
exitNodeName
)
} }
filteredPeers[0].StableID filteredPeers[0].StableID
@ -65,7 +63,8 @@ class UseExitNodeWorker(
val scope = CoroutineScope(Dispatchers.Default + Job()) val scope = CoroutineScope(Dispatchers.Default + Job())
var result: String? = null var result: String? = null
Client(scope).editPrefs(prefsOut) { Client(scope).editPrefs(prefsOut) {
result = if (it.isFailure) { result =
if (it.isFailure) {
it.exceptionOrNull()?.message it.exceptionOrNull()?.message
} else { } else {
null null
@ -88,7 +87,8 @@ class UseExitNodeWorker(
PendingIntent.getActivity( PendingIntent.getActivity(
app, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) app, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val notification = NotificationCompat.Builder(app, STATUS_CHANNEL_ID) val notification =
NotificationCompat.Builder(app, STATUS_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(R.drawable.ic_notification)
.setContentTitle(app.getString(R.string.use_exit_node_intent_failed)) .setContentTitle(app.getString(R.string.use_exit_node_intent_failed))
.setContentText(result) .setContentText(result)

@ -14,7 +14,8 @@ 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 =
context?.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
MDMSettings.update(App.get(), 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)
} }
@ -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

@ -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)

@ -34,28 +34,31 @@ fun ClipboardValueView(value: String, title: String? = null, subtitle: String? =
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 = subtitle?.let { supportingContent =
{ Text(it, modifier = Modifier.padding(top = 8.dp), style = MaterialTheme.typography.bodyMedium) } subtitle?.let {
{
Text(
it,
modifier = Modifier.padding(top = 8.dp),
style = MaterialTheme.typography.bodyMedium)
}
}, },
trailingContent = { trailingContent = {
Icon( Icon(
painterResource(R.drawable.clipboard), painterResource(R.drawable.clipboard),
contentDescription = stringResource(R.string.copy_to_clipboard), contentDescription = stringResource(R.string.copy_to_clipboard),
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp))
) })
}
)
} }

@ -50,29 +50,25 @@ object Lists {
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)
)
} }
} }
} }

@ -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(
@ -58,8 +55,7 @@ fun EditSubnetRouteDialogView(
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))
@ -68,25 +64,18 @@ fun EditSubnetRouteDialogView(
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() }) {
) {
Button(colors = ButtonDefaults.outlinedButtonColors(), onClick = {
onCancel()
}) {
Text(stringResource(R.string.cancel)) 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)
}, enabled = value.isNotEmpty() && isValueValid) {
Text(stringResource(R.string.ok)) Text(stringResource(R.string.ok))
} }
} }
@ -95,7 +84,8 @@ fun EditSubnetRouteDialogView(
// 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 }
.collect { isWindowFocused ->
if (isWindowFocused) { if (isWindowFocused) {
focusRequester.requestFocus() 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,
@ -57,8 +56,7 @@ fun ErrorDialog(type: ErrorDialogType, action: () -> Unit = {}) {
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
@ -72,8 +70,7 @@ fun ErrorDialog(
title = title, title = title,
message = stringResource(id = message), message = stringResource(id = message),
buttonText = buttonText, buttonText = buttonText,
onDismiss = onDismiss onDismiss = onDismiss)
)
} }
@Composable @Composable

@ -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()

@ -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(
@ -60,20 +59,16 @@ fun SettingsView(
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
.padding(innerPadding)
.verticalScroll(rememberScrollState())
) {
if (isVPNPrepared) { if (isVPNPrepared) {
UserView( UserView(
profile = user, profile = user,
actionState = UserActionState.NAV, actionState = UserActionState.NAV,
onClick = settingsNav.onNavigateToUserSwitcher onClick = settingsNav.onNavigateToUserSwitcher)
)
} }
if (isAdmin && !isAndroidTV()) { if (isAdmin && !isAndroidTV()) {
@ -83,34 +78,33 @@ fun SettingsView(
Lists.SectionDivider() Lists.SectionDivider()
Setting.Text( Setting.Text(
R.string.dns_settings, subtitle = corpDNSEnabled?.let { 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() Lists.ItemDivider()
Setting.Text( Setting.Text(
R.string.split_tunneling, R.string.split_tunneling,
subtitle = stringResource(R.string.exclude_certain_apps_from_using_tailscale), subtitle = stringResource(R.string.exclude_certain_apps_from_using_tailscale),
onClick = settingsNav.onNavigateToSplitTunneling onClick = settingsNav.onNavigateToSplitTunneling)
)
if (showTailnetLock.value == ShowHide.Show) { if (showTailnetLock.value == ShowHide.Show) {
Lists.ItemDivider() Lists.ItemDivider()
Setting.Text( Setting.Text(
R.string.tailnet_lock, subtitle = tailnetLockEnabled?.let { R.string.tailnet_lock,
subtitle =
tailnetLockEnabled?.let {
stringResource(if (it) R.string.enabled else R.string.disabled) stringResource(if (it) R.string.enabled else R.string.disabled)
}, onClick = settingsNav.onNavigateToTailnetLock },
) onClick = settingsNav.onNavigateToTailnetLock)
} }
if (useTailscaleSubnets.value == AlwaysNeverUserDecides.UserDecides) { if (useTailscaleSubnets.value == AlwaysNeverUserDecides.UserDecides) {
Lists.ItemDivider() Lists.ItemDivider()
Setting.Text( Setting.Text(R.string.subnet_routing, onClick = settingsNav.onNavigateToSubnetRouting)
R.string.subnet_routing,
onClick = settingsNav.onNavigateToSubnetRouting
)
} }
if (!AndroidTVUtil.isAndroidTV()) { if (!AndroidTVUtil.isAndroidTV()) {
Lists.ItemDivider() Lists.ItemDivider()
@ -121,8 +115,7 @@ fun SettingsView(
Lists.ItemDivider() Lists.ItemDivider()
Setting.Text( Setting.Text(
title = stringResource(R.string.managed_by_orgName, it), title = stringResource(R.string.managed_by_orgName, it),
onClick = settingsNav.onNavigateToManagedBy onClick = settingsNav.onNavigateToManagedBy)
)
} }
Lists.SectionDivider() Lists.SectionDivider()
@ -132,8 +125,7 @@ fun SettingsView(
Setting.Text( Setting.Text(
R.string.about_tailscale, R.string.about_tailscale,
subtitle = "${stringResource(id = R.string.version)} ${AppVersion.Short()}", subtitle = "${stringResource(id = R.string.version)} ${AppVersion.Short()}",
onClick = settingsNav.onNavigateToAbout onClick = settingsNav.onNavigateToAbout)
)
// TODO: put a heading for the debug section // TODO: put a heading for the debug section
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
@ -159,22 +151,22 @@ object Setting {
if (enabled) { if (enabled) {
onClick?.let { modifier = modifier.clickable(onClick = it) } onClick?.let { modifier = modifier.clickable(onClick = it) }
} }
ListItem(modifier = modifier, ListItem(
modifier = modifier,
colors = MaterialTheme.colorScheme.listItem, colors = MaterialTheme.colorScheme.listItem,
headlineContent = { headlineContent = {
Text( Text(
title ?: stringResource(titleRes), title ?: stringResource(titleRes),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = if (destructive) MaterialTheme.colorScheme.error else Color.Unspecified color = if (destructive) MaterialTheme.colorScheme.error else Color.Unspecified)
)
}, },
supportingContent = subtitle?.let { supportingContent =
subtitle?.let {
{ {
Text( Text(
it, it,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant)
)
} }
}) })
} }
@ -187,12 +179,15 @@ object Setting {
enabled: Boolean = true, enabled: Boolean = true,
onToggle: (Boolean) -> Unit = {} onToggle: (Boolean) -> Unit = {}
) { ) {
ListItem(colors = MaterialTheme.colorScheme.listItem, headlineContent = { ListItem(
colors = MaterialTheme.colorScheme.listItem,
headlineContent = {
Text( Text(
title ?: stringResource(titleRes), title ?: stringResource(titleRes),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
}, trailingContent = { },
trailingContent = {
TintedSwitch(checked = isOn, onCheckedChange = onToggle, enabled = enabled) TintedSwitch(checked = isOn, onCheckedChange = onToggle, enabled = enabled)
}) })
} }
@ -205,10 +200,10 @@ fun AdminTextView(onNavigateToAdminConsole: () -> Unit) {
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))
} }
} }

@ -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,7 +28,10 @@ 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) },
@ -38,21 +41,19 @@ fun SubnetRouteRowView(
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 = IconButtonDefaults.iconButtonColors(contentColor = MaterialTheme.colorScheme.error) colors =
) { 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)
.clickable(
interactionSource = interactionSource, interactionSource = interactionSource,
indication = LocalIndication.current 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,53 +19,47 @@ 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)
@ -73,8 +67,7 @@ class SubnetRoutingViewModel : ViewModel() {
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
@ -96,16 +89,18 @@ class SubnetRoutingViewModel : ViewModel() {
.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
.map { it?.RouteAll }
.distinctUntilChanged()
.collect {
Log.d(TAG, "RouteAll changed in the backend: $it") Log.d(TAG, "RouteAll changed in the backend: $it")
routeAll.set(it) routeAll.set(it)
} }
@ -116,16 +111,17 @@ class SubnetRoutingViewModel : ViewModel() {
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)
.editPrefs(
prefsOut,
responseHandler = { result ->
if (result.isFailure) { if (result.isFailure) {
Log.e(TAG, "Error saving RouteAll: ${result.exceptionOrNull()}") Log.e(TAG, "Error saving RouteAll: ${result.exceptionOrNull()}")
currentError.set(result.exceptionOrNull()?.localizedMessage) currentError.set(result.exceptionOrNull()?.localizedMessage)
return@editPrefs return@editPrefs
} else { } else {
Log.d( Log.d(
TAG, TAG, "RouteAll set in backend. New value: ${result.getOrNull()?.RouteAll}")
"RouteAll set in backend. New value: ${result.getOrNull()?.RouteAll}"
)
currentError.set(null) currentError.set(null)
} }
}) })
@ -141,11 +137,11 @@ class SubnetRoutingViewModel : ViewModel() {
} }
/** /**
* 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()
@ -159,8 +155,8 @@ class SubnetRoutingViewModel : ViewModel() {
} }
/** /**
* 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")
@ -169,18 +165,14 @@ class SubnetRoutingViewModel : ViewModel() {
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) { fun doneEditingRoute(newValue: String) {
Log.d(TAG, "doneEditingRoute: $newValue") Log.d(TAG, "doneEditingRoute: $newValue")
editRoute(editingRoute.value, newValue) editRoute(editingRoute.value, newValue)
stopEditingRoute() stopEditingRoute()
} }
/** /** Cancels any current editing session and closes the dialog. */
* Cancels any current editing session and closes the dialog.
*/
fun stopEditingRoute() { fun stopEditingRoute() {
Log.d(TAG, "stopEditingRoute") Log.d(TAG, "stopEditingRoute")
isPresentingDialog.set(false) isPresentingDialog.set(false)
@ -189,10 +181,9 @@ class SubnetRoutingViewModel : ViewModel() {
} }
/** /**
* 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()
@ -226,7 +217,10 @@ class SubnetRoutingViewModel : ViewModel() {
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)
.editPrefs(
prefsOut,
responseHandler = { result ->
if (result.isFailure) { 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)
@ -234,16 +228,15 @@ class SubnetRoutingViewModel : ViewModel() {
} 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)
@ -254,16 +247,15 @@ class SubnetRoutingViewModel : ViewModel() {
} }
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 { fun isValidCIDR(newRoute: String): Boolean {
val cidrPattern = val cidrPattern =
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 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("(([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 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
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,36 +15,29 @@ 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 @Before
fun setUp() { fun setUp() {
libtailscaleWrapperMock = mock(LibtailscaleWrapper::class.java) libtailscaleWrapperMock = mock(LibtailscaleWrapper::class.java)
doNothing().`when`(libtailscaleWrapperMock).sendLog(anyString(), anyString()) doNothing().`when`(libtailscaleWrapperMock).sendLog(anyString(), anyString())
// Store the original wrapper so we can reset it later // Store the original wrapper so we can reset it later
originalWrapper = TSLog.libtailscaleWrapper originalWrapper = TSLog.libtailscaleWrapper
// Inject mock into TSLog // Inject mock into TSLog
TSLog.libtailscaleWrapper = libtailscaleWrapperMock TSLog.libtailscaleWrapper = libtailscaleWrapperMock
} }
@After @After
fun tearDown() { fun tearDown() {
// Reset TSLog after each test to avoid side effects // Reset TSLog after each test to avoid side effects
TSLog.libtailscaleWrapper = originalWrapper TSLog.libtailscaleWrapper = originalWrapper
} }
@Test @Test
fun durationInvalidMsUnits() { fun durationInvalidMsUnits() {
val input = "5s10ms" val input = "5s10ms"
@ -53,7 +45,6 @@ class TimeUtilTest {
assertNull("Should return null", actual) assertNull("Should return null", actual)
} }
@Test @Test
fun durationInvalidUsUnits() { fun durationInvalidUsUnits() {
val input = "5s10us" val input = "5s10us"
@ -61,7 +52,6 @@ class TimeUtilTest {
assertNull("Should return null", actual) assertNull("Should return null", actual)
} }
@Test @Test
fun durationTestHappyPath() { fun durationTestHappyPath() {
val input = arrayOf("1.0y1.0w1.0d1.0h1.0m1.0s", "1s", "1m", "1h", "1d", "1w", "1y") val input = arrayOf("1.0y1.0w1.0d1.0h1.0m1.0s", "1s", "1m", "1h", "1d", "1w", "1y")
@ -72,7 +62,6 @@ class TimeUtilTest {
assertEquals("Incorrect conversion", expected, actual) assertEquals("Incorrect conversion", expected, actual)
} }
@Test @Test
fun testBadDurationString() { fun testBadDurationString() {
val input = "1..0y1.0w1.0d1.0h1.0m1.0s" val input = "1..0y1.0w1.0d1.0h1.0m1.0s"
@ -80,19 +69,16 @@ class TimeUtilTest {
assertNull("Should return null", actual) assertNull("Should return null", actual)
} }
@Test @Test
fun testBadDInputString() { fun testBadDInputString() {
val libtailscaleWrapperMock = mock(LibtailscaleWrapper::class.java) val libtailscaleWrapperMock = mock(LibtailscaleWrapper::class.java)
doNothing().`when`(libtailscaleWrapperMock).sendLog(anyString(), anyString()) doNothing().`when`(libtailscaleWrapperMock).sendLog(anyString(), anyString())
val input = "1.0yy1.0w1.0d1.0h1.0m1.0s" val input = "1.0yy1.0w1.0d1.0h1.0m1.0s"
val actual = TimeUtil.duration(input) val actual = TimeUtil.duration(input)
assertNull("Should return null", actual) assertNull("Should return null", actual)
} }
@Test @Test
fun testIgnoreFractionalSeconds() { fun testIgnoreFractionalSeconds() {
val input = "10.9s" val input = "10.9s"
@ -102,6 +88,3 @@ class TimeUtilTest {
assertEquals("Should return $expectedSeconds seconds", expected, actual) 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