diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index 2cdc57c..eae0168 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -316,21 +316,25 @@ class App : UninitializedApp(), libtailscale.AppContext { @Throws( IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class) override fun getSyspolicyStringValue(key: String): String { - return MDMSettings.allSettingsByKey[key]?.flow?.value?.toString() - ?: run { - Log.d("MDM", "$key is not defined on Android. Throwing NoSuchKeyException.") - throw MDMSettings.NoSuchKeyException() - } + val setting = MDMSettings.allSettingsByKey[key]?.flow?.value + if (setting?.isSet != true) { + throw MDMSettings.NoSuchKeyException() + } + return setting.value?.toString() ?: "" } @Throws( IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class) override fun getSyspolicyStringArrayJSONValue(key: String): String { - val list = MDMSettings.allSettingsByKey[key]?.flow?.value as? List<*> + val setting = MDMSettings.allSettingsByKey[key]?.flow?.value + if (setting?.isSet != true) { + throw MDMSettings.NoSuchKeyException() + } try { + val list = setting.value as? List<*> return Json.encodeToString(list) } catch (e: Exception) { - Log.d("MDM", "$key is not defined on Android. Throwing NoSuchKeyException.") + Log.d("MDM", "$key value cannot be serialized to JSON. Throwing NoSuchKeyException.") throw MDMSettings.NoSuchKeyException() } } @@ -510,7 +514,7 @@ open class UninitializedApp : Application() { } fun disallowedPackageNames(): List { - val mdmDisallowed = MDMSettings.excludedPackages.flow.value?.split(",")?.map { it.trim() } ?: emptyList() + val mdmDisallowed = MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList() if (mdmDisallowed.isNotEmpty()) { Log.d(TAG, "Excluded application packages were set via MDM: $mdmDisallowed") return builtInDisallowedPackageNames + mdmDisallowed diff --git a/android/src/main/java/com/tailscale/ipn/IPNService.kt b/android/src/main/java/com/tailscale/ipn/IPNService.kt index efebc2f..193e2ff 100644 --- a/android/src/main/java/com/tailscale/ipn/IPNService.kt +++ b/android/src/main/java/com/tailscale/ipn/IPNService.kt @@ -116,7 +116,7 @@ open class IPNService : VpnService(), libtailscale.IPNService { b.setUnderlyingNetworks(null) // Use all available networks. val includedPackages: List = - MDMSettings.includedPackages.flow.value?.split(",")?.map { it.trim() } ?: emptyList() + MDMSettings.includedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList() if (includedPackages.isNotEmpty()) { // If an admin defined a list of packages that are exclusively allowed to be used via // Tailscale, 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 546a0c2..aa7a899 100644 --- a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt +++ b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt @@ -105,6 +105,7 @@ object MDMSettings { fun update(app: App, restrictionsManager: RestrictionsManager?) { val bundle = restrictionsManager?.applicationRestrictions - allSettings.forEach { it.setFrom(bundle, app) } + val preferences = lazy { app.getEncryptedPrefs() } + allSettings.forEach { it.setFrom(bundle, preferences) } } } 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 020e355..1ddf59c 100644 --- a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt +++ b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt @@ -3,77 +3,68 @@ package com.tailscale.ipn.mdm +import android.content.SharedPreferences 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 +data class SettingState(val value: T, val isSet: Boolean) + abstract class MDMSetting(defaultValue: T, val key: String, val localizedTitle: String) { - val flow: StateFlow = MutableStateFlow(defaultValue) + val defaultValue = defaultValue + val flow = MutableStateFlow(SettingState(defaultValue, false)) + + fun setFrom(bundle: Bundle?, prefs: Lazy) { + val v: T? = getFrom(bundle, prefs) + flow.set(SettingState(v ?: defaultValue, v != null)) + } - fun setFrom(bundle: Bundle?, app: App) { - val v = getFrom(bundle, app) - flow.set(v) + fun getFrom(bundle: Bundle?, prefs: Lazy): T? { + return when { + bundle != null -> bundle.takeIf { it.containsKey(key) }?.let { getFromBundle(it) } + else -> prefs.value.takeIf { it.contains(key) }?.let { getFromPrefs(it) } + } } - abstract fun getFrom(bundle: Bundle?, app: App): T + protected abstract fun getFromBundle(bundle: Bundle): T + protected abstract fun getFromPrefs(prefs: SharedPreferences): T } 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) + override fun getFromBundle(bundle: Bundle) = bundle.getBoolean(key) + override fun getFromPrefs(prefs: SharedPreferences) = prefs.getBoolean(key, false) } 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) + override fun getFromBundle(bundle: Bundle) = bundle.getString(key) + override fun getFromPrefs(prefs: SharedPreferences) = prefs.getString(key, null) } 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() + override fun getFromBundle(bundle: Bundle) = bundle.getStringArrayList(key) + override fun getFromPrefs(prefs: SharedPreferences) = + prefs.getStringSet(key, HashSet())?.toList() } 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.get().getEncryptedPrefs().getString(key, null) - ?: "user-decides" - return when (storedString) { - "always" -> { - AlwaysNeverUserDecides.Always - } - "never" -> { - AlwaysNeverUserDecides.Never - } - else -> { - AlwaysNeverUserDecides.UserDecides - } - } - } + override fun getFromBundle(bundle: Bundle) = + AlwaysNeverUserDecides.fromString(bundle.getString(key)) + override fun getFromPrefs(prefs: SharedPreferences) = + AlwaysNeverUserDecides.fromString(prefs.getString(key, null)) } 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.get().getEncryptedPrefs().getString(key, null) ?: "show" - return when (storedString) { - "hide" -> { - ShowHide.Hide - } - else -> { - ShowHide.Show - } - } - } + override fun getFromBundle(bundle: Bundle) = + ShowHide.fromString(bundle.getString(key)) + override fun getFromPrefs(prefs: SharedPreferences) = + ShowHide.fromString(prefs.getString(key, null)) } enum class AlwaysNeverUserDecides(val value: String) { @@ -89,6 +80,12 @@ enum class AlwaysNeverUserDecides(val value: String) { override fun toString(): String { return value } + + companion object { + fun fromString(value: String?): AlwaysNeverUserDecides { + return values().find { it.value == value } ?: UserDecides + } + } } enum class ShowHide(val value: String) { @@ -98,4 +95,10 @@ enum class ShowHide(val value: String) { override fun toString(): String { return value } + + companion object { + fun fromString(value: String?): ShowHide { + return ShowHide.values().find { it.value == value } ?: Show + } + } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt b/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt index 42e2d9a..cd7e36b 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt @@ -21,7 +21,7 @@ class PeerCategorizer { val selfNode = netmap.SelfNode var grouped = mutableMapOf>() - val mdm = MDMSettings.hiddenNetworkDevices.flow.value + val mdm = MDMSettings.hiddenNetworkDevices.flow.value.value val hideMyDevices = mdm?.contains("current-user") ?: false val hideOtherDevices = mdm?.contains("other-users") ?: false val hideTaggedDevices = mdm?.contains("tagged-devices") ?: false diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/DNSSettingsView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/DNSSettingsView.kt index bf88ac7..0fbb825 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/DNSSettingsView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/DNSSettingsView.kt @@ -69,7 +69,7 @@ fun DNSSettingsView( }, supportingContent = { Text(stringResource(state.caption)) }) - if (!dnsSettingsMDMDisposition.hiddenFromUser) { + if (!dnsSettingsMDMDisposition.value.hiddenFromUser) { Lists.ItemDivider() Setting.Switch( R.string.use_ts_dns, diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt b/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt index c72aa7a..19bec62 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt @@ -54,7 +54,7 @@ fun ExitNodePicker( val showRunAsExitNode by MDMSettings.runExitNode.flow.collectAsState() val allowLanAccessMDMDisposition by MDMSettings.exitNodeAllowLANAccess.flow.collectAsState() val managedByOrganization by model.managedByOrganization.collectAsState() - val forcedExitNodeId = MDMSettings.exitNodeID.flow.collectAsState().value + val forcedExitNodeId = MDMSettings.exitNodeID.flow.collectAsState().value.value LazyColumn(modifier = Modifier.padding(innerPadding)) { item(key = "header") { @@ -76,7 +76,7 @@ fun ExitNodePicker( selected = !anyActive, )) } - if (showRunAsExitNode == ShowHide.Show) { + if (showRunAsExitNode.value == ShowHide.Show) { Lists.ItemDivider() RunAsExitNodeItem(nav = nav, viewModel = model, anyActive) } @@ -99,7 +99,7 @@ fun ExitNodePicker( } } - if (!allowLanAccessMDMDisposition.hiddenFromUser) { + if (!allowLanAccessMDMDisposition.value.hiddenFromUser) { item(key = "allowLANAccess") { Lists.SectionDivider() @@ -121,7 +121,7 @@ fun ExitNodeItem( ) { val online by node.online.collectAsState() val isRunningExitNode = viewModel.isRunningExitNode.collectAsState().value - val forcedExitNodeId = MDMSettings.exitNodeID.flow.collectAsState().value + val forcedExitNodeId = MDMSettings.exitNodeID.flow.collectAsState().value.value Box { var modifier: Modifier = Modifier 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 917316e..ec6a68a 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 @@ -50,7 +50,7 @@ fun MDMSettingView(setting: MDMSetting<*>) { }, trailingContent = { Text( - value.toString(), + if (value.isSet) value.value.toString() else "[not set]", fontFamily = FontFamily.Monospace, maxLines = 1, fontWeight = FontWeight.SemiBold) diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt index e3e9416..8ba74e9 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt @@ -136,7 +136,7 @@ fun MainView( val stateStr = stringResource(id = stateVal) val netmap by viewModel.netmap.collectAsState(initial = null) val showExitNodePicker by MDMSettings.exitNodesPicker.flow.collectAsState() - val disableToggle by MDMSettings.forceEnabled.flow.collectAsState(initial = true) + val disableToggle by MDMSettings.forceEnabled.flow.collectAsState() val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false) // Hide the header only on Android TV when the user needs to login @@ -148,11 +148,11 @@ fun MainView( if (!hideHeader) { TintedSwitch( onCheckedChange = { - if (!disableToggle) { + if (!disableToggle.value) { viewModel.toggleVpn() } }, - enabled = !disableToggle, + enabled = !disableToggle.value, checked = isOn) } }, @@ -204,7 +204,7 @@ fun MainView( HealthNotification(warning = warning) } - if (showExitNodePicker == ShowHide.Show) { + if (showExitNodePicker.value == ShowHide.Show) { ExitNodeStatus( navAction = navigation.onNavigateToExitNodes, viewModel = viewModel) } @@ -273,7 +273,7 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { Modifier.padding(start = 16.dp, end = 16.dp, top = 36.dp, bottom = 16.dp)) { Text( text = - managedByOrganization?.let { + managedByOrganization.value?.let { stringResource(R.string.exit_node_offline_mdm_orgname, it) } ?: stringResource(R.string.exit_node_offline_mdm), style = MaterialTheme.typography.bodyMedium, 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 4be2d44..e66e89a 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 @@ -32,9 +32,9 @@ fun ManagedByView(backToSettings: BackNavigation, model: IpnViewModel = viewMode horizontalAlignment = Alignment.Start, modifier = Modifier.fillMaxWidth().safeContentPadding().verticalScroll(rememberScrollState())) { val managedByOrganization = - MDMSettings.managedByOrganizationName.flow.collectAsState().value - val managedByCaption = MDMSettings.managedByCaption.flow.collectAsState().value - val managedByURL = MDMSettings.managedByURL.flow.collectAsState().value + MDMSettings.managedByOrganizationName.flow.collectAsState().value.value + val managedByCaption = MDMSettings.managedByCaption.flow.collectAsState().value.value + val managedByURL = MDMSettings.managedByURL.flow.collectAsState().value.value managedByOrganization?.let { Text(stringResource(R.string.managed_by_explainer_orgName, it)) } ?: run { Text(stringResource(R.string.managed_by_explainer)) } 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 340867e..0586126 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 @@ -83,7 +83,7 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo subtitle = stringResource(R.string.exclude_certain_apps_from_using_tailscale), onClick = settingsNav.onNavigateToSplitTunneling) - if (showTailnetLock == ShowHide.Show) { + if (showTailnetLock.value == ShowHide.Show) { Lists.ItemDivider() Setting.Text( R.string.tailnet_lock, @@ -97,7 +97,7 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo Lists.ItemDivider() Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions) - managedByOrganization?.let { + managedByOrganization.value?.let { Lists.ItemDivider() Setting.Text( title = stringResource(R.string.managed_by_orgName, it), diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SplitTunnelAppPickerView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SplitTunnelAppPickerView.kt index 6a797ab..27b18c5 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SplitTunnelAppPickerView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SplitTunnelAppPickerView.kt @@ -52,14 +52,14 @@ fun SplitTunnelAppPickerView( .selected_apps_will_access_the_internet_directly_without_using_tailscale)) }) } - if (mdmExcludedPackages?.isNotEmpty() == true) { + if (mdmExcludedPackages.value?.isNotEmpty() == true) { item("mdmExcludedNotice") { ListItem( headlineContent = { Text(stringResource(R.string.certain_apps_are_not_routed_via_tailscale)) }) } - } else if (mdmIncludedPackages?.isNotEmpty() == true) { + } else if (mdmIncludedPackages.value?.isNotEmpty() == true) { item("mdmIncludedNotice") { ListItem( headlineContent = { 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 e0e8be3..db7ebda 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 @@ -117,7 +117,7 @@ open class IpnViewModel : ViewModel() { when { exitNodePeer?.Online == false -> { - if (MDMSettings.exitNodeID.flow.value != null) { + if (MDMSettings.exitNodeID.flow.value.value != null) { NodeState.OFFLINE_MDM } else if (validPrefs.activeExitNodeID != null) { NodeState.OFFLINE_ENABLED @@ -194,7 +194,7 @@ open class IpnViewModel : ViewModel() { // If an MDM control URL is set, we will always use that in lieu of anything the user sets. var prefs = maskedPrefs - val mdmControlURL = MDMSettings.loginURL.flow.value + val mdmControlURL = MDMSettings.loginURL.flow.value.value if (mdmControlURL != null) { prefs = prefs ?: Ipn.MaskedPrefs() diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt index 726ff15..c263567 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt @@ -108,7 +108,7 @@ class MainViewModel : IpnViewModel() { showExpiry.set(false) return@let } else { - val expiryNotificationWindowMDM = MDMSettings.keyExpirationNotice.flow.value + val expiryNotificationWindowMDM = MDMSettings.keyExpirationNotice.flow.value.value val window = expiryNotificationWindowMDM?.let { TimeUtil.duration(it) } ?: Duration.ofHours(24) val expiresSoon = diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt index 34ae8d0..d00efb6 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt @@ -6,6 +6,7 @@ package com.tailscale.ipn.ui.viewModel import androidx.lifecycle.ViewModel import com.tailscale.ipn.App import com.tailscale.ipn.mdm.MDMSettings +import com.tailscale.ipn.mdm.SettingState import com.tailscale.ipn.ui.util.InstalledApp import com.tailscale.ipn.ui.util.InstalledAppsManager import com.tailscale.ipn.ui.util.set @@ -16,8 +17,8 @@ class SplitTunnelAppPickerViewModel : ViewModel() { val installedAppsManager = InstalledAppsManager(packageManager = App.get().packageManager) val excludedPackageNames: StateFlow> = MutableStateFlow(listOf()) val installedApps: StateFlow> = MutableStateFlow(listOf()) - val mdmExcludedPackages: StateFlow = MDMSettings.excludedPackages.flow - val mdmIncludedPackages: StateFlow = MDMSettings.includedPackages.flow + val mdmExcludedPackages: StateFlow> = MDMSettings.excludedPackages.flow + val mdmIncludedPackages: StateFlow> = MDMSettings.includedPackages.flow init { installedApps.set(installedAppsManager.fetchInstalledApps())