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.Request
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.notifier.HealthNotifier
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory
import com.tailscale.ipn.util.FeatureFlags
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.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
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 {
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@ -165,10 +167,15 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
initViewModels()
applicationScope.launch {
Notifier.state.collect { _ ->
combine(Notifier.state, MDMSettings.forceEnabled.flow) { state, forceEnabled ->
Pair(state, forceEnabled)
combine(Notifier.state, MDMSettings.forceEnabled.flow, Notifier.prefs, Notifier.netmap) {
state,
forceEnabled,
prefs,
netmap ->
Triple(state, forceEnabled, getExitNodeName(prefs, netmap))
}
.collect { (state, hideDisconnectAction) ->
.distinctUntilChanged()
.collect { (state, hideDisconnectAction, exitNodeName) ->
val ableToStartVPN = state > Ipn.State.NeedsMachineAuth
// If VPN is stopped, show a disconnected notification. If it is running as a
// foreground
@ -183,7 +190,10 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
// Update notification status when VPN is running
if (vpnRunning) {
notifyStatus(vpnRunning = true, hideDisconnectAction = hideDisconnectAction.value)
notifyStatus(
vpnRunning = true,
hideDisconnectAction = hideDisconnectAction.value,
exitNodeName = exitNodeName)
}
}
}
@ -391,6 +401,18 @@ open class UninitializedApp : Application() {
fun get(): UninitializedApp {
return appInstance
}
/**
* Return the name of the active (but not the selected/prior one) exit node based on the
* provided [Ipn.Prefs] and [Netmap.NetworkMap].
*
* @return The name of the exit node or `null` if there isn't one.
*/
fun getExitNodeName(prefs: Ipn.Prefs?, netmap: Netmap.NetworkMap?): String? {
return prefs?.activeExitNodeID?.let { exitNodeID ->
netmap?.Peers?.find { it.StableID == exitNodeID }?.exitNodeName
}
}
}
protected fun setUnprotectedInstance(instance: UninitializedApp) {
@ -476,8 +498,12 @@ open class UninitializedApp : Application() {
notificationManager.createNotificationChannel(channel)
}
fun notifyStatus(vpnRunning: Boolean, hideDisconnectAction: Boolean) {
notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction))
fun notifyStatus(
vpnRunning: Boolean,
hideDisconnectAction: Boolean,
exitNodeName: String? = null
) {
notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction, exitNodeName))
}
fun notifyStatus(notification: Notification) {
@ -495,8 +521,16 @@ open class UninitializedApp : Application() {
notificationManager.notify(STATUS_NOTIFICATION_ID, notification)
}
fun buildStatusNotification(vpnRunning: Boolean, hideDisconnectAction: Boolean): Notification {
val message = getString(if (vpnRunning) R.string.connected else R.string.not_connected)
fun buildStatusNotification(
vpnRunning: Boolean,
hideDisconnectAction: Boolean,
exitNodeName: String? = null
): Notification {
val title = getString(if (vpnRunning) R.string.connected else R.string.not_connected)
val message =
if (vpnRunning && exitNodeName != null) {
getString(R.string.using_exit_node, exitNodeName)
} else null
val icon = if (vpnRunning) R.drawable.ic_notification else R.drawable.ic_notification_disabled
val action =
if (vpnRunning) IPNReceiver.INTENT_DISCONNECT_VPN else IPNReceiver.INTENT_CONNECT_VPN
@ -520,7 +554,7 @@ open class UninitializedApp : Application() {
val builder =
NotificationCompat.Builder(this, STATUS_CHANNEL_ID)
.setSmallIcon(icon)
.setContentTitle(getString(R.string.app_name))
.setContentTitle(title)
.setContentText(message)
.setAutoCancel(!vpnRunning)
.setOnlyAlertOnce(!vpnRunning)

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

@ -17,7 +17,6 @@ import android.net.NetworkCapabilities
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
@ -198,7 +197,7 @@ class MainActivity : ComponentActivity() {
onNavigateToSearch = {
viewModel.enableSearchAutoFocus()
navController.navigate("search")
})
})
val settingsNav =
SettingsNav(
@ -245,9 +244,8 @@ class MainActivity : ComponentActivity() {
viewModel = viewModel,
navController = navController,
onNavigateBack = { navController.popBackStack() },
autoFocus = autoFocus
)
}
autoFocus = autoFocus)
}
composable("settings") { SettingsView(settingsNav) }
composable("exitNodes") { ExitNodePicker(exitNodePickerNav) }
composable("health") { HealthView(backTo("main")) }
@ -365,23 +363,21 @@ class MainActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
if (intent.getBooleanExtra(START_AT_ROOT, false)) {
if (this::navController.isInitialized) {
val previousEntry = navController.previousBackStackEntry
TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry")
if (previousEntry != null) {
navController.popBackStack(route = "main", inclusive = false)
} else {
TSLog.e("MainActivity", "onNewIntent: No previous back stack entry, navigating directly to 'main'")
navController.navigate("main") {
popUpTo("main") { inclusive = true }
}
}
if (this::navController.isInitialized) {
val previousEntry = navController.previousBackStackEntry
TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry")
if (previousEntry != null) {
navController.popBackStack(route = "main", inclusive = false)
} else {
TSLog.e(
"MainActivity",
"onNewIntent: No previous back stack entry, navigating directly to 'main'")
navController.navigate("main") { popUpTo("main") { inclusive = true } }
}
}
}
}
}
private fun login(urlString: String) {
// 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.util.Log
import com.tailscale.ipn.util.TSLog
import libtailscale.Libtailscale
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import libtailscale.Libtailscale
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.view.TaildropView
import com.tailscale.ipn.util.TSLog
import kotlin.random.Random
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.random.Random
// ShareActivity is the entry point for Taildrop share intents
class ShareActivity : ComponentActivity() {
@ -92,24 +92,21 @@ class ShareActivity : ComponentActivity() {
}
}
val pendingFiles: List<Ipn.OutgoingFile> =
val pendingFiles: List<Ipn.OutgoingFile> =
uris?.filterNotNull()?.mapNotNull { uri ->
contentResolver?.query(uri, null, null, null, null)?.use { cursor ->
val nameCol = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeCol = cursor.getColumnIndex(OpenableColumns.SIZE)
contentResolver?.query(uri, null, null, null, null)?.use { cursor ->
val nameCol = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeCol = cursor.getColumnIndex(OpenableColumns.SIZE)
if (cursor.moveToFirst()) {
val name: String = cursor.getString(nameCol)
?: generateFallbackName(uri)
val size: Long = cursor.getLong(sizeCol)
Ipn.OutgoingFile(Name = name, DeclaredSize = size).apply {
this.uri = uri
}
} else {
TSLog.e(TAG, "Cursor is empty for URI: $uri")
null
}
if (cursor.moveToFirst()) {
val name: String = cursor.getString(nameCol) ?: generateFallbackName(uri)
val size: Long = cursor.getLong(sizeCol)
Ipn.OutgoingFile(Name = name, DeclaredSize = size).apply { this.uri = uri }
} else {
TSLog.e(TAG, "Cursor is empty for URI: $uri")
null
}
}
} ?: emptyList()
if (pendingFiles.isEmpty()) {
@ -124,5 +121,5 @@ class ShareActivity : ComponentActivity() {
val mimeType = contentResolver?.getType(uri)
val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) }
return if (extension != null) "$randomId.$extension" else randomId.toString()
}
}
}

@ -10,7 +10,6 @@ import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.WorkerParameters
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.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
@ -18,95 +17,96 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
class UseExitNodeWorker(
appContext: Context,
workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
val app = UninitializedApp.get()
suspend fun runAndGetResult(): String? {
val exitNodeName = inputData.getString(EXIT_NODE_NAME)
val exitNodeId = if (exitNodeName.isNullOrEmpty()) {
null
} else {
if (!app.isAbleToStartVPN()) {
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
class UseExitNodeWorker(appContext: Context, workerParams: WorkerParameters) :
CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
val app = UninitializedApp.get()
suspend fun runAndGetResult(): String? {
val exitNodeName = inputData.getString(EXIT_NODE_NAME)
val exitNodeId =
if (exitNodeName.isNullOrEmpty()) {
null
} else {
if (!app.isAbleToStartVPN()) {
return app.getString(R.string.vpn_is_not_ready_to_start)
}
val allowLanAccess = inputData.getBoolean(ALLOW_LAN_ACCESS, false)
val prefsOut = Ipn.MaskedPrefs()
prefsOut.ExitNodeID = exitNodeId
prefsOut.ExitNodeAllowLANAccess = allowLanAccess
val scope = CoroutineScope(Dispatchers.Default + Job())
var result: String? = null
Client(scope).editPrefs(prefsOut) {
result = if (it.isFailure) {
it.exceptionOrNull()?.message
} else {
null
}
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)
}
scope.coroutineContext[Job]?.join()
return result
}
val result = runAndGetResult()
filteredPeers[0].StableID
}
return if (result != null) {
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 allowLanAccess = inputData.getBoolean(ALLOW_LAN_ACCESS, false)
val prefsOut = Ipn.MaskedPrefs()
prefsOut.ExitNodeID = exitNodeId
prefsOut.ExitNodeAllowLANAccess = allowLanAccess
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()
val scope = CoroutineScope(Dispatchers.Default + Job())
var result: String? = null
Client(scope).editPrefs(prefsOut) {
result =
if (it.isFailure) {
it.exceptionOrNull()?.message
} else {
null
}
}
app.notifyStatus(notification)
scope.coroutineContext[Job]?.join()
Result.failure(Data.Builder().putString(ERROR_KEY, result).build())
} else {
Result.success()
}
return result
}
companion object {
const val EXIT_NODE_NAME = "EXIT_NODE_NAME"
const val ALLOW_LAN_ACCESS = "ALLOW_LAN_ACCESS"
const val ERROR_KEY = "error"
val result = runAndGetResult()
return if (result != null) {
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
class MDMSettingsChangedReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED) {
TSLog.d("syspolicy", "MDM settings changed")
val restrictionsManager = context?.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
MDMSettings.update(App.get(), restrictionsManager)
}
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED) {
TSLog.d("syspolicy", "MDM settings changed")
val 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.os.Bundle
import com.tailscale.ipn.App
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
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 getFromPrefs(prefs: SharedPreferences): T
}
class BooleanMDMSetting(key: String, localizedTitle: String) :
MDMSetting<Boolean>(false, key, localizedTitle) {
override fun getFromBundle(bundle: Bundle) = bundle.getBoolean(key)
override fun getFromPrefs(prefs: SharedPreferences) = prefs.getBoolean(key, false)
}
class StringMDMSetting(key: String, localizedTitle: String) :
MDMSetting<String?>(null, key, localizedTitle) {
override fun getFromBundle(bundle: Bundle) = bundle.getString(key)
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
val stringArray = bundle.getStringArray(key)
if (stringArray != null) {
return stringArray.toList()
return stringArray.toList()
}
// Optionally, handle other types if necessary
val stringArrayList = bundle.getStringArrayList(key)
if (stringArrayList != null) {
return stringArrayList
return stringArrayList
}
// 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>? {
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) {
override fun getFromBundle(bundle: Bundle) =
AlwaysNeverUserDecides.fromString(bundle.getString(key))
override fun getFromPrefs(prefs: SharedPreferences) =
AlwaysNeverUserDecides.fromString(prefs.getString(key, null))
}
class ShowHideMDMSetting(key: String, localizedTitle: String) :
MDMSetting<ShowHide>(ShowHide.Show, key, localizedTitle) {
override fun getFromBundle(bundle: Bundle) =
ShowHide.fromString(bundle.getString(key))
override fun getFromBundle(bundle: Bundle) = ShowHide.fromString(bundle.getString(key))
override fun getFromPrefs(prefs: SharedPreferences) =
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.util.InputStreamAdapter
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.Dispatchers
import kotlinx.coroutines.launch
@ -23,9 +26,6 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.serializer
import libtailscale.FilePart
import java.nio.charset.Charset
import kotlin.reflect.KType
import kotlin.reflect.typeOf
private object Endpoint {
const val DEBUG = "debug"

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

@ -3,8 +3,8 @@
package com.tailscale.ipn.ui.model
import kotlinx.serialization.Serializable
import java.net.URL
import kotlinx.serialization.Serializable
class IpnState {
@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.flag
import com.tailscale.ipn.ui.viewModel.PeerSettingInfo
import java.util.Date
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import java.util.Date
class Tailcfg {
@Serializable
@ -116,9 +116,9 @@ class Tailcfg {
val exitNodeName: String
get() {
if (isMullvadNode &&
Hostinfo.Location?.Country != null &&
Hostinfo.Location?.City != null &&
Hostinfo.Location?.CountryCode != null) {
Hostinfo.Location?.Country != null &&
Hostinfo.Location?.City != null &&
Hostinfo.Location?.CountryCode != null) {
return "${Hostinfo.Location!!.CountryCode!!.flag()} ${Hostinfo.Location!!.Country!!}: ${Hostinfo.Location!!.City!!}"
}
return displayName

@ -56,7 +56,9 @@ class HealthNotifier(
// When the client is Stopped, no warnings should get added, and any warnings added
// previously should be removed.
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()
return@collect
} else {
@ -131,9 +133,8 @@ class HealthNotifier(
/**
* Sets the icon displayed to represent the overall health state.
*
* - If there are any high severity warnings, or warnings that affect internet connectivity,
* a warning icon is displayed.
* - If there are any high severity warnings, or warnings that affect internet connectivity, a
* warning 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.
*/
@ -171,8 +172,8 @@ class HealthNotifier(
}
/**
* Removes all warnings currently displayed, including any system notifications, and
* updates the icon (causing it to be set to null since the set of warnings is empty).
* Removes all warnings currently displayed, including any system notifications, and updates the
* icon (causing it to be set to null since the set of warnings is empty).
*/
private fun dropAllWarnings() {
removeNotifications(this.currentWarnings.value)

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

@ -29,33 +29,36 @@ import com.tailscale.ipn.R
@Composable
fun ClipboardValueView(value: String, title: String? = null, subtitle: String? = null) {
val isFocused = remember { mutableStateOf(false) }
val localClipboardManager = LocalClipboardManager.current
val interactionSource = remember { MutableInteractionSource() }
val isFocused = remember { mutableStateOf(false) }
val localClipboardManager = LocalClipboardManager.current
val interactionSource = remember { MutableInteractionSource() }
ListItem(
modifier = Modifier
.focusable(interactionSource = interactionSource)
.onFocusChanged { focusState -> isFocused.value = focusState.isFocused }
.clickable(
interactionSource = interactionSource,
indication = LocalIndication.current
) { localClipboardManager.setText(AnnotatedString(value)) }
.background(
if (isFocused.value) MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
else Color.Transparent
),
overlineContent = title?.let { { Text(it, style = MaterialTheme.typography.titleMedium) } },
headlineContent = { Text(text = value, style = MaterialTheme.typography.bodyMedium) },
supportingContent = subtitle?.let {
{ Text(it, modifier = Modifier.padding(top = 8.dp), style = MaterialTheme.typography.bodyMedium) }
},
trailingContent = {
Icon(
painterResource(R.drawable.clipboard),
contentDescription = stringResource(R.string.copy_to_clipboard),
modifier = Modifier.size(24.dp)
)
}
)
ListItem(
modifier =
Modifier.focusable(interactionSource = interactionSource)
.onFocusChanged { focusState -> isFocused.value = focusState.isFocused }
.clickable(
interactionSource = interactionSource, indication = LocalIndication.current) {
localClipboardManager.setText(AnnotatedString(value))
}
.background(
if (isFocused.value) MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
else Color.Transparent),
overlineContent = title?.let { { Text(it, style = MaterialTheme.typography.titleMedium) } },
headlineContent = { Text(text = value, style = MaterialTheme.typography.bodyMedium) },
supportingContent =
subtitle?.let {
{
Text(
it,
modifier = Modifier.padding(top = 8.dp),
style = MaterialTheme.typography.bodyMedium)
}
},
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
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
fun titleString(): String {
return when (this) {
is NotConnected -> stringResource(id = R.string.not_connected)
is Derp -> stringResource(R.string.relayed_connection, relayName)
is Direct -> stringResource(R.string.direct_connection)
}
@Composable
fun titleString(): String {
return when (this) {
is NotConnected -> stringResource(id = R.string.not_connected)
is Derp -> stringResource(R.string.relayed_connection, relayName)
is Direct -> stringResource(R.string.direct_connection)
}
}
fun contentKey(): String {
return when (this) {
is NotConnected -> "NotConnected"
is Derp -> "Derp($relayName)"
is Direct -> "Direct"
}
fun contentKey(): String {
return when (this) {
is NotConnected -> "NotConnected"
is Derp -> "Derp($relayName)"
is Direct -> "Direct"
}
}
fun iconDrawable(): Int {
return when (this) {
is NotConnected -> R.drawable.xmark_circle
is Derp -> R.drawable.link_off
is Direct -> R.drawable.link
}
fun iconDrawable(): Int {
return when (this) {
is NotConnected -> R.drawable.xmark_circle
is Derp -> R.drawable.link_off
is Direct -> R.drawable.link
}
@Composable
fun color(): Color {
return when (this) {
is NotConnected -> MaterialTheme.colorScheme.onPrimary
is Derp -> MaterialTheme.colorScheme.error
is Direct -> MaterialTheme.colorScheme.on
}
}
@Composable
fun color(): Color {
return when (this) {
is NotConnected -> MaterialTheme.colorScheme.onPrimary
is Derp -> MaterialTheme.colorScheme.error
is Direct -> MaterialTheme.colorScheme.on
}
}
}

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

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

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

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

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

@ -3,6 +3,7 @@
package com.tailscale.ipn.ui.view
import android.os.Build
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
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.selected
import kotlinx.coroutines.flow.MutableStateFlow
import android.os.Build
@Composable
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.
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") {
Lists.SectionDivider()

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

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

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

@ -5,7 +5,6 @@ package com.tailscale.ipn.ui.view
import android.app.Activity
import android.os.Build
import android.util.Log
import android.window.OnBackInvokedCallback
import android.window.OnBackInvokedDispatcher
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.theme.link
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.AppVersion
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.SettingsNav
import com.tailscale.ipn.ui.viewModel.SettingsViewModel
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
fun SettingsView(
@ -49,180 +48,176 @@ fun SettingsView(
viewModel: SettingsViewModel = viewModel(),
vpnViewModel: VpnViewModel = viewModel()
) {
val handler = LocalUriHandler.current
val user by viewModel.loggedInUser.collectAsState()
val isAdmin by viewModel.isAdmin.collectAsState()
val managedByOrganization by viewModel.managedByOrganization.collectAsState()
val tailnetLockEnabled by viewModel.tailNetLockEnabled.collectAsState()
val corpDNSEnabled by viewModel.corpDNSEnabled.collectAsState()
val isVPNPrepared by vpnViewModel.vpnPrepared.collectAsState()
val showTailnetLock by MDMSettings.manageTailnetLock.flow.collectAsState()
val useTailscaleSubnets by MDMSettings.useTailscaleSubnets.flow.collectAsState()
Scaffold(topBar = {
val handler = LocalUriHandler.current
val user by viewModel.loggedInUser.collectAsState()
val isAdmin by viewModel.isAdmin.collectAsState()
val managedByOrganization by viewModel.managedByOrganization.collectAsState()
val tailnetLockEnabled by viewModel.tailNetLockEnabled.collectAsState()
val corpDNSEnabled by viewModel.corpDNSEnabled.collectAsState()
val isVPNPrepared by vpnViewModel.vpnPrepared.collectAsState()
val showTailnetLock by MDMSettings.manageTailnetLock.flow.collectAsState()
val useTailscaleSubnets by MDMSettings.useTailscaleSubnets.flow.collectAsState()
Scaffold(
topBar = {
Header(titleRes = R.string.settings_title, onBack = settingsNav.onNavigateBackHome)
}) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.verticalScroll(rememberScrollState())
) {
if (isVPNPrepared) {
UserView(
profile = user,
actionState = UserActionState.NAV,
onClick = settingsNav.onNavigateToUserSwitcher
)
}
if (isAdmin && !isAndroidTV()) {
Lists.ItemDivider()
AdminTextView { handler.openUri(Links.ADMIN_URL) }
}
Lists.SectionDivider()
Setting.Text(
R.string.dns_settings, subtitle = corpDNSEnabled?.let {
}) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding).verticalScroll(rememberScrollState())) {
if (isVPNPrepared) {
UserView(
profile = user,
actionState = UserActionState.NAV,
onClick = settingsNav.onNavigateToUserSwitcher)
}
if (isAdmin && !isAndroidTV()) {
Lists.ItemDivider()
AdminTextView { handler.openUri(Links.ADMIN_URL) }
}
Lists.SectionDivider()
Setting.Text(
R.string.dns_settings,
subtitle =
corpDNSEnabled?.let {
stringResource(
if (it) R.string.using_tailscale_dns else R.string.not_using_tailscale_dns
)
}, onClick = settingsNav.onNavigateToDNSSettings
)
if (it) R.string.using_tailscale_dns else R.string.not_using_tailscale_dns)
},
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()
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()
Setting.Text(
R.string.tailnet_lock, subtitle = tailnetLockEnabled?.let {
stringResource(if (it) R.string.enabled else R.string.disabled)
}, onClick = settingsNav.onNavigateToTailnetLock
)
}
if (useTailscaleSubnets.value == AlwaysNeverUserDecides.UserDecides) {
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)
R.string.tailnet_lock,
subtitle =
tailnetLockEnabled?.let {
stringResource(if (it) R.string.enabled else R.string.disabled)
},
onClick = settingsNav.onNavigateToTailnetLock)
}
if (useTailscaleSubnets.value == AlwaysNeverUserDecides.UserDecides) {
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(
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)
}
title = stringResource(R.string.managed_by_orgName, it),
onClick = settingsNav.onNavigateToManagedBy)
}
Lists.SectionDivider()
Setting.Text(R.string.bug_report, onClick = settingsNav.onNavigateToBugReport)
Lists.ItemDivider()
Setting.Text(
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 {
@Composable
fun Text(
titleRes: Int = 0,
title: String? = null,
subtitle: String? = null,
destructive: Boolean = false,
enabled: Boolean = true,
onClick: (() -> Unit)? = null
) {
var modifier: Modifier = Modifier
if (enabled) {
onClick?.let { modifier = modifier.clickable(onClick = it) }
}
ListItem(modifier = modifier,
colors = MaterialTheme.colorScheme.listItem,
headlineContent = {
@Composable
fun Text(
titleRes: Int = 0,
title: String? = null,
subtitle: String? = null,
destructive: Boolean = false,
enabled: Boolean = true,
onClick: (() -> Unit)? = null
) {
var modifier: Modifier = Modifier
if (enabled) {
onClick?.let { modifier = modifier.clickable(onClick = it) }
}
ListItem(
modifier = modifier,
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(
title ?: stringResource(titleRes),
style = MaterialTheme.typography.bodyMedium,
color = if (destructive) MaterialTheme.colorScheme.error else Color.Unspecified
)
},
supportingContent = subtitle?.let {
{
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
})
}
@Composable
fun Switch(
titleRes: Int = 0,
title: String? = null,
isOn: Boolean,
enabled: Boolean = true,
onToggle: (Boolean) -> Unit = {}
) {
ListItem(colors = MaterialTheme.colorScheme.listItem, headlineContent = {
Text(
title ?: stringResource(titleRes),
style = MaterialTheme.typography.bodyMedium,
)
}, trailingContent = {
TintedSwitch(checked = isOn, onCheckedChange = onToggle, enabled = enabled)
}
@Composable
fun Switch(
titleRes: Int = 0,
title: String? = null,
isOn: Boolean,
enabled: Boolean = true,
onToggle: (Boolean) -> Unit = {}
) {
ListItem(
colors = MaterialTheme.colorScheme.listItem,
headlineContent = {
Text(
title ?: stringResource(titleRes),
style = MaterialTheme.typography.bodyMedium,
)
},
trailingContent = {
TintedSwitch(checked = isOn, onCheckedChange = onToggle, enabled = enabled)
})
}
}
}
@Composable
fun AdminTextView(onNavigateToAdminConsole: () -> Unit) {
val adminStr = buildAnnotatedString {
append(stringResource(id = R.string.settings_admin_prefix))
pushStringAnnotation(tag = "link", annotation = Links.ADMIN_URL)
withStyle(
style = SpanStyle(
color = MaterialTheme.colorScheme.link, textDecoration = TextDecoration.Underline
)
) {
append(stringResource(id = R.string.settings_admin_link))
val adminStr = buildAnnotatedString {
append(stringResource(id = R.string.settings_admin_prefix))
pushStringAnnotation(tag = "link", annotation = Links.ADMIN_URL)
withStyle(
style =
SpanStyle(
color = MaterialTheme.colorScheme.link,
textDecoration = TextDecoration.Underline)) {
append(stringResource(id = R.string.settings_admin_link))
}
}
}
Lists.InfoItem(adminStr, onClick = onNavigateToAdminConsole)
Lists.InfoItem(adminStr, onClick = onNavigateToAdminConsole)
}
@Preview
@Composable
fun SettingsPreview() {
val vm = SettingsViewModel()
vm.corpDNSEnabled.set(true)
vm.tailNetLockEnabled.set(true)
vm.isAdmin.set(true)
vm.managedByOrganization.set("Tails and Scales Inc.")
SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm)
val vm = SettingsViewModel()
vm.corpDNSEnabled.set(true)
vm.tailNetLockEnabled.set(true)
vm.isAdmin.set(true)
vm.managedByOrganization.set("Tails and Scales Inc.")
SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm)
}

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

@ -22,7 +22,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@ -63,12 +62,9 @@ fun TailnetLockSetupView(
val interactionSource = remember { MutableInteractionSource() }
ListItem(
modifier =
Modifier.focusable(
interactionSource = interactionSource)
.clickable(
interactionSource = interactionSource,
indication = LocalIndication.current
) {},
Modifier.focusable(interactionSource = interactionSource).clickable(
interactionSource = interactionSource,
indication = LocalIndication.current) {},
leadingContent = {
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.standaloneLogoDotEnabled
import com.tailscale.ipn.ui.util.set
import kotlin.concurrent.timer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlin.concurrent.timer
// DotsMatrix represents the state of the progress indicator.
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.success
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import com.tailscale.ipn.util.TSLog
class DNSSettingsViewModelFactory : ViewModelProvider.Factory {
@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.util.LoadingIndicator
import com.tailscale.ipn.ui.util.set
import java.util.TreeMap
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.util.TreeMap
data class ExitNodePickerNav(
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.TimeUtil
import com.tailscale.ipn.ui.util.set
import java.time.Duration
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
@ -33,7 +34,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
import java.time.Duration
class MainViewModelFactory(private val vpnViewModel: VpnViewModel) : ViewModelProvider.Factory {
@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.util.ComposableStringFormatter
import com.tailscale.ipn.ui.util.set
import java.io.File
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import java.io.File
data class PeerSettingInfo(val titleRes: Int, val value: ComposableStringFormatter)

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

@ -9,6 +9,7 @@
<string name="disconnect">Disconnect</string>
<string name="unknown_user">Unknown user</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="empty" translatable="false"> </string>
<string name="template" translatable="false">%s</string>

@ -1,13 +1,12 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailcale.ipn.ui.util
import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.util.TSLog
import com.tailscale.ipn.util.TSLog.LibtailscaleWrapper
import java.time.Duration
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
@ -16,92 +15,76 @@ import org.junit.Test
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mockito.doNothing
import org.mockito.Mockito.mock
import java.time.Duration
class TimeUtilTest {
private lateinit var libtailscaleWrapperMock: LibtailscaleWrapper
private lateinit var originalWrapper: LibtailscaleWrapper
@Before
fun setUp() {
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
TSLog.libtailscaleWrapper = libtailscaleWrapperMock
}
@After
fun tearDown() {
// Reset TSLog after each test to avoid side effects
TSLog.libtailscaleWrapper = originalWrapper
}
@Test
fun durationInvalidMsUnits() {
val input = "5s10ms"
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
}
@Test
fun durationInvalidUsUnits() {
val input = "5s10us"
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
}
@Test
fun durationTestHappyPath() {
val input = arrayOf("1.0y1.0w1.0d1.0h1.0m1.0s", "1s", "1m", "1h", "1d", "1w", "1y")
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) }
assertEquals("Incorrect conversion", expected, actual)
}
@Test
fun testBadDurationString() {
val input = "1..0y1.0w1.0d1.0h1.0m1.0s"
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
}
@Test
fun testBadDInputString() {
val libtailscaleWrapperMock = mock(LibtailscaleWrapper::class.java)
doNothing().`when`(libtailscaleWrapperMock).sendLog(anyString(), anyString())
val input = "1.0yy1.0w1.0d1.0h1.0m1.0s"
val actual = TimeUtil.duration(input)
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)
}
private lateinit var libtailscaleWrapperMock: LibtailscaleWrapper
private lateinit var originalWrapper: LibtailscaleWrapper
@Before
fun setUp() {
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
TSLog.libtailscaleWrapper = libtailscaleWrapperMock
}
@After
fun tearDown() {
// Reset TSLog after each test to avoid side effects
TSLog.libtailscaleWrapper = originalWrapper
}
@Test
fun durationInvalidMsUnits() {
val input = "5s10ms"
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
}
@Test
fun durationInvalidUsUnits() {
val input = "5s10us"
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
}
@Test
fun durationTestHappyPath() {
val input = arrayOf("1.0y1.0w1.0d1.0h1.0m1.0s", "1s", "1m", "1h", "1d", "1w", "1y")
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) }
assertEquals("Incorrect conversion", expected, actual)
}
@Test
fun testBadDurationString() {
val input = "1..0y1.0w1.0d1.0h1.0m1.0s"
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
}
@Test
fun testBadDInputString() {
val libtailscaleWrapperMock = mock(LibtailscaleWrapper::class.java)
doNothing().`when`(libtailscaleWrapperMock).sendLog(anyString(), anyString())
val input = "1.0yy1.0w1.0d1.0h1.0m1.0s"
val actual = TimeUtil.duration(input)
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
# Preload Gradle
COPY android/gradlew android/build.gradle android
COPY android/gradlew android/build.gradle android/
COPY android/gradle android/gradle
RUN chmod 755 android/gradlew && \

Loading…
Cancel
Save