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

View File

@ -1,17 +1,21 @@
package de.mm20.launcher2.ui.settings package de.mm20.launcher2.ui.settings
import android.os.Bundle import android.os.Bundle
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.toArgb
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable 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.licenses.OpenSourceLicenses
import de.mm20.launcher2.ui.base.BaseActivity import de.mm20.launcher2.ui.base.BaseActivity
import de.mm20.launcher2.ui.base.ProvideSettings 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.LocalNavController
import de.mm20.launcher2.ui.locals.LocalWallpaperColors import de.mm20.launcher2.ui.locals.LocalWallpaperColors
import de.mm20.launcher2.ui.overlays.OverlayHost 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.log.LogScreen
import de.mm20.launcher2.ui.settings.main.MainSettingsScreen import de.mm20.launcher2.ui.settings.main.MainSettingsScreen
import de.mm20.launcher2.ui.settings.media.MediaIntegrationSettingsScreen 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.plugins.PluginsSettingsScreen
import de.mm20.launcher2.ui.settings.search.SearchSettingsScreen import de.mm20.launcher2.ui.settings.search.SearchSettingsScreen
import de.mm20.launcher2.ui.settings.searchactions.SearchActionsSettingsScreen import de.mm20.launcher2.ui.settings.searchactions.SearchActionsSettingsScreen
@ -79,6 +85,15 @@ class SettingsActivity : BaseActivity() {
) { ) {
ProvideSettings { ProvideSettings {
LauncherTheme { 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 { OverlayHost {
NavHost( NavHost(
navController = navController, navController = navController,
@ -167,6 +182,9 @@ class SettingsActivity : BaseActivity() {
composable("settings/plugins") { composable("settings/plugins") {
PluginsSettingsScreen() PluginsSettingsScreen()
} }
composable("settings/plugins/{id}") {
PluginSettingsScreen(it.arguments?.getString("id") ?: return@composable)
}
composable("settings/about") { composable("settings/about") {
AboutSettingsScreen() 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Extension import androidx.compose.material.icons.rounded.Extension
import androidx.compose.material.icons.rounded.ExtensionOff 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.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -23,19 +24,25 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
import de.mm20.launcher2.plugin.PluginPackage
import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.LargeMessage import de.mm20.launcher2.ui.component.LargeMessage
import de.mm20.launcher2.ui.component.MissingPermissionBanner import de.mm20.launcher2.ui.component.MissingPermissionBanner
import de.mm20.launcher2.ui.component.preferences.Preference 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.component.preferences.PreferenceScreen
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 hostInstalled by viewModel.hostInstalled.collectAsState(null)
val hasPermission by viewModel.hasPermission.collectAsState(null) val hasPermission by viewModel.hasPermission.collectAsState(null)
val context = LocalContext.current 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)) { PreferenceScreen(title = stringResource(R.string.preference_screen_plugins)) {
when { when {
hostInstalled == false -> { hostInstalled == false -> {
@ -73,7 +80,7 @@ fun PluginsSettingsScreen() {
} }
} }
plugins?.isEmpty() == true -> { pluginPackages?.isEmpty() == true -> {
item { item {
Column( Column(
modifier = Modifier modifier = Modifier
@ -92,28 +99,61 @@ fun PluginsSettingsScreen() {
} }
} }
plugins != null -> { else -> {
items(plugins!!) { item -> if (enabledPackages.isNotEmpty()) {
val icon by remember(item.plugin.authority) { item {
viewModel.getIcon(item.plugin) PreferenceCategory("Enabled") {
}.collectAsState(null) for (plugin in enabledPackages) {
Preference( PluginPreference(viewModel, plugin)
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)
} }
) }
}
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 android.content.Context
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModel 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.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager 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 de.mm20.launcher2.plugins.PluginService
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow 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.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
@ -20,23 +23,23 @@ class PluginsSettingsScreenVM : ViewModel(), KoinComponent {
val hostInstalled = pluginService.isPluginHostInstalled() val hostInstalled = pluginService.isPluginHostInstalled()
val hasPermission = permissionsManager.hasPermission(PermissionGroup.Plugins) val hasPermission = permissionsManager.hasPermission(PermissionGroup.Plugins)
val plugins = hasPermission.flatMapLatest { val pluginPackages = pluginService
if (it) pluginService.getPluginsWithState() else emptyFlow() .getPluginPackages()
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(100), 1)
val enabledPluginPackages = pluginPackages.mapLatest {
it.filter { it.enabled }.sortedBy { it.label }
} }
fun setPluginEnabled(plugin: Plugin, value: Boolean) { val disabledPluginPackages = pluginPackages.mapLatest {
if (value) { it.filter { !it.enabled }.sortedBy { it.label }
pluginService.enablePlugin(plugin)
} else {
pluginService.disablePlugin(plugin)
}
} }
fun requestPermission(context: Context) { fun requestPermission(context: Context) {
permissionsManager.requestPermission(context as AppCompatActivity, PermissionGroup.Plugins) permissionsManager.requestPermission(context as AppCompatActivity, PermissionGroup.Plugins)
} }
fun getIcon(plugin: Plugin) = flow { fun getIcon(plugin: PluginPackage) = flow {
emit(pluginService.getPluginIcon(plugin)) 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 insertMany(plugins: List<Plugin>): Job
fun insert(plugin: Plugin): Job fun insert(plugin: Plugin): Job
fun update(plugin: Plugin): Job fun update(plugin: Plugin): Job
fun updateMany(plugins: List<Plugin>): Job
fun deleteMany(): 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 @Update
suspend fun update(plugin: PluginEntity) suspend fun update(plugin: PluginEntity)
@Update
suspend fun updateMany(plugins: List<PluginEntity>)
@Query("DELETE FROM Plugins") @Query("DELETE FROM Plugins")
suspend fun deleteMany() suspend fun deleteMany()
} }

View File

@ -360,6 +360,7 @@ internal class PluginFileDeserializer(
val authority = obj.getString("authority") val authority = obj.getString("authority")
val id = obj.getString("id") val id = obj.getString("id")
val plugin = pluginRepository.get(authority).firstOrNull() ?: return null val plugin = pluginRepository.get(authority).firstOrNull() ?: return null
if (!plugin.enabled) return null
val provider = PluginFileProvider(context, plugin) val provider = PluginFileProvider(context, plugin)
return provider.getFile(id) return provider.getFile(id)
} catch (e: Exception) { } 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 { override fun deleteMany(): Job {
return scope.launch { return scope.launch {
dao.deleteMany() dao.deleteMany()

View File

@ -4,7 +4,7 @@ compileSdk = "34"
targetSdk = "34" targetSdk = "34"
gradle = "8.1.2" gradle = "8.1.2"
android-gradle-plugin = "8.1.3" android-gradle-plugin = "8.1.4"
protobuf-gradle-plugin = "0.9.4" protobuf-gradle-plugin = "0.9.4"
ksp-gradle-plugin = "1.9.0-1.0.13" 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 package de.mm20.launcher2.sdk.base
import android.app.PendingIntent
import android.content.ContentProvider import android.content.ContentProvider
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Bundle import android.os.Bundle
import de.mm20.launcher2.plugin.PluginState
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 kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
abstract class BasePluginProvider : ContentProvider() { abstract class BasePluginProvider : ContentProvider() {
@ -17,23 +18,14 @@ abstract class BasePluginProvider : ContentProvider() {
putString("type", getPluginType().name) putString("type", getPluginType().name)
} }
PluginContract.Methods.GetState -> Bundle().apply { PluginContract.Methods.GetState -> {
val state = runBlocking { val state = runBlocking {
getPluginState() getPluginState()
} }
when (state) { return state.toBundle()
is PluginState.SetupRequired -> {
putString("type", "SetupRequired")
putString("setupActivity", state.setupActivity)
putString("message", state.message)
}
is PluginState.Ready -> {
putString("type", "Ready")
}
}
} }
PluginContract.Methods.GetConfig -> { PluginContract.Methods.GetConfig -> {
getPluginConfig() getPluginConfig()
} }
@ -49,7 +41,7 @@ abstract class BasePluginProvider : ContentProvider() {
} }
open suspend fun getPluginState(): PluginState { open suspend fun getPluginState(): PluginState {
return PluginState.Ready return PluginState.Ready()
} }
internal fun checkPermissionOrThrow(context: Context) { internal fun checkPermissionOrThrow(context: Context) {
@ -59,4 +51,31 @@ abstract class BasePluginProvider : ContentProvider() {
throw SecurityException("Caller does not have permission to use plugins") 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.content.pm.PackageManager
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.net.Uri import android.net.Uri
import android.os.Build
import android.util.Base64
import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.plugin.Plugin import de.mm20.launcher2.plugin.Plugin
import de.mm20.launcher2.plugin.PluginPackage
import de.mm20.launcher2.plugin.PluginRepository import de.mm20.launcher2.plugin.PluginRepository
import de.mm20.launcher2.plugin.PluginState import de.mm20.launcher2.plugin.PluginState
import de.mm20.launcher2.plugin.PluginType import de.mm20.launcher2.plugin.PluginType
@ -21,11 +26,14 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.security.MessageDigest
data class PluginWithState( data class PluginWithState(
val plugin: Plugin, val plugin: Plugin,
@ -33,14 +41,20 @@ data class PluginWithState(
) )
interface PluginService { interface PluginService {
fun enablePlugin(plugin: Plugin) fun enablePluginPackage(plugin: PluginPackage)
fun disablePlugin(plugin: Plugin) fun disablePluginPackage(plugin: PluginPackage)
fun getPluginsWithState(type: PluginType? = null): Flow<List<PluginWithState>> fun getPluginsWithState(type: PluginType? = null): Flow<List<PluginWithState>>
fun isPluginHostInstalled(): Flow<Boolean> fun isPluginHostInstalled(): Flow<Boolean>
fun getPluginPackages(): Flow<List<PluginPackage>>
fun getPluginPackage(packageName: String): Flow<PluginPackage?>
suspend fun getPluginState(plugin: Plugin): PluginState? suspend fun getPluginState(plugin: Plugin): PluginState?
suspend fun getPluginPackageIcon(plugin: PluginPackage): Drawable?
suspend fun getPluginIcon(plugin: Plugin): Drawable? suspend fun getPluginIcon(plugin: Plugin): Drawable?
fun uninstallPluginPackage(context: Context, plugin: PluginPackage)
} }
internal class PluginServiceImpl( internal class PluginServiceImpl(
@ -56,28 +70,34 @@ internal class PluginServiceImpl(
init { init {
refreshPlugins() refreshPlugins()
ContextCompat.registerReceiver( context.registerReceiver(AppUpdateReceiver(), IntentFilter().apply {
context, addAction(Intent.ACTION_PACKAGE_REPLACED)
AppUpdateReceiver(), addAction(Intent.ACTION_PACKAGE_ADDED)
IntentFilter().apply { addAction(Intent.ACTION_PACKAGE_REMOVED)
addAction(Intent.ACTION_PACKAGE_ADDED) addAction(Intent.ACTION_MY_PACKAGE_REPLACED)
addAction(Intent.ACTION_PACKAGE_REMOVED) addAction(Intent.ACTION_PACKAGE_CHANGED)
addAction(Intent.ACTION_PACKAGE_REPLACED) addDataScheme("package")
addAction(Intent.ACTION_PACKAGE_CHANGED) })
}, }
ContextCompat.RECEIVER_NOT_EXPORTED
override fun enablePluginPackage(plugin: PluginPackage) {
repository.updateMany(
plugin.plugins.map {
it.copy(enabled = true)
}
) )
} }
override fun enablePlugin(plugin: Plugin) { override fun disablePluginPackage(plugin: PluginPackage) {
repository.update(plugin.copy(enabled = true)) repository.updateMany(
} plugin.plugins.map {
it.copy(enabled = false)
override fun disablePlugin(plugin: Plugin) { }
repository.update(plugin.copy(enabled = false)) )
} }
private fun refreshPlugins() { private fun refreshPlugins() {
Log.d("PluginService", "Refreshing plugins")
scope.launch { scope.launch {
try { try {
val permission = val permission =
@ -88,11 +108,11 @@ internal class PluginServiceImpl(
return@launch return@launch
} }
mutex.withLock { mutex.withLock {
val enabledPlugins = val enabledPluginPackages =
repository.findMany(enabled = true).first().map { it.authority } repository.findMany(enabled = true).first().map { it.packageName }.distinct()
val scanner = PluginScanner(context) val scanner = PluginScanner(context)
val plugins = scanner.findPlugins().map { val plugins = scanner.findPlugins().map {
if (it.authority in enabledPlugins) { if (it.packageName in enabledPluginPackages) {
it.copy(enabled = true) it.copy(enabled = true)
} else { } else {
it it
@ -101,6 +121,7 @@ internal class PluginServiceImpl(
repository.deleteMany().join() repository.deleteMany().join()
repository.insertMany(plugins).join() repository.insertMany(plugins).join()
} }
Log.d("PluginService", "done.")
} }
} }
@ -131,20 +152,7 @@ internal class PluginServiceImpl(
null null
) )
} ?: return null } ?: return null
val type = bundle.getString("type") ?: return null return PluginState.fromBundle(bundle)
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
}
} }
override fun isPluginHostInstalled(): Flow<Boolean> { override fun isPluginHostInstalled(): Flow<Boolean> {
@ -163,13 +171,129 @@ internal class PluginServiceImpl(
} catch (e: PackageManager.NameNotFoundException) { } catch (e: PackageManager.NameNotFoundException) {
return@withContext null 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() { private inner class AppUpdateReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
refreshPlugins() 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=")
}
} }