Implement custom permission system for plugins
This commit is contained in:
parent
1c542f38ba
commit
cb7f6b6693
@ -1,6 +1,10 @@
|
|||||||
package de.mm20.launcher2.ui.settings.plugins
|
package de.mm20.launcher2.ui.settings.plugins
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.app.PendingIntent
|
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.appcompat.app.AppCompatActivity
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.animateColorAsState
|
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.automirrored.rounded.InsertDriveFile
|
||||||
import androidx.compose.material.icons.rounded.Delete
|
import androidx.compose.material.icons.rounded.Delete
|
||||||
import androidx.compose.material.icons.rounded.Error
|
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.Info
|
||||||
|
import androidx.compose.material.icons.rounded.LightMode
|
||||||
import androidx.compose.material.icons.rounded.Settings
|
import androidx.compose.material.icons.rounded.Settings
|
||||||
import androidx.compose.material.icons.rounded.Verified
|
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.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@ -66,12 +67,25 @@ fun PluginSettingsScreen(pluginId: String) {
|
|||||||
val icon by viewModel.icon.collectAsStateWithLifecycle(null)
|
val icon by viewModel.icon.collectAsStateWithLifecycle(null)
|
||||||
val types by viewModel.types.collectAsStateWithLifecycle(emptyList())
|
val types by viewModel.types.collectAsStateWithLifecycle(emptyList())
|
||||||
|
|
||||||
|
val hasPermission by viewModel.hasPermission.collectAsStateWithLifecycle(
|
||||||
|
null,
|
||||||
|
minActiveState = Lifecycle.State.RESUMED
|
||||||
|
)
|
||||||
val filePlugins by viewModel.filePlugins.collectAsStateWithLifecycle(
|
val filePlugins by viewModel.filePlugins.collectAsStateWithLifecycle(
|
||||||
emptyList(),
|
emptyList(),
|
||||||
minActiveState = Lifecycle.State.RESUMED
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@ -217,6 +231,7 @@ fun PluginSettingsScreen(pluginId: String) {
|
|||||||
Icon(
|
Icon(
|
||||||
when (type) {
|
when (type) {
|
||||||
PluginType.FileSearch -> Icons.AutoMirrored.Rounded.InsertDriveFile
|
PluginType.FileSearch -> Icons.AutoMirrored.Rounded.InsertDriveFile
|
||||||
|
PluginType.Weather -> Icons.Rounded.LightMode
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
@ -225,6 +240,7 @@ fun PluginSettingsScreen(pluginId: String) {
|
|||||||
Text(
|
Text(
|
||||||
when (type) {
|
when (type) {
|
||||||
PluginType.FileSearch -> "File search"
|
PluginType.FileSearch -> "File search"
|
||||||
|
PluginType.Weather -> "Weather provider"
|
||||||
},
|
},
|
||||||
modifier = Modifier.padding(horizontal = 4.dp),
|
modifier = Modifier.padding(horizontal = 4.dp),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
@ -249,16 +265,25 @@ fun PluginSettingsScreen(pluginId: String) {
|
|||||||
color = surfaceColor,
|
color = surfaceColor,
|
||||||
) {
|
) {
|
||||||
SwitchPreference(
|
SwitchPreference(
|
||||||
enabled = pluginPackage != null,
|
enabled = pluginPackage != null && hasPermission != null,
|
||||||
iconPadding = false,
|
iconPadding = false,
|
||||||
title = "Enable plugin",
|
title = "Enable plugin",
|
||||||
value = pluginPackage?.enabled == true,
|
value = pluginPackage?.enabled == true && hasPermission == true,
|
||||||
onValueChanged = {
|
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()) {
|
if (filePlugins.isNotEmpty()) {
|
||||||
PreferenceCategory(
|
PreferenceCategory(
|
||||||
"File search",
|
"File search",
|
||||||
@ -299,7 +324,10 @@ fun PluginSettingsScreen(pluginId: String) {
|
|||||||
?: plugin.plugin.description,
|
?: plugin.plugin.description,
|
||||||
value = enabledFileSearchPlugins?.contains(plugin.plugin.authority) == true && state is PluginState.Ready,
|
value = enabledFileSearchPlugins?.contains(plugin.plugin.authority) == true && state is PluginState.Ready,
|
||||||
onValueChanged = {
|
onValueChanged = {
|
||||||
viewModel.setFileSearchPluginEnabled(plugin.plugin.authority, it)
|
viewModel.setFileSearchPluginEnabled(
|
||||||
|
plugin.plugin.authority,
|
||||||
|
it
|
||||||
|
)
|
||||||
},
|
},
|
||||||
iconPadding = false,
|
iconPadding = false,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.distinctUntilChangedBy
|
|||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.shareIn
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.inject
|
import org.koin.core.component.inject
|
||||||
@ -50,17 +51,24 @@ class PluginSettingsScreenVM : ViewModel(), KoinComponent {
|
|||||||
it?.plugins?.map { it.type }?.distinct() ?: emptyList()
|
it?.plugins?.map { it.type }?.distinct() ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
val filePlugins = pluginPackage
|
val states = pluginPackage
|
||||||
.map {
|
.map {
|
||||||
it?.plugins?.mapNotNull {
|
it?.plugins?.map {
|
||||||
if (it.type == PluginType.FileSearch) {
|
val state = pluginService.getPluginState(it)
|
||||||
val state = pluginService.getPluginState(it)
|
PluginWithState(it, state)
|
||||||
PluginWithState(it, state)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
} ?: emptyList()
|
} ?: 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) {
|
fun init(pluginId: String) {
|
||||||
|
|||||||
@ -36,50 +36,12 @@ import de.mm20.launcher2.ui.locals.LocalNavController
|
|||||||
@Composable
|
@Composable
|
||||||
fun PluginsSettingsScreen() {
|
fun PluginsSettingsScreen() {
|
||||||
val viewModel: PluginsSettingsScreenVM = viewModel()
|
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 context = LocalContext.current
|
||||||
val pluginPackages by viewModel.pluginPackages.collectAsState(null)
|
val pluginPackages by viewModel.pluginPackages.collectAsState(null)
|
||||||
val enabledPackages by viewModel.enabledPluginPackages.collectAsState(emptyList())
|
val enabledPackages by viewModel.enabledPluginPackages.collectAsState(emptyList())
|
||||||
val disabledPackages by viewModel.disabledPluginPackages.collectAsState(emptyList())
|
val disabledPackages by viewModel.disabledPluginPackages.collectAsState(emptyList())
|
||||||
PreferenceScreen(title = stringResource(R.string.preference_screen_plugins)) {
|
PreferenceScreen(title = stringResource(R.string.preference_screen_plugins)) {
|
||||||
when {
|
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 -> {
|
pluginPackages?.isEmpty() == true -> {
|
||||||
item {
|
item {
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
@ -19,10 +19,7 @@ import org.koin.core.component.inject
|
|||||||
class PluginsSettingsScreenVM : ViewModel(), KoinComponent {
|
class PluginsSettingsScreenVM : ViewModel(), KoinComponent {
|
||||||
|
|
||||||
private val pluginService: PluginService by inject()
|
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
|
val pluginPackages = pluginService
|
||||||
.getPluginPackages()
|
.getPluginPackages()
|
||||||
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(100), 1)
|
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(100), 1)
|
||||||
@ -35,10 +32,6 @@ class PluginsSettingsScreenVM : ViewModel(), KoinComponent {
|
|||||||
it.filter { !it.enabled }.sortedBy { it.label }
|
it.filter { !it.enabled }.sortedBy { it.label }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun requestPermission(context: Context) {
|
|
||||||
permissionsManager.requestPermission(context as AppCompatActivity, PermissionGroup.Plugins)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getIcon(plugin: PluginPackage) = flow {
|
fun getIcon(plugin: PluginPackage) = flow {
|
||||||
emit(pluginService.getPluginPackageIcon(plugin))
|
emit(pluginService.getPluginPackageIcon(plugin))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,8 @@ sealed class PluginState {
|
|||||||
|
|
||||||
data object Error: PluginState()
|
data object Error: PluginState()
|
||||||
|
|
||||||
|
data object NoPermission: PluginState()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromBundle(bundle: Bundle): PluginState? {
|
fun fromBundle(bundle: Bundle): PluginState? {
|
||||||
val type = bundle.getString("type") ?: return null
|
val type = bundle.getString("type") ?: return null
|
||||||
|
|||||||
@ -64,7 +64,6 @@ enum class PermissionGroup {
|
|||||||
Notifications,
|
Notifications,
|
||||||
AppShortcuts,
|
AppShortcuts,
|
||||||
Accessibility,
|
Accessibility,
|
||||||
Plugins,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class PermissionsManagerImpl(
|
internal class PermissionsManagerImpl(
|
||||||
@ -85,9 +84,6 @@ internal class PermissionsManagerImpl(
|
|||||||
private val locationPermissionState = MutableStateFlow(
|
private val locationPermissionState = MutableStateFlow(
|
||||||
checkPermissionOnce(PermissionGroup.Location)
|
checkPermissionOnce(PermissionGroup.Location)
|
||||||
)
|
)
|
||||||
private val pluginsPermissionState = MutableStateFlow(
|
|
||||||
checkPermissionOnce(PermissionGroup.Plugins)
|
|
||||||
)
|
|
||||||
private val notificationsPermissionState = MutableStateFlow(false)
|
private val notificationsPermissionState = MutableStateFlow(false)
|
||||||
private val accessibilityPermissionState = MutableStateFlow(false)
|
private val accessibilityPermissionState = MutableStateFlow(false)
|
||||||
private val appShortcutsPermissionState = MutableStateFlow(
|
private val appShortcutsPermissionState = MutableStateFlow(
|
||||||
@ -158,14 +154,6 @@ internal class PermissionsManagerImpl(
|
|||||||
CrashReporter.logException(e)
|
CrashReporter.logException(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PermissionGroup.Plugins -> {
|
|
||||||
ActivityCompat.requestPermissions(
|
|
||||||
context,
|
|
||||||
pluginPermissions,
|
|
||||||
permissionGroup.ordinal
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -183,10 +171,6 @@ internal class PermissionsManagerImpl(
|
|||||||
contactPermissions.all { context.checkPermission(it) }
|
contactPermissions.all { context.checkPermission(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
PermissionGroup.Plugins -> {
|
|
||||||
pluginPermissions.all { context.checkPermission(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
PermissionGroup.ExternalStorage -> {
|
PermissionGroup.ExternalStorage -> {
|
||||||
if (isAtLeastApiLevel(Build.VERSION_CODES.R)) {
|
if (isAtLeastApiLevel(Build.VERSION_CODES.R)) {
|
||||||
Environment.isExternalStorageManager()
|
Environment.isExternalStorageManager()
|
||||||
@ -218,7 +202,6 @@ internal class PermissionsManagerImpl(
|
|||||||
PermissionGroup.Notifications -> notificationsPermissionState
|
PermissionGroup.Notifications -> notificationsPermissionState
|
||||||
PermissionGroup.AppShortcuts -> appShortcutsPermissionState
|
PermissionGroup.AppShortcuts -> appShortcutsPermissionState
|
||||||
PermissionGroup.Accessibility -> accessibilityPermissionState
|
PermissionGroup.Accessibility -> accessibilityPermissionState
|
||||||
PermissionGroup.Plugins -> pluginsPermissionState
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -237,7 +220,6 @@ internal class PermissionsManagerImpl(
|
|||||||
PermissionGroup.Notifications -> notificationsPermissionState.value = granted
|
PermissionGroup.Notifications -> notificationsPermissionState.value = granted
|
||||||
PermissionGroup.AppShortcuts -> appShortcutsPermissionState.value = granted
|
PermissionGroup.AppShortcuts -> appShortcutsPermissionState.value = granted
|
||||||
PermissionGroup.Accessibility -> accessibilityPermissionState.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.READ_EXTERNAL_STORAGE,
|
||||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||||
)
|
)
|
||||||
private val pluginPermissions = arrayOf(PluginContract.Permission)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
package de.mm20.launcher2.plugin.contracts
|
package de.mm20.launcher2.plugin.contracts
|
||||||
|
|
||||||
object PluginContract {
|
object PluginContract {
|
||||||
|
|
||||||
const val Permission = "de.mm20.launcher2.permission.USE_PLUGINS"
|
|
||||||
object Methods {
|
object Methods {
|
||||||
const val GetType = "getType"
|
const val GetType = "getType"
|
||||||
const val GetState = "getState"
|
const val GetState = "getState"
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.library)
|
alias(libs.plugins.android.library)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.plugin.serialization)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
package de.mm20.launcher2.weather.settings
|
||||||
@ -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>
|
||||||
|
)
|
||||||
@ -1,6 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.library)
|
alias(libs.plugins.android.library)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.plugin.serialization)
|
||||||
`maven-publish`
|
`maven-publish`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -14,6 +15,10 @@ android {
|
|||||||
consumerProguardFiles("consumer-rules.pro")
|
consumerProguardFiles("consumer-rules.pro")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
viewBinding = true
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
@ -46,6 +51,8 @@ android {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
api(project(":core:shared"))
|
api(project(":core:shared"))
|
||||||
|
implementation(libs.androidx.datastore)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(libs.bundles.kotlin)
|
implementation(libs.bundles.kotlin)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
13
plugins/sdk/src/main/AndroidManifest.xml
Normal file
13
plugins/sdk/src/main/AndroidManifest.xml
Normal 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>
|
||||||
@ -8,17 +8,22 @@ import android.os.Bundle
|
|||||||
import de.mm20.launcher2.plugin.PluginType
|
import de.mm20.launcher2.plugin.PluginType
|
||||||
import de.mm20.launcher2.plugin.contracts.PluginContract
|
import de.mm20.launcher2.plugin.contracts.PluginContract
|
||||||
import de.mm20.launcher2.sdk.PluginState
|
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
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
abstract class BasePluginProvider : ContentProvider() {
|
abstract class BasePluginProvider : ContentProvider() {
|
||||||
|
|
||||||
override fun call(method: String, arg: String?, extras: Bundle?): Bundle? {
|
override fun call(method: String, arg: String?, extras: Bundle?): Bundle? {
|
||||||
|
val context = context ?: return null
|
||||||
return when (method) {
|
return when (method) {
|
||||||
PluginContract.Methods.GetType -> Bundle().apply {
|
PluginContract.Methods.GetType -> Bundle().apply {
|
||||||
putString("type", getPluginType().name)
|
putString("type", getPluginType().name)
|
||||||
}
|
}
|
||||||
|
|
||||||
PluginContract.Methods.GetState -> {
|
PluginContract.Methods.GetState -> {
|
||||||
|
checkPermissionOrThrow(context)
|
||||||
val state = runBlocking {
|
val state = runBlocking {
|
||||||
getPluginState()
|
getPluginState()
|
||||||
}
|
}
|
||||||
@ -27,6 +32,7 @@ abstract class BasePluginProvider : ContentProvider() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
PluginContract.Methods.GetConfig -> {
|
PluginContract.Methods.GetConfig -> {
|
||||||
|
checkPermissionOrThrow(context)
|
||||||
getPluginConfig()
|
getPluginConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,7 +51,12 @@ abstract class BasePluginProvider : ContentProvider() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
internal fun checkPermissionOrThrow(context: Context) {
|
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
|
return
|
||||||
}
|
}
|
||||||
throw SecurityException("Caller does not have permission to use plugins")
|
throw SecurityException("Caller does not have permission to use plugins")
|
||||||
|
|||||||
@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
6
plugins/sdk/src/main/res/values/strings.xml
Normal file
6
plugins/sdk/src/main/res/values/strings.xml
Normal 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>
|
||||||
@ -4,5 +4,5 @@ import org.koin.android.ext.koin.androidContext
|
|||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
val servicesPluginsModule = module {
|
val servicesPluginsModule = module {
|
||||||
single<PluginService> { PluginServiceImpl(androidContext(), get(), get()) }
|
single<PluginService> { PluginServiceImpl(androidContext(), get()) }
|
||||||
}
|
}
|
||||||
@ -13,8 +13,6 @@ import android.os.Build
|
|||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import de.mm20.launcher2.ktx.tryStartActivity
|
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.Plugin
|
||||||
import de.mm20.launcher2.plugin.PluginPackage
|
import de.mm20.launcher2.plugin.PluginPackage
|
||||||
import de.mm20.launcher2.plugin.PluginRepository
|
import de.mm20.launcher2.plugin.PluginRepository
|
||||||
@ -25,8 +23,6 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
@ -50,8 +46,6 @@ interface PluginService {
|
|||||||
enabled: Boolean? = null,
|
enabled: Boolean? = null,
|
||||||
): Flow<List<PluginWithState>>
|
): Flow<List<PluginWithState>>
|
||||||
|
|
||||||
fun isPluginHostInstalled(): Flow<Boolean>
|
|
||||||
|
|
||||||
fun getPluginPackages(): Flow<List<PluginPackage>>
|
fun getPluginPackages(): Flow<List<PluginPackage>>
|
||||||
fun getPluginPackage(packageName: String): Flow<PluginPackage?>
|
fun getPluginPackage(packageName: String): Flow<PluginPackage?>
|
||||||
suspend fun getPluginState(plugin: Plugin): PluginState?
|
suspend fun getPluginState(plugin: Plugin): PluginState?
|
||||||
@ -65,13 +59,10 @@ interface PluginService {
|
|||||||
internal class PluginServiceImpl(
|
internal class PluginServiceImpl(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val repository: PluginRepository,
|
private val repository: PluginRepository,
|
||||||
private val permissionsManager: PermissionsManager,
|
|
||||||
) : PluginService {
|
) : PluginService {
|
||||||
|
|
||||||
private val scope: CoroutineScope = CoroutineScope(Job() + Dispatchers.Default)
|
private val scope: CoroutineScope = CoroutineScope(Job() + Dispatchers.Default)
|
||||||
|
|
||||||
private val pluginHostInstalled = MutableStateFlow(false)
|
|
||||||
|
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@ -84,14 +75,6 @@ internal class PluginServiceImpl(
|
|||||||
addAction(Intent.ACTION_PACKAGE_CHANGED)
|
addAction(Intent.ACTION_PACKAGE_CHANGED)
|
||||||
addDataScheme("package")
|
addDataScheme("package")
|
||||||
})
|
})
|
||||||
|
|
||||||
scope.launch {
|
|
||||||
permissionsManager.hasPermission(PermissionGroup.Plugins).collectLatest {
|
|
||||||
if (it) {
|
|
||||||
refreshPlugins()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun enablePluginPackage(plugin: PluginPackage) {
|
override fun enablePluginPackage(plugin: PluginPackage) {
|
||||||
@ -113,14 +96,6 @@ internal class PluginServiceImpl(
|
|||||||
private fun refreshPlugins() {
|
private fun refreshPlugins() {
|
||||||
Log.d("PluginService", "Refreshing plugins")
|
Log.d("PluginService", "Refreshing plugins")
|
||||||
scope.launch {
|
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 {
|
mutex.withLock {
|
||||||
val enabledPluginPackages =
|
val enabledPluginPackages =
|
||||||
repository.findMany(enabled = true).first().map { it.packageName }.distinct()
|
repository.findMany(enabled = true).first().map { it.packageName }.distinct()
|
||||||
@ -157,24 +132,25 @@ internal class PluginServiceImpl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPluginState(plugin: Plugin): PluginState {
|
override suspend fun getPluginState(plugin: Plugin): PluginState {
|
||||||
val bundle = withContext(Dispatchers.IO) {
|
val bundle =
|
||||||
context.contentResolver.call(
|
try {
|
||||||
Uri.Builder()
|
withContext(Dispatchers.IO) {
|
||||||
.scheme("content")
|
context.contentResolver.call(
|
||||||
.authority(plugin.authority)
|
Uri.Builder()
|
||||||
.build(),
|
.scheme("content")
|
||||||
PluginContract.Methods.GetState,
|
.authority(plugin.authority)
|
||||||
null,
|
.build(),
|
||||||
null
|
PluginContract.Methods.GetState,
|
||||||
)
|
null,
|
||||||
} ?: return PluginState.Error
|
null
|
||||||
|
)
|
||||||
|
} ?: return PluginState.Error
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
return PluginState.NoPermission
|
||||||
|
}
|
||||||
return PluginState.fromBundle(bundle) ?: PluginState.Error
|
return PluginState.fromBundle(bundle) ?: PluginState.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun isPluginHostInstalled(): Flow<Boolean> {
|
|
||||||
return pluginHostInstalled
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getPluginIcon(plugin: Plugin): Drawable? {
|
override suspend fun getPluginIcon(plugin: Plugin): Drawable? {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
val info = try {
|
val info = try {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user