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