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.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 = {
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.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}")
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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 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
|
||||||
}
|
}
|
||||||
@ -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
|
@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()
|
||||||
}
|
}
|
||||||
@ -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) {
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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"
|
||||||
|
|
||||||
|
|||||||
@ -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
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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=")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user