Add plugin settings screen

This commit is contained in:
MM20 2023-11-06 19:30:41 +01:00
parent 7c20d541cd
commit e7ae751340
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
20 changed files with 398 additions and 85 deletions

View File

@ -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>

View File

@ -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"))

View File

@ -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
)
}
}

View File

@ -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()
}

View File

@ -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),

View File

@ -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)
}
)
}
}
}
}
}

View File

@ -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))
}
}

View File

@ -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
}

View File

@ -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>

View File

@ -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)
}
}

View File

@ -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()
}

View File

@ -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"))

View File

@ -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"
}

View 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)

View File

@ -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()
}
}
}

View File

@ -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")
}
}

View File

@ -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")
}
}

View File

@ -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 {

View File

@ -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

View File

@ -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()