Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added android/LocalAPI.aar
Binary file not shown.
1 change: 1 addition & 0 deletions android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ dependencies {

// Tailscale dependencies.
implementation(files("libs/libtailscale.aar"))
implementation(files("LocalAPI.aar"))

// Integration tests.
androidTestImplementation(platform(libs.androidx.compose.bom))
Expand Down
67 changes: 27 additions & 40 deletions android/src/main/java/com/tailscale/ipn/ui/model/Favorites.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,44 +3,31 @@

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<FavoriteItem>? = null,
@SerialName("ExitNodes") val exitNodes: List<FavoriteItem>? = null,
@SerialName("Services") val services: List<FavoriteItem>? = null,
) {
val deviceIds: List<StableNodeID> by lazy { devices.orEmpty().mapNotNull { it.id } }

fun isFavoriteDevice(id: StableNodeID): Boolean = id in deviceIds

fun withToggledDevice(id: StableNodeID): FavoritesRequest {
val current = devices.orEmpty()
val updated =
if (isFavoriteDevice(id)) {
current.filterNot { it.id == id }
} else {
current + FavoriteItem(id = id)
}
return FavoritesRequest(
pins = copy(devices = updated),
devicesSet = true,
)
}
}
import com.tailscale.localapi.PintypeItem
import com.tailscale.localapi.PintypeSet
import com.tailscale.localapi.SetPinsRequest

typealias FavoriteItem = PintypeItem

typealias Favorites = PintypeSet

typealias FavoritesRequest = SetPinsRequest

@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,
)
val Favorites.deviceIds: List<StableNodeID>
get() = devices.orEmpty().mapNotNull { it.ID }

fun Favorites.isFavoriteDevice(id: StableNodeID): Boolean = id in deviceIds

fun Favorites.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,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.model.ProfileID
import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.model.withToggledDevice
import com.tailscale.ipn.util.TSLog
import kotlin.coroutines.resume
import kotlin.time.Duration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.model.Permissions
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.model.isFavoriteDevice
import com.tailscale.ipn.ui.theme.customErrorContainer
import com.tailscale.ipn.ui.theme.disabled
import com.tailscale.ipn.ui.theme.errorButton
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.isFavoriteDevice
import com.tailscale.ipn.ui.theme.listItem
import com.tailscale.ipn.ui.theme.short
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ 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.model.deviceIds
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.PeerCategorizer
import com.tailscale.ipn.ui.util.PeerSet
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ class FavoritesManagerTest {
TSLog.libtailscaleWrapper = originalLogWrapper
}

private fun favorites(vararg ids: String) = Favorites(devices = ids.map { FavoriteItem(id = it) })
private fun favorites(vararg ids: String) = Favorites(devices = ids.map { FavoriteItem(ID = it) })

private fun TestScope.createManager(initial: Favorites = favorites()): FavoritesManager {
val manager =
Expand Down
27 changes: 15 additions & 12 deletions android/src/test/kotlin/com/tailscale/ipn/ui/util/FavoritesTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ package com.tailscale.ipn.ui.util

import com.tailscale.ipn.ui.model.FavoriteItem
import com.tailscale.ipn.ui.model.Favorites
import com.tailscale.ipn.ui.model.deviceIds
import com.tailscale.ipn.ui.model.isFavoriteDevice
import com.tailscale.ipn.ui.model.withToggledDevice
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
Expand All @@ -15,7 +18,7 @@ import org.junit.Test

class FavoritesTest {

private fun ids(favorites: Favorites) = favorites.devices?.mapNotNull { it.id }
private fun ids(favorites: Favorites) = favorites.devices?.mapNotNull { it.ID }

@Test
fun emptyFavoritesHasNoPinnedDevices() {
Expand All @@ -31,9 +34,9 @@ class FavoritesTest {
Favorites(
devices =
listOf(
FavoriteItem(id = "n1"),
FavoriteItem(ID = "n1"),
FavoriteItem(name = "no id"),
FavoriteItem(id = "n2"),
FavoriteItem(ID = "n2"),
)
)

Expand All @@ -42,14 +45,14 @@ class FavoritesTest {

@Test
fun togglingAnUnpinnedDeviceAddsIt() {
val request = Favorites(devices = listOf(FavoriteItem(id = "n1"))).withToggledDevice("n2")
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")))
val favorites = Favorites(devices = listOf(FavoriteItem(ID = "n1"), FavoriteItem(ID = "n2")))

assertEquals(listOf("n2"), ids(favorites.withToggledDevice("n1").pins))
}
Expand All @@ -67,15 +70,15 @@ class FavoritesTest {
fun togglePreservesExitNodesAndServices() {
val favorites =
Favorites(
devices = listOf(FavoriteItem(id = "n1")),
exitNodes = listOf(FavoriteItem(id = "x1")),
services = listOf(FavoriteItem(id = "s1")),
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 })
assertEquals(listOf("x1"), request.pins.exitNodes?.map { it.ID })
assertEquals(listOf("s1"), request.pins.services?.map { it.ID })
}

@Test
Expand All @@ -91,7 +94,7 @@ class FavoritesTest {
@Test
fun unpinningTheLastDeviceSendsAnExplicitEmptyList() {
// An omitted Devices key would mean "no change" to the backend
val request = Favorites(devices = listOf(FavoriteItem(id = "nodeA"))).withToggledDevice("nodeA")
val request = Favorites(devices = listOf(FavoriteItem(ID = "nodeA"))).withToggledDevice("nodeA")

assertEquals("""{"Pins":{"Devices":[]},"DevicesSet":true}""", Json.encodeToString(request))
}
Expand All @@ -104,7 +107,7 @@ class FavoritesTest {
val favorites = Json { ignoreUnknownKeys = true }.decodeFromString<Favorites>(json)

assertTrue(favorites.isFavoriteDevice("nodeA"))
assertEquals("nodeA", favorites.devices?.single()?.id)
assertEquals("nodeA", favorites.devices?.single()?.ID)
assertNull(favorites.exitNodes)
assertEquals(emptyList<FavoriteItem>(), favorites.services)
}
Expand Down
Loading