diff --git a/android/LocalAPI.aar b/android/LocalAPI.aar new file mode 100644 index 0000000000..d263520b3b Binary files /dev/null and b/android/LocalAPI.aar differ diff --git a/android/build.gradle.kts b/android/build.gradle.kts index f166454979..ccd702c095 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -111,6 +111,7 @@ dependencies { // Tailscale dependencies. implementation(files("libs/libtailscale.aar")) + implementation(files("LocalAPI.aar")) // Integration tests. androidTestImplementation(platform(libs.androidx.compose.bom)) 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 2dda8fbefe..fa7df90bae 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 @@ -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? = null, - @SerialName("ExitNodes") val exitNodes: List? = null, - @SerialName("Services") val services: List? = null, -) { - val deviceIds: List by lazy { devices.orEmpty().mapNotNull { it.id } } - - fun isFavoriteDevice(id: StableNodeID): Boolean = id in deviceIds - - fun withToggledDevice(id: StableNodeID): FavoritesRequest { - val current = devices.orEmpty() - val updated = - if (isFavoriteDevice(id)) { - current.filterNot { it.id == id } - } else { - current + FavoriteItem(id = id) - } - return FavoritesRequest( - pins = copy(devices = updated), - devicesSet = true, - ) - } -} +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 + 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, + ) +} 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 46aaa319b3..ba1de2a0c2 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.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 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 ce2d994a57..95ff7ecadc 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 @@ -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 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..02f5e22ac9 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 @@ -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 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 ac11fa7e8d..dd893a1c65 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 @@ -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 diff --git a/android/src/test/kotlin/com/tailscale/ipn/ui/notifier/FavoritesManagerTests.kt b/android/src/test/kotlin/com/tailscale/ipn/ui/notifier/FavoritesManagerTests.kt index 225501cb3b..b213b86026 100644 --- a/android/src/test/kotlin/com/tailscale/ipn/ui/notifier/FavoritesManagerTests.kt +++ b/android/src/test/kotlin/com/tailscale/ipn/ui/notifier/FavoritesManagerTests.kt @@ -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 = diff --git a/android/src/test/kotlin/com/tailscale/ipn/ui/util/FavoritesTest.kt b/android/src/test/kotlin/com/tailscale/ipn/ui/util/FavoritesTest.kt index 3b2ab05b9d..7d88565c97 100644 --- a/android/src/test/kotlin/com/tailscale/ipn/ui/util/FavoritesTest.kt +++ b/android/src/test/kotlin/com/tailscale/ipn/ui/util/FavoritesTest.kt @@ -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 @@ -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() { @@ -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"), ) ) @@ -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)) } @@ -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 @@ -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)) } @@ -104,7 +107,7 @@ class FavoritesTest { val favorites = Json { ignoreUnknownKeys = true }.decodeFromString(json) assertTrue(favorites.isFavoriteDevice("nodeA")) - assertEquals("nodeA", favorites.devices?.single()?.id) + assertEquals("nodeA", favorites.devices?.single()?.ID) assertNull(favorites.exitNodes) assertEquals(emptyList(), favorites.services) }