android/ui: speed up loading of SettingsView

Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
kari/closesearch
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-android:1.8.0"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
// Compose dependencies.
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.Context
import android.content.Intent
import android.content.RestrictionsManager
import android.content.SharedPreferences
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
@ -38,11 +37,6 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.security.crypto.EncryptedSharedPreferences
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.Request
import com.tailscale.ipn.ui.model.Ipn
@ -59,7 +53,6 @@ import java.net.InetAddress
import java.net.NetworkInterface
import java.security.GeneralSecurityException
import java.util.Locale
import java.util.Objects
class App : Application(), libtailscale.AppContext {
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@ -143,9 +136,7 @@ class App : Application(), libtailscale.AppContext {
fun setWantRunning(wantRunning: Boolean) {
val callback: (Result<Ipn.Prefs>) -> Unit = { result ->
result.fold(
onSuccess = { _ ->
setTileStatus(wantRunning)
},
onSuccess = { _ -> setTileStatus(wantRunning) },
onFailure = { error ->
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
}
/*
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 {
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.ui.notifier.Notifier
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.BackNavigation
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.UserSwitcherView
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.IpnViewModel
import com.tailscale.ipn.ui.viewModel.SettingsNav
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -177,7 +175,9 @@ class MainActivity : ComponentActivity() {
super.onResume()
val 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() {
@ -196,7 +196,9 @@ class MainActivity : ComponentActivity() {
super.onStop()
val 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() {

@ -5,65 +5,60 @@ package com.tailscale.ipn.mdm
import android.content.RestrictionsManager
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) {
fun get(setting: BooleanSetting): Boolean {
restrictionsManager?.let {
if (it.applicationRestrictions.containsKey(setting.key)) {
return it.applicationRestrictions.getBoolean(setting.key)
}
}
return App.getApplication().getEncryptedPrefs().getBoolean(setting.key, false)
}
object MDMSettings {
val forceEnabled = BooleanMDMSetting("ForceEnabled", "Force Enabled Connection Toggle")
fun get(setting: StringSetting): String? {
return restrictionsManager?.applicationRestrictions?.getString(setting.key)
?: App.getApplication().getEncryptedPrefs().getString(setting.key, null)
}
val exitNodeID = StringMDMSetting("ExitNodeID", "Forced Exit Node: Stable ID")
val keyExpirationNotice = StringMDMSetting("KeyExpirationNotice", "Key Expiration Notice Period")
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 storedString: String =
restrictionsManager?.applicationRestrictions?.getString(setting.key)
?: App.getApplication().getEncryptedPrefs().getString(setting.key, null)
?: "user-decides"
return when (storedString) {
"always" -> {
AlwaysNeverUserDecidesValue.Always
}
"never" -> {
AlwaysNeverUserDecidesValue.Never
}
else -> {
AlwaysNeverUserDecidesValue.UserDecides
}
}
}
val hiddenNetworkDevices =
StringArrayListMDMSetting("HiddenNetworkDevices", "Hidden Network Device Categories")
val allowIncomingConnections =
AlwaysNeverUserDecidesMDMSetting("AllowIncomingConnections", "Allow Incoming Connections")
val detectThirdPartyAppConflicts =
AlwaysNeverUserDecidesMDMSetting(
"DetectThirdPartyAppConflicts", "Detect potentially problematic third-party apps")
val exitNodeAllowLANAccess =
AlwaysNeverUserDecidesMDMSetting(
"ExitNodeAllowLANAccess", "Allow LAN Access when using an exit node")
val postureChecking =
AlwaysNeverUserDecidesMDMSetting("PostureChecking", "Enable Posture Checking")
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 storedString: String =
restrictionsManager?.applicationRestrictions?.getString(setting.key)
?: App.getApplication().getEncryptedPrefs().getString(setting.key, null)
?: "show"
return when (storedString) {
"hide" -> {
ShowHideValue.Hide
}
else -> {
ShowHideValue.Show
}
}
val allSettings by lazy {
MDMSettings::class
.declaredMemberProperties
.filter {
it.visibility == KVisibility.PUBLIC &&
it.returnType.jvmErasure.isSubclassOf(MDMSetting::class)
}
.map { it.call(MDMSettings) as MDMSetting<*> }
}
fun get(setting: StringArraySetting): Array<String>? {
restrictionsManager?.let {
if (it.applicationRestrictions.containsKey(setting.key)) {
return it.applicationRestrictions.getStringArray(setting.key)
}
}
return App.getApplication()
.getEncryptedPrefs()
.getStringSet(setting.key, HashSet<String>())
?.toTypedArray()
?.sortedArray()
fun update(app: App, restrictionsManager: RestrictionsManager?) {
val bundle = restrictionsManager?.applicationRestrictions
allSettings.forEach { it.setFrom(bundle, app) }
}
}

@ -3,58 +3,88 @@
package com.tailscale.ipn.mdm
enum class BooleanSetting(val key: String, val localizedTitle: String) {
ForceEnabled("ForceEnabled", "Force Enabled Connection Toggle")
import android.os.Bundle
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) {
ExitNodeID("ExitNodeID", "Forced Exit Node: Stable ID"),
KeyExpirationNotice("KeyExpirationNotice", "Key Expiration Notice Period"),
LoginURL("LoginURL", "Custom control server URL"),
ManagedByCaption("ManagedByCaption", "Managed By - Caption"),
ManagedByOrganizationName("ManagedByOrganizationName", "Managed By - Organization Name"),
ManagedByURL("ManagedByURL", "Managed By - Support URL"),
Tailnet("Tailnet", "Recommended/Required Tailnet Name"),
class BooleanMDMSetting(key: String, localizedTitle: String) :
MDMSetting<Boolean>(false, key, localizedTitle) {
override fun getFrom(bundle: Bundle?, app: App) =
bundle?.getBoolean(key) ?: app.getEncryptedPrefs().getBoolean(key, false)
}
enum class StringArraySetting(val key: String, val localizedTitle: String) {
HiddenNetworkDevices("HiddenNetworkDevices", "Hidden Network Device Categories")
class StringMDMSetting(key: String, localizedTitle: String) :
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`.
enum class AlwaysNeverUserDecidesSetting(val key: String, val localizedTitle: String) {
AllowIncomingConnections("AllowIncomingConnections", "Allow Incoming Connections"),
DetectThirdPartyAppConflicts(
"DetectThirdPartyAppConflicts", "Detect potentially problematic third-party apps"),
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")
class StringArrayListMDMSetting(key: String, localizedTitle: String) :
MDMSetting<List<String>?>(null, key, localizedTitle) {
override fun getFrom(bundle: Bundle?, app: App) =
bundle?.getStringArrayList(key)
?: app.getEncryptedPrefs().getStringSet(key, HashSet<String>())?.toList()
}
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"),
Never("never"),
UserDecides("user-decides")
}
// A setting representing a String value which is set to either `show` or `hide`.
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) {
enum class ShowHide(val value: String) {
Show("show"),
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.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.AlwaysNeverUserDecidesSetting
import com.tailscale.ipn.mdm.BooleanSetting
import com.tailscale.ipn.mdm.ShowHideSetting
import com.tailscale.ipn.mdm.StringArraySetting
import com.tailscale.ipn.mdm.StringSetting
import com.tailscale.ipn.mdm.MDMSetting
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.util.itemsWithDividers
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()) {
Scaffold(topBar = { Header(R.string.current_mdm_settings, onBack = nav.onBack) }) { innerPadding
->
val mdmSettings = IpnViewModel.mdmSettings.collectAsState().value
LazyColumn(modifier = Modifier.padding(innerPadding)) {
itemsWithDividers(enumValues<BooleanSetting>(), key = { it.key }) { booleanSetting ->
MDMSettingView(
title = booleanSetting.localizedTitle,
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())
itemsWithDividers(MDMSettings.allSettings.sortedBy { "${it::class.java.name}|${it.key}" }) {
setting ->
MDMSettingView(setting)
}
}
}
}
@Composable
fun MDMSettingView(title: String, caption: String, valueDescription: String) {
fun MDMSettingView(setting: MDMSetting<*>) {
val value = setting.flow.collectAsState().value
ListItem(
headlineContent = { Text(title, maxLines = 3) },
headlineContent = { Text(setting.localizedTitle, maxLines = 3) },
supportingContent = {
Text(
caption,
setting.key,
fontSize = MaterialTheme.typography.labelSmall.fontSize,
color = MaterialTheme.colorScheme.tertiary,
fontFamily = FontFamily.Monospace)
},
trailingContent = {
Text(
valueDescription,
value.toString(),
color = MaterialTheme.colorScheme.secondary,
fontFamily = FontFamily.Monospace,
maxLines = 1,

@ -17,7 +17,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.StringSetting
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.viewModel.IpnViewModel
@Composable
@ -28,18 +28,19 @@ fun ManagedByView(nav: BackNavigation, model: IpnViewModel = viewModel()) {
Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.Start,
modifier = Modifier.fillMaxWidth().safeContentPadding()) {
val mdmSettings = IpnViewModel.mdmSettings.collectAsState().value
mdmSettings.get(StringSetting.ManagedByOrganizationName)?.let {
val managedByOrganization =
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))
} ?: run { Text(stringResource(R.string.managed_by_explainer)) }
mdmSettings.get(StringSetting.ManagedByCaption)?.let {
managedByCaption?.let {
if (it.isNotEmpty()) {
Text(it)
}
}
mdmSettings.get(StringSetting.ManagedByURL)?.let {
OpenURLButton(stringResource(R.string.open_support), it)
}
managedByURL?.let { 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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.ListItem
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.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.BuildConfig
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.theme.ts_color_dark_desctrutive_text
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.SettingType
import com.tailscale.ipn.ui.viewModel.SettingsNav
@ -45,30 +44,44 @@ fun SettingsView(
val handler = LocalUriHandler.current
val user = viewModel.loggedInUser.collectAsState().value
val isAdmin = viewModel.isAdmin.collectAsState().value
val settingsBundles = viewModel.settings.collectAsState().value
val managedBy = viewModel.managedBy.collectAsState().value
Scaffold(
topBar = { Header(title = R.string.settings_title, onBack = settingsNav.onBackPressed) }) {
innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item {
UserView(
profile = user,
actionState = UserActionState.NAV,
onClick = viewModel.navigation.onNavigateToUserSwitcher)
}
Column(modifier = Modifier.padding(innerPadding)) {
UserView(
profile = user,
actionState = UserActionState.NAV,
onClick = viewModel.navigation.onNavigateToUserSwitcher)
if (isAdmin) {
item {
Spacer(modifier = Modifier.height(4.dp))
AdminTextView { handler.openUri(Links.ADMIN_URL) }
}
Spacer(modifier = Modifier.height(4.dp))
AdminTextView { handler.openUri(Links.ADMIN_URL) }
}
settingsBundles.forEach { bundle ->
item { Lists.SectionDivider() }
SettingRow(viewModel.dns)
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
fun SettingRow(setting: Setting) {
val enabled = setting.enabled.collectAsState().value
val swVal = setting.isOn?.collectAsState()?.value ?: false
val txtVal = setting.value?.collectAsState()?.value
Box {
when (setting.type) {
SettingType.TEXT ->
ListItem(
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.TEXT -> TextRow(setting)
SettingType.SWITCH -> SwitchRow(setting)
SettingType.NAV -> {
ListItem(
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) }
})
NavRow(setting)
}
}
}
}
@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
fun AdminTextView(onNavigateToAdminConsole: () -> Unit) {
val adminStr = buildAnnotatedString {

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

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

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

@ -3,16 +3,11 @@
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.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.BuildConfig
import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.StringSetting
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow
@ -25,13 +20,6 @@ enum class SettingType {
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.
// title: The title of the 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
// value
data class Setting(
val title: ComposableStringFormatter,
val titleRes: Int = 0,
val title: String? = null,
val type: SettingType,
val destructive: Boolean = false,
val enabled: StateFlow<Boolean> = MutableStateFlow(true),
val value: StateFlow<String?>? = null,
val isOn: StateFlow<Boolean?>? = null,
val onClick: () -> 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(
val onNavigateToBugReport: () -> Unit,
@ -94,17 +65,62 @@ class SettingsViewModel(val navigation: SettingsNav) : IpnViewModel() {
// Display name for the logged in user
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 {
viewModelScope.launch {
mdmSettings.collect { mdmSettings ->
settings.set(
listOf(
// Empty for now
SettingBundle(settings = listOf()),
// General settings, always enabled
SettingBundle(settings = footerSettings(mdmSettings))))
MDMSettings.managedByOrganizationName.flow.collect { managedByOrganization ->
managedBy.set(
managedByOrganization?.let {
Setting(
R.string.managed_by_orgName,
it,
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) }
}
}
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 loginSetting =
Setting(
title = ComposableStringFormatter(R.string.reauthenticate),
type = SettingType.NAV,
onClick = { login {} })
Setting(titleRes = R.string.reauthenticate, type = SettingType.NAV, onClick = { login {} })
val logoutSetting =
Setting(
title = ComposableStringFormatter(R.string.log_out),
titleRes = R.string.log_out,
destructive = true,
type = SettingType.TEXT,
onClick = {
@ -35,7 +32,7 @@ class UserSwitcherViewModel : IpnViewModel() {
val addProfileSetting =
Setting(
title = ComposableStringFormatter(R.string.add_account),
titleRes = R.string.add_account,
type = SettingType.NAV,
onClick = {
addProfile {

Loading…
Cancel
Save