|
|
|
@ -1,6 +1,7 @@
|
|
|
|
// Copyright (c) Tailscale Inc & AUTHORS
|
|
|
|
// Copyright (c) Tailscale Inc & AUTHORS
|
|
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package com.tailscale.ipn
|
|
|
|
package com.tailscale.ipn
|
|
|
|
|
|
|
|
|
|
|
|
import android.Manifest
|
|
|
|
import android.Manifest
|
|
|
|
import android.app.Application
|
|
|
|
import android.app.Application
|
|
|
|
import android.app.Notification
|
|
|
|
import android.app.Notification
|
|
|
|
@ -37,6 +38,10 @@ import com.tailscale.ipn.ui.viewModel.AppViewModelFactory
|
|
|
|
import com.tailscale.ipn.util.FeatureFlags
|
|
|
|
import com.tailscale.ipn.util.FeatureFlags
|
|
|
|
import com.tailscale.ipn.util.ShareFileHelper
|
|
|
|
import com.tailscale.ipn.util.ShareFileHelper
|
|
|
|
import com.tailscale.ipn.util.TSLog
|
|
|
|
import com.tailscale.ipn.util.TSLog
|
|
|
|
|
|
|
|
import java.io.IOException
|
|
|
|
|
|
|
|
import java.net.NetworkInterface
|
|
|
|
|
|
|
|
import java.security.GeneralSecurityException
|
|
|
|
|
|
|
|
import java.util.Locale
|
|
|
|
import kotlinx.coroutines.CoroutineScope
|
|
|
|
import kotlinx.coroutines.CoroutineScope
|
|
|
|
import kotlinx.coroutines.Dispatchers
|
|
|
|
import kotlinx.coroutines.Dispatchers
|
|
|
|
import kotlinx.coroutines.SupervisorJob
|
|
|
|
import kotlinx.coroutines.SupervisorJob
|
|
|
|
@ -48,12 +53,10 @@ import kotlinx.coroutines.launch
|
|
|
|
import kotlinx.serialization.encodeToString
|
|
|
|
import kotlinx.serialization.encodeToString
|
|
|
|
import kotlinx.serialization.json.Json
|
|
|
|
import kotlinx.serialization.json.Json
|
|
|
|
import libtailscale.Libtailscale
|
|
|
|
import libtailscale.Libtailscale
|
|
|
|
import java.io.IOException
|
|
|
|
|
|
|
|
import java.net.NetworkInterface
|
|
|
|
|
|
|
|
import java.security.GeneralSecurityException
|
|
|
|
|
|
|
|
import java.util.Locale
|
|
|
|
|
|
|
|
class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
|
|
|
|
class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
|
|
|
|
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
|
|
|
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
|
|
|
|
|
|
|
|
|
|
|
companion object {
|
|
|
|
companion object {
|
|
|
|
private const val FILE_CHANNEL_ID = "tailscale-files"
|
|
|
|
private const val FILE_CHANNEL_ID = "tailscale-files"
|
|
|
|
// Key to store the SAF URI in EncryptedSharedPreferences.
|
|
|
|
// Key to store the SAF URI in EncryptedSharedPreferences.
|
|
|
|
@ -70,26 +73,34 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
|
|
|
|
return appInstance
|
|
|
|
return appInstance
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
val dns = DnsConfig()
|
|
|
|
val dns = DnsConfig()
|
|
|
|
private lateinit var connectivityManager: ConnectivityManager
|
|
|
|
private lateinit var connectivityManager: ConnectivityManager
|
|
|
|
private lateinit var mdmChangeReceiver: MDMSettingsChangedReceiver
|
|
|
|
private lateinit var mdmChangeReceiver: MDMSettingsChangedReceiver
|
|
|
|
private lateinit var app: libtailscale.Application
|
|
|
|
private lateinit var app: libtailscale.Application
|
|
|
|
override val viewModelStore: ViewModelStore
|
|
|
|
override val viewModelStore: ViewModelStore
|
|
|
|
get() = appViewModelStore
|
|
|
|
get() = appViewModelStore
|
|
|
|
|
|
|
|
|
|
|
|
private val appViewModelStore: ViewModelStore by lazy { ViewModelStore() }
|
|
|
|
private val appViewModelStore: ViewModelStore by lazy { ViewModelStore() }
|
|
|
|
var healthNotifier: HealthNotifier? = null
|
|
|
|
var healthNotifier: HealthNotifier? = null
|
|
|
|
|
|
|
|
|
|
|
|
override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString
|
|
|
|
override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString
|
|
|
|
|
|
|
|
|
|
|
|
override fun getInstallSource(): String = AppSourceChecker.getInstallSource(this)
|
|
|
|
override fun getInstallSource(): String = AppSourceChecker.getInstallSource(this)
|
|
|
|
|
|
|
|
|
|
|
|
override fun shouldUseGoogleDNSFallback(): Boolean = BuildConfig.USE_GOOGLE_DNS_FALLBACK
|
|
|
|
override fun shouldUseGoogleDNSFallback(): Boolean = BuildConfig.USE_GOOGLE_DNS_FALLBACK
|
|
|
|
|
|
|
|
|
|
|
|
override fun log(s: String, s1: String) {
|
|
|
|
override fun log(s: String, s1: String) {
|
|
|
|
Log.d(s, s1)
|
|
|
|
Log.d(s, s1)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fun getLibtailscaleApp(): libtailscale.Application {
|
|
|
|
fun getLibtailscaleApp(): libtailscale.Application {
|
|
|
|
if (!isInitialized) {
|
|
|
|
if (!isInitialized) {
|
|
|
|
initOnce() // Calls the synchronized initialization logic
|
|
|
|
initOnce() // Calls the synchronized initialization logic
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return app
|
|
|
|
return app
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
override fun onCreate() {
|
|
|
|
override fun onCreate() {
|
|
|
|
super.onCreate()
|
|
|
|
super.onCreate()
|
|
|
|
appInstance = this
|
|
|
|
appInstance = this
|
|
|
|
@ -113,6 +124,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
|
|
|
|
getString(R.string.health_channel_description),
|
|
|
|
getString(R.string.health_channel_description),
|
|
|
|
NotificationManagerCompat.IMPORTANCE_HIGH)
|
|
|
|
NotificationManagerCompat.IMPORTANCE_HIGH)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
override fun onTerminate() {
|
|
|
|
override fun onTerminate() {
|
|
|
|
super.onTerminate()
|
|
|
|
super.onTerminate()
|
|
|
|
Notifier.stop()
|
|
|
|
Notifier.stop()
|
|
|
|
@ -121,7 +133,9 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
|
|
|
|
viewModelStore.clear()
|
|
|
|
viewModelStore.clear()
|
|
|
|
unregisterReceiver(mdmChangeReceiver)
|
|
|
|
unregisterReceiver(mdmChangeReceiver)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@Volatile private var isInitialized = false
|
|
|
|
@Volatile private var isInitialized = false
|
|
|
|
|
|
|
|
|
|
|
|
@Synchronized
|
|
|
|
@Synchronized
|
|
|
|
private fun initOnce() {
|
|
|
|
private fun initOnce() {
|
|
|
|
if (isInitialized) {
|
|
|
|
if (isInitialized) {
|
|
|
|
@ -130,6 +144,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
|
|
|
|
initializeApp()
|
|
|
|
initializeApp()
|
|
|
|
isInitialized = true
|
|
|
|
isInitialized = true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private fun initializeApp() {
|
|
|
|
private fun initializeApp() {
|
|
|
|
// Check if a directory URI has already been stored.
|
|
|
|
// Check if a directory URI has already been stored.
|
|
|
|
val storedUri = getStoredDirectoryUri()
|
|
|
|
val storedUri = getStoredDirectoryUri()
|
|
|
|
@ -244,6 +259,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
|
|
|
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
|
|
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
|
|
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM)
|
|
|
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fun getStoredDirectoryUri(): Uri? {
|
|
|
|
fun getStoredDirectoryUri(): Uri? {
|
|
|
|
val uriString = getEncryptedPrefs().getString(PREF_KEY_SAF_URI, null)
|
|
|
|
val uriString = getEncryptedPrefs().getString(PREF_KEY_SAF_URI, null)
|
|
|
|
return uriString?.let { Uri.parse(it) }
|
|
|
|
return uriString?.let { Uri.parse(it) }
|
|
|
|
@ -258,6 +274,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
|
|
|
|
QuickToggleService.updateTile()
|
|
|
|
QuickToggleService.updateTile()
|
|
|
|
TSLog.d("App", "Set Tile Ready: $ableToStartVPN")
|
|
|
|
TSLog.d("App", "Set Tile Ready: $ableToStartVPN")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
override fun getModelName(): String {
|
|
|
|
override fun getModelName(): String {
|
|
|
|
val manu = Build.MANUFACTURER
|
|
|
|
val manu = Build.MANUFACTURER
|
|
|
|
var model = Build.MODEL
|
|
|
|
var model = Build.MODEL
|
|
|
|
@ -268,10 +285,13 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return "$manu $model"
|
|
|
|
return "$manu $model"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
override fun getOSVersion(): String = Build.VERSION.RELEASE
|
|
|
|
override fun getOSVersion(): String = Build.VERSION.RELEASE
|
|
|
|
|
|
|
|
|
|
|
|
override fun isChromeOS(): Boolean {
|
|
|
|
override fun isChromeOS(): Boolean {
|
|
|
|
return packageManager.hasSystemFeature("android.hardware.type.pc")
|
|
|
|
return packageManager.hasSystemFeature("android.hardware.type.pc")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
override fun getInterfacesAsString(): String {
|
|
|
|
override fun getInterfacesAsString(): String {
|
|
|
|
val interfaces: ArrayList<NetworkInterface> =
|
|
|
|
val interfaces: ArrayList<NetworkInterface> =
|
|
|
|
java.util.Collections.list(NetworkInterface.getNetworkInterfaces())
|
|
|
|
java.util.Collections.list(NetworkInterface.getNetworkInterfaces())
|
|
|
|
@ -303,11 +323,13 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return sb.toString()
|
|
|
|
return sb.toString()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@Throws(
|
|
|
|
@Throws(
|
|
|
|
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
|
|
|
|
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
|
|
|
|
override fun getSyspolicyBooleanValue(key: String): Boolean {
|
|
|
|
override fun getSyspolicyBooleanValue(key: String): Boolean {
|
|
|
|
return getSyspolicyStringValue(key) == "true"
|
|
|
|
return getSyspolicyStringValue(key) == "true"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@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 {
|
|
|
|
@ -317,6 +339,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return setting.value?.toString() ?: ""
|
|
|
|
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 {
|
|
|
|
@ -332,6 +355,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
|
|
|
|
throw MDMSettings.NoSuchKeyException()
|
|
|
|
throw MDMSettings.NoSuchKeyException()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fun notifyPolicyChanged() {
|
|
|
|
fun notifyPolicyChanged() {
|
|
|
|
app.notifyPolicyChanged()
|
|
|
|
app.notifyPolicyChanged()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@ -374,9 +398,11 @@ open class UninitializedApp : Application() {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
protected fun setUnprotectedInstance(instance: UninitializedApp) {
|
|
|
|
protected fun setUnprotectedInstance(instance: UninitializedApp) {
|
|
|
|
appInstance = instance
|
|
|
|
appInstance = instance
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
protected fun setAbleToStartVPN(rdy: Boolean) {
|
|
|
|
protected fun setAbleToStartVPN(rdy: Boolean) {
|
|
|
|
getUnencryptedPrefs().edit().putBoolean(ABLE_TO_START_VPN_KEY, rdy).apply()
|
|
|
|
getUnencryptedPrefs().edit().putBoolean(ABLE_TO_START_VPN_KEY, rdy).apply()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@ -384,9 +410,11 @@ open class UninitializedApp : Application() {
|
|
|
|
fun isAbleToStartVPN(): Boolean {
|
|
|
|
fun isAbleToStartVPN(): Boolean {
|
|
|
|
return getUnencryptedPrefs().getBoolean(ABLE_TO_START_VPN_KEY, false)
|
|
|
|
return getUnencryptedPrefs().getBoolean(ABLE_TO_START_VPN_KEY, false)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private fun getUnencryptedPrefs(): SharedPreferences {
|
|
|
|
private fun getUnencryptedPrefs(): SharedPreferences {
|
|
|
|
return getSharedPreferences(UNENCRYPTED_PREFERENCES, MODE_PRIVATE)
|
|
|
|
return getSharedPreferences(UNENCRYPTED_PREFERENCES, MODE_PRIVATE)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fun startVPN() {
|
|
|
|
fun startVPN() {
|
|
|
|
val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_START_VPN }
|
|
|
|
val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_START_VPN }
|
|
|
|
// FLAG_UPDATE_CURRENT ensures that if the intent is already pending, the existing intent will
|
|
|
|
// FLAG_UPDATE_CURRENT ensures that if the intent is already pending, the existing intent will
|
|
|
|
@ -411,6 +439,7 @@ open class UninitializedApp : Application() {
|
|
|
|
TSLog.e(TAG, "startVPN hit exception: $e")
|
|
|
|
TSLog.e(TAG, "startVPN hit exception: $e")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fun stopVPN() {
|
|
|
|
fun stopVPN() {
|
|
|
|
val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_STOP_VPN }
|
|
|
|
val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_STOP_VPN }
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
@ -421,6 +450,7 @@ open class UninitializedApp : Application() {
|
|
|
|
TSLog.e(TAG, "stopVPN hit exception in startService(): $e")
|
|
|
|
TSLog.e(TAG, "stopVPN hit exception in startService(): $e")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fun restartVPN() {
|
|
|
|
fun restartVPN() {
|
|
|
|
val intent =
|
|
|
|
val intent =
|
|
|
|
Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_RESTART_VPN }
|
|
|
|
Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_RESTART_VPN }
|
|
|
|
@ -432,12 +462,14 @@ open class UninitializedApp : Application() {
|
|
|
|
TSLog.e(TAG, "restartVPN hit exception in startService(): $e")
|
|
|
|
TSLog.e(TAG, "restartVPN hit exception in startService(): $e")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fun createNotificationChannel(id: String, name: String, description: String, importance: Int) {
|
|
|
|
fun createNotificationChannel(id: String, name: String, description: String, importance: Int) {
|
|
|
|
val channel = NotificationChannel(id, name, importance)
|
|
|
|
val channel = NotificationChannel(id, name, importance)
|
|
|
|
channel.description = description
|
|
|
|
channel.description = description
|
|
|
|
notificationManager = NotificationManagerCompat.from(this)
|
|
|
|
notificationManager = NotificationManagerCompat.from(this)
|
|
|
|
notificationManager.createNotificationChannel(channel)
|
|
|
|
notificationManager.createNotificationChannel(channel)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fun notifyStatus(
|
|
|
|
fun notifyStatus(
|
|
|
|
vpnRunning: Boolean,
|
|
|
|
vpnRunning: Boolean,
|
|
|
|
hideDisconnectAction: Boolean,
|
|
|
|
hideDisconnectAction: Boolean,
|
|
|
|
@ -445,6 +477,7 @@ open class UninitializedApp : Application() {
|
|
|
|
) {
|
|
|
|
) {
|
|
|
|
notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction, exitNodeName))
|
|
|
|
notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction, exitNodeName))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fun notifyStatus(notification: Notification) {
|
|
|
|
fun notifyStatus(notification: Notification) {
|
|
|
|
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
|
|
|
|
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
|
|
|
|
PackageManager.PERMISSION_GRANTED) {
|
|
|
|
PackageManager.PERMISSION_GRANTED) {
|
|
|
|
@ -459,6 +492,7 @@ open class UninitializedApp : Application() {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
notificationManager.notify(STATUS_NOTIFICATION_ID, notification)
|
|
|
|
notificationManager.notify(STATUS_NOTIFICATION_ID, notification)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fun buildStatusNotification(
|
|
|
|
fun buildStatusNotification(
|
|
|
|
vpnRunning: Boolean,
|
|
|
|
vpnRunning: Boolean,
|
|
|
|
hideDisconnectAction: Boolean,
|
|
|
|
hideDisconnectAction: Boolean,
|
|
|
|
@ -504,6 +538,7 @@ open class UninitializedApp : Application() {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return builder.build()
|
|
|
|
return builder.build()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fun updateUserDisallowedPackageNames(packageNames: List<String>) {
|
|
|
|
fun updateUserDisallowedPackageNames(packageNames: List<String>) {
|
|
|
|
if (packageNames.any { it.isEmpty() }) {
|
|
|
|
if (packageNames.any { it.isEmpty() }) {
|
|
|
|
TSLog.e(TAG, "updateUserDisallowedPackageNames called with empty packageName(s)")
|
|
|
|
TSLog.e(TAG, "updateUserDisallowedPackageNames called with empty packageName(s)")
|
|
|
|
@ -512,6 +547,7 @@ open class UninitializedApp : Application() {
|
|
|
|
getUnencryptedPrefs().edit().putStringSet(DISALLOWED_APPS_KEY, packageNames.toSet()).apply()
|
|
|
|
getUnencryptedPrefs().edit().putStringSet(DISALLOWED_APPS_KEY, packageNames.toSet()).apply()
|
|
|
|
this.restartVPN()
|
|
|
|
this.restartVPN()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fun disallowedPackageNames(): List<String> {
|
|
|
|
fun disallowedPackageNames(): List<String> {
|
|
|
|
val mdmDisallowed =
|
|
|
|
val mdmDisallowed =
|
|
|
|
MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
|
|
|
|
MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
|
|
|
|
|