android/ui: speed up loading of SettingsView

Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
pull/267/head
Percy Wegmann 2 months ago committed by Percy Wegmann
parent 2818195400
commit 8105271d25

@ -75,6 +75,7 @@ dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
// Compose dependencies. // Compose dependencies.
def composeBom = platform('androidx.compose:compose-bom:2023.06.01') def composeBom = platform('androidx.compose:compose-bom:2023.06.01')

@ -15,7 +15,6 @@ import android.content.ContentResolver
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.RestrictionsManager
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
@ -38,11 +37,6 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import com.tailscale.ipn.mdm.AlwaysNeverUserDecidesSetting
import com.tailscale.ipn.mdm.BooleanSetting
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.ShowHideSetting
import com.tailscale.ipn.mdm.StringSetting
import com.tailscale.ipn.ui.localapi.Client import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.localapi.Request import com.tailscale.ipn.ui.localapi.Request
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
@ -59,7 +53,6 @@ import java.net.InetAddress
import java.net.NetworkInterface import java.net.NetworkInterface
import java.security.GeneralSecurityException import java.security.GeneralSecurityException
import java.util.Locale import java.util.Locale
import java.util.Objects
class App : Application(), libtailscale.AppContext { class App : Application(), libtailscale.AppContext {
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@ -143,9 +136,7 @@ class App : Application(), libtailscale.AppContext {
fun setWantRunning(wantRunning: Boolean) { fun setWantRunning(wantRunning: Boolean) {
val callback: (Result<Ipn.Prefs>) -> Unit = { result -> val callback: (Result<Ipn.Prefs>) -> Unit = { result ->
result.fold( result.fold(
onSuccess = { _ -> onSuccess = { _ -> setTileStatus(wantRunning) },
setTileStatus(wantRunning)
},
onFailure = { error -> onFailure = { error ->
Log.d("TAG", "Set want running: failed to update preferences: ${error.message}") Log.d("TAG", "Set want running: failed to update preferences: ${error.message}")
}) })
@ -438,45 +429,6 @@ class App : Application(), libtailscale.AppContext {
return mm.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION return mm.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
} }
/*
The following methods are called by the syspolicy handler from Go via JNI.
*/
fun getSyspolicyBooleanValue(key: String?): Boolean? {
val manager: RestrictionsManager =
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
val mdmSettings = MDMSettings(manager)
val setting: BooleanSetting? = key?.let { BooleanSetting.valueOf(it) }
return setting?.let { mdmSettings.get(it) }
}
fun getSyspolicyStringValue(key: String): String {
val manager: RestrictionsManager =
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
val mdmSettings = MDMSettings(manager)
// Before looking for a StringSetting matching the given key, Go could also be
// asking us for either a AlwaysNeverUserDecidesSetting or a ShowHideSetting.
// Check the enum cases for these two before looking for a StringSetting.
return try {
val anuSetting: AlwaysNeverUserDecidesSetting = AlwaysNeverUserDecidesSetting.valueOf(key)
mdmSettings.get(anuSetting).value
} catch (eanu: IllegalArgumentException) { // AlwaysNeverUserDecidesSetting does not exist
try {
val showHideSetting: ShowHideSetting = ShowHideSetting.valueOf(key)
mdmSettings.get(showHideSetting).value
} catch (esh: IllegalArgumentException) {
try {
val stringSetting: StringSetting = StringSetting.valueOf(key)
val value: String? = mdmSettings.get(stringSetting)
Objects.requireNonNullElse<String>(value, "")
} catch (estr: IllegalArgumentException) {
Log.d("MDM", "$key is not defined on Android. Returning empty.")
""
}
}
}
}
fun prepareDownloadsFolder(): File { fun prepareDownloadsFolder(): File {
var downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) var downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)

@ -27,7 +27,6 @@ import com.tailscale.ipn.Peer.RequestCodes
import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.theme.AppTheme import com.tailscale.ipn.ui.theme.AppTheme
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
@ -47,7 +46,6 @@ import com.tailscale.ipn.ui.view.SettingsView
import com.tailscale.ipn.ui.view.TailnetLockSetupView import com.tailscale.ipn.ui.view.TailnetLockSetupView
import com.tailscale.ipn.ui.view.UserSwitcherView import com.tailscale.ipn.ui.view.UserSwitcherView
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.IpnViewModel
import com.tailscale.ipn.ui.viewModel.SettingsNav import com.tailscale.ipn.ui.viewModel.SettingsNav
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -177,7 +175,9 @@ class MainActivity : ComponentActivity() {
super.onResume() super.onResume()
val restrictionsManager = val restrictionsManager =
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
IpnViewModel.mdmSettings.set(MDMSettings(restrictionsManager)) lifecycleScope.launch(Dispatchers.IO) {
MDMSettings.update(App.getApplication(), restrictionsManager)
}
} }
override fun onStart() { override fun onStart() {
@ -196,7 +196,9 @@ class MainActivity : ComponentActivity() {
super.onStop() super.onStop()
val restrictionsManager = val restrictionsManager =
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
IpnViewModel.mdmSettings.set(MDMSettings(restrictionsManager)) lifecycleScope.launch(Dispatchers.IO) {
MDMSettings.update(App.getApplication(), restrictionsManager)
}
} }
private fun requestVpnPermission() { private fun requestVpnPermission() {

@ -5,65 +5,60 @@ package com.tailscale.ipn.mdm
import android.content.RestrictionsManager import android.content.RestrictionsManager
import com.tailscale.ipn.App import com.tailscale.ipn.App
import kotlin.reflect.KVisibility
import kotlin.reflect.full.declaredMemberProperties
import kotlin.reflect.full.isSubclassOf
import kotlin.reflect.jvm.jvmErasure
class MDMSettings(private val restrictionsManager: RestrictionsManager? = null) { object MDMSettings {
fun get(setting: BooleanSetting): Boolean { val forceEnabled = BooleanMDMSetting("ForceEnabled", "Force Enabled Connection Toggle")
restrictionsManager?.let {
if (it.applicationRestrictions.containsKey(setting.key)) {
return it.applicationRestrictions.getBoolean(setting.key)
}
}
return App.getApplication().getEncryptedPrefs().getBoolean(setting.key, false)
}
fun get(setting: StringSetting): String? { val exitNodeID = StringMDMSetting("ExitNodeID", "Forced Exit Node: Stable ID")
return restrictionsManager?.applicationRestrictions?.getString(setting.key) val keyExpirationNotice = StringMDMSetting("KeyExpirationNotice", "Key Expiration Notice Period")
?: App.getApplication().getEncryptedPrefs().getString(setting.key, null) val loginURL = StringMDMSetting("LoginURL", "Custom control server URL")
} val managedByCaption = StringMDMSetting("ManagedByCaption", "Managed By - Caption")
val managedByOrganizationName =
StringMDMSetting("ManagedByOrganizationName", "Managed By - Organization Name")
val managedByURL = StringMDMSetting("ManagedByURL", "Managed By - Support URL")
val tailnet = StringMDMSetting("Tailnet", "Recommended/Required Tailnet Name")
fun get(setting: AlwaysNeverUserDecidesSetting): AlwaysNeverUserDecidesValue { val hiddenNetworkDevices =
val storedString: String = StringArrayListMDMSetting("HiddenNetworkDevices", "Hidden Network Device Categories")
restrictionsManager?.applicationRestrictions?.getString(setting.key)
?: App.getApplication().getEncryptedPrefs().getString(setting.key, null) val allowIncomingConnections =
?: "user-decides" AlwaysNeverUserDecidesMDMSetting("AllowIncomingConnections", "Allow Incoming Connections")
return when (storedString) { val detectThirdPartyAppConflicts =
"always" -> { AlwaysNeverUserDecidesMDMSetting(
AlwaysNeverUserDecidesValue.Always "DetectThirdPartyAppConflicts", "Detect potentially problematic third-party apps")
} val exitNodeAllowLANAccess =
"never" -> { AlwaysNeverUserDecidesMDMSetting(
AlwaysNeverUserDecidesValue.Never "ExitNodeAllowLANAccess", "Allow LAN Access when using an exit node")
} val postureChecking =
else -> { AlwaysNeverUserDecidesMDMSetting("PostureChecking", "Enable Posture Checking")
AlwaysNeverUserDecidesValue.UserDecides val useTailscaleDNSSettings =
} AlwaysNeverUserDecidesMDMSetting("UseTailscaleDNSSettings", "Use Tailscale DNS Settings")
} val useTailscaleSubnets =
} AlwaysNeverUserDecidesMDMSetting("UseTailscaleSubnets", "Use Tailscale Subnets")
val exitNodesPicker = ShowHideMDMSetting("ExitNodesPicker", "Exit Nodes Picker")
val manageTailnetLock = ShowHideMDMSetting("ManageTailnetLock", "“Manage Tailnet lock” menu item")
val resetToDefaults = ShowHideMDMSetting("ResetToDefaults", "“Reset to Defaults” menu item")
val runExitNode = ShowHideMDMSetting("RunExitNode", "Run as Exit Node")
val testMenu = ShowHideMDMSetting("TestMenu", "Show Debug Menu")
val updateMenu = ShowHideMDMSetting("UpdateMenu", "“Update Available” menu item")
fun get(setting: ShowHideSetting): ShowHideValue { val allSettings by lazy {
val storedString: String = MDMSettings::class
restrictionsManager?.applicationRestrictions?.getString(setting.key) .declaredMemberProperties
?: App.getApplication().getEncryptedPrefs().getString(setting.key, null) .filter {
?: "show" it.visibility == KVisibility.PUBLIC &&
return when (storedString) { it.returnType.jvmErasure.isSubclassOf(MDMSetting::class)
"hide" -> { }
ShowHideValue.Hide .map { it.call(MDMSettings) as MDMSetting<*> }
}
else -> {
ShowHideValue.Show
}
}
} }
fun get(setting: StringArraySetting): Array<String>? { fun update(app: App, restrictionsManager: RestrictionsManager?) {
restrictionsManager?.let { val bundle = restrictionsManager?.applicationRestrictions
if (it.applicationRestrictions.containsKey(setting.key)) { allSettings.forEach { it.setFrom(bundle, app) }
return it.applicationRestrictions.getStringArray(setting.key)
}
}
return App.getApplication()
.getEncryptedPrefs()
.getStringSet(setting.key, HashSet<String>())
?.toTypedArray()
?.sortedArray()
} }
} }

@ -3,58 +3,88 @@
package com.tailscale.ipn.mdm package com.tailscale.ipn.mdm
enum class BooleanSetting(val key: String, val localizedTitle: String) { import android.os.Bundle
ForceEnabled("ForceEnabled", "Force Enabled Connection Toggle") import com.tailscale.ipn.App
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
abstract class MDMSetting<T>(defaultValue: T, val key: String, val localizedTitle: String) {
val flow: StateFlow<T> = MutableStateFlow<T>(defaultValue)
fun setFrom(bundle: Bundle?, app: App) {
val v = getFrom(bundle, app)
flow.set(v)
}
abstract fun getFrom(bundle: Bundle?, app: App): T
} }
enum class StringSetting(val key: String, val localizedTitle: String) { class BooleanMDMSetting(key: String, localizedTitle: String) :
ExitNodeID("ExitNodeID", "Forced Exit Node: Stable ID"), MDMSetting<Boolean>(false, key, localizedTitle) {
KeyExpirationNotice("KeyExpirationNotice", "Key Expiration Notice Period"), override fun getFrom(bundle: Bundle?, app: App) =
LoginURL("LoginURL", "Custom control server URL"), bundle?.getBoolean(key) ?: app.getEncryptedPrefs().getBoolean(key, false)
ManagedByCaption("ManagedByCaption", "Managed By - Caption"),
ManagedByOrganizationName("ManagedByOrganizationName", "Managed By - Organization Name"),
ManagedByURL("ManagedByURL", "Managed By - Support URL"),
Tailnet("Tailnet", "Recommended/Required Tailnet Name"),
} }
enum class StringArraySetting(val key: String, val localizedTitle: String) { class StringMDMSetting(key: String, localizedTitle: String) :
HiddenNetworkDevices("HiddenNetworkDevices", "Hidden Network Device Categories") MDMSetting<String?>(null, key, localizedTitle) {
override fun getFrom(bundle: Bundle?, app: App) =
bundle?.getString(key) ?: app.getEncryptedPrefs().getString(key, null)
} }
// A setting representing a String value which is set to either `always`, `never` or `user-decides`. class StringArrayListMDMSetting(key: String, localizedTitle: String) :
enum class AlwaysNeverUserDecidesSetting(val key: String, val localizedTitle: String) { MDMSetting<List<String>?>(null, key, localizedTitle) {
AllowIncomingConnections("AllowIncomingConnections", "Allow Incoming Connections"), override fun getFrom(bundle: Bundle?, app: App) =
DetectThirdPartyAppConflicts( bundle?.getStringArrayList(key)
"DetectThirdPartyAppConflicts", "Detect potentially problematic third-party apps"), ?: app.getEncryptedPrefs().getStringSet(key, HashSet<String>())?.toList()
ExitNodeAllowLANAccess("ExitNodeAllowLANAccess", "Allow LAN Access when using an exit node"),
PostureChecking("PostureChecking", "Enable Posture Checking"),
UseTailscaleDNSSettings("UseTailscaleDNSSettings", "Use Tailscale DNS Settings"),
UseTailscaleSubnets("UseTailscaleSubnets", "Use Tailscale Subnets")
} }
enum class AlwaysNeverUserDecidesValue(val value: String) { class AlwaysNeverUserDecidesMDMSetting(key: String, localizedTitle: String) :
MDMSetting<AlwaysNeverUserDecides>(AlwaysNeverUserDecides.UserDecides, key, localizedTitle) {
override fun getFrom(bundle: Bundle?, app: App): AlwaysNeverUserDecides {
val storedString =
bundle?.getString(key)
?: App.getApplication().getEncryptedPrefs().getString(key, null)
?: "user-decides"
return when (storedString) {
"always" -> {
AlwaysNeverUserDecides.Always
}
"never" -> {
AlwaysNeverUserDecides.Never
}
else -> {
AlwaysNeverUserDecides.UserDecides
}
}
}
}
class ShowHideMDMSetting(key: String, localizedTitle: String) :
MDMSetting<ShowHide>(ShowHide.Show, key, localizedTitle) {
override fun getFrom(bundle: Bundle?, app: App): ShowHide {
val storedString =
bundle?.getString(key)
?: App.getApplication().getEncryptedPrefs().getString(key, null)
?: "show"
return when (storedString) {
"hide" -> {
ShowHide.Hide
}
else -> {
ShowHide.Show
}
}
}
}
enum class AlwaysNeverUserDecides(val value: String) {
Always("always"), Always("always"),
Never("never"), Never("never"),
UserDecides("user-decides") UserDecides("user-decides")
} }
// A setting representing a String value which is set to either `show` or `hide`. enum class ShowHide(val value: String) {
enum class ShowHideSetting(val key: String, val localizedTitle: String) {
ExitNodesPicker("ExitNodesPicker", "Exit Nodes Picker"),
ManageTailnetLock("ManageTailnetLock", "“Manage Tailnet lock” menu item"),
ResetToDefaults("ResetToDefaults", "“Reset to Defaults” menu item"),
RunExitNode("RunExitNode", "Run as Exit Node"),
TestMenu("TestMenu", "Show Debug Menu"),
UpdateMenu("UpdateMenu", "“Update Available” menu item"),
}
enum class ShowHideValue(val value: String) {
Show("show"), Show("show"),
Hide("hide") Hide("hide")
} }
enum class NetworkDevices(val value: String) {
currentUser("current-user"),
otherUsers("other-users"),
taggedDevices("tagged-devices"),
}

@ -17,11 +17,8 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.AlwaysNeverUserDecidesSetting import com.tailscale.ipn.mdm.MDMSetting
import com.tailscale.ipn.mdm.BooleanSetting import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.ShowHideSetting
import com.tailscale.ipn.mdm.StringArraySetting
import com.tailscale.ipn.mdm.StringSetting
import com.tailscale.ipn.ui.util.itemsWithDividers import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.IpnViewModel import com.tailscale.ipn.ui.viewModel.IpnViewModel
@ -30,65 +27,30 @@ import com.tailscale.ipn.ui.viewModel.IpnViewModel
fun MDMSettingsDebugView(nav: BackNavigation, model: IpnViewModel = viewModel()) { fun MDMSettingsDebugView(nav: BackNavigation, model: IpnViewModel = viewModel()) {
Scaffold(topBar = { Header(R.string.current_mdm_settings, onBack = nav.onBack) }) { innerPadding Scaffold(topBar = { Header(R.string.current_mdm_settings, onBack = nav.onBack) }) { innerPadding
-> ->
val mdmSettings = IpnViewModel.mdmSettings.collectAsState().value
LazyColumn(modifier = Modifier.padding(innerPadding)) { LazyColumn(modifier = Modifier.padding(innerPadding)) {
itemsWithDividers(enumValues<BooleanSetting>(), key = { it.key }) { booleanSetting -> itemsWithDividers(MDMSettings.allSettings.sortedBy { "${it::class.java.name}|${it.key}" }) {
MDMSettingView( setting ->
title = booleanSetting.localizedTitle, MDMSettingView(setting)
caption = booleanSetting.key,
valueDescription = mdmSettings.get(booleanSetting).toString())
}
itemsWithDividers(enumValues<StringSetting>(), key = { it.key }, forceLeading = true) {
stringSetting ->
MDMSettingView(
title = stringSetting.localizedTitle,
caption = stringSetting.key,
valueDescription = mdmSettings.get(stringSetting).toString())
}
itemsWithDividers(enumValues<ShowHideSetting>(), key = { it.key }, forceLeading = true) {
showHideSetting ->
MDMSettingView(
title = showHideSetting.localizedTitle,
caption = showHideSetting.key,
valueDescription = mdmSettings.get(showHideSetting).toString())
}
itemsWithDividers(
enumValues<AlwaysNeverUserDecidesSetting>(), key = { it.key }, forceLeading = true) {
anuSetting ->
MDMSettingView(
title = anuSetting.localizedTitle,
caption = anuSetting.key,
valueDescription = mdmSettings.get(anuSetting).toString())
}
itemsWithDividers(enumValues<StringArraySetting>(), key = { it.key }, forceLeading = true) {
stringArraySetting ->
MDMSettingView(
title = stringArraySetting.localizedTitle,
caption = stringArraySetting.key,
valueDescription = mdmSettings.get(stringArraySetting).toString())
} }
} }
} }
} }
@Composable @Composable
fun MDMSettingView(title: String, caption: String, valueDescription: String) { fun MDMSettingView(setting: MDMSetting<*>) {
val value = setting.flow.collectAsState().value
ListItem( ListItem(
headlineContent = { Text(title, maxLines = 3) }, headlineContent = { Text(setting.localizedTitle, maxLines = 3) },
supportingContent = { supportingContent = {
Text( Text(
caption, setting.key,
fontSize = MaterialTheme.typography.labelSmall.fontSize, fontSize = MaterialTheme.typography.labelSmall.fontSize,
color = MaterialTheme.colorScheme.tertiary, color = MaterialTheme.colorScheme.tertiary,
fontFamily = FontFamily.Monospace) fontFamily = FontFamily.Monospace)
}, },
trailingContent = { trailingContent = {
Text( Text(
valueDescription, value.toString(),
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
maxLines = 1, maxLines = 1,

@ -17,7 +17,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.StringSetting import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.viewModel.IpnViewModel import com.tailscale.ipn.ui.viewModel.IpnViewModel
@Composable @Composable
@ -28,18 +28,19 @@ fun ManagedByView(nav: BackNavigation, model: IpnViewModel = viewModel()) {
Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically), Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
modifier = Modifier.fillMaxWidth().safeContentPadding()) { modifier = Modifier.fillMaxWidth().safeContentPadding()) {
val mdmSettings = IpnViewModel.mdmSettings.collectAsState().value val managedByOrganization =
mdmSettings.get(StringSetting.ManagedByOrganizationName)?.let { MDMSettings.managedByOrganizationName.flow.collectAsState().value
val managedByCaption = MDMSettings.managedByCaption.flow.collectAsState().value
val managedByURL = MDMSettings.managedByURL.flow.collectAsState().value
managedByOrganization?.let {
Text(stringResource(R.string.managed_by_explainer_orgName, it)) Text(stringResource(R.string.managed_by_explainer_orgName, it))
} ?: run { Text(stringResource(R.string.managed_by_explainer)) } } ?: run { Text(stringResource(R.string.managed_by_explainer)) }
mdmSettings.get(StringSetting.ManagedByCaption)?.let { managedByCaption?.let {
if (it.isNotEmpty()) { if (it.isNotEmpty()) {
Text(it) Text(it)
} }
} }
mdmSettings.get(StringSetting.ManagedByURL)?.let { managedByURL?.let { OpenURLButton(stringResource(R.string.open_support), it) }
OpenURLButton(stringResource(R.string.open_support), it)
}
} }
} }
} }

@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -26,11 +25,11 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.BuildConfig
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.theme.ts_color_dark_desctrutive_text import com.tailscale.ipn.ui.theme.ts_color_dark_desctrutive_text
import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.Setting import com.tailscale.ipn.ui.viewModel.Setting
import com.tailscale.ipn.ui.viewModel.SettingType import com.tailscale.ipn.ui.viewModel.SettingType
import com.tailscale.ipn.ui.viewModel.SettingsNav import com.tailscale.ipn.ui.viewModel.SettingsNav
@ -45,30 +44,44 @@ fun SettingsView(
val handler = LocalUriHandler.current val handler = LocalUriHandler.current
val user = viewModel.loggedInUser.collectAsState().value val user = viewModel.loggedInUser.collectAsState().value
val isAdmin = viewModel.isAdmin.collectAsState().value val isAdmin = viewModel.isAdmin.collectAsState().value
val settingsBundles = viewModel.settings.collectAsState().value val managedBy = viewModel.managedBy.collectAsState().value
Scaffold( Scaffold(
topBar = { Header(title = R.string.settings_title, onBack = settingsNav.onBackPressed) }) { topBar = { Header(title = R.string.settings_title, onBack = settingsNav.onBackPressed) }) {
innerPadding -> innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) { Column(modifier = Modifier.padding(innerPadding)) {
item { UserView(
UserView( profile = user,
profile = user, actionState = UserActionState.NAV,
actionState = UserActionState.NAV, onClick = viewModel.navigation.onNavigateToUserSwitcher)
onClick = viewModel.navigation.onNavigateToUserSwitcher)
}
if (isAdmin) { if (isAdmin) {
item { Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(4.dp)) AdminTextView { handler.openUri(Links.ADMIN_URL) }
AdminTextView { handler.openUri(Links.ADMIN_URL) }
}
} }
settingsBundles.forEach { bundle -> SettingRow(viewModel.dns)
item { Lists.SectionDivider() }
Lists.ItemDivider()
SettingRow(viewModel.tailnetLock)
Lists.ItemDivider()
SettingRow(viewModel.permissions)
itemsWithDividers(bundle.settings) { setting -> SettingRow(setting) } Lists.ItemDivider()
SettingRow(viewModel.about)
Lists.ItemDivider()
SettingRow(viewModel.bugReport)
if (BuildConfig.DEBUG) {
Lists.ItemDivider()
SettingRow(viewModel.mdmDebug)
}
managedBy?.let {
Lists.ItemDivider()
SettingRow(it)
} }
} }
} }
@ -76,50 +89,59 @@ fun SettingsView(
@Composable @Composable
fun SettingRow(setting: Setting) { fun SettingRow(setting: Setting) {
val enabled = setting.enabled.collectAsState().value
val swVal = setting.isOn?.collectAsState()?.value ?: false
val txtVal = setting.value?.collectAsState()?.value
Box { Box {
when (setting.type) { when (setting.type) {
SettingType.TEXT -> SettingType.TEXT -> TextRow(setting)
ListItem( SettingType.SWITCH -> SwitchRow(setting)
modifier = Modifier.clickable { if (enabled) setting.onClick() },
headlineContent = {
Text(
setting.title.getString(),
style = MaterialTheme.typography.bodyMedium,
color =
if (setting.destructive) ts_color_dark_desctrutive_text
else MaterialTheme.colorScheme.primary)
},
)
SettingType.SWITCH ->
ListItem(
modifier = Modifier.clickable { if (enabled) setting.onClick() },
headlineContent = { Text(setting.title.getString()) },
trailingContent = {
TintedSwitch(checked = swVal, onCheckedChange = setting.onToggle, enabled = enabled)
})
SettingType.NAV -> { SettingType.NAV -> {
ListItem( NavRow(setting)
modifier = Modifier.clickable { if (enabled) setting.onClick() },
headlineContent = {
Text(
setting.title.getString(),
style = MaterialTheme.typography.bodyMedium,
color =
if (setting.destructive) ts_color_dark_desctrutive_text
else MaterialTheme.colorScheme.primary)
},
supportingContent = {
txtVal?.let { Text(text = it, style = MaterialTheme.typography.bodyMedium) }
})
} }
} }
} }
} }
@Composable
private fun TextRow(setting: Setting) {
val enabled = setting.enabled.collectAsState().value
ListItem(
modifier = Modifier.clickable { if (enabled) setting.onClick() },
headlineContent = {
Text(
setting.title ?: stringResource(setting.titleRes),
style = MaterialTheme.typography.bodyMedium,
color =
if (setting.destructive) ts_color_dark_desctrutive_text
else MaterialTheme.colorScheme.primary)
},
)
}
@Composable
private fun SwitchRow(setting: Setting) {
val enabled = setting.enabled.collectAsState().value
val swVal = setting.isOn?.collectAsState()?.value ?: false
ListItem(
modifier = Modifier.clickable { if (enabled) setting.onClick() },
headlineContent = { Text(setting.title ?: stringResource(setting.titleRes)) },
trailingContent = {
TintedSwitch(checked = swVal, onCheckedChange = setting.onToggle, enabled = enabled)
})
}
@Composable
private fun NavRow(setting: Setting) {
ListItem(
modifier = Modifier.clickable { setting.onClick() },
headlineContent = {
Text(
setting.title ?: stringResource(setting.titleRes),
style = MaterialTheme.typography.bodyMedium,
color =
if (setting.destructive) ts_color_dark_desctrutive_text
else MaterialTheme.colorScheme.primary)
})
}
@Composable @Composable
fun AdminTextView(onNavigateToAdminConsole: () -> Unit) { fun AdminTextView(onNavigateToAdminConsole: () -> Unit) {
val adminStr = buildAnnotatedString { val adminStr = buildAnnotatedString {

@ -39,7 +39,7 @@ class DNSSettingsViewModel() : IpnViewModel() {
val useDNSSetting = val useDNSSetting =
Setting( Setting(
R.string.use_ts_dns, R.string.use_ts_dns,
SettingType.SWITCH, type = SettingType.SWITCH,
isOn = MutableStateFlow(Notifier.prefs.value?.CorpDNS), isOn = MutableStateFlow(Notifier.prefs.value?.CorpDNS),
onToggle = { onToggle = {
LoadingIndicator.start() LoadingIndicator.start()

@ -60,7 +60,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
val allowLANAccessSetting = val allowLANAccessSetting =
Setting( Setting(
R.string.allow_lan_access, R.string.allow_lan_access,
SettingType.SWITCH, type = SettingType.SWITCH,
isOn = MutableStateFlow(Notifier.prefs.value?.ExitNodeAllowLANAccess), isOn = MutableStateFlow(Notifier.prefs.value?.ExitNodeAllowLANAccess),
enabled = MutableStateFlow(true), enabled = MutableStateFlow(true),
onToggle = { onToggle = {

@ -12,7 +12,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App import com.tailscale.ipn.App
import com.tailscale.ipn.IPNReceiver import com.tailscale.ipn.IPNReceiver
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.localapi.Client import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnLocal
@ -28,10 +27,6 @@ import kotlinx.coroutines.launch
* notifications, managing login/logout, updating preferences, etc. * notifications, managing login/logout, updating preferences, etc.
*/ */
open class IpnViewModel : ViewModel() { open class IpnViewModel : ViewModel() {
companion object {
val mdmSettings: StateFlow<MDMSettings> = MutableStateFlow(MDMSettings())
}
protected val TAG = this::class.simpleName protected val TAG = this::class.simpleName
val loggedInUser: StateFlow<IpnLocal.LoginProfile?> = MutableStateFlow(null) val loggedInUser: StateFlow<IpnLocal.LoginProfile?> = MutableStateFlow(null)

@ -3,16 +3,11 @@
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.BuildConfig
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.StringSetting
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -25,13 +20,6 @@ enum class SettingType {
TEXT TEXT
} }
class ComposableStringFormatter(@StringRes val stringRes: Int, vararg val params: Any) {
@Composable fun getString(): String = stringResource(id = stringRes, *params)
}
// Represents a bundle of settings values that should be grouped together under a title
data class SettingBundle(val title: String? = null, val settings: List<Setting>)
// Represents a UI setting. // Represents a UI setting.
// title: The title of the setting // title: The title of the setting
// type: The type of setting // type: The type of setting
@ -45,32 +33,15 @@ data class SettingBundle(val title: String? = null, val settings: List<Setting>)
// isOn and onToggle, while navigation settings should supply an onClick and an optional // isOn and onToggle, while navigation settings should supply an onClick and an optional
// value // value
data class Setting( data class Setting(
val title: ComposableStringFormatter, val titleRes: Int = 0,
val title: String? = null,
val type: SettingType, val type: SettingType,
val destructive: Boolean = false, val destructive: Boolean = false,
val enabled: StateFlow<Boolean> = MutableStateFlow(true), val enabled: StateFlow<Boolean> = MutableStateFlow(true),
val value: StateFlow<String?>? = null,
val isOn: StateFlow<Boolean?>? = null, val isOn: StateFlow<Boolean?>? = null,
val onClick: () -> Unit = {}, val onClick: () -> Unit = {},
val onToggle: (Boolean) -> Unit = {} val onToggle: (Boolean) -> Unit = {}
) { )
constructor(
titleRes: Int,
type: SettingType,
enabled: StateFlow<Boolean> = MutableStateFlow(false),
value: StateFlow<String?>? = null,
isOn: StateFlow<Boolean?>? = null,
onClick: () -> Unit = {},
onToggle: (Boolean) -> Unit = {}
) : this(
title = ComposableStringFormatter(titleRes),
type = type,
enabled = enabled,
value = value,
isOn = isOn,
onClick = onClick,
onToggle = onToggle)
}
data class SettingsNav( data class SettingsNav(
val onNavigateToBugReport: () -> Unit, val onNavigateToBugReport: () -> Unit,
@ -94,17 +65,62 @@ 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 settings: StateFlow<List<SettingBundle>> = MutableStateFlow(emptyList()) val dns =
Setting(
titleRes = R.string.dns_settings,
type = SettingType.NAV,
onClick = { navigation.onNavigateToDNSSettings() },
enabled = MutableStateFlow(true))
val tailnetLock =
Setting(
titleRes = R.string.tailnet_lock,
type = SettingType.NAV,
onClick = { navigation.onNavigateToTailnetLock() },
enabled = MutableStateFlow(true))
val permissions =
Setting(
titleRes = R.string.permissions,
type = SettingType.NAV,
onClick = { navigation.onNavigateToPermissions() },
enabled = MutableStateFlow(true))
val about =
Setting(
titleRes = R.string.about,
type = SettingType.NAV,
onClick = { navigation.onNavigateToAbout() },
enabled = MutableStateFlow(true))
val bugReport =
Setting(
titleRes = R.string.bug_report,
type = SettingType.NAV,
onClick = { navigation.onNavigateToBugReport() },
enabled = MutableStateFlow(true))
val managedBy: StateFlow<Setting?> = MutableStateFlow(null)
val mdmDebug =
Setting(
titleRes = R.string.mdm_settings,
type = SettingType.NAV,
onClick = { navigation.onNavigateToMDMSettings() },
enabled = MutableStateFlow(true))
init { init {
viewModelScope.launch { viewModelScope.launch {
mdmSettings.collect { mdmSettings -> MDMSettings.managedByOrganizationName.flow.collect { managedByOrganization ->
settings.set( managedBy.set(
listOf( managedByOrganization?.let {
// Empty for now Setting(
SettingBundle(settings = listOf()), R.string.managed_by_orgName,
// General settings, always enabled it,
SettingBundle(settings = footerSettings(mdmSettings)))) SettingType.NAV,
onClick = { navigation.onNavigateToManagedBy() },
enabled = MutableStateFlow(true))
})
} }
} }
@ -112,48 +128,4 @@ class SettingsViewModel(val navigation: SettingsNav) : IpnViewModel() {
Notifier.netmap.collect { netmap -> isAdmin.set(netmap?.SelfNode?.isAdmin ?: false) } Notifier.netmap.collect { netmap -> isAdmin.set(netmap?.SelfNode?.isAdmin ?: false) }
} }
} }
private fun footerSettings(mdmSettings: MDMSettings): List<Setting> =
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)),
Setting(
titleRes = R.string.permissions,
SettingType.NAV,
onClick = { navigation.onNavigateToPermissions() },
enabled = MutableStateFlow(true)),
Setting(
titleRes = R.string.about,
SettingType.NAV,
onClick = { navigation.onNavigateToAbout() },
enabled = MutableStateFlow(true)),
Setting(
titleRes = R.string.bug_report,
SettingType.NAV,
onClick = { navigation.onNavigateToBugReport() },
enabled = MutableStateFlow(true)),
mdmSettings.get(StringSetting.ManagedByOrganizationName)?.let {
Setting(
ComposableStringFormatter(R.string.managed_by_orgName, it),
SettingType.NAV,
onClick = { navigation.onNavigateToManagedBy() },
enabled = MutableStateFlow(true))
},
if (BuildConfig.DEBUG) {
Setting(
titleRes = R.string.mdm_settings,
SettingType.NAV,
onClick = { navigation.onNavigateToMDMSettings() },
enabled = MutableStateFlow(true))
} else {
null
})
} }

@ -15,14 +15,11 @@ class UserSwitcherViewModel : IpnViewModel() {
val showDialog: StateFlow<ErrorDialogType?> = MutableStateFlow(null) val showDialog: StateFlow<ErrorDialogType?> = MutableStateFlow(null)
val loginSetting = val loginSetting =
Setting( Setting(titleRes = R.string.reauthenticate, type = SettingType.NAV, onClick = { login {} })
title = ComposableStringFormatter(R.string.reauthenticate),
type = SettingType.NAV,
onClick = { login {} })
val logoutSetting = val logoutSetting =
Setting( Setting(
title = ComposableStringFormatter(R.string.log_out), titleRes = R.string.log_out,
destructive = true, destructive = true,
type = SettingType.TEXT, type = SettingType.TEXT,
onClick = { onClick = {
@ -35,7 +32,7 @@ class UserSwitcherViewModel : IpnViewModel() {
val addProfileSetting = val addProfileSetting =
Setting( Setting(
title = ComposableStringFormatter(R.string.add_account), titleRes = R.string.add_account,
type = SettingType.NAV, type = SettingType.NAV,
onClick = { onClick = {
addProfile { addProfile {

Loading…
Cancel
Save