mdm: improve handling and returning of not configured policy settings (#461)

We should distinguish between unconfigured policy settings and those configured with the default values.
In the first case, the syspolicyHandler should return syspolicy.ErrNoSuchKey instead of the default value,
while in the latter case, it should return the actual setting value, even if that value happens to be the default
value such as "user-decides". This distinction should also be reflected in the "Current MDM settings" view.

In this PR, we update MDMSetting.flow to hold both the value to be used by the app and a flag indicating
whether the policy setting is configured or not. If the policy setting is not configured, the value is the default
value for the setting type. We then use this new flag to decide whether to throw a NoSuchKeyException from
the Kotlin-side of the syspolicyHandler implementation and how to display the policy setting in the
"Current MDM settings" view.

Additionally, we update the MDMSettings.update and MDMSetting.setFrom methods to avoid calling
app.getEncryptedPrefs (and reading/decrypting the prefs) for every defined MDM setting.

Updates tailscale/tailscale#12687

Signed-off-by: Nick Khyl <nickk@tailscale.com>
pull/462/head
Nick Khyl 1 year ago committed by GitHub
parent 946afb6c33
commit 8767fbd8d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -316,21 +316,25 @@ class App : UninitializedApp(), libtailscale.AppContext {
@Throws( @Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class) IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyStringValue(key: String): String { override fun getSyspolicyStringValue(key: String): String {
return MDMSettings.allSettingsByKey[key]?.flow?.value?.toString() val setting = MDMSettings.allSettingsByKey[key]?.flow?.value
?: run { if (setting?.isSet != true) {
Log.d("MDM", "$key is not defined on Android. Throwing NoSuchKeyException.")
throw MDMSettings.NoSuchKeyException() throw MDMSettings.NoSuchKeyException()
} }
return setting.value?.toString() ?: ""
} }
@Throws( @Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class) IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyStringArrayJSONValue(key: String): String { 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 { try {
val list = setting.value as? List<*>
return Json.encodeToString(list) return Json.encodeToString(list)
} catch (e: Exception) { } 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() throw MDMSettings.NoSuchKeyException()
} }
} }
@ -510,7 +514,7 @@ open class UninitializedApp : Application() {
} }
fun disallowedPackageNames(): List<String> { fun disallowedPackageNames(): List<String> {
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()) { if (mdmDisallowed.isNotEmpty()) {
Log.d(TAG, "Excluded application packages were set via MDM: $mdmDisallowed") Log.d(TAG, "Excluded application packages were set via MDM: $mdmDisallowed")
return builtInDisallowedPackageNames + mdmDisallowed return builtInDisallowedPackageNames + mdmDisallowed

@ -116,7 +116,7 @@ open class IPNService : VpnService(), libtailscale.IPNService {
b.setUnderlyingNetworks(null) // Use all available networks. b.setUnderlyingNetworks(null) // Use all available networks.
val includedPackages: List<String> = val includedPackages: List<String> =
MDMSettings.includedPackages.flow.value?.split(",")?.map { it.trim() } ?: emptyList() MDMSettings.includedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
if (includedPackages.isNotEmpty()) { if (includedPackages.isNotEmpty()) {
// If an admin defined a list of packages that are exclusively allowed to be used via // If an admin defined a list of packages that are exclusively allowed to be used via
// Tailscale, // Tailscale,

@ -105,6 +105,7 @@ object MDMSettings {
fun update(app: App, restrictionsManager: RestrictionsManager?) { fun update(app: App, restrictionsManager: RestrictionsManager?) {
val bundle = restrictionsManager?.applicationRestrictions val bundle = restrictionsManager?.applicationRestrictions
allSettings.forEach { it.setFrom(bundle, app) } val preferences = lazy { app.getEncryptedPrefs() }
allSettings.forEach { it.setFrom(bundle, preferences) }
} }
} }

@ -3,77 +3,68 @@
package com.tailscale.ipn.mdm package com.tailscale.ipn.mdm
import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import com.tailscale.ipn.App import com.tailscale.ipn.App
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
data class SettingState<T>(val value: T, val isSet: Boolean)
abstract class MDMSetting<T>(defaultValue: T, val key: String, val localizedTitle: String) { abstract class MDMSetting<T>(defaultValue: T, val key: String, val localizedTitle: String) {
val flow: StateFlow<T> = MutableStateFlow<T>(defaultValue) val defaultValue = defaultValue
val flow = MutableStateFlow(SettingState(defaultValue, false))
fun setFrom(bundle: Bundle?, app: App) { fun setFrom(bundle: Bundle?, prefs: Lazy<SharedPreferences>) {
val v = getFrom(bundle, app) val v: T? = getFrom(bundle, prefs)
flow.set(v) flow.set(SettingState(v ?: defaultValue, v != null))
} }
abstract fun getFrom(bundle: Bundle?, app: App): T fun getFrom(bundle: Bundle?, prefs: Lazy<SharedPreferences>): T? {
return when {
bundle != null -> bundle.takeIf { it.containsKey(key) }?.let { getFromBundle(it) }
else -> prefs.value.takeIf { it.contains(key) }?.let { getFromPrefs(it) }
}
}
protected abstract fun getFromBundle(bundle: Bundle): T
protected abstract fun getFromPrefs(prefs: SharedPreferences): T
} }
class BooleanMDMSetting(key: String, localizedTitle: String) : class BooleanMDMSetting(key: String, localizedTitle: String) :
MDMSetting<Boolean>(false, key, localizedTitle) { MDMSetting<Boolean>(false, key, localizedTitle) {
override fun getFrom(bundle: Bundle?, app: App) = override fun getFromBundle(bundle: Bundle) = bundle.getBoolean(key)
bundle?.getBoolean(key) ?: app.getEncryptedPrefs().getBoolean(key, false) override fun getFromPrefs(prefs: SharedPreferences) = prefs.getBoolean(key, false)
} }
class StringMDMSetting(key: String, localizedTitle: String) : class StringMDMSetting(key: String, localizedTitle: String) :
MDMSetting<String?>(null, key, localizedTitle) { MDMSetting<String?>(null, key, localizedTitle) {
override fun getFrom(bundle: Bundle?, app: App) = override fun getFromBundle(bundle: Bundle) = bundle.getString(key)
bundle?.getString(key) ?: app.getEncryptedPrefs().getString(key, null) override fun getFromPrefs(prefs: SharedPreferences) = prefs.getString(key, null)
} }
class StringArrayListMDMSetting(key: String, localizedTitle: String) : class StringArrayListMDMSetting(key: String, localizedTitle: String) :
MDMSetting<List<String>?>(null, key, localizedTitle) { MDMSetting<List<String>?>(null, key, localizedTitle) {
override fun getFrom(bundle: Bundle?, app: App) = override fun getFromBundle(bundle: Bundle) = bundle.getStringArrayList(key)
bundle?.getStringArrayList(key) override fun getFromPrefs(prefs: SharedPreferences) =
?: app.getEncryptedPrefs().getStringSet(key, HashSet<String>())?.toList() prefs.getStringSet(key, HashSet<String>())?.toList()
} }
class AlwaysNeverUserDecidesMDMSetting(key: String, localizedTitle: String) : class AlwaysNeverUserDecidesMDMSetting(key: String, localizedTitle: String) :
MDMSetting<AlwaysNeverUserDecides>(AlwaysNeverUserDecides.UserDecides, key, localizedTitle) { MDMSetting<AlwaysNeverUserDecides>(AlwaysNeverUserDecides.UserDecides, key, localizedTitle) {
override fun getFrom(bundle: Bundle?, app: App): AlwaysNeverUserDecides { override fun getFromBundle(bundle: Bundle) =
val storedString = AlwaysNeverUserDecides.fromString(bundle.getString(key))
bundle?.getString(key) override fun getFromPrefs(prefs: SharedPreferences) =
?: App.get().getEncryptedPrefs().getString(key, null) AlwaysNeverUserDecides.fromString(prefs.getString(key, null))
?: "user-decides"
return when (storedString) {
"always" -> {
AlwaysNeverUserDecides.Always
}
"never" -> {
AlwaysNeverUserDecides.Never
}
else -> {
AlwaysNeverUserDecides.UserDecides
}
}
}
} }
class ShowHideMDMSetting(key: String, localizedTitle: String) : class ShowHideMDMSetting(key: String, localizedTitle: String) :
MDMSetting<ShowHide>(ShowHide.Show, key, localizedTitle) { MDMSetting<ShowHide>(ShowHide.Show, key, localizedTitle) {
override fun getFrom(bundle: Bundle?, app: App): ShowHide { override fun getFromBundle(bundle: Bundle) =
val storedString = ShowHide.fromString(bundle.getString(key))
bundle?.getString(key) ?: App.get().getEncryptedPrefs().getString(key, null) ?: "show" override fun getFromPrefs(prefs: SharedPreferences) =
return when (storedString) { ShowHide.fromString(prefs.getString(key, null))
"hide" -> {
ShowHide.Hide
}
else -> {
ShowHide.Show
}
}
}
} }
enum class AlwaysNeverUserDecides(val value: String) { enum class AlwaysNeverUserDecides(val value: String) {
@ -89,6 +80,12 @@ enum class AlwaysNeverUserDecides(val value: String) {
override fun toString(): String { override fun toString(): String {
return value return value
} }
companion object {
fun fromString(value: String?): AlwaysNeverUserDecides {
return values().find { it.value == value } ?: UserDecides
}
}
} }
enum class ShowHide(val value: String) { enum class ShowHide(val value: String) {
@ -98,4 +95,10 @@ enum class ShowHide(val value: String) {
override fun toString(): String { override fun toString(): String {
return value return value
} }
companion object {
fun fromString(value: String?): ShowHide {
return ShowHide.values().find { it.value == value } ?: Show
}
}
} }

@ -21,7 +21,7 @@ class PeerCategorizer {
val selfNode = netmap.SelfNode val selfNode = netmap.SelfNode
var grouped = mutableMapOf<UserID, MutableList<Tailcfg.Node>>() var grouped = mutableMapOf<UserID, MutableList<Tailcfg.Node>>()
val mdm = MDMSettings.hiddenNetworkDevices.flow.value val mdm = MDMSettings.hiddenNetworkDevices.flow.value.value
val hideMyDevices = mdm?.contains("current-user") ?: false val hideMyDevices = mdm?.contains("current-user") ?: false
val hideOtherDevices = mdm?.contains("other-users") ?: false val hideOtherDevices = mdm?.contains("other-users") ?: false
val hideTaggedDevices = mdm?.contains("tagged-devices") ?: false val hideTaggedDevices = mdm?.contains("tagged-devices") ?: false

@ -69,7 +69,7 @@ fun DNSSettingsView(
}, },
supportingContent = { Text(stringResource(state.caption)) }) supportingContent = { Text(stringResource(state.caption)) })
if (!dnsSettingsMDMDisposition.hiddenFromUser) { if (!dnsSettingsMDMDisposition.value.hiddenFromUser) {
Lists.ItemDivider() Lists.ItemDivider()
Setting.Switch( Setting.Switch(
R.string.use_ts_dns, R.string.use_ts_dns,

@ -54,7 +54,7 @@ fun ExitNodePicker(
val showRunAsExitNode by MDMSettings.runExitNode.flow.collectAsState() val showRunAsExitNode by MDMSettings.runExitNode.flow.collectAsState()
val allowLanAccessMDMDisposition by MDMSettings.exitNodeAllowLANAccess.flow.collectAsState() val allowLanAccessMDMDisposition by MDMSettings.exitNodeAllowLANAccess.flow.collectAsState()
val managedByOrganization by model.managedByOrganization.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)) { LazyColumn(modifier = Modifier.padding(innerPadding)) {
item(key = "header") { item(key = "header") {
@ -76,7 +76,7 @@ fun ExitNodePicker(
selected = !anyActive, selected = !anyActive,
)) ))
} }
if (showRunAsExitNode == ShowHide.Show) { if (showRunAsExitNode.value == ShowHide.Show) {
Lists.ItemDivider() Lists.ItemDivider()
RunAsExitNodeItem(nav = nav, viewModel = model, anyActive) RunAsExitNodeItem(nav = nav, viewModel = model, anyActive)
} }
@ -99,7 +99,7 @@ fun ExitNodePicker(
} }
} }
if (!allowLanAccessMDMDisposition.hiddenFromUser) { if (!allowLanAccessMDMDisposition.value.hiddenFromUser) {
item(key = "allowLANAccess") { item(key = "allowLANAccess") {
Lists.SectionDivider() Lists.SectionDivider()
@ -121,7 +121,7 @@ fun ExitNodeItem(
) { ) {
val online by node.online.collectAsState() val online by node.online.collectAsState()
val isRunningExitNode = viewModel.isRunningExitNode.collectAsState().value val isRunningExitNode = viewModel.isRunningExitNode.collectAsState().value
val forcedExitNodeId = MDMSettings.exitNodeID.flow.collectAsState().value val forcedExitNodeId = MDMSettings.exitNodeID.flow.collectAsState().value.value
Box { Box {
var modifier: Modifier = Modifier var modifier: Modifier = Modifier

@ -50,7 +50,7 @@ fun MDMSettingView(setting: MDMSetting<*>) {
}, },
trailingContent = { trailingContent = {
Text( Text(
value.toString(), if (value.isSet) value.value.toString() else "[not set]",
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
maxLines = 1, maxLines = 1,
fontWeight = FontWeight.SemiBold) fontWeight = FontWeight.SemiBold)

@ -136,7 +136,7 @@ fun MainView(
val stateStr = stringResource(id = stateVal) val stateStr = stringResource(id = stateVal)
val netmap by viewModel.netmap.collectAsState(initial = null) val netmap by viewModel.netmap.collectAsState(initial = null)
val showExitNodePicker by MDMSettings.exitNodesPicker.flow.collectAsState() 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) val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false)
// Hide the header only on Android TV when the user needs to login // Hide the header only on Android TV when the user needs to login
@ -148,11 +148,11 @@ fun MainView(
if (!hideHeader) { if (!hideHeader) {
TintedSwitch( TintedSwitch(
onCheckedChange = { onCheckedChange = {
if (!disableToggle) { if (!disableToggle.value) {
viewModel.toggleVpn() viewModel.toggleVpn()
} }
}, },
enabled = !disableToggle, enabled = !disableToggle.value,
checked = isOn) checked = isOn)
} }
}, },
@ -204,7 +204,7 @@ fun MainView(
HealthNotification(warning = warning) HealthNotification(warning = warning)
} }
if (showExitNodePicker == ShowHide.Show) { if (showExitNodePicker.value == ShowHide.Show) {
ExitNodeStatus( ExitNodeStatus(
navAction = navigation.onNavigateToExitNodes, viewModel = viewModel) 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)) { Modifier.padding(start = 16.dp, end = 16.dp, top = 36.dp, bottom = 16.dp)) {
Text( Text(
text = text =
managedByOrganization?.let { managedByOrganization.value?.let {
stringResource(R.string.exit_node_offline_mdm_orgname, it) stringResource(R.string.exit_node_offline_mdm_orgname, it)
} ?: stringResource(R.string.exit_node_offline_mdm), } ?: stringResource(R.string.exit_node_offline_mdm),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,

@ -32,9 +32,9 @@ fun ManagedByView(backToSettings: BackNavigation, model: IpnViewModel = viewMode
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
modifier = Modifier.fillMaxWidth().safeContentPadding().verticalScroll(rememberScrollState())) { modifier = Modifier.fillMaxWidth().safeContentPadding().verticalScroll(rememberScrollState())) {
val managedByOrganization = val managedByOrganization =
MDMSettings.managedByOrganizationName.flow.collectAsState().value MDMSettings.managedByOrganizationName.flow.collectAsState().value.value
val managedByCaption = MDMSettings.managedByCaption.flow.collectAsState().value val managedByCaption = MDMSettings.managedByCaption.flow.collectAsState().value.value
val managedByURL = MDMSettings.managedByURL.flow.collectAsState().value val managedByURL = MDMSettings.managedByURL.flow.collectAsState().value.value
managedByOrganization?.let { 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)) }

@ -83,7 +83,7 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo
subtitle = stringResource(R.string.exclude_certain_apps_from_using_tailscale), subtitle = stringResource(R.string.exclude_certain_apps_from_using_tailscale),
onClick = settingsNav.onNavigateToSplitTunneling) onClick = settingsNav.onNavigateToSplitTunneling)
if (showTailnetLock == ShowHide.Show) { if (showTailnetLock.value == ShowHide.Show) {
Lists.ItemDivider() Lists.ItemDivider()
Setting.Text( Setting.Text(
R.string.tailnet_lock, R.string.tailnet_lock,
@ -97,7 +97,7 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo
Lists.ItemDivider() Lists.ItemDivider()
Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions) Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions)
managedByOrganization?.let { managedByOrganization.value?.let {
Lists.ItemDivider() Lists.ItemDivider()
Setting.Text( Setting.Text(
title = stringResource(R.string.managed_by_orgName, it), title = stringResource(R.string.managed_by_orgName, it),

@ -52,14 +52,14 @@ fun SplitTunnelAppPickerView(
.selected_apps_will_access_the_internet_directly_without_using_tailscale)) .selected_apps_will_access_the_internet_directly_without_using_tailscale))
}) })
} }
if (mdmExcludedPackages?.isNotEmpty() == true) { if (mdmExcludedPackages.value?.isNotEmpty() == true) {
item("mdmExcludedNotice") { item("mdmExcludedNotice") {
ListItem( ListItem(
headlineContent = { headlineContent = {
Text(stringResource(R.string.certain_apps_are_not_routed_via_tailscale)) Text(stringResource(R.string.certain_apps_are_not_routed_via_tailscale))
}) })
} }
} else if (mdmIncludedPackages?.isNotEmpty() == true) { } else if (mdmIncludedPackages.value?.isNotEmpty() == true) {
item("mdmIncludedNotice") { item("mdmIncludedNotice") {
ListItem( ListItem(
headlineContent = { headlineContent = {

@ -117,7 +117,7 @@ open class IpnViewModel : ViewModel() {
when { when {
exitNodePeer?.Online == false -> { exitNodePeer?.Online == false -> {
if (MDMSettings.exitNodeID.flow.value != null) { if (MDMSettings.exitNodeID.flow.value.value != null) {
NodeState.OFFLINE_MDM NodeState.OFFLINE_MDM
} else if (validPrefs.activeExitNodeID != null) { } else if (validPrefs.activeExitNodeID != null) {
NodeState.OFFLINE_ENABLED 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. // If an MDM control URL is set, we will always use that in lieu of anything the user sets.
var prefs = maskedPrefs var prefs = maskedPrefs
val mdmControlURL = MDMSettings.loginURL.flow.value val mdmControlURL = MDMSettings.loginURL.flow.value.value
if (mdmControlURL != null) { if (mdmControlURL != null) {
prefs = prefs ?: Ipn.MaskedPrefs() prefs = prefs ?: Ipn.MaskedPrefs()

@ -108,7 +108,7 @@ class MainViewModel : IpnViewModel() {
showExpiry.set(false) showExpiry.set(false)
return@let return@let
} else { } else {
val expiryNotificationWindowMDM = MDMSettings.keyExpirationNotice.flow.value val expiryNotificationWindowMDM = MDMSettings.keyExpirationNotice.flow.value.value
val window = val window =
expiryNotificationWindowMDM?.let { TimeUtil.duration(it) } ?: Duration.ofHours(24) expiryNotificationWindowMDM?.let { TimeUtil.duration(it) } ?: Duration.ofHours(24)
val expiresSoon = val expiresSoon =

@ -6,6 +6,7 @@ package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.tailscale.ipn.App import com.tailscale.ipn.App
import com.tailscale.ipn.mdm.MDMSettings 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.InstalledApp
import com.tailscale.ipn.ui.util.InstalledAppsManager import com.tailscale.ipn.ui.util.InstalledAppsManager
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
@ -16,8 +17,8 @@ class SplitTunnelAppPickerViewModel : ViewModel() {
val installedAppsManager = InstalledAppsManager(packageManager = App.get().packageManager) val installedAppsManager = InstalledAppsManager(packageManager = App.get().packageManager)
val excludedPackageNames: StateFlow<List<String>> = MutableStateFlow(listOf()) val excludedPackageNames: StateFlow<List<String>> = MutableStateFlow(listOf())
val installedApps: StateFlow<List<InstalledApp>> = MutableStateFlow(listOf()) val installedApps: StateFlow<List<InstalledApp>> = MutableStateFlow(listOf())
val mdmExcludedPackages: StateFlow<String?> = MDMSettings.excludedPackages.flow val mdmExcludedPackages: StateFlow<SettingState<String?>> = MDMSettings.excludedPackages.flow
val mdmIncludedPackages: StateFlow<String?> = MDMSettings.includedPackages.flow val mdmIncludedPackages: StateFlow<SettingState<String?>> = MDMSettings.includedPackages.flow
init { init {
installedApps.set(installedAppsManager.fetchInstalledApps()) installedApps.set(installedAppsManager.fetchInstalledApps())

Loading…
Cancel
Save