Implement custom permission system for plugins

This commit is contained in:
MM20 2023-12-18 16:51:48 +01:00
parent 1c542f38ba
commit cb7f6b6693
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
20 changed files with 294 additions and 126 deletions

View File

@ -1,6 +1,10 @@
package de.mm20.launcher2.ui.settings.plugins
import android.app.Activity
import android.app.PendingIntent
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
@ -18,13 +22,10 @@ import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.InsertDriveFile
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Error
import androidx.compose.material.icons.rounded.ErrorOutline
import androidx.compose.material.icons.rounded.FileCopy
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.LightMode
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Verified
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material.icons.rounded.WarningAmber
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@ -66,12 +67,25 @@ fun PluginSettingsScreen(pluginId: String) {
val icon by viewModel.icon.collectAsStateWithLifecycle(null)
val types by viewModel.types.collectAsStateWithLifecycle(emptyList())
val hasPermission by viewModel.hasPermission.collectAsStateWithLifecycle(
null,
minActiveState = Lifecycle.State.RESUMED
)
val filePlugins by viewModel.filePlugins.collectAsStateWithLifecycle(
emptyList(),
minActiveState = Lifecycle.State.RESUMED
)
val enabledFileSearchPlugins by viewModel.enabledFileSearchPlugins.collectAsStateWithLifecycle(null)
val requestPermissionStarter =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
viewModel.setPluginEnabled(true)
}
}
val enabledFileSearchPlugins by viewModel.enabledFileSearchPlugins.collectAsStateWithLifecycle(
null
)
Scaffold(
topBar = {
@ -217,6 +231,7 @@ fun PluginSettingsScreen(pluginId: String) {
Icon(
when (type) {
PluginType.FileSearch -> Icons.AutoMirrored.Rounded.InsertDriveFile
PluginType.Weather -> Icons.Rounded.LightMode
},
null,
modifier = Modifier.size(16.dp),
@ -225,6 +240,7 @@ fun PluginSettingsScreen(pluginId: String) {
Text(
when (type) {
PluginType.FileSearch -> "File search"
PluginType.Weather -> "Weather provider"
},
modifier = Modifier.padding(horizontal = 4.dp),
style = MaterialTheme.typography.labelMedium,
@ -249,16 +265,25 @@ fun PluginSettingsScreen(pluginId: String) {
color = surfaceColor,
) {
SwitchPreference(
enabled = pluginPackage != null,
enabled = pluginPackage != null && hasPermission != null,
iconPadding = false,
title = "Enable plugin",
value = pluginPackage?.enabled == true,
value = pluginPackage?.enabled == true && hasPermission == true,
onValueChanged = {
viewModel.setPluginEnabled(it)
if (hasPermission == true) {
viewModel.setPluginEnabled(it)
} else {
requestPermissionStarter.launch(
Intent().apply {
`package` = pluginPackage?.packageName
action = "de.mm20.launcher2.plugin.REQUEST_PERMISSION"
}
)
}
}
)
}
AnimatedVisibility(pluginPackage?.enabled == true) {
AnimatedVisibility(pluginPackage?.enabled == true && hasPermission == true) {
if (filePlugins.isNotEmpty()) {
PreferenceCategory(
"File search",
@ -299,7 +324,10 @@ fun PluginSettingsScreen(pluginId: String) {
?: plugin.plugin.description,
value = enabledFileSearchPlugins?.contains(plugin.plugin.authority) == true && state is PluginState.Ready,
onValueChanged = {
viewModel.setFileSearchPluginEnabled(plugin.plugin.authority, it)
viewModel.setFileSearchPluginEnabled(
plugin.plugin.authority,
it
)
},
iconPadding = false,
)

View File

@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@ -50,17 +51,24 @@ class PluginSettingsScreenVM : ViewModel(), KoinComponent {
it?.plugins?.map { it.type }?.distinct() ?: emptyList()
}
val filePlugins = pluginPackage
val states = pluginPackage
.map {
it?.plugins?.mapNotNull {
if (it.type == PluginType.FileSearch) {
val state = pluginService.getPluginState(it)
PluginWithState(it, state)
} else {
null
}
it?.plugins?.map {
val state = pluginService.getPluginState(it)
PluginWithState(it, state)
} ?: emptyList()
}
.shareIn(viewModelScope, SharingStarted.WhileSubscribed())
val hasPermission = states
.map {
it.none { it.state is PluginState.NoPermission }
}
val filePlugins = states
.map {
it.filter { it.plugin.type == PluginType.FileSearch }
}
fun init(pluginId: String) {

View File

@ -36,50 +36,12 @@ import de.mm20.launcher2.ui.locals.LocalNavController
@Composable
fun PluginsSettingsScreen() {
val viewModel: PluginsSettingsScreenVM = viewModel()
val navController = LocalNavController.current
val hostInstalled by viewModel.hostInstalled.collectAsState(null)
val hasPermission by viewModel.hasPermission.collectAsState(null)
val context = LocalContext.current
val pluginPackages by viewModel.pluginPackages.collectAsState(null)
val enabledPackages by viewModel.enabledPluginPackages.collectAsState(emptyList())
val disabledPackages by viewModel.disabledPluginPackages.collectAsState(emptyList())
PreferenceScreen(title = stringResource(R.string.preference_screen_plugins)) {
when {
hostInstalled == false -> {
item {
Column(
modifier = Modifier
.fillMaxWidth()
.fillParentMaxHeight()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
LargeMessage(
icon = Icons.Rounded.ExtensionOff,
text = stringResource(R.string.plugin_host_not_installed),
color = MaterialTheme.colorScheme.secondary
)
}
}
}
hasPermission == false -> {
item {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
MissingPermissionBanner(
text = stringResource(R.string.missing_permission_plugins),
onClick = { viewModel.requestPermission(context) }
)
}
}
}
pluginPackages?.isEmpty() == true -> {
item {
Column(

View File

@ -19,10 +19,7 @@ import org.koin.core.component.inject
class PluginsSettingsScreenVM : ViewModel(), KoinComponent {
private val pluginService: PluginService by inject()
private val permissionsManager: PermissionsManager by inject()
val hostInstalled = pluginService.isPluginHostInstalled()
val hasPermission = permissionsManager.hasPermission(PermissionGroup.Plugins)
val pluginPackages = pluginService
.getPluginPackages()
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(100), 1)
@ -35,10 +32,6 @@ class PluginsSettingsScreenVM : ViewModel(), KoinComponent {
it.filter { !it.enabled }.sortedBy { it.label }
}
fun requestPermission(context: Context) {
permissionsManager.requestPermission(context as AppCompatActivity, PermissionGroup.Plugins)
}
fun getIcon(plugin: PluginPackage) = flow {
emit(pluginService.getPluginPackageIcon(plugin))
}

View File

@ -19,6 +19,8 @@ sealed class PluginState {
data object Error: PluginState()
data object NoPermission: PluginState()
companion object {
fun fromBundle(bundle: Bundle): PluginState? {
val type = bundle.getString("type") ?: return null

View File

@ -64,7 +64,6 @@ enum class PermissionGroup {
Notifications,
AppShortcuts,
Accessibility,
Plugins,
}
internal class PermissionsManagerImpl(
@ -85,9 +84,6 @@ internal class PermissionsManagerImpl(
private val locationPermissionState = MutableStateFlow(
checkPermissionOnce(PermissionGroup.Location)
)
private val pluginsPermissionState = MutableStateFlow(
checkPermissionOnce(PermissionGroup.Plugins)
)
private val notificationsPermissionState = MutableStateFlow(false)
private val accessibilityPermissionState = MutableStateFlow(false)
private val appShortcutsPermissionState = MutableStateFlow(
@ -158,14 +154,6 @@ internal class PermissionsManagerImpl(
CrashReporter.logException(e)
}
}
PermissionGroup.Plugins -> {
ActivityCompat.requestPermissions(
context,
pluginPermissions,
permissionGroup.ordinal
)
}
}
}
@ -183,10 +171,6 @@ internal class PermissionsManagerImpl(
contactPermissions.all { context.checkPermission(it) }
}
PermissionGroup.Plugins -> {
pluginPermissions.all { context.checkPermission(it) }
}
PermissionGroup.ExternalStorage -> {
if (isAtLeastApiLevel(Build.VERSION_CODES.R)) {
Environment.isExternalStorageManager()
@ -218,7 +202,6 @@ internal class PermissionsManagerImpl(
PermissionGroup.Notifications -> notificationsPermissionState
PermissionGroup.AppShortcuts -> appShortcutsPermissionState
PermissionGroup.Accessibility -> accessibilityPermissionState
PermissionGroup.Plugins -> pluginsPermissionState
}
}
@ -237,7 +220,6 @@ internal class PermissionsManagerImpl(
PermissionGroup.Notifications -> notificationsPermissionState.value = granted
PermissionGroup.AppShortcuts -> appShortcutsPermissionState.value = granted
PermissionGroup.Accessibility -> accessibilityPermissionState.value = granted
PermissionGroup.Plugins -> pluginsPermissionState.value = granted
}
}
@ -280,6 +262,5 @@ internal class PermissionsManagerImpl(
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE
)
private val pluginPermissions = arrayOf(PluginContract.Permission)
}
}

View File

@ -1,8 +1,6 @@
package de.mm20.launcher2.plugin.contracts
object PluginContract {
const val Permission = "de.mm20.launcher2.permission.USE_PLUGINS"
object Methods {
const val GetType = "getType"
const val GetState = "getState"

View File

@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.plugin.serialization)
}
android {

View File

@ -0,0 +1 @@
package de.mm20.launcher2.weather.settings

View File

@ -0,0 +1,27 @@
package de.mm20.launcher2.weather.settings
import kotlinx.serialization.Serializable
@Serializable
data class LatLon(
val lat: Double,
val lon: Double,
)
@Serializable
data class ProviderSettings(
val lastUpdate: Long = 0,
val locationId: String? = null,
val locationName: String? = null,
)
@Serializable
data class WeatherSettingsData(
val provider: String = "metno",
val autoLocation: Boolean = true,
val location: LatLon? = null,
val locationName: String? = null,
val lastLocation: LatLon? = null,
val providerSettings: Map<String, ProviderSettings>
)

View File

@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.plugin.serialization)
`maven-publish`
}
@ -14,6 +15,10 @@ android {
consumerProguardFiles("consumer-rules.pro")
}
buildFeatures {
viewBinding = true
}
buildTypes {
release {
proguardFiles(
@ -46,6 +51,8 @@ android {
dependencies {
api(project(":core:shared"))
implementation(libs.androidx.datastore)
implementation(libs.kotlinx.serialization.json)
implementation(libs.bundles.kotlin)
}

View File

@ -0,0 +1,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".permissions.RequestPermissionActivity"
android:exported="true"
android:theme="@android:style/Theme.DeviceDefault.Dialog.NoActionBar.MinWidth">
<intent-filter>
<action android:name="de.mm20.launcher2.plugin.REQUEST_PERMISSION" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -8,17 +8,22 @@ import android.os.Bundle
import de.mm20.launcher2.plugin.PluginType
import de.mm20.launcher2.plugin.contracts.PluginContract
import de.mm20.launcher2.sdk.PluginState
import de.mm20.launcher2.sdk.permissions.PluginPermissionManager
import de.mm20.launcher2.sdk.permissions.permissionsDataStore
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
abstract class BasePluginProvider : ContentProvider() {
override fun call(method: String, arg: String?, extras: Bundle?): Bundle? {
val context = context ?: return null
return when (method) {
PluginContract.Methods.GetType -> Bundle().apply {
putString("type", getPluginType().name)
}
PluginContract.Methods.GetState -> {
checkPermissionOrThrow(context)
val state = runBlocking {
getPluginState()
}
@ -27,6 +32,7 @@ abstract class BasePluginProvider : ContentProvider() {
}
PluginContract.Methods.GetConfig -> {
checkPermissionOrThrow(context)
getPluginConfig()
}
@ -45,7 +51,12 @@ abstract class BasePluginProvider : ContentProvider() {
}
internal fun checkPermissionOrThrow(context: Context) {
if (context.checkCallingPermission(PluginContract.Permission) == PackageManager.PERMISSION_GRANTED) {
val callingPackage = callingPackage ?: throw IllegalArgumentException("No calling package")
val permissionManager = PluginPermissionManager(context)
val hasPermission = runBlocking {
permissionManager.hasPermission(callingPackage).first()
}
if (hasPermission) {
return
}
throw SecurityException("Caller does not have permission to use plugins")

View File

@ -0,0 +1,25 @@
package de.mm20.launcher2.sdk.permissions
import android.content.Context
import androidx.datastore.core.Serializer
import androidx.datastore.dataStore
import java.io.InputStream
import java.io.OutputStream
internal val Context.permissionsDataStore by dataStore(
fileName = "plugin_permissions",
serializer = PermissionsSerializer,
)
internal object PermissionsSerializer : Serializer<Set<String>> {
override val defaultValue: Set<String>
get() = emptySet()
override suspend fun readFrom(input: InputStream): Set<String> {
return input.bufferedReader().readLines().toSet()
}
override suspend fun writeTo(t: Set<String>, output: OutputStream) {
output.bufferedWriter().write(t.joinToString("\n"))
}
}

View File

@ -0,0 +1,32 @@
package de.mm20.launcher2.sdk.permissions
import android.content.Context
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
class PluginPermissionManager(
context: Context,
) {
private val dataStore = context.applicationContext.permissionsDataStore
fun hasPermission(pluginPackage: String): Flow<Boolean> {
return dataStore.data.map { it.contains(pluginPackage) }
}
fun grantPermission(pluginPackage: String) {
runBlocking {
dataStore.updateData {
it + pluginPackage
}
}
}
fun revokePermission(pluginPackage: String) {
runBlocking {
dataStore.updateData {
it - pluginPackage
}
}
}
}

View File

@ -0,0 +1,64 @@
package de.mm20.launcher2.sdk.permissions
import android.app.Activity
import android.content.pm.PackageManager
import android.os.Bundle
import android.text.Html
import android.view.LayoutInflater
import de.mm20.launcher2.sdk.R
import de.mm20.launcher2.sdk.databinding.ActivityRequestPermissionBinding
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
class RequestPermissionActivity: Activity() {
private lateinit var binding: ActivityRequestPermissionBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val callingPackage = callingPackage ?: throw IllegalArgumentException("No calling package")
val callingPackageInfo = try {
packageManager.getApplicationInfo(callingPackage, 0)
} catch (e: PackageManager.NameNotFoundException) {
throw IllegalArgumentException("Invalid calling package")
}
val myPackageInfo = try {
packageManager.getApplicationInfo(packageName, 0)
} catch (e: PackageManager.NameNotFoundException) {
throw IllegalStateException("Invalid package")
}
val permissionManager = PluginPermissionManager(this)
val hasPermission = runBlocking {
permissionManager.hasPermission(callingPackage).first()
}
if (hasPermission) {
finish()
return
}
binding = ActivityRequestPermissionBinding.inflate(LayoutInflater.from(this))
val text = getString(
R.string.request_permission_message,
callingPackageInfo.loadLabel(packageManager),
myPackageInfo.loadLabel(packageManager)
)
binding.textView.text = Html.fromHtml(text, Html.FROM_HTML_MODE_LEGACY)
setContentView(binding.root)
binding.grantButton.setOnClickListener {
permissionManager.grantPermission(callingPackage)
setResult(RESULT_OK)
finish()
}
binding.denyButton.setOnClickListener {
permissionManager.revokePermission(callingPackage)
setResult(RESULT_CANCELED)
finish()
}
}
}

View File

@ -0,0 +1,33 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@android:style/TextAppearance.DeviceDefault"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:gravity="end">
<Button
android:id="@+id/deny_button"
style="@android:style/Widget.DeviceDefault.Button.Borderless"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/request_permission_deny_button" />
<Button
android:id="@+id/grant_button"
style="@android:style/Widget.DeviceDefault.Button.Borderless"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/request_permission_allow_button" />
</LinearLayout>
</LinearLayout>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="request_permission_message"><![CDATA[<b>%1$s</b> wants to access data from <b>%2$s</b>.]]></string>
<string name="request_permission_deny_button">Deny</string>
<string name="request_permission_allow_button">Allow</string>
</resources>

View File

@ -4,5 +4,5 @@ import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val servicesPluginsModule = module {
single<PluginService> { PluginServiceImpl(androidContext(), get(), get()) }
single<PluginService> { PluginServiceImpl(androidContext(), get()) }
}

View File

@ -13,8 +13,6 @@ import android.os.Build
import android.util.Base64
import android.util.Log
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.plugin.Plugin
import de.mm20.launcher2.plugin.PluginPackage
import de.mm20.launcher2.plugin.PluginRepository
@ -25,8 +23,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
@ -50,8 +46,6 @@ interface PluginService {
enabled: Boolean? = null,
): Flow<List<PluginWithState>>
fun isPluginHostInstalled(): Flow<Boolean>
fun getPluginPackages(): Flow<List<PluginPackage>>
fun getPluginPackage(packageName: String): Flow<PluginPackage?>
suspend fun getPluginState(plugin: Plugin): PluginState?
@ -65,13 +59,10 @@ interface PluginService {
internal class PluginServiceImpl(
private val context: Context,
private val repository: PluginRepository,
private val permissionsManager: PermissionsManager,
) : PluginService {
private val scope: CoroutineScope = CoroutineScope(Job() + Dispatchers.Default)
private val pluginHostInstalled = MutableStateFlow(false)
private val mutex = Mutex()
init {
@ -84,14 +75,6 @@ internal class PluginServiceImpl(
addAction(Intent.ACTION_PACKAGE_CHANGED)
addDataScheme("package")
})
scope.launch {
permissionsManager.hasPermission(PermissionGroup.Plugins).collectLatest {
if (it) {
refreshPlugins()
}
}
}
}
override fun enablePluginPackage(plugin: PluginPackage) {
@ -113,14 +96,6 @@ internal class PluginServiceImpl(
private fun refreshPlugins() {
Log.d("PluginService", "Refreshing plugins")
scope.launch {
try {
val permission =
context.packageManager.getPermissionInfo(PluginContract.Permission, 0)
pluginHostInstalled.value = permission != null
} catch (e: PackageManager.NameNotFoundException) {
pluginHostInstalled.value = false
return@launch
}
mutex.withLock {
val enabledPluginPackages =
repository.findMany(enabled = true).first().map { it.packageName }.distinct()
@ -157,24 +132,25 @@ internal class PluginServiceImpl(
}
override suspend fun getPluginState(plugin: Plugin): PluginState {
val bundle = withContext(Dispatchers.IO) {
context.contentResolver.call(
Uri.Builder()
.scheme("content")
.authority(plugin.authority)
.build(),
PluginContract.Methods.GetState,
null,
null
)
} ?: return PluginState.Error
val bundle =
try {
withContext(Dispatchers.IO) {
context.contentResolver.call(
Uri.Builder()
.scheme("content")
.authority(plugin.authority)
.build(),
PluginContract.Methods.GetState,
null,
null
)
} ?: return PluginState.Error
} catch (e: SecurityException) {
return PluginState.NoPermission
}
return PluginState.fromBundle(bundle) ?: PluginState.Error
}
override fun isPluginHostInstalled(): Flow<Boolean> {
return pluginHostInstalled
}
override suspend fun getPluginIcon(plugin: Plugin): Drawable? {
return withContext(Dispatchers.IO) {
val info = try {