android: add DNS Settings view (#233)

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 <andrea@gottardo.me>
pull/238/head
Andrea Gottardo 8 months ago committed by GitHub
parent f96e9b923f
commit e187a8db81
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -30,6 +30,7 @@ import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.view.AboutView import com.tailscale.ipn.ui.view.AboutView
import com.tailscale.ipn.ui.view.BackNavigation import com.tailscale.ipn.ui.view.BackNavigation
import com.tailscale.ipn.ui.view.BugReportView 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.ExitNodePicker
import com.tailscale.ipn.ui.view.MDMSettingsDebugView import com.tailscale.ipn.ui.view.MDMSettingsDebugView
import com.tailscale.ipn.ui.view.MainView import com.tailscale.ipn.ui.view.MainView
@ -80,6 +81,7 @@ class MainActivity : ComponentActivity() {
SettingsNav( SettingsNav(
onNavigateToBugReport = { navController.navigate("bugReport") }, onNavigateToBugReport = { navController.navigate("bugReport") },
onNavigateToAbout = { navController.navigate("about") }, onNavigateToAbout = { navController.navigate("about") },
onNavigateToDNSSettings = { navController.navigate("dnsSettings") },
onNavigateToTailnetLock = { navController.navigate("tailnetLock") }, onNavigateToTailnetLock = { navController.navigate("tailnetLock") },
onNavigateToMDMSettings = { navController.navigate("mdmSettings") }, onNavigateToMDMSettings = { navController.navigate("mdmSettings") },
onNavigateToManagedBy = { navController.navigate("managedBy") }, onNavigateToManagedBy = { navController.navigate("managedBy") },
@ -118,6 +120,7 @@ class MainActivity : ComponentActivity() {
PeerDetails(nav = backNav, it.arguments?.getString("nodeId") ?: "") PeerDetails(nav = backNav, it.arguments?.getString("nodeId") ?: "")
} }
composable("bugReport") { BugReportView(nav = backNav) } composable("bugReport") { BugReportView(nav = backNav) }
composable("dnsSettings") { DNSSettingsView(nav = backNav) }
composable("tailnetLock") { TailnetLockSetupView(nav = backNav) } composable("tailnetLock") { TailnetLockSetupView(nav = backNav) }
composable("about") { AboutView(nav = backNav) } composable("about") { AboutView(nav = backNav) }
composable("mdmSettings") { MDMSettingsDebugView(nav = backNav) } composable("mdmSettings") { MDMSettingsDebugView(nav = backNav) }

@ -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
}

@ -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<DnsType.Resolver>)
@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<ViewableRoute> =
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))
}
}
}
}
}
}

@ -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)
}
}
}

@ -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 <T : ViewModel> create(modelClass: Class<T>): T {
return DNSSettingsViewModel() as T
}
}
class DNSSettingsViewModel() : IpnViewModel() {
val enablementState: StateFlow<DNSEnablementState> =
MutableStateFlow(DNSEnablementState.NOT_RUNNING)
val dnsConfig: StateFlow<Tailcfg.DNSConfig?> = 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<Ipn.Prefs>) -> 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
}
}

@ -164,19 +164,6 @@ open class IpnViewModel : ViewModel() {
Client(viewModelScope).editPrefs(Ipn.MaskedPrefs(), callback) Client(viewModelScope).editPrefs(Ipn.MaskedPrefs(), callback)
} }
fun toggleCorpDNS(callback: (Result<Ipn.Prefs>) -> 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<Ipn.Prefs>) -> Unit) { fun toggleShieldsUp(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs = val prefs =
Notifier.prefs.value Notifier.prefs.value

@ -76,6 +76,7 @@ data class Setting(
data class SettingsNav( data class SettingsNav(
val onNavigateToBugReport: () -> Unit, val onNavigateToBugReport: () -> Unit,
val onNavigateToAbout: () -> Unit, val onNavigateToAbout: () -> Unit,
val onNavigateToDNSSettings: () -> Unit,
val onNavigateToTailnetLock: () -> Unit, val onNavigateToTailnetLock: () -> Unit,
val onNavigateToMDMSettings: () -> Unit, val onNavigateToMDMSettings: () -> Unit,
val onNavigateToManagedBy: () -> Unit, val onNavigateToManagedBy: () -> Unit,
@ -93,37 +94,15 @@ class SettingsViewModel(val navigation: SettingsNav) : IpnViewModel() {
// Display name for the logged in user // Display name for the logged in user
var isAdmin: StateFlow<Boolean> = MutableStateFlow(false) var isAdmin: StateFlow<Boolean> = 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<List<SettingBundle>> = MutableStateFlow(emptyList()) val settings: StateFlow<List<SettingBundle>> = MutableStateFlow(emptyList())
init { 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 { viewModelScope.launch {
mdmSettings.collect { mdmSettings -> mdmSettings.collect { mdmSettings ->
settings.set( settings.set(
listOf( listOf(
SettingBundle( // Empty for now
settings = SettingBundle(settings = listOf()),
listOf(
useDNSSetting,
)),
// General settings, always enabled // General settings, always enabled
SettingBundle(settings = footerSettings(mdmSettings)))) SettingBundle(settings = footerSettings(mdmSettings))))
} }
@ -136,12 +115,16 @@ class SettingsViewModel(val navigation: SettingsNav) : IpnViewModel() {
private fun footerSettings(mdmSettings: MDMSettings): List<Setting> = private fun footerSettings(mdmSettings: MDMSettings): List<Setting> =
listOfNotNull( listOfNotNull(
Setting(
titleRes = R.string.dns_settings,
SettingType.NAV,
onClick = { navigation.onNavigateToDNSSettings() },
enabled = MutableStateFlow(true)),
Setting( Setting(
titleRes = R.string.tailnet_lock, titleRes = R.string.tailnet_lock,
SettingType.NAV, SettingType.NAV,
onClick = { navigation.onNavigateToTailnetLock() }, onClick = { navigation.onNavigateToTailnetLock() },
enabled = MutableStateFlow(true) enabled = MutableStateFlow(true)),
),
Setting( Setting(
titleRes = R.string.about, titleRes = R.string.about,
SettingType.NAV, SettingType.NAV,

@ -115,5 +115,14 @@
<string name="tailnet_lock_key_explainer">Used to authorize changes to the Tailnet lock configuration.</string> <string name="tailnet_lock_key_explainer">Used to authorize changes to the Tailnet lock configuration.</string>
<string name="bug_report_id">Bug Report ID</string> <string name="bug_report_id">Bug Report ID</string>
<string name="learn_more">Learn more…</string> <string name="learn_more">Learn more…</string>
<string name="dns_settings">DNS Settings</string>
<string name="using_tailscale_dns">Using Tailscale DNS</string>
<string name="this_device_is_using_tailscale_to_resolve_dns_names">This device is using Tailscale to resolve DNS names.</string>
<string name="resolvers">Resolvers</string>
<string name="search_domains">Search Domains</string>
<string name="not_running">Not Running</string>
<string name="tailscale_is_not_running_this_device_is_using_the_system_dns_resolver">Tailscale is not running. This device is using the system\'s DNS resolver.</string>
<string name="this_device_is_using_the_system_dns_resolver">This device is using the system DNS resolver.</string>
<string name="not_using_tailscale_dns">Not Using Tailscale DNS</string>
</resources> </resources>

Loading…
Cancel
Save