Group plugins by package

This commit is contained in:
MM20 2023-11-25 14:47:22 +01:00
parent 6c311fc947
commit 25cd9b707e
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
17 changed files with 728 additions and 101 deletions

View File

@ -15,6 +15,7 @@ import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.ArrowBack
import androidx.compose.material.icons.rounded.HelpOutline
import androidx.compose.material3.CenterAlignedTopAppBar
@ -80,9 +81,6 @@ fun PreferenceScreen(
content: LazyListScope.() -> Unit,
) {
val navController = LocalNavController.current
val systemUiController = rememberSystemUiController()
systemUiController.setStatusBarColor(MaterialTheme.colorScheme.surface)
systemUiController.setNavigationBarColor(Color.Black)
val context = LocalContext.current
@ -124,7 +122,7 @@ fun PreferenceScreen(
activity?.onBackPressed()
}
}) {
Icon(imageVector = Icons.Rounded.ArrowBack, contentDescription = "Back")
Icon(imageVector = Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
}
},
actions = {

View File

@ -1,17 +1,21 @@
package de.mm20.launcher2.ui.settings
import android.os.Bundle
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.toArgb
import androidx.core.view.WindowCompat
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@ -21,6 +25,7 @@ import de.mm20.launcher2.licenses.AppLicense
import de.mm20.launcher2.licenses.OpenSourceLicenses
import de.mm20.launcher2.ui.base.BaseActivity
import de.mm20.launcher2.ui.base.ProvideSettings
import de.mm20.launcher2.ui.locals.LocalDarkTheme
import de.mm20.launcher2.ui.locals.LocalNavController
import de.mm20.launcher2.ui.locals.LocalWallpaperColors
import de.mm20.launcher2.ui.overlays.OverlayHost
@ -47,6 +52,7 @@ import de.mm20.launcher2.ui.settings.license.LicenseScreen
import de.mm20.launcher2.ui.settings.log.LogScreen
import de.mm20.launcher2.ui.settings.main.MainSettingsScreen
import de.mm20.launcher2.ui.settings.media.MediaIntegrationSettingsScreen
import de.mm20.launcher2.ui.settings.plugins.PluginSettingsScreen
import de.mm20.launcher2.ui.settings.plugins.PluginsSettingsScreen
import de.mm20.launcher2.ui.settings.search.SearchSettingsScreen
import de.mm20.launcher2.ui.settings.searchactions.SearchActionsSettingsScreen
@ -79,6 +85,15 @@ class SettingsActivity : BaseActivity() {
) {
ProvideSettings {
LauncherTheme {
val systemBarColor = MaterialTheme.colorScheme.surfaceDim
val systemBarColorAlt = MaterialTheme.colorScheme.onSurface
val isDarkTheme = LocalDarkTheme.current
LaunchedEffect(isDarkTheme, systemBarColor, systemBarColorAlt) {
enableEdgeToEdge(
if (isDarkTheme) SystemBarStyle.dark(systemBarColor.toArgb())
else SystemBarStyle.light(systemBarColor.toArgb(), systemBarColorAlt.toArgb())
)
}
OverlayHost {
NavHost(
navController = navController,
@ -167,6 +182,9 @@ class SettingsActivity : BaseActivity() {
composable("settings/plugins") {
PluginsSettingsScreen()
}
composable("settings/plugins/{id}") {
PluginSettingsScreen(it.arguments?.getString("id") ?: return@composable)
}
composable("settings/about") {
AboutSettingsScreen()
}

View File

@ -0,0 +1,256 @@
package de.mm20.launcher2.ui.settings.plugins
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
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.Info
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Verified
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import de.mm20.launcher2.plugin.PluginType
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.SwitchPreference
import de.mm20.launcher2.ui.locals.LocalNavController
@Composable
fun PluginSettingsScreen(pluginId: String) {
val navController = LocalNavController.current
val activity = LocalContext.current as AppCompatActivity
val context = LocalContext.current
val viewModel: PluginSettingsScreenVM = viewModel()
LaunchedEffect(pluginId) {
viewModel.init(pluginId)
}
val pluginPackage by viewModel.pluginPackage.collectAsStateWithLifecycle(null)
val icon by viewModel.icon.collectAsStateWithLifecycle(null)
val types by viewModel.types.collectAsStateWithLifecycle(emptyList())
val states by viewModel.states.collectAsStateWithLifecycle(
emptyList(),
minActiveState = Lifecycle.State.RESUMED
)
Scaffold(
topBar = {
TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = {
if (navController?.navigateUp() != true) {
activity.onBackPressed()
}
}) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = "Back"
)
}
},
actions = {
if (pluginPackage?.settings != null) {
IconButton(onClick = {
pluginPackage?.settings?.let {
activity.startActivity(it)
}
}) {
Icon(
imageVector = Icons.Rounded.Settings,
contentDescription = null
)
}
}
IconButton(onClick = {
viewModel.openAppInfo(context)
}) {
Icon(
imageVector = Icons.Rounded.Info,
contentDescription = null
)
}
IconButton(onClick = {
viewModel.uninstall(context)
navController?.navigateUp()
}) {
Icon(
imageVector = Icons.Rounded.Delete,
contentDescription = null
)
}
}
)
}
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(it)
) {
Surface(
modifier = Modifier.fillMaxWidth()
) {
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 8.dp, horizontal = 12.dp)
) {
AsyncImage(
model = icon,
contentDescription = null,
modifier = Modifier
.padding(end = 12.dp)
.size(48.dp)
)
Column {
Text(
pluginPackage?.label ?: "",
style = MaterialTheme.typography.titleLarge
)
if (pluginPackage?.isOfficial == true) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(top = 4.dp)
.background(
MaterialTheme.colorScheme.secondary,
shape = MaterialTheme.shapes.medium,
)
.padding(4.dp)
) {
Text(
"Official",
modifier = Modifier.padding(horizontal = 4.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSecondary,
)
Icon(
Icons.Rounded.Verified, null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSecondary,
)
}
} else if (pluginPackage?.author != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(top = 4.dp)
) {
Text(
pluginPackage!!.author!!,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.secondary,
)
}
}
}
}
pluginPackage?.description?.let {
Text(
text = it,
modifier = Modifier
.padding(
start = 12.dp,
end = 12.dp,
top = 16.dp,
bottom = 24.dp
),
style = MaterialTheme.typography.bodyMedium,
)
}
Row(
modifier = Modifier
.horizontalScroll(rememberScrollState())
.padding(bottom = 24.dp, start = 12.dp, end = 12.dp)
) {
for (type in types) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(end = 4.dp)
.background(
MaterialTheme.colorScheme.tertiaryContainer,
shape = MaterialTheme.shapes.medium,
)
.padding(4.dp)
) {
Icon(
when (type) {
PluginType.FileSearch -> Icons.AutoMirrored.Rounded.InsertDriveFile
},
null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onTertiaryContainer,
)
Text(
when (type) {
PluginType.FileSearch -> "File search"
},
modifier = Modifier.padding(horizontal = 4.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
}
}
}
}
val surfaceColor by animateColorAsState(
if (pluginPackage?.enabled == true) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainer
}
)
Surface(
modifier = Modifier
.fillMaxWidth(),
color = surfaceColor,
) {
SwitchPreference(
enabled = pluginPackage != null,
iconPadding = false,
title = "Enable plugin",
value = pluginPackage?.enabled == true,
onValueChanged = {
viewModel.setPluginEnabled(it)
}
)
}
AnimatedVisibility(pluginPackage?.enabled == true) {
PreferenceCategory {
}
}
}
}
}

View File

@ -0,0 +1,81 @@
package de.mm20.launcher2.ui.settings.plugins
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Drawable
import android.net.Uri
import android.provider.Settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.plugin.PluginPackage
import de.mm20.launcher2.plugin.PluginState
import de.mm20.launcher2.plugin.PluginType
import de.mm20.launcher2.plugins.PluginService
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class PluginSettingsScreenVM : ViewModel(), KoinComponent {
private val pluginService by inject<PluginService>()
private var pluginPackageName = MutableStateFlow<String?>(null)
val pluginPackage: StateFlow<PluginPackage?> = pluginPackageName.flatMapLatest {
if (it == null) {
emptyFlow()
} else {
pluginService.getPluginPackage(it)
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(100), null)
val icon: Flow<Drawable?> = pluginPackage
.distinctUntilChangedBy { it?.packageName }
.map {
it?.let { pluginService.getPluginPackageIcon(it) }
}
val types: Flow<List<PluginType>> = pluginPackage.map {
it?.plugins?.map { it.type }?.distinct() ?: emptyList()
}
val states: Flow<List<PluginState?>> = pluginPackage.map {
it?.plugins?.map {
pluginService.getPluginState(it)
} ?: emptyList()
}
fun init(pluginId: String) {
this.pluginPackageName.value = pluginId
}
fun setPluginEnabled(enabled: Boolean) {
val plugin = pluginPackage.value ?: return
if (enabled) {
pluginService.enablePluginPackage(plugin)
} else {
pluginService.disablePluginPackage(plugin)
}
}
fun openAppInfo(context: Context) {
val plugin = pluginPackage.value ?: return
context.tryStartActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:${plugin.packageName}")
})
}
fun uninstall(context: Context) {
val plugin = pluginPackage.value ?: return
pluginService.uninstallPluginPackage(context, plugin)
}
}

View File

@ -2,15 +2,16 @@ package de.mm20.launcher2.ui.settings.plugins
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Extension
import androidx.compose.material.icons.rounded.ExtensionOff
import androidx.compose.material.icons.rounded.Verified
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@ -23,19 +24,25 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import de.mm20.launcher2.plugin.PluginPackage
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.LargeMessage
import de.mm20.launcher2.ui.component.MissingPermissionBanner
import de.mm20.launcher2.ui.component.preferences.Preference
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
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 plugins by viewModel.plugins.collectAsState(null)
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 -> {
@ -73,7 +80,7 @@ fun PluginsSettingsScreen() {
}
}
plugins?.isEmpty() == true -> {
pluginPackages?.isEmpty() == true -> {
item {
Column(
modifier = Modifier
@ -92,28 +99,61 @@ fun PluginsSettingsScreen() {
}
}
plugins != null -> {
items(plugins!!) { item ->
val icon by remember(item.plugin.authority) {
viewModel.getIcon(item.plugin)
}.collectAsState(null)
Preference(
title = { Text(item.plugin.label) },
summary = item.plugin.description?.let { { Text(it) } },
controls = {
Switch(checked = item.plugin.enabled, onCheckedChange = {
viewModel.setPluginEnabled(item.plugin, it)
})
},
icon = {
AsyncImage(model = icon, contentDescription = null, modifier = Modifier.size(36.dp))
},
onClick = {
viewModel.setPluginEnabled(item.plugin, !item.plugin.enabled)
else -> {
if (enabledPackages.isNotEmpty()) {
item {
PreferenceCategory("Enabled") {
for (plugin in enabledPackages) {
PluginPreference(viewModel, plugin)
}
}
)
}
}
if (disabledPackages.isNotEmpty()) {
item {
PreferenceCategory("Installed") {
for (plugin in disabledPackages) {
PluginPreference(viewModel, plugin)
}
}
}
}
}
}
}
}
@Composable
private fun PluginPreference(viewModel: PluginsSettingsScreenVM, plugin: PluginPackage) {
val navController = LocalNavController.current
val icon by remember(plugin.packageName) {
viewModel.getIcon(plugin)
}.collectAsState(null)
Preference(
title = {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(plugin.label)
if (plugin.isOfficial) {
Icon(
Icons.Rounded.Verified, null,
modifier = Modifier.padding(start = 4.dp).size(16.dp),
tint = MaterialTheme.colorScheme.secondary,
)
}
}
},
summary = plugin.description?.let { { Text(it) } },
icon = {
AsyncImage(
model = icon,
contentDescription = null,
modifier = Modifier.size(36.dp)
)
},
onClick = {
navController?.navigate("settings/plugins/${plugin.packageName}")
}
)
}

View File

@ -3,13 +3,16 @@ package de.mm20.launcher2.ui.settings.plugins
import android.content.Context
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.ktx.normalize
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.plugins.PluginService
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.shareIn
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@ -20,23 +23,23 @@ class PluginsSettingsScreenVM : ViewModel(), KoinComponent {
val hostInstalled = pluginService.isPluginHostInstalled()
val hasPermission = permissionsManager.hasPermission(PermissionGroup.Plugins)
val plugins = hasPermission.flatMapLatest {
if (it) pluginService.getPluginsWithState() else emptyFlow()
val pluginPackages = pluginService
.getPluginPackages()
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(100), 1)
val enabledPluginPackages = pluginPackages.mapLatest {
it.filter { it.enabled }.sortedBy { it.label }
}
fun setPluginEnabled(plugin: Plugin, value: Boolean) {
if (value) {
pluginService.enablePlugin(plugin)
} else {
pluginService.disablePlugin(plugin)
}
val disabledPluginPackages = pluginPackages.mapLatest {
it.filter { !it.enabled }.sortedBy { it.label }
}
fun requestPermission(context: Context) {
permissionsManager.requestPermission(context as AppCompatActivity, PermissionGroup.Plugins)
}
fun getIcon(plugin: Plugin) = flow {
emit(pluginService.getPluginIcon(plugin))
fun getIcon(plugin: PluginPackage) = flow {
emit(pluginService.getPluginPackageIcon(plugin))
}
}

View File

@ -0,0 +1,16 @@
package de.mm20.launcher2.plugin
import android.content.Intent
data class PluginPackage(
val packageName: String,
val label: String,
val description: String? = null,
val author: String? = null,
val settings: Intent? = null,
val plugins: List<Plugin>,
val isOfficial: Boolean = false,
) {
val enabled: Boolean = plugins.all { it.enabled }
}

View File

@ -15,5 +15,6 @@ interface PluginRepository {
fun insertMany(plugins: List<Plugin>): Job
fun insert(plugin: Plugin): Job
fun update(plugin: Plugin): Job
fun updateMany(plugins: List<Plugin>): Job
fun deleteMany(): Job
}

View File

@ -0,0 +1,35 @@
package de.mm20.launcher2.plugin
import android.app.PendingIntent
import android.os.Bundle
sealed class PluginState {
data class Ready(
/**
* Status text, providing additional info what this plugin is currently configured to do.
* For example "Search %user's files on %service"
*/
val text: String? = null,
) : PluginState()
data class SetupRequired(
val setupActivity: PendingIntent,
val message: String? = null,
) : PluginState()
companion object {
fun fromBundle(bundle: Bundle): PluginState? {
val type = bundle.getString("type") ?: return null
return when(type) {
"Ready" -> Ready(
text = bundle.getString("text"),
)
"SetupRequired" -> SetupRequired(
setupActivity = bundle.getParcelable("setupActivity") ?: return null,
message = bundle.getString("message"),
)
else -> null
}
}
}
}

View File

@ -1,10 +0,0 @@
package de.mm20.launcher2.plugin
sealed class PluginState {
data object Ready : PluginState()
data class SetupRequired(
val setupActivity: String,
val message: String? = null,
) : PluginState()
}

View File

@ -36,6 +36,9 @@ interface PluginDao {
@Update
suspend fun update(plugin: PluginEntity)
@Update
suspend fun updateMany(plugins: List<PluginEntity>)
@Query("DELETE FROM Plugins")
suspend fun deleteMany()
}

View File

@ -360,6 +360,7 @@ internal class PluginFileDeserializer(
val authority = obj.getString("authority")
val id = obj.getString("id")
val plugin = pluginRepository.get(authority).firstOrNull() ?: return null
if (!plugin.enabled) return null
val provider = PluginFileProvider(context, plugin)
return provider.getFile(id)
} catch (e: Exception) {

View File

@ -52,6 +52,14 @@ internal class PluginRepositoryImpl(
}
}
override fun updateMany(plugins: List<Plugin>): Job {
return scope.launch {
dao.updateMany(
plugins.map { PluginEntity(it) }
)
}
}
override fun deleteMany(): Job {
return scope.launch {
dao.deleteMany()

View File

@ -4,7 +4,7 @@ compileSdk = "34"
targetSdk = "34"
gradle = "8.1.2"
android-gradle-plugin = "8.1.3"
android-gradle-plugin = "8.1.4"
protobuf-gradle-plugin = "0.9.4"
ksp-gradle-plugin = "1.9.0-1.0.13"

View File

@ -0,0 +1,34 @@
package de.mm20.launcher2.sdk
import android.app.PendingIntent
import android.content.Intent
import android.os.Bundle
import de.mm20.launcher2.sdk.base.BasePluginProvider
sealed class PluginState {
/**
* Plugin is ready to be used.
*/
data class Ready(
/**
* Status text, providing additional info what this plugin is currently configured to do.
* For example "Search %user's files on %service"
*/
val text: String? = null,
) : PluginState()
/**
* Plugin requires some setup, e.g. user needs to login to a service.
*/
data class SetupRequired(
/**
* Activity to start to setup the plugin.
*/
val setupActivity: Intent,
/**
* Optional message to display to the user, describing what needs to be done to setup the plugin.
*/
val message: String? = null,
) : PluginState()
}

View File

@ -1,12 +1,13 @@
package de.mm20.launcher2.sdk.base
import android.app.PendingIntent
import android.content.ContentProvider
import android.content.Context
import android.content.pm.PackageManager
import android.os.Bundle
import de.mm20.launcher2.plugin.PluginState
import de.mm20.launcher2.plugin.PluginType
import de.mm20.launcher2.plugin.contracts.PluginContract
import de.mm20.launcher2.sdk.PluginState
import kotlinx.coroutines.runBlocking
abstract class BasePluginProvider : ContentProvider() {
@ -17,23 +18,14 @@ abstract class BasePluginProvider : ContentProvider() {
putString("type", getPluginType().name)
}
PluginContract.Methods.GetState -> Bundle().apply {
PluginContract.Methods.GetState -> {
val state = runBlocking {
getPluginState()
}
when (state) {
is PluginState.SetupRequired -> {
putString("type", "SetupRequired")
putString("setupActivity", state.setupActivity)
putString("message", state.message)
}
is PluginState.Ready -> {
putString("type", "Ready")
}
}
return state.toBundle()
}
PluginContract.Methods.GetConfig -> {
getPluginConfig()
}
@ -49,7 +41,7 @@ abstract class BasePluginProvider : ContentProvider() {
}
open suspend fun getPluginState(): PluginState {
return PluginState.Ready
return PluginState.Ready()
}
internal fun checkPermissionOrThrow(context: Context) {
@ -59,4 +51,31 @@ abstract class BasePluginProvider : ContentProvider() {
throw SecurityException("Caller does not have permission to use plugins")
}
private fun PluginState.toBundle(): Bundle {
when (this) {
is PluginState.Ready -> {
return Bundle().apply {
putString("type", "Ready")
putString("text", text)
}
}
is PluginState.SetupRequired -> {
val requestCode = (this::class.qualifiedName + "-setup").hashCode()
return Bundle().apply {
putString("type", "SetupRequired")
putParcelable(
"setupActivity",
PendingIntent.getActivity(
context,
requestCode,
setupActivity,
PendingIntent.FLAG_IMMUTABLE,
)
)
putString("message", message)
}
}
}
}
}

View File

@ -9,8 +9,13 @@ import android.content.IntentFilter
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Build
import android.util.Base64
import android.util.Log
import androidx.core.content.ContextCompat
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.plugin.Plugin
import de.mm20.launcher2.plugin.PluginPackage
import de.mm20.launcher2.plugin.PluginRepository
import de.mm20.launcher2.plugin.PluginState
import de.mm20.launcher2.plugin.PluginType
@ -21,11 +26,14 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.security.MessageDigest
data class PluginWithState(
val plugin: Plugin,
@ -33,14 +41,20 @@ data class PluginWithState(
)
interface PluginService {
fun enablePlugin(plugin: Plugin)
fun disablePlugin(plugin: Plugin)
fun enablePluginPackage(plugin: PluginPackage)
fun disablePluginPackage(plugin: PluginPackage)
fun getPluginsWithState(type: PluginType? = 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?
suspend fun getPluginPackageIcon(plugin: PluginPackage): Drawable?
suspend fun getPluginIcon(plugin: Plugin): Drawable?
fun uninstallPluginPackage(context: Context, plugin: PluginPackage)
}
internal class PluginServiceImpl(
@ -56,28 +70,34 @@ internal class PluginServiceImpl(
init {
refreshPlugins()
ContextCompat.registerReceiver(
context,
AppUpdateReceiver(),
IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REMOVED)
addAction(Intent.ACTION_PACKAGE_REPLACED)
addAction(Intent.ACTION_PACKAGE_CHANGED)
},
ContextCompat.RECEIVER_NOT_EXPORTED
context.registerReceiver(AppUpdateReceiver(), IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_REPLACED)
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REMOVED)
addAction(Intent.ACTION_MY_PACKAGE_REPLACED)
addAction(Intent.ACTION_PACKAGE_CHANGED)
addDataScheme("package")
})
}
override fun enablePluginPackage(plugin: PluginPackage) {
repository.updateMany(
plugin.plugins.map {
it.copy(enabled = true)
}
)
}
override fun enablePlugin(plugin: Plugin) {
repository.update(plugin.copy(enabled = true))
}
override fun disablePlugin(plugin: Plugin) {
repository.update(plugin.copy(enabled = false))
override fun disablePluginPackage(plugin: PluginPackage) {
repository.updateMany(
plugin.plugins.map {
it.copy(enabled = false)
}
)
}
private fun refreshPlugins() {
Log.d("PluginService", "Refreshing plugins")
scope.launch {
try {
val permission =
@ -88,11 +108,11 @@ internal class PluginServiceImpl(
return@launch
}
mutex.withLock {
val enabledPlugins =
repository.findMany(enabled = true).first().map { it.authority }
val enabledPluginPackages =
repository.findMany(enabled = true).first().map { it.packageName }.distinct()
val scanner = PluginScanner(context)
val plugins = scanner.findPlugins().map {
if (it.authority in enabledPlugins) {
if (it.packageName in enabledPluginPackages) {
it.copy(enabled = true)
} else {
it
@ -101,6 +121,7 @@ internal class PluginServiceImpl(
repository.deleteMany().join()
repository.insertMany(plugins).join()
}
Log.d("PluginService", "done.")
}
}
@ -131,20 +152,7 @@ internal class PluginServiceImpl(
null
)
} ?: return null
val type = bundle.getString("type") ?: return null
return when (type) {
"Ready" -> PluginState.Ready
"SetupRequired" -> {
val setupActivity = bundle.getString("setupActivity") ?: return null
val message = bundle.getString("message")
PluginState.SetupRequired(
setupActivity = setupActivity,
message = message,
)
}
else -> null
}
return PluginState.fromBundle(bundle)
}
override fun isPluginHostInstalled(): Flow<Boolean> {
@ -163,13 +171,129 @@ internal class PluginServiceImpl(
} catch (e: PackageManager.NameNotFoundException) {
return@withContext null
}
info.loadIcon(context.packageManager) ?: info.applicationInfo?.loadIcon(context.packageManager)
info.loadIcon(context.packageManager)
?: info.applicationInfo?.loadIcon(context.packageManager)
}
}
override suspend fun getPluginPackageIcon(plugin: PluginPackage): Drawable? {
return withContext(Dispatchers.IO) {
try {
context.packageManager.getApplicationIcon(
plugin.packageName
)
} catch (e: PackageManager.NameNotFoundException) {
return@withContext null
}
}
}
override fun getPluginPackages(): Flow<List<PluginPackage>> {
return repository.findMany().map {
val packageGroups = it.groupBy { it.packageName }
packageGroups.mapNotNull { (packageName, plugins) ->
val appInfo = try {
context.packageManager.getApplicationInfo(
packageName,
PackageManager.GET_META_DATA
)
} catch (e: PackageManager.NameNotFoundException) {
return@mapNotNull null
}
val settingsActivity = context.packageManager.queryIntentActivities(
Intent().apply {
`package` = packageName
action = "de.mm20.launcher2.action.PLUGIN_SETTINGS"
},
0
).firstOrNull()
val signature = getSignature(packageName)
PluginPackage(
packageName = packageName,
label = appInfo.loadLabel(context.packageManager).toString(),
description = appInfo.metaData?.getString("de.mm20.launcher2.plugin.description"),
author = appInfo.metaData?.getString("de.mm20.launcher2.plugin.author"),
plugins = plugins,
settings = settingsActivity?.let {
Intent().apply {
component =
ComponentName(it.activityInfo.packageName, it.activityInfo.name)
}
},
isOfficial = OFFICIAL_PLUGIN_SIGNATURES.contains(signature),
)
}
}.flowOn(Dispatchers.Default)
}
override fun getPluginPackage(packageName: String): Flow<PluginPackage?> {
val appInfo = try {
context.packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA)
} catch (e: PackageManager.NameNotFoundException) {
return flowOf(null)
}
val settingsActivityInfo = context.packageManager.queryIntentActivities(
Intent().apply {
`package` = packageName
action = "de.mm20.launcher2.action.PLUGIN_SETTINGS"
},
0
).firstOrNull()
val signature = getSignature(packageName)
return repository.findMany(packageName = packageName)
.map {
PluginPackage(
packageName = packageName,
label = appInfo.loadLabel(context.packageManager).toString(),
description = appInfo.metaData?.getString("de.mm20.launcher2.plugin.description"),
author = appInfo.metaData?.getString("de.mm20.launcher2.plugin.author"),
plugins = it,
settings = settingsActivityInfo?.let {
Intent().apply {
component =
ComponentName(it.activityInfo.packageName, it.activityInfo.name)
}
},
isOfficial = OFFICIAL_PLUGIN_SIGNATURES.contains(signature),
)
}
.flowOn(Dispatchers.Default)
}
private fun getSignature(packageName: String): String? {
val signature = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val pi = context.packageManager.getPackageInfo(
packageName,
PackageManager.GET_SIGNING_CERTIFICATES
)
pi.signingInfo.apkContentsSigners.firstOrNull()
} else {
val pi = context.packageManager.getPackageInfo(
packageName,
PackageManager.GET_SIGNATURES
)
pi.signatures.firstOrNull()
}
return if (signature != null) {
val digest = MessageDigest.getInstance("SHA")
digest.update(signature.toByteArray())
Base64.encodeToString(digest.digest(), Base64.NO_WRAP)
} else null
}
private inner class AppUpdateReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
refreshPlugins()
}
}
override fun uninstallPluginPackage(context: Context, plugin: PluginPackage) {
val intent = Intent(Intent.ACTION_DELETE)
intent.data = Uri.parse("package:${plugin.packageName}")
context.tryStartActivity(intent)
}
companion object {
private val OFFICIAL_PLUGIN_SIGNATURES = listOf("rx1fSnL7r5/OMoFC0e1KPqTndXQ=")
}
}