From 25cd9b707e362fcde4d726fbfd0577b0c9af2d6e Mon Sep 17 00:00:00 2001 From: MM20 <15646950+MM2-0@users.noreply.github.com> Date: Sat, 25 Nov 2023 14:47:22 +0100 Subject: [PATCH] Group plugins by package --- .../component/preferences/PreferenceScreen.kt | 6 +- .../launcher2/ui/settings/SettingsActivity.kt | 18 ++ .../settings/plugins/PluginSettingsScreen.kt | 256 ++++++++++++++++++ .../plugins/PluginSettingsScreenVM.kt | 81 ++++++ .../settings/plugins/PluginsSettingsScreen.kt | 86 ++++-- .../plugins/PluginsSettingsScreenVM.kt | 29 +- .../de/mm20/launcher2/plugin/PluginPackage.kt | 16 ++ .../mm20/launcher2/plugin/PluginRepository.kt | 1 + .../de/mm20/launcher2/plugin/PluginState.kt | 35 +++ .../de/mm20/launcher2/plugin/PluginState.kt | 10 - .../mm20/launcher2/database/daos/PluginDao.kt | 3 + .../mm20/launcher2/files/FileSerialization.kt | 1 + .../data/plugins/PluginRepositoryImpl.kt | 8 + gradle/libs.versions.toml | 2 +- .../java/de/mm20/launcher2/sdk/PluginState.kt | 34 +++ .../launcher2/sdk/base/BasePluginProvider.kt | 47 +++- .../mm20/launcher2/plugins/PluginService.kt | 196 +++++++++++--- 17 files changed, 728 insertions(+), 101 deletions(-) create mode 100644 app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginSettingsScreen.kt create mode 100644 app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginSettingsScreenVM.kt create mode 100644 core/base/src/main/java/de/mm20/launcher2/plugin/PluginPackage.kt create mode 100644 core/base/src/main/java/de/mm20/launcher2/plugin/PluginState.kt delete mode 100644 core/shared/src/main/java/de/mm20/launcher2/plugin/PluginState.kt create mode 100644 plugins/sdk/src/main/java/de/mm20/launcher2/sdk/PluginState.kt diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/PreferenceScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/PreferenceScreen.kt index 095cbd18..07978dfd 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/PreferenceScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/PreferenceScreen.kt @@ -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 = { diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt index fed13cdd..08e4e683 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt @@ -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() } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginSettingsScreen.kt new file mode 100644 index 00000000..3a8be167 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginSettingsScreen.kt @@ -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 { + + } + } + } + } +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginSettingsScreenVM.kt new file mode 100644 index 00000000..2b097275 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginSettingsScreenVM.kt @@ -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() + + private var pluginPackageName = MutableStateFlow(null) + + val pluginPackage: StateFlow = pluginPackageName.flatMapLatest { + if (it == null) { + emptyFlow() + } else { + pluginService.getPluginPackage(it) + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(100), null) + + val icon: Flow = pluginPackage + .distinctUntilChangedBy { it?.packageName } + .map { + it?.let { pluginService.getPluginPackageIcon(it) } + } + + val types: Flow> = pluginPackage.map { + it?.plugins?.map { it.type }?.distinct() ?: emptyList() + } + + val states: Flow> = 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) + } +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginsSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginsSettingsScreen.kt index b44f4508..609e19dc 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginsSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginsSettingsScreen.kt @@ -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}") + } + ) } \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginsSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginsSettingsScreenVM.kt index 7625fb24..f184c242 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginsSettingsScreenVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginsSettingsScreenVM.kt @@ -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)) } } \ No newline at end of file diff --git a/core/base/src/main/java/de/mm20/launcher2/plugin/PluginPackage.kt b/core/base/src/main/java/de/mm20/launcher2/plugin/PluginPackage.kt new file mode 100644 index 00000000..0d6a66f3 --- /dev/null +++ b/core/base/src/main/java/de/mm20/launcher2/plugin/PluginPackage.kt @@ -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, + val isOfficial: Boolean = false, +) { + val enabled: Boolean = plugins.all { it.enabled } +} \ No newline at end of file diff --git a/core/base/src/main/java/de/mm20/launcher2/plugin/PluginRepository.kt b/core/base/src/main/java/de/mm20/launcher2/plugin/PluginRepository.kt index 8706faab..8418d6fb 100644 --- a/core/base/src/main/java/de/mm20/launcher2/plugin/PluginRepository.kt +++ b/core/base/src/main/java/de/mm20/launcher2/plugin/PluginRepository.kt @@ -15,5 +15,6 @@ interface PluginRepository { fun insertMany(plugins: List): Job fun insert(plugin: Plugin): Job fun update(plugin: Plugin): Job + fun updateMany(plugins: List): Job fun deleteMany(): Job } \ No newline at end of file diff --git a/core/base/src/main/java/de/mm20/launcher2/plugin/PluginState.kt b/core/base/src/main/java/de/mm20/launcher2/plugin/PluginState.kt new file mode 100644 index 00000000..b1a40010 --- /dev/null +++ b/core/base/src/main/java/de/mm20/launcher2/plugin/PluginState.kt @@ -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 + } + } + } +} \ No newline at end of file diff --git a/core/shared/src/main/java/de/mm20/launcher2/plugin/PluginState.kt b/core/shared/src/main/java/de/mm20/launcher2/plugin/PluginState.kt deleted file mode 100644 index bc0433b4..00000000 --- a/core/shared/src/main/java/de/mm20/launcher2/plugin/PluginState.kt +++ /dev/null @@ -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() -} \ No newline at end of file diff --git a/data/database/src/main/java/de/mm20/launcher2/database/daos/PluginDao.kt b/data/database/src/main/java/de/mm20/launcher2/database/daos/PluginDao.kt index 6c6eba33..6e1923eb 100644 --- a/data/database/src/main/java/de/mm20/launcher2/database/daos/PluginDao.kt +++ b/data/database/src/main/java/de/mm20/launcher2/database/daos/PluginDao.kt @@ -36,6 +36,9 @@ interface PluginDao { @Update suspend fun update(plugin: PluginEntity) + @Update + suspend fun updateMany(plugins: List) + @Query("DELETE FROM Plugins") suspend fun deleteMany() } \ No newline at end of file diff --git a/data/files/src/main/java/de/mm20/launcher2/files/FileSerialization.kt b/data/files/src/main/java/de/mm20/launcher2/files/FileSerialization.kt index f394b731..8decede5 100644 --- a/data/files/src/main/java/de/mm20/launcher2/files/FileSerialization.kt +++ b/data/files/src/main/java/de/mm20/launcher2/files/FileSerialization.kt @@ -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) { diff --git a/data/plugins/src/main/java/de/mm20/launcher2/data/plugins/PluginRepositoryImpl.kt b/data/plugins/src/main/java/de/mm20/launcher2/data/plugins/PluginRepositoryImpl.kt index 6b9d2a4d..15cc8865 100644 --- a/data/plugins/src/main/java/de/mm20/launcher2/data/plugins/PluginRepositoryImpl.kt +++ b/data/plugins/src/main/java/de/mm20/launcher2/data/plugins/PluginRepositoryImpl.kt @@ -52,6 +52,14 @@ internal class PluginRepositoryImpl( } } + override fun updateMany(plugins: List): Job { + return scope.launch { + dao.updateMany( + plugins.map { PluginEntity(it) } + ) + } + } + override fun deleteMany(): Job { return scope.launch { dao.deleteMany() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c97d4714..dcada2a1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" diff --git a/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/PluginState.kt b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/PluginState.kt new file mode 100644 index 00000000..57a392f5 --- /dev/null +++ b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/PluginState.kt @@ -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() +} \ No newline at end of file diff --git a/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/base/BasePluginProvider.kt b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/base/BasePluginProvider.kt index abfa7593..b8398e9d 100644 --- a/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/base/BasePluginProvider.kt +++ b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/base/BasePluginProvider.kt @@ -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) + } + } + } + } } \ No newline at end of file diff --git a/services/plugins/src/main/java/de/mm20/launcher2/plugins/PluginService.kt b/services/plugins/src/main/java/de/mm20/launcher2/plugins/PluginService.kt index 5634ba2a..385ab5a4 100644 --- a/services/plugins/src/main/java/de/mm20/launcher2/plugins/PluginService.kt +++ b/services/plugins/src/main/java/de/mm20/launcher2/plugins/PluginService.kt @@ -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> fun isPluginHostInstalled(): Flow + + fun getPluginPackages(): Flow> + fun getPluginPackage(packageName: String): Flow 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 { @@ -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> { + 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 { + 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=") + } } \ No newline at end of file