From 9c3378d7eb6b1f94ff8d060c93629ec5b125d644 Mon Sep 17 00:00:00 2001 From: Andrea Gottardo Date: Thu, 16 Jan 2025 22:12:38 -0800 Subject: [PATCH] ui: add ability to advertise Android device as subnet router (#595) --- .../java/com/tailscale/ipn/MainActivity.kt | 3 + .../main/java/com/tailscale/ipn/ui/Links.kt | 1 + .../ipn/ui/view/EditSubnetRouteDialogView.kt | 104 ++++++ .../com/tailscale/ipn/ui/view/SettingsView.kt | 298 +++++++++--------- .../ipn/ui/view/SubnetRouteRowView.kt | 58 ++++ .../ipn/ui/view/SubnetRoutingView.kt | 126 ++++++++ .../ipn/ui/viewModel/SettingsViewModel.kt | 1 + .../ui/viewModel/SubnetRoutingViewModel.kt | 247 +++++++++++++++ android/src/main/res/drawable/pencil.xml | 5 + android/src/main/res/drawable/xmark.xml | 5 + android/src/main/res/values/strings.xml | 15 + 11 files changed, 721 insertions(+), 142 deletions(-) create mode 100644 android/src/main/java/com/tailscale/ipn/ui/view/EditSubnetRouteDialogView.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/view/SubnetRouteRowView.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/view/SubnetRoutingView.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/viewModel/SubnetRoutingViewModel.kt create mode 100644 android/src/main/res/drawable/pencil.xml create mode 100644 android/src/main/res/drawable/xmark.xml diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index 118d5235b6..9eed309924 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -69,6 +69,7 @@ import com.tailscale.ipn.ui.view.RunExitNodeView import com.tailscale.ipn.ui.view.SearchView import com.tailscale.ipn.ui.view.SettingsView import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView +import com.tailscale.ipn.ui.view.SubnetRoutingView import com.tailscale.ipn.ui.view.TailnetLockSetupView import com.tailscale.ipn.ui.view.UserSwitcherNav import com.tailscale.ipn.ui.view.UserSwitcherView @@ -185,6 +186,7 @@ class MainActivity : ComponentActivity() { onNavigateToDNSSettings = { navController.navigate("dnsSettings") }, onNavigateToSplitTunneling = { navController.navigate("splitTunneling") }, onNavigateToTailnetLock = { navController.navigate("tailnetLock") }, + onNavigateToSubnetRouting = { navController.navigate("subnetRouting")}, onNavigateToMDMSettings = { navController.navigate("mdmSettings") }, onNavigateToManagedBy = { navController.navigate("managedBy") }, onNavigateToUserSwitcher = { navController.navigate("userSwitcher") }, @@ -247,6 +249,7 @@ class MainActivity : ComponentActivity() { composable("dnsSettings") { DNSSettingsView(backTo("settings")) } composable("splitTunneling") { SplitTunnelAppPickerView(backTo("settings")) } composable("tailnetLock") { TailnetLockSetupView(backTo("settings")) } + composable("subnetRouting") { SubnetRoutingView(backTo("settings")) } composable("about") { AboutView(backTo("settings")) } composable("mdmSettings") { MDMSettingsDebugView(backTo("settings")) } composable("managedBy") { ManagedByView(backTo("settings")) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/Links.kt b/android/src/main/java/com/tailscale/ipn/ui/Links.kt index 5141dae808..00334a71a3 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/Links.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/Links.kt @@ -24,4 +24,5 @@ object Links { const val SUPPORT_URL = "https://tailscale.com/contact/support#support-form" const val TAILDROP_KB_URL = "https://tailscale.com/kb/1106/taildrop" const val TAILFS_KB_URL = "https://tailscale.com/kb/1106/taildrop" + const val SUBNET_ROUTERS_KB_URL = "https://tailscale.com/kb/1019/subnets" } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/EditSubnetRouteDialogView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/EditSubnetRouteDialogView.kt new file mode 100644 index 0000000000..dfd7629f61 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/EditSubnetRouteDialogView.kt @@ -0,0 +1,104 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.view + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.tailscale.ipn.R +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * EditSubnetRouteDialogView is the content of the dialog that allows the user to add or edit a subnet route. + */ +@Composable +fun EditSubnetRouteDialogView( + valueFlow: MutableStateFlow, + isValueValidFlow: StateFlow, + onValueChange: (String) -> Unit, + onCommit: (String) -> Unit, + onCancel: () -> Unit +) { + val value by valueFlow.collectAsState() + val isValueValid by isValueValidFlow.collectAsState() + val focusRequester = remember { FocusRequester() } + + Column( + modifier = Modifier.padding(16.dp), + ) { + Text(text = stringResource(R.string.enter_valid_route)) + + Text( + text = stringResource(R.string.route_help_text), + color = MaterialTheme.colorScheme.secondary, + fontSize = MaterialTheme.typography.bodySmall.fontSize + ) + + Spacer(modifier = Modifier.height(8.dp)) + + TextField( + value = value, + onValueChange = { onValueChange(it) }, + singleLine = true, + isError = !isValueValid, + modifier = Modifier.focusRequester(focusRequester) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.align(Alignment.End) + ) { + Button(colors = ButtonDefaults.outlinedButtonColors(), onClick = { + onCancel() + }) { + Text(stringResource(R.string.cancel)) + } + + Spacer(modifier = Modifier.width(8.dp)) + + Button(onClick = { + onCommit(value) + }, enabled = value.isNotEmpty() && isValueValid) { + Text(stringResource(R.string.ok)) + } + } + } + + // When the dialog is opened, focus on the text field to present the keyboard auto-magically. + val windowInfo = LocalWindowInfo.current + LaunchedEffect(windowInfo) { + snapshotFlow { windowInfo.isWindowFocused }.collect { isWindowFocused -> + if (isWindowFocused) { + focusRequester.requestFocus() + } + } + } +} \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt index b89df15512..52609f7932 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.viewmodel.compose.viewModel import com.tailscale.ipn.BuildConfig import com.tailscale.ipn.R +import com.tailscale.ipn.mdm.AlwaysNeverUserDecides import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.ShowHide import com.tailscale.ipn.ui.Links @@ -43,172 +44,185 @@ import com.tailscale.ipn.ui.util.AndroidTVUtil import com.tailscale.ipn.ui.util.AppVersion @Composable -fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewModel(), vpnViewModel: VpnViewModel = viewModel()) { - val handler = LocalUriHandler.current - - val user by viewModel.loggedInUser.collectAsState() - val isAdmin by viewModel.isAdmin.collectAsState() - val managedByOrganization by viewModel.managedByOrganization.collectAsState() - val tailnetLockEnabled by viewModel.tailNetLockEnabled.collectAsState() - val corpDNSEnabled by viewModel.corpDNSEnabled.collectAsState() - val isVPNPrepared by vpnViewModel.vpnPrepared.collectAsState() - val showTailnetLock by MDMSettings.manageTailnetLock.flow.collectAsState() - - Scaffold( - topBar = { +fun SettingsView( + settingsNav: SettingsNav, + viewModel: SettingsViewModel = viewModel(), + vpnViewModel: VpnViewModel = viewModel() +) { + val handler = LocalUriHandler.current + + val user by viewModel.loggedInUser.collectAsState() + val isAdmin by viewModel.isAdmin.collectAsState() + val managedByOrganization by viewModel.managedByOrganization.collectAsState() + val tailnetLockEnabled by viewModel.tailNetLockEnabled.collectAsState() + val corpDNSEnabled by viewModel.corpDNSEnabled.collectAsState() + val isVPNPrepared by vpnViewModel.vpnPrepared.collectAsState() + val showTailnetLock by MDMSettings.manageTailnetLock.flow.collectAsState() + val useTailscaleSubnets by MDMSettings.useTailscaleSubnets.flow.collectAsState() + + Scaffold(topBar = { Header(titleRes = R.string.settings_title, onBack = settingsNav.onNavigateBackHome) - }) { innerPadding -> - Column(modifier = Modifier.padding(innerPadding).verticalScroll(rememberScrollState())) { - if (isVPNPrepared) { - UserView( - profile = user, - actionState = UserActionState.NAV, - onClick = settingsNav.onNavigateToUserSwitcher) - } - - if (isAdmin && !isAndroidTV()) { - Lists.ItemDivider() - AdminTextView { handler.openUri(Links.ADMIN_URL) } - } - - Lists.SectionDivider() - Setting.Text( - R.string.dns_settings, - subtitle = - corpDNSEnabled?.let { - stringResource( - if (it) R.string.using_tailscale_dns else R.string.not_using_tailscale_dns) - }, - onClick = settingsNav.onNavigateToDNSSettings) - - Lists.ItemDivider() - Setting.Text( - R.string.split_tunneling, - subtitle = stringResource(R.string.exclude_certain_apps_from_using_tailscale), - onClick = settingsNav.onNavigateToSplitTunneling) + }) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + ) { + if (isVPNPrepared) { + UserView( + profile = user, + actionState = UserActionState.NAV, + onClick = settingsNav.onNavigateToUserSwitcher + ) + } + + if (isAdmin && !isAndroidTV()) { + Lists.ItemDivider() + AdminTextView { handler.openUri(Links.ADMIN_URL) } + } - if (showTailnetLock.value == ShowHide.Show) { - Lists.ItemDivider() + Lists.SectionDivider() Setting.Text( - R.string.tailnet_lock, - subtitle = - tailnetLockEnabled?.let { - stringResource(if (it) R.string.enabled else R.string.disabled) - }, - onClick = settingsNav.onNavigateToTailnetLock) - } - if (!AndroidTVUtil.isAndroidTV()){ - Lists.ItemDivider() - Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions) - } + R.string.dns_settings, subtitle = corpDNSEnabled?.let { + stringResource( + if (it) R.string.using_tailscale_dns else R.string.not_using_tailscale_dns + ) + }, onClick = settingsNav.onNavigateToDNSSettings + ) - managedByOrganization.value?.let { Lists.ItemDivider() Setting.Text( - title = stringResource(R.string.managed_by_orgName, it), - onClick = settingsNav.onNavigateToManagedBy) - } - - Lists.SectionDivider() - Setting.Text(R.string.bug_report, onClick = settingsNav.onNavigateToBugReport) + R.string.split_tunneling, + subtitle = stringResource(R.string.exclude_certain_apps_from_using_tailscale), + onClick = settingsNav.onNavigateToSplitTunneling + ) + + if (showTailnetLock.value == ShowHide.Show) { + Lists.ItemDivider() + Setting.Text( + R.string.tailnet_lock, subtitle = tailnetLockEnabled?.let { + stringResource(if (it) R.string.enabled else R.string.disabled) + }, onClick = settingsNav.onNavigateToTailnetLock + ) + } + if (useTailscaleSubnets.value == AlwaysNeverUserDecides.UserDecides) { + Lists.ItemDivider() + Setting.Text( + R.string.subnet_routing, + onClick = settingsNav.onNavigateToSubnetRouting + ) + } + if (!AndroidTVUtil.isAndroidTV()) { + Lists.ItemDivider() + Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions) + } + + managedByOrganization.value?.let { + Lists.ItemDivider() + Setting.Text( + title = stringResource(R.string.managed_by_orgName, it), + onClick = settingsNav.onNavigateToManagedBy + ) + } - Lists.ItemDivider() - Setting.Text( - R.string.about_tailscale, - subtitle = "${stringResource(id = R.string.version)} ${AppVersion.Short()}", - onClick = settingsNav.onNavigateToAbout) - - // TODO: put a heading for the debug section - if (BuildConfig.DEBUG) { Lists.SectionDivider() - Lists.MutedHeader(text = stringResource(R.string.internal_debug_options)) - Setting.Text(R.string.mdm_settings, onClick = settingsNav.onNavigateToMDMSettings) - } + Setting.Text(R.string.bug_report, onClick = settingsNav.onNavigateToBugReport) + + Lists.ItemDivider() + Setting.Text( + R.string.about_tailscale, + subtitle = "${stringResource(id = R.string.version)} ${AppVersion.Short()}", + onClick = settingsNav.onNavigateToAbout + ) + + // TODO: put a heading for the debug section + if (BuildConfig.DEBUG) { + Lists.SectionDivider() + Lists.MutedHeader(text = stringResource(R.string.internal_debug_options)) + Setting.Text(R.string.mdm_settings, onClick = settingsNav.onNavigateToMDMSettings) + } } - } + } } object Setting { - @Composable - fun Text( - titleRes: Int = 0, - title: String? = null, - subtitle: String? = null, - destructive: Boolean = false, - enabled: Boolean = true, - onClick: (() -> Unit)? = null - ) { - var modifier: Modifier = Modifier - if (enabled) { - onClick?.let { modifier = modifier.clickable(onClick = it) } - } - ListItem( - modifier = modifier, - colors = MaterialTheme.colorScheme.listItem, - headlineContent = { - Text( - title ?: stringResource(titleRes), - style = MaterialTheme.typography.bodyMedium, - color = if (destructive) MaterialTheme.colorScheme.error else Color.Unspecified) - }, - supportingContent = - subtitle?.let { - { + @Composable + fun Text( + titleRes: Int = 0, + title: String? = null, + subtitle: String? = null, + destructive: Boolean = false, + enabled: Boolean = true, + onClick: (() -> Unit)? = null + ) { + var modifier: Modifier = Modifier + if (enabled) { + onClick?.let { modifier = modifier.clickable(onClick = it) } + } + ListItem(modifier = modifier, + colors = MaterialTheme.colorScheme.listItem, + headlineContent = { Text( - it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - } + title ?: stringResource(titleRes), + style = MaterialTheme.typography.bodyMedium, + color = if (destructive) MaterialTheme.colorScheme.error else Color.Unspecified + ) + }, + supportingContent = subtitle?.let { + { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } }) - } - - @Composable - fun Switch( - titleRes: Int = 0, - title: String? = null, - isOn: Boolean, - enabled: Boolean = true, - onToggle: (Boolean) -> Unit = {} - ) { - ListItem( - colors = MaterialTheme.colorScheme.listItem, - headlineContent = { - Text( - title ?: stringResource(titleRes), - style = MaterialTheme.typography.bodyMedium, - ) - }, - trailingContent = { - TintedSwitch(checked = isOn, onCheckedChange = onToggle, enabled = enabled) + } + + @Composable + fun Switch( + titleRes: Int = 0, + title: String? = null, + isOn: Boolean, + enabled: Boolean = true, + onToggle: (Boolean) -> Unit = {} + ) { + ListItem(colors = MaterialTheme.colorScheme.listItem, headlineContent = { + Text( + title ?: stringResource(titleRes), + style = MaterialTheme.typography.bodyMedium, + ) + }, trailingContent = { + TintedSwitch(checked = isOn, onCheckedChange = onToggle, enabled = enabled) }) - } + } } @Composable fun AdminTextView(onNavigateToAdminConsole: () -> Unit) { - val adminStr = buildAnnotatedString { - append(stringResource(id = R.string.settings_admin_prefix)) - - pushStringAnnotation(tag = "link", annotation = Links.ADMIN_URL) - withStyle( - style = - SpanStyle( - color = MaterialTheme.colorScheme.link, - textDecoration = TextDecoration.Underline)) { - append(stringResource(id = R.string.settings_admin_link)) + val adminStr = buildAnnotatedString { + append(stringResource(id = R.string.settings_admin_prefix)) + + pushStringAnnotation(tag = "link", annotation = Links.ADMIN_URL) + withStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.link, textDecoration = TextDecoration.Underline + ) + ) { + append(stringResource(id = R.string.settings_admin_link)) } - } + } - Lists.InfoItem(adminStr, onClick = onNavigateToAdminConsole) + Lists.InfoItem(adminStr, onClick = onNavigateToAdminConsole) } @Preview @Composable fun SettingsPreview() { - val vm = SettingsViewModel() - vm.corpDNSEnabled.set(true) - vm.tailNetLockEnabled.set(true) - vm.isAdmin.set(true) - vm.managedByOrganization.set("Tails and Scales Inc.") - SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm) + val vm = SettingsViewModel() + vm.corpDNSEnabled.set(true) + vm.tailNetLockEnabled.set(true) + vm.isAdmin.set(true) + vm.managedByOrganization.set("Tails and Scales Inc.") + SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SubnetRouteRowView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SubnetRouteRowView.kt new file mode 100644 index 0000000000..b6e797f784 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SubnetRouteRowView.kt @@ -0,0 +1,58 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.view + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.tailscale.ipn.R + +/** + * SubnetRouteRowView is a row in RunSubnetRouterView, representing a subnet route. + * It provides options to edit or delete the route. + * + * @param route The subnet route itself (e.g., "192.168.1.0/24"). + * @param onEdit A callback invoked when the edit icon is clicked. + * @param onDelete A callback invoked when the delete icon is clicked. + */ +@Composable +fun SubnetRouteRowView( + route: String, onEdit: () -> Unit, onDelete: () -> Unit, modifier: Modifier = Modifier +) { + ListItem( + headlineContent = { Text(text = route, style = MaterialTheme.typography.bodyMedium) }, + trailingContent = { + Row { + IconButton(onClick = onEdit) { + Icon( + painterResource(R.drawable.pencil), + contentDescription = stringResource(R.string.edit_route), + modifier = Modifier.size(24.dp) + ) + } + IconButton( + onClick = onDelete, + colors = IconButtonDefaults.iconButtonColors(contentColor = MaterialTheme.colorScheme.error) + ) { + Icon( + painterResource(R.drawable.xmark), + contentDescription = stringResource(R.string.delete_route), + modifier = Modifier.size(24.dp) + ) + } + } + }, + modifier = modifier + ) +} \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SubnetRoutingView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SubnetRoutingView.kt new file mode 100644 index 0000000000..357d609359 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SubnetRoutingView.kt @@ -0,0 +1,126 @@ +// 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.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.lifecycle.viewmodel.compose.viewModel +import com.tailscale.ipn.R +import com.tailscale.ipn.ui.Links.SUBNET_ROUTERS_KB_URL +import com.tailscale.ipn.ui.util.Lists +import com.tailscale.ipn.ui.util.LoadingIndicator +import com.tailscale.ipn.ui.util.itemsWithDividers +import com.tailscale.ipn.ui.util.set +import com.tailscale.ipn.ui.viewModel.SubnetRoutingViewModel + +@Composable +fun SubnetRoutingView(backToSettings: BackNavigation, model: SubnetRoutingViewModel = viewModel()) { + val subnetRoutes by model.advertisedRoutes.collectAsState() + val uriHandler = LocalUriHandler.current + val isPresentingDialog by model.isPresentingDialog.collectAsState() + val useSubnets by model.routeAll.collectAsState() + + Scaffold(topBar = { + Header(R.string.subnet_routes, onBack = backToSettings, actions = { + IconButton(onClick = { + uriHandler.openUri(SUBNET_ROUTERS_KB_URL) + }) { + Icon( + painter = painterResource(R.drawable.info), contentDescription = stringResource( + R.string.open_kb_article + ) + ) + } + }) + }) { innerPadding -> + LoadingIndicator.Wrap { + LazyColumn(modifier = Modifier.padding(innerPadding)) { + item("subnetsToggle") { + Setting.Switch(R.string.use_tailscale_subnets, isOn = useSubnets, onToggle = { + LoadingIndicator.start() + model.toggleUseSubnets { LoadingIndicator.stop() } + }) + } + item("subtitle") { + ListItem(headlineContent = { + Text( + stringResource(R.string.use_tailscale_subnets_subtitle), + modifier = Modifier.padding(bottom = 8.dp) + ) + }) + } + item("divider0") { + Lists.SectionDivider() + } + item(key = "header") { + Lists.MutedHeader(stringResource(R.string.advertised_routes)) + ListItem(headlineContent = { + Text( + stringResource(R.string.run_as_subnet_router_header), + modifier = Modifier.padding(vertical = 8.dp) + ) + }) + } + + itemsWithDividers(subnetRoutes, key = { it }) { + SubnetRouteRowView(route = it, onEdit = { + model.startEditingRoute(it) + }, onDelete = { + model.deleteRoute(it) + }, modifier = Modifier.animateItem()) + } + + item("addNewRoute") { + Lists.ItemDivider() + ListItem(headlineContent = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Outlined.Add, contentDescription = null) + Text(stringResource(R.string.add_new_route)) + } + }, modifier = Modifier.clickable { model.startEditingRoute("") }) + } + } + } + } + + if (isPresentingDialog) { + Dialog(onDismissRequest = { + model.isPresentingDialog.set(false) + }) { + Card { + EditSubnetRouteDialogView(valueFlow = model.dialogTextFieldValue, + isValueValidFlow = model.isTextFieldValueValid, + onValueChange = { + model.dialogTextFieldValue.set(it) + }, + onCommit = { + model.doneEditingRoute(newValue = it) + }, + onCancel = { + model.stopEditingRoute() + }) + } + } + } +} \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt index 50095eb58d..b9343c96b5 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt @@ -18,6 +18,7 @@ data class SettingsNav( val onNavigateToDNSSettings: () -> Unit, val onNavigateToSplitTunneling: () -> Unit, val onNavigateToTailnetLock: () -> Unit, + val onNavigateToSubnetRouting: () -> Unit, val onNavigateToMDMSettings: () -> Unit, val onNavigateToManagedBy: () -> Unit, val onNavigateToUserSwitcher: () -> Unit, diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SubnetRoutingViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SubnetRoutingViewModel.kt new file mode 100644 index 0000000000..73269dfc03 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SubnetRoutingViewModel.kt @@ -0,0 +1,247 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.viewModel + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.tailscale.ipn.ui.localapi.Client +import com.tailscale.ipn.ui.model.Ipn +import com.tailscale.ipn.ui.notifier.Notifier +import com.tailscale.ipn.ui.util.set +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch + +/** + * SubnetRoutingViewModel is responsible for managing the content of the subnet router management view. + * This class watches the backend preferences and updates the UI accordingly whenever the advertised routes + * change. It also handles the state of the editing dialog, and updates the preferences stored in + * the backend when the routes are edited in the UI. + */ +class SubnetRoutingViewModel : ViewModel() { + private val TAG = "SubnetRoutingViewModel" + + /** + * Matches the value of the "RouteAll" backend preference. + */ + val routeAll: StateFlow = MutableStateFlow(true) + + /** + * The advertised routes displayed at any point in time in the UI. The class observes + * this value for changes, and updates the backend preferences accordingly. + */ + val advertisedRoutes: StateFlow> = MutableStateFlow(listOf()) + + /** + * Whether we are presenting the add/edit dialog to set/change the value of a route. + */ + val isPresentingDialog: StateFlow = MutableStateFlow(false) + + /** + * When editing a route, this stores the initial value. It is used to determine which + * of the previously existing routes needs to be updated. This starts as empty, and dismissing + * the edit dialog should reset it to empty as well. + * If the user is adding a new route, this will be empty despite isPresentingDialog being true. + */ + private val editingRoute: StateFlow = MutableStateFlow("") + + /** + * The value currently entered in the add/edit dialog text field. + */ + val dialogTextFieldValue: MutableStateFlow = MutableStateFlow("") + + /** + * True if the value currently entered in the dialog text field is valid, false otherwise. + * If the text field is empty, this returns true as we don't want to display an error state + * when the user hasn't entered anything. + */ + val isTextFieldValueValid: StateFlow = MutableStateFlow(true) + + init { + viewModelScope.launch { + // Any time the value entered by the user in the add/edit dialog changes, we determine + // whether it is valid or invalid, and set isTextFieldValueValid accordingly. + dialogTextFieldValue + .collect { newValue -> + if (newValue.isEmpty()) { + isTextFieldValueValid.set(true) + return@collect + } + val isValid = isValidCIDR(newValue) + Log.v(TAG, "isValidCIDR($newValue): $isValid") + isTextFieldValueValid.set(isValid) + return@collect + } + } + + viewModelScope.launch { + // Similarly, if the routes change in the backend at any time, we should also reflect + // that change in the UI. + Notifier.prefs + // Ignore any prefs updates without AdvertiseRoutes + .mapNotNull { it?.AdvertiseRoutes } + // Ignore duplicate values to prevent an unnecessary UI update + .distinctUntilChanged() + // Ignore any value that matches the current value in UI, + // to prevent an unnecessary UI update + .filter { it != advertisedRoutes }.collect { newRoutesFromBackend -> + Log.d( + TAG, "AdvertiseRoutes changed in the backend: $newRoutesFromBackend" + ) + advertisedRoutes.set(newRoutesFromBackend) + } + } + + viewModelScope.launch { + Notifier.prefs.map { it?.RouteAll }.distinctUntilChanged().collect { + Log.d(TAG, "RouteAll changed in the backend: $it") + routeAll.set(it) + } + } + + viewModelScope.launch { + routeAll.collect { + val prefsOut = Ipn.MaskedPrefs() + prefsOut.RouteAll = it + Log.d(TAG, "Will save RouteAll in the backend: $it") + Client(viewModelScope).editPrefs(prefsOut, responseHandler = { result -> + if (result.isFailure) { + Log.e(TAG, "Error saving RouteAll: ${result.exceptionOrNull()}") + return@editPrefs + } else { + Log.d( + TAG, + "RouteAll set in backend. New value: ${result.getOrNull()?.RouteAll}" + ) + } + }) + } + } + } + + // Public functions + + fun toggleUseSubnets(onDone: () -> Unit) { + routeAll.set(!routeAll.value) + onDone() + } + + /** + * Deletes the given subnet route from the list of advertised routes. + * Calling this function will cause the backend preferences to be updated in the background. + * + * @param route The route string to be deleted from the list of advertised routes. + * If the route does not exist in the list, no changes are made. + */ + fun deleteRoute(route: String) { + val currentRoutes = advertisedRoutes.value.toMutableList() + if (!currentRoutes.contains(route)) { + Log.e(TAG, "Attempted to delete route, but it does not exist: $route") + return + } + currentRoutes.remove(route) + advertisedRoutes.set(currentRoutes) + saveRoutesToPrefs() + } + + /** + * Starts editing the given subnet route. Called when the user taps the 'pencil' button + * on a route in the list. + */ + fun startEditingRoute(route: String) { + Log.d(TAG, "startEditingRoute: $route") + editingRoute.set(route) + dialogTextFieldValue.set(route) + isPresentingDialog.set(true) + } + + /** + * Commits the changes made so far in the editing dialog. + */ + fun doneEditingRoute(newValue: String) { + Log.d(TAG, "doneEditingRoute: $newValue") + editRoute(editingRoute.value, newValue) + stopEditingRoute() + } + + /** + * Cancels any current editing session and closes the dialog. + */ + fun stopEditingRoute() { + Log.d(TAG, "stopEditingRoute") + isPresentingDialog.set(false) + dialogTextFieldValue.set("") + editingRoute.set("") + } + + /** + * This makes the actual changes whenever adding or editing a route. + * If adding a new route, oldRoute will be empty. + * This function validates the input before making any changes. If newRoute + * is not a valid CIDR IPv4/IPv6 range, this function does nothing. + */ + private fun editRoute(oldRoute: String, newRoute: String) { + val currentRoutes = advertisedRoutes.value.toMutableList() + if (oldRoute == newRoute) { + Log.v(TAG, "Attempted to call editRoute with the same route: $newRoute") + return + } + if (currentRoutes.contains(newRoute)) { + Log.e(TAG, "Attempted to call editRoute with a duplicate route: $newRoute") + return + } + // Verify the newRoute is a valid IPv4 or IPv6 CIDR range. + val isValid = isValidCIDR(newRoute) + if (!isValid) { + Log.e(TAG, "Attempted to call editRoute with an invalid route: $newRoute") + return + } + val index = currentRoutes.indexOf(oldRoute) + if (index == -1) { + Log.v(TAG, "Adding new route: $newRoute") + currentRoutes.add(newRoute) + } else { + Log.v(TAG, "Updating route at index $index: $newRoute") + currentRoutes[index] = newRoute + } + advertisedRoutes.set(currentRoutes) + saveRoutesToPrefs() + } + + private fun saveRoutesToPrefs() { + val prefsOut = Ipn.MaskedPrefs() + prefsOut.AdvertiseRoutes = advertisedRoutes.value + Log.d(TAG, "Will save AdvertiseRoutes in the backend: $(advertisedRoutes.value)") + Client(viewModelScope).editPrefs(prefsOut, responseHandler = { result -> + if (result.isFailure) { + Log.e(TAG, "Error saving AdvertiseRoutes: ${result.exceptionOrNull()}") + return@editPrefs + } else { + Log.d( + TAG, + "AdvertiseRoutes set in backend. New value: ${result.getOrNull()?.AdvertiseRoutes}" + ) + } + }) + } + + companion object RouteValidation { + /** + * Returns true if the given String is a valid IPv4 or IPv6 CIDR range, false otherwise. + */ + fun isValidCIDR(newRoute: String): Boolean { + val cidrPattern = + Regex("(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])/(\\d+)") // IPv4 CIDR + val ipv6CidrPattern = + Regex("(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))/(\\d+)") // IPv6 CIDR + return cidrPattern.matches(newRoute) || ipv6CidrPattern.matches(newRoute) + } + } +} + diff --git a/android/src/main/res/drawable/pencil.xml b/android/src/main/res/drawable/pencil.xml new file mode 100644 index 0000000000..f378d2f350 --- /dev/null +++ b/android/src/main/res/drawable/pencil.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/src/main/res/drawable/xmark.xml b/android/src/main/res/drawable/xmark.xml new file mode 100644 index 0000000000..a5dd7dbec4 --- /dev/null +++ b/android/src/main/res/drawable/xmark.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index b315df9319..0c5d8e266c 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -304,5 +304,20 @@ Only one VPN can be active, and it appears another is already running. Before starting Tailscale, disable the other VPN. Go to Settings Cancel + Subnet routes + Advertise routes to machines that are not running Tailscale to make them available in your tailnet. Routes must be approved in the admin console. + Open KB Article + Delete route + Edit route + Enter a IPv4 or IPv6 route in CIDR format. + Advertised routes + No advertised routes + Add route + Invalid route + Valid route + e.g. 192.168.1.0/24 + Run as subnet router + Route traffic according to your network\'s rules. Some networks require this to access IP addresses that don\'t start with 100.x.y.z. + Subnet routing