diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index ae46272..d5debf3 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -37,6 +37,7 @@ import com.tailscale.ipn.ui.view.MainView import com.tailscale.ipn.ui.view.MainViewNavigation import com.tailscale.ipn.ui.view.ManagedByView import com.tailscale.ipn.ui.view.MullvadExitNodePicker +import com.tailscale.ipn.ui.view.MullvadExitNodePickerList import com.tailscale.ipn.ui.view.PeerDetails import com.tailscale.ipn.ui.view.RunExitNodeView import com.tailscale.ipn.ui.view.Settings @@ -96,7 +97,9 @@ class MainActivity : ComponentActivity() { onNavigateHome = { navController.popBackStack(route = "main", inclusive = false) }, + onNavigateBack = { navController.popBackStack() }, onNavigateToExitNodePicker = { navController.popBackStack() }, + onNavigateToMullvad = { navController.navigate("mullvad") }, onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") }, onNavigateToRunAsExitNode = { navController.navigate("runExitNode") }) @@ -104,15 +107,14 @@ class MainActivity : ComponentActivity() { composable("settings") { Settings(settingsNav) } navigation(startDestination = "list", route = "exitNodes") { composable("list") { ExitNodePicker(exitNodePickerNav) } + composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) } composable( "mullvad/{countryCode}", arguments = listOf(navArgument("countryCode") { type = NavType.StringType })) { MullvadExitNodePicker( it.arguments!!.getString("countryCode")!!, exitNodePickerNav) } - composable("runExitNode") { - RunExitNodeView(exitNodePickerNav) - } + composable("runExitNode") { RunExitNodeView(exitNodePickerNav) } } composable( "peerDetails/{nodeId}", diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/ClickableOrGrayedOut.kt b/android/src/main/java/com/tailscale/ipn/ui/util/ClickableOrGrayedOut.kt new file mode 100644 index 0000000..1bff49b --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/util/ClickableOrGrayedOut.kt @@ -0,0 +1,27 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.util + +import androidx.compose.foundation.clickable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.semantics.Role + +/** + * Similar to Modifier.clickable, but if enabled == false, this adds a 75% alpha to make disabled + * items appear grayed out. + */ +@Composable +fun Modifier.clickableOrGrayedOut( + enabled: Boolean = true, + onClickLabel: String? = null, + role: Role? = null, + onClick: () -> Unit +) = + if (enabled) { + clickable(onClickLabel = onClickLabel, role = role, onClick = onClick) + } else { + alpha(0.75f) + } 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 new file mode 100644 index 0000000..4c89bdc --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/util/Lists.kt @@ -0,0 +1,43 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.util + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +object Lists { + @Composable + fun SectionDivider() { + Box(Modifier.size(0.dp, 24.dp)) + } + + @Composable + fun ItemDivider() { + HorizontalDivider(color = MaterialTheme.colorScheme.secondaryContainer) + } +} + +/** Similar to items() but includes a horizontal divider between items. */ +inline fun LazyListScope.itemsWithDividers( + items: List, + noinline key: ((item: T) -> Any)? = null, + crossinline contentType: (item: T) -> Any? = { _ -> null }, + 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 (it > 0 && it < items.size) { + Lists.ItemDivider() + } + itemContent(items[it]) + } diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/LoadingIndicator.kt b/android/src/main/java/com/tailscale/ipn/ui/util/LoadingIndicator.kt index d473eb7..581cefe 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/LoadingIndicator.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/LoadingIndicator.kt @@ -4,15 +4,18 @@ package com.tailscale.ipn.ui.util import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.tailscale.ipn.ui.view.TailscaleLogoView import kotlinx.coroutines.flow.MutableStateFlow object LoadingIndicator { @@ -35,12 +38,12 @@ object LoadingIndicator { content() val isLoading = loading.collectAsState().value if (isLoading) { - Box(Modifier.matchParentSize().background(Color.Gray.copy(alpha = 0.5f))) + Box(Modifier.clickable {}.matchParentSize().background(Color.Gray.copy(alpha = 0.5f))) Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { - CircularProgressIndicator() + TailscaleLogoView(true, Modifier.size(72.dp)) } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt b/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt index c7f7772..053217c 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt @@ -6,35 +6,29 @@ package com.tailscale.ipn.ui.view import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight import androidx.compose.material.icons.outlined.Check import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.tailscale.ipn.R +import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.LoadingIndicator -import com.tailscale.ipn.ui.util.flag +import com.tailscale.ipn.ui.util.clickableOrGrayedOut +import com.tailscale.ipn.ui.util.itemsWithDividers import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory +import com.tailscale.ipn.ui.viewModel.selected @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -43,16 +37,17 @@ fun ExitNodePicker( model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav)) ) { LoadingIndicator.Wrap { - Scaffold(topBar = { Header(R.string.choose_exit_node, onBack = nav.onNavigateHome) }) { + Scaffold(topBar = { Header(R.string.choose_exit_node, onBack = nav.onNavigateBack) }) { innerPadding -> - val tailnetExitNodes = model.tailnetExitNodes.collectAsState() - val mullvadExitNodes = model.mullvadExitNodesByCountryCode.collectAsState() + val tailnetExitNodes = model.tailnetExitNodes.collectAsState().value + val mullvadExitNodesByCountryCode = model.mullvadExitNodesByCountryCode.collectAsState().value + val mullvadExitNodeCount = model.mullvadExitNodeCount.collectAsState().value val anyActive = model.anyActive.collectAsState() LazyColumn(modifier = Modifier.padding(innerPadding)) { item(key = "runExitNode") { RunAsExitNodeItem(nav = nav, viewModel = model) - HorizontalDivider() + Lists.SectionDivider() } item(key = "none") { @@ -65,48 +60,15 @@ fun ExitNodePicker( )) } - item { ListHeading(stringResource(R.string.tailnet_exit_nodes)) } + item { Lists.SectionDivider() } - items(tailnetExitNodes.value, key = { it.id!! }) { node -> - ExitNodeItem(model, node, indent = 16.dp) - } - - item { ListHeading(stringResource(R.string.mullvad_exit_nodes)) } + itemsWithDividers(tailnetExitNodes, key = { it.id!! }) { node -> ExitNodeItem(model, node) } - val sortedCountries = - mullvadExitNodes.value.entries.toList().sortedBy { - it.value.first().country.lowercase() - } - items(sortedCountries) { (countryCode, nodes) -> - val first = nodes.first() + item { Lists.SectionDivider() } - // TODO(oxtoacart): the modifier on the ListItem occasionally causes a crash - // with java.lang.ClassCastException: androidx.compose.ui.ComposedModifier cannot be cast - // to androidx.compose.runtime.RecomposeScopeImpl - // Wrapping it in a Box eliminates this. It appears to be some kind of - // interaction between the LazyList and the modifier. - Box { - ListItem( - modifier = - Modifier.padding(start = 16.dp).clickable { - if (nodes.size > 1) { - nav.onNavigateToMullvadCountry(countryCode) - } else { - model.setExitNode(first) - } - }, - headlineContent = { Text("${countryCode.flag()} ${first.country}") }, - trailingContent = { - val text = if (nodes.size == 1) first.city else "${nodes.size}" - val icon = - if (nodes.size > 1) Icons.AutoMirrored.Outlined.KeyboardArrowRight - else if (first.selected) Icons.Outlined.Check else null - Row(verticalAlignment = Alignment.CenterVertically) { - Text(text) - Spacer(modifier = Modifier.width(8.dp)) - icon?.let { Icon(it, contentDescription = stringResource(R.string.more)) } - } - }) + if (mullvadExitNodeCount > 0) { + item(key = "mullvad") { + MullvadItem(nav, mullvadExitNodeCount, mullvadExitNodesByCountryCode.selected) } } @@ -116,36 +78,43 @@ fun ExitNodePicker( } } -@Composable -fun ListHeading(label: String, indent: Dp = 0.dp) { - ListItem( - modifier = Modifier.padding(start = indent), - headlineContent = { Text(text = label, style = MaterialTheme.typography.titleMedium) }) -} - @Composable fun ExitNodeItem( viewModel: ExitNodePickerViewModel, node: ExitNodePickerViewModel.ExitNode, - indent: Dp = 0.dp ) { Box { + // TODO: add disabled styling ListItem( - modifier = Modifier.padding(start = indent).clickable { viewModel.setExitNode(node) }, + modifier = + Modifier.clickableOrGrayedOut(enabled = node.online) { viewModel.setExitNode(node) }, headlineContent = { Text(node.city.ifEmpty { node.label }) }, + supportingContent = { if (!node.online) Text(stringResource(R.string.offline)) }, trailingContent = { Row { if (node.selected) { - Icon(Icons.Outlined.Check, contentDescription = stringResource(R.string.more)) - } else if (!node.online) { - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.offline)) + Icon(Icons.Outlined.Check, contentDescription = stringResource(R.string.selected)) } } }) } } +@Composable +fun MullvadItem(nav: ExitNodePickerNav, count: Int, selected: Boolean) { + Box { + ListItem( + modifier = Modifier.clickable { nav.onNavigateToMullvad() }, + headlineContent = { Text(stringResource(R.string.mullvad_exit_nodes)) }, + supportingContent = { Text("$count ${stringResource(R.string.nodes_available)}") }, + trailingContent = { + if (selected) { + Icon(Icons.Outlined.Check, contentDescription = stringResource(R.string.selected)) + } + }) + } +} + @Composable fun RunAsExitNodeItem(nav: ExitNodePickerNav, viewModel: ExitNodePickerViewModel) { val isRunningExitNode = viewModel.isRunningExitNode.collectAsState().value 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 97d1522..2140377 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 @@ -25,8 +25,8 @@ import androidx.compose.material.icons.outlined.Clear import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.Button 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 @@ -55,8 +55,10 @@ import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.StableNodeID import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.theme.ts_color_light_green +import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.flag +import com.tailscale.ipn.ui.util.itemsWithDividers import com.tailscale.ipn.ui.viewModel.MainViewModel import kotlinx.coroutines.flow.StateFlow @@ -69,64 +71,67 @@ data class MainViewNavigation( @Composable fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewModel()) { - Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets -> - Column( - modifier = Modifier.fillMaxWidth().padding(paddingInsets), - verticalArrangement = Arrangement.Center) { - val state = viewModel.ipnState.collectAsState(initial = Ipn.State.NoState) - val user = viewModel.loggedInUser.collectAsState(initial = null) + LoadingIndicator.Wrap { + Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets -> + Column( + modifier = Modifier.fillMaxWidth().padding(paddingInsets), + verticalArrangement = Arrangement.Center) { + val state = viewModel.ipnState.collectAsState(initial = Ipn.State.NoState) + val user = viewModel.loggedInUser.collectAsState(initial = null) - Row( - modifier = - Modifier.fillMaxWidth() - .background(MaterialTheme.colorScheme.secondaryContainer) - .padding(horizontal = 16.dp) - .padding(top = 10.dp), - verticalAlignment = Alignment.CenterVertically) { - val isOn = viewModel.vpnToggleState.collectAsState(initial = false) - if (state.value != Ipn.State.NeedsLogin && state.value != Ipn.State.NoState) { - TintedSwitch(onCheckedChange = { viewModel.toggleVpn() }, checked = isOn.value) - Spacer(Modifier.size(3.dp)) - } + Row( + modifier = + Modifier.fillMaxWidth() + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding(horizontal = 16.dp) + .padding(top = 10.dp), + verticalAlignment = Alignment.CenterVertically) { + val isOn = viewModel.vpnToggleState.collectAsState(initial = false) + if (state.value != Ipn.State.NeedsLogin && state.value != Ipn.State.NoState) { + TintedSwitch(onCheckedChange = { viewModel.toggleVpn() }, checked = isOn.value) + Spacer(Modifier.size(3.dp)) + } - StateDisplay(viewModel.stateRes, viewModel.userName) + StateDisplay(viewModel.stateRes, viewModel.userName) - Box( - modifier = Modifier.weight(1f).clickable { navigation.onNavigateToSettings() }, - contentAlignment = Alignment.CenterEnd) { - when (user.value) { - null -> SettingsButton(user.value) { navigation.onNavigateToSettings() } - else -> Avatar(profile = user.value, size = 36) + Box( + modifier = + Modifier.weight(1f).clickable { navigation.onNavigateToSettings() }, + contentAlignment = Alignment.CenterEnd) { + when (user.value) { + null -> SettingsButton(user.value) { navigation.onNavigateToSettings() } + else -> Avatar(profile = user.value, size = 36) + } } - } - } + } - when (state.value) { - Ipn.State.Running -> { + when (state.value) { + Ipn.State.Running -> { - val selfPeerId = viewModel.selfPeerId.collectAsState(initial = "") - Row( - modifier = - Modifier.background(MaterialTheme.colorScheme.secondaryContainer) - .padding(top = 10.dp, bottom = 20.dp)) { - ExitNodeStatus( - navAction = navigation.onNavigateToExitNodes, viewModel = viewModel) - } - PeerList( - searchTerm = viewModel.searchTerm, - state = viewModel.ipnState, - peers = viewModel.peers, - selfPeer = selfPeerId.value, - onNavigateToPeerDetails = navigation.onNavigateToPeerDetails, - onSearch = { viewModel.searchPeers(it) }) + val selfPeerId = viewModel.selfPeerId.collectAsState(initial = "") + Row( + modifier = + Modifier.background(MaterialTheme.colorScheme.secondaryContainer) + .padding(top = 10.dp, bottom = 20.dp)) { + ExitNodeStatus( + navAction = navigation.onNavigateToExitNodes, viewModel = viewModel) + } + PeerList( + searchTerm = viewModel.searchTerm, + state = viewModel.ipnState, + peers = viewModel.peers, + selfPeer = selfPeerId.value, + onNavigateToPeerDetails = navigation.onNavigateToPeerDetails, + onSearch = { viewModel.searchPeers(it) }) + } + Ipn.State.NoState, + Ipn.State.Starting -> StartingView() + else -> + ConnectView( + state.value, user.value, { viewModel.toggleVpn() }, { viewModel.login {} }) } - Ipn.State.NoState, - Ipn.State.Starting -> StartingView() - else -> - ConnectView( - state.value, user.value, { viewModel.toggleVpn() }, { viewModel.login {} }) } - } + } } } @@ -135,17 +140,10 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { val prefs = viewModel.prefs.collectAsState() val netmap = viewModel.netmap.collectAsState() val exitNodeId = prefs.value?.ExitNodeID - val exitNode = - exitNodeId?.let { id -> - netmap.value - ?.Peers - ?.find { it.StableID == id } - ?.let { peer -> - peer.Hostinfo.Location?.let { location -> - "${location.Country?.flag()} ${location.Country} - ${location.City}" - } ?: peer.Name - } - } + val peer = exitNodeId?.let { id -> netmap.value?.Peers?.find { it.StableID == id } } + val location = peer?.Hostinfo?.Location + val name = peer?.Name + Box( modifier = Modifier.clickable { navAction() } @@ -153,21 +151,34 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { .clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp)) .background(MaterialTheme.colorScheme.background) .fillMaxWidth()) { - Column(modifier = Modifier.padding(vertical = 15.dp, horizontal = 18.dp)) { - Text( - text = stringResource(id = R.string.exit_node), - color = MaterialTheme.colorScheme.secondary, - style = MaterialTheme.typography.titleSmall) - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = exitNode ?: stringResource(id = R.string.none), - style = MaterialTheme.typography.bodyLarge) - Icon( - Icons.Outlined.ArrowDropDown, - null, - ) - } - } + ListItem( + headlineContent = { + Text( + stringResource(R.string.exit_node), + style = MaterialTheme.typography.titleMedium, + ) + }, + supportingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = + location?.let { "${it.Country?.flag()} ${it.Country} - ${it.City}" } + ?: name + ?: stringResource(id = R.string.none), + style = MaterialTheme.typography.bodyLarge) + Icon( + Icons.Outlined.ArrowDropDown, + null, + ) + } + }, + trailingContent = { + if (peer != null) { + Button(onClick = { viewModel.disableExitNode() }) { + Text(stringResource(R.string.disable)) + } + } + }) } } @@ -349,39 +360,36 @@ fun PeerList( fontWeight = FontWeight.SemiBold) }) } - peerSet.peers.forEach { peer -> - item { - ListItem( - modifier = Modifier.clickable { onNavigateToPeerDetails(peer) }, - headlineContent = { - Row(verticalAlignment = Alignment.CenterVertically) { - // By definition, SelfPeer is online since we will not show the peer list - // unless you're connected. - val isSelfAndRunning = - (peer.StableID == selfPeer && stateVal.value == Ipn.State.Running) - val color: Color = - if ((peer.Online == true) || isSelfAndRunning) { - ts_color_light_green - } else { - Color.Gray - } - Box( - modifier = - Modifier.size(10.dp) - .background( - color = color, shape = RoundedCornerShape(percent = 50))) {} - Spacer(modifier = Modifier.size(6.dp)) - Text(text = peer.ComputedName, style = MaterialTheme.typography.titleMedium) - } - }, - supportingContent = { - Text( - text = peer.Addresses?.first()?.split("/")?.first() ?: "", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.secondary) - }) - HorizontalDivider(color = MaterialTheme.colorScheme.secondaryContainer) - } + itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer -> + ListItem( + modifier = Modifier.clickable { onNavigateToPeerDetails(peer) }, + headlineContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + // By definition, SelfPeer is online since we will not show the peer list + // unless you're connected. + val isSelfAndRunning = + (peer.StableID == selfPeer && stateVal.value == Ipn.State.Running) + val color: Color = + if ((peer.Online == true) || isSelfAndRunning) { + ts_color_light_green + } else { + Color.Gray + } + Box( + modifier = + Modifier.size(10.dp) + .background( + color = color, shape = RoundedCornerShape(percent = 50))) {} + Spacer(modifier = Modifier.size(6.dp)) + Text(text = peer.ComputedName, style = MaterialTheme.typography.titleMedium) + } + }, + supportingContent = { + Text( + text = peer.Addresses?.first()?.split("/")?.first() ?: "", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.secondary) + }) } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePicker.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePicker.kt index 67e673e..5e5784c 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePicker.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePicker.kt @@ -5,19 +5,18 @@ package com.tailscale.ipn.ui.view import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.lifecycle.viewmodel.compose.viewModel import com.tailscale.ipn.R +import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.flag +import com.tailscale.ipn.ui.util.itemsWithDividers import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory @@ -36,26 +35,29 @@ fun MullvadExitNodePicker( val any = nodes.first() LoadingIndicator.Wrap { - Scaffold(topBar = { TopAppBar(title = { Text("${countryCode.flag()} ${any.country}") }) }) { - innerPadding -> - LazyColumn(modifier = Modifier.padding(innerPadding)) { - if (nodes.size > 1) { - val bestAvailableNode = bestAvailableByCountry.value[countryCode]!! - item { - ExitNodeItem( - model, - ExitNodePickerViewModel.ExitNode( - id = bestAvailableNode.id, - label = stringResource(R.string.best_available), - online = bestAvailableNode.online, - selected = false, - )) + Scaffold( + topBar = { + Header(titleText = "${countryCode.flag()} ${any.country}", onBack = nav.onNavigateBack) + }) { innerPadding -> + LazyColumn(modifier = Modifier.padding(innerPadding)) { + if (nodes.size > 1) { + val bestAvailableNode = bestAvailableByCountry.value[countryCode]!! + item { + ExitNodeItem( + model, + ExitNodePickerViewModel.ExitNode( + id = bestAvailableNode.id, + label = stringResource(R.string.best_available), + online = bestAvailableNode.online, + selected = false, + )) + Lists.ItemDivider() + } + } + + itemsWithDividers(nodes) { node -> ExitNodeItem(model, node) } } } - - items(nodes) { node -> ExitNodeItem(model, node) } - } - } } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePickerList.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePickerList.kt new file mode 100644 index 0000000..7012376 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePickerList.kt @@ -0,0 +1,92 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.view + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.viewmodel.compose.viewModel +import com.tailscale.ipn.R +import com.tailscale.ipn.ui.util.LoadingIndicator +import com.tailscale.ipn.ui.util.flag +import com.tailscale.ipn.ui.util.itemsWithDividers +import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav +import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel +import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory +import com.tailscale.ipn.ui.viewModel.selected + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MullvadExitNodePickerList( + nav: ExitNodePickerNav, + model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav)) +) { + LoadingIndicator.Wrap { + Scaffold(topBar = { Header(R.string.choose_mullvad_exit_node, onBack = nav.onNavigateBack) }) { + innerPadding -> + val mullvadExitNodes = model.mullvadExitNodesByCountryCode.collectAsState() + + LazyColumn(modifier = Modifier.padding(innerPadding)) { + val sortedCountries = + mullvadExitNodes.value.entries.toList().sortedBy { + it.value.first().country.lowercase() + } + itemsWithDividers(sortedCountries) { (countryCode, nodes) -> + val first = nodes.first() + + // TODO(oxtoacart): the modifier on the ListItem occasionally causes a crash + // with java.lang.ClassCastException: androidx.compose.ui.ComposedModifier cannot be cast + // to androidx.compose.runtime.RecomposeScopeImpl + // Wrapping it in a Box eliminates this. It appears to be some kind of + // interaction between the LazyList and the modifier. + Box { + ListItem( + modifier = + Modifier.clickable { + if (nodes.size > 1) { + nav.onNavigateToMullvadCountry(countryCode) + } else { + model.setExitNode(first) + } + }, + leadingContent = { + Text( + countryCode.flag(), + style = MaterialTheme.typography.titleLarge, + ) + }, + headlineContent = { Text(first.country) }, + supportingContent = { + Text( + if (nodes.size == 1) first.city + else "${nodes.size} ${stringResource(R.string.cities_available)}") + }, + trailingContent = { + if (nodes.size > 1 && nodes.selected || first.selected) { + if (nodes.selected) { + Icon( + Icons.Outlined.Check, + contentDescription = stringResource(R.string.selected)) + } + } + }) + } + } + } + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SharedViews.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SharedViews.kt index 02af82c..897c4cb 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SharedViews.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SharedViews.kt @@ -29,14 +29,14 @@ data class BackNavigation( // Header view for all secondary screens @OptIn(ExperimentalMaterial3Api::class) @Composable -fun Header(@StringRes title: Int, onBack: (() -> Unit)? = null) { +fun Header(@StringRes title: Int = 0, titleText: String? = null, onBack: (() -> Unit)? = null) { TopAppBar( colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surfaceContainer, titleContentColor = MaterialTheme.colorScheme.primary, ), - title = { Text(stringResource(title)) }, + title = { Text(titleText ?: stringResource(title)) }, navigationIcon = { onBack?.let { BackArrow(action = it) } }, ) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt index 27b4ec2..3462c99 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt @@ -22,7 +22,9 @@ import java.util.TreeMap data class ExitNodePickerNav( val onNavigateHome: () -> Unit, + val onNavigateBack: () -> Unit, val onNavigateToExitNodePicker: () -> Unit, + val onNavigateToMullvad: () -> Unit, val onNavigateToMullvadCountry: (String) -> Unit, val onNavigateToRunAsExitNode: () -> Unit, ) @@ -51,6 +53,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel val mullvadExitNodesByCountryCode: StateFlow>> = MutableStateFlow(TreeMap()) val mullvadBestAvailableByCountry: StateFlow> = MutableStateFlow(TreeMap()) + val mullvadExitNodeCount: StateFlow = MutableStateFlow(0) val anyActive: StateFlow = MutableStateFlow(false) val isRunningExitNode: StateFlow = MutableStateFlow(false) @@ -94,12 +97,13 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel val tailnetNodes = allNodes.filter { !it.mullvad } tailnetExitNodes.set(tailnetNodes.sortedWith { a, b -> a.label.compareTo(b.label) }) + val allMullvadExitNodes = + allNodes.filter { + // Pick all mullvad nodes that are online or the currently selected + it.mullvad && (it.selected || it.online) + } val mullvadExitNodes = - allNodes - .filter { - // Pick all mullvad nodes that are online or the currently selected - it.mullvad && (it.selected || it.online) - } + allMullvadExitNodes .groupBy { // Group by countryCode it.countryCode @@ -127,6 +131,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel .sortedBy { it.city.lowercase() } } mullvadExitNodesByCountryCode.set(mullvadExitNodes) + mullvadExitNodeCount.set(mullvadExitNodes.size) val bestAvailableByCountry = mullvadExitNodes.mapValues { (_, nodes) -> @@ -150,7 +155,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel } } - fun toggleAllowLANAccess(callback: (Result) -> Unit) { + private fun toggleAllowLANAccess(callback: (Result) -> Unit) { val prefs = Notifier.prefs.value ?: run { @@ -163,3 +168,9 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel Client(viewModelScope).editPrefs(prefsOut, callback) } } + +val List.selected + get() = this.any { it.selected } + +val Map>.selected + get() = this.any { it.value.selected } 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 b945216..4434dcd 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 @@ -5,9 +5,12 @@ package com.tailscale.ipn.ui.viewModel import androidx.lifecycle.viewModelScope import com.tailscale.ipn.R +import com.tailscale.ipn.ui.localapi.Client +import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn.State import com.tailscale.ipn.ui.model.StableNodeID import com.tailscale.ipn.ui.notifier.Notifier +import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.PeerCategorizer import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.set @@ -65,6 +68,13 @@ class MainViewModel : IpnViewModel() { this.searchTerm.set(searchTerm) viewModelScope.launch { peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm)) } } + + fun disableExitNode() { + LoadingIndicator.start() + val prefsOut = Ipn.MaskedPrefs() + prefsOut.ExitNodeID = null + Client(viewModelScope).editPrefs(prefsOut) { LoadingIndicator.stop() } + } } private fun State?.userStringRes(): Int { diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 93f3482..625724e 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -12,6 +12,7 @@ %s More + Selected Offline OK @@ -87,6 +88,7 @@ Choose Exit Node + Mullvad Exit Nodes Tailnet Exit Nodes Mullvad VPN Best Available @@ -100,6 +102,7 @@ Other devices in your tailnet can now route their Internet traffic through this Android device. Make sure to approve this exit node in the admin console in order for other devices to see it. Enabled Disabled + Disable Tailnet lock Tailnet lock lets devices in your network verify public keys distributed by the coordination server before trusting them for connectivity. Tailnet lock is currently enabled. @@ -125,5 +128,7 @@ This device is using the system DNS resolver. Not Using Tailscale DNS Allow LAN Access + nodes available + cities available