android: Add settings screen (#196)

updates tailscale/corp#18202
updates ENG-2854

Adds a basic settings screen.  This isn't correctly localized, but that's on the way.

Adds the required hooks to edit prefs via localAPI.

Adds basic but incomplete login/logout flow.

Fixes the sorting of nodes on the main screen and fixes the proper display of your current node details.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
pull/197/head
Jonathan Nobels 2 months ago committed by GitHub
parent 3926cf4b56
commit bf0e56469f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -29,7 +29,7 @@ android {
ndkVersion "23.1.7779620"
compileSdkVersion 34
defaultConfig {
minSdkVersion 22
minSdkVersion 26
targetSdkVersion 34
versionCode 198
versionName "1.59.53-t0f042b981-g1017015de26"

@ -4,9 +4,13 @@
package com.tailscale.ipn
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@ -14,15 +18,20 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.tailscale.ipn.ui.service.IpnManager
import com.tailscale.ipn.ui.theme.AppTheme
import com.tailscale.ipn.ui.view.AboutView
import com.tailscale.ipn.ui.view.BugReportView
import com.tailscale.ipn.ui.view.ExitNodePicker
import com.tailscale.ipn.ui.view.MainView
import com.tailscale.ipn.ui.view.MainViewNavigation
import com.tailscale.ipn.ui.view.PeerDetails
import com.tailscale.ipn.ui.view.Settings
import com.tailscale.ipn.ui.view.SettingsNav
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
import com.tailscale.ipn.ui.viewModel.MainViewModel
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel
import com.tailscale.ipn.ui.viewModel.SettingsViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
@ -43,11 +52,16 @@ class MainActivity : ComponentActivity() {
onNavigateToExitNodes = { navController.navigate("exitNodes") }
)
val settingsNav = SettingsNav(
onNavigateToBugReport = { navController.navigate("bugReport") },
onNavigateToAbout = { navController.navigate("about") }
)
composable("main") {
MainView(viewModel = MainViewModel(manager.model, manager.actions), navigation = mainViewNav)
}
composable("settings") {
Settings(SettingsViewModel(manager.model))
Settings(SettingsViewModel(manager.model, manager.actions, settingsNav))
}
composable("exitNodes") {
ExitNodePicker(ExitNodePickerViewModel(manager.model))
@ -56,9 +70,37 @@ class MainActivity : ComponentActivity() {
PeerDetails(PeerDetailsViewModel(manager.model, nodeId = it.arguments?.getString("nodeId")
?: ""))
}
composable("bugReport") {
BugReportView()
}
composable("about") {
AboutView()
}
}
}
}
}
init {
// Watch the model's browseToURL and launch the browser when it changes
// This will trigger the login flow
lifecycleScope.launch {
manager.model.browseToURL.collect { url ->
url?.let {
Dispatchers.Main.run {
login(it)
}
}
}
}
}
fun login(url: String) {
// (jonathan) TODO: This is functional, but the navigation doesn't quite work
// as expected. There's probably a better built in way to do this. This will
// unblock in dev for the time being though.
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
startActivity(browserIntent)
}
}

@ -22,7 +22,6 @@ class LocalApiClient(private val scope: CoroutineScope) {
Log.d("LocalApiClient", "LocalApiClient created")
}
companion object {
val isReady = CompletableDeferred<Boolean>()
@ -89,6 +88,11 @@ class LocalApiClient(private val scope: CoroutineScope) {
executeRequest(req)
}
fun editPrefs(prefs: Ipn.MaskedPrefs, responseHandler: (Result<Ipn.Prefs>) -> Unit) {
val req = LocalAPIRequest.editPrefs(prefs, responseHandler)
executeRequest<Ipn.Prefs>(req)
}
fun getProfiles(responseHandler: (Result<List<IpnLocal.LoginProfile>>) -> Unit) {
val req = LocalAPIRequest.profiles(responseHandler)
executeRequest(req)
@ -107,6 +111,14 @@ class LocalApiClient(private val scope: CoroutineScope) {
executeRequest<String>(req)
}
fun logout() {
val req = LocalAPIRequest.logout { result ->
result.success?.let { Log.d("LocalApiClient", "Logout started: $it") }
?: run { Log.e("LocalApiClient", "Error starting logout: ${result.error}") }
}
executeRequest<String>(req)
}
// (jonathan) TODO: A (likely) exhaustive list of localapi endpoints required for
// a fully functioning client. This is a work in progress and will be updated
// See: corp/xcode/Shared/LocalAPIClient.swift for the various verbs, parameters,
@ -123,8 +135,6 @@ class LocalApiClient(private val scope: CoroutineScope) {
// start
// startLoginInteractive
// logout
// profiles
// currentProfile
// addProfile
// switchProfile
// deleteProfile

@ -9,6 +9,7 @@ import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.IpnState
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import java.util.UUID
@ -46,8 +47,11 @@ private object Endpoint {
//
// (jonathan) TODO: Audit local API for all of the possible error results and clean
// it up if possible.
enum class APIErrorVals(private val rawValue: String) {
UNPARSEABLE_RESPONSE("Unparseable localAPI response"), NOT_READY("Not Ready");
enum class APIErrorVals(val rawValue: String) {
UNPARSEABLE_RESPONSE("Unparseable localAPI response"),
NOT_READY("Not Ready"),
NO_PREFS("Current prefs not available");
fun toError(): Error {
return Error(rawValue)
@ -55,31 +59,49 @@ enum class APIErrorVals(private val rawValue: String) {
}
class LocalAPIRequest<T>(
path: String,
val method: String,
val body: ByteArray? = null,
val parser: (ByteArray) -> Unit,
path: String,
val method: String,
val body: ByteArray? = null,
val parser: (ByteArray) -> Unit,
) {
val path = "/localapi/v0/$path"
val cookie = UUID.randomUUID().toString()
companion object {
val decoder = Json { ignoreUnknownKeys = true }
fun <T> get(path: String, body: ByteArray? = null, parser: (ByteArray) -> Unit) =
LocalAPIRequest<T>(
method = "GET", path = path, body = body, parser = parser
)
LocalAPIRequest<T>(
method = "GET",
path = path,
body = body,
parser = parser
)
fun <T> put(path: String, body: ByteArray? = null, parser: (ByteArray) -> Unit) =
LocalAPIRequest<T>(
method = "PUT", path = path, body = body, parser = parser
)
private fun <T> post(path: String, body: ByteArray? = null, parser: (ByteArray) -> Unit) =
LocalAPIRequest<T>(
method = "POST", path = path, body = body, parser = parser
)
LocalAPIRequest<T>(
method = "PUT",
path = path,
body = body,
parser = parser
)
fun <T> post(path: String, body: ByteArray? = null, parser: (ByteArray) -> Unit) =
LocalAPIRequest<T>(
method = "POST",
path = path,
body = body,
parser = parser
)
fun <T> patch(path: String, body: ByteArray? = null, parser: (ByteArray) -> Unit) =
LocalAPIRequest<T>(
method = "PATCH",
path = path,
body = body,
parser = parser
)
fun status(responseHandler: StatusResponseHandler): LocalAPIRequest<IpnState.Status> {
return get(Endpoint.STATUS) { resp ->
@ -99,6 +121,14 @@ class LocalAPIRequest<T>(
}
}
fun editPrefs(prefs: Ipn.MaskedPrefs, responseHandler: (Result<Ipn.Prefs>) -> Unit): LocalAPIRequest<Ipn.Prefs> {
val body = Json.encodeToString(prefs).toByteArray()
return patch(Endpoint.PREFS, body) { resp ->
responseHandler(decode<Ipn.Prefs>(resp))
}
}
fun profiles(responseHandler: (Result<List<IpnLocal.LoginProfile>>) -> Unit): LocalAPIRequest<List<IpnLocal.LoginProfile>> {
return get(Endpoint.PROFILES) { resp ->
responseHandler(decode<List<IpnLocal.LoginProfile>>(resp))
@ -117,6 +147,12 @@ class LocalAPIRequest<T>(
}
}
fun logout(responseHandler: (Result<String>) -> Unit): LocalAPIRequest<String> {
return post(Endpoint.LOGOUT) { resp ->
responseHandler(parseString(resp))
}
}
// Check if the response was a generic error
@OptIn(ExperimentalSerializationApi::class)
fun parseError(respData: ByteArray): Error {

@ -112,5 +112,9 @@ class IpnLocal {
val UserProfile: Tailcfg.UserProfile,
val NetworkProfile: Tailcfg.NetworkProfile? = null,
val LocalUserID: String,
)
) {
fun isEmpty(): Boolean {
return ID.isEmpty()
}
}
}

@ -31,6 +31,13 @@ class Netmap {
return UserProfiles[id.toString()]
}
fun getPeer(id: StableNodeID): Tailcfg.Node? {
if(id == SelfNode.StableID) {
return SelfNode
}
return Peers?.find { it.StableID == id }
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is NetworkMap) return false

@ -71,7 +71,10 @@ class Tailcfg {
var Capabilities: List<String>? = null,
var ComputedName: String,
var ComputedNameWithHost: String
)
) {
val isAdmin: Boolean
get() = (Capabilities ?: emptyList()).contains("https://tailscale.com/cap/is-admin")
}
@Serializable
data class Service(var Proto: String, var Port: Int, var Description: String? = null)

@ -37,12 +37,12 @@ class Notifier() {
// NotifyWatchOpt is a bitmask of options supplied to the notifier to specify which
// what we want to see on the Noitfy bus
enum class NotifyWatchOpt(val value: Int) {
engineUpdates(0),
initialState(1),
prefs(2),
netmap(4),
noPrivateKey(8),
initialTailFSShares(16)
engineUpdates(1),
initialState(2),
prefs(4),
netmap(8),
noPrivateKey(16),
initialTailFSShares(32)
}
companion object {

@ -14,6 +14,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
typealias PrefChangeCallback = (Result<Boolean>) -> Unit
// Abstracts the actions that can be taken by the UI so that the concept of an IPNManager
@ -22,6 +23,8 @@ data class IpnActions(
val startVPN: () -> Unit,
val stopVPN: () -> Unit,
val login: () -> Unit,
val logout: () -> Unit,
val openAdminConsole: () -> Unit,
val updatePrefs: (Ipn.MaskedPrefs, PrefChangeCallback) -> Unit
)
@ -35,30 +38,27 @@ class IpnManager {
val actions = IpnActions(
startVPN = { startVPN() },
stopVPN = { stopVPN() },
login = { login() },
login = { apiClient.startLoginInteractive() },
logout = { apiClient.logout() },
openAdminConsole = { /* TODO */ },
updatePrefs = { prefs, callback -> updatePrefs(prefs, callback) }
)
fun startVPN() {
val context = App.getApplication().applicationContext
val intent = Intent(context, IPNReceiver::class.java)
intent.action = "com.tailscale.ipn.CONNECT_VPN"
intent.action = IPNReceiver.INTENT_CONNECT_VPN
context.sendBroadcast(intent)
}
fun stopVPN() {
val context = App.getApplication().applicationContext
val intent = Intent(context, IPNReceiver::class.java)
intent.action = "com.tailscale.ipn.DISCONNECT_VPN"
intent.action = IPNReceiver.INTENT_DISCONNECT_VPN
context.sendBroadcast(intent)
}
fun login() {
apiClient.startLoginInteractive()
}
fun updatePrefs(prefs: Ipn.MaskedPrefs, callback: PrefChangeCallback) {
// (jonathan) TODO: Implement this in localAPI
//apiClient.updatePrefs(prefs)

@ -10,24 +10,21 @@ import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.notifier.Notifier
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class IpnModel(
notifier: Notifier,
private val apiClient: LocalApiClient,
scope: CoroutineScope
notifier: Notifier,
val apiClient: LocalApiClient,
val scope: CoroutineScope
) {
private var notifierSessions: MutableList<String> = mutableListOf()
private val _state: MutableStateFlow<Ipn.State> = MutableStateFlow(Ipn.State.NoState)
private val _netmap: MutableStateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
private val _prefs: MutableStateFlow<Ipn.Prefs?> = MutableStateFlow(null)
protected val _prefs: MutableStateFlow<Ipn.Prefs?> = MutableStateFlow(null)
private val _engineStatus: MutableStateFlow<Ipn.EngineStatus?> = MutableStateFlow(null)
private val _tailFSShares: MutableStateFlow<Map<String, String>?> = MutableStateFlow(null)
private val _browseToURL: MutableStateFlow<String?> = MutableStateFlow(null)
@ -36,7 +33,7 @@ class IpnModel(
private val _loggedInUser: MutableStateFlow<IpnLocal.LoginProfile?> = MutableStateFlow(null)
private val _loginProfiles: MutableStateFlow<List<IpnLocal.LoginProfile>?> =
MutableStateFlow(null)
MutableStateFlow(null)
val state: StateFlow<Ipn.State> = _state
@ -55,7 +52,6 @@ class IpnModel(
return prefs.value != null
}
// Backend Observation
private suspend fun loadUserProfiles() {
@ -63,17 +59,23 @@ class IpnModel(
apiClient.getProfiles { result ->
result.success?.let { users -> _loginProfiles.value = users }
?: run { Log.e("IpnManager", "Error loading profiles: ${result.error}") }
?: run { Log.e("IpnManager", "Error loading profiles: ${result.error}") }
}
apiClient.getCurrentProfile { result ->
result.success?.let { user -> _loggedInUser.value = user }
?: run { Log.e("IpnManager", "Error loading profiles: ${result.error}") }
?: run { Log.e("IpnManager", "Error loading profiles: ${result.error}") }
}
}
private fun onNotifyChange(notify: Ipn.Notify) {
notify.State?.let { state ->
// Refresh the user profiles if we're transitioning out of the
// NeedsLogin state.
if (_state.value == Ipn.State.NeedsLogin) {
scope.launch { loadUserProfiles() }
}
Log.d("IpnModel", "State changed: $state")
_state.value = Ipn.State.fromInt(state)
}

@ -0,0 +1,76 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.service
import com.tailscale.ipn.ui.localapi.APIErrorVals
import com.tailscale.ipn.ui.localapi.Result
import com.tailscale.ipn.ui.model.Ipn
// Handles all types of preference modifications typically invoked by the UI.
// Callers generally shouldn't care about the returned prefs value - the source of
// truth is the IPNModel, who's prefs flow will change in value to reflect the true
// value of the pref setting in the back end (and will match the value returned here).
// Generally, you will want to inspect the returned value in the callback for errors
// to indicate why a particular setting did not change in the interface.
//
// Usage:
// - User/Interface changed to new value. Render the new value.
// - Submit the new value to the PrefsEditor
// - Observe the prefs on the IpnModel and update the UI when/if the value changes.
// For a typical flow, the changed value should reflect the value already shown.
// - Inform the user of any error which may have occurred
//
// The "toggle' functions here will attempt to set the pref value to the inverse of
// what is currently known in the IpnModel.prefs. If IpnModel.prefs is not available,
// the callback will be called with a NO_PREFS error
fun IpnModel.setWantRunning(wantRunning: Boolean, callback: (Result<Ipn.Prefs>) -> Unit) {
Ipn.MaskedPrefs().WantRunning = wantRunning
apiClient.editPrefs(Ipn.MaskedPrefs(), callback)
}
fun IpnModel.toggleCorpDNS(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs = prefs.value ?: run {
callback(Result(Error(APIErrorVals.NO_PREFS.rawValue)))
return@toggleCorpDNS
}
val prefsOut = Ipn.MaskedPrefs()
prefsOut.CorpDNS = !prefs.CorpDNS
apiClient.editPrefs(prefsOut, callback)
}
fun IpnModel.toggleShieldsUp(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs = prefs.value ?: run {
callback(Result(Error(APIErrorVals.NO_PREFS.rawValue)))
return@toggleShieldsUp
}
val prefsOut = Ipn.MaskedPrefs()
prefsOut.ShieldsUp = !prefs.ShieldsUp
apiClient.editPrefs(prefsOut, callback)
}
fun IpnModel.setExitNodeId(id: String, callback: (Result<Ipn.Prefs>) -> Unit) {
val prefsOut = Ipn.MaskedPrefs()
prefsOut.ExitNodeId = id
apiClient.editPrefs(prefsOut, callback)
}
fun IpnModel.toggleRouteAll(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs = prefs.value ?: run {
callback(Result(Error(APIErrorVals.NO_PREFS.rawValue)))
return@toggleRouteAll
}
val prefsOut = Ipn.MaskedPrefs()
prefsOut.RouteAll = !prefs.RouteAll
apiClient.editPrefs(prefsOut, callback)
}

@ -32,10 +32,16 @@ class PeerCategorizer(val model: IpnModel) {
}
grouped[userId]?.add(peer)
}
val selfPeers = grouped[selfNode.User] ?: emptyList()
var selfPeers = (grouped[selfNode.User] ?: emptyList()).sortedBy { it.ComputedName }
grouped.remove(selfNode.User)
var sorted = grouped.map { (userId, peers) ->
val currentNode = selfPeers.first { it.ID == selfNode.ID }
currentNode.let {
selfPeers = selfPeers.filter { it.ID != currentNode.ID }
selfPeers = listOf(currentNode) + selfPeers
}
val sorted = grouped.map { (userId, peers) ->
val profile = netmap.userProfile(userId)
PeerSet(profile, peers)
}.sortedBy {

@ -0,0 +1,28 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
@Composable
fun settingsRowModifier(): Modifier {
return Modifier
.clip(shape = RoundedCornerShape(8.dp))
.background(color = MaterialTheme.colorScheme.secondaryContainer)
.fillMaxWidth()
}
@Composable
fun defaultPaddingModifier(): Modifier {
return Modifier.padding(8.dp)
}

@ -3,9 +3,43 @@
package com.tailscale.ipn.ui.util
import java.time.Instant
import java.time.format.DateTimeFormatter
import java.util.Date
class TimeUtil {
fun keyExpiryFromGoTime(goTime: String?): String {
// (jonathan) TODO: Turn these time strings into 'in 4 months', 'in 2 days', 'in 1 year', etc
return goTime ?: "Never"
val time = goTime ?: return ""
val expTime = epochMillisFromGoTime(time)
val now = Instant.now().toEpochMilli()
val diff = (expTime - now) / 1000
if(diff < 0){
return "expired"
}
return when (diff) {
in 0..60 -> "under a minute"
in 61..3600 -> "in ${diff / 60} minutes"
in 3601..86400 -> "in ${diff / 3600} hours"
in 86401..2592000 -> "in ${diff / 86400} days"
in 2592001..31536000 -> "in ${diff / 2592000} months"
else -> "in ${diff / 31536000} years"
}
}
fun epochMillisFromGoTime(goTime: String): Long {
val ta = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(goTime)
val i = Instant.from(ta)
return i.toEpochMilli()
}
fun dateFromGoString(goTime: String): Date {
val ta = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(goTime)
val i = Instant.from(ta)
return Date.from(i)
}
}

@ -173,7 +173,7 @@ fun ConnectView(user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAc
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "Not Connected", style = MaterialTheme.typography.titleMedium)
if (user != null) {
if (user != null && !user.isEmpty()) {
val tailnetName = user.NetworkProfile?.DomainName ?: ""
Text(
"Connect to your ${tailnetName} tailnet",

@ -22,10 +22,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.util.settingsRowModifier
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel
@ -51,10 +51,7 @@ fun PeerDetails(viewModel: PeerDetailsViewModel) {
Text(text = "TAILSCALE ADDRESSES", style = MaterialTheme.typography.titleMedium)
Column(modifier = Modifier
.clip(shape = RoundedCornerShape(8.dp))
.background(color = MaterialTheme.colorScheme.secondaryContainer)
.fillMaxWidth()) {
Column(modifier = settingsRowModifier()) {
viewModel.addresses.forEach {
AddressRow(address = it.address, type = it.typeString)
}
@ -62,10 +59,7 @@ fun PeerDetails(viewModel: PeerDetailsViewModel) {
Spacer(modifier = Modifier.size(16.dp))
Column(modifier = Modifier
.clip(shape = RoundedCornerShape(8.dp))
.background(color = MaterialTheme.colorScheme.secondaryContainer)
.fillMaxWidth()) {
Column(modifier = settingsRowModifier()) {
viewModel.info.forEach {
ValueRow(title = it.title, value = it.value)
}
@ -78,7 +72,6 @@ fun AddressRow(address: String, type: String) {
val localClipboardManager = LocalClipboardManager.current
Row(modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.clickable(onClick = { localClipboardManager.setText(AnnotatedString(address)) })) {
Column {
@ -102,3 +95,5 @@ fun ValueRow(title: String, value: String) {
}
}
}

@ -4,15 +4,137 @@
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
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.text.ClickableText
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.util.defaultPaddingModifier
import com.tailscale.ipn.ui.util.settingsRowModifier
import com.tailscale.ipn.ui.viewModel.Setting
import com.tailscale.ipn.ui.viewModel.SettingType
import com.tailscale.ipn.ui.viewModel.SettingsViewModel
data class SettingsNav(
val onNavigateToBugReport: () -> Unit,
val onNavigateToAbout: () -> Unit
)
@Composable
fun Settings(viewModel: SettingsViewModel) {
Column {
Text(text = "Future Home of Settings")
Column(modifier = defaultPaddingModifier()) {
viewModel.user?.let { user ->
UserView(profile = user, viewModel.isAdmin, viewModel.adminText(), onClick = { viewModel.ipnActions.openAdminConsole() })
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { viewModel.ipnActions.logout() }) {
Text(text = "Log Out")
}
} ?: run {
Button(onClick = { viewModel.ipnActions.login() }) {
Text(text = "Sign In")
}
}
Spacer(modifier = Modifier.height(8.dp))
viewModel.settings.forEach { settingBundle ->
Column(modifier = settingsRowModifier()) {
settingBundle.title?.let {
Text(text = it, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(8.dp))
}
settingBundle.settings.forEach { setting ->
when (setting.type) {
SettingType.NAV -> {
SettingsNavRow(setting)
}
SettingType.SWITCH -> {
SettingsSwitchRow(setting)
}
SettingType.NAV_WITH_TEXT -> {
SettingsNavRow(setting)
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
}
@Composable
fun UserView(profile: IpnLocal.LoginProfile?, isAdmin: Boolean, adminText: AnnotatedString, onClick: () -> Unit) {
Column(modifier = defaultPaddingModifier()) {
Column(modifier = settingsRowModifier().padding(8.dp)) {
Text(text = profile?.UserProfile?.DisplayName
?: "", style = MaterialTheme.typography.titleMedium)
Text(text = profile?.Name ?: "", style = MaterialTheme.typography.bodyMedium)
}
if (isAdmin) {
Column(modifier = Modifier.padding(horizontal = 12.dp)) {
ClickableText(text = adminText, style = MaterialTheme.typography.bodySmall, onClick = {
onClick()
})
}
}
}
}
@Composable
fun SettingsNavRow(setting: Setting) {
val txtVal = setting.value?.collectAsState()?.value ?: ""
val enabled = setting.enabled.collectAsState().value
Row(modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() }) {
Text(text = setting.title)
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Text(text = txtVal, style = MaterialTheme.typography.bodyMedium)
}
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
}
}
@Composable
fun SettingsSwitchRow(setting: Setting) {
val swVal = setting.isOn?.collectAsState()?.value ?: false
val enabled = setting.enabled.collectAsState().value
Row(modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() }, verticalAlignment = Alignment.CenterVertically) {
Text(text = setting.title)
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Switch(checked = swVal, onCheckedChange = setting.onToggle, enabled = enabled)
}
}
}
@Composable
fun BugReportView() {
Text(text = "Future Home of Bug Reporting")
}
@Composable
fun AboutView() {
Text(text = "Future Home of About")
}

@ -6,13 +6,14 @@ package com.tailscale.ipn.ui.viewModel
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel
import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.service.IpnModel
import com.tailscale.ipn.ui.util.DisplayAddress
import com.tailscale.ipn.ui.util.TimeUtil
data class PeerSettingInfo(val title: String, val value: String)
class PeerDetailsViewModel(val model: IpnModel, val nodeId: String) : ViewModel() {
class PeerDetailsViewModel(val model: IpnModel, val nodeId: StableNodeID) : ViewModel() {
var addresses: List<DisplayAddress> = emptyList()
var info: List<PeerSettingInfo> = emptyList()
@ -22,7 +23,7 @@ class PeerDetailsViewModel(val model: IpnModel, val nodeId: String) : ViewModel(
val connectedColor: Color
init {
val peer = model.netmap.value?.Peers?.find { it.StableID == nodeId }
val peer = model.netmap.value?.getPeer(nodeId)
peer?.Addresses?.let {
addresses = it.map { addr ->
DisplayAddress(addr)
@ -36,7 +37,6 @@ class PeerDetailsViewModel(val model: IpnModel, val nodeId: String) : ViewModel(
)
}
nodeName = peer?.ComputedName ?: ""
connectedStr = if (peer?.Online == true) "Connected" else "Not Connected"
connectedColor = if (peer?.Online == true) Color.Green else Color.Gray

@ -1,12 +1,97 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.ui.service.IpnActions
import com.tailscale.ipn.ui.service.IpnModel
import com.tailscale.ipn.ui.service.toggleCorpDNS
import com.tailscale.ipn.ui.view.SettingsNav
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
enum class SettingType { NAV, SWITCH, NAV_WITH_TEXT }
// Represents a UI setting.
// title: The title of the setting
// type: The type of setting
// enabled: Whether the setting is enabled
// value: The value of the setting for textual settings
// isOn: The value of the setting for switch settings
// onClick: The action to take when the setting is clicked (typicall for navigation)
// onToggle: The action to take when the setting is toggled (typically for switches)
//
// Behavior is undefined if you mix the types here. Switch settings should supply an
// isOn and onToggle, while navigation settings should supply an onClick and an optional
// value
data class Setting(
val title: String,
val type: SettingType,
val enabled: MutableStateFlow<Boolean> = MutableStateFlow(false),
val value: MutableStateFlow<String?>? = null,
val isOn: MutableStateFlow<Boolean?>? = null,
val onClick: () -> Unit = {},
val onToggle: (Boolean) -> Unit = {})
data class SettingBundle(val title: String? = null, val settings: List<Setting>)
class SettingsViewModel(val model: IpnModel, val ipnActions: IpnActions, val navigation: SettingsNav) : ViewModel() {
// The logged in user
val user = model.loggedInUser.value
// Display name for the logged in user
val userName = user?.UserProfile?.DisplayName ?: ""
val tailnetName = user?.Name ?: ""
val isAdmin = model.netmap.value?.SelfNode?.isAdmin ?: false
val useDNSSetting = Setting(
"Use Tailscale DNS",
SettingType.SWITCH,
isOn = MutableStateFlow(model.prefs.value?.CorpDNS),
onToggle = {
model.toggleCorpDNS {
// (jonathan) TODO: Error handling
}
})
init {
viewModelScope.launch {
// Monitor our prefs for changes and update the displayed values accordingly
model.prefs.collect { prefs ->
useDNSSetting.isOn?.value = prefs?.CorpDNS
useDNSSetting.enabled?.value = prefs != null
}
}
}
val settings: List<SettingBundle> = listOf(
SettingBundle(settings = listOf(
useDNSSetting,
)),
// General settings, always enabled
SettingBundle(settings = listOf(
Setting("About", SettingType.NAV, onClick = { navigation.onNavigateToAbout() }, enabled = MutableStateFlow(true)),
Setting("Bug Report", SettingType.NAV, onClick = { navigation.onNavigateToBugReport() }, enabled = MutableStateFlow(true))
))
)
class SettingsViewModel(val model: IpnModel) : ViewModel() {
fun adminText(): AnnotatedString {
val annotatedString = buildAnnotatedString {
append("You can manage your account from the admin console. ")
pushStringAnnotation(tag = "policy", annotation = "https://google.com/policy")
withStyle(style = SpanStyle(color = Color.Blue)) {
append("View admin console...")
}
pop()
}
return annotatedString
}
}
Loading…
Cancel
Save