From 3347647a468e56ac5af7eeb0970381b04fab1abf Mon Sep 17 00:00:00 2001 From: Andrea Gottardo Date: Thu, 21 Mar 2024 20:55:29 -0700 Subject: [PATCH] android: add DNS Settings view Updates ENG-2990 This PR adds a DNS Settings view with the same functionality and items as the iOS one. It also moves the 'Use Tailscale DNS Settings' item out of the main settings view into the detail view. Signed-off-by: Andrea Gottardo --- .../java/com/tailscale/ipn/MainActivity.kt | 3 + .../ipn/ui/util/FeatureStateRepresentation.kt | 18 +++ .../tailscale/ipn/ui/view/DNSSettingsView.kt | 89 ++++++++++++ .../tailscale/ipn/ui/view/FeatureStateView.kt | 44 ++++++ .../ipn/ui/viewModel/DNSSettingsViewModel.kt | 133 ++++++++++++++++++ .../ipn/ui/viewModel/IpnViewModel.kt | 13 -- .../ipn/ui/viewModel/SettingsViewModel.kt | 35 ++--- android/src/main/res/values/strings.xml | 9 ++ 8 files changed, 305 insertions(+), 39 deletions(-) create mode 100644 android/src/main/java/com/tailscale/ipn/ui/util/FeatureStateRepresentation.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/view/DNSSettingsView.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/view/FeatureStateView.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/viewModel/DNSSettingsViewModel.kt diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index dcf98e5..ae46272 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -30,6 +30,7 @@ import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.view.AboutView import com.tailscale.ipn.ui.view.BackNavigation import com.tailscale.ipn.ui.view.BugReportView +import com.tailscale.ipn.ui.view.DNSSettingsView import com.tailscale.ipn.ui.view.ExitNodePicker import com.tailscale.ipn.ui.view.MDMSettingsDebugView import com.tailscale.ipn.ui.view.MainView @@ -80,6 +81,7 @@ class MainActivity : ComponentActivity() { SettingsNav( onNavigateToBugReport = { navController.navigate("bugReport") }, onNavigateToAbout = { navController.navigate("about") }, + onNavigateToDNSSettings = { navController.navigate("dnsSettings") }, onNavigateToTailnetLock = { navController.navigate("tailnetLock") }, onNavigateToMDMSettings = { navController.navigate("mdmSettings") }, onNavigateToManagedBy = { navController.navigate("managedBy") }, @@ -118,6 +120,7 @@ class MainActivity : ComponentActivity() { PeerDetails(nav = backNav, it.arguments?.getString("nodeId") ?: "") } composable("bugReport") { BugReportView(nav = backNav) } + composable("dnsSettings") { DNSSettingsView(nav = backNav) } composable("tailnetLock") { TailnetLockSetupView(nav = backNav) } composable("about") { AboutView(nav = backNav) } composable("mdmSettings") { MDMSettingsDebugView(nav = backNav) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/FeatureStateRepresentation.kt b/android/src/main/java/com/tailscale/ipn/ui/util/FeatureStateRepresentation.kt new file mode 100644 index 0000000..8481dd9 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/util/FeatureStateRepresentation.kt @@ -0,0 +1,18 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.util + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.ui.graphics.Color + +// FeatureStateRepresentation represents the status of a feature +// in the UI, by providing a symbol, a title, and a caption. +// It is typically implemented as an enumeration. +interface FeatureStateRepresentation { + @get:DrawableRes val symbolDrawable: Int + val tint: Color + @get:StringRes val title: Int + @get:StringRes val caption: Int +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/DNSSettingsView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/DNSSettingsView.kt new file mode 100644 index 0000000..469c0a8 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/DNSSettingsView.kt @@ -0,0 +1,89 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.view + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.tailscale.ipn.R +import com.tailscale.ipn.ui.model.DnsType +import com.tailscale.ipn.ui.util.ClipboardValueView +import com.tailscale.ipn.ui.util.LoadingIndicator +import com.tailscale.ipn.ui.viewModel.DNSEnablementState +import com.tailscale.ipn.ui.viewModel.DNSSettingsViewModel +import com.tailscale.ipn.ui.viewModel.DNSSettingsViewModelFactory + +data class ViewableRoute(val name: String, val resolvers: List) + +@Composable +fun DNSSettingsView( + nav: BackNavigation, + model: DNSSettingsViewModel = viewModel(factory = DNSSettingsViewModelFactory()) +) { + val state: DNSEnablementState = model.enablementState.collectAsState().value + val resolvers = model.dnsConfig.collectAsState().value?.Resolvers ?: emptyList() + val domains = model.dnsConfig.collectAsState().value?.Domains ?: emptyList() + val routes: List = + model.dnsConfig.collectAsState().value?.Routes?.mapNotNull { entry -> + entry.value?.let { resolvers -> ViewableRoute(name = entry.key, resolvers) } ?: run { null } + } ?: emptyList() + + Scaffold(topBar = { Header(R.string.dns_settings, onBack = nav.onBack) }) { innerPadding -> + LoadingIndicator.Wrap { + LazyColumn(Modifier.padding(innerPadding).padding(16.dp)) { + item("state") { FeatureStateView(state) } + + item("toggle") { SettingRow(model.useDNSSetting) } + + if (resolvers.isNotEmpty()) { + item("resolversHeader") { + Text(stringResource(R.string.resolvers), fontWeight = FontWeight.SemiBold) + Spacer(Modifier.size(8.dp)) + } + + items(resolvers) { resolver -> + ClipboardValueView(resolver.Addr.orEmpty()) + Spacer(Modifier.size(8.dp)) + } + } + + if (domains.isNotEmpty()) { + item("domainsHeader") { + Text(stringResource(R.string.search_domains), fontWeight = FontWeight.SemiBold) + Spacer(Modifier.size(8.dp)) + } + items(domains) { domain -> + ClipboardValueView(domain) + Spacer(Modifier.size(8.dp)) + } + } + + if (routes.isNotEmpty()) { + item("routesHeader") { Spacer(Modifier.size(8.dp)) } + items(routes) { route -> + Text("Route: ${route.name}", fontWeight = FontWeight.SemiBold) + Spacer(Modifier.size(8.dp)) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + route.resolvers.forEach { ClipboardValueView(it.Addr.orEmpty()) } + } + Spacer(Modifier.size(16.dp)) + } + } + } + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/FeatureStateView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/FeatureStateView.kt new file mode 100644 index 0000000..538fbff --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/FeatureStateView.kt @@ -0,0 +1,44 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.view + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.tailscale.ipn.ui.util.FeatureStateRepresentation + +// FeatureStateView is a Composable that displays the contents of +// a FeatureStateRepresentation. +@Composable +fun FeatureStateView(state: FeatureStateRepresentation) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(bottom = 16.dp)) { + Icon( + painter = painterResource(state.symbolDrawable), + contentDescription = null, + tint = state.tint, + modifier = Modifier.size(64.dp)) + Column { + Text( + stringResource(state.title), + fontSize = MaterialTheme.typography.titleMedium.fontSize, + fontWeight = FontWeight.SemiBold) + Text( + stringResource(state.caption), + color = MaterialTheme.colorScheme.secondary) + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/DNSSettingsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/DNSSettingsViewModel.kt new file mode 100644 index 0000000..1539bef --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/DNSSettingsViewModel.kt @@ -0,0 +1,133 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.viewModel + +import android.util.Log +import androidx.annotation.StringRes +import androidx.compose.ui.graphics.Color +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +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.Tailcfg +import com.tailscale.ipn.ui.notifier.Notifier +import com.tailscale.ipn.ui.theme.ts_color_light_desctrutive_text +import com.tailscale.ipn.ui.theme.ts_color_light_green +import com.tailscale.ipn.ui.util.FeatureStateRepresentation +import com.tailscale.ipn.ui.util.LoadingIndicator +import com.tailscale.ipn.ui.util.set +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class DNSSettingsViewModelFactory() : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return DNSSettingsViewModel() as T + } +} + +class DNSSettingsViewModel() : IpnViewModel() { + val enablementState: StateFlow = + MutableStateFlow(DNSEnablementState.NOT_RUNNING) + val dnsConfig: StateFlow = MutableStateFlow(null) + + val useDNSSetting = + Setting( + R.string.use_ts_dns, + SettingType.SWITCH, + isOn = MutableStateFlow(Notifier.prefs.value?.CorpDNS), + onToggle = { + LoadingIndicator.start() + toggleCorpDNS { + LoadingIndicator.stop() + // (jonathan) TODO: Error handling + } + }) + + init { + viewModelScope.launch { + Notifier.netmap + .combine(Notifier.prefs) { netmap, prefs -> Pair(netmap, prefs) } + .stateIn(viewModelScope) + .collect { (netmap, prefs) -> + Log.d("DNSSettingsViewModel", "prefs: CorpDNS=" + prefs?.CorpDNS.toString()) + prefs?.let { + useDNSSetting.isOn?.set(it.CorpDNS) + useDNSSetting.enabled.set(true) + + if (it.CorpDNS) { + enablementState.set(DNSEnablementState.ENABLED) + } else { + enablementState.set(DNSEnablementState.DISABLED) + } + } + ?: run { + enablementState.set(DNSEnablementState.NOT_RUNNING) + useDNSSetting.enabled.set(false) + } + netmap?.let { dnsConfig.set(netmap.DNS) } + } + } + } + + fun toggleCorpDNS(callback: (Result) -> Unit) { + val prefs = + Notifier.prefs.value + ?: run { + callback(Result.failure(Exception("no prefs"))) + return@toggleCorpDNS + } + + val prefsOut = Ipn.MaskedPrefs() + prefsOut.CorpDNS = !prefs.CorpDNS + Client(viewModelScope).editPrefs(prefsOut, callback) + } +} + +enum class DNSEnablementState : FeatureStateRepresentation { + NOT_RUNNING { + override val title: Int + @StringRes get() = R.string.not_running + + override val caption: Int + @StringRes + get() = R.string.tailscale_is_not_running_this_device_is_using_the_system_dns_resolver + + override val tint: Color + get() = Color.Gray + + override val symbolDrawable: Int + get() = R.drawable.xmark_circle + }, + ENABLED { + override val title: Int + @StringRes get() = R.string.using_tailscale_dns + + override val caption: Int + @StringRes get() = R.string.this_device_is_using_tailscale_to_resolve_dns_names + + override val tint: Color + get() = ts_color_light_green + + override val symbolDrawable: Int + get() = R.drawable.check_circle + }, + DISABLED { + override val title: Int + @StringRes get() = R.string.not_using_tailscale_dns + + override val caption: Int + @StringRes get() = R.string.this_device_is_using_the_system_dns_resolver + + override val tint: Color + get() = ts_color_light_desctrutive_text + + override val symbolDrawable: Int + get() = R.drawable.xmark_circle + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt index 1933856..8c5a922 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt @@ -164,19 +164,6 @@ open class IpnViewModel : ViewModel() { Client(viewModelScope).editPrefs(Ipn.MaskedPrefs(), callback) } - fun toggleCorpDNS(callback: (Result) -> Unit) { - val prefs = - Notifier.prefs.value - ?: run { - callback(Result.failure(Exception("no prefs"))) - return@toggleCorpDNS - } - - val prefsOut = Ipn.MaskedPrefs() - prefsOut.CorpDNS = !prefs.CorpDNS - Client(viewModelScope).editPrefs(prefsOut, callback) - } - fun toggleShieldsUp(callback: (Result) -> Unit) { val prefs = Notifier.prefs.value 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 aaf71db..ed56836 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 @@ -76,6 +76,7 @@ data class Setting( data class SettingsNav( val onNavigateToBugReport: () -> Unit, val onNavigateToAbout: () -> Unit, + val onNavigateToDNSSettings: () -> Unit, val onNavigateToTailnetLock: () -> Unit, val onNavigateToMDMSettings: () -> Unit, val onNavigateToManagedBy: () -> Unit, @@ -93,37 +94,15 @@ class SettingsViewModel(val navigation: SettingsNav) : IpnViewModel() { // Display name for the logged in user var isAdmin: StateFlow = MutableStateFlow(false) - val useDNSSetting = - Setting( - R.string.use_ts_dns, - SettingType.SWITCH, - isOn = MutableStateFlow(Notifier.prefs.value?.CorpDNS), - onToggle = { - toggleCorpDNS { - // (jonathan) TODO: Error handling - } - }) - val settings: StateFlow> = MutableStateFlow(emptyList()) init { - viewModelScope.launch { - // Monitor our prefs for changes and update the displayed values accordingly - Notifier.prefs.collect { prefs -> - useDNSSetting.isOn?.set(prefs?.CorpDNS) - useDNSSetting.enabled.set(prefs != null) - } - } - viewModelScope.launch { mdmSettings.collect { mdmSettings -> settings.set( listOf( - SettingBundle( - settings = - listOf( - useDNSSetting, - )), + // Empty for now + SettingBundle(settings = listOf()), // General settings, always enabled SettingBundle(settings = footerSettings(mdmSettings)))) } @@ -136,12 +115,16 @@ class SettingsViewModel(val navigation: SettingsNav) : IpnViewModel() { private fun footerSettings(mdmSettings: MDMSettings): List = listOfNotNull( + Setting( + titleRes = R.string.dns_settings, + SettingType.NAV, + onClick = { navigation.onNavigateToDNSSettings() }, + enabled = MutableStateFlow(true)), Setting( titleRes = R.string.tailnet_lock, SettingType.NAV, onClick = { navigation.onNavigateToTailnetLock() }, - enabled = MutableStateFlow(true) - ), + enabled = MutableStateFlow(true)), Setting( titleRes = R.string.about, SettingType.NAV, diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index d60590a..ea09e51 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -115,5 +115,14 @@ Used to authorize changes to the Tailnet lock configuration. Bug Report ID Learn moreā€¦ + DNS Settings + Using Tailscale DNS + This device is using Tailscale to resolve DNS names. + Resolvers + Search Domains + Not Running + Tailscale is not running. This device is using the system\'s DNS resolver. + This device is using the system DNS resolver. + Not Using Tailscale DNS