Group plugins by package
This commit is contained in:
parent
6c311fc947
commit
25cd9b707e
@ -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 = {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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}")
|
||||
}
|
||||
)
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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=")
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user