ui: add ability to advertise Android device as subnet router (#595)
parent
a2850b1078
commit
9c3378d7eb
@ -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<String>,
|
||||||
|
isValueValidFlow: StateFlow<Boolean>,
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<Boolean> = 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<List<String>> = MutableStateFlow(listOf())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether we are presenting the add/edit dialog to set/change the value of a route.
|
||||||
|
*/
|
||||||
|
val isPresentingDialog: StateFlow<Boolean> = 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<String> = MutableStateFlow("")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value currently entered in the add/edit dialog text field.
|
||||||
|
*/
|
||||||
|
val dialogTextFieldValue: MutableStateFlow<String> = 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<Boolean> = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
|
||||||
|
|
||||||
|
<path android:fillColor="@android:color/white" android:pathData="M200,760L257,760L648,369L591,312L200,703L200,760ZM160,840Q143,840 131.5,828.5Q120,817 120,800L120,703Q120,687 126,672.5Q132,658 143,647L648,143Q660,132 674.5,126Q689,120 705,120Q721,120 736,126Q751,132 762,144L817,200Q829,211 834.5,226Q840,241 840,256Q840,272 834.5,286.5Q829,301 817,313L313,817Q302,828 287.5,834Q273,840 257,840L160,840ZM760,256L760,256L704,200L704,200L760,256ZM619,341L591,312L591,312L648,369L648,369L619,341Z"/>
|
||||||
|
|
||||||
|
</vector>
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
|
||||||
|
|
||||||
|
<path android:fillColor="@android:color/white" android:pathData="M480,536L284,732Q273,743 256,743Q239,743 228,732Q217,721 217,704Q217,687 228,676L424,480L228,284Q217,273 217,256Q217,239 228,228Q239,217 256,217Q273,217 284,228L480,424L676,228Q687,217 704,217Q721,217 732,228Q743,239 743,256Q743,273 732,284L536,480L732,676Q743,687 743,704Q743,721 732,732Q721,743 704,743Q687,743 676,732L480,536Z"/>
|
||||||
|
|
||||||
|
</vector>
|
||||||
Loading…
Reference in New Issue