From f46a92c025b7e8fe81d8eaba0e5dfac0d8209e14 Mon Sep 17 00:00:00 2001 From: hiimtmac Date: Thu, 3 Sep 2026 18:47:33 -0500 Subject: [PATCH 01/14] add new resources and strings --- android/src/main/res/drawable/pin_24.xml | 10 ++++++++++ android/src/main/res/drawable/sensors_24.xml | 10 ++++++++++ android/src/main/res/drawable/unpin_24.xml | 10 ++++++++++ android/src/main/res/values/strings.xml | 20 ++++++++++++++------ 4 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 android/src/main/res/drawable/pin_24.xml create mode 100644 android/src/main/res/drawable/sensors_24.xml create mode 100644 android/src/main/res/drawable/unpin_24.xml 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/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..d9554b0564 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ - + Log in @@ -13,13 +13,13 @@ Not connected %s - Selected + Selected Offline OK Continue Warning Search - Search... + Search… Dismiss No results Back @@ -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 From 39f45f9dfb72132f6d5c8a5c0107d5d7facc18c0 Mon Sep 17 00:00:00 2001 From: hiimtmac Date: Thu, 3 Sep 2026 19:00:13 -0500 Subject: [PATCH 02/14] models and logic changes --- .../com/tailscale/ipn/ui/localapi/Client.kt | 80 +++++++++---- .../com/tailscale/ipn/ui/model/Favorites.kt | 46 ++++++++ .../com/tailscale/ipn/ui/model/TailCfg.kt | 44 ++++--- .../com/tailscale/ipn/ui/util/PeerHelper.kt | 109 +++++++++++------- .../ipn/ui/viewModel/IpnViewModel.kt | 89 ++++++++++---- .../ipn/ui/viewModel/MainViewModel.kt | 79 ++++++++++--- 6 files changed, 327 insertions(+), 120 deletions(-) create mode 100644 android/src/main/java/com/tailscale/ipn/ui/model/Favorites.kt 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..bdbc2884dc 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() @@ -176,7 +179,8 @@ class Client(private val scope: CoroutineScope) { part.contentLength = file.DeclaredSize part.body = InputStreamAdapter(stream) part - }) + } + ) } catch (e: Exception) { parts.forEach { it.body.close() } TSLog.e(TAG, "Error creating file upload body: $e") @@ -191,10 +195,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 +217,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 +233,8 @@ class Client(private val scope: CoroutineScope) { path = path, body = body, responseType = typeOf(), - responseHandler = responseHandler) + responseHandler = responseHandler, + ) .execute() } @@ -225,7 +242,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 +251,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 +268,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,22 +284,25 @@ 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() } + // endregion } class Request( @@ -291,7 +313,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 +336,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 +363,10 @@ 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 +380,12 @@ 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..c4d820fb8b --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Favorites.kt @@ -0,0 +1,46 @@ +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 + get() = devices.orEmpty().mapNotNull { it.id } + + fun isFavoriteDevice(id: StableNodeID): Boolean { + return deviceIds.contains(id) + } + + 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..775b29b1aa 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) @@ -118,10 +121,12 @@ class Tailcfg { val exitNodeName: String get() { - if (isMullvadNode && - Hostinfo.Location?.Country != null && - Hostinfo.Location?.City != null && - Hostinfo.Location?.CountryCode != null) { + if ( + isMullvadNode && + Hostinfo.Location?.Country != null && + Hostinfo.Location?.City != null && + Hostinfo.Location?.CountryCode != null + ) { return "${Hostinfo.Location!!.CountryCode!!.flag()} ${Hostinfo.Location!!.Country!!}: ${Hostinfo.Location!!.City!!}" } return displayName @@ -146,7 +151,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 +165,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 +190,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 +213,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 +222,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/util/PeerHelper.kt b/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt index 8e6ca28539..814a1518d6 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 @@ -3,27 +3,65 @@ package com.tailscale.ipn.ui.util +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource import androidx.compose.ui.util.fastAny +import com.tailscale.ipn.R import com.tailscale.ipn.mdm.MDMSettings +import com.tailscale.ipn.ui.model.Favorites import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.model.UserID +private const val FAVORITES_ID: UserID = -1 + data class PeerSet( - val userID: UserID, - val user: Tailcfg.UserProfile?, - val peers: List -) + val id: UserID, + val title: String?, + val nodes: List, +) { + companion object { + fun create(id: UserID, title: String?, nodes: List): PeerSet? { + if (nodes.isEmpty()) return null + return PeerSet(id, title, nodes) + } + } + + @Composable + fun sectionTitle(): String { + return if (id == FAVORITES_ID) { + stringResource(id = R.string.pinned_devices) + } else { + title ?: stringResource(id = R.string.unknown_user) + } + } +} + +private fun MutableList.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() var lastSearchResult: List = emptyList() var lastSearchTerm: String = "" - fun regenerateGroupedPeers(netmap: Netmap.NetworkMap) { + fun regenerateGroupedPeers( + netmap: Netmap.NetworkMap, + favorites: Favorites, + ) { val peers: List = netmap.Peers ?: return val selfNode = netmap.SelfNode - var grouped = mutableMapOf>() + val grouped = mutableMapOf>() + grouped[FAVORITES_ID] = mutableListOf() + val favoriteDeviceIds = favorites.deviceIds.toSet() val mdm = MDMSettings.hiddenNetworkDevices.flow.value.value val hideMyDevices = mdm?.contains("current-user") ?: false @@ -33,8 +71,7 @@ class PeerCategorizer { val me = netmap.currentUserProfile() for (peer in (peers + selfNode)) { - - val userId = peer.User + val userId = if (favoriteDeviceIds.contains(peer.StableID)) FAVORITES_ID else peer.User val profile = netmap.userProfile(userId) // Mullvad nodes should not be shown in the peer list @@ -58,34 +95,25 @@ 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), + ) } + .sortedWith( + compareBy( + { it.id != FAVORITES_ID }, // keep pinned at top + { if (it.id == me?.ID) "" else it.title?.lowercase() ?: "unknown user" }, + ) + ) } fun groupedAndFilteredPeers(searchTerm: String = ""): List { @@ -108,26 +136,23 @@ class PeerCategorizer { val matchingSets = setsToSearch .map { peerSet -> - val user = peerSet.user - val peers = peerSet.peers + val peers = peerSet.nodes - val userMatches = user?.DisplayName?.contains(searchTerm, ignoreCase = true) ?: false - if (userMatches) { + if (peerSet.title?.contains(searchTerm, ignoreCase = true) ?: false) { return@map peerSet } - 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 + val matchingPeers = peers.filter { peer -> + val matchDisplay = peer.displayName.contains(searchTerm, ignoreCase = true) + val matchAddress = peer.Addresses.orEmpty().fastAny { it.contains(searchTerm) } + matchDisplay || matchAddress } + + if (matchingPeers.isNotEmpty()) PeerSet(peerSet.id, peerSet.title, matchingPeers) + else null } .filterNotNull() + lastSearchResult = matchingSets return matchingSets } 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..9419ede0a6 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 @@ -8,8 +8,10 @@ import androidx.lifecycle.viewModelScope import com.tailscale.ipn.UninitializedApp import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.ui.localapi.Client +import com.tailscale.ipn.ui.model.Favorites import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.IpnLocal +import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.model.UserID import com.tailscale.ipn.ui.model.deepCopy import com.tailscale.ipn.ui.notifier.Notifier @@ -35,6 +37,12 @@ open class IpnViewModel : ViewModel() { private val _vpnPrepared = MutableStateFlow(false) val vpnPrepared: StateFlow = _vpnPrepared + private val _favorites = MutableStateFlow(Favorites()) + val favorites: StateFlow = _favorites + + private var _isToggleFavoriteInProgress = MutableStateFlow(false) + val isToggleFavoriteInProgress: StateFlow = _isToggleFavoriteInProgress + // The userId associated with the current node. ie: The logged in user. private var selfNodeUserId: UserID? = null @@ -50,34 +58,41 @@ 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/favorites on all state transitions to ensure loggedInUser is + // correct viewModelScope.launch { loadUserProfiles() } + viewModelScope.launch { loadUserFavorites() } } } - // This will observe the userId of the current node and reload our user profiles if + // This will observe the userId of the current node and reload our user profiles/favorites 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() } + viewModelScope.launch { loadUserFavorites() } } } } @@ -94,6 +109,8 @@ open class IpnViewModel : ViewModel() { viewModelScope.launch { loadUserProfiles() } + viewModelScope.launch { loadUserFavorites() } + viewModelScope.launch { combine(prefs, netmap, isRunningExitNode) { prefs, netmap, isRunningExitNode -> // Handle nullability for prefs and netmap @@ -101,8 +118,9 @@ open class IpnViewModel : ViewModel() { val validNetmap = netmap ?: return@combine NodeState.NONE val chosenExitNodeId = validPrefs.activeExitNodeID ?: validPrefs.selectedExitNodeID - val exitNodePeer = - chosenExitNodeId?.let { id -> validNetmap.Peers?.find { it.StableID == id } } + val exitNodePeer = chosenExitNodeId?.let { id -> + validNetmap.Peers?.find { it.StableID == id } + } when { exitNodePeer?.Online == false -> { @@ -114,6 +132,7 @@ open class IpnViewModel : ViewModel() { NodeState.OFFLINE_DISABLED } } + exitNodePeer != null -> { if (!validPrefs.activeExitNodeID.isNullOrEmpty()) { NodeState.ACTIVE_AND_RUNNING @@ -121,9 +140,11 @@ open class IpnViewModel : ViewModel() { NodeState.ACTIVE_NOT_RUNNING } } - isRunningExitNode == true -> { + + isRunningExitNode -> { NodeState.RUNNING_AS_EXIT_NODE } + else -> { NodeState.NONE } @@ -161,7 +182,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 +209,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 +222,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 +244,7 @@ open class IpnViewModel : ViewModel() { fun loginWithCustomControlURL( controlURL: String, - completionHandler: (Result) -> Unit = {} + completionHandler: (Result) -> Unit = {}, ) { val prefs = Ipn.MaskedPrefs() prefs.ControlURL = controlURL @@ -283,6 +307,27 @@ open class IpnViewModel : ViewModel() { } } + // Favorites + + private fun loadUserFavorites() { + Client(viewModelScope).getFavorites { result -> + result + .onSuccess { _favorites.value = it } + .onFailure { TSLog.e(TAG, "Error loading favorites: ${it.message}") } + } + } + + fun toggleDeviceFavorite(peer: Tailcfg.Node) { + _isToggleFavoriteInProgress.value = true + val toggled = favorites.value.withToggledDevice(peer.StableID) + Client(viewModelScope).setFavorites(toggled) { result -> + _isToggleFavoriteInProgress.value = false + result + .onSuccess { _favorites.value = it } + .onFailure { TSLog.e(TAG, "Error toggling favorites: ${it.message}") } + } + } + // Exit Node Manipulation fun toggleExitNode() { @@ -304,12 +349,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 +363,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 +373,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..0cff0c77a3 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,8 +17,9 @@ 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.Netmap import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.util.PeerCategorizer @@ -27,6 +28,7 @@ import com.tailscale.ipn.ui.util.TimeUtil import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.util.TSLog import java.time.Duration +import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -34,6 +36,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch class MainViewModelFactory(private val appViewModel: AppViewModel) : ViewModelProvider.Factory { @@ -50,34 +53,44 @@ 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 var _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 + // 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 + // 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) @@ -91,6 +104,7 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { val isVpnActive: StateFlow = appViewModel.vpnActive var searchJob: Job? = null + var categorizeJob: Job? = null // Icon displayed in the button to present the health view val healthIcon: StateFlow = MutableStateFlow(null) @@ -103,10 +117,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) { + toggleDeviceFavorite(peer) + } + fun startPing(peer: Tailcfg.Node) { this.pingViewModel.startPing(peer) } @@ -136,6 +162,7 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { when { active && (currentState == State.Running || currentState == State.Starting) -> true + previousState == State.NoState && currentState == State.Starting -> true else -> false } @@ -146,7 +173,7 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { } } viewModelScope.launch { - _searchTerm.debounce(250L).collect { term -> + _searchTerm.debounce(250L.milliseconds).collect { term -> // run the search as a background task searchJob?.cancel() searchJob = @@ -160,12 +187,11 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { 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 - } + categorizeJob?.cancel() + categorizeJob = + launch(Dispatchers.Default) { + categorize(netmap, favorites.value) + } if (netmap.SelfNode.keyDoesNotExpire) { showExpiry.set(false) return@let @@ -174,17 +200,37 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { val window = expiryNotificationWindowMDM?.let { TimeUtil.duration(it) } ?: Duration.ofHours(24) val expiresSoon = - TimeUtil.isWithinExpiryNotificationWindow(window, it.SelfNode.KeyExpiry ?: "") + TimeUtil.isWithinExpiryNotificationWindow( + window, + it.SelfNode.KeyExpiry ?: "", + ) showExpiry.set(expiresSoon) } } } } + viewModelScope.launch { + favorites.drop(1).collect { favs -> + val netmap = Notifier.netmap.value ?: return@collect + categorizeJob?.cancel() + categorizeJob = + launch(Dispatchers.Default) { + categorize(netmap, favs) + } + } + } viewModelScope.launch { App.get().healthNotifier?.currentIcon?.collect { icon -> healthIcon.set(icon) } } } + fun categorize(netmap: Netmap.NetworkMap, favorites: Favorites) { + peerCategorizer.regenerateGroupedPeers(netmap, favorites) + val filteredPeers = peerCategorizer.groupedAndFilteredPeers(searchTerm.value) + _peers.value = peerCategorizer.peerSets + _searchViewPeers.value = filteredPeers + } + fun maybeRequestVpnPermission() { _requestVpnPermission.value = true } @@ -208,23 +254,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 } } } @@ -254,6 +300,7 @@ private fun userStringRes(currentState: State?, previousState: State?, vpnActive currentState == State.InUseOtherUser -> R.string.placeholder currentState == State.NeedsLogin -> if (vpnActive) R.string.please_login else R.string.connect_to_vpn + currentState == State.NeedsMachineAuth -> R.string.needs_machine_auth currentState == State.Stopped -> R.string.stopped currentState == State.Starting -> R.string.starting From 307751006860d5020c867b1e381fd32445c5e71e Mon Sep 17 00:00:00 2001 From: hiimtmac Date: Thu, 3 Sep 2026 19:06:47 -0500 Subject: [PATCH 03/14] views --- .../com/tailscale/ipn/ui/localapi/Client.kt | 1 - .../com/tailscale/ipn/ui/util/PeerHelper.kt | 40 +- .../com/tailscale/ipn/ui/view/MainView.kt | 1094 ++++++++++------- .../com/tailscale/ipn/ui/view/SearchView.kt | 67 +- .../ipn/ui/viewModel/IpnViewModel.kt | 2 +- android/src/main/res/values/strings.xml | 6 +- 6 files changed, 733 insertions(+), 477 deletions(-) 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 bdbc2884dc..4e70eb74f1 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 @@ -302,7 +302,6 @@ class Client(private val scope: CoroutineScope) { ) .execute() } - // endregion } class Request( 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 814a1518d6..a717f62f66 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 @@ -37,7 +37,7 @@ data class PeerSet( } } -private fun MutableList.nodeSort(netmap: Netmap.NetworkMap): List { +private fun List.nodeSort(netmap: Netmap.NetworkMap): List { return this.sortedWith { a, b -> when { a.StableID == b.StableID -> 0 @@ -60,7 +60,6 @@ class PeerCategorizer { val peers: List = netmap.Peers ?: return val selfNode = netmap.SelfNode val grouped = mutableMapOf>() - grouped[FAVORITES_ID] = mutableListOf() val favoriteDeviceIds = favorites.deviceIds.toSet() val mdm = MDMSettings.hiddenNetworkDevices.flow.value.value @@ -71,8 +70,9 @@ class PeerCategorizer { val me = netmap.currentUserProfile() for (peer in (peers + selfNode)) { - val userId = if (favoriteDeviceIds.contains(peer.StableID)) FAVORITES_ID else peer.User + val userId = peer.User val profile = netmap.userProfile(userId) + val groupId = if (favoriteDeviceIds.contains(peer.StableID)) FAVORITES_ID else peer.User // Mullvad nodes should not be shown in the peer list if (peer.isMullvadNode) { @@ -92,11 +92,11 @@ class PeerCategorizer { continue } - if (!grouped.containsKey(userId)) { - grouped[userId] = mutableListOf() + if (!grouped.containsKey(groupId)) { + grouped[groupId] = mutableListOf() } - grouped[userId]?.add(peer) + grouped[groupId]?.add(peer) } peerSets = @@ -133,25 +133,21 @@ class PeerCategorizer { else peerSets this.lastSearchTerm = searchTerm - val matchingSets = - setsToSearch - .map { peerSet -> - val peers = peerSet.nodes + val matchingSets = setsToSearch.mapNotNull { peerSet -> + val peers = peerSet.nodes - if (peerSet.title?.contains(searchTerm, ignoreCase = true) ?: false) { - return@map peerSet - } + if (peerSet.title?.contains(searchTerm, ignoreCase = true) ?: false) { + 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 { peer -> + val matchDisplay = peer.displayName.contains(searchTerm, ignoreCase = true) + val matchAddress = peer.Addresses.orEmpty().fastAny { it.contains(searchTerm) } + matchDisplay || matchAddress + } - if (matchingPeers.isNotEmpty()) PeerSet(peerSet.id, peerSet.title, 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..177257ee59 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 @@ -134,122 +137,131 @@ 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) + } + }, + 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, + ) + } } - 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() }) + }, + ) + 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) @@ -261,9 +273,10 @@ fun MainView( ModalBottomSheet( onDismissRequest = { viewModel.pendingTaildrop.isPresentingPendingItemsList.value = false - }) { - InlineShareListSheet(viewModel = viewModel.pendingTaildrop) } + ) { + InlineShareListSheet(viewModel = viewModel.pendingTaildrop) + } } } } @@ -272,18 +285,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 -fun LaunchVpnPermissionIfNeeded(viewModel: MainViewModel) { +private fun TaildropDirectoryPickerPromptPreview() { + TaildropDirectoryPickerPrompt() +} + +@Composable +private fun LaunchVpnPermissionIfNeeded(viewModel: MainViewModel) { val lifecycleOwner = LocalLifecycleOwner.current val shouldRequest by viewModel.requestVpnPermission.collectAsState() LaunchedEffect(shouldRequest) { @@ -296,10 +319,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,138 +331,152 @@ 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)) { - if (nodeState == NodeState.OFFLINE_MDM) { - Box( - modifier = - Modifier.padding(start = 16.dp, end = 16.dp, top = 56.dp, bottom = 16.dp) - .clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp)) - .background(MaterialTheme.colorScheme.customErrorContainer) - .fillMaxWidth() - .align(Alignment.TopCenter)) { - Column( - modifier = - Modifier.padding(start = 16.dp, end = 16.dp, top = 36.dp, bottom = 16.dp)) { - Text( - text = - managedByOrganization.value?.let { - stringResource(R.string.exit_node_offline_mdm_orgname, it) - } ?: stringResource(R.string.exit_node_offline_mdm), - style = MaterialTheme.typography.bodyMedium, - color = Color.White) - } - } + Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surfaceContainer) + ) { + if (nodeState == NodeState.OFFLINE_MDM) { + Box( + modifier = + Modifier.padding(start = 16.dp, end = 16.dp, top = 56.dp, bottom = 16.dp) + .clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp)) + .background(MaterialTheme.colorScheme.customErrorContainer) + .fillMaxWidth() + .align(Alignment.TopCenter) + ) { + Column( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 36.dp, bottom = 16.dp) + ) { + Text( + text = + managedByOrganization.value?.let { + stringResource(R.string.exit_node_offline_mdm_orgname, it) + } ?: stringResource(R.string.exit_node_offline_mdm), + style = MaterialTheme.typography.bodyMedium, + color = Color.White, + ) } - Box( - modifier = - Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp) - .clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp)) - .fillMaxWidth()) { - ListItem( - modifier = Modifier.clickable { navAction() }, + } + } + Box( + modifier = + Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp) + .clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp)) + .fillMaxWidth() + ) { + ListItem( + modifier = Modifier.clickable { navAction() }, + colors = + when (nodeState) { + NodeState.ACTIVE_AND_RUNNING -> MaterialTheme.colorScheme.primaryListItem + NodeState.ACTIVE_NOT_RUNNING -> MaterialTheme.colorScheme.listItem + NodeState.RUNNING_AS_EXIT_NODE -> MaterialTheme.colorScheme.warningListItem + NodeState.OFFLINE_ENABLED -> MaterialTheme.colorScheme.errorListItem + NodeState.OFFLINE_DISABLED -> MaterialTheme.colorScheme.errorListItem + NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.errorListItem + else -> ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surface) + }, + overlineContent = { + Text( + text = + if ( + nodeState == NodeState.OFFLINE_ENABLED || + nodeState == NodeState.OFFLINE_DISABLED || + nodeState == NodeState.OFFLINE_MDM + ) + stringResource(R.string.exit_node_offline) + else stringResource(R.string.exit_node), + style = MaterialTheme.typography.bodySmall, + ) + }, + headlineContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = + when (nodeState) { + NodeState.NONE -> stringResource(id = R.string.none) + NodeState.RUNNING_AS_EXIT_NODE -> + stringResource(id = R.string.running_exit_node) + + else -> name ?: "" + }, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Icon( + imageVector = Icons.Outlined.ArrowDropDown, + contentDescription = null, + tint = + if (nodeState == NodeState.NONE) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), + ) + } + }, + trailingContent = { + if (nodeState != NodeState.NONE) { + Button( colors = when (nodeState) { - NodeState.ACTIVE_AND_RUNNING -> MaterialTheme.colorScheme.primaryListItem - NodeState.ACTIVE_NOT_RUNNING -> MaterialTheme.colorScheme.listItem - NodeState.RUNNING_AS_EXIT_NODE -> MaterialTheme.colorScheme.warningListItem - NodeState.OFFLINE_ENABLED -> MaterialTheme.colorScheme.errorListItem - NodeState.OFFLINE_DISABLED -> MaterialTheme.colorScheme.errorListItem - NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.errorListItem - else -> - ListItemDefaults.colors( - containerColor = MaterialTheme.colorScheme.surface) + NodeState.OFFLINE_ENABLED -> MaterialTheme.colorScheme.errorButton + NodeState.OFFLINE_DISABLED -> MaterialTheme.colorScheme.errorButton + NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.errorButton + NodeState.RUNNING_AS_EXIT_NODE -> MaterialTheme.colorScheme.warningButton + + NodeState.ACTIVE_NOT_RUNNING -> + MaterialTheme.colorScheme.exitNodeToggleButton + + else -> MaterialTheme.colorScheme.secondaryButton }, - overlineContent = { - Text( - text = - if (nodeState == NodeState.OFFLINE_ENABLED || - nodeState == NodeState.OFFLINE_DISABLED || - nodeState == NodeState.OFFLINE_MDM) - stringResource(R.string.exit_node_offline) - else stringResource(R.string.exit_node), - style = MaterialTheme.typography.bodySmall, - ) - }, - headlineContent = { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = - when (nodeState) { - NodeState.NONE -> stringResource(id = R.string.none) - NodeState.RUNNING_AS_EXIT_NODE -> - stringResource(id = R.string.running_exit_node) - else -> name ?: "" - }, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis) - Icon( - imageVector = Icons.Outlined.ArrowDropDown, - contentDescription = null, - tint = - if (nodeState == NodeState.NONE) - MaterialTheme.colorScheme.onSurfaceVariant - else MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), - ) - } + onClick = { + if (nodeState == NodeState.RUNNING_AS_EXIT_NODE) + viewModel.setRunningExitNode(false) + else viewModel.toggleExitNode() }, - trailingContent = { - if (nodeState != NodeState.NONE) { - Button( - colors = - when (nodeState) { - NodeState.OFFLINE_ENABLED -> MaterialTheme.colorScheme.errorButton - NodeState.OFFLINE_DISABLED -> MaterialTheme.colorScheme.errorButton - NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.errorButton - NodeState.RUNNING_AS_EXIT_NODE -> - MaterialTheme.colorScheme.warningButton - NodeState.ACTIVE_NOT_RUNNING -> - MaterialTheme.colorScheme.exitNodeToggleButton - else -> MaterialTheme.colorScheme.secondaryButton - }, - onClick = { - 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 +496,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 +520,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 +565,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 +590,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,20 +599,80 @@ 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, + ) + } + } + } + } + } +} + +@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 = {}, + ) } } @@ -565,64 +684,71 @@ 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) } else { if (!isAndroidTV()) { Box( - modifier = - Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surface)) { - OutlinedTextField( - modifier = - Modifier.fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 0.dp) - .onFocusChanged { isSearchFocussed = it.isFocused }, - singleLine = true, - shape = MaterialTheme.shapes.extraLarge, - colors = MaterialTheme.colorScheme.searchBarColors, - leadingIcon = { - Icon(imageVector = Icons.Outlined.Search, contentDescription = "search") - }, - trailingIcon = { - if (isSearchFocussed) { - IconButton( - onClick = { - focusManager.clearFocus() - onSearch("") - }) { - Icon( - imageVector = - if (searchTermStr.isEmpty()) Icons.Outlined.Close - else Icons.Outlined.Clear, - contentDescription = "clear search", - tint = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - }, - placeholder = { - Text( - text = stringResource(id = R.string.search), - style = MaterialTheme.typography.bodyLarge, - maxLines = 1) - }, - value = searchTermStr, - onValueChange = { onSearch(it) }) - } + modifier = Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surface) + ) { + OutlinedTextField( + modifier = + Modifier.fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 0.dp) + .onFocusChanged { isSearchFocussed = it.isFocused }, + singleLine = true, + shape = MaterialTheme.shapes.extraLarge, + colors = MaterialTheme.colorScheme.searchBarColors, + leadingIcon = { + Icon(imageVector = Icons.Outlined.Search, contentDescription = "search") + }, + trailingIcon = { + if (isSearchFocussed) { + IconButton( + onClick = { + focusManager.clearFocus() + onSearch("") + } + ) { + Icon( + imageVector = + if (searchTermStr.isEmpty()) Icons.Outlined.Close + else Icons.Outlined.Clear, + contentDescription = "clear search", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + placeholder = { + Text( + text = stringResource(id = R.string.search), + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + ) + }, + value = searchTermStr, + onValueChange = { onSearch(it) }, + ) + } } } + // Peers display LazyColumn( modifier = @@ -630,94 +756,185 @@ fun PeerList( .weight(1f) // LazyColumn gets the remaining vertical space .onFocusChanged { isListFocussed = it.isFocused } .background(color = MaterialTheme.colorScheme.surface) - .windowInsetsPadding(WindowInsets.navigationBars)) { - // Handle case when no results are found - if (showNoResults) { - item { - Spacer( - Modifier.height(16.dp) - .fillMaxSize() - .focusable(false) - .background(color = MaterialTheme.colorScheme.surface)) - Lists.LargeTitle( - stringResource(id = R.string.no_results), - bottomPadding = 8.dp, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Light) - } + .windowInsetsPadding(WindowInsets.navigationBars) + ) { + // Handle case when no results are found + if (showNoResults) { + item { + Spacer( + Modifier.height(16.dp) + .fillMaxSize() + .focusable(false) + .background(color = MaterialTheme.colorScheme.surface) + ) + Lists.LargeTitle( + stringResource(id = R.string.no_results), + bottomPadding = 8.dp, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Light, + ) + } + } + + // Iterate over peer sets to display them + peerList.forEachIndexed { idx, peerSet -> + if (idx != 0) { + item(key = "user_divider_${peerSet.id}") { + Lists.ItemDivider() } - // Iterate over peer sets to display them - var first = true - peerList.forEach { peerSet -> - if (!first) { - item(key = "user_divider_${peerSet.userID}") { Lists.ItemDivider() } - } - first = false - if (isAndroidTV()) { - item { NodesSectionHeader(peerSet = peerSet) } - } else { - stickyHeader { NodesSectionHeader(peerSet = peerSet) } - } - itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer -> - ListItem( - modifier = - Modifier.combinedClickable( - onClick = { onNavigateToPeerDetails(peer) }, - onLongClick = { viewModel.expandedMenuPeer.set(peer) }), - colors = MaterialTheme.colorScheme.listItem, - headlineContent = { - Row(verticalAlignment = Alignment.CenterVertically) { - 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) - }) - } - } - } - } - }, - supportingContent = { - Text( - text = peer.Addresses?.first()?.split("/")?.first() ?: "", - style = - MaterialTheme.typography.bodyMedium.copy( - lineHeight = MaterialTheme.typography.titleMedium.lineHeight)) - }) - } + } + if (isAndroidTV()) { + item { + NodesSectionHeader(peerSet = peerSet) + } + } else { + stickyHeader { + NodesSectionHeader(peerSet = peerSet) } } + itemsWithDividers(peerSet.nodes, key = { it.StableID }) { peer -> + ListItem( + modifier = + Modifier.combinedClickable( + onClick = { onNavigateToPeerDetails(peer) }, + onLongClick = { viewModel.expandedMenuPeer.set(peer) }, + ), + colors = MaterialTheme.colorScheme.listItem, + headlineContent = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = + Modifier.padding(top = 2.dp) + .size(10.dp) + .background( + color = peer.connectedColor(netmap), + shape = RoundedCornerShape(percent = 50), + ) + ) + Text( + text = peer.displayName, + style = MaterialTheme.typography.titleMedium, + ) + DeviceDropdownMenu( + viewModel, + peer, + netmap, + expandedPeer?.StableID == peer.StableID, + ) + } + }, + supportingContent = { + Text( + text = peer.Addresses?.first()?.split("/")?.first() ?: "", + style = + MaterialTheme.typography.bodyMedium.copy( + lineHeight = MaterialTheme.typography.titleMedium.lineHeight + ), + ) + }, + ) + } + } + } + } +} + +@Composable +fun DeviceDropdownMenu( + viewModel: MainViewModel, + peer: Tailcfg.Node, + netmap: Netmap.NetworkMap?, + expanded: Boolean, +) { + val localClipboardManager = LocalClipboardManager.current + val favorites by viewModel.favorites.collectAsState() + val isToggleInProgress by viewModel.isToggleFavoriteInProgress.collectAsState() + val isFavorite = favorites.isFavoriteDevice(peer.StableID) + + DropdownMenu( + expanded = expanded, + 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( + enabled = !isToggleInProgress, + 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() + }, + ) } } @@ -725,37 +942,49 @@ fun PeerList( 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, + ) +} + +@Preview(showBackground = true) +@Composable +private fun NodesSectionHeaderPreview() { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + NodesSectionHeader(peerSet = PeerSet(-1, 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 = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp) .clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp)) - .fillMaxWidth()) { - ListItem( - modifier = Modifier.clickable { action() }, - colors = MaterialTheme.colorScheme.warningListItem, - headlineContent = { - Text( - netmap.SelfNode.expiryLabel(), - style = MaterialTheme.typography.titleMedium, - ) - }, - supportingContent = { - Text( - stringResource(id = R.string.keyExpiryExplainer), - style = MaterialTheme.typography.bodyMedium) - }) - } + .fillMaxWidth() + ) { + ListItem( + modifier = Modifier.clickable { action() }, + colors = MaterialTheme.colorScheme.warningListItem, + headlineContent = { + Text( + netmap.SelfNode.expiryLabel(), + style = MaterialTheme.typography.titleMedium, + ) + }, + supportingContent = { + Text( + stringResource(id = R.string.keyExpiryExplainer), + style = MaterialTheme.typography.bodyMedium, + ) + }, + ) + } } } @@ -769,9 +998,10 @@ fun PromptForMissingPermissions(viewModel: MainViewModel) { ErrorDialog( title = permission.title, message = permission.description, - buttonText = R.string._continue) { - state.launchPermissionRequest() - } + buttonText = R.string._continue, + ) { + state.launchPermissionRequest() + } } } @@ -785,46 +1015,45 @@ fun Search( var isNavigating by remember { mutableStateOf(false) } Box( modifier = - Modifier.fillMaxWidth() - .background(MaterialTheme.colorScheme.surface) - .padding(top = 8.dp)) { - Box( - modifier = - Modifier.fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, top = 16.dp) - .height(56.dp) - .clip(MaterialTheme.shapes.extraLarge) // Rounded corners for search bar - .background(backgroundColor) // Search bar background - .clickable(enabled = !isNavigating) { // Intercept taps - isNavigating = true - onSearchBarClick() - } - .padding(horizontal = 16.dp) // Internal padding - ) { - 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.fillMaxWidth().background(MaterialTheme.colorScheme.surface).padding(top = 8.dp) + ) { + Box( + modifier = + Modifier.fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 16.dp) + .height(56.dp) + .clip(MaterialTheme.shapes.extraLarge) // Rounded corners for search bar + .background(backgroundColor) // Search bar background + .clickable(enabled = !isNavigating) { // Intercept taps + isNavigating = true + onSearchBarClick() + } + .padding(horizontal = 16.dp) // Internal padding + ) { + 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 + ) } + } + } } @Preview @@ -840,7 +1069,8 @@ fun MainViewPreview() { onNavigateToPeerDetails = {}, onNavigateToExitNodes = {}, onNavigateToHealth = {}, - onNavigateToSearch = {}), + onNavigateToSearch = {}, + ), vm, ) } 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..1e2b2a82dc 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 @@ -53,13 +53,19 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import com.tailscale.ipn.App 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.AppViewModel import com.tailscale.ipn.ui.viewModel.MainViewModel +import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.emptyFlow @RequiresApi(Build.VERSION_CODES.TIRAMISU) @OptIn(ExperimentalMaterial3Api::class) @@ -68,7 +74,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 +112,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 +120,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() } @@ -150,12 +156,14 @@ fun SearchView( focusManager.clearFocus() onNavigateBack() viewModel.updateSearchTerm("") - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.search), - tint = MaterialTheme.colorScheme.onSurfaceVariant) } + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.search), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } }, trailingIcon = { if (searchTerm.isNotEmpty()) { @@ -165,11 +173,13 @@ fun SearchView( viewModel.updateSearchTerm("") focusManager.clearFocus() keyboardController?.hide() - }) { - Icon( - Icons.Default.Clear, - contentDescription = stringResource(R.string.clear_search)) } + ) { + Icon( + Icons.Default.Clear, + contentDescription = stringResource(R.string.clear_search), + ) + } } }, ) @@ -188,7 +198,8 @@ fun SearchView( style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Light, backgroundColor = noResultsBackground, - fontColor = MaterialTheme.colorScheme.onSurfaceVariant) + fontColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } else { @@ -199,8 +210,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 +224,11 @@ 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 +236,7 @@ fun SearchView( }, supportingContent = { Column { - Text(userName) + Text(peerSet.sectionTitle()) Text(peer.Addresses?.firstOrNull()?.split("/")?.first() ?: "No IP") } }, @@ -232,7 +246,8 @@ fun SearchView( .clickable { viewModel.disableSearchAutoFocus() navController.navigate("peerDetails/${peer.StableID}") - }) + }, + ) } } } @@ -242,3 +257,19 @@ fun SearchView( } } } + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +@Preview(showSystemUi = true) +@Composable +private fun SearchViewPreview() { + val fakePrompt = emptyFlow() + val appViewModel = AppViewModel(App.get(), fakePrompt) + val vm = MainViewModel(appViewModel) + + SearchView( + vm, + rememberNavController(), + {}, + true, + ) +} 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 9419ede0a6..d888e20468 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 @@ -40,7 +40,7 @@ open class IpnViewModel : ViewModel() { private val _favorites = MutableStateFlow(Favorites()) val favorites: StateFlow = _favorites - private var _isToggleFavoriteInProgress = MutableStateFlow(false) + private val _isToggleFavoriteInProgress = MutableStateFlow(false) val isToggleFavoriteInProgress: StateFlow = _isToggleFavoriteInProgress // The userId associated with the current node. ie: The logged in user. diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index d9554b0564..f8e5a6f947 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -13,13 +13,13 @@ Not connected %s - Selected + Selected Offline OK Continue Warning Search - Search… + Search... Dismiss No results Back @@ -395,7 +395,7 @@ Enable hardware attestation Use hardware-backed keys to bind node identity to the device - + Pinned Devices From 1a1787c2315e9add6970a7d7a6615fb93623fb8f Mon Sep 17 00:00:00 2001 From: hiimtmac Date: Thu, 3 Sep 2026 20:05:09 -0500 Subject: [PATCH 04/14] refactors --- .../com/tailscale/ipn/ui/model/Favorites.kt | 7 +- .../com/tailscale/ipn/ui/util/PeerHelper.kt | 35 ++++----- .../com/tailscale/ipn/ui/view/MainView.kt | 22 ++++-- .../ipn/ui/viewModel/IpnViewModel.kt | 15 +++- .../ipn/ui/viewModel/MainViewModel.kt | 76 +++++++++---------- 5 files changed, 75 insertions(+), 80 deletions(-) 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 index c4d820fb8b..912ddc0711 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/Favorites.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Favorites.kt @@ -15,12 +15,9 @@ data class Favorites( @SerialName("ExitNodes") val exitNodes: List? = null, @SerialName("Services") val services: List? = null, ) { - val deviceIds: List - get() = devices.orEmpty().mapNotNull { it.id } + val deviceIds: Set by lazy { devices.orEmpty().mapNotNull { it.id }.toSet() } - fun isFavoriteDevice(id: StableNodeID): Boolean { - return deviceIds.contains(id) - } + fun isFavoriteDevice(id: StableNodeID): Boolean = id in deviceIds fun withToggledDevice(id: StableNodeID): FavoritesRequest { val current = devices.orEmpty() 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 a717f62f66..7424601147 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 @@ -3,38 +3,27 @@ package com.tailscale.ipn.ui.util -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource import androidx.compose.ui.util.fastAny -import com.tailscale.ipn.R import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.ui.model.Favorites import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.model.UserID -private const val FAVORITES_ID: UserID = -1 - data class PeerSet( val id: UserID, val title: String?, val nodes: List, ) { companion object { - fun create(id: UserID, title: String?, nodes: List): PeerSet? { - if (nodes.isEmpty()) return null - return PeerSet(id, title, nodes) - } - } + const val FAVORITES_ID: UserID = -1 - @Composable - fun sectionTitle(): String { - return if (id == FAVORITES_ID) { - stringResource(id = R.string.pinned_devices) - } else { - title ?: stringResource(id = R.string.unknown_user) - } + fun create(id: UserID, title: String?, nodes: List): PeerSet? = + if (nodes.isEmpty()) null else PeerSet(id, title, nodes) } + + val isPinned: Boolean + get() = id == FAVORITES_ID } private fun List.nodeSort(netmap: Netmap.NetworkMap): List { @@ -60,7 +49,7 @@ class PeerCategorizer { val peers: List = netmap.Peers ?: return val selfNode = netmap.SelfNode val grouped = mutableMapOf>() - val favoriteDeviceIds = favorites.deviceIds.toSet() + val favoriteDeviceIds = favorites.deviceIds val mdm = MDMSettings.hiddenNetworkDevices.flow.value.value val hideMyDevices = mdm?.contains("current-user") ?: false @@ -72,7 +61,8 @@ class PeerCategorizer { for (peer in (peers + selfNode)) { val userId = peer.User val profile = netmap.userProfile(userId) - val groupId = if (favoriteDeviceIds.contains(peer.StableID)) FAVORITES_ID else peer.User + val groupId = + if (favoriteDeviceIds.contains(peer.StableID)) PeerSet.FAVORITES_ID else peer.User // Mullvad nodes should not be shown in the peer list if (peer.isMullvadNode) { @@ -110,10 +100,13 @@ class PeerCategorizer { } .sortedWith( compareBy( - { it.id != FAVORITES_ID }, // keep pinned at top + { it.id != PeerSet.FAVORITES_ID }, // keep pinned at top { if (it.id == me?.ID) "" else it.title?.lowercase() ?: "unknown user" }, ) ) + + lastSearchTerm = "" + lastSearchResult = emptyList() } fun groupedAndFilteredPeers(searchTerm: String = ""): List { @@ -136,7 +129,7 @@ class PeerCategorizer { val matchingSets = setsToSearch.mapNotNull { peerSet -> val peers = peerSet.nodes - if (peerSet.title?.contains(searchTerm, ignoreCase = true) ?: false) { + if (peerSet.title?.contains(searchTerm, ignoreCase = true) == true) { return@mapNotNull peerSet } 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 177257ee59..aed2e46d11 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 @@ -109,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 @@ -818,12 +819,13 @@ fun PeerList( text = peer.displayName, style = MaterialTheme.typography.titleMedium, ) - DeviceDropdownMenu( - viewModel, - peer, - netmap, - expandedPeer?.StableID == peer.StableID, - ) + if (expandedPeer?.StableID == peer.StableID) { + DeviceDropdownMenu( + viewModel, + peer, + netmap, + ) + } } }, supportingContent = { @@ -847,7 +849,6 @@ fun DeviceDropdownMenu( viewModel: MainViewModel, peer: Tailcfg.Node, netmap: Netmap.NetworkMap?, - expanded: Boolean, ) { val localClipboardManager = LocalClipboardManager.current val favorites by viewModel.favorites.collectAsState() @@ -855,7 +856,7 @@ fun DeviceDropdownMenu( val isFavorite = favorites.isFavoriteDevice(peer.StableID) DropdownMenu( - expanded = expanded, + expanded = true, onDismissRequest = viewModel::hidePeerDropdownMenu, ) { netmap?.let { netMap -> @@ -938,6 +939,11 @@ fun DeviceDropdownMenu( } } +@Composable +fun PeerSet.sectionTitle(): String = + if (id == FAVORITES_ID) 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)) 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 d888e20468..b6402985da 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 @@ -91,6 +91,7 @@ open class IpnViewModel : ViewModel() { netmap?.SelfNode?.User.let { if (it != selfNodeUserId) { selfNodeUserId = it + _favorites.value = Favorites() viewModelScope.launch { loadUserProfiles() } viewModelScope.launch { loadUserFavorites() } } @@ -313,18 +314,24 @@ open class IpnViewModel : ViewModel() { Client(viewModelScope).getFavorites { result -> result .onSuccess { _favorites.value = it } - .onFailure { TSLog.e(TAG, "Error loading favorites: ${it.message}") } + .onFailure { + _favorites.value = Favorites() + TSLog.e(TAG, "Error loading favorites: ${it.message}") + } } } fun toggleDeviceFavorite(peer: Tailcfg.Node) { - _isToggleFavoriteInProgress.value = true + if (!_isToggleFavoriteInProgress.compareAndSet(expect = false, update = true)) return + val toggled = favorites.value.withToggledDevice(peer.StableID) Client(viewModelScope).setFavorites(toggled) { result -> - _isToggleFavoriteInProgress.value = false result .onSuccess { _favorites.value = it } - .onFailure { TSLog.e(TAG, "Error toggling favorites: ${it.message}") } + .onFailure { + TSLog.e(TAG, "Error toggling favorites: ${it.message}") + } + _isToggleFavoriteInProgress.value = false } } 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 0cff0c77a3..faa79b09d1 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,9 +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.Favorites import com.tailscale.ipn.ui.model.Ipn.State -import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.util.PeerCategorizer @@ -30,14 +28,17 @@ 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.drop +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class MainViewModelFactory(private val appViewModel: AppViewModel) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") @@ -103,8 +104,7 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { val isVpnActive: StateFlow = appViewModel.vpnActive - var searchJob: Job? = null - var categorizeJob: Job? = null + private var searchJob: Job? = null // Icon displayed in the button to present the health view val healthIcon: StateFlow = MutableStateFlow(null) @@ -149,6 +149,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 { @@ -172,6 +174,7 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { previousState = currentState } } + viewModelScope.launch { _searchTerm.debounce(250L.milliseconds).collect { term -> // run the search as a background task @@ -183,40 +186,36 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { } } } + viewModelScope.launch { - Notifier.netmap.collect { it -> - it?.let { netmap -> - searchJob?.cancel() - categorizeJob?.cancel() - categorizeJob = - launch(Dispatchers.Default) { - categorize(netmap, favorites.value) - } - 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) + combine(Notifier.netmap.filterNotNull(), favorites) { netmap, favs -> netmap to favs } + .collectLatest { (netmap, favs) -> + searchJob?.cancel() + withContext(categorizerDispatcher) { + peerCategorizer.regenerateGroupedPeers(netmap, favs) + val filteredPeers = peerCategorizer.groupedAndFilteredPeers(searchTerm.value) + _peers.value = peerCategorizer.peerSets + _searchViewPeers.value = filteredPeers + } } - } - } } + + // Key expiry on depends on netmap viewModelScope.launch { - favorites.drop(1).collect { favs -> - val netmap = Notifier.netmap.value ?: return@collect - categorizeJob?.cancel() - categorizeJob = - launch(Dispatchers.Default) { - categorize(netmap, favs) - } + 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 ?: "", + ) + ) + } } } viewModelScope.launch { @@ -224,13 +223,6 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { } } - fun categorize(netmap: Netmap.NetworkMap, favorites: Favorites) { - peerCategorizer.regenerateGroupedPeers(netmap, favorites) - val filteredPeers = peerCategorizer.groupedAndFilteredPeers(searchTerm.value) - _peers.value = peerCategorizer.peerSets - _searchViewPeers.value = filteredPeers - } - fun maybeRequestVpnPermission() { _requestVpnPermission.value = true } From a8a12c2da3aec5ac2cf1a7799176ca941c873ef4 Mon Sep 17 00:00:00 2001 From: hiimtmac Date: Thu, 3 Sep 2026 20:20:49 -0500 Subject: [PATCH 05/14] fmt and tests --- .../com/tailscale/ipn/ui/localapi/Client.kt | 9 +- .../com/tailscale/ipn/ui/model/TailCfg.kt | 10 +- .../com/tailscale/ipn/ui/util/PeerHelper.kt | 32 +- .../com/tailscale/ipn/ui/view/MainView.kt | 589 +++++++++--------- .../com/tailscale/ipn/ui/view/SearchView.kt | 27 +- .../ipn/ui/viewModel/IpnViewModel.kt | 12 +- .../ipn/ui/viewModel/MainViewModel.kt | 9 +- android/src/main/res/values/strings.xml | 2 +- .../tailcale/ipn/ui/PeerCategorizerTest.kt | 296 +++++++++ .../com/tailcale/ipn/ui/util/FavoritesTest.kt | 121 ++++ 10 files changed, 741 insertions(+), 366 deletions(-) create mode 100644 android/src/test/kotlin/com/tailcale/ipn/ui/PeerCategorizerTest.kt create mode 100644 android/src/test/kotlin/com/tailcale/ipn/ui/util/FavoritesTest.kt 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 4e70eb74f1..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 @@ -179,8 +179,7 @@ class Client(private val scope: CoroutineScope) { part.contentLength = file.DeclaredSize part.body = InputStreamAdapter(stream) part - } - ) + }) } catch (e: Exception) { parts.forEach { it.body.close() } TSLog.e(TAG, "Error creating file upload body: $e") @@ -364,8 +363,7 @@ class Request( jsonDecoder.decodeFromStream( Json.serializersModule.serializer(responseType), respData.inputStream(), - ) as T - ) + ) as T) } catch (t: Throwable) { // If we couldn't parse the response body, assume it's an error response try { @@ -383,8 +381,7 @@ class Request( 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/TailCfg.kt b/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt index 775b29b1aa..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 @@ -121,12 +121,10 @@ class Tailcfg { val exitNodeName: String get() { - if ( - isMullvadNode && - Hostinfo.Location?.Country != null && - Hostinfo.Location?.City != null && - Hostinfo.Location?.CountryCode != null - ) { + if (isMullvadNode && + Hostinfo.Location?.Country != null && + Hostinfo.Location?.City != null && + Hostinfo.Location?.CountryCode != null) { return "${Hostinfo.Location!!.CountryCode!!.flag()} ${Hostinfo.Location!!.Country!!}: ${Hostinfo.Location!!.City!!}" } return displayName 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 7424601147..0190cebdb5 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 @@ -22,7 +22,7 @@ data class PeerSet( if (nodes.isEmpty()) null else PeerSet(id, title, nodes) } - val isPinned: Boolean + val isFavorite: Boolean get() = id == FAVORITES_ID } @@ -102,8 +102,7 @@ class PeerCategorizer { compareBy( { it.id != PeerSet.FAVORITES_ID }, // keep pinned at top { if (it.id == me?.ID) "" else it.title?.lowercase() ?: "unknown user" }, - ) - ) + )) lastSearchTerm = "" lastSearchResult = emptyList() @@ -126,21 +125,24 @@ class PeerCategorizer { else peerSets this.lastSearchTerm = searchTerm - val matchingSets = setsToSearch.mapNotNull { peerSet -> - val peers = peerSet.nodes + val matchingSets = + setsToSearch.mapNotNull { peerSet -> + val peers = peerSet.nodes - if (peerSet.title?.contains(searchTerm, ignoreCase = true) == true) { - return@mapNotNull peerSet - } + 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 { peer -> + val matchDisplay = peer.displayName.contains(searchTerm, ignoreCase = true) + val matchAddress = peer.Addresses.orEmpty().fastAny { it.contains(searchTerm) } + matchDisplay || matchAddress + } - if (matchingPeers.isNotEmpty()) PeerSet(peerSet.id, peerSet.title, matchingPeers) else null - } + 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 aed2e46d11..579d50fa7c 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 @@ -223,9 +223,7 @@ fun MainView( PromptForMissingPermissions(viewModel) if (showKeyExpiry) { - netmap?.let { - ExpiryNotification(netmap = it, action = { viewModel.login() }) - } + netmap?.let { ExpiryNotification(netmap = it, action = { viewModel.login() }) } } if (showExitNodePicker.value == ShowHide.Show) { ExitNodeStatus( @@ -274,10 +272,9 @@ fun MainView( ModalBottomSheet( onDismissRequest = { viewModel.pendingTaildrop.isPresentingPendingItemsList.value = false + }) { + InlineShareListSheet(viewModel = viewModel.pendingTaildrop) } - ) { - InlineShareListSheet(viewModel = viewModel.pendingTaildrop) - } } } } @@ -335,123 +332,116 @@ private fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { Box( modifier = - Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surfaceContainer) - ) { - if (nodeState == NodeState.OFFLINE_MDM) { - Box( - modifier = - Modifier.padding(start = 16.dp, end = 16.dp, top = 56.dp, bottom = 16.dp) - .clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp)) - .background(MaterialTheme.colorScheme.customErrorContainer) - .fillMaxWidth() - .align(Alignment.TopCenter) - ) { - Column( - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 36.dp, bottom = 16.dp) - ) { - Text( - text = - managedByOrganization.value?.let { - stringResource(R.string.exit_node_offline_mdm_orgname, it) - } ?: stringResource(R.string.exit_node_offline_mdm), - style = MaterialTheme.typography.bodyMedium, - color = Color.White, - ) + Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surfaceContainer)) { + if (nodeState == NodeState.OFFLINE_MDM) { + Box( + modifier = + Modifier.padding(start = 16.dp, end = 16.dp, top = 56.dp, bottom = 16.dp) + .clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp)) + .background(MaterialTheme.colorScheme.customErrorContainer) + .fillMaxWidth() + .align(Alignment.TopCenter)) { + Column( + modifier = + Modifier.padding(start = 16.dp, end = 16.dp, top = 36.dp, bottom = 16.dp)) { + Text( + text = + managedByOrganization.value?.let { + stringResource(R.string.exit_node_offline_mdm_orgname, it) + } ?: stringResource(R.string.exit_node_offline_mdm), + style = MaterialTheme.typography.bodyMedium, + color = Color.White, + ) + } + } } - } - } - Box( - modifier = - Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp) - .clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp)) - .fillMaxWidth() - ) { - ListItem( - modifier = Modifier.clickable { navAction() }, - colors = - when (nodeState) { - NodeState.ACTIVE_AND_RUNNING -> MaterialTheme.colorScheme.primaryListItem - NodeState.ACTIVE_NOT_RUNNING -> MaterialTheme.colorScheme.listItem - NodeState.RUNNING_AS_EXIT_NODE -> MaterialTheme.colorScheme.warningListItem - NodeState.OFFLINE_ENABLED -> MaterialTheme.colorScheme.errorListItem - NodeState.OFFLINE_DISABLED -> MaterialTheme.colorScheme.errorListItem - NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.errorListItem - else -> ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surface) - }, - overlineContent = { - Text( - text = - if ( - nodeState == NodeState.OFFLINE_ENABLED || - nodeState == NodeState.OFFLINE_DISABLED || - nodeState == NodeState.OFFLINE_MDM - ) - stringResource(R.string.exit_node_offline) - else stringResource(R.string.exit_node), - style = MaterialTheme.typography.bodySmall, - ) - }, - headlineContent = { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = - when (nodeState) { - NodeState.NONE -> stringResource(id = R.string.none) - NodeState.RUNNING_AS_EXIT_NODE -> - stringResource(id = R.string.running_exit_node) - - else -> name ?: "" - }, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Icon( - imageVector = Icons.Outlined.ArrowDropDown, - contentDescription = null, - tint = - if (nodeState == NodeState.NONE) MaterialTheme.colorScheme.onSurfaceVariant - else MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), - ) - } - }, - trailingContent = { - if (nodeState != NodeState.NONE) { - Button( + Box( + modifier = + Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp) + .clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp)) + .fillMaxWidth()) { + ListItem( + modifier = Modifier.clickable { navAction() }, colors = when (nodeState) { - NodeState.OFFLINE_ENABLED -> MaterialTheme.colorScheme.errorButton - NodeState.OFFLINE_DISABLED -> MaterialTheme.colorScheme.errorButton - NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.errorButton - NodeState.RUNNING_AS_EXIT_NODE -> MaterialTheme.colorScheme.warningButton - - NodeState.ACTIVE_NOT_RUNNING -> - MaterialTheme.colorScheme.exitNodeToggleButton - - else -> MaterialTheme.colorScheme.secondaryButton + NodeState.ACTIVE_AND_RUNNING -> MaterialTheme.colorScheme.primaryListItem + NodeState.ACTIVE_NOT_RUNNING -> MaterialTheme.colorScheme.listItem + NodeState.RUNNING_AS_EXIT_NODE -> MaterialTheme.colorScheme.warningListItem + NodeState.OFFLINE_ENABLED -> MaterialTheme.colorScheme.errorListItem + NodeState.OFFLINE_DISABLED -> MaterialTheme.colorScheme.errorListItem + NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.errorListItem + else -> + ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surface) }, - onClick = { - if (nodeState == NodeState.RUNNING_AS_EXIT_NODE) - viewModel.setRunningExitNode(false) - else viewModel.toggleExitNode() + overlineContent = { + Text( + text = + if (nodeState == NodeState.OFFLINE_ENABLED || + nodeState == NodeState.OFFLINE_DISABLED || + nodeState == NodeState.OFFLINE_MDM) + stringResource(R.string.exit_node_offline) + else stringResource(R.string.exit_node), + style = MaterialTheme.typography.bodySmall, + ) }, - ) { - 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) + headlineContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = + when (nodeState) { + NodeState.NONE -> stringResource(id = R.string.none) + NodeState.RUNNING_AS_EXIT_NODE -> + stringResource(id = R.string.running_exit_node) + else -> name ?: "" + }, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Icon( + imageVector = Icons.Outlined.ArrowDropDown, + contentDescription = null, + tint = + if (nodeState == NodeState.NONE) + MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), + ) } - ) - } + }, + trailingContent = { + if (nodeState != NodeState.NONE) { + Button( + colors = + when (nodeState) { + NodeState.OFFLINE_ENABLED -> MaterialTheme.colorScheme.errorButton + NodeState.OFFLINE_DISABLED -> MaterialTheme.colorScheme.errorButton + NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.errorButton + NodeState.RUNNING_AS_EXIT_NODE -> + MaterialTheme.colorScheme.warningButton + NodeState.ACTIVE_NOT_RUNNING -> + MaterialTheme.colorScheme.exitNodeToggleButton + else -> MaterialTheme.colorScheme.secondaryButton + }, + onClick = { + 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) + }) + } + } + }, + ) } - }, - ) - } - } + } } @Composable @@ -644,9 +634,7 @@ private fun ConnectViewPreview() { horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.horizontalScroll(rememberScrollState()), ) { - Button(onClick = { isPrepared = !isPrepared }) { - Text("Prepared: $isPrepared") - } + Button(onClick = { isPrepared = !isPrepared }) { Text("Prepared: $isPrepared") } Button(onClick = { showState = true }) { Text("State: $state") DropdownMenu(expanded = showState, onDismissRequest = { showState = false }) { @@ -655,12 +643,8 @@ private fun ConnectViewPreview() { } } } - Button(onClick = { showUser = !showUser }) { - Text("User: $showUser") - } - Button(onClick = { showNode = !showNode }) { - Text("Node: $showNode") - } + Button(onClick = { showUser = !showUser }) { Text("User: $showUser") } + Button(onClick = { showNode = !showNode }) { Text("Node: $showNode") } } ConnectView( @@ -705,48 +689,47 @@ fun PeerList( } else { if (!isAndroidTV()) { Box( - modifier = Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surface) - ) { - OutlinedTextField( - modifier = - Modifier.fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 0.dp) - .onFocusChanged { isSearchFocussed = it.isFocused }, - singleLine = true, - shape = MaterialTheme.shapes.extraLarge, - colors = MaterialTheme.colorScheme.searchBarColors, - leadingIcon = { - Icon(imageVector = Icons.Outlined.Search, contentDescription = "search") - }, - trailingIcon = { - if (isSearchFocussed) { - IconButton( - onClick = { - focusManager.clearFocus() - onSearch("") - } - ) { - Icon( - imageVector = - if (searchTermStr.isEmpty()) Icons.Outlined.Close - else Icons.Outlined.Clear, - contentDescription = "clear search", - tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surface)) { + OutlinedTextField( + modifier = + Modifier.fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 0.dp) + .onFocusChanged { isSearchFocussed = it.isFocused }, + singleLine = true, + shape = MaterialTheme.shapes.extraLarge, + colors = MaterialTheme.colorScheme.searchBarColors, + leadingIcon = { + Icon(imageVector = Icons.Outlined.Search, contentDescription = "search") + }, + trailingIcon = { + if (isSearchFocussed) { + IconButton( + onClick = { + focusManager.clearFocus() + onSearch("") + }) { + Icon( + imageVector = + if (searchTermStr.isEmpty()) Icons.Outlined.Close + else Icons.Outlined.Clear, + contentDescription = "clear search", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + placeholder = { + Text( + text = stringResource(id = R.string.search), + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, ) - } - } - }, - placeholder = { - Text( - text = stringResource(id = R.string.search), - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - ) - }, - value = searchTermStr, - onValueChange = { onSearch(it) }, - ) - } + }, + value = searchTermStr, + onValueChange = { onSearch(it) }, + ) + } } } @@ -757,90 +740,80 @@ fun PeerList( .weight(1f) // LazyColumn gets the remaining vertical space .onFocusChanged { isListFocussed = it.isFocused } .background(color = MaterialTheme.colorScheme.surface) - .windowInsetsPadding(WindowInsets.navigationBars) - ) { - // Handle case when no results are found - if (showNoResults) { - item { - Spacer( - Modifier.height(16.dp) - .fillMaxSize() - .focusable(false) - .background(color = MaterialTheme.colorScheme.surface) - ) - Lists.LargeTitle( - stringResource(id = R.string.no_results), - bottomPadding = 8.dp, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Light, - ) - } - } - - // Iterate over peer sets to display them - peerList.forEachIndexed { idx, peerSet -> - if (idx != 0) { - item(key = "user_divider_${peerSet.id}") { - Lists.ItemDivider() - } - } - if (isAndroidTV()) { - item { - NodesSectionHeader(peerSet = peerSet) - } - } else { - stickyHeader { - NodesSectionHeader(peerSet = peerSet) + .windowInsetsPadding(WindowInsets.navigationBars)) { + // Handle case when no results are found + if (showNoResults) { + item { + Spacer( + Modifier.height(16.dp) + .fillMaxSize() + .focusable(false) + .background(color = MaterialTheme.colorScheme.surface)) + Lists.LargeTitle( + stringResource(id = R.string.no_results), + bottomPadding = 8.dp, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Light, + ) + } } - } - itemsWithDividers(peerSet.nodes, key = { it.StableID }) { peer -> - ListItem( - modifier = - Modifier.combinedClickable( - onClick = { onNavigateToPeerDetails(peer) }, - onLongClick = { viewModel.expandedMenuPeer.set(peer) }, - ), - colors = MaterialTheme.colorScheme.listItem, - headlineContent = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Box( - modifier = - Modifier.padding(top = 2.dp) - .size(10.dp) - .background( - 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, + + // Iterate over peer sets to display them + peerList.forEachIndexed { idx, peerSet -> + if (idx != 0) { + item(key = "user_divider_${peerSet.id}") { Lists.ItemDivider() } + } + if (isAndroidTV()) { + item { NodesSectionHeader(peerSet = peerSet) } + } else { + stickyHeader { NodesSectionHeader(peerSet = peerSet) } + } + itemsWithDividers(peerSet.nodes, key = { it.StableID }) { peer -> + ListItem( + modifier = + Modifier.combinedClickable( + onClick = { onNavigateToPeerDetails(peer) }, + onLongClick = { viewModel.expandedMenuPeer.set(peer) }, + ), + colors = MaterialTheme.colorScheme.listItem, + headlineContent = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = + Modifier.padding(top = 2.dp) + .size(10.dp) + .background( + 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 = { + Text( + text = peer.Addresses?.first()?.split("/")?.first() ?: "", + style = + MaterialTheme.typography.bodyMedium.copy( + lineHeight = MaterialTheme.typography.titleMedium.lineHeight), ) - } - } - }, - supportingContent = { - Text( - text = peer.Addresses?.first()?.split("/")?.first() ?: "", - style = - MaterialTheme.typography.bodyMedium.copy( - lineHeight = MaterialTheme.typography.titleMedium.lineHeight - ), - ) - }, - ) + }, + ) + } + } } - } - } } } @@ -928,8 +901,7 @@ fun DeviceDropdownMenu( }, text = { Text( - text = stringResource(if (isFavorite) R.string.unpin_device else R.string.pin_device) - ) + text = stringResource(if (isFavorite) R.string.unpin_device else R.string.pin_device)) }, onClick = { viewModel.togglePin(peer) @@ -941,7 +913,7 @@ fun DeviceDropdownMenu( @Composable fun PeerSet.sectionTitle(): String = - if (id == FAVORITES_ID) stringResource(id = R.string.pinned_devices) + if (isFavorite) stringResource(id = R.string.pinned_devices) else title ?: stringResource(id = R.string.unknown_user) @Composable @@ -960,7 +932,7 @@ fun NodesSectionHeader(peerSet: PeerSet) { @Composable private fun NodesSectionHeaderPreview() { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - NodesSectionHeader(peerSet = PeerSet(-1, null, nodes = emptyList())) + NodesSectionHeader(peerSet = PeerSet(FAVORITES_ID, null, nodes = emptyList())) NodesSectionHeader(peerSet = PeerSet(1, "Thing", nodes = emptyList())) } } @@ -972,25 +944,24 @@ fun ExpiryNotification(netmap: Netmap.NetworkMap, action: () -> Unit = {}) { modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp) .clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp)) - .fillMaxWidth() - ) { - ListItem( - modifier = Modifier.clickable { action() }, - colors = MaterialTheme.colorScheme.warningListItem, - headlineContent = { - Text( - netmap.SelfNode.expiryLabel(), - style = MaterialTheme.typography.titleMedium, - ) - }, - supportingContent = { - Text( - stringResource(id = R.string.keyExpiryExplainer), - style = MaterialTheme.typography.bodyMedium, - ) - }, - ) - } + .fillMaxWidth()) { + ListItem( + modifier = Modifier.clickable { action() }, + colors = MaterialTheme.colorScheme.warningListItem, + headlineContent = { + Text( + netmap.SelfNode.expiryLabel(), + style = MaterialTheme.typography.titleMedium, + ) + }, + supportingContent = { + Text( + stringResource(id = R.string.keyExpiryExplainer), + style = MaterialTheme.typography.bodyMedium, + ) + }, + ) + } } } @@ -1021,45 +992,47 @@ fun Search( var isNavigating by remember { mutableStateOf(false) } Box( modifier = - Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface).padding(top = 8.dp) - ) { - Box( - modifier = - Modifier.fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, top = 16.dp) - .height(56.dp) - .clip(MaterialTheme.shapes.extraLarge) // Rounded corners for search bar - .background(backgroundColor) // Search bar background - .clickable(enabled = !isNavigating) { // Intercept taps - isNavigating = true - onSearchBarClick() - } - .padding(horizontal = 16.dp) // Internal padding - ) { - 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.fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(top = 8.dp)) { + Box( + modifier = + Modifier.fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 16.dp) + .height(56.dp) + .clip(MaterialTheme.shapes.extraLarge) // Rounded corners for search bar + .background(backgroundColor) // Search bar background + .clickable(enabled = !isNavigating) { // Intercept taps + isNavigating = true + onSearchBarClick() + } + .padding(horizontal = 16.dp) // Internal padding + ) { + 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 + ) + } + } } - } - } } @Preview 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 1e2b2a82dc..08aa537167 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 @@ -156,14 +156,13 @@ fun SearchView( focusManager.clearFocus() onNavigateBack() viewModel.updateSearchTerm("") + }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.search), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) } - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.search), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } }, trailingIcon = { if (searchTerm.isNotEmpty()) { @@ -173,13 +172,12 @@ fun SearchView( viewModel.updateSearchTerm("") focusManager.clearFocus() keyboardController?.hide() + }) { + Icon( + Icons.Default.Clear, + contentDescription = stringResource(R.string.clear_search), + ) } - ) { - Icon( - Icons.Default.Clear, - contentDescription = stringResource(R.string.clear_search), - ) - } } }, ) @@ -227,8 +225,7 @@ fun SearchView( .background( onlineColor, RoundedCornerShape(50), - ) - ) + )) Spacer(modifier = Modifier.size(8.dp)) Text(peer.displayName) } 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 b6402985da..fc858e6dea 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 @@ -119,9 +119,8 @@ open class IpnViewModel : ViewModel() { val validNetmap = netmap ?: return@combine NodeState.NONE val chosenExitNodeId = validPrefs.activeExitNodeID ?: validPrefs.selectedExitNodeID - val exitNodePeer = chosenExitNodeId?.let { id -> - validNetmap.Peers?.find { it.StableID == id } - } + val exitNodePeer = + chosenExitNodeId?.let { id -> validNetmap.Peers?.find { it.StableID == id } } when { exitNodePeer?.Online == false -> { @@ -133,7 +132,6 @@ open class IpnViewModel : ViewModel() { NodeState.OFFLINE_DISABLED } } - exitNodePeer != null -> { if (!validPrefs.activeExitNodeID.isNullOrEmpty()) { NodeState.ACTIVE_AND_RUNNING @@ -141,11 +139,9 @@ open class IpnViewModel : ViewModel() { NodeState.ACTIVE_NOT_RUNNING } } - isRunningExitNode -> { NodeState.RUNNING_AS_EXIT_NODE } - else -> { NodeState.NONE } @@ -328,9 +324,7 @@ open class IpnViewModel : ViewModel() { Client(viewModelScope).setFavorites(toggled) { result -> result .onSuccess { _favorites.value = it } - .onFailure { - TSLog.e(TAG, "Error toggling favorites: ${it.message}") - } + .onFailure { TSLog.e(TAG, "Error toggling favorites: ${it.message}") } _isToggleFavoriteInProgress.value = false } } 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 faa79b09d1..c6dbbb9108 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 @@ -61,7 +61,7 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { // Keeps track of whether a toggle operation is in progress. This ensures that toggleVpn cannot be // invoked until the current operation is complete. - private var _isToggleInProgress = MutableStateFlow(false) + private val _isToggleInProgress = MutableStateFlow(false) val isToggleInProgress: StateFlow = _isToggleInProgress // Permission to prepare VPN @@ -164,7 +164,6 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { when { active && (currentState == State.Running || currentState == State.Starting) -> true - previousState == State.NoState && currentState == State.Starting -> true else -> false } @@ -180,7 +179,7 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { // run the search as a background task searchJob?.cancel() searchJob = - launch(Dispatchers.Default) { + launch(categorizerDispatcher) { val filteredPeers = peerCategorizer.groupedAndFilteredPeers(term) _searchViewPeers.value = filteredPeers } @@ -213,8 +212,7 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { TimeUtil.isWithinExpiryNotificationWindow( window, netmap.SelfNode.KeyExpiry ?: "", - ) - ) + )) } } } @@ -292,7 +290,6 @@ private fun userStringRes(currentState: State?, previousState: State?, vpnActive currentState == State.InUseOtherUser -> R.string.placeholder currentState == State.NeedsLogin -> if (vpnActive) R.string.please_login else R.string.connect_to_vpn - currentState == State.NeedsMachineAuth -> R.string.needs_machine_auth currentState == State.Stopped -> R.string.stopped currentState == State.Starting -> R.string.starting diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index f8e5a6f947..818c0bd51c 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ - + Log in 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..ca277137a8 --- /dev/null +++ b/android/src/test/kotlin/com/tailcale/ipn/ui/PeerCategorizerTest.kt @@ -0,0 +1,296 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// DESTINATION: android/src/test/kotlin/com/tailcale/ipn/ui/util/PeerCategorizerTest.kt +// (yes, "tailcale" -- the existing unit-test package root is misspelled; see +// TimeUtilTest.kt and HealthNotifierTest.kt.) +// +// Run with: cd android && ./gradlew test --tests '*PeerCategorizerTest*' + +package com.tailcale.ipn.ui + +import com.tailscale.ipn.mdm.MDMSettings +import com.tailscale.ipn.mdm.SettingState +import com.tailscale.ipn.ui.model.FavoriteItem +import com.tailscale.ipn.ui.model.Favorites +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 org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Ignore +import org.junit.Test + +// PeerHelper.kt keeps FAVORITES_ID private to the file, so it has to be +// duplicated here. Once it moves to PeerSet.PINNED_ID, delete this and use +// PeerSet.PINNED_ID directly. +private const val PINNED_ID = -1L + +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) + +private fun pins(vararg stableIds: String) = + Favorites(devices = stableIds.map { FavoriteItem(id = it) }) + +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; Name carries a trailing + // dot here, so the suffix has to be on ComputedName to be detected. + 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, + ) + + private lateinit var categorizer: PeerCategorizer + + @Before + fun setUp() { + categorizer = PeerCategorizer() + clearHiddenDevices() + } + + // MDMSettings is a process-wide singleton, so leaving it set would leak into + // whichever test class runs next in the same JVM. + @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(favorites: Favorites = Favorites()) = + categorizer.regenerateGroupedPeers(netmap, favorites) + + private fun sectionIds() = categorizer.peerSets.map { it.id } + + private fun section(id: Long): PeerSet = categorizer.peerSets.first { it.id == id } + + private fun stableIds(peerSet: PeerSet) = peerSet.nodes.map { it.StableID } + + // ---------------------------------------------------------------- grouping + + @Test + fun withoutPins_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" -- a case-sensitive compare would put + // "Delta" first because 'D' < 'a' in ASCII. + assertEquals(listOf("self", "a1", "d1"), stableIds(section(ME_ID))) + } + + @Test + fun mullvadNodesAreExcludedEntirely() { + regenerate() + + assertTrue(categorizer.peerSets.none { set -> set.nodes.any { it.StableID == "mv" } }) + } + + @Test + fun withoutPins_noPinnedSectionIsEmitted() { + regenerate(Favorites()) + assertTrue(sectionIds().none { it == PINNED_ID }) + + regenerate(Favorites(devices = emptyList())) + assertTrue(sectionIds().none { it == PINNED_ID }) + } + + @Test + fun pinnedSectionSortsFirstAndHasANullTitle() { + regenerate(pins("z1", "d1")) + + val pinned = categorizer.peerSets.first() + assertEquals(PINNED_ID, pinned.id) + assertNull("Pinned section resolves its title from a string resource", pinned.title) + assertEquals(listOf("d1", "z1"), stableIds(pinned)) // Delta, zulu + } + + @Test + fun pinnedNodesAreMovedOutOfTheirOwnerSection() { + // Documents the current "move, don't duplicate" behaviour. Confirm this + // matches darwin before locking it in. + regenerate(pins("z1", "d1")) + + assertEquals(listOf("self", "a1"), stableIds(section(ME_ID))) + // zeke owned only z1, so their section disappears completely. + assertTrue(sectionIds().none { it == ZEKE_ID }) + } + + @Test + fun selfNodeStillSortsFirstInsideThePinnedSection() { + regenerate(pins("self", "d1", "z1")) + + assertEquals(listOf("self", "d1", "z1"), stableIds(section(PINNED_ID))) + } + + // ------------------------------------------------------------- MDM filters + + @Test + fun hideTaggedDevices_hidesUnpinnedTaggedDevices() { + hideDevices("tagged-devices") + regenerate() + + assertTrue(sectionIds().none { it == TAGGED_ID }) + } + + @Test + fun hideOtherDevices_keepsMyOwnPinnedDevices() { + // d1 is grouped under the pinned id, so a "userId != me.ID" check would + // hide it even though I own it. Filter on peer.User, group on the pinned id. + hideDevices("other-users") + regenerate(pins("d1")) + + assertEquals(listOf("d1"), stableIds(section(PINNED_ID))) + } + + @Test + fun hideMyDevices_alsoHidesMyPinnedDevices() { + // A "userId == me.ID" check can never match a pinned node, so d1 would + // leak into the Pinned section. + hideDevices("current-user") + regenerate(pins("d1")) + + assertTrue(categorizer.peerSets.none { set -> set.nodes.any { it.StableID == "d1" } }) + } + + @Test + fun hideTaggedDevices_alsoHidesPinnedTaggedDevices() { + // userProfile(pinnedId) is null, so isTaggedDevice() would never be + // consulted for a pinned node. + hideDevices("tagged-devices") + regenerate(pins("t1")) + + assertTrue(categorizer.peerSets.none { set -> set.nodes.any { it.StableID == "t1" } }) + } + + // ----------------------------------------------------------------- search + + @Test + fun searchMatchesNodeNameInsideThePinnedSection() { + regenerate(pins("z1")) + + val result = categorizer.groupedAndFilteredPeers("zulu") + + assertEquals(listOf(PINNED_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() + + // "ta" matches the "tagged-devices" section title, so the whole section + // comes back and is cached as lastSearchResult. + assertEquals(listOf(TAGGED_ID), categorizer.groupedAndFilteredPeers("ta").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() { + // FAILS until regenerateGroupedPeers() resets lastSearchTerm / + // lastSearchResult. MainViewModel.categorize() calls + // groupedAndFilteredPeers(searchTerm.value) right after regenerating, and + // it short-circuits on the unchanged term and returns the pre-pin result. + regenerate() + assertEquals(listOf(ZEKE_ID), categorizer.groupedAndFilteredPeers("zulu").map { it.id }) + + regenerate(pins("z1")) + val result = categorizer.groupedAndFilteredPeers("zulu") + + assertEquals(listOf(PINNED_ID), result.map { it.id }) + } + + @Ignore("Design decision: should a user-name search still reach that user's pinned devices?") + @Test + fun searchingAUserNameStillFindsTheirPinnedDevices() { + // The pinned PeerSet has a null title, so a user-name search can't reach + // it. Because bob's only other node is a Mullvad node, searching "bob" + // returns nothing at all once b1 is pinned. + regenerate(pins("b1")) + + val result = categorizer.groupedAndFilteredPeers("bob") + + assertEquals(listOf("b1"), result.flatMap { stableIds(it) }) + } +} 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..f40d03dd0d --- /dev/null +++ b/android/src/test/kotlin/com/tailcale/ipn/ui/util/FavoritesTest.kt @@ -0,0 +1,121 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// DESTINATION: android/src/test/kotlin/com/tailcale/ipn/ui/model/FavoritesTest.kt +// +// Run with: cd android && ./gradlew test --tests '*FavoritesTest*' + +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"), + ) + ) + + // .toSet() so this holds whether deviceIds stays a List or becomes a Set. + 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 }) + } + + // Locks the exact bytes Client.setFavorites puts on the wire. Client uses the + // default Json instance (encodeDefaults = false), which is what makes the + // null *Set flags and the unset ExitNodes/Services drop out. + @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, so the + // empty list has to survive serialization. + 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","Name":"laptop"}],"ExitNodes":null,"Services":[],"Unknown":1}""" + + val favorites = Json { ignoreUnknownKeys = true }.decodeFromString(json) + + assertTrue(favorites.isFavoriteDevice("nodeA")) + assertEquals("laptop", favorites.devices?.single()?.name) + assertNull(favorites.exitNodes) + assertEquals(emptyList(), favorites.services) + } +} From a91ea9f4be95134b66d41e2ee61760888e0478b8 Mon Sep 17 00:00:00 2001 From: hiimtmac Date: Thu, 3 Sep 2026 20:24:54 -0500 Subject: [PATCH 06/14] fix test --- .../src/test/kotlin/com/tailcale/ipn/ui/PeerCategorizerTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/src/test/kotlin/com/tailcale/ipn/ui/PeerCategorizerTest.kt b/android/src/test/kotlin/com/tailcale/ipn/ui/PeerCategorizerTest.kt index ca277137a8..c1b9d7ee61 100644 --- a/android/src/test/kotlin/com/tailcale/ipn/ui/PeerCategorizerTest.kt +++ b/android/src/test/kotlin/com/tailcale/ipn/ui/PeerCategorizerTest.kt @@ -258,7 +258,7 @@ class PeerCategorizerTest { // "ta" matches the "tagged-devices" section title, so the whole section // comes back and is cached as lastSearchResult. - assertEquals(listOf(TAGGED_ID), categorizer.groupedAndFilteredPeers("ta").map { it.id }) + 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. From 76da34028fa7f576f5064d63549051682b57c6a7 Mon Sep 17 00:00:00 2001 From: hiimtmac Date: Fri, 4 Sep 2026 10:04:27 -0500 Subject: [PATCH 07/14] extract fav manager --- .../src/main/java/com/tailscale/ipn/App.kt | 79 ++- .../com/tailscale/ipn/ui/model/Favorites.kt | 2 +- .../ipn/ui/notifier/FavoritesManager.kt | 123 ++++ .../com/tailscale/ipn/ui/util/PeerHelper.kt | 71 ++- .../com/tailscale/ipn/ui/view/MainView.kt | 562 +++++++++--------- .../ipn/ui/viewModel/IpnViewModel.kt | 43 +- .../ipn/ui/viewModel/MainViewModel.kt | 19 +- .../tailcale/ipn/ui/PeerCategorizerTest.kt | 138 ++--- .../com/tailcale/ipn/ui/PinnedSectionTest.kt | 105 ++++ .../com/tailcale/ipn/ui/util/FavoritesTest.kt | 5 +- 10 files changed, 664 insertions(+), 483 deletions(-) create mode 100644 android/src/main/java/com/tailscale/ipn/ui/notifier/FavoritesManager.kt create mode 100644 android/src/test/kotlin/com/tailcale/ipn/ui/PinnedSectionTest.kt diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index d6d559bea9..6befa43c04 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 } @@ -365,7 +381,8 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { pointToPoint = nif.isPointToPoint, multicast = nif.supportsMulticast(), addrs = addrs, - )) + ) + ) } catch (_: Exception) { continue } @@ -376,13 +393,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 +415,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 +558,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 +579,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 +616,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,14 +665,16 @@ open class UninitializedApp : Application() { fun notifyStatus( vpnRunning: Boolean, hideDisconnectAction: Boolean, - exitNodeName: String? = null + exitNodeName: String? = null, ) { notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction, exitNodeName)) } fun notifyStatus(notification: Notification) { - if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != - PackageManager.PERMISSION_GRANTED) { + if ( + ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED + ) { // TODO: Consider calling // ActivityCompat#requestPermissions // here to request the missing permissions, and then overriding @@ -659,7 +690,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 +707,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) @@ -697,7 +733,8 @@ open class UninitializedApp : Application() { .setContentIntent(pendingIntent) if (!vpnRunning || !hideDisconnectAction) { builder.addAction( - NotificationCompat.Action.Builder(0, actionLabel, pendingButtonIntent).build()) + NotificationCompat.Action.Builder(0, actionLabel, pendingButtonIntent).build() + ) } return builder.build() } 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 index 912ddc0711..cd74156b65 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/Favorites.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Favorites.kt @@ -15,7 +15,7 @@ data class Favorites( @SerialName("ExitNodes") val exitNodes: List? = null, @SerialName("Services") val services: List? = null, ) { - val deviceIds: Set by lazy { devices.orEmpty().mapNotNull { it.id }.toSet() } + val deviceIds: List by lazy { devices.orEmpty().mapNotNull { it.id } } fun isFavoriteDevice(id: StableNodeID): Boolean = id in deviceIds 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..b0c8543991 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/notifier/FavoritesManager.kt @@ -0,0 +1,123 @@ +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 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.filterNotNull +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class FavoritesManager +constructor( + 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 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 pendingWrite: Job? = null + private var revert: Favorites? = null + + init { + scope.launch { + userFlow.distinctUntilChanged().collect { user -> + withContext(dispatcher) { + currentUser = user + pendingWrite?.cancel() + revert = null + _writing.value = false + _favorites.value = null + } + } + } + + scope.launch { + combine(ipnStateFlow, userFlow) { state, user -> user.takeIf { state == Ipn.State.Running } } + .filterNotNull() + .distinctUntilChanged() + .collect { load() } + } + } + + private fun load() { + scope.launch(dispatcher) { + val user = currentUser + 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}") } + } + } + } + } + + 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 + result + .onSuccess { _favorites.value = it } + .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/PeerHelper.kt b/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt index 0190cebdb5..6f131084b7 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 @@ -5,8 +5,8 @@ package com.tailscale.ipn.ui.util import androidx.compose.ui.util.fastAny import com.tailscale.ipn.mdm.MDMSettings -import com.tailscale.ipn.ui.model.Favorites 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 @@ -26,6 +26,26 @@ data class PeerSet( 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 { @@ -42,14 +62,10 @@ class PeerCategorizer { var lastSearchResult: List = emptyList() var lastSearchTerm: String = "" - fun regenerateGroupedPeers( - netmap: Netmap.NetworkMap, - favorites: Favorites, - ) { - val peers: List = netmap.Peers ?: return + fun regenerateGroupedPeers(netmap: Netmap.NetworkMap) { + val peers: List = netmap.Peers.orEmpty() val selfNode = netmap.SelfNode val grouped = mutableMapOf>() - val favoriteDeviceIds = favorites.deviceIds val mdm = MDMSettings.hiddenNetworkDevices.flow.value.value val hideMyDevices = mdm?.contains("current-user") ?: false @@ -61,8 +77,6 @@ class PeerCategorizer { for (peer in (peers + selfNode)) { val userId = peer.User val profile = netmap.userProfile(userId) - val groupId = - if (favoriteDeviceIds.contains(peer.StableID)) PeerSet.FAVORITES_ID else peer.User // Mullvad nodes should not be shown in the peer list if (peer.isMullvadNode) { @@ -82,11 +96,11 @@ class PeerCategorizer { continue } - if (!grouped.containsKey(groupId)) { - grouped[groupId] = mutableListOf() + if (!grouped.containsKey(userId)) { + grouped[userId] = mutableListOf() } - grouped[groupId]?.add(peer) + grouped[userId]?.add(peer) } peerSets = @@ -98,11 +112,7 @@ class PeerCategorizer { peers.nodeSort(netmap), ) } - .sortedWith( - compareBy( - { it.id != PeerSet.FAVORITES_ID }, // keep pinned at top - { if (it.id == me?.ID) "" else it.title?.lowercase() ?: "unknown user" }, - )) + .sortedBy { if (it.id == me?.ID) "" else it.title?.lowercase() ?: "unknown user" } lastSearchTerm = "" lastSearchResult = emptyList() @@ -125,24 +135,21 @@ class PeerCategorizer { else peerSets this.lastSearchTerm = searchTerm - val matchingSets = - setsToSearch.mapNotNull { peerSet -> - val peers = peerSet.nodes + val matchingSets = setsToSearch.mapNotNull { peerSet -> + val peers = peerSet.nodes - if (peerSet.title?.contains(searchTerm, ignoreCase = true) == true) { - return@mapNotNull peerSet - } + 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 { peer -> + val matchDisplay = peer.displayName.contains(searchTerm, ignoreCase = true) + val matchAddress = peer.Addresses.orEmpty().fastAny { it.contains(searchTerm) } + matchDisplay || matchAddress + } - if (matchingPeers.isNotEmpty()) PeerSet(peerSet.id, peerSet.title, matchingPeers) - else null - } + 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 579d50fa7c..9b4136ac7e 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 @@ -272,9 +272,10 @@ fun MainView( ModalBottomSheet( onDismissRequest = { viewModel.pendingTaildrop.isPresentingPendingItemsList.value = false - }) { - InlineShareListSheet(viewModel = viewModel.pendingTaildrop) } + ) { + InlineShareListSheet(viewModel = viewModel.pendingTaildrop) + } } } } @@ -332,116 +333,118 @@ private fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { Box( modifier = - Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surfaceContainer)) { - if (nodeState == NodeState.OFFLINE_MDM) { - Box( - modifier = - Modifier.padding(start = 16.dp, end = 16.dp, top = 56.dp, bottom = 16.dp) - .clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp)) - .background(MaterialTheme.colorScheme.customErrorContainer) - .fillMaxWidth() - .align(Alignment.TopCenter)) { - Column( - modifier = - Modifier.padding(start = 16.dp, end = 16.dp, top = 36.dp, bottom = 16.dp)) { - Text( - text = - managedByOrganization.value?.let { - stringResource(R.string.exit_node_offline_mdm_orgname, it) - } ?: stringResource(R.string.exit_node_offline_mdm), - style = MaterialTheme.typography.bodyMedium, - color = Color.White, - ) - } - } + Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surfaceContainer) + ) { + if (nodeState == NodeState.OFFLINE_MDM) { + Box( + modifier = + Modifier.padding(start = 16.dp, end = 16.dp, top = 56.dp, bottom = 16.dp) + .clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp)) + .background(MaterialTheme.colorScheme.customErrorContainer) + .fillMaxWidth() + .align(Alignment.TopCenter) + ) { + Column( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 36.dp, bottom = 16.dp) + ) { + Text( + text = + managedByOrganization.value?.let { + stringResource(R.string.exit_node_offline_mdm_orgname, it) + } ?: stringResource(R.string.exit_node_offline_mdm), + style = MaterialTheme.typography.bodyMedium, + color = Color.White, + ) } - Box( - modifier = - Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp) - .clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp)) - .fillMaxWidth()) { - ListItem( - modifier = Modifier.clickable { navAction() }, + } + } + Box( + modifier = + Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp) + .clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp)) + .fillMaxWidth() + ) { + ListItem( + modifier = Modifier.clickable { navAction() }, + colors = + when (nodeState) { + NodeState.ACTIVE_AND_RUNNING -> MaterialTheme.colorScheme.primaryListItem + NodeState.ACTIVE_NOT_RUNNING -> MaterialTheme.colorScheme.listItem + NodeState.RUNNING_AS_EXIT_NODE -> MaterialTheme.colorScheme.warningListItem + NodeState.OFFLINE_ENABLED -> MaterialTheme.colorScheme.errorListItem + NodeState.OFFLINE_DISABLED -> MaterialTheme.colorScheme.errorListItem + NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.errorListItem + else -> ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surface) + }, + overlineContent = { + Text( + text = + if ( + nodeState == NodeState.OFFLINE_ENABLED || + nodeState == NodeState.OFFLINE_DISABLED || + nodeState == NodeState.OFFLINE_MDM + ) + stringResource(R.string.exit_node_offline) + else stringResource(R.string.exit_node), + style = MaterialTheme.typography.bodySmall, + ) + }, + headlineContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = + when (nodeState) { + NodeState.NONE -> stringResource(id = R.string.none) + NodeState.RUNNING_AS_EXIT_NODE -> + stringResource(id = R.string.running_exit_node) + else -> name ?: "" + }, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Icon( + imageVector = Icons.Outlined.ArrowDropDown, + contentDescription = null, + tint = + if (nodeState == NodeState.NONE) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), + ) + } + }, + trailingContent = { + if (nodeState != NodeState.NONE) { + Button( colors = when (nodeState) { - NodeState.ACTIVE_AND_RUNNING -> MaterialTheme.colorScheme.primaryListItem - NodeState.ACTIVE_NOT_RUNNING -> MaterialTheme.colorScheme.listItem - NodeState.RUNNING_AS_EXIT_NODE -> MaterialTheme.colorScheme.warningListItem - NodeState.OFFLINE_ENABLED -> MaterialTheme.colorScheme.errorListItem - NodeState.OFFLINE_DISABLED -> MaterialTheme.colorScheme.errorListItem - NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.errorListItem - else -> - ListItemDefaults.colors( - containerColor = MaterialTheme.colorScheme.surface) + NodeState.OFFLINE_ENABLED -> MaterialTheme.colorScheme.errorButton + NodeState.OFFLINE_DISABLED -> MaterialTheme.colorScheme.errorButton + NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.errorButton + NodeState.RUNNING_AS_EXIT_NODE -> MaterialTheme.colorScheme.warningButton + NodeState.ACTIVE_NOT_RUNNING -> + MaterialTheme.colorScheme.exitNodeToggleButton + else -> MaterialTheme.colorScheme.secondaryButton }, - overlineContent = { - Text( - text = - if (nodeState == NodeState.OFFLINE_ENABLED || - nodeState == NodeState.OFFLINE_DISABLED || - nodeState == NodeState.OFFLINE_MDM) - stringResource(R.string.exit_node_offline) - else stringResource(R.string.exit_node), - style = MaterialTheme.typography.bodySmall, - ) - }, - headlineContent = { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = - when (nodeState) { - NodeState.NONE -> stringResource(id = R.string.none) - NodeState.RUNNING_AS_EXIT_NODE -> - stringResource(id = R.string.running_exit_node) - else -> name ?: "" - }, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Icon( - imageVector = Icons.Outlined.ArrowDropDown, - contentDescription = null, - tint = - if (nodeState == NodeState.NONE) - MaterialTheme.colorScheme.onSurfaceVariant - else MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), - ) - } + onClick = { + if (nodeState == NodeState.RUNNING_AS_EXIT_NODE) + viewModel.setRunningExitNode(false) + else viewModel.toggleExitNode() }, - trailingContent = { - if (nodeState != NodeState.NONE) { - Button( - colors = - when (nodeState) { - NodeState.OFFLINE_ENABLED -> MaterialTheme.colorScheme.errorButton - NodeState.OFFLINE_DISABLED -> MaterialTheme.colorScheme.errorButton - NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.errorButton - NodeState.RUNNING_AS_EXIT_NODE -> - MaterialTheme.colorScheme.warningButton - NodeState.ACTIVE_NOT_RUNNING -> - MaterialTheme.colorScheme.exitNodeToggleButton - else -> MaterialTheme.colorScheme.secondaryButton - }, - onClick = { - 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 @@ -689,47 +692,48 @@ fun PeerList( } else { if (!isAndroidTV()) { Box( - modifier = - Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surface)) { - OutlinedTextField( - modifier = - Modifier.fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 0.dp) - .onFocusChanged { isSearchFocussed = it.isFocused }, - singleLine = true, - shape = MaterialTheme.shapes.extraLarge, - colors = MaterialTheme.colorScheme.searchBarColors, - leadingIcon = { - Icon(imageVector = Icons.Outlined.Search, contentDescription = "search") - }, - trailingIcon = { - if (isSearchFocussed) { - IconButton( - onClick = { - focusManager.clearFocus() - onSearch("") - }) { - Icon( - imageVector = - if (searchTermStr.isEmpty()) Icons.Outlined.Close - else Icons.Outlined.Clear, - contentDescription = "clear search", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - }, - placeholder = { - Text( - text = stringResource(id = R.string.search), - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, + modifier = Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surface) + ) { + OutlinedTextField( + modifier = + Modifier.fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 0.dp) + .onFocusChanged { isSearchFocussed = it.isFocused }, + singleLine = true, + shape = MaterialTheme.shapes.extraLarge, + colors = MaterialTheme.colorScheme.searchBarColors, + leadingIcon = { + Icon(imageVector = Icons.Outlined.Search, contentDescription = "search") + }, + trailingIcon = { + if (isSearchFocussed) { + IconButton( + onClick = { + focusManager.clearFocus() + onSearch("") + } + ) { + Icon( + imageVector = + if (searchTermStr.isEmpty()) Icons.Outlined.Close + else Icons.Outlined.Clear, + contentDescription = "clear search", + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) - }, - value = searchTermStr, - onValueChange = { onSearch(it) }, - ) - } + } + } + }, + placeholder = { + Text( + text = stringResource(id = R.string.search), + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + ) + }, + value = searchTermStr, + onValueChange = { onSearch(it) }, + ) + } } } @@ -740,80 +744,84 @@ fun PeerList( .weight(1f) // LazyColumn gets the remaining vertical space .onFocusChanged { isListFocussed = it.isFocused } .background(color = MaterialTheme.colorScheme.surface) - .windowInsetsPadding(WindowInsets.navigationBars)) { - // Handle case when no results are found - if (showNoResults) { - item { - Spacer( - Modifier.height(16.dp) - .fillMaxSize() - .focusable(false) - .background(color = MaterialTheme.colorScheme.surface)) - Lists.LargeTitle( - stringResource(id = R.string.no_results), - bottomPadding = 8.dp, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Light, - ) - } - } + .windowInsetsPadding(WindowInsets.navigationBars) + ) { + // Handle case when no results are found + if (showNoResults) { + item { + Spacer( + Modifier.height(16.dp) + .fillMaxSize() + .focusable(false) + .background(color = MaterialTheme.colorScheme.surface) + ) + Lists.LargeTitle( + stringResource(id = R.string.no_results), + bottomPadding = 8.dp, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Light, + ) + } + } - // Iterate over peer sets to display them - peerList.forEachIndexed { idx, peerSet -> - if (idx != 0) { - item(key = "user_divider_${peerSet.id}") { Lists.ItemDivider() } - } - if (isAndroidTV()) { - item { NodesSectionHeader(peerSet = peerSet) } - } else { - stickyHeader { NodesSectionHeader(peerSet = peerSet) } - } - itemsWithDividers(peerSet.nodes, key = { it.StableID }) { peer -> - ListItem( - modifier = - Modifier.combinedClickable( - onClick = { onNavigateToPeerDetails(peer) }, - onLongClick = { viewModel.expandedMenuPeer.set(peer) }, - ), - colors = MaterialTheme.colorScheme.listItem, - headlineContent = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Box( - modifier = - Modifier.padding(top = 2.dp) - .size(10.dp) - .background( - 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 = { - Text( - text = peer.Addresses?.first()?.split("/")?.first() ?: "", - style = - MaterialTheme.typography.bodyMedium.copy( - lineHeight = MaterialTheme.typography.titleMedium.lineHeight), + // Iterate over peer sets to display them + peerList.forEachIndexed { idx, peerSet -> + if (idx != 0) { + item(key = "user_divider_${peerSet.id}") { Lists.ItemDivider() } + } + if (isAndroidTV()) { + item { NodesSectionHeader(peerSet = peerSet) } + } else { + stickyHeader { NodesSectionHeader(peerSet = peerSet) } + } + itemsWithDividers(peerSet.nodes, key = { it.StableID }) { peer -> + ListItem( + modifier = + Modifier.combinedClickable( + onClick = { onNavigateToPeerDetails(peer) }, + onLongClick = { viewModel.expandedMenuPeer.set(peer) }, + ), + colors = MaterialTheme.colorScheme.listItem, + headlineContent = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = + Modifier.padding(top = 2.dp) + .size(10.dp) + .background( + 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 = { + Text( + text = peer.Addresses?.first()?.split("/")?.first() ?: "", + style = + MaterialTheme.typography.bodyMedium.copy( + lineHeight = MaterialTheme.typography.titleMedium.lineHeight + ), + ) + }, + ) } + } + } } } @@ -825,8 +833,7 @@ fun DeviceDropdownMenu( ) { val localClipboardManager = LocalClipboardManager.current val favorites by viewModel.favorites.collectAsState() - val isToggleInProgress by viewModel.isToggleFavoriteInProgress.collectAsState() - val isFavorite = favorites.isFavoriteDevice(peer.StableID) + val isFavorite = favorites?.isFavoriteDevice(peer.StableID) == true DropdownMenu( expanded = true, @@ -892,7 +899,6 @@ fun DeviceDropdownMenu( HorizontalDivider() DropdownMenuItem( - enabled = !isToggleInProgress, leadingIcon = { Icon( painter = painterResource(if (isFavorite) R.drawable.unpin_24 else R.drawable.pin_24), @@ -901,7 +907,8 @@ fun DeviceDropdownMenu( }, text = { Text( - text = stringResource(if (isFavorite) R.string.unpin_device else R.string.pin_device)) + text = stringResource(if (isFavorite) R.string.unpin_device else R.string.pin_device) + ) }, onClick = { viewModel.togglePin(peer) @@ -944,24 +951,25 @@ fun ExpiryNotification(netmap: Netmap.NetworkMap, action: () -> Unit = {}) { modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp) .clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp)) - .fillMaxWidth()) { - ListItem( - modifier = Modifier.clickable { action() }, - colors = MaterialTheme.colorScheme.warningListItem, - headlineContent = { - Text( - netmap.SelfNode.expiryLabel(), - style = MaterialTheme.typography.titleMedium, - ) - }, - supportingContent = { - Text( - stringResource(id = R.string.keyExpiryExplainer), - style = MaterialTheme.typography.bodyMedium, - ) - }, - ) - } + .fillMaxWidth() + ) { + ListItem( + modifier = Modifier.clickable { action() }, + colors = MaterialTheme.colorScheme.warningListItem, + headlineContent = { + Text( + netmap.SelfNode.expiryLabel(), + style = MaterialTheme.typography.titleMedium, + ) + }, + supportingContent = { + Text( + stringResource(id = R.string.keyExpiryExplainer), + style = MaterialTheme.typography.bodyMedium, + ) + }, + ) + } } } @@ -992,47 +1000,45 @@ fun Search( var isNavigating by remember { mutableStateOf(false) } Box( modifier = - Modifier.fillMaxWidth() - .background(MaterialTheme.colorScheme.surface) - .padding(top = 8.dp)) { - Box( - modifier = - Modifier.fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, top = 16.dp) - .height(56.dp) - .clip(MaterialTheme.shapes.extraLarge) // Rounded corners for search bar - .background(backgroundColor) // Search bar background - .clickable(enabled = !isNavigating) { // Intercept taps - isNavigating = true - onSearchBarClick() - } - .padding(horizontal = 16.dp) // Internal padding - ) { - 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.fillMaxWidth().background(MaterialTheme.colorScheme.surface).padding(top = 8.dp) + ) { + Box( + modifier = + Modifier.fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 16.dp) + .height(56.dp) + .clip(MaterialTheme.shapes.extraLarge) // Rounded corners for search bar + .background(backgroundColor) // Search bar background + .clickable(enabled = !isNavigating) { // Intercept taps + isNavigating = true + onSearchBarClick() + } + .padding(horizontal = 16.dp) // Internal padding + ) { + 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 + ) } + } + } } @Preview 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 fc858e6dea..302f5a49a2 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 @@ -8,10 +8,8 @@ import androidx.lifecycle.viewModelScope import com.tailscale.ipn.UninitializedApp import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.ui.localapi.Client -import com.tailscale.ipn.ui.model.Favorites import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.IpnLocal -import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.model.UserID import com.tailscale.ipn.ui.model.deepCopy import com.tailscale.ipn.ui.notifier.Notifier @@ -37,12 +35,6 @@ open class IpnViewModel : ViewModel() { private val _vpnPrepared = MutableStateFlow(false) val vpnPrepared: StateFlow = _vpnPrepared - private val _favorites = MutableStateFlow(Favorites()) - val favorites: StateFlow = _favorites - - private val _isToggleFavoriteInProgress = MutableStateFlow(false) - val isToggleFavoriteInProgress: StateFlow = _isToggleFavoriteInProgress - // The userId associated with the current node. ie: The logged in user. private var selfNodeUserId: UserID? = null @@ -80,7 +72,6 @@ open class IpnViewModel : ViewModel() { // Reload the user profiles/favorites on all state transitions to ensure loggedInUser is // correct viewModelScope.launch { loadUserProfiles() } - viewModelScope.launch { loadUserFavorites() } } } @@ -91,9 +82,7 @@ open class IpnViewModel : ViewModel() { netmap?.SelfNode?.User.let { if (it != selfNodeUserId) { selfNodeUserId = it - _favorites.value = Favorites() viewModelScope.launch { loadUserProfiles() } - viewModelScope.launch { loadUserFavorites() } } } } @@ -110,8 +99,6 @@ open class IpnViewModel : ViewModel() { viewModelScope.launch { loadUserProfiles() } - viewModelScope.launch { loadUserFavorites() } - viewModelScope.launch { combine(prefs, netmap, isRunningExitNode) { prefs, netmap, isRunningExitNode -> // Handle nullability for prefs and netmap @@ -119,8 +106,9 @@ open class IpnViewModel : ViewModel() { val validNetmap = netmap ?: return@combine NodeState.NONE val chosenExitNodeId = validPrefs.activeExitNodeID ?: validPrefs.selectedExitNodeID - val exitNodePeer = - chosenExitNodeId?.let { id -> validNetmap.Peers?.find { it.StableID == id } } + val exitNodePeer = chosenExitNodeId?.let { id -> + validNetmap.Peers?.find { it.StableID == id } + } when { exitNodePeer?.Online == false -> { @@ -304,31 +292,6 @@ open class IpnViewModel : ViewModel() { } } - // Favorites - - private fun loadUserFavorites() { - Client(viewModelScope).getFavorites { result -> - result - .onSuccess { _favorites.value = it } - .onFailure { - _favorites.value = Favorites() - TSLog.e(TAG, "Error loading favorites: ${it.message}") - } - } - } - - fun toggleDeviceFavorite(peer: Tailcfg.Node) { - if (!_isToggleFavoriteInProgress.compareAndSet(expect = false, update = true)) return - - val toggled = favorites.value.withToggledDevice(peer.StableID) - Client(viewModelScope).setFavorites(toggled) { result -> - result - .onSuccess { _favorites.value = it } - .onFailure { TSLog.e(TAG, "Error toggling favorites: ${it.message}") } - _isToggleFavoriteInProgress.value = false - } - } - // Exit Node Manipulation fun toggleExitNode() { 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 c6dbbb9108..8aebcddbf6 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,6 +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.Favorites import com.tailscale.ipn.ui.model.Ipn.State import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.notifier.Notifier @@ -24,6 +25,7 @@ import com.tailscale.ipn.ui.util.PeerCategorizer import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.TimeUtil import com.tailscale.ipn.ui.util.set +import com.tailscale.ipn.ui.util.withPinnedSection import com.tailscale.ipn.util.TSLog import java.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -83,6 +85,10 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { // 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 @@ -130,7 +136,7 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { } fun togglePin(peer: Tailcfg.Node) { - toggleDeviceFavorite(peer) + favoritesManager.toggleDevice(peer.StableID) } fun startPing(peer: Tailcfg.Node) { @@ -187,13 +193,15 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { } viewModelScope.launch { - combine(Notifier.netmap.filterNotNull(), favorites) { netmap, favs -> netmap to favs } + combine(Notifier.netmap.filterNotNull(), favorites) { netmap, favs -> + netmap to favs + } .collectLatest { (netmap, favs) -> searchJob?.cancel() withContext(categorizerDispatcher) { - peerCategorizer.regenerateGroupedPeers(netmap, favs) + peerCategorizer.regenerateGroupedPeers(netmap) val filteredPeers = peerCategorizer.groupedAndFilteredPeers(searchTerm.value) - _peers.value = peerCategorizer.peerSets + _peers.value = peerCategorizer.peerSets.withPinnedSection(favs?.deviceIds.orEmpty()) _searchViewPeers.value = filteredPeers } } @@ -212,7 +220,8 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { TimeUtil.isWithinExpiryNotificationWindow( window, netmap.SelfNode.KeyExpiry ?: "", - )) + ) + ) } } } diff --git a/android/src/test/kotlin/com/tailcale/ipn/ui/PeerCategorizerTest.kt b/android/src/test/kotlin/com/tailcale/ipn/ui/PeerCategorizerTest.kt index c1b9d7ee61..75378effab 100644 --- a/android/src/test/kotlin/com/tailcale/ipn/ui/PeerCategorizerTest.kt +++ b/android/src/test/kotlin/com/tailcale/ipn/ui/PeerCategorizerTest.kt @@ -1,35 +1,21 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause -// DESTINATION: android/src/test/kotlin/com/tailcale/ipn/ui/util/PeerCategorizerTest.kt -// (yes, "tailcale" -- the existing unit-test package root is misspelled; see -// TimeUtilTest.kt and HealthNotifierTest.kt.) -// -// Run with: cd android && ./gradlew test --tests '*PeerCategorizerTest*' - package com.tailcale.ipn.ui import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.SettingState -import com.tailscale.ipn.ui.model.FavoriteItem -import com.tailscale.ipn.ui.model.Favorites 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.assertNull import org.junit.Assert.assertTrue import org.junit.Before -import org.junit.Ignore import org.junit.Test -// PeerHelper.kt keeps FAVORITES_ID private to the file, so it has to be -// duplicated here. Once it moves to PeerSet.PINNED_ID, delete this and use -// PeerSet.PINNED_ID directly. -private const val PINNED_ID = -1L - private const val ME_ID = 1L private const val BOB_ID = 2L private const val ZEKE_ID = 3L @@ -52,9 +38,6 @@ private fun node( private fun profile(id: Long, displayName: String, loginName: String) = Tailcfg.UserProfile(ID = id, DisplayName = displayName, LoginName = loginName) -private fun pins(vararg stableIds: String) = - Favorites(devices = stableIds.map { FavoriteItem(id = it) }) - class PeerCategorizerTest { private val self = node("self", "my-phone", ME_ID, "100.64.0.1/32") @@ -64,8 +47,7 @@ class PeerCategorizerTest { 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; Name carries a trailing - // dot here, so the suffix has to be on ComputedName to be detected. + // 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 = @@ -83,6 +65,10 @@ class PeerCategorizerTest { 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 @@ -91,8 +77,7 @@ class PeerCategorizerTest { clearHiddenDevices() } - // MDMSettings is a process-wide singleton, so leaving it set would leak into - // whichever test class runs next in the same JVM. + // MDMSettings is a process-wide singleton @After fun tearDown() { clearHiddenDevices() @@ -106,19 +91,20 @@ class PeerCategorizerTest { MDMSettings.hiddenNetworkDevices.flow.value = SettingState(null, false) } - private fun regenerate(favorites: Favorites = Favorites()) = - categorizer.regenerateGroupedPeers(netmap, favorites) + private fun regenerate(map: Netmap.NetworkMap = netmap) = categorizer.regenerateGroupedPeers(map) - private fun sectionIds() = categorizer.peerSets.map { it.id } + private fun sectionIds(sets: List = categorizer.peerSets) = sets.map { it.id } - private fun section(id: Long): PeerSet = categorizer.peerSets.first { it.id == 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 withoutPins_currentUserSectionSortsFirstThenAlphabetically() { + fun currentUserSectionSortsFirstThenAlphabetically() { regenerate() assertEquals(listOf(ME_ID, BOB_ID, TAGGED_ID, ZEKE_ID), sectionIds()) @@ -132,8 +118,7 @@ class PeerCategorizerTest { fun withinASection_selfNodeIsFirstThenCaseInsensitiveAlphabetical() { regenerate() - // "alpha" must sort before "Delta" -- a case-sensitive compare would put - // "Delta" first because 'D' < 'a' in ASCII. + // "alpha" must sort before "Delta" assertEquals(listOf("self", "a1", "d1"), stableIds(section(ME_ID))) } @@ -144,47 +129,12 @@ class PeerCategorizerTest { assertTrue(categorizer.peerSets.none { set -> set.nodes.any { it.StableID == "mv" } }) } - @Test - fun withoutPins_noPinnedSectionIsEmitted() { - regenerate(Favorites()) - assertTrue(sectionIds().none { it == PINNED_ID }) - - regenerate(Favorites(devices = emptyList())) - assertTrue(sectionIds().none { it == PINNED_ID }) - } - - @Test - fun pinnedSectionSortsFirstAndHasANullTitle() { - regenerate(pins("z1", "d1")) - - val pinned = categorizer.peerSets.first() - assertEquals(PINNED_ID, pinned.id) - assertNull("Pinned section resolves its title from a string resource", pinned.title) - assertEquals(listOf("d1", "z1"), stableIds(pinned)) // Delta, zulu - } - - @Test - fun pinnedNodesAreMovedOutOfTheirOwnerSection() { - // Documents the current "move, don't duplicate" behaviour. Confirm this - // matches darwin before locking it in. - regenerate(pins("z1", "d1")) - - assertEquals(listOf("self", "a1"), stableIds(section(ME_ID))) - // zeke owned only z1, so their section disappears completely. - assertTrue(sectionIds().none { it == ZEKE_ID }) - } - - @Test - fun selfNodeStillSortsFirstInsideThePinnedSection() { - regenerate(pins("self", "d1", "z1")) - - assertEquals(listOf("self", "d1", "z1"), stableIds(section(PINNED_ID))) - } - // ------------------------------------------------------------- MDM filters + // + // MDM filtering runs here, before withPinnedSection ever sees the nodes @Test - fun hideTaggedDevices_hidesUnpinnedTaggedDevices() { + fun hideTaggedDevices_hidesTaggedDevices() { hideDevices("tagged-devices") regenerate() @@ -193,43 +143,43 @@ class PeerCategorizerTest { @Test fun hideOtherDevices_keepsMyOwnPinnedDevices() { - // d1 is grouped under the pinned id, so a "userId != me.ID" check would - // hide it even though I own it. Filter on peer.User, group on the pinned id. hideDevices("other-users") - regenerate(pins("d1")) + regenerate() - assertEquals(listOf("d1"), stableIds(section(PINNED_ID))) + val sets = categorizer.peerSets.withPinnedSection(listOf("d1")) + + assertEquals(listOf("d1"), stableIds(section(PeerSet.FAVORITES_ID, sets))) } @Test fun hideMyDevices_alsoHidesMyPinnedDevices() { - // A "userId == me.ID" check can never match a pinned node, so d1 would - // leak into the Pinned section. hideDevices("current-user") - regenerate(pins("d1")) + regenerate() - assertTrue(categorizer.peerSets.none { set -> set.nodes.any { it.StableID == "d1" } }) + val sets = categorizer.peerSets.withPinnedSection(listOf("d1")) + + assertTrue(sets.none { set -> set.nodes.any { it.StableID == "d1" } }) } @Test fun hideTaggedDevices_alsoHidesPinnedTaggedDevices() { - // userProfile(pinnedId) is null, so isTaggedDevice() would never be - // consulted for a pinned node. hideDevices("tagged-devices") - regenerate(pins("t1")) + regenerate() - assertTrue(categorizer.peerSets.none { set -> set.nodes.any { it.StableID == "t1" } }) + val sets = categorizer.peerSets.withPinnedSection(listOf("t1")) + + assertTrue(sets.none { set -> set.nodes.any { it.StableID == "t1" } }) } // ----------------------------------------------------------------- search @Test - fun searchMatchesNodeNameInsideThePinnedSection() { - regenerate(pins("z1")) + fun searchMatchesNodeName() { + regenerate() val result = categorizer.groupedAndFilteredPeers("zulu") - assertEquals(listOf(PINNED_ID), result.map { it.id }) + assertEquals(listOf(ZEKE_ID), result.map { it.id }) assertEquals(listOf("z1"), stableIds(result.single())) } @@ -256,7 +206,7 @@ class PeerCategorizerTest { fun incrementalSearchNarrowsThePreviousResult() { regenerate() - // "ta" matches the "tagged-devices" section title, so the whole section + // "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 }) @@ -268,29 +218,11 @@ class PeerCategorizerTest { @Test fun regeneratingInvalidatesTheSearchCache() { - // FAILS until regenerateGroupedPeers() resets lastSearchTerm / - // lastSearchResult. MainViewModel.categorize() calls - // groupedAndFilteredPeers(searchTerm.value) right after regenerating, and - // it short-circuits on the unchanged term and returns the pre-pin result. regenerate() assertEquals(listOf(ZEKE_ID), categorizer.groupedAndFilteredPeers("zulu").map { it.id }) - regenerate(pins("z1")) - val result = categorizer.groupedAndFilteredPeers("zulu") - - assertEquals(listOf(PINNED_ID), result.map { it.id }) - } - - @Ignore("Design decision: should a user-name search still reach that user's pinned devices?") - @Test - fun searchingAUserNameStillFindsTheirPinnedDevices() { - // The pinned PeerSet has a null title, so a user-name search can't reach - // it. Because bob's only other node is a Mullvad node, searching "bob" - // returns nothing at all once b1 is pinned. - regenerate(pins("b1")) - - val result = categorizer.groupedAndFilteredPeers("bob") + regenerate(netmapWithoutZulu) - assertEquals(listOf("b1"), result.flatMap { stableIds(it) }) + assertTrue(categorizer.groupedAndFilteredPeers("zulu").isEmpty()) } } diff --git a/android/src/test/kotlin/com/tailcale/ipn/ui/PinnedSectionTest.kt b/android/src/test/kotlin/com/tailcale/ipn/ui/PinnedSectionTest.kt new file mode 100644 index 0000000000..73e0e81c87 --- /dev/null +++ b/android/src/test/kotlin/com/tailcale/ipn/ui/PinnedSectionTest.kt @@ -0,0 +1,105 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// Package root is "tailcale", matching the existing unit tests (TimeUtilTest, HealthNotifierTest). +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())) + } +} 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 index f40d03dd0d..f4bbccbbed 100644 --- a/android/src/test/kotlin/com/tailcale/ipn/ui/util/FavoritesTest.kt +++ b/android/src/test/kotlin/com/tailcale/ipn/ui/util/FavoritesTest.kt @@ -108,13 +108,12 @@ class FavoritesTest { @Test fun parsesAGetPinsResponse() { // Client decodes with ignoreUnknownKeys = true; mirror that here. - val json = - """{"Devices":[{"ID":"nodeA","Name":"laptop"}],"ExitNodes":null,"Services":[],"Unknown":1}""" + val json = """{"Devices":[{"ID":"nodeA"}],"ExitNodes":null,"Services":[],"Unknown":1}""" val favorites = Json { ignoreUnknownKeys = true }.decodeFromString(json) assertTrue(favorites.isFavoriteDevice("nodeA")) - assertEquals("laptop", favorites.devices?.single()?.name) + assertEquals("nodeA", favorites.devices?.single()?.id) assertNull(favorites.exitNodes) assertEquals(emptyList(), favorites.services) } From b2efd72ed5b6aed1d6f6cf799d6569f435494d3b Mon Sep 17 00:00:00 2001 From: hiimtmac Date: Fri, 4 Sep 2026 10:24:22 -0500 Subject: [PATCH 08/14] add detail view pinning --- .../src/main/java/com/tailscale/ipn/App.kt | 12 +-- .../ipn/ui/notifier/FavoritesManager.kt | 57 ++++++------ .../java/com/tailscale/ipn/ui/util/Lists.kt | 90 ++++++++++--------- .../com/tailscale/ipn/ui/util/PeerHelper.kt | 33 +++---- .../com/tailscale/ipn/ui/view/MainView.kt | 12 +++ .../com/tailscale/ipn/ui/view/PeerDetails.kt | 41 +++++++-- .../ipn/ui/viewModel/IpnViewModel.kt | 5 +- .../ipn/ui/viewModel/MainViewModel.kt | 7 +- .../ipn/ui/viewModel/PeerDetailsViewModel.kt | 14 ++- .../tailcale/ipn/ui/PeerCategorizerTest.kt | 5 +- .../com/tailcale/ipn/ui/util/FavoritesTest.kt | 3 +- 11 files changed, 164 insertions(+), 115 deletions(-) diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index 6befa43c04..5cf7c418e5 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -381,8 +381,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { pointToPoint = nif.isPointToPoint, multicast = nif.supportsMulticast(), addrs = addrs, - ) - ) + )) } catch (_: Exception) { continue } @@ -671,10 +670,8 @@ open class UninitializedApp : Application() { } fun notifyStatus(notification: Notification) { - if ( - ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != - PackageManager.PERMISSION_GRANTED - ) { + if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED) { // TODO: Consider calling // ActivityCompat#requestPermissions // here to request the missing permissions, and then overriding @@ -733,8 +730,7 @@ open class UninitializedApp : Application() { .setContentIntent(pendingIntent) if (!vpnRunning || !hideDisconnectAction) { builder.addAction( - NotificationCompat.Action.Builder(0, actionLabel, pendingButtonIntent).build() - ) + NotificationCompat.Action.Builder(0, actionLabel, pendingButtonIntent).build()) } return builder.build() } 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 index b0c8543991..6f8446c18a 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/notifier/FavoritesManager.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/notifier/FavoritesManager.kt @@ -20,13 +20,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -class FavoritesManager -constructor( +class FavoritesManager( ipnStateFlow: StateFlow, netmapFlow: StateFlow, private val scope: CoroutineScope, @@ -47,40 +45,43 @@ constructor( 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 { - userFlow.distinctUntilChanged().collect { user -> - withContext(dispatcher) { - currentUser = user - pendingWrite?.cancel() - revert = null - _writing.value = false - _favorites.value = null - } - } - } - - scope.launch { - combine(ipnStateFlow, userFlow) { state, user -> user.takeIf { state == Ipn.State.Running } } - .filterNotNull() + combine(ipnStateFlow, userFlow) { state, user -> state to user } .distinctUntilChanged() - .collect { load() } + .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() { - scope.launch(dispatcher) { - val user = currentUser - 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}") } - } + private fun load(user: UserID) { + 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 + } } } } 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..031673d5bd 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,30 +52,29 @@ 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) { - 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) - } - } + Modifier.fillMaxWidth().background(color = backgroundColor, shape = RectangleShape) + ) { + 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, + style = style, + fontWeight = fontWeight, + color = fontColor ?: Color.Unspecified, + ) + } + } } @Composable @@ -78,13 +82,15 @@ object Lists { Box( modifier = Modifier.fillMaxWidth() - .background(color = MaterialTheme.colorScheme.surface, shape = RectangleShape)) { - Text( - modifier = Modifier.padding(start = 16.dp, top = 16.dp), - text = text, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - } + .background(color = MaterialTheme.colorScheme.surface, shape = RectangleShape) + ) { + Text( + modifier = Modifier.padding(start = 16.dp, top = 16.dp), + text = text, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } @Composable @@ -98,10 +104,12 @@ object Lists { Text( text = text as AnnotatedString, style = style, - modifier = Modifier.clickable { onClick() }) + modifier = Modifier.clickable { onClick() }, + ) } ?: run { Text(text as String, style = style) } } - }) + } + ) } @Composable @@ -109,7 +117,8 @@ object Lists { ListItem( headlineContent = { Box(modifier = Modifier.padding(vertical = 8.dp)) { headlineContent() } - }) + } + ) } } @@ -121,22 +130,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 6f131084b7..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 @@ -135,21 +135,24 @@ class PeerCategorizer { else peerSets this.lastSearchTerm = searchTerm - val matchingSets = 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 - } - - if (matchingPeers.isNotEmpty()) PeerSet(peerSet.id, peerSet.title, matchingPeers) else null - } + val matchingSets = + 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 + } + + 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 9b4136ac7e..71ab87b632 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 @@ -2,6 +2,7 @@ // SPDX-License-Identifier: BSD-3-Clause package com.tailscale.ipn.ui.view +import android.graphics.drawable.Icon import android.os.Build import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background @@ -932,6 +933,17 @@ fun NodesSectionHeader(peerSet: PeerSet) { focusable = isAndroidTV(), style = MaterialTheme.typography.titleLarge, 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, ) } 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..d45d5a4586 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,8 @@ 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 +68,50 @@ 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 +163,8 @@ fun AddressRow(address: String, type: String) { if (!isAndroidTV()) { Icon(painter = painterResource(id = R.drawable.clipboard), null) } - }) + }, + ) } @Composable @@ -150,5 +172,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/viewModel/IpnViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt index 302f5a49a2..633b128997 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 @@ -106,9 +106,8 @@ open class IpnViewModel : ViewModel() { val validNetmap = netmap ?: return@combine NodeState.NONE val chosenExitNodeId = validPrefs.activeExitNodeID ?: validPrefs.selectedExitNodeID - val exitNodePeer = chosenExitNodeId?.let { id -> - validNetmap.Peers?.find { it.StableID == id } - } + val exitNodePeer = + chosenExitNodeId?.let { id -> validNetmap.Peers?.find { it.StableID == id } } when { exitNodePeer?.Online == false -> { 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 8aebcddbf6..bf2efe46d4 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 @@ -193,9 +193,7 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { } viewModelScope.launch { - combine(Notifier.netmap.filterNotNull(), favorites) { netmap, favs -> - netmap to favs - } + combine(Notifier.netmap.filterNotNull(), favorites) { netmap, favs -> netmap to favs } .collectLatest { (netmap, favs) -> searchJob?.cancel() withContext(categorizerDispatcher) { @@ -220,8 +218,7 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { TimeUtil.isWithinExpiryNotificationWindow( window, netmap.SelfNode.KeyExpiry ?: "", - ) - ) + )) } } } 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/test/kotlin/com/tailcale/ipn/ui/PeerCategorizerTest.kt b/android/src/test/kotlin/com/tailcale/ipn/ui/PeerCategorizerTest.kt index 75378effab..98c2a9c73e 100644 --- a/android/src/test/kotlin/com/tailcale/ipn/ui/PeerCategorizerTest.kt +++ b/android/src/test/kotlin/com/tailcale/ipn/ui/PeerCategorizerTest.kt @@ -95,9 +95,8 @@ class PeerCategorizerTest { 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 section(id: Long, sets: List = categorizer.peerSets) = + sets.first { it.id == id } private fun stableIds(peerSet: PeerSet) = peerSet.nodes.map { it.StableID } 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 index f4bbccbbed..00191ef391 100644 --- a/android/src/test/kotlin/com/tailcale/ipn/ui/util/FavoritesTest.kt +++ b/android/src/test/kotlin/com/tailcale/ipn/ui/util/FavoritesTest.kt @@ -38,8 +38,7 @@ class FavoritesTest { FavoriteItem(id = "n1"), FavoriteItem(name = "no id"), FavoriteItem(id = "n2"), - ) - ) + )) // .toSet() so this holds whether deviceIds stays a List or becomes a Set. assertEquals(setOf("n1", "n2"), favorites.deviceIds.toSet()) From 23824f59a3d38a2cc441b60ea80ff66578a528eb Mon Sep 17 00:00:00 2001 From: hiimtmac Date: Fri, 4 Sep 2026 10:27:54 -0500 Subject: [PATCH 09/14] fmt --- .../java/com/tailscale/ipn/ui/util/Lists.kt | 58 +- .../com/tailscale/ipn/ui/view/MainView.kt | 558 +++++++++--------- .../com/tailscale/ipn/ui/view/PeerDetails.kt | 9 +- 3 files changed, 305 insertions(+), 320 deletions(-) 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 031673d5bd..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 @@ -57,24 +57,23 @@ object Lists { ) { Box( modifier = - Modifier.fillMaxWidth().background(color = backgroundColor, shape = RectangleShape) - ) { - 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, - style = style, - fontWeight = fontWeight, - color = fontColor ?: Color.Unspecified, - ) - } - } + Modifier.fillMaxWidth().background(color = backgroundColor, shape = RectangleShape)) { + 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, + style = style, + fontWeight = fontWeight, + color = fontColor ?: Color.Unspecified, + ) + } + } } @Composable @@ -82,15 +81,14 @@ object Lists { Box( modifier = Modifier.fillMaxWidth() - .background(color = MaterialTheme.colorScheme.surface, shape = RectangleShape) - ) { - Text( - modifier = Modifier.padding(start = 16.dp, top = 16.dp), - text = text, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + .background(color = MaterialTheme.colorScheme.surface, shape = RectangleShape)) { + Text( + modifier = Modifier.padding(start = 16.dp, top = 16.dp), + text = text, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } @Composable @@ -108,8 +106,7 @@ object Lists { ) } ?: run { Text(text as String, style = style) } } - } - ) + }) } @Composable @@ -117,8 +114,7 @@ object Lists { ListItem( headlineContent = { Box(modifier = Modifier.padding(vertical = 8.dp)) { headlineContent() } - } - ) + }) } } 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 71ab87b632..d9f75c4086 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 @@ -273,10 +273,9 @@ fun MainView( ModalBottomSheet( onDismissRequest = { viewModel.pendingTaildrop.isPresentingPendingItemsList.value = false + }) { + InlineShareListSheet(viewModel = viewModel.pendingTaildrop) } - ) { - InlineShareListSheet(viewModel = viewModel.pendingTaildrop) - } } } } @@ -334,118 +333,116 @@ private fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { Box( modifier = - Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surfaceContainer) - ) { - if (nodeState == NodeState.OFFLINE_MDM) { - Box( - modifier = - Modifier.padding(start = 16.dp, end = 16.dp, top = 56.dp, bottom = 16.dp) - .clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp)) - .background(MaterialTheme.colorScheme.customErrorContainer) - .fillMaxWidth() - .align(Alignment.TopCenter) - ) { - Column( - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 36.dp, bottom = 16.dp) - ) { - Text( - text = - managedByOrganization.value?.let { - stringResource(R.string.exit_node_offline_mdm_orgname, it) - } ?: stringResource(R.string.exit_node_offline_mdm), - style = MaterialTheme.typography.bodyMedium, - color = Color.White, - ) + Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surfaceContainer)) { + if (nodeState == NodeState.OFFLINE_MDM) { + Box( + modifier = + Modifier.padding(start = 16.dp, end = 16.dp, top = 56.dp, bottom = 16.dp) + .clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp)) + .background(MaterialTheme.colorScheme.customErrorContainer) + .fillMaxWidth() + .align(Alignment.TopCenter)) { + Column( + modifier = + Modifier.padding(start = 16.dp, end = 16.dp, top = 36.dp, bottom = 16.dp)) { + Text( + text = + managedByOrganization.value?.let { + stringResource(R.string.exit_node_offline_mdm_orgname, it) + } ?: stringResource(R.string.exit_node_offline_mdm), + style = MaterialTheme.typography.bodyMedium, + color = Color.White, + ) + } + } } - } - } - Box( - modifier = - Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp) - .clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp)) - .fillMaxWidth() - ) { - ListItem( - modifier = Modifier.clickable { navAction() }, - colors = - when (nodeState) { - NodeState.ACTIVE_AND_RUNNING -> MaterialTheme.colorScheme.primaryListItem - NodeState.ACTIVE_NOT_RUNNING -> MaterialTheme.colorScheme.listItem - NodeState.RUNNING_AS_EXIT_NODE -> MaterialTheme.colorScheme.warningListItem - NodeState.OFFLINE_ENABLED -> MaterialTheme.colorScheme.errorListItem - NodeState.OFFLINE_DISABLED -> MaterialTheme.colorScheme.errorListItem - NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.errorListItem - else -> ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surface) - }, - overlineContent = { - Text( - text = - if ( - nodeState == NodeState.OFFLINE_ENABLED || - nodeState == NodeState.OFFLINE_DISABLED || - nodeState == NodeState.OFFLINE_MDM - ) - stringResource(R.string.exit_node_offline) - else stringResource(R.string.exit_node), - style = MaterialTheme.typography.bodySmall, - ) - }, - headlineContent = { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = - when (nodeState) { - NodeState.NONE -> stringResource(id = R.string.none) - NodeState.RUNNING_AS_EXIT_NODE -> - stringResource(id = R.string.running_exit_node) - else -> name ?: "" - }, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Icon( - imageVector = Icons.Outlined.ArrowDropDown, - contentDescription = null, - tint = - if (nodeState == NodeState.NONE) MaterialTheme.colorScheme.onSurfaceVariant - else MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), - ) - } - }, - trailingContent = { - if (nodeState != NodeState.NONE) { - Button( + Box( + modifier = + Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp) + .clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp)) + .fillMaxWidth()) { + ListItem( + modifier = Modifier.clickable { navAction() }, colors = when (nodeState) { - NodeState.OFFLINE_ENABLED -> MaterialTheme.colorScheme.errorButton - NodeState.OFFLINE_DISABLED -> MaterialTheme.colorScheme.errorButton - NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.errorButton - NodeState.RUNNING_AS_EXIT_NODE -> MaterialTheme.colorScheme.warningButton - NodeState.ACTIVE_NOT_RUNNING -> - MaterialTheme.colorScheme.exitNodeToggleButton - else -> MaterialTheme.colorScheme.secondaryButton + NodeState.ACTIVE_AND_RUNNING -> MaterialTheme.colorScheme.primaryListItem + NodeState.ACTIVE_NOT_RUNNING -> MaterialTheme.colorScheme.listItem + NodeState.RUNNING_AS_EXIT_NODE -> MaterialTheme.colorScheme.warningListItem + NodeState.OFFLINE_ENABLED -> MaterialTheme.colorScheme.errorListItem + NodeState.OFFLINE_DISABLED -> MaterialTheme.colorScheme.errorListItem + NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.errorListItem + else -> + ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surface) }, - onClick = { - if (nodeState == NodeState.RUNNING_AS_EXIT_NODE) - viewModel.setRunningExitNode(false) - else viewModel.toggleExitNode() + overlineContent = { + Text( + text = + if (nodeState == NodeState.OFFLINE_ENABLED || + nodeState == NodeState.OFFLINE_DISABLED || + nodeState == NodeState.OFFLINE_MDM) + stringResource(R.string.exit_node_offline) + else stringResource(R.string.exit_node), + style = MaterialTheme.typography.bodySmall, + ) }, - ) { - 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) + headlineContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = + when (nodeState) { + NodeState.NONE -> stringResource(id = R.string.none) + NodeState.RUNNING_AS_EXIT_NODE -> + stringResource(id = R.string.running_exit_node) + else -> name ?: "" + }, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Icon( + imageVector = Icons.Outlined.ArrowDropDown, + contentDescription = null, + tint = + if (nodeState == NodeState.NONE) + MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), + ) } - ) - } + }, + trailingContent = { + if (nodeState != NodeState.NONE) { + Button( + colors = + when (nodeState) { + NodeState.OFFLINE_ENABLED -> MaterialTheme.colorScheme.errorButton + NodeState.OFFLINE_DISABLED -> MaterialTheme.colorScheme.errorButton + NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.errorButton + NodeState.RUNNING_AS_EXIT_NODE -> + MaterialTheme.colorScheme.warningButton + NodeState.ACTIVE_NOT_RUNNING -> + MaterialTheme.colorScheme.exitNodeToggleButton + else -> MaterialTheme.colorScheme.secondaryButton + }, + onClick = { + 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) + }) + } + } + }, + ) } - }, - ) - } - } + } } @Composable @@ -693,48 +690,47 @@ fun PeerList( } else { if (!isAndroidTV()) { Box( - modifier = Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surface) - ) { - OutlinedTextField( - modifier = - Modifier.fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 0.dp) - .onFocusChanged { isSearchFocussed = it.isFocused }, - singleLine = true, - shape = MaterialTheme.shapes.extraLarge, - colors = MaterialTheme.colorScheme.searchBarColors, - leadingIcon = { - Icon(imageVector = Icons.Outlined.Search, contentDescription = "search") - }, - trailingIcon = { - if (isSearchFocussed) { - IconButton( - onClick = { - focusManager.clearFocus() - onSearch("") - } - ) { - Icon( - imageVector = - if (searchTermStr.isEmpty()) Icons.Outlined.Close - else Icons.Outlined.Clear, - contentDescription = "clear search", - tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surface)) { + OutlinedTextField( + modifier = + Modifier.fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 0.dp) + .onFocusChanged { isSearchFocussed = it.isFocused }, + singleLine = true, + shape = MaterialTheme.shapes.extraLarge, + colors = MaterialTheme.colorScheme.searchBarColors, + leadingIcon = { + Icon(imageVector = Icons.Outlined.Search, contentDescription = "search") + }, + trailingIcon = { + if (isSearchFocussed) { + IconButton( + onClick = { + focusManager.clearFocus() + onSearch("") + }) { + Icon( + imageVector = + if (searchTermStr.isEmpty()) Icons.Outlined.Close + else Icons.Outlined.Clear, + contentDescription = "clear search", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + placeholder = { + Text( + text = stringResource(id = R.string.search), + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, ) - } - } - }, - placeholder = { - Text( - text = stringResource(id = R.string.search), - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - ) - }, - value = searchTermStr, - onValueChange = { onSearch(it) }, - ) - } + }, + value = searchTermStr, + onValueChange = { onSearch(it) }, + ) + } } } @@ -745,84 +741,80 @@ fun PeerList( .weight(1f) // LazyColumn gets the remaining vertical space .onFocusChanged { isListFocussed = it.isFocused } .background(color = MaterialTheme.colorScheme.surface) - .windowInsetsPadding(WindowInsets.navigationBars) - ) { - // Handle case when no results are found - if (showNoResults) { - item { - Spacer( - Modifier.height(16.dp) - .fillMaxSize() - .focusable(false) - .background(color = MaterialTheme.colorScheme.surface) - ) - Lists.LargeTitle( - stringResource(id = R.string.no_results), - bottomPadding = 8.dp, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Light, - ) - } - } + .windowInsetsPadding(WindowInsets.navigationBars)) { + // Handle case when no results are found + if (showNoResults) { + item { + Spacer( + Modifier.height(16.dp) + .fillMaxSize() + .focusable(false) + .background(color = MaterialTheme.colorScheme.surface)) + Lists.LargeTitle( + stringResource(id = R.string.no_results), + bottomPadding = 8.dp, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Light, + ) + } + } - // Iterate over peer sets to display them - peerList.forEachIndexed { idx, peerSet -> - if (idx != 0) { - item(key = "user_divider_${peerSet.id}") { Lists.ItemDivider() } - } - if (isAndroidTV()) { - item { NodesSectionHeader(peerSet = peerSet) } - } else { - stickyHeader { NodesSectionHeader(peerSet = peerSet) } - } - itemsWithDividers(peerSet.nodes, key = { it.StableID }) { peer -> - ListItem( - modifier = - Modifier.combinedClickable( - onClick = { onNavigateToPeerDetails(peer) }, - onLongClick = { viewModel.expandedMenuPeer.set(peer) }, - ), - colors = MaterialTheme.colorScheme.listItem, - headlineContent = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Box( - modifier = - Modifier.padding(top = 2.dp) - .size(10.dp) - .background( - 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, + // Iterate over peer sets to display them + peerList.forEachIndexed { idx, peerSet -> + if (idx != 0) { + item(key = "user_divider_${peerSet.id}") { Lists.ItemDivider() } + } + if (isAndroidTV()) { + item { NodesSectionHeader(peerSet = peerSet) } + } else { + stickyHeader { NodesSectionHeader(peerSet = peerSet) } + } + itemsWithDividers(peerSet.nodes, key = { it.StableID }) { peer -> + ListItem( + modifier = + Modifier.combinedClickable( + onClick = { onNavigateToPeerDetails(peer) }, + onLongClick = { viewModel.expandedMenuPeer.set(peer) }, + ), + colors = MaterialTheme.colorScheme.listItem, + headlineContent = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = + Modifier.padding(top = 2.dp) + .size(10.dp) + .background( + 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 = { + Text( + text = peer.Addresses?.first()?.split("/")?.first() ?: "", + style = + MaterialTheme.typography.bodyMedium.copy( + lineHeight = MaterialTheme.typography.titleMedium.lineHeight), ) - } - } - }, - supportingContent = { - Text( - text = peer.Addresses?.first()?.split("/")?.first() ?: "", - style = - MaterialTheme.typography.bodyMedium.copy( - lineHeight = MaterialTheme.typography.titleMedium.lineHeight - ), - ) - }, - ) + }, + ) + } + } } - } - } } } @@ -908,8 +900,7 @@ fun DeviceDropdownMenu( }, text = { Text( - text = stringResource(if (isFavorite) R.string.unpin_device else R.string.pin_device) - ) + text = stringResource(if (isFavorite) R.string.unpin_device else R.string.pin_device)) }, onClick = { viewModel.togglePin(peer) @@ -963,25 +954,24 @@ fun ExpiryNotification(netmap: Netmap.NetworkMap, action: () -> Unit = {}) { modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp) .clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp)) - .fillMaxWidth() - ) { - ListItem( - modifier = Modifier.clickable { action() }, - colors = MaterialTheme.colorScheme.warningListItem, - headlineContent = { - Text( - netmap.SelfNode.expiryLabel(), - style = MaterialTheme.typography.titleMedium, - ) - }, - supportingContent = { - Text( - stringResource(id = R.string.keyExpiryExplainer), - style = MaterialTheme.typography.bodyMedium, - ) - }, - ) - } + .fillMaxWidth()) { + ListItem( + modifier = Modifier.clickable { action() }, + colors = MaterialTheme.colorScheme.warningListItem, + headlineContent = { + Text( + netmap.SelfNode.expiryLabel(), + style = MaterialTheme.typography.titleMedium, + ) + }, + supportingContent = { + Text( + stringResource(id = R.string.keyExpiryExplainer), + style = MaterialTheme.typography.bodyMedium, + ) + }, + ) + } } } @@ -1012,45 +1002,47 @@ fun Search( var isNavigating by remember { mutableStateOf(false) } Box( modifier = - Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface).padding(top = 8.dp) - ) { - Box( - modifier = - Modifier.fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, top = 16.dp) - .height(56.dp) - .clip(MaterialTheme.shapes.extraLarge) // Rounded corners for search bar - .background(backgroundColor) // Search bar background - .clickable(enabled = !isNavigating) { // Intercept taps - isNavigating = true - onSearchBarClick() - } - .padding(horizontal = 16.dp) // Internal padding - ) { - 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.fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(top = 8.dp)) { + Box( + modifier = + Modifier.fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 16.dp) + .height(56.dp) + .clip(MaterialTheme.shapes.extraLarge) // Rounded corners for search bar + .background(backgroundColor) // Search bar background + .clickable(enabled = !isNavigating) { // Intercept taps + isNavigating = true + onSearchBarClick() + } + .padding(horizontal = 16.dp) // Internal padding + ) { + 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 + ) + } + } } - } - } } @Preview 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 d45d5a4586..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,8 +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() @@ -77,8 +76,7 @@ fun PeerDetails( .background( color = node.connectedColor(netmap), shape = RoundedCornerShape(percent = 50), - ) - ) {} + )) {} Spacer(modifier = Modifier.size(8.dp)) Text( text = stringResource(id = node.connectedStrRes(netmap)), @@ -105,8 +103,7 @@ fun PeerDetails( 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 - ), + if (isPinned) R.string.unpin_device else R.string.pin_device), ) } }, From 34588b5366794e32ffad46bedaf70ab6091d658e Mon Sep 17 00:00:00 2001 From: hiimtmac Date: Fri, 4 Sep 2026 10:53:37 -0500 Subject: [PATCH 10/14] dont regroup all the time --- .../ipn/ui/notifier/FavoritesManager.kt | 20 +++++++++---- .../com/tailscale/ipn/ui/view/MainView.kt | 1 - .../com/tailscale/ipn/ui/view/SearchView.kt | 21 -------------- .../ipn/ui/viewModel/IpnViewModel.kt | 4 +-- .../ipn/ui/viewModel/MainViewModel.kt | 28 +++++++++++++------ android/src/main/res/drawable/timer.xml | 5 ---- .../com/tailcale/ipn/ui/util/FavoritesTest.kt | 4 --- .../ipn/ui/{ => util}/PinnedSectionTest.kt | 0 8 files changed, 36 insertions(+), 47 deletions(-) delete mode 100644 android/src/main/res/drawable/timer.xml rename android/src/test/kotlin/com/tailcale/ipn/ui/{ => util}/PinnedSectionTest.kt (100%) 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 index 6f8446c18a..479d162af7 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/notifier/FavoritesManager.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/notifier/FavoritesManager.kt @@ -10,6 +10,7 @@ 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 @@ -31,6 +32,7 @@ class FavoritesManager( @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" @@ -81,6 +83,13 @@ class FavoritesManager( .onFailure { TSLog.e(TAG, "Error loading favorites: ${it.message}") loadedForUser = null + scope.launch(dispatcher) { + delay(retryDelay) + if (currentUser == user && loadedForUser == null) { + loadedForUser = user + load(user) + } + } } } } @@ -95,12 +104,11 @@ class FavoritesManager( scope.launch(dispatcher) { if (currentUser != user) return@launch _writing.value = false - result - .onSuccess { _favorites.value = it } - .onFailure { - TSLog.e(TAG, "Error writing favorites: ${it.message}") - _favorites.value = snapshot - } + if (revert != null) return@launch // newer burst opened while inflight + result.onFailure { + TSLog.e(TAG, "Error writing favorites: ${it.message}") + _favorites.value = snapshot + } } } } 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 d9f75c4086..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 @@ -2,7 +2,6 @@ // SPDX-License-Identifier: BSD-3-Clause package com.tailscale.ipn.ui.view -import android.graphics.drawable.Icon import android.os.Build import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background 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 08aa537167..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 @@ -53,19 +53,14 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.navigation.NavController -import androidx.navigation.compose.rememberNavController -import com.tailscale.ipn.App 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.AppViewModel import com.tailscale.ipn.ui.viewModel.MainViewModel import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.emptyFlow @RequiresApi(Build.VERSION_CODES.TIRAMISU) @OptIn(ExperimentalMaterial3Api::class) @@ -254,19 +249,3 @@ fun SearchView( } } } - -@RequiresApi(Build.VERSION_CODES.TIRAMISU) -@Preview(showSystemUi = true) -@Composable -private fun SearchViewPreview() { - val fakePrompt = emptyFlow() - val appViewModel = AppViewModel(App.get(), fakePrompt) - val vm = MainViewModel(appViewModel) - - SearchView( - vm, - rememberNavController(), - {}, - true, - ) -} 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 633b128997..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 @@ -69,13 +69,13 @@ open class IpnViewModel : ViewModel() { init { viewModelScope.launch { Notifier.state.collect { - // Reload the user profiles/favorites on all state transitions to ensure loggedInUser is + // Reload the user profiles on all state transitions to ensure loggedInUser is // correct viewModelScope.launch { loadUserProfiles() } } } - // This will observe the userId of the current node and reload our user profiles/favorites if + // 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 { netmap -> 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 bf2efe46d4..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 @@ -78,6 +78,8 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { 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 @@ -192,20 +194,30 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { } } + // handle grouping + viewModelScope.launch { + 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(Notifier.netmap.filterNotNull(), favorites) { netmap, favs -> netmap to favs } - .collectLatest { (netmap, favs) -> - searchJob?.cancel() + combine(_groupedPeers, favorites) { sets, favs -> sets to favs } + .collectLatest { (sets, favs) -> withContext(categorizerDispatcher) { - peerCategorizer.regenerateGroupedPeers(netmap) - val filteredPeers = peerCategorizer.groupedAndFilteredPeers(searchTerm.value) - _peers.value = peerCategorizer.peerSets.withPinnedSection(favs?.deviceIds.orEmpty()) - _searchViewPeers.value = filteredPeers + _peers.value = sets.withPinnedSection(favs?.deviceIds.orEmpty()) } } } - // Key expiry on depends on netmap + // Key expiry viewModelScope.launch { Notifier.netmap.filterNotNull().collect { netmap -> if (netmap.SelfNode.keyDoesNotExpire) { 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/test/kotlin/com/tailcale/ipn/ui/util/FavoritesTest.kt b/android/src/test/kotlin/com/tailcale/ipn/ui/util/FavoritesTest.kt index 00191ef391..63f338717a 100644 --- a/android/src/test/kotlin/com/tailcale/ipn/ui/util/FavoritesTest.kt +++ b/android/src/test/kotlin/com/tailcale/ipn/ui/util/FavoritesTest.kt @@ -1,10 +1,6 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause -// DESTINATION: android/src/test/kotlin/com/tailcale/ipn/ui/model/FavoritesTest.kt -// -// Run with: cd android && ./gradlew test --tests '*FavoritesTest*' - package com.tailcale.ipn.ui.util import com.tailscale.ipn.ui.model.FavoriteItem diff --git a/android/src/test/kotlin/com/tailcale/ipn/ui/PinnedSectionTest.kt b/android/src/test/kotlin/com/tailcale/ipn/ui/util/PinnedSectionTest.kt similarity index 100% rename from android/src/test/kotlin/com/tailcale/ipn/ui/PinnedSectionTest.kt rename to android/src/test/kotlin/com/tailcale/ipn/ui/util/PinnedSectionTest.kt From b0be3018a73456e60218a48e8ce612bbcea5366e Mon Sep 17 00:00:00 2001 From: hiimtmac Date: Fri, 4 Sep 2026 11:00:39 -0500 Subject: [PATCH 11/14] remove unbounded retry --- .../java/com/tailscale/ipn/ui/notifier/FavoritesManager.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 index 479d162af7..7be1d3435c 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/notifier/FavoritesManager.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/notifier/FavoritesManager.kt @@ -74,7 +74,7 @@ class FavoritesManager( } } - private fun load(user: UserID) { + private fun load(user: UserID, isRetry: Boolean = false) { client.getFavorites { result -> scope.launch(dispatcher) { if (currentUser != user) return@launch @@ -83,11 +83,12 @@ class FavoritesManager( .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) + load(user, isRetry = true) } } } From 64432fc85b9b38faf2c961a6d089d0bb53115dc8 Mon Sep 17 00:00:00 2001 From: hiimtmac Date: Fri, 4 Sep 2026 11:12:10 -0500 Subject: [PATCH 12/14] add license headers --- android/src/main/java/com/tailscale/ipn/ui/model/Favorites.kt | 3 +++ .../java/com/tailscale/ipn/ui/notifier/FavoritesManager.kt | 3 +++ 2 files changed, 6 insertions(+) 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 index cd74156b65..2dda8fbefe 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/Favorites.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Favorites.kt @@ -1,3 +1,6 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + package com.tailscale.ipn.ui.model import kotlinx.serialization.SerialName 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 index 7be1d3435c..98e55ffce3 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/notifier/FavoritesManager.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/notifier/FavoritesManager.kt @@ -1,3 +1,6 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + package com.tailscale.ipn.ui.notifier import com.tailscale.ipn.ui.localapi.Client From 3f23e9094d1cf55e0a1ab8d203d5cb8900ff389a Mon Sep 17 00:00:00 2001 From: hiimtmac Date: Fri, 4 Sep 2026 11:22:24 -0500 Subject: [PATCH 13/14] comments fix --- .../kotlin/com/tailcale/ipn/ui/util/FavoritesTest.kt | 10 +++------- .../com/tailcale/ipn/ui/util/PinnedSectionTest.kt | 1 - 2 files changed, 3 insertions(+), 8 deletions(-) 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 index 63f338717a..b27510c906 100644 --- a/android/src/test/kotlin/com/tailcale/ipn/ui/util/FavoritesTest.kt +++ b/android/src/test/kotlin/com/tailcale/ipn/ui/util/FavoritesTest.kt @@ -34,9 +34,9 @@ class FavoritesTest { FavoriteItem(id = "n1"), FavoriteItem(name = "no id"), FavoriteItem(id = "n2"), - )) + ) + ) - // .toSet() so this holds whether deviceIds stays a List or becomes a Set. assertEquals(setOf("n1", "n2"), favorites.deviceIds.toSet()) } @@ -78,9 +78,6 @@ class FavoritesTest { assertEquals(listOf("s1"), request.pins.services?.map { it.id }) } - // Locks the exact bytes Client.setFavorites puts on the wire. Client uses the - // default Json instance (encodeDefaults = false), which is what makes the - // null *Set flags and the unset ExitNodes/Services drop out. @Test fun pinRequestSerializesToTheExpectedJson() { val request = Favorites().withToggledDevice("nodeA") @@ -93,8 +90,7 @@ class FavoritesTest { @Test fun unpinningTheLastDeviceSendsAnExplicitEmptyList() { - // An omitted Devices key would mean "no change" to the backend, so the - // empty list has to survive serialization. + // 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)) 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 index 73e0e81c87..3e09636185 100644 --- a/android/src/test/kotlin/com/tailcale/ipn/ui/util/PinnedSectionTest.kt +++ b/android/src/test/kotlin/com/tailcale/ipn/ui/util/PinnedSectionTest.kt @@ -1,7 +1,6 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause -// Package root is "tailcale", matching the existing unit tests (TimeUtilTest, HealthNotifierTest). package com.tailcale.ipn.ui.util import com.tailscale.ipn.ui.model.Tailcfg From c59f7b6187b2e5d18f12ee35f7501431f9471caa Mon Sep 17 00:00:00 2001 From: hiimtmac Date: Fri, 4 Sep 2026 11:29:16 -0500 Subject: [PATCH 14/14] fmt --- .../src/test/kotlin/com/tailcale/ipn/ui/util/FavoritesTest.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 index b27510c906..4f3652904e 100644 --- a/android/src/test/kotlin/com/tailcale/ipn/ui/util/FavoritesTest.kt +++ b/android/src/test/kotlin/com/tailcale/ipn/ui/util/FavoritesTest.kt @@ -34,8 +34,7 @@ class FavoritesTest { FavoriteItem(id = "n1"), FavoriteItem(name = "no id"), FavoriteItem(id = "n2"), - ) - ) + )) assertEquals(setOf("n1", "n2"), favorites.deviceIds.toSet()) }