diff --git a/android/build.gradle b/android/build.gradle index 155b72c..8724029 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -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') diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index cac04d2..ae6ec07 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -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) -> 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(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) diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index 0b71d86..b183071 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -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() { diff --git a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt index 440581e..924eae4 100644 --- a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt +++ b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt @@ -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? { - restrictionsManager?.let { - if (it.applicationRestrictions.containsKey(setting.key)) { - return it.applicationRestrictions.getStringArray(setting.key) - } - } - return App.getApplication() - .getEncryptedPrefs() - .getStringSet(setting.key, HashSet()) - ?.toTypedArray() - ?.sortedArray() + fun update(app: App, restrictionsManager: RestrictionsManager?) { + val bundle = restrictionsManager?.applicationRestrictions + allSettings.forEach { it.setFrom(bundle, app) } } } diff --git a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt index d3e26c6..8602d59 100644 --- a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt +++ b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt @@ -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(defaultValue: T, val key: String, val localizedTitle: String) { + val flow: StateFlow = MutableStateFlow(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(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(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?>(null, key, localizedTitle) { + override fun getFrom(bundle: Bundle?, app: App) = + bundle?.getStringArrayList(key) + ?: app.getEncryptedPrefs().getStringSet(key, HashSet())?.toList() } -enum class AlwaysNeverUserDecidesValue(val value: String) { +class AlwaysNeverUserDecidesMDMSetting(key: String, localizedTitle: String) : + MDMSetting(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.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"), -} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MDMSettingsDebugView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MDMSettingsDebugView.kt index c8d3044..64118bb 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MDMSettingsDebugView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MDMSettingsDebugView.kt @@ -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(), key = { it.key }) { booleanSetting -> - MDMSettingView( - title = booleanSetting.localizedTitle, - caption = booleanSetting.key, - valueDescription = mdmSettings.get(booleanSetting).toString()) - } - - itemsWithDividers(enumValues(), key = { it.key }, forceLeading = true) { - stringSetting -> - MDMSettingView( - title = stringSetting.localizedTitle, - caption = stringSetting.key, - valueDescription = mdmSettings.get(stringSetting).toString()) - } - - itemsWithDividers(enumValues(), key = { it.key }, forceLeading = true) { - showHideSetting -> - MDMSettingView( - title = showHideSetting.localizedTitle, - caption = showHideSetting.key, - valueDescription = mdmSettings.get(showHideSetting).toString()) - } - - itemsWithDividers( - enumValues(), key = { it.key }, forceLeading = true) { - anuSetting -> - MDMSettingView( - title = anuSetting.localizedTitle, - caption = anuSetting.key, - valueDescription = mdmSettings.get(anuSetting).toString()) - } - - itemsWithDividers(enumValues(), 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, diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/ManagedByView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/ManagedByView.kt index 2b8b108..e8e0def 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/ManagedByView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/ManagedByView.kt @@ -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) } } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt index 91f66c8..1415ac6 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt @@ -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 { diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/DNSSettingsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/DNSSettingsViewModel.kt index 761391c..1da7513 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/DNSSettingsViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/DNSSettingsViewModel.kt @@ -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() diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt index 3462c99..f38e890 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt @@ -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 = { diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt index 987f87d..7b7fba4 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt @@ -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 = MutableStateFlow(MDMSettings()) - } - protected val TAG = this::class.simpleName val loggedInUser: StateFlow = MutableStateFlow(null) diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt index 4b7db2e..73662fe 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt @@ -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) - // 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) // 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 = MutableStateFlow(true), - val value: StateFlow? = null, val isOn: StateFlow? = null, val onClick: () -> Unit = {}, val onToggle: (Boolean) -> Unit = {} -) { - constructor( - titleRes: Int, - type: SettingType, - enabled: StateFlow = MutableStateFlow(false), - value: StateFlow? = null, - isOn: StateFlow? = 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 = MutableStateFlow(false) - val settings: StateFlow> = 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 = 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 = - 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 - }) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/UserSwitcherViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/UserSwitcherViewModel.kt index 2250c2e..29c4a3c 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/UserSwitcherViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/UserSwitcherViewModel.kt @@ -15,14 +15,11 @@ class UserSwitcherViewModel : IpnViewModel() { val showDialog: StateFlow = 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 {