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
parent
f96e9b923f
commit
e187a8db81
@ -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
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue