Add plugin settings screen
This commit is contained in:
parent
7c20d541cd
commit
e7ae751340
@ -1,9 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<permission-group
|
||||
android:name="de.mm20.launcher2.permission-group.PLUGINS"
|
||||
android:label="Kvaesitso plugins" />
|
||||
<permission
|
||||
android:name="de.mm20.launcher2.permission.USE_PLUGINS"
|
||||
android:label="Use Kvaesitso plugins"
|
||||
android:label="@string/app_name"
|
||||
android:description="@string/app_name"
|
||||
android:icon="@drawable/ic_launcher_monochrome"
|
||||
android:permissionGroup="de.mm20.launcher2.permission-group.PLUGINS"
|
||||
android:protectionLevel="dangerous" />
|
||||
|
||||
</manifest>
|
||||
|
||||
@ -143,6 +143,7 @@ dependencies {
|
||||
implementation(project(":libs:g-services"))
|
||||
implementation(project(":libs:owncloud"))
|
||||
implementation(project(":services:accounts"))
|
||||
implementation(project(":services:plugins"))
|
||||
implementation(project(":services:backup"))
|
||||
implementation(project(":data:search-actions"))
|
||||
implementation(project(":services:global-actions"))
|
||||
|
||||
@ -5,11 +5,13 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
@ -18,7 +20,8 @@ import androidx.compose.ui.unit.dp
|
||||
fun LargeMessage(
|
||||
modifier: Modifier = Modifier,
|
||||
icon: ImageVector,
|
||||
text: String
|
||||
text: String,
|
||||
color: Color = LocalContentColor.current
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
@ -30,12 +33,14 @@ fun LargeMessage(
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 24.dp)
|
||||
.size(64.dp)
|
||||
.size(64.dp),
|
||||
tint = color
|
||||
)
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = color
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -2,21 +2,23 @@ package de.mm20.launcher2.ui.settings
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.animation.core.tween
|
||||
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.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.google.accompanist.navigation.animation.AnimatedNavHost
|
||||
import com.google.accompanist.navigation.animation.composable
|
||||
import com.google.accompanist.navigation.animation.rememberAnimatedNavController
|
||||
import de.mm20.launcher2.licenses.AppLicense
|
||||
import de.mm20.launcher2.licenses.OpenSourceLicenses
|
||||
import de.mm20.launcher2.preferences.LauncherDataStore
|
||||
import de.mm20.launcher2.ui.base.BaseActivity
|
||||
import de.mm20.launcher2.ui.base.ProvideSettings
|
||||
import de.mm20.launcher2.ui.locals.LocalNavController
|
||||
@ -45,6 +47,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.PluginsSettingsScreen
|
||||
import de.mm20.launcher2.ui.settings.search.SearchSettingsScreen
|
||||
import de.mm20.launcher2.ui.settings.searchactions.SearchActionsSettingsScreen
|
||||
import de.mm20.launcher2.ui.settings.tags.TagsSettingsScreen
|
||||
@ -53,20 +56,17 @@ import de.mm20.launcher2.ui.settings.weather.WeatherIntegrationSettingsScreen
|
||||
import de.mm20.launcher2.ui.settings.wikipedia.WikipediaSettingsScreen
|
||||
import de.mm20.launcher2.ui.theme.LauncherTheme
|
||||
import de.mm20.launcher2.ui.theme.wallpaperColorsAsState
|
||||
import org.koin.android.ext.android.inject
|
||||
import java.net.URLDecoder
|
||||
import java.util.UUID
|
||||
|
||||
class SettingsActivity : BaseActivity() {
|
||||
|
||||
private val dataStore: LauncherDataStore by inject()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
|
||||
setContent {
|
||||
val navController = rememberAnimatedNavController()
|
||||
val navController = rememberNavController()
|
||||
|
||||
LaunchedEffect(intent) {
|
||||
intent.getStringExtra(EXTRA_ROUTE)
|
||||
@ -80,13 +80,21 @@ class SettingsActivity : BaseActivity() {
|
||||
ProvideSettings {
|
||||
LauncherTheme {
|
||||
OverlayHost {
|
||||
AnimatedNavHost(
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = "settings",
|
||||
exitTransition = { fadeOut(tween(300, 300)) },
|
||||
enterTransition = { fadeIn(tween(200)) },
|
||||
popEnterTransition = { fadeIn(tween(0)) },
|
||||
popExitTransition = { fadeOut(tween(200)) },
|
||||
exitTransition = {
|
||||
fadeOut() + scaleOut(targetScale = 0.5f)
|
||||
},
|
||||
enterTransition = {
|
||||
slideInHorizontally { it }
|
||||
},
|
||||
popEnterTransition = {
|
||||
fadeIn() + scaleIn(initialScale = 0.5f)
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutHorizontally { it }
|
||||
},
|
||||
) {
|
||||
composable("settings") {
|
||||
MainSettingsScreen()
|
||||
@ -156,6 +164,9 @@ class SettingsActivity : BaseActivity() {
|
||||
composable("settings/integrations") {
|
||||
IntegrationsSettingsScreen()
|
||||
}
|
||||
composable("settings/plugins") {
|
||||
PluginsSettingsScreen()
|
||||
}
|
||||
composable("settings/about") {
|
||||
AboutSettingsScreen()
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package de.mm20.launcher2.ui.settings.main
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Apps
|
||||
import androidx.compose.material.icons.rounded.BugReport
|
||||
import androidx.compose.material.icons.rounded.Extension
|
||||
import androidx.compose.material.icons.rounded.Gesture
|
||||
import androidx.compose.material.icons.rounded.Home
|
||||
import androidx.compose.material.icons.rounded.Info
|
||||
@ -74,6 +75,14 @@ fun MainSettingsScreen() {
|
||||
navController?.navigate("settings/integrations")
|
||||
}
|
||||
)
|
||||
Preference(
|
||||
icon = Icons.Rounded.Extension,
|
||||
title = stringResource(id = R.string.preference_screen_plugins),
|
||||
summary = stringResource(id = R.string.preference_screen_plugins_summary),
|
||||
onClick = {
|
||||
navController?.navigate("settings/plugins")
|
||||
}
|
||||
)
|
||||
Preference(
|
||||
icon = Icons.Rounded.SettingsBackupRestore,
|
||||
title = stringResource(id = R.string.preference_screen_backup),
|
||||
|
||||
@ -0,0 +1,119 @@
|
||||
package de.mm20.launcher2.ui.settings.plugins
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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.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.PreferenceScreen
|
||||
|
||||
@Composable
|
||||
fun PluginsSettingsScreen() {
|
||||
val viewModel: PluginsSettingsScreenVM = viewModel()
|
||||
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)
|
||||
PreferenceScreen(title = stringResource(R.string.preference_screen_plugins)) {
|
||||
when {
|
||||
hostInstalled == false -> {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillParentMaxHeight()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
LargeMessage(
|
||||
icon = Icons.Rounded.ExtensionOff,
|
||||
text = stringResource(R.string.plugin_host_not_installed),
|
||||
color = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hasPermission == false -> {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
MissingPermissionBanner(
|
||||
text = stringResource(R.string.missing_permission_plugins),
|
||||
onClick = { viewModel.requestPermission(context) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
plugins?.isEmpty() == true -> {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillParentMaxHeight()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
LargeMessage(
|
||||
icon = Icons.Rounded.Extension,
|
||||
text = stringResource(R.string.no_plugins_installed),
|
||||
color = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
package de.mm20.launcher2.ui.settings.plugins
|
||||
|
||||
import android.content.Context
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.ViewModel
|
||||
import de.mm20.launcher2.permissions.PermissionGroup
|
||||
import de.mm20.launcher2.permissions.PermissionsManager
|
||||
import de.mm20.launcher2.plugin.Plugin
|
||||
import de.mm20.launcher2.plugins.PluginService
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
class PluginsSettingsScreenVM : ViewModel(), KoinComponent {
|
||||
|
||||
private val pluginService: PluginService by inject()
|
||||
private val permissionsManager: PermissionsManager by inject()
|
||||
|
||||
val hostInstalled = pluginService.isPluginHostInstalled()
|
||||
val hasPermission = permissionsManager.hasPermission(PermissionGroup.Plugins)
|
||||
val plugins = hasPermission.flatMapLatest {
|
||||
if (it) pluginService.getPluginsWithState() else emptyFlow()
|
||||
}
|
||||
|
||||
fun setPluginEnabled(plugin: Plugin, value: Boolean) {
|
||||
if (value) {
|
||||
pluginService.enablePlugin(plugin)
|
||||
} else {
|
||||
pluginService.disablePlugin(plugin)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestPermission(context: Context) {
|
||||
permissionsManager.requestPermission(context as AppCompatActivity, PermissionGroup.Plugins)
|
||||
}
|
||||
|
||||
fun getIcon(plugin: Plugin) = flow {
|
||||
emit(pluginService.getPluginIcon(plugin))
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
package de.mm20.launcher2.plugin
|
||||
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface PluginRepository {
|
||||
@ -10,8 +12,8 @@ interface PluginRepository {
|
||||
): Flow<List<Plugin>>
|
||||
fun get(authority: String): Flow<Plugin?>
|
||||
|
||||
fun insertMany(plugins: List<Plugin>)
|
||||
fun insert(plugin: Plugin)
|
||||
fun update(plugin: Plugin)
|
||||
fun deleteMany()
|
||||
fun insertMany(plugins: List<Plugin>): Job
|
||||
fun insert(plugin: Plugin): Job
|
||||
fun update(plugin: Plugin): Job
|
||||
fun deleteMany(): Job
|
||||
}
|
||||
@ -421,6 +421,7 @@
|
||||
<string name="missing_permission_appshortcuts_search">Set %1$s as default home app to search app shortcuts.</string>
|
||||
<!-- Missing permission app shortcuts permission, used when creating a shortcut in the edit favorites sheet -->
|
||||
<string name="missing_permission_appshortcuts_create">Set %1$s as default home app to create shortcuts.</string>
|
||||
<string name="missing_permission_plugins">Plugin permission is required to use plugins.</string>
|
||||
<!-- Grant a permission, shown in permission banners -->
|
||||
<string name="grant_permission">Grant</string>
|
||||
<!-- Appearance preference title -->
|
||||
@ -503,6 +504,10 @@
|
||||
<string name="preference_icon_shape_hexagon">Hexagon</string>
|
||||
<string name="preference_category_searchbar">Search bar</string>
|
||||
<string name="preference_screen_integrations">Integrations</string>
|
||||
<string name="preference_screen_plugins">Plugins</string>
|
||||
<string name="preference_screen_plugins_summary">Manage installed extensions</string>
|
||||
<string name="no_plugins_installed">No plugins installed</string>
|
||||
<string name="plugin_host_not_installed">Plugin host not installed</string>
|
||||
<string name="preference_theme_system">Follow system</string>
|
||||
<string name="preference_themed_icons">Themed icons</string>
|
||||
<string name="preference_themed_icons_summary">Color icons with the application\'s color scheme</string>
|
||||
|
||||
@ -17,6 +17,7 @@ import de.mm20.launcher2.crashreporter.CrashReporter
|
||||
import de.mm20.launcher2.ktx.checkPermission
|
||||
import de.mm20.launcher2.ktx.isAtLeastApiLevel
|
||||
import de.mm20.launcher2.ktx.tryStartActivity
|
||||
import de.mm20.launcher2.plugin.contracts.PluginContract
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
@ -63,6 +64,7 @@ enum class PermissionGroup {
|
||||
Notifications,
|
||||
AppShortcuts,
|
||||
Accessibility,
|
||||
Plugins,
|
||||
}
|
||||
|
||||
internal class PermissionsManagerImpl(
|
||||
@ -83,6 +85,9 @@ internal class PermissionsManagerImpl(
|
||||
private val locationPermissionState = MutableStateFlow(
|
||||
checkPermissionOnce(PermissionGroup.Location)
|
||||
)
|
||||
private val pluginsPermissionState = MutableStateFlow(
|
||||
checkPermissionOnce(PermissionGroup.Plugins)
|
||||
)
|
||||
private val notificationsPermissionState = MutableStateFlow(false)
|
||||
private val accessibilityPermissionState = MutableStateFlow(false)
|
||||
private val appShortcutsPermissionState = MutableStateFlow(
|
||||
@ -153,6 +158,14 @@ internal class PermissionsManagerImpl(
|
||||
CrashReporter.logException(e)
|
||||
}
|
||||
}
|
||||
|
||||
PermissionGroup.Plugins -> {
|
||||
ActivityCompat.requestPermissions(
|
||||
context,
|
||||
pluginPermissions,
|
||||
permissionGroup.ordinal
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -170,6 +183,10 @@ internal class PermissionsManagerImpl(
|
||||
contactPermissions.all { context.checkPermission(it) }
|
||||
}
|
||||
|
||||
PermissionGroup.Plugins -> {
|
||||
pluginPermissions.all { context.checkPermission(it) }
|
||||
}
|
||||
|
||||
PermissionGroup.ExternalStorage -> {
|
||||
if (isAtLeastApiLevel(Build.VERSION_CODES.R)) {
|
||||
Environment.isExternalStorageManager()
|
||||
@ -201,6 +218,7 @@ internal class PermissionsManagerImpl(
|
||||
PermissionGroup.Notifications -> notificationsPermissionState
|
||||
PermissionGroup.AppShortcuts -> appShortcutsPermissionState
|
||||
PermissionGroup.Accessibility -> accessibilityPermissionState
|
||||
PermissionGroup.Plugins -> pluginsPermissionState
|
||||
}
|
||||
}
|
||||
|
||||
@ -209,7 +227,7 @@ internal class PermissionsManagerImpl(
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
val permissionGroup = PermissionGroup.values().getOrNull(requestCode) ?: return
|
||||
val permissionGroup = PermissionGroup.entries.getOrNull(requestCode) ?: return
|
||||
val granted = grantResults.all { it == PackageManager.PERMISSION_GRANTED }
|
||||
when (permissionGroup) {
|
||||
PermissionGroup.Calendar -> calendarPermissionState.value = granted
|
||||
@ -219,6 +237,7 @@ internal class PermissionsManagerImpl(
|
||||
PermissionGroup.Notifications -> notificationsPermissionState.value = granted
|
||||
PermissionGroup.AppShortcuts -> appShortcutsPermissionState.value = granted
|
||||
PermissionGroup.Accessibility -> accessibilityPermissionState.value = granted
|
||||
PermissionGroup.Plugins -> pluginsPermissionState.value = granted
|
||||
}
|
||||
}
|
||||
|
||||
@ -261,5 +280,6 @@ internal class PermissionsManagerImpl(
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE,
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
)
|
||||
private val pluginPermissions = arrayOf(PluginContract.Permission)
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package de.mm20.launcher2.database.daos
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import de.mm20.launcher2.database.entities.PluginEntity
|
||||
@ -26,15 +27,15 @@ interface PluginDao {
|
||||
@Query("SELECT * FROM Plugins WHERE authority = :authority")
|
||||
fun get(authority: String): Flow<PluginEntity>
|
||||
|
||||
@Insert
|
||||
fun insertMany(plugins: List<PluginEntity>)
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertMany(plugins: List<PluginEntity>)
|
||||
|
||||
@Insert
|
||||
fun insert(plugin: PluginEntity)
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(plugin: PluginEntity)
|
||||
|
||||
@Update
|
||||
fun update(plugin: PluginEntity)
|
||||
suspend fun update(plugin: PluginEntity)
|
||||
|
||||
@Query("DELETE FROM Plugins")
|
||||
fun deleteMany()
|
||||
suspend fun deleteMany()
|
||||
}
|
||||
@ -42,6 +42,7 @@ dependencies {
|
||||
implementation(libs.bundles.androidx.lifecycle)
|
||||
|
||||
implementation(libs.koin.android)
|
||||
implementation(libs.coil.core)
|
||||
|
||||
implementation(project(":core:base"))
|
||||
implementation(project(":core:ktx"))
|
||||
|
||||
@ -2,9 +2,16 @@ package de.mm20.launcher2.files.providers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import coil.imageLoader
|
||||
import coil.request.ImageRequest
|
||||
import de.mm20.launcher2.files.PluginFileSerializer
|
||||
import de.mm20.launcher2.icons.ColorLayer
|
||||
import de.mm20.launcher2.icons.LauncherIcon
|
||||
import de.mm20.launcher2.icons.StaticIconLayer
|
||||
import de.mm20.launcher2.icons.StaticLauncherIcon
|
||||
import de.mm20.launcher2.ktx.tryStartActivity
|
||||
import de.mm20.launcher2.plugin.config.StorageStrategy
|
||||
import de.mm20.launcher2.search.File
|
||||
@ -47,6 +54,21 @@ data class PluginFile(
|
||||
return PluginFileSerializer()
|
||||
}
|
||||
|
||||
override suspend fun loadIcon(context: Context, size: Int, themed: Boolean): LauncherIcon? {
|
||||
if (thumbnailUri != null) {
|
||||
val request = ImageRequest.Builder(context)
|
||||
.data(thumbnailUri)
|
||||
.build()
|
||||
val result = context.imageLoader.execute(request)
|
||||
val drawable = result.drawable ?: return null
|
||||
return StaticLauncherIcon(
|
||||
foregroundLayer = StaticIconLayer(icon = drawable, scale = 1.5f),
|
||||
backgroundLayer = ColorLayer(),
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val Domain = "plugin.file"
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.CancellationSignal
|
||||
import android.util.Log
|
||||
import androidx.core.database.getStringOrNull
|
||||
import de.mm20.launcher2.plugin.Plugin
|
||||
import de.mm20.launcher2.plugin.config.StorageStrategy
|
||||
@ -36,7 +37,13 @@ class PluginFileProvider(
|
||||
null,
|
||||
null,
|
||||
cancellationSignal
|
||||
) ?: return@suspendCancellableCoroutine it.resume(emptyList<File>())
|
||||
)
|
||||
|
||||
if (cursor == null) {
|
||||
Log.e("MM20", "Plugin ${plugin.authority} returned null cursor")
|
||||
it.resume(emptyList())
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
|
||||
val results = fromCursor(cursor) ?: emptyList()
|
||||
it.resume(results)
|
||||
|
||||
@ -4,12 +4,18 @@ import de.mm20.launcher2.database.daos.PluginDao
|
||||
import de.mm20.launcher2.plugin.Plugin
|
||||
import de.mm20.launcher2.plugin.PluginRepository
|
||||
import de.mm20.launcher2.plugin.PluginType
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal class PluginRepositoryImpl(
|
||||
private val dao: PluginDao,
|
||||
): PluginRepository {
|
||||
) : PluginRepository {
|
||||
|
||||
private val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
override fun findMany(
|
||||
type: PluginType?,
|
||||
enabled: Boolean?,
|
||||
@ -28,19 +34,27 @@ internal class PluginRepositoryImpl(
|
||||
return dao.get(authority).map { Plugin(it) }
|
||||
}
|
||||
|
||||
override fun insertMany(plugins: List<Plugin>) {
|
||||
TODO("Not yet implemented")
|
||||
override fun insertMany(plugins: List<Plugin>): Job {
|
||||
return scope.launch {
|
||||
dao.insertMany(plugins.map { PluginEntity(it) })
|
||||
}
|
||||
}
|
||||
|
||||
override fun insert(plugin: Plugin) {
|
||||
dao.insert(PluginEntity(plugin))
|
||||
override fun insert(plugin: Plugin): Job {
|
||||
return scope.launch {
|
||||
dao.insert(PluginEntity(plugin))
|
||||
}
|
||||
}
|
||||
|
||||
override fun update(plugin: Plugin) {
|
||||
dao.update(PluginEntity(plugin))
|
||||
override fun update(plugin: Plugin): Job {
|
||||
return scope.launch {
|
||||
dao.update(PluginEntity(plugin))
|
||||
}
|
||||
}
|
||||
|
||||
override fun deleteMany() {
|
||||
dao.deleteMany()
|
||||
override fun deleteMany(): Job {
|
||||
return scope.launch {
|
||||
dao.deleteMany()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
package de.mm20.launcher2.sdk.base
|
||||
|
||||
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
|
||||
@ -43,4 +45,11 @@ abstract class BasePluginProvider : ContentProvider() {
|
||||
return PluginState.Ready
|
||||
}
|
||||
|
||||
internal fun checkPermissionOrThrow(context: Context) {
|
||||
if (context.checkCallingPermission(PluginContract.Permission) == PackageManager.PERMISSION_GRANTED) {
|
||||
return
|
||||
}
|
||||
throw SecurityException("Caller does not have permission to use plugins")
|
||||
}
|
||||
|
||||
}
|
||||
@ -45,7 +45,7 @@ abstract class SearchPluginProvider<T> : BasePluginProvider() {
|
||||
val context = context ?: return null
|
||||
checkPermissionOrThrow(context)
|
||||
when {
|
||||
uri.path == SearchPluginContract.Paths.Search -> {
|
||||
uri.pathSegments.size == 1 && uri.pathSegments.first() == SearchPluginContract.Paths.Search -> {
|
||||
val query =
|
||||
uri.getQueryParameter(SearchPluginContract.Paths.QueryParam) ?: return null
|
||||
val results = search(query, cancellationSignal)
|
||||
@ -53,7 +53,7 @@ abstract class SearchPluginProvider<T> : BasePluginProvider() {
|
||||
for (result in results) {
|
||||
writeToCursor(cursor, result)
|
||||
}
|
||||
return null
|
||||
return cursor
|
||||
}
|
||||
uri.pathSegments.size == 2 && uri.pathSegments.first() == SearchPluginContract.Paths.Root -> {
|
||||
val id = uri.pathSegments[1]
|
||||
@ -110,12 +110,4 @@ abstract class SearchPluginProvider<T> : BasePluginProvider() {
|
||||
|
||||
internal abstract fun createCursor(capacity: Int): MatrixCursor
|
||||
internal abstract fun writeToCursor(cursor: MatrixCursor, item: T)
|
||||
|
||||
|
||||
private fun checkPermissionOrThrow(context: Context) {
|
||||
if (context.checkCallingPermission(PluginContract.Permission) == PackageManager.PERMISSION_GRANTED) {
|
||||
return
|
||||
}
|
||||
throw SecurityException("Caller does not have permission to use plugins")
|
||||
}
|
||||
}
|
||||
@ -1,16 +1,15 @@
|
||||
package de.mm20.launcher2.sdk.files
|
||||
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.net.Uri
|
||||
import de.mm20.launcher2.plugin.PluginType
|
||||
import de.mm20.launcher2.plugin.contracts.FilePluginContract
|
||||
import de.mm20.launcher2.sdk.base.SearchPluginProvider
|
||||
|
||||
abstract class FileProvider : SearchPluginProvider<File>() {
|
||||
abstract override suspend fun search(query: String): List<File>
|
||||
|
||||
final override fun getPluginType(): de.mm20.launcher2.plugin.PluginType {
|
||||
return de.mm20.launcher2.plugin.PluginType.FileSearch
|
||||
final override fun getPluginType(): PluginType {
|
||||
return PluginType.FileSearch
|
||||
}
|
||||
|
||||
override fun createCursor(capacity: Int): MatrixCursor {
|
||||
|
||||
@ -2,6 +2,8 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import de.mm20.launcher2.crashreporter.CrashReporter
|
||||
import de.mm20.launcher2.plugin.Plugin
|
||||
import de.mm20.launcher2.plugin.PluginType
|
||||
|
||||
@ -16,37 +18,44 @@ class PluginScanner(
|
||||
val plugins = mutableListOf<Plugin>()
|
||||
|
||||
for (cr in contentResolvers) {
|
||||
val providerInfo = cr.providerInfo ?: continue
|
||||
val authority = providerInfo.authority ?: continue
|
||||
val bundle = context.contentResolver.call(
|
||||
Uri.Builder()
|
||||
.scheme("content")
|
||||
.authority(authority)
|
||||
.build(),
|
||||
"getType",
|
||||
null,
|
||||
null,
|
||||
) ?: continue
|
||||
val type = bundle.getString("type")
|
||||
?.let {
|
||||
try {
|
||||
PluginType.valueOf(it)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
} ?: continue
|
||||
plugins.add(
|
||||
Plugin(
|
||||
label = cr.loadLabel(context.packageManager).toString(),
|
||||
description = providerInfo.metaData?.getString("de.mm20.launcher2.plugin.description"),
|
||||
packageName = providerInfo.packageName,
|
||||
className = providerInfo.name,
|
||||
type = type,
|
||||
authority = authority,
|
||||
settingsActivity = providerInfo.metaData?.getString("de.mm20.launcher2.plugin.settings"),
|
||||
enabled = false,
|
||||
try {
|
||||
val providerInfo = cr.providerInfo ?: continue
|
||||
val authority = providerInfo.authority ?: continue
|
||||
val bundle = context.contentResolver.call(
|
||||
Uri.Builder()
|
||||
.scheme("content")
|
||||
.authority(authority)
|
||||
.build(),
|
||||
"getType",
|
||||
null,
|
||||
null,
|
||||
) ?: continue
|
||||
val type = bundle.getString("type")
|
||||
?.let {
|
||||
try {
|
||||
PluginType.valueOf(it)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
} ?: continue
|
||||
plugins.add(
|
||||
Plugin(
|
||||
label = cr.loadLabel(context.packageManager).toString(),
|
||||
description = providerInfo.metaData?.getString("de.mm20.launcher2.plugin.description"),
|
||||
packageName = providerInfo.packageName,
|
||||
className = providerInfo.name,
|
||||
type = type,
|
||||
authority = authority,
|
||||
settingsActivity = providerInfo.metaData?.getString("de.mm20.launcher2.plugin.settings"),
|
||||
enabled = false,
|
||||
)
|
||||
)
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
continue
|
||||
} catch (e: Exception) {
|
||||
CrashReporter.logException(e)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return plugins
|
||||
|
||||
@ -2,9 +2,12 @@ package de.mm20.launcher2.plugins
|
||||
|
||||
import PluginScanner
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import androidx.core.content.ContextCompat
|
||||
import de.mm20.launcher2.plugin.Plugin
|
||||
@ -16,6 +19,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
@ -32,7 +36,11 @@ interface PluginService {
|
||||
fun enablePlugin(plugin: Plugin)
|
||||
fun disablePlugin(plugin: Plugin)
|
||||
fun getPluginsWithState(type: PluginType? = null): Flow<List<PluginWithState>>
|
||||
|
||||
fun isPluginHostInstalled(): Flow<Boolean>
|
||||
suspend fun getPluginState(plugin: Plugin): PluginState?
|
||||
|
||||
suspend fun getPluginIcon(plugin: Plugin): Drawable?
|
||||
}
|
||||
|
||||
internal class PluginServiceImpl(
|
||||
@ -42,6 +50,10 @@ internal class PluginServiceImpl(
|
||||
|
||||
private val scope: CoroutineScope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
|
||||
private val pluginHostInstalled = MutableStateFlow(false)
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
init {
|
||||
refreshPlugins()
|
||||
ContextCompat.registerReceiver(
|
||||
@ -65,9 +77,16 @@ internal class PluginServiceImpl(
|
||||
repository.update(plugin.copy(enabled = false))
|
||||
}
|
||||
|
||||
private val mutex = Mutex()
|
||||
private fun refreshPlugins() {
|
||||
scope.launch {
|
||||
try {
|
||||
val permission =
|
||||
context.packageManager.getPermissionInfo(PluginContract.Permission, 0)
|
||||
pluginHostInstalled.value = permission != null
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
pluginHostInstalled.value = false
|
||||
return@launch
|
||||
}
|
||||
mutex.withLock {
|
||||
val enabledPlugins =
|
||||
repository.findMany(enabled = true).first().map { it.authority }
|
||||
@ -79,8 +98,8 @@ internal class PluginServiceImpl(
|
||||
it
|
||||
}
|
||||
}
|
||||
repository.deleteMany()
|
||||
repository.insertMany(plugins)
|
||||
repository.deleteMany().join()
|
||||
repository.insertMany(plugins).join()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -128,6 +147,26 @@ internal class PluginServiceImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override fun isPluginHostInstalled(): Flow<Boolean> {
|
||||
return pluginHostInstalled
|
||||
}
|
||||
|
||||
override suspend fun getPluginIcon(plugin: Plugin): Drawable? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val info = try {
|
||||
context.packageManager.getProviderInfo(
|
||||
ComponentName(
|
||||
plugin.packageName,
|
||||
plugin.className
|
||||
), 0
|
||||
)
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
return@withContext null
|
||||
}
|
||||
info.loadIcon(context.packageManager) ?: info.applicationInfo?.loadIcon(context.packageManager)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class AppUpdateReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
refreshPlugins()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user