diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index d6d559bea9..5cf7c418e5 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -31,6 +31,7 @@ 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.FavoritesManager import com.tailscale.ipn.ui.notifier.HealthNotifier import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.viewModel.AppViewModel @@ -67,6 +68,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { private val PREF_KEY_SAF_URI = "saf_directory_uri" private const val TAG = "App" private lateinit var appInstance: App + /** * Initializes the app (if necessary) and returns the singleton app instance. Always use this * function to obtain an App reference to make sure the app initializes. @@ -87,6 +89,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { private val appViewModelStore: ViewModelStore by lazy { ViewModelStore() } var healthNotifier: HealthNotifier? = null + lateinit var favoritesManager: FavoritesManager override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString @@ -116,17 +119,20 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { STATUS_CHANNEL_ID, getString(R.string.vpn_status), getString(R.string.optional_notifications_which_display_the_status_of_the_vpn_tunnel), - NotificationManagerCompat.IMPORTANCE_MIN) + NotificationManagerCompat.IMPORTANCE_MIN, + ) createNotificationChannel( FILE_CHANNEL_ID, getString(R.string.taildrop_file_transfers), getString(R.string.notifications_delivered_when_a_file_is_received_using_taildrop), - NotificationManagerCompat.IMPORTANCE_DEFAULT) + NotificationManagerCompat.IMPORTANCE_DEFAULT, + ) createNotificationChannel( HealthNotifier.HEALTH_CHANNEL_ID, getString(R.string.health_channel_name), getString(R.string.health_channel_description), - NotificationManagerCompat.IMPORTANCE_HIGH) + NotificationManagerCompat.IMPORTANCE_HIGH, + ) } override fun onTerminate() { @@ -169,6 +175,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { startLibtailscale(this.filesDir.absolutePath, hardwareAttestation) } healthNotifier = HealthNotifier(Notifier.health, Notifier.state, applicationScope) + favoritesManager = FavoritesManager(Notifier.state, Notifier.netmap, applicationScope) connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns) initViewModels() @@ -203,7 +210,8 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { notifyStatus( vpnRunning = true, hideDisconnectAction = hideDisconnectAction.value, - exitNodeName = exitNodeName) + exitNodeName = exitNodeName, + ) } } } @@ -211,6 +219,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { TSLog.init(this) FeatureFlags.initialize(mapOf("enable_new_search" to true)) } + /** * Called when a SAF directory URI is available (either already stored or chosen). We must restart * Tailscale because directFileRoot must be set before LocalBackend starts being used. @@ -235,17 +244,20 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { onSuccess = { onSuccess?.invoke() }, onFailure = { error -> TSLog.d("TAG", "Set want running: failed to update preferences: ${error.message}") - }) + }, + ) } Client(applicationScope) .editPrefs(Ipn.MaskedPrefs().apply { WantRunning = wantRunning }, callback) } + // encryptToPref a byte array of data using the Jetpack Security // library and writes it to a global encrypted preference store. @Throws(IOException::class, GeneralSecurityException::class) override fun encryptToPref(prefKey: String?, plaintext: String?) { getEncryptedPrefs().edit().putString(prefKey, plaintext).commit() } + // decryptFromPref decrypts a encrypted preference using the Jetpack Security // library and returns the plaintext. @Throws(IOException::class, GeneralSecurityException::class) @@ -272,13 +284,15 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { "secret_shared_prefs", key, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM) + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) } fun getStoredDirectoryUri(): Uri? { val uriString = getEncryptedPrefs().getString(PREF_KEY_SAF_URI, null) return uriString?.let { Uri.parse(it) } } + /* * setAbleToStartVPN remembers whether or not we're able to start the VPN * by storing this in a shared preference. This allows us to check this @@ -293,7 +307,9 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { override fun getDeviceName(): String { // Try user-defined device name first android.provider.Settings.Global.getString( - contentResolver, android.provider.Settings.Global.DEVICE_NAME) + contentResolver, + android.provider.Settings.Global.DEVICE_NAME, + ) ?.let { return it } @@ -376,13 +392,19 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { } @Throws( - IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class) + IOException::class, + GeneralSecurityException::class, + MDMSettings.NoSuchKeyException::class, + ) override fun getSyspolicyBooleanValue(key: String): Boolean { return getSyspolicyStringValue(key) == "true" } @Throws( - IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class) + IOException::class, + GeneralSecurityException::class, + MDMSettings.NoSuchKeyException::class, + ) override fun getSyspolicyStringValue(key: String): String { val setting = MDMSettings.allSettingsByKey[key]?.flow?.value if (setting?.isSet != true) { @@ -392,7 +414,10 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { } @Throws( - IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class) + IOException::class, + GeneralSecurityException::class, + MDMSettings.NoSuchKeyException::class, + ) override fun getSyspolicyStringArrayJSONValue(key: String): String { val setting = MDMSettings.allSettingsByKey[key]?.flow?.value if (setting?.isSet != true) { @@ -532,6 +557,7 @@ 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]. @@ -552,6 +578,7 @@ open class UninitializedApp : Application() { protected fun setAbleToStartVPN(rdy: Boolean) { getUnencryptedPrefs().edit().putBoolean(ABLE_TO_START_VPN_KEY, rdy).apply() } + /** This function can be called without initializing the App. */ fun isAbleToStartVPN(): Boolean { return getUnencryptedPrefs().getBoolean(ABLE_TO_START_VPN_KEY, false) @@ -588,14 +615,15 @@ open class UninitializedApp : Application() { 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or - PendingIntent.FLAG_IMMUTABLE // FLAG_IMMUTABLE for Android 12+ - ) + PendingIntent.FLAG_IMMUTABLE, // FLAG_IMMUTABLE for Android 12+ + ) try { pendingIntent.send() } catch (foregroundServiceStartException: IllegalStateException) { TSLog.e( TAG, - "startVPN hit ForegroundServiceStartNotAllowedException: $foregroundServiceStartException") + "startVPN hit ForegroundServiceStartNotAllowedException: $foregroundServiceStartException", + ) } catch (securityException: SecurityException) { TSLog.e(TAG, "startVPN hit SecurityException: $securityException") } catch (e: Exception) { @@ -636,7 +664,7 @@ open class UninitializedApp : Application() { fun notifyStatus( vpnRunning: Boolean, hideDisconnectAction: Boolean, - exitNodeName: String? = null + exitNodeName: String? = null, ) { notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction, exitNodeName)) } @@ -659,7 +687,7 @@ open class UninitializedApp : Application() { fun buildStatusNotification( vpnRunning: Boolean, hideDisconnectAction: Boolean, - exitNodeName: String? = null + exitNodeName: String? = null, ): Notification { val title = getString(if (vpnRunning) R.string.connected else R.string.not_connected) val message = @@ -676,14 +704,19 @@ open class UninitializedApp : Application() { this, 0, buttonIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) val intent = Intent(this, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } val pendingIntent: PendingIntent = PendingIntent.getActivity( - this, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + this, + 1, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) val builder = NotificationCompat.Builder(this, STATUS_CHANNEL_ID) .setSmallIcon(icon) diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt index aeed568aca..26f22f3257 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt @@ -7,6 +7,8 @@ import android.content.Context import com.tailscale.ipn.App import com.tailscale.ipn.ui.model.BugReportID import com.tailscale.ipn.ui.model.Errors +import com.tailscale.ipn.ui.model.Favorites +import com.tailscale.ipn.ui.model.FavoritesRequest import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnState @@ -49,6 +51,7 @@ private object Endpoint { const val FILE_PUT = "file-put" const val TAILFS_SERVER_ADDRESS = "tailfs/fileserver-address" const val ENABLE_EXIT_NODE = "set-use-exit-node-enabled" + const val FAVORITES = "pins" } typealias StatusResponseHandler = (Result) -> Unit @@ -123,14 +126,14 @@ class Client(private val scope: CoroutineScope) { fun deleteProfile( profile: IpnLocal.LoginProfile, - responseHandler: (Result) -> Unit = {} + responseHandler: (Result) -> Unit = {}, ) { return delete(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler) } fun switchProfile( profile: IpnLocal.LoginProfile, - responseHandler: (Result) -> Unit = {} + responseHandler: (Result) -> Unit = {}, ) { return post(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler) } @@ -155,7 +158,7 @@ class Client(private val scope: CoroutineScope) { context: Context, peerId: StableNodeID, files: Collection, - responseHandler: (Result) -> Unit + responseHandler: (Result) -> Unit, ) { val manifest = Json.encodeToString(files) val manifestPart = FilePart() @@ -191,10 +194,21 @@ class Client(private val scope: CoroutineScope) { ) } + // Favorites + + fun getFavorites(responseHandler: (Result) -> Unit) { + get(Endpoint.FAVORITES, responseHandler = responseHandler) + } + + fun setFavorites(favorites: FavoritesRequest, responseHandler: (Result) -> Unit) { + val body = Json.encodeToString(favorites).toByteArray() + return post(Endpoint.FAVORITES, body, responseHandler = responseHandler) + } + private inline fun get( path: String, body: ByteArray? = null, - noinline responseHandler: (Result) -> Unit + noinline responseHandler: (Result) -> Unit, ) { Request( scope = scope, @@ -202,14 +216,15 @@ class Client(private val scope: CoroutineScope) { path = path, body = body, responseType = typeOf(), - responseHandler = responseHandler) + responseHandler = responseHandler, + ) .execute() } private inline fun put( path: String, body: ByteArray? = null, - noinline responseHandler: (Result) -> Unit + noinline responseHandler: (Result) -> Unit, ) { Request( scope = scope, @@ -217,7 +232,8 @@ class Client(private val scope: CoroutineScope) { path = path, body = body, responseType = typeOf(), - responseHandler = responseHandler) + responseHandler = responseHandler, + ) .execute() } @@ -225,7 +241,7 @@ class Client(private val scope: CoroutineScope) { path: String, body: ByteArray? = null, timeoutMillis: Long = 30000, - noinline responseHandler: (Result) -> Unit + noinline responseHandler: (Result) -> Unit, ) { Request( scope = scope, @@ -234,14 +250,15 @@ class Client(private val scope: CoroutineScope) { body = body, timeoutMillis = timeoutMillis, responseType = typeOf(), - responseHandler = responseHandler) + responseHandler = responseHandler, + ) .execute() } private inline fun postMultipart( path: String, parts: FileParts, - noinline responseHandler: (Result) -> Unit + noinline responseHandler: (Result) -> Unit, ) { Request( scope = scope, @@ -250,14 +267,15 @@ class Client(private val scope: CoroutineScope) { parts = parts, timeoutMillis = 24 * 60 * 60 * 1000, // 24 hours responseType = typeOf(), - responseHandler = responseHandler) + responseHandler = responseHandler, + ) .execute() } private inline fun patch( path: String, body: ByteArray? = null, - noinline responseHandler: (Result) -> Unit + noinline responseHandler: (Result) -> Unit, ) { Request( scope = scope, @@ -265,20 +283,22 @@ class Client(private val scope: CoroutineScope) { path = path, body = body, responseType = typeOf(), - responseHandler = responseHandler) + responseHandler = responseHandler, + ) .execute() } private inline fun delete( path: String, - noinline responseHandler: (Result) -> Unit + noinline responseHandler: (Result) -> Unit, ) { Request( scope = scope, method = "DELETE", path = path, responseType = typeOf(), - responseHandler = responseHandler) + responseHandler = responseHandler, + ) .execute() } } @@ -291,7 +311,7 @@ class Request( private val parts: FileParts? = null, private val timeoutMillis: Long = 30000, private val responseType: KType, - private val responseHandler: (Result) -> Unit + private val responseHandler: (Result) -> Unit, ) { private val fullPath = "/localapi/v0/$path" @@ -314,13 +334,20 @@ class Request( TSLog.d(TAG, "Executing request:${method}:${fullPath} on app $app") try { val resp = - if (parts != null) app.callLocalAPIMultipart(timeoutMillis, method, fullPath, parts) + if (parts != null) + app.callLocalAPIMultipart( + timeoutMillis, + method, + fullPath, + parts, + ) else app.callLocalAPI( timeoutMillis, method, fullPath, - body?.let { InputStreamAdapter(it.inputStream()) }) + body?.let { InputStreamAdapter(it.inputStream()) }, + ) // TODO: use the streaming body for performance // An empty body is a perfectly valid response and indicates success val respData = resp.bodyBytes() ?: ByteArray(0) @@ -334,8 +361,9 @@ class Request( try { Result.success( jsonDecoder.decodeFromStream( - Json.serializersModule.serializer(responseType), respData.inputStream()) - as T) + Json.serializersModule.serializer(responseType), + respData.inputStream(), + ) as T) } catch (t: Throwable) { // If we couldn't parse the response body, assume it's an error response try { @@ -349,7 +377,11 @@ class Request( } if (resp.statusCode() >= 400) { throw Exception( - "Request failed with status ${resp.statusCode()}: ${respData.toString(Charset.defaultCharset())}") + "Request failed with status ${resp.statusCode()}: ${ + respData.toString( + Charset.defaultCharset() + ) + }") } // The response handler will invoked internally by the request parser scope.launch { responseHandler(response) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Favorites.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Favorites.kt new file mode 100644 index 0000000000..2dda8fbefe --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Favorites.kt @@ -0,0 +1,46 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class FavoriteItem( + @SerialName("ID") var id: String? = null, + @SerialName("Name") var name: String? = null, +) + +@Serializable +data class Favorites( + @SerialName("Devices") val devices: List? = null, + @SerialName("ExitNodes") val exitNodes: List? = null, + @SerialName("Services") val services: List? = null, +) { + val deviceIds: List by lazy { devices.orEmpty().mapNotNull { it.id } } + + fun isFavoriteDevice(id: StableNodeID): Boolean = id in deviceIds + + fun withToggledDevice(id: StableNodeID): FavoritesRequest { + val current = devices.orEmpty() + val updated = + if (isFavoriteDevice(id)) { + current.filterNot { it.id == id } + } else { + current + FavoriteItem(id = id) + } + return FavoritesRequest( + pins = copy(devices = updated), + devicesSet = true, + ) + } +} + +@Serializable +data class FavoritesRequest( + @SerialName("Pins") val pins: Favorites, + @SerialName("DevicesSet") val devicesSet: Boolean? = null, + @SerialName("ExitNodesSet") val exitNodesSet: Boolean? = null, + @SerialName("ServicesSet") val servicesSet: Boolean? = null, +) diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt b/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt index 3742ebdd23..040e3864ab 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt @@ -27,7 +27,7 @@ class Tailcfg { var UrgentSecurityUpdate: Boolean? = null, var Notify: Boolean? = null, var NotifyURL: String? = null, - var NotifyText: String? = null + var NotifyText: String? = null, ) @Serializable @@ -84,7 +84,7 @@ class Tailcfg { var Capabilities: List? = null, var CapMap: Map? = null, var ComputedName: String? = null, - var ComputedNameWithHost: String? = null + var ComputedNameWithHost: String? = null, ) { val isAdmin: Boolean get() = @@ -101,6 +101,9 @@ class Tailcfg { val primaryIPv6Address: String? get() = displayAddresses.firstOrNull { it.type == DisplayAddress.addrType.V6 }?.address + val magicDNSAddress: String? + get() = displayAddresses.firstOrNull { it.type == DisplayAddress.addrType.MagicDNS }?.address + // isExitNode reproduces the Go logic in local.go peerStatusFromNode val isExitNode: Boolean = (AllowedIPs?.contains("0.0.0.0/0") ?: false) && (AllowedIPs?.contains("::/0") ?: false) @@ -146,7 +149,7 @@ class Tailcfg { val displayAddresses: List get() { - var addresses = mutableListOf() + val addresses = mutableListOf() addresses.add(DisplayAddress(nameWithoutTrailingDot)) Addresses?.let { addresses.addAll(it.map { addr -> DisplayAddress(addr) }) } return addresses @@ -160,13 +163,12 @@ class Tailcfg { PeerSettingInfo(R.string.os, ComposableStringFormatter(Hostinfo.OS!!)), ) } - if (keyDoesNotExpire) { - result.add( - PeerSettingInfo( - R.string.key_expiry, ComposableStringFormatter(R.string.deviceKeyNeverExpires))) - } else { - result.add(PeerSettingInfo(R.string.key_expiry, TimeUtil.keyExpiryFromGoTime(KeyExpiry))) - } + val settingValue = + if (keyDoesNotExpire) ComposableStringFormatter(R.string.deviceKeyNeverExpires) + else TimeUtil.keyExpiryFromGoTime(KeyExpiry) + + result.add(PeerSettingInfo(R.string.key_expiry, settingValue)) + return result } @@ -186,13 +188,17 @@ class Tailcfg { } @Serializable - data class Service(var Proto: String, var Port: Int, var Description: String? = null) + data class Service( + var Proto: String, + var Port: Int, + var Description: String? = null, + ) @Serializable data class NetworkProfile( var MagicDNSName: String? = null, var DomainName: String? = null, - var DisplayName: String? = null + var DisplayName: String? = null, ) { fun tailnetNameForDisplay(): String? { return DisplayName?.takeIf { it.isNotEmpty() } ?: DomainName @@ -205,7 +211,7 @@ class Tailcfg { var CountryCode: String? = null, var City: String? = null, var CityCode: String? = null, - var Priority: Int? = null + var Priority: Int? = null, ) @Serializable @@ -214,6 +220,6 @@ class Tailcfg { var Routes: Map?>? = null, var FallbackResolvers: List? = null, var Domains: List? = null, - var Nameservers: List? = null + var Nameservers: List? = null, ) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/notifier/FavoritesManager.kt b/android/src/main/java/com/tailscale/ipn/ui/notifier/FavoritesManager.kt new file mode 100644 index 0000000000..98e55ffce3 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/notifier/FavoritesManager.kt @@ -0,0 +1,136 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.notifier + +import com.tailscale.ipn.ui.localapi.Client +import com.tailscale.ipn.ui.model.Favorites +import com.tailscale.ipn.ui.model.FavoritesRequest +import com.tailscale.ipn.ui.model.Ipn +import com.tailscale.ipn.ui.model.Netmap +import com.tailscale.ipn.ui.model.StableNodeID +import com.tailscale.ipn.ui.model.UserID +import com.tailscale.ipn.util.TSLog +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class FavoritesManager( + ipnStateFlow: StateFlow, + netmapFlow: StateFlow, + private val scope: CoroutineScope, + @OptIn(ExperimentalCoroutinesApi::class) + private val dispatcher: CoroutineDispatcher = Dispatchers.Default.limitedParallelism(1), + private val writeDebounce: Duration = 350.milliseconds, + private val retryDelay: Duration = 2.seconds, +) { + private val TAG = "FavoritesManager" + + private val _favorites = MutableStateFlow(null) + val favorites: StateFlow = _favorites + + private val _writing = MutableStateFlow(false) + val writing: StateFlow = _writing + + private val userFlow = netmapFlow.mapNotNull { it?.User() } + + private val client = Client(scope) + + private var currentUser: UserID? = null + private var loadedForUser: UserID? = null + private var pendingWrite: Job? = null + private var revert: Favorites? = null + + init { + scope.launch { + combine(ipnStateFlow, userFlow) { state, user -> state to user } + .distinctUntilChanged() + .collect { (state, user) -> + withContext(dispatcher) { + if (user != currentUser) { + currentUser = user + loadedForUser = null + pendingWrite?.cancel() + revert = null + _writing.value = false + _favorites.value = null + } + if (state == Ipn.State.Running && loadedForUser != user) { + loadedForUser = user + load(user) + } + } + } + } + } + + private fun load(user: UserID, isRetry: Boolean = false) { + client.getFavorites { result -> + scope.launch(dispatcher) { + if (currentUser != user) return@launch + result + .onSuccess { _favorites.value = it } + .onFailure { + TSLog.e(TAG, "Error loading favorites: ${it.message}") + loadedForUser = null + if (isRetry) return@onFailure + scope.launch(dispatcher) { + delay(retryDelay) + if (currentUser == user && loadedForUser == null) { + loadedForUser = user + load(user, isRetry = true) + } + } + } + } + } + } + + private fun send(request: FavoritesRequest) { + val snapshot = revert + val user = currentUser + revert = null + _writing.value = true + client.setFavorites(request) { result -> + scope.launch(dispatcher) { + if (currentUser != user) return@launch + _writing.value = false + if (revert != null) return@launch // newer burst opened while inflight + result.onFailure { + TSLog.e(TAG, "Error writing favorites: ${it.message}") + _favorites.value = snapshot + } + } + } + } + + fun toggleDevice(id: StableNodeID) { + scope.launch(dispatcher) { + val current = _favorites.value ?: return@launch + if (revert == null) revert = current + pendingWrite?.cancel() + + val request = current.withToggledDevice(id) + _favorites.value = request.pins + + pendingWrite = + scope.launch(dispatcher) { + delay(writeDebounce) + send(request) + } + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/Lists.kt b/android/src/main/java/com/tailscale/ipn/ui/util/Lists.kt index 8cbc393e80..fe1d15cf8c 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/Lists.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/Lists.kt @@ -6,7 +6,9 @@ package com.tailscale.ipn.ui.util import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -17,6 +19,7 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape @@ -36,7 +39,9 @@ object Lists { @Composable fun ItemDivider() { HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant, modifier = Modifier.fillMaxWidth()) + color = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.fillMaxWidth(), + ) } @Composable @@ -47,28 +52,26 @@ object Lists { fontWeight: FontWeight? = null, focusable: Boolean = false, backgroundColor: Color = MaterialTheme.colorScheme.surface, - fontColor: Color? = null + fontColor: Color? = null, + leadingIcon: (@Composable () -> Unit)? = null, ) { Box( modifier = Modifier.fillMaxWidth().background(color = backgroundColor, shape = RectangleShape)) { - if (fontColor != null) { + Row( + modifier = + Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottomPadding) + .focusable(focusable), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + leadingIcon?.invoke() 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) + color = fontColor ?: Color.Unspecified, + ) } } } @@ -83,7 +86,8 @@ object Lists { modifier = Modifier.padding(start = 16.dp, top = 16.dp), text = text, style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } @@ -98,7 +102,8 @@ object Lists { Text( text = text as AnnotatedString, style = style, - modifier = Modifier.clickable { onClick() }) + modifier = Modifier.clickable { onClick() }, + ) } ?: run { Text(text as String, style = style) } } }) @@ -121,22 +126,23 @@ inline fun LazyListScope.itemsWithDividers( noinline key: ((item: T) -> Any)? = null, forceLeading: Boolean = false, crossinline contentType: (item: T) -> Any? = { _ -> null }, - crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit + crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit, ) = items( count = items.size, key = if (key != null) { index: Int -> key(items[index]) } else null, - contentType = { index -> contentType(items[index]) }) { - if (forceLeading && it == 0 || it > 0 && it < items.size) { - Lists.ItemDivider() - } - itemContent(items[it]) - } + contentType = { index -> contentType(items[index]) }, + ) { + if (forceLeading && it == 0 || it > 0 && it < items.size) { + Lists.ItemDivider() + } + itemContent(items[it]) + } inline fun LazyListScope.itemsWithDividers( items: Array, noinline key: ((item: T) -> Any)? = null, forceLeading: Boolean = false, crossinline contentType: (item: T) -> Any? = { _ -> null }, - crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit + crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit, ) = itemsWithDividers(items.toList(), key, forceLeading, contentType, itemContent) diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt b/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt index 8e6ca28539..c8ef136d42 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt @@ -6,14 +6,56 @@ package com.tailscale.ipn.ui.util import androidx.compose.ui.util.fastAny import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.ui.model.Netmap +import com.tailscale.ipn.ui.model.StableNodeID import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.model.UserID data class PeerSet( - val userID: UserID, - val user: Tailcfg.UserProfile?, - val peers: List -) + val id: UserID, + val title: String?, + val nodes: List, +) { + companion object { + const val FAVORITES_ID: UserID = -1 + + fun create(id: UserID, title: String?, nodes: List): PeerSet? = + if (nodes.isEmpty()) null else PeerSet(id, title, nodes) + } + + val isFavorite: Boolean + get() = id == FAVORITES_ID +} + +fun List.withPinnedSection(pinnedIds: List): List { + val ids = pinnedIds.distinct() + if (ids.isEmpty()) return this + + val pinned = ids.toSet() + val byId = mutableMapOf() + for (set in this) for (node in set.nodes) { + if (node.StableID in pinned) byId[node.StableID] = node + } + + val pinnedNodes = ids.mapNotNull { byId[it] } + if (pinnedNodes.isEmpty()) return this + + val remaining = mapNotNull { set -> + PeerSet.create(set.id, set.title, set.nodes.filterNot { it.StableID in pinned }) + } + + return listOf(PeerSet(PeerSet.FAVORITES_ID, null, pinnedNodes)) + remaining +} + +private fun List.nodeSort(netmap: Netmap.NetworkMap): List { + return this.sortedWith { a, b -> + when { + a.StableID == b.StableID -> 0 + a.isSelfNode(netmap) -> -1 + b.isSelfNode(netmap) -> 1 + else -> (a.ComputedName?.lowercase() ?: "").compareTo(b.ComputedName?.lowercase() ?: "") + } + } +} class PeerCategorizer { var peerSets: List = emptyList() @@ -21,9 +63,9 @@ class PeerCategorizer { var lastSearchTerm: String = "" fun regenerateGroupedPeers(netmap: Netmap.NetworkMap) { - val peers: List = netmap.Peers ?: return + val peers: List = netmap.Peers.orEmpty() val selfNode = netmap.SelfNode - var grouped = mutableMapOf>() + val grouped = mutableMapOf>() val mdm = MDMSettings.hiddenNetworkDevices.flow.value.value val hideMyDevices = mdm?.contains("current-user") ?: false @@ -33,7 +75,6 @@ class PeerCategorizer { val me = netmap.currentUserProfile() for (peer in (peers + selfNode)) { - val userId = peer.User val profile = netmap.userProfile(userId) @@ -58,34 +99,23 @@ class PeerCategorizer { if (!grouped.containsKey(userId)) { grouped[userId] = mutableListOf() } + grouped[userId]?.add(peer) } peerSets = grouped - .map { (userId, peers) -> - val profile = netmap.userProfile(userId) - PeerSet( + .mapNotNull { (userId, peers) -> + PeerSet.create( userId, - profile, - peers.sortedWith { a, b -> - when { - a.StableID == b.StableID -> 0 - a.isSelfNode(netmap) -> -1 - b.isSelfNode(netmap) -> 1 - else -> - (a.ComputedName?.lowercase() ?: "").compareTo( - b.ComputedName?.lowercase() ?: "") - } - }) - } - .sortedBy { - if (it.user?.ID == me?.ID) { - "" - } else { - it.user?.DisplayName?.lowercase() ?: "unknown user" - } + netmap.userProfile(userId)?.DisplayName, + peers.nodeSort(netmap), + ) } + .sortedBy { if (it.id == me?.ID) "" else it.title?.lowercase() ?: "unknown user" } + + lastSearchTerm = "" + lastSearchResult = emptyList() } fun groupedAndFilteredPeers(searchTerm: String = ""): List { @@ -106,28 +136,24 @@ class PeerCategorizer { this.lastSearchTerm = searchTerm val matchingSets = - setsToSearch - .map { peerSet -> - val user = peerSet.user - val peers = peerSet.peers - - val userMatches = user?.DisplayName?.contains(searchTerm, ignoreCase = true) ?: false - if (userMatches) { - return@map peerSet + setsToSearch.mapNotNull { peerSet -> + val peers = peerSet.nodes + + if (peerSet.title?.contains(searchTerm, ignoreCase = true) == true) { + return@mapNotNull peerSet + } + + val matchingPeers = + peers.filter { peer -> + val matchDisplay = peer.displayName.contains(searchTerm, ignoreCase = true) + val matchAddress = peer.Addresses.orEmpty().fastAny { it.contains(searchTerm) } + matchDisplay || matchAddress } - val matchingPeers = - peers.filter { - it.displayName.contains(searchTerm, ignoreCase = true) || - (it.Addresses ?: emptyList()).fastAny { addr -> addr.contains(searchTerm) } - } - if (matchingPeers.isNotEmpty()) { - PeerSet(peerSet.userID, user, matchingPeers) - } else { - null - } - } - .filterNotNull() + if (matchingPeers.isNotEmpty()) PeerSet(peerSet.id, peerSet.title, matchingPeers) + else null + } + lastSearchResult = matchingSets return matchingSets } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt index e16469e0a3..55b098ee59 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.focusable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -25,6 +26,7 @@ import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ArrowDropDown @@ -37,6 +39,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem @@ -106,6 +109,7 @@ import com.tailscale.ipn.ui.util.AutoResizingText import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.PeerSet +import com.tailscale.ipn.ui.util.PeerSet.Companion.FAVORITES_ID import com.tailscale.ipn.ui.util.itemsWithDividers import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.viewModel.AppViewModel @@ -134,122 +138,129 @@ fun MainView( val healthIcon by viewModel.healthIcon.collectAsState() LoadingIndicator.Wrap { - Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets -> + Scaffold(contentWindowInsets = WindowInsets.statusBars) { paddingInsets -> Column( modifier = Modifier.fillMaxWidth().padding(paddingInsets), - verticalArrangement = Arrangement.Center) { - // Assume VPN has been prepared for optimistic UI. Whether or not it has been prepared - // cannot be known - // until permission has been granted to prepare the VPN. - val isPrepared by viewModel.isVpnPrepared.collectAsState(initial = true) - val isOn by viewModel.vpnToggleState.collectAsState(initial = false) - val state by viewModel.ipnState.collectAsState(initial = Ipn.State.NoState) - val user by viewModel.loggedInUser.collectAsState(initial = null) - val stateVal by viewModel.stateRes.collectAsState(initial = R.string.placeholder) - val stateStr = stringResource(id = stateVal) - val netmap by viewModel.netmap.collectAsState(initial = null) - val showExitNodePicker by MDMSettings.exitNodesPicker.flow.collectAsState() - val disableToggle by MDMSettings.forceEnabled.flow.collectAsState() - val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false) - - // Hide the header only on Android TV when the user needs to login - val hideHeader = (isAndroidTV() && state == Ipn.State.NeedsLogin) - ListItem( - colors = MaterialTheme.colorScheme.surfaceContainerListItem, - leadingContent = { - if (!hideHeader) { - TintedSwitch( - checked = isOn, - enabled = - !disableToggle.value && - !viewModel.isToggleInProgress - .value, // Disable switch if toggle is in progress - onCheckedChange = { desiredState -> viewModel.toggleVpn(desiredState) }) - } - }, - headlineContent = { - user?.NetworkProfile?.tailnetNameForDisplay()?.let { domain -> - AutoResizingText( - text = domain, - style = MaterialTheme.typography.titleMedium.short, - minFontSize = MaterialTheme.typography.minTextSize, - overflow = TextOverflow.Ellipsis) - } - }, - supportingContent = { - if (!hideHeader) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text(text = stateStr, style = MaterialTheme.typography.bodyMedium.short) - healthIcon?.let { - Spacer(modifier = Modifier.size(4.dp)) - IconButton( - onClick = { navigation.onNavigateToHealth() }, - modifier = Modifier.size(16.dp)) { - Icon( - painterResource(id = it), - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.error) - } - } - } - } - }, - trailingContent = { - Box(modifier = Modifier.padding(8.dp), contentAlignment = Alignment.CenterEnd) { - when (user) { - null -> SettingsButton { navigation.onNavigateToSettings() } - else -> { - Avatar( - profile = user, - size = 36, - { navigation.onNavigateToSettings() }, - isFocusable = true) - } + verticalArrangement = Arrangement.Center, + ) { + val isPrepared by viewModel.isVpnPrepared.collectAsState() + val isOn by viewModel.vpnToggleState.collectAsState() + val state by viewModel.ipnState.collectAsState() + val user by viewModel.loggedInUser.collectAsState() + val stateVal by viewModel.stateRes.collectAsState(initial = R.string.placeholder) + val stateStr = stringResource(id = stateVal) + val netmap by viewModel.netmap.collectAsState() + val showExitNodePicker by MDMSettings.exitNodesPicker.flow.collectAsState() + val disableToggle by MDMSettings.forceEnabled.flow.collectAsState() + val isToggleInProgress by viewModel.isToggleInProgress.collectAsState() + val showKeyExpiry by viewModel.showExpiry.collectAsState() + + val hideHeader = (isAndroidTV() && state == Ipn.State.NeedsLogin) + ListItem( + colors = MaterialTheme.colorScheme.surfaceContainerListItem, + leadingContent = { + if (!hideHeader) { + TintedSwitch( + checked = isOn, + enabled = + !disableToggle.value && + !isToggleInProgress, // Disable switch if toggle is in progress + onCheckedChange = { desiredState -> viewModel.toggleVpn(desiredState) }, + ) + } + }, + headlineContent = { + user?.NetworkProfile?.tailnetNameForDisplay()?.let { domain -> + AutoResizingText( + text = domain, + style = MaterialTheme.typography.titleMedium.short, + minFontSize = MaterialTheme.typography.minTextSize, + overflow = TextOverflow.Ellipsis, + ) + } + }, + supportingContent = { + if (!hideHeader) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = stateStr, style = MaterialTheme.typography.bodyMedium.short) + healthIcon?.let { + Spacer(modifier = Modifier.size(4.dp)) + IconButton( + onClick = { navigation.onNavigateToHealth() }, + modifier = Modifier.size(16.dp), + ) { + Icon( + painterResource(id = it), + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.error, + ) } } - }) - when (state) { - Ipn.State.Running -> { - viewModel.maybeRequestVpnPermission() - LaunchVpnPermissionIfNeeded(viewModel) - PromptForMissingPermissions(viewModel) - - if (showKeyExpiry) { - ExpiryNotification(netmap = netmap, action = { viewModel.login() }) - } - if (showExitNodePicker.value == ShowHide.Show) { - ExitNodeStatus( - navAction = navigation.onNavigateToExitNodes, viewModel = viewModel) } - val pending by viewModel.pendingTaildrop.pendingItems.collectAsState() - if (pending.isNotEmpty()) { - TaildropBannerView(viewModel = viewModel.pendingTaildrop) - } - PeerList( - viewModel = viewModel, - onNavigateToPeerDetails = navigation.onNavigateToPeerDetails, - onSearchBarClick = navigation.onNavigateToSearch, - onSearch = { viewModel.searchPeers(it) }) } - Ipn.State.NoState, - Ipn.State.Starting -> StartingView() - else -> { - ConnectView( - state, - isPrepared, - // If Tailscale is stopping, don't automatically restart; wait for user to take - // action (eg, if the user connected to another VPN). - state != Ipn.State.Stopping, - user, - { viewModel.toggleVpn(desiredState = !isOn) }, - { viewModel.login() }, - loginAtUrl, - netmap?.SelfNode, - { viewModel.showVPNPermissionLauncherIfUnauthorized() }) + }, + trailingContent = { + Box(modifier = Modifier.padding(8.dp), contentAlignment = Alignment.CenterEnd) { + when (user) { + null -> SettingsButton { navigation.onNavigateToSettings() } + else -> { + Avatar( + profile = user, + size = 36, + { navigation.onNavigateToSettings() }, + isFocusable = true, + ) + } + } } + }, + ) + when (state) { + Ipn.State.Running -> { + viewModel.maybeRequestVpnPermission() + LaunchVpnPermissionIfNeeded(viewModel) + PromptForMissingPermissions(viewModel) + + if (showKeyExpiry) { + netmap?.let { ExpiryNotification(netmap = it, action = { viewModel.login() }) } } + if (showExitNodePicker.value == ShowHide.Show) { + ExitNodeStatus( + navAction = navigation.onNavigateToExitNodes, + viewModel = viewModel, + ) + } + val pending by viewModel.pendingTaildrop.pendingItems.collectAsState() + if (pending.isNotEmpty()) { + TaildropBannerView(viewModel = viewModel.pendingTaildrop) + } + PeerList( + viewModel = viewModel, + onNavigateToPeerDetails = navigation.onNavigateToPeerDetails, + onSearchBarClick = navigation.onNavigateToSearch, + onSearch = { viewModel.searchPeers(it) }, + ) } + Ipn.State.NoState, + Ipn.State.Starting -> StartingView() + else -> { + ConnectView( + state, + isPrepared, + // If Tailscale is stopping, don't automatically restart; wait for user to take + // action (eg, if the user connected to another VPN). + state != Ipn.State.Stopping, + user, + { viewModel.toggleVpn(desiredState = !isOn) }, + { viewModel.login() }, + loginAtUrl, + netmap?.SelfNode, + { viewModel.showVPNPermissionLauncherIfUnauthorized() }, + ) + } + } + } currentPingDevice?.let { _ -> ModalBottomSheet(onDismissRequest = { viewModel.onPingDismissal() }) { PingView(model = viewModel.pingViewModel) @@ -272,18 +283,28 @@ fun MainView( @Composable fun TaildropDirectoryPickerPrompt() { val uriHandler = LocalUriHandler.current - Column(verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.Start) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.Start, + ) { Text(text = stringResource(id = R.string.taildrop_directory_picker_body)) Text( text = stringResource(id = R.string.taildrop_directory_picker_info), modifier = Modifier.clickable { uriHandler.openUri(Links.TAILDROP_KB_URL) }, color = MaterialTheme.colorScheme.primary, - textDecoration = TextDecoration.Underline) + textDecoration = TextDecoration.Underline, + ) } } +@Preview(showBackground = true) +@Composable +private fun TaildropDirectoryPickerPromptPreview() { + TaildropDirectoryPickerPrompt() +} + @Composable -fun LaunchVpnPermissionIfNeeded(viewModel: MainViewModel) { +private fun LaunchVpnPermissionIfNeeded(viewModel: MainViewModel) { val lifecycleOwner = LocalLifecycleOwner.current val shouldRequest by viewModel.requestVpnPermission.collectAsState() LaunchedEffect(shouldRequest) { @@ -296,10 +317,11 @@ fun LaunchVpnPermissionIfNeeded(viewModel: MainViewModel) { } @Composable -fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { +private fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { val nodeState by viewModel.nodeState.collectAsState() val maybePrefs by viewModel.prefs.collectAsState() val netmap by viewModel.netmap.collectAsState() + val managedByOrganization by viewModel.managedByOrganization.collectAsState() // There's nothing to render if we haven't loaded the prefs yet val prefs = maybePrefs ?: return // The activeExitNode is the source of truth. The selectedExitNode is only relevant if we @@ -307,7 +329,7 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { val chosenExitNodeId = prefs.activeExitNodeID ?: prefs.selectedExitNodeID val exitNodePeer = chosenExitNodeId?.let { id -> netmap?.Peers?.find { it.StableID == id } } val name = exitNodePeer?.exitNodeName - val managedByOrganization by viewModel.managedByOrganization.collectAsState() + Box( modifier = Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surfaceContainer)) { @@ -328,7 +350,8 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { stringResource(R.string.exit_node_offline_mdm_orgname, it) } ?: stringResource(R.string.exit_node_offline_mdm), style = MaterialTheme.typography.bodyMedium, - color = Color.White) + color = Color.White, + ) } } } @@ -374,7 +397,8 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { }, style = MaterialTheme.typography.bodyMedium, maxLines = 1, - overflow = TextOverflow.Ellipsis) + overflow = TextOverflow.Ellipsis, + ) Icon( imageVector = Icons.Outlined.ArrowDropDown, contentDescription = null, @@ -403,42 +427,47 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { if (nodeState == NodeState.RUNNING_AS_EXIT_NODE) viewModel.setRunningExitNode(false) else viewModel.toggleExitNode() - }) { - Text( - when (nodeState) { - NodeState.OFFLINE_DISABLED -> stringResource(id = R.string.enable) - NodeState.ACTIVE_NOT_RUNNING -> - stringResource(id = R.string.enable) - NodeState.RUNNING_AS_EXIT_NODE -> - stringResource(id = R.string.stop) - else -> stringResource(id = R.string.disable) - }) - } + }, + ) { + Text( + when (nodeState) { + NodeState.OFFLINE_DISABLED -> stringResource(id = R.string.enable) + NodeState.ACTIVE_NOT_RUNNING -> stringResource(id = R.string.enable) + NodeState.RUNNING_AS_EXIT_NODE -> stringResource(id = R.string.stop) + else -> stringResource(id = R.string.disable) + }) + } } - }) + }, + ) } } } @Composable -fun SettingsButton(action: () -> Unit) { +private fun SettingsButton(action: () -> Unit) { IconButton(modifier = Modifier.size(24.dp), onClick = { action() }) { Icon( Icons.Outlined.Settings, contentDescription = "Open settings", - tint = MaterialTheme.colorScheme.onSurfaceVariant) + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } @Composable -fun StartingView() { +private fun StartingView() { Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally) { - TailscaleLogoView( - animated = true, usesOnBackgroundColors = false, Modifier.size(40.dp).alpha(0.3f)) - } + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TailscaleLogoView( + animated = true, + usesOnBackgroundColors = false, + Modifier.size(40.dp).alpha(0.3f), + ) + } } @Composable @@ -458,11 +487,22 @@ fun ConnectView( showVPNPermissionLauncher() } } - Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { - Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) { + + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth(), + ) { Column( modifier = Modifier.padding(8.dp).fillMaxWidth(0.7f).fillMaxHeight(), - verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterVertically), + verticalArrangement = + Arrangement.spacedBy( + 8.dp, + alignment = Alignment.CenterVertically, + ), horizontalAlignment = Alignment.CenterHorizontally, ) { if (!isPrepared) { @@ -471,36 +511,44 @@ fun ConnectView( Text( text = stringResource(id = R.string.welcome_to_tailscale), style = MaterialTheme.typography.titleMedium, - textAlign = TextAlign.Center) + textAlign = TextAlign.Center, + ) Text( stringResource(R.string.give_permissions), style = MaterialTheme.typography.titleSmall, - textAlign = TextAlign.Center) + textAlign = TextAlign.Center, + ) Spacer(modifier = Modifier.size(1.dp)) PrimaryActionButton(onClick = connectAction) { Text( text = stringResource(id = R.string.connect), - fontSize = MaterialTheme.typography.titleMedium.fontSize) + fontSize = MaterialTheme.typography.titleMedium.fontSize, + ) } } else if (state == Ipn.State.NeedsMachineAuth) { Icon( modifier = Modifier.size(40.dp), imageVector = Icons.Outlined.Lock, - contentDescription = "Device requires authentication") + contentDescription = "Device requires authentication", + ) Text( text = stringResource(id = R.string.machine_auth_required), style = MaterialTheme.typography.titleMedium, - textAlign = TextAlign.Center) + textAlign = TextAlign.Center, + ) Text( text = stringResource(id = R.string.machine_auth_explainer), style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center) + textAlign = TextAlign.Center, + ) Spacer(modifier = Modifier.size(1.dp)) + selfNode?.let { PrimaryActionButton(onClick = { loginAtUrlAction(it.nodeAdminUrl) }) { Text( text = stringResource(id = R.string.open_admin_console), - fontSize = MaterialTheme.typography.titleMedium.fontSize) + fontSize = MaterialTheme.typography.titleMedium.fontSize, + ) } } } else if (state != Ipn.State.NeedsLogin && user != null && !user.isEmpty()) { @@ -508,19 +556,20 @@ fun ConnectView( painter = painterResource(id = R.drawable.power), contentDescription = null, modifier = Modifier.size(40.dp), - tint = MaterialTheme.colorScheme.disabled) + tint = MaterialTheme.colorScheme.disabled, + ) Text( text = stringResource(id = R.string.not_connected), fontSize = MaterialTheme.typography.titleMedium.fontSize, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center, - fontFamily = MaterialTheme.typography.titleMedium.fontFamily) - val tailnetName = user.NetworkProfile?.tailnetNameForDisplay() ?: "" + fontFamily = MaterialTheme.typography.titleMedium.fontFamily, + ) Text( buildAnnotatedString { append(stringResource(id = R.string.connect_to_tailnet_prefix)) pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) - append(tailnetName) + append(user.NetworkProfile?.tailnetNameForDisplay() ?: "") pop() append(stringResource(id = R.string.connect_to_tailnet_suffix)) }, @@ -532,7 +581,8 @@ fun ConnectView( PrimaryActionButton(onClick = connectAction) { Text( text = stringResource(id = R.string.connect), - fontSize = MaterialTheme.typography.titleMedium.fontSize) + fontSize = MaterialTheme.typography.titleMedium.fontSize, + ) } } else { TailscaleLogoView(modifier = Modifier.size(50.dp)) @@ -540,16 +590,19 @@ fun ConnectView( Text( text = stringResource(id = R.string.welcome_to_tailscale), style = MaterialTheme.typography.titleMedium, - textAlign = TextAlign.Center) + textAlign = TextAlign.Center, + ) Text( stringResource(R.string.login_to_join_your_tailnet), style = MaterialTheme.typography.titleSmall, - textAlign = TextAlign.Center) + textAlign = TextAlign.Center, + ) Spacer(modifier = Modifier.size(1.dp)) PrimaryActionButton(onClick = loginAction) { Text( text = stringResource(id = R.string.log_in), - fontSize = MaterialTheme.typography.titleMedium.fontSize) + fontSize = MaterialTheme.typography.titleMedium.fontSize, + ) } } } @@ -557,6 +610,57 @@ fun ConnectView( } } +@Preview(showBackground = true) +@Composable +private fun ConnectViewPreview() { + var isPrepared by remember { mutableStateOf(false) } + var showUser by remember { mutableStateOf(false) } + var showNode by remember { mutableStateOf(false) } + var showState by remember { mutableStateOf(false) } + var state by remember { mutableStateOf(Ipn.State.NoState) } + val user = + IpnLocal.LoginProfile( + ID = "id", + Name = "name", + Key = "key", + UserProfile = Tailcfg.UserProfile(ID = -1), + NetworkProfile = null, + LocalUserID = "id", + ControlURL = null, + ) + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.horizontalScroll(rememberScrollState()), + ) { + Button(onClick = { isPrepared = !isPrepared }) { Text("Prepared: $isPrepared") } + Button(onClick = { showState = true }) { + Text("State: $state") + DropdownMenu(expanded = showState, onDismissRequest = { showState = false }) { + Ipn.State.entries.forEach { entry -> + DropdownMenuItem(text = { Text("$entry") }, onClick = { state = entry }) + } + } + } + Button(onClick = { showUser = !showUser }) { Text("User: $showUser") } + Button(onClick = { showNode = !showNode }) { Text("Node: $showNode") } + } + + ConnectView( + state = state, + isPrepared = isPrepared, + shouldStartAutomatically = false, + user = if (showUser) user else null, + connectAction = {}, + loginAction = {}, + loginAtUrlAction = {}, + selfNode = if (showNode) Tailcfg.Node() else null, + showVPNPermissionLauncher = {}, + ) + } +} + @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun PeerList( @@ -565,18 +669,20 @@ fun PeerList( onSearchBarClick: () -> Unit, onSearch: (String) -> Unit, ) { - val peerList by viewModel.peers.collectAsState(initial = emptyList()) - val searchTermStr by viewModel.searchTerm.collectAsState(initial = "") - val showNoResults = - remember { derivedStateOf { searchTermStr.isNotEmpty() && peerList.isEmpty() } }.value - val netmap = viewModel.netmap.collectAsState() val focusManager = LocalFocusManager.current + val peerList by viewModel.peers.collectAsState() + val searchTermStr by viewModel.searchTerm.collectAsState() + val expandedPeer by viewModel.expandedMenuPeer.collectAsState() + val netmap by viewModel.netmap.collectAsState() + val showNoResults by remember { + derivedStateOf { searchTermStr.isNotEmpty() && peerList.isEmpty() } + } var isSearchFocussed by remember { mutableStateOf(false) } var isListFocussed by remember { mutableStateOf(false) } - val expandedPeer = viewModel.expandedMenuPeer.collectAsState() - val localClipboardManager = LocalClipboardManager.current + // Restrict search to devices running API 33+ (see https://github.com/tailscale/corp/issues/27375) val enableSearch = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + Column(modifier = Modifier.fillMaxSize()) { if (enableSearch && FeatureFlags.isEnabled("enable_new_search")) { Search(onSearchBarClick) @@ -608,7 +714,8 @@ fun PeerList( if (searchTermStr.isEmpty()) Icons.Outlined.Close else Icons.Outlined.Clear, contentDescription = "clear search", - tint = MaterialTheme.colorScheme.onSurfaceVariant) + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } }, @@ -616,13 +723,16 @@ fun PeerList( Text( text = stringResource(id = R.string.search), style = MaterialTheme.typography.bodyLarge, - maxLines = 1) + maxLines = 1, + ) }, value = searchTermStr, - onValueChange = { onSearch(it) }) + onValueChange = { onSearch(it) }, + ) } } } + // Peers display LazyColumn( modifier = @@ -643,69 +753,53 @@ fun PeerList( stringResource(id = R.string.no_results), bottomPadding = 8.dp, style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Light) + fontWeight = FontWeight.Light, + ) } } + // Iterate over peer sets to display them - var first = true - peerList.forEach { peerSet -> - if (!first) { - item(key = "user_divider_${peerSet.userID}") { Lists.ItemDivider() } + peerList.forEachIndexed { idx, peerSet -> + if (idx != 0) { + item(key = "user_divider_${peerSet.id}") { Lists.ItemDivider() } } - first = false if (isAndroidTV()) { item { NodesSectionHeader(peerSet = peerSet) } } else { stickyHeader { NodesSectionHeader(peerSet = peerSet) } } - itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer -> + itemsWithDividers(peerSet.nodes, key = { it.StableID }) { peer -> ListItem( modifier = Modifier.combinedClickable( onClick = { onNavigateToPeerDetails(peer) }, - onLongClick = { viewModel.expandedMenuPeer.set(peer) }), + onLongClick = { viewModel.expandedMenuPeer.set(peer) }, + ), colors = MaterialTheme.colorScheme.listItem, headlineContent = { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { Box( modifier = Modifier.padding(top = 2.dp) .size(10.dp) .background( - color = peer.connectedColor(netmap.value), - shape = RoundedCornerShape(percent = 50))) {} - Spacer(modifier = Modifier.size(8.dp)) - Text(text = peer.displayName, style = MaterialTheme.typography.titleMedium) - DropdownMenu( - expanded = expandedPeer.value?.StableID == peer.StableID, - onDismissRequest = { viewModel.hidePeerDropdownMenu() }) { - DropdownMenuItem( - leadingIcon = { - Icon( - painter = painterResource(R.drawable.clipboard), - contentDescription = null) - }, - text = { Text(text = stringResource(R.string.copy_ip_address)) }, - onClick = { - viewModel.copyIpAddress(peer, localClipboardManager) - viewModel.hidePeerDropdownMenu() - }) - netmap.value?.let { netMap -> - if (!peer.isSelfNode(netMap)) { - DropdownMenuItem( - leadingIcon = { - Icon( - painter = painterResource(R.drawable.timer), - contentDescription = null) - }, - text = { Text(text = stringResource(R.string.ping)) }, - onClick = { - viewModel.hidePeerDropdownMenu() - viewModel.startPing(peer) - }) - } - } - } + color = peer.connectedColor(netmap), + shape = RoundedCornerShape(percent = 50), + )) + Text( + text = peer.displayName, + style = MaterialTheme.typography.titleMedium, + ) + if (expandedPeer?.StableID == peer.StableID) { + DeviceDropdownMenu( + viewModel, + peer, + netmap, + ) + } } }, supportingContent = { @@ -713,28 +807,147 @@ fun PeerList( text = peer.Addresses?.first()?.split("/")?.first() ?: "", style = MaterialTheme.typography.bodyMedium.copy( - lineHeight = MaterialTheme.typography.titleMedium.lineHeight)) - }) + lineHeight = MaterialTheme.typography.titleMedium.lineHeight), + ) + }, + ) } } } } } +@Composable +fun DeviceDropdownMenu( + viewModel: MainViewModel, + peer: Tailcfg.Node, + netmap: Netmap.NetworkMap?, +) { + val localClipboardManager = LocalClipboardManager.current + val favorites by viewModel.favorites.collectAsState() + val isFavorite = favorites?.isFavoriteDevice(peer.StableID) == true + + DropdownMenu( + expanded = true, + onDismissRequest = viewModel::hidePeerDropdownMenu, + ) { + netmap?.let { netMap -> + if (!peer.isSelfNode(netMap)) { + DropdownMenuItem( + leadingIcon = { + Icon( + painter = painterResource(R.drawable.sensors_24), + contentDescription = null, + ) + }, + text = { Text(text = stringResource(R.string.ping)) }, + onClick = { + viewModel.hidePeerDropdownMenu() + viewModel.startPing(peer) + }, + ) + } + } + DropdownMenuItem( + leadingIcon = { + Icon( + painter = painterResource(R.drawable.clipboard), + contentDescription = null, + ) + }, + text = { Text(text = stringResource(R.string.copy_magic_dns_address)) }, + onClick = { + viewModel.copyMagicDNSAddress(peer, localClipboardManager) + viewModel.hidePeerDropdownMenu() + }, + ) + DropdownMenuItem( + leadingIcon = { + Icon( + painter = painterResource(R.drawable.clipboard), + contentDescription = null, + ) + }, + text = { Text(text = stringResource(R.string.copy_ipv4_address)) }, + onClick = { + viewModel.copyIPV4Address(peer, localClipboardManager) + viewModel.hidePeerDropdownMenu() + }, + ) + DropdownMenuItem( + leadingIcon = { + Icon( + painter = painterResource(R.drawable.clipboard), + contentDescription = null, + ) + }, + text = { Text(text = stringResource(R.string.copy_ipv6_address)) }, + onClick = { + viewModel.copyIPV6Address(peer, localClipboardManager) + viewModel.hidePeerDropdownMenu() + }, + ) + + HorizontalDivider() + + DropdownMenuItem( + leadingIcon = { + Icon( + painter = painterResource(if (isFavorite) R.drawable.unpin_24 else R.drawable.pin_24), + contentDescription = null, + ) + }, + text = { + Text( + text = stringResource(if (isFavorite) R.string.unpin_device else R.string.pin_device)) + }, + onClick = { + viewModel.togglePin(peer) + viewModel.hidePeerDropdownMenu() + }, + ) + } +} + +@Composable +fun PeerSet.sectionTitle(): String = + if (isFavorite) stringResource(id = R.string.pinned_devices) + else title ?: stringResource(id = R.string.unknown_user) + @Composable fun NodesSectionHeader(peerSet: PeerSet) { Spacer(Modifier.height(16.dp).fillMaxSize().background(color = MaterialTheme.colorScheme.surface)) Lists.LargeTitle( - peerSet.user?.DisplayName ?: stringResource(id = R.string.unknown_user), + peerSet.sectionTitle(), bottomPadding = 8.dp, focusable = isAndroidTV(), style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold) + fontWeight = FontWeight.SemiBold, + leadingIcon = + if (peerSet.isFavorite) { + { + Icon( + painter = painterResource(R.drawable.pin_24), + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else null, + ) +} + +@Preview(showBackground = true) +@Composable +private fun NodesSectionHeaderPreview() { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + NodesSectionHeader(peerSet = PeerSet(FAVORITES_ID, null, nodes = emptyList())) + NodesSectionHeader(peerSet = PeerSet(1, "Thing", nodes = emptyList())) + } } @Composable -fun ExpiryNotification(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) { - if (netmap == null) return +fun ExpiryNotification(netmap: Netmap.NetworkMap, action: () -> Unit = {}) { Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainer)) { Box( modifier = @@ -753,8 +966,10 @@ fun ExpiryNotification(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) { supportingContent = { Text( stringResource(id = R.string.keyExpiryExplainer), - style = MaterialTheme.typography.bodyMedium) - }) + style = MaterialTheme.typography.bodyMedium, + ) + }, + ) } } } @@ -769,9 +984,10 @@ fun PromptForMissingPermissions(viewModel: MainViewModel) { ErrorDialog( title = permission.title, message = permission.description, - buttonText = R.string._continue) { - state.launchPermissionRequest() - } + buttonText = R.string._continue, + ) { + state.launchPermissionRequest() + } } } @@ -803,26 +1019,27 @@ fun Search( ) { Row( verticalAlignment = Alignment.CenterVertically, // Ensure icon aligns with text - modifier = Modifier.fillMaxSize()) { - // Leading Icon - Icon( - imageVector = Icons.Outlined.Search, - contentDescription = "Search", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = - Modifier.padding(start = 0.dp) // Optional start padding for alignment - ) - Spacer(modifier = Modifier.width(4.dp)) - // Placeholder Text - Text( - text = stringResource(R.string.search_ellipsis), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) // Ensure text takes up remaining space - ) - } + modifier = Modifier.fillMaxSize(), + ) { + // Leading Icon + Icon( + imageVector = Icons.Outlined.Search, + contentDescription = "Search", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier.padding(start = 0.dp), // Optional start padding for alignment + ) + Spacer(modifier = Modifier.width(4.dp)) + // Placeholder Text + Text( + text = stringResource(R.string.search_ellipsis), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), // Ensure text takes up remaining space + ) + } } } } @@ -840,7 +1057,8 @@ fun MainViewPreview() { onNavigateToPeerDetails = {}, onNavigateToExitNodes = {}, onNavigateToHealth = {}, - onNavigateToSearch = {}), + onNavigateToSearch = {}, + ), vm, ) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt b/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt index 1490e2bb17..ba5a9ce8c5 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt @@ -53,7 +53,7 @@ fun PeerDetails( model: PeerDetailsViewModel = viewModel( factory = - PeerDetailsViewModelFactory(nodeId, LocalContext.current.filesDir, pingViewModel)) + PeerDetailsViewModelFactory(nodeId, LocalContext.current.filesDir, pingViewModel)), ) { val isPinging by model.isPinging.collectAsState() @@ -67,30 +67,48 @@ fun PeerDetails( Text( text = node.displayName, style = MaterialTheme.typography.titleMedium.short, - color = MaterialTheme.colorScheme.onSurface) + color = MaterialTheme.colorScheme.onSurface, + ) Row(verticalAlignment = Alignment.CenterVertically) { Box( modifier = Modifier.size(8.dp) .background( color = node.connectedColor(netmap), - shape = RoundedCornerShape(percent = 50))) {} + shape = RoundedCornerShape(percent = 50), + )) {} Spacer(modifier = Modifier.size(8.dp)) Text( text = stringResource(id = node.connectedStrRes(netmap)), style = MaterialTheme.typography.bodyMedium.short, - color = MaterialTheme.colorScheme.onSurfaceVariant) + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } }, actions = { IconButton(onClick = { model.startPing() }) { Icon( - painter = painterResource(R.drawable.timer), - contentDescription = "Ping device") + painter = painterResource(R.drawable.sensors_24), + contentDescription = "Ping device", + ) + } + + val favorites by model.favorites.collectAsState() + val isWriting by model.isWritingFavorites.collectAsState() + val isPinned = favorites?.isFavoriteDevice(node.StableID) == true + + IconButton(enabled = !isWriting, onClick = { model.togglePin() }) { + Icon( + painterResource(if (isPinned) R.drawable.unpin_24 else R.drawable.pin_24), + contentDescription = + stringResource( + if (isPinned) R.string.unpin_device else R.string.pin_device), + ) } }, - onBack = onNavigateBack) + onBack = onNavigateBack, + ) }, ) { innerPadding -> LazyColumn( @@ -142,7 +160,8 @@ fun AddressRow(address: String, type: String) { if (!isAndroidTV()) { Icon(painter = painterResource(id = R.drawable.clipboard), null) } - }) + }, + ) } @Composable @@ -150,5 +169,6 @@ fun ValueRow(title: String, value: String) { ListItem( colors = MaterialTheme.colorScheme.listItem, headlineContent = { Text(text = title) }, - supportingContent = { Text(text = value) }) + supportingContent = { Text(text = value) }, + ) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt index b5e7897e32..6e64d6a02d 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt @@ -59,6 +59,7 @@ import com.tailscale.ipn.R import com.tailscale.ipn.ui.theme.listItem import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.viewModel.MainViewModel +import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.delay @RequiresApi(Build.VERSION_CODES.TIRAMISU) @@ -68,7 +69,7 @@ fun SearchView( viewModel: MainViewModel, navController: NavController, onNavigateBack: () -> Unit, - autoFocus: Boolean // Pass true if coming from the main view, false otherwise. + autoFocus: Boolean, // Pass true if coming from the main view, false otherwise. ) { // Use TextFieldValue to preserve text and cursor position. var searchFieldValue by @@ -106,7 +107,7 @@ fun SearchView( LaunchedEffect(searchTerm, filteredPeers) { if (searchTerm.isEmpty() && filteredPeers.isNotEmpty()) { - delay(100) // Give Compose time to update list + delay(100.milliseconds) // Give Compose time to update list listState.scrollToItem(0) } } @@ -114,7 +115,7 @@ fun SearchView( // Use the autoFocus parameter to decide if we request focus when entering. LaunchedEffect(autoFocus) { if (autoFocus) { - delay(300) // Delay to ensure UI is fully composed + delay(300.milliseconds) // Delay to ensure UI is fully composed focusRequester.requestFocus() keyboardController?.show() } @@ -154,7 +155,8 @@ fun SearchView( Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.search), - tint = MaterialTheme.colorScheme.onSurfaceVariant) + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) } }, trailingIcon = { @@ -168,7 +170,8 @@ fun SearchView( }) { Icon( Icons.Default.Clear, - contentDescription = stringResource(R.string.clear_search)) + contentDescription = stringResource(R.string.clear_search), + ) } } }, @@ -188,7 +191,8 @@ fun SearchView( style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Light, backgroundColor = noResultsBackground, - fontColor = MaterialTheme.colorScheme.onSurfaceVariant) + fontColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } else { @@ -199,8 +203,7 @@ fun SearchView( } firstGroup = false - val userName = peerSet.user?.DisplayName ?: "Unknown User" - peerSet.peers.forEachIndexed { index, peer -> + peerSet.nodes.forEachIndexed { index, peer -> if (index > 0) { item(key = "divider_${peer.StableID}") { Lists.ItemDivider() } } @@ -214,7 +217,10 @@ fun SearchView( Box( modifier = Modifier.size(10.dp) - .background(onlineColor, RoundedCornerShape(50))) + .background( + onlineColor, + RoundedCornerShape(50), + )) Spacer(modifier = Modifier.size(8.dp)) Text(peer.displayName) } @@ -222,7 +228,7 @@ fun SearchView( }, supportingContent = { Column { - Text(userName) + Text(peerSet.sectionTitle()) Text(peer.Addresses?.firstOrNull()?.split("/")?.first() ?: "No IP") } }, @@ -232,7 +238,8 @@ fun SearchView( .clickable { viewModel.disableSearchAutoFocus() navController.navigate("peerDetails/${peer.StableID}") - }) + }, + ) } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt index c2cf247de7..08e3afe28f 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt @@ -50,22 +50,27 @@ open class IpnViewModel : ViewModel() { enum class NodeState { NONE, ACTIVE_AND_RUNNING, + // Last selected exit node is active but is not being used. ACTIVE_NOT_RUNNING, + // Last selected exit node is currently offline. OFFLINE_ENABLED, + // Last selected exit node has been de-selected and is currently offline. OFFLINE_DISABLED, + // Exit node selection is managed by an administrator, and last selected exit node is currently // offline OFFLINE_MDM, - RUNNING_AS_EXIT_NODE + RUNNING_AS_EXIT_NODE, } init { viewModelScope.launch { Notifier.state.collect { - // Reload the user profiles on all state transitions to ensure loggedInUser is correct + // Reload the user profiles on all state transitions to ensure loggedInUser is + // correct viewModelScope.launch { loadUserProfiles() } } } @@ -73,8 +78,8 @@ open class IpnViewModel : ViewModel() { // This will observe the userId of the current node and reload our user profiles if // we discover it has changed (e.g. due to a login or user switch) viewModelScope.launch { - Notifier.netmap.collect { - it?.SelfNode?.User.let { + Notifier.netmap.collect { netmap -> + netmap?.SelfNode?.User.let { if (it != selfNodeUserId) { selfNodeUserId = it viewModelScope.launch { loadUserProfiles() } @@ -121,7 +126,7 @@ open class IpnViewModel : ViewModel() { NodeState.ACTIVE_NOT_RUNNING } } - isRunningExitNode == true -> { + isRunningExitNode -> { NodeState.RUNNING_AS_EXIT_NODE } else -> { @@ -161,7 +166,7 @@ open class IpnViewModel : ViewModel() { fun login( maskedPrefs: Ipn.MaskedPrefs? = null, authKey: String? = null, - completionHandler: (Result) -> Unit = {} + completionHandler: (Result) -> Unit = {}, ) { // Start the IPNService foreground notification so that Android // does not freeze the process or cut network access while the user is in the browser @@ -188,9 +193,9 @@ open class IpnViewModel : ViewModel() { TSLog.e(TAG, "editPrefs() failed: ${it.message}") completionHandler(Result.failure(it)) } - .onSuccess { - it.WantRunning = true - val opts = Ipn.Options(UpdatePrefs = it, AuthKey = authKey) + .onSuccess { success -> + success.WantRunning = true + val opts = Ipn.Options(UpdatePrefs = success, AuthKey = authKey) client.start(opts) { startResult -> startResult .onFailure { @@ -201,7 +206,10 @@ open class IpnViewModel : ViewModel() { client.startLoginInteractive { loginResult -> loginResult .onFailure { - TSLog.e(TAG, "startLoginInteractive() failed: ${it.message}") + TSLog.e( + TAG, + "startLoginInteractive() failed: ${it.message}", + ) completionHandler(Result.failure(it)) } .onSuccess { completionHandler(Result.success(Unit)) } @@ -220,7 +228,7 @@ open class IpnViewModel : ViewModel() { fun loginWithCustomControlURL( controlURL: String, - completionHandler: (Result) -> Unit = {} + completionHandler: (Result) -> Unit = {}, ) { val prefs = Ipn.MaskedPrefs() prefs.ControlURL = controlURL @@ -304,12 +312,12 @@ open class IpnViewModel : ViewModel() { fun setRunningExitNode(isOn: Boolean) { LoadingIndicator.start() lastPrefs?.let { currentPrefs -> - val newPrefs: Ipn.MaskedPrefs - if (isOn) { - newPrefs = setZeroRoutes(currentPrefs) - } else { - newPrefs = removeAllZeroRoutes(currentPrefs) - } + val newPrefs: Ipn.MaskedPrefs = + if (isOn) { + setZeroRoutes(currentPrefs) + } else { + removeAllZeroRoutes(currentPrefs) + } Client(viewModelScope).editPrefs(newPrefs) { result -> LoadingIndicator.stop() TSLog.d("RunExitNodeViewModel", "Edited prefs: $result") @@ -318,7 +326,7 @@ open class IpnViewModel : ViewModel() { } private fun setZeroRoutes(prefs: Ipn.Prefs): Ipn.MaskedPrefs { - val newRoutes = (removeAllZeroRoutes(prefs).AdvertiseRoutes ?: emptyList()).toMutableList() + val newRoutes = removeAllZeroRoutes(prefs).AdvertiseRoutes.orEmpty().toMutableList() newRoutes.add("0.0.0.0/0") newRoutes.add("::/0") val newPrefs = Ipn.MaskedPrefs() @@ -328,7 +336,7 @@ open class IpnViewModel : ViewModel() { private fun removeAllZeroRoutes(prefs: Ipn.Prefs): Ipn.MaskedPrefs { val newRoutes = emptyList().toMutableList() - (prefs.AdvertiseRoutes ?: emptyList()).forEach { + prefs.AdvertiseRoutes.orEmpty().forEach { if (it != "0.0.0.0/0" && it != "::/0") { newRoutes.add(it) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt index 06d8df8b8d..18f106b5e5 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt @@ -17,7 +17,7 @@ import androidx.lifecycle.viewModelScope import com.tailscale.ipn.App import com.tailscale.ipn.R import com.tailscale.ipn.mdm.MDMSettings -import com.tailscale.ipn.ui.model.Ipn +import com.tailscale.ipn.ui.model.Favorites import com.tailscale.ipn.ui.model.Ipn.State import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.notifier.Notifier @@ -25,16 +25,22 @@ 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 com.tailscale.ipn.ui.util.withPinnedSection import com.tailscale.ipn.util.TSLog import java.time.Duration +import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class MainViewModelFactory(private val appViewModel: AppViewModel) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") @@ -50,34 +56,50 @@ class MainViewModelFactory(private val appViewModel: AppViewModel) : ViewModelPr class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { // The user readable state of the system val stateRes: StateFlow = MutableStateFlow(userStringRes(State.NoState, State.NoState, true)) + // The expected state of the VPN toggle private val _vpnToggleState = MutableStateFlow(false) val vpnToggleState: StateFlow = _vpnToggleState + // Keeps track of whether a toggle operation is in progress. This ensures that toggleVpn cannot be // invoked until the current operation is complete. - var isToggleInProgress = MutableStateFlow(false) + private val _isToggleInProgress = MutableStateFlow(false) + val isToggleInProgress: StateFlow = _isToggleInProgress + // Permission to prepare VPN private var vpnPermissionLauncher: ActivityResultLauncher? = null private val _requestVpnPermission = MutableStateFlow(false) val requestVpnPermission: StateFlow = _requestVpnPermission + // Select Taildrop directory private var directoryPickerLauncher: ActivityResultLauncher? = null + // The list of peers private val _peers = MutableStateFlow>(emptyList()) val peers: StateFlow> = _peers + + private val _groupedPeers = MutableStateFlow>(emptyList()) + // The list of peers private val _searchViewPeers = MutableStateFlow>(emptyList()) val searchViewPeers: StateFlow> = _searchViewPeers + // The current state of the IPN for determining view visibility val ipnState = Notifier.state + + private val favoritesManager = App.get().favoritesManager + val favorites: StateFlow = favoritesManager.favorites + val isWritingFavorites: StateFlow = favoritesManager.writing + // The active search term for filtering peers private val _searchTerm = MutableStateFlow("") val searchTerm: StateFlow = _searchTerm var autoFocusSearch by mutableStateOf(true) private set - // True if we should render the key expiry bannder + // True if we should render the key expiry banner val showExpiry: StateFlow = MutableStateFlow(false) + // The peer for which the dropdown menu is currently expanded. Null if no menu is expanded var expandedMenuPeer: StateFlow = MutableStateFlow(null) @@ -90,7 +112,7 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { val isVpnActive: StateFlow = appViewModel.vpnActive - var searchJob: Job? = null + private var searchJob: Job? = null // Icon displayed in the button to present the health view val healthIcon: StateFlow = MutableStateFlow(null) @@ -103,10 +125,22 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { expandedMenuPeer.set(null) } - fun copyIpAddress(peer: Tailcfg.Node, clipboardManager: ClipboardManager) { + fun copyIPV4Address(peer: Tailcfg.Node, clipboardManager: ClipboardManager) { clipboardManager.setText(AnnotatedString(peer.primaryIPv4Address ?: "")) } + fun copyIPV6Address(peer: Tailcfg.Node, clipboardManager: ClipboardManager) { + clipboardManager.setText(AnnotatedString(peer.primaryIPv6Address ?: "")) + } + + fun copyMagicDNSAddress(peer: Tailcfg.Node, clipboardManager: ClipboardManager) { + clipboardManager.setText(AnnotatedString(peer.magicDNSAddress ?: "")) + } + + fun togglePin(peer: Tailcfg.Node) { + favoritesManager.toggleDevice(peer.StableID) + } + fun startPing(peer: Tailcfg.Node) { this.pingViewModel.startPing(peer) } @@ -123,6 +157,8 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { } private val peerCategorizer = PeerCategorizer() + @OptIn(ExperimentalCoroutinesApi::class) + private val categorizerDispatcher = Dispatchers.Default.limitedParallelism(1) init { viewModelScope.launch { @@ -145,38 +181,56 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { previousState = currentState } } + viewModelScope.launch { - _searchTerm.debounce(250L).collect { term -> + _searchTerm.debounce(250L.milliseconds).collect { term -> // run the search as a background task searchJob?.cancel() searchJob = - launch(Dispatchers.Default) { + launch(categorizerDispatcher) { val filteredPeers = peerCategorizer.groupedAndFilteredPeers(term) _searchViewPeers.value = filteredPeers } } } + + // handle grouping viewModelScope.launch { - Notifier.netmap.collect { it -> - it?.let { netmap -> - searchJob?.cancel() - launch(Dispatchers.Default) { - peerCategorizer.regenerateGroupedPeers(netmap) - val filteredPeers = peerCategorizer.groupedAndFilteredPeers(searchTerm.value) - _peers.value = peerCategorizer.peerSets - _searchViewPeers.value = filteredPeers - } - if (netmap.SelfNode.keyDoesNotExpire) { - showExpiry.set(false) - return@let - } else { - val expiryNotificationWindowMDM = MDMSettings.keyExpirationNotice.flow.value.value - val window = - expiryNotificationWindowMDM?.let { TimeUtil.duration(it) } ?: Duration.ofHours(24) - val expiresSoon = - TimeUtil.isWithinExpiryNotificationWindow(window, it.SelfNode.KeyExpiry ?: "") - showExpiry.set(expiresSoon) + Notifier.netmap.filterNotNull().collectLatest { netmap -> + searchJob?.cancel() + withContext(categorizerDispatcher) { + peerCategorizer.regenerateGroupedPeers(netmap) + val filteredPeers = peerCategorizer.groupedAndFilteredPeers(searchTerm.value) + _groupedPeers.value = peerCategorizer.peerSets + _searchViewPeers.value = filteredPeers + } + } + } + + // transform with favorites + viewModelScope.launch { + combine(_groupedPeers, favorites) { sets, favs -> sets to favs } + .collectLatest { (sets, favs) -> + withContext(categorizerDispatcher) { + _peers.value = sets.withPinnedSection(favs?.deviceIds.orEmpty()) + } } + } + + // Key expiry + viewModelScope.launch { + Notifier.netmap.filterNotNull().collect { netmap -> + if (netmap.SelfNode.keyDoesNotExpire) { + showExpiry.set(false) + } else { + val expiryNotificationWindowMDM = MDMSettings.keyExpirationNotice.flow.value.value + val window = + expiryNotificationWindowMDM?.let { TimeUtil.duration(it) } ?: Duration.ofHours(24) + showExpiry.set( + TimeUtil.isWithinExpiryNotificationWindow( + window, + netmap.SelfNode.KeyExpiry ?: "", + )) } } } @@ -208,23 +262,23 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { } viewModelScope.launch { - isToggleInProgress.value = true + _isToggleInProgress.value = true try { val currentState = Notifier.state.value if (desiredState) { // User wants to turn ON the VPN when { - currentState != Ipn.State.Running -> showVPNPermissionLauncherIfUnauthorized() + currentState != State.Running -> showVPNPermissionLauncherIfUnauthorized() } } else { // User wants to turn OFF the VPN - if (currentState == Ipn.State.Running) { + if (currentState == State.Running) { stopVPN() } } } finally { - isToggleInProgress.value = false + _isToggleInProgress.value = false } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt index b0531c13f7..d16dbedd96 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt @@ -6,6 +6,8 @@ package com.tailscale.ipn.ui.viewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import com.tailscale.ipn.App +import com.tailscale.ipn.ui.model.Favorites import com.tailscale.ipn.ui.model.StableNodeID import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.notifier.Notifier @@ -21,7 +23,7 @@ data class PeerSettingInfo(val titleRes: Int, val value: ComposableStringFormatt class PeerDetailsViewModelFactory( private val nodeId: StableNodeID, private val filesDir: File, - private val pingViewModel: PingViewModel + private val pingViewModel: PingViewModel, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { @@ -32,11 +34,15 @@ class PeerDetailsViewModelFactory( class PeerDetailsViewModel( val nodeId: StableNodeID, val filesDir: File, - val pingViewModel: PingViewModel + val pingViewModel: PingViewModel, ) : IpnViewModel() { val node: StateFlow = MutableStateFlow(null) val isPinging: StateFlow = MutableStateFlow(false) + private val favoritesManager = App.get().favoritesManager + val favorites: StateFlow = favoritesManager.favorites + val isWritingFavorites: StateFlow = favoritesManager.writing + init { viewModelScope.launch { Notifier.netmap.collect { nm -> @@ -55,4 +61,8 @@ class PeerDetailsViewModel( isPinging.set(false) this.pingViewModel.handleDismissal() } + + fun togglePin() { + node.value?.let { favoritesManager.toggleDevice(it.StableID) } + } } diff --git a/android/src/main/res/drawable/pin_24.xml b/android/src/main/res/drawable/pin_24.xml new file mode 100644 index 0000000000..b7be67f893 --- /dev/null +++ b/android/src/main/res/drawable/pin_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/sensors_24.xml b/android/src/main/res/drawable/sensors_24.xml new file mode 100644 index 0000000000..fbc3c488df --- /dev/null +++ b/android/src/main/res/drawable/sensors_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/timer.xml b/android/src/main/res/drawable/timer.xml deleted file mode 100644 index c00ce1df75..0000000000 --- a/android/src/main/res/drawable/timer.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/android/src/main/res/drawable/unpin_24.xml b/android/src/main/res/drawable/unpin_24.xml new file mode 100644 index 0000000000..4e2e158938 --- /dev/null +++ b/android/src/main/res/drawable/unpin_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index c2dad1908d..818c0bd51c 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -13,7 +13,7 @@ Not connected %s - Selected + Selected Offline OK Continue @@ -142,11 +142,11 @@ As the owner of this tailnet, to remove yourself from the tailnet you can either reassign ownership and contact our Support team, or delete the whole tailnet through the admin console. To do the latter, go to - + and look for “Delete tailnet”. - + All requests related to the removal or deletion of data are handled by our Support team. To open a request, tap the Contact Support button below to be taken to our contact form in the browser. Complete the form, and a Customer Support Engineer will work with you directly to assist. @@ -303,7 +303,6 @@ Notifications delivered when a file is received using Taildrop. Errors and warnings This notification category is used to deliver important status notifications and should be left enabled. For instance, it is used to notify you about errors or warnings that affect Internet connectivity. - Copy IP Address Ping Relayed connection (%1$s) Direct connection @@ -396,4 +395,13 @@ Enable hardware attestation Use hardware-backed keys to bind node identity to the device + + Pinned Devices + + + Pin device + Unpin device + Copy MagicDNS hostname + Copy IPv4 + Copy IPv6 diff --git a/android/src/test/kotlin/com/tailcale/ipn/ui/PeerCategorizerTest.kt b/android/src/test/kotlin/com/tailcale/ipn/ui/PeerCategorizerTest.kt new file mode 100644 index 0000000000..98c2a9c73e --- /dev/null +++ b/android/src/test/kotlin/com/tailcale/ipn/ui/PeerCategorizerTest.kt @@ -0,0 +1,227 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailcale.ipn.ui + +import com.tailscale.ipn.mdm.MDMSettings +import com.tailscale.ipn.mdm.SettingState +import com.tailscale.ipn.ui.model.Netmap +import com.tailscale.ipn.ui.model.Tailcfg +import com.tailscale.ipn.ui.util.PeerCategorizer +import com.tailscale.ipn.ui.util.PeerSet +import com.tailscale.ipn.ui.util.withPinnedSection +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +private const val ME_ID = 1L +private const val BOB_ID = 2L +private const val ZEKE_ID = 3L +private const val TAGGED_ID = 4L + +private fun node( + stableId: String, + computedName: String, + user: Long, + address: String, +) = + Tailcfg.Node( + StableID = stableId, + Name = "$computedName.example.ts.net.", + User = user, + ComputedName = computedName, + Addresses = listOf(address), + ) + +private fun profile(id: Long, displayName: String, loginName: String) = + Tailcfg.UserProfile(ID = id, DisplayName = displayName, LoginName = loginName) + +class PeerCategorizerTest { + + private val self = node("self", "my-phone", ME_ID, "100.64.0.1/32") + private val delta = node("d1", "Delta", ME_ID, "100.64.0.2/32") + private val alpha = node("a1", "alpha", ME_ID, "100.64.0.3/32") + private val bravo = node("b1", "bravo", BOB_ID, "100.64.0.4/32") + private val zulu = node("z1", "zulu", ZEKE_ID, "100.64.0.5/32") + private val taggedBox = node("t1", "tagged-box", TAGGED_ID, "100.64.0.6/32") + + // isMullvadNode matches on Name/ComputedName suffix + private val mullvad = node("mv", "se-sto-wg-001.mullvad.ts.net", BOB_ID, "100.64.0.7/32") + + private val netmap = + Netmap.NetworkMap( + SelfNode = self, + Peers = listOf(delta, alpha, bravo, zulu, taggedBox, mullvad), + Domain = "example.ts.net", + UserProfiles = + mapOf( + ME_ID.toString() to profile(ME_ID, "alice", "alice@example.com"), + BOB_ID.toString() to profile(BOB_ID, "bob", "bob@example.com"), + ZEKE_ID.toString() to profile(ZEKE_ID, "zeke", "zeke@example.com"), + TAGGED_ID.toString() to profile(TAGGED_ID, "tagged-devices", "tagged-devices"), + ), + TKAEnabled = false, + ) + + // Used to prove the search cache is invalidated by a regeneration. + private val netmapWithoutZulu = + netmap.copy(Peers = listOf(delta, alpha, bravo, taggedBox, mullvad)) + + private lateinit var categorizer: PeerCategorizer + + @Before + fun setUp() { + categorizer = PeerCategorizer() + clearHiddenDevices() + } + + // MDMSettings is a process-wide singleton + @After + fun tearDown() { + clearHiddenDevices() + } + + private fun hideDevices(vararg categories: String) { + MDMSettings.hiddenNetworkDevices.flow.value = SettingState(categories.toList(), true) + } + + private fun clearHiddenDevices() { + MDMSettings.hiddenNetworkDevices.flow.value = SettingState(null, false) + } + + private fun regenerate(map: Netmap.NetworkMap = netmap) = categorizer.regenerateGroupedPeers(map) + + private fun sectionIds(sets: List = categorizer.peerSets) = sets.map { it.id } + + private fun section(id: Long, sets: List = categorizer.peerSets) = + sets.first { it.id == id } + + private fun stableIds(peerSet: PeerSet) = peerSet.nodes.map { it.StableID } + + // ---------------------------------------------------------------- grouping + + @Test + fun currentUserSectionSortsFirstThenAlphabetically() { + regenerate() + + assertEquals(listOf(ME_ID, BOB_ID, TAGGED_ID, ZEKE_ID), sectionIds()) + assertEquals( + listOf("alice", "bob", "tagged-devices", "zeke"), + categorizer.peerSets.map { it.title }, + ) + } + + @Test + fun withinASection_selfNodeIsFirstThenCaseInsensitiveAlphabetical() { + regenerate() + + // "alpha" must sort before "Delta" + assertEquals(listOf("self", "a1", "d1"), stableIds(section(ME_ID))) + } + + @Test + fun mullvadNodesAreExcludedEntirely() { + regenerate() + + assertTrue(categorizer.peerSets.none { set -> set.nodes.any { it.StableID == "mv" } }) + } + + // ------------------------------------------------------------- MDM filters + // + // MDM filtering runs here, before withPinnedSection ever sees the nodes + + @Test + fun hideTaggedDevices_hidesTaggedDevices() { + hideDevices("tagged-devices") + regenerate() + + assertTrue(sectionIds().none { it == TAGGED_ID }) + } + + @Test + fun hideOtherDevices_keepsMyOwnPinnedDevices() { + hideDevices("other-users") + regenerate() + + val sets = categorizer.peerSets.withPinnedSection(listOf("d1")) + + assertEquals(listOf("d1"), stableIds(section(PeerSet.FAVORITES_ID, sets))) + } + + @Test + fun hideMyDevices_alsoHidesMyPinnedDevices() { + hideDevices("current-user") + regenerate() + + val sets = categorizer.peerSets.withPinnedSection(listOf("d1")) + + assertTrue(sets.none { set -> set.nodes.any { it.StableID == "d1" } }) + } + + @Test + fun hideTaggedDevices_alsoHidesPinnedTaggedDevices() { + hideDevices("tagged-devices") + regenerate() + + val sets = categorizer.peerSets.withPinnedSection(listOf("t1")) + + assertTrue(sets.none { set -> set.nodes.any { it.StableID == "t1" } }) + } + + // ----------------------------------------------------------------- search + + @Test + fun searchMatchesNodeName() { + regenerate() + + val result = categorizer.groupedAndFilteredPeers("zulu") + + assertEquals(listOf(ZEKE_ID), result.map { it.id }) + assertEquals(listOf("z1"), stableIds(result.single())) + } + + @Test + fun searchMatchesAddress() { + regenerate() + + val result = categorizer.groupedAndFilteredPeers("100.64.0.5") + + assertEquals(listOf("z1"), stableIds(result.single())) + } + + @Test + fun searchMatchingAUserNameReturnsTheWholeSection() { + regenerate() + + val result = categorizer.groupedAndFilteredPeers("ali") + + assertEquals(listOf(ME_ID), result.map { it.id }) + assertEquals(listOf("self", "a1", "d1"), stableIds(result.single())) + } + + @Test + fun incrementalSearchNarrowsThePreviousResult() { + regenerate() + + // "tagg" matches the "tagged-devices" section title, so the whole section + // comes back and is cached as lastSearchResult. + assertEquals(listOf(TAGGED_ID), categorizer.groupedAndFilteredPeers("tagg").map { it.id }) + + // "tagged-box" no longer matches the title but does match the node, and is + // resolved against the cached result rather than all peerSets. + val result = categorizer.groupedAndFilteredPeers("tagged-box") + assertEquals(listOf("t1"), stableIds(result.single())) + } + + @Test + fun regeneratingInvalidatesTheSearchCache() { + regenerate() + assertEquals(listOf(ZEKE_ID), categorizer.groupedAndFilteredPeers("zulu").map { it.id }) + + regenerate(netmapWithoutZulu) + + assertTrue(categorizer.groupedAndFilteredPeers("zulu").isEmpty()) + } +} diff --git a/android/src/test/kotlin/com/tailcale/ipn/ui/util/FavoritesTest.kt b/android/src/test/kotlin/com/tailcale/ipn/ui/util/FavoritesTest.kt new file mode 100644 index 0000000000..4f3652904e --- /dev/null +++ b/android/src/test/kotlin/com/tailcale/ipn/ui/util/FavoritesTest.kt @@ -0,0 +1,110 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailcale.ipn.ui.util + +import com.tailscale.ipn.ui.model.FavoriteItem +import com.tailscale.ipn.ui.model.Favorites +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class FavoritesTest { + + private fun ids(favorites: Favorites) = favorites.devices?.mapNotNull { it.id } + + @Test + fun emptyFavoritesHasNoPinnedDevices() { + val favorites = Favorites() + + assertTrue(favorites.deviceIds.isEmpty()) + assertFalse(favorites.isFavoriteDevice("n1")) + } + + @Test + fun deviceIdsSkipsEntriesWithoutAnId() { + val favorites = + Favorites( + devices = + listOf( + FavoriteItem(id = "n1"), + FavoriteItem(name = "no id"), + FavoriteItem(id = "n2"), + )) + + assertEquals(setOf("n1", "n2"), favorites.deviceIds.toSet()) + } + + @Test + fun togglingAnUnpinnedDeviceAddsIt() { + val request = Favorites(devices = listOf(FavoriteItem(id = "n1"))).withToggledDevice("n2") + + assertEquals(listOf("n1", "n2"), ids(request.pins)) + } + + @Test + fun togglingAPinnedDeviceRemovesIt() { + val favorites = Favorites(devices = listOf(FavoriteItem(id = "n1"), FavoriteItem(id = "n2"))) + + assertEquals(listOf("n2"), ids(favorites.withToggledDevice("n1").pins)) + } + + @Test + fun toggleOnlyMarksDevicesAsSet() { + val request = Favorites().withToggledDevice("n1") + + assertEquals(true, request.devicesSet) + assertNull(request.exitNodesSet) + assertNull(request.servicesSet) + } + + @Test + fun togglePreservesExitNodesAndServices() { + val favorites = + Favorites( + devices = listOf(FavoriteItem(id = "n1")), + exitNodes = listOf(FavoriteItem(id = "x1")), + services = listOf(FavoriteItem(id = "s1")), + ) + + val request = favorites.withToggledDevice("n1") + + assertEquals(listOf("x1"), request.pins.exitNodes?.map { it.id }) + assertEquals(listOf("s1"), request.pins.services?.map { it.id }) + } + + @Test + fun pinRequestSerializesToTheExpectedJson() { + val request = Favorites().withToggledDevice("nodeA") + + assertEquals( + """{"Pins":{"Devices":[{"ID":"nodeA"}]},"DevicesSet":true}""", + Json.encodeToString(request), + ) + } + + @Test + fun unpinningTheLastDeviceSendsAnExplicitEmptyList() { + // An omitted Devices key would mean "no change" to the backend + val request = Favorites(devices = listOf(FavoriteItem(id = "nodeA"))).withToggledDevice("nodeA") + + assertEquals("""{"Pins":{"Devices":[]},"DevicesSet":true}""", Json.encodeToString(request)) + } + + @Test + fun parsesAGetPinsResponse() { + // Client decodes with ignoreUnknownKeys = true; mirror that here. + val json = """{"Devices":[{"ID":"nodeA"}],"ExitNodes":null,"Services":[],"Unknown":1}""" + + val favorites = Json { ignoreUnknownKeys = true }.decodeFromString(json) + + assertTrue(favorites.isFavoriteDevice("nodeA")) + assertEquals("nodeA", favorites.devices?.single()?.id) + assertNull(favorites.exitNodes) + assertEquals(emptyList(), favorites.services) + } +} diff --git a/android/src/test/kotlin/com/tailcale/ipn/ui/util/PinnedSectionTest.kt b/android/src/test/kotlin/com/tailcale/ipn/ui/util/PinnedSectionTest.kt new file mode 100644 index 0000000000..3e09636185 --- /dev/null +++ b/android/src/test/kotlin/com/tailcale/ipn/ui/util/PinnedSectionTest.kt @@ -0,0 +1,104 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailcale.ipn.ui.util + +import com.tailscale.ipn.ui.model.Tailcfg +import com.tailscale.ipn.ui.util.PeerSet +import com.tailscale.ipn.ui.util.withPinnedSection +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +private const val ALICE_ID = 1L +private const val BOB_ID = 2L +private const val ZEKE_ID = 3L + +private fun node(stableId: String, computedName: String) = + Tailcfg.Node( + StableID = stableId, + Name = "$computedName.example.ts.net.", + ComputedName = computedName, + ) + +// withPinnedSection is a pure list transform: no netmap, no MDM singleton, no ordering +// rules of its own beyond the pin order handed to it. +class PinnedSectionTest { + + private val alpha = node("a1", "alpha") + private val delta = node("d1", "Delta") + private val bravo = node("b1", "bravo") + private val zulu = node("z1", "zulu") + + private val sets = + listOf( + PeerSet(ALICE_ID, "alice", listOf(alpha, delta)), + PeerSet(BOB_ID, "bob", listOf(bravo)), + PeerSet(ZEKE_ID, "zeke", listOf(zulu)), + ) + + private fun stableIds(peerSet: PeerSet) = peerSet.nodes.map { it.StableID } + + @Test + fun noPinsReturnsTheInputUnchanged() { + assertSame(sets, sets.withPinnedSection(emptyList())) + } + + @Test + fun pinnedSectionSortsFirstAndHasANullTitle() { + val result = sets.withPinnedSection(listOf("z1")) + + val pinned = result.first() + assertEquals(PeerSet.FAVORITES_ID, pinned.id) + // The title is resolved from a string resource at render time (PeerSet.sectionTitle). + assertNull(pinned.title) + } + + @Test + fun pinnedSectionUsesPinOrderNotAlphabetical() { + // Backend list order is the user's display order. Alphabetically this would be + // ["d1", "z1"]; the pin list says otherwise. + val result = sets.withPinnedSection(listOf("z1", "d1")) + + assertEquals(listOf("z1", "d1"), stableIds(result.first())) + } + + @Test + fun pinnedNodesAreRemovedFromTheirOwnerSection() { + // Move, don't duplicate -- matching NodeSearcher.prependFavoritesSection on darwin. + // Also required by LazyColumn, which rejects a duplicated item key. + val result = sets.withPinnedSection(listOf("d1")) + + assertEquals(listOf("a1"), stableIds(result.first { it.id == ALICE_ID })) + } + + @Test + fun ownerSectionDisappearsWhenAllOfItsNodesArePinned() { + // zeke owns only z1, so pinning it empties their section entirely. + val result = sets.withPinnedSection(listOf("z1")) + + assertEquals(listOf(PeerSet.FAVORITES_ID, ALICE_ID, BOB_ID), result.map { it.id }) + } + + @Test + fun pinsForNodesNotInTheNetmapAreSkipped() { + val result = sets.withPinnedSection(listOf("gone", "z1")) + + assertEquals(listOf("z1"), stableIds(result.first())) + } + + @Test + fun allStalePinsReturnTheInputUnchanged() { + assertSame(sets, sets.withPinnedSection(listOf("gone", "alsoGone"))) + } + + @Test + fun aDuplicatedPinIdYieldsTheNodeOnce() { + // A duplicated id from the backend would otherwise produce two rows with the same + // StableID, which LazyColumn throws on ("Key was already used"). + val result = sets.withPinnedSection(listOf("d1", "d1")) + + assertEquals(listOf("d1"), stableIds(result.first())) + } +}