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"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <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 <permission
android:name="de.mm20.launcher2.permission.USE_PLUGINS" 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" /> android:protectionLevel="dangerous" />
</manifest> </manifest>

View File

@ -143,6 +143,7 @@ dependencies {
implementation(project(":libs:g-services")) implementation(project(":libs:g-services"))
implementation(project(":libs:owncloud")) implementation(project(":libs:owncloud"))
implementation(project(":services:accounts")) implementation(project(":services:accounts"))
implementation(project(":services:plugins"))
implementation(project(":services:backup")) implementation(project(":services:backup"))
implementation(project(":data:search-actions")) implementation(project(":data:search-actions"))
implementation(project(":services:global-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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -18,7 +20,8 @@ import androidx.compose.ui.unit.dp
fun LargeMessage( fun LargeMessage(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
icon: ImageVector, icon: ImageVector,
text: String text: String,
color: Color = LocalContentColor.current
) { ) {
Column( Column(
modifier = modifier, modifier = modifier,
@ -30,12 +33,14 @@ fun LargeMessage(
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.padding(bottom = 24.dp) .padding(bottom = 24.dp)
.size(64.dp) .size(64.dp),
tint = color
) )
Text( Text(
text, text,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
color = color
) )
} }
} }

View File

@ -2,21 +2,23 @@ package de.mm20.launcher2.ui.settings
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.animation.core.tween
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.scaleOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
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.core.view.WindowCompat 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 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.AppLicense
import de.mm20.launcher2.licenses.OpenSourceLicenses 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.BaseActivity
import de.mm20.launcher2.ui.base.ProvideSettings import de.mm20.launcher2.ui.base.ProvideSettings
import de.mm20.launcher2.ui.locals.LocalNavController 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.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.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
import de.mm20.launcher2.ui.settings.tags.TagsSettingsScreen 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.settings.wikipedia.WikipediaSettingsScreen
import de.mm20.launcher2.ui.theme.LauncherTheme import de.mm20.launcher2.ui.theme.LauncherTheme
import de.mm20.launcher2.ui.theme.wallpaperColorsAsState import de.mm20.launcher2.ui.theme.wallpaperColorsAsState
import org.koin.android.ext.android.inject
import java.net.URLDecoder import java.net.URLDecoder
import java.util.UUID import java.util.UUID
class SettingsActivity : BaseActivity() { class SettingsActivity : BaseActivity() {
private val dataStore: LauncherDataStore by inject()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
setContent { setContent {
val navController = rememberAnimatedNavController() val navController = rememberNavController()
LaunchedEffect(intent) { LaunchedEffect(intent) {
intent.getStringExtra(EXTRA_ROUTE) intent.getStringExtra(EXTRA_ROUTE)
@ -80,13 +80,21 @@ class SettingsActivity : BaseActivity() {
ProvideSettings { ProvideSettings {
LauncherTheme { LauncherTheme {
OverlayHost { OverlayHost {
AnimatedNavHost( NavHost(
navController = navController, navController = navController,
startDestination = "settings", startDestination = "settings",
exitTransition = { fadeOut(tween(300, 300)) }, exitTransition = {
enterTransition = { fadeIn(tween(200)) }, fadeOut() + scaleOut(targetScale = 0.5f)
popEnterTransition = { fadeIn(tween(0)) }, },
popExitTransition = { fadeOut(tween(200)) }, enterTransition = {
slideInHorizontally { it }
},
popEnterTransition = {
fadeIn() + scaleIn(initialScale = 0.5f)
},
popExitTransition = {
slideOutHorizontally { it }
},
) { ) {
composable("settings") { composable("settings") {
MainSettingsScreen() MainSettingsScreen()
@ -156,6 +164,9 @@ class SettingsActivity : BaseActivity() {
composable("settings/integrations") { composable("settings/integrations") {
IntegrationsSettingsScreen() IntegrationsSettingsScreen()
} }
composable("settings/plugins") {
PluginsSettingsScreen()
}
composable("settings/about") { composable("settings/about") {
AboutSettingsScreen() 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.Icons
import androidx.compose.material.icons.rounded.Apps import androidx.compose.material.icons.rounded.Apps
import androidx.compose.material.icons.rounded.BugReport 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.Gesture
import androidx.compose.material.icons.rounded.Home import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Info
@ -74,6 +75,14 @@ fun MainSettingsScreen() {
navController?.navigate("settings/integrations") 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( Preference(
icon = Icons.Rounded.SettingsBackupRestore, icon = Icons.Rounded.SettingsBackupRestore,
title = stringResource(id = R.string.preference_screen_backup), 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 package de.mm20.launcher2.plugin
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface PluginRepository { interface PluginRepository {
@ -10,8 +12,8 @@ interface PluginRepository {
): Flow<List<Plugin>> ): Flow<List<Plugin>>
fun get(authority: String): Flow<Plugin?> fun get(authority: String): Flow<Plugin?>
fun insertMany(plugins: List<Plugin>) fun insertMany(plugins: List<Plugin>): Job
fun insert(plugin: Plugin) fun insert(plugin: Plugin): Job
fun update(plugin: Plugin) fun update(plugin: Plugin): Job
fun deleteMany() 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> <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 --> <!-- 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_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 --> <!-- Grant a permission, shown in permission banners -->
<string name="grant_permission">Grant</string> <string name="grant_permission">Grant</string>
<!-- Appearance preference title --> <!-- Appearance preference title -->
@ -503,6 +504,10 @@
<string name="preference_icon_shape_hexagon">Hexagon</string> <string name="preference_icon_shape_hexagon">Hexagon</string>
<string name="preference_category_searchbar">Search bar</string> <string name="preference_category_searchbar">Search bar</string>
<string name="preference_screen_integrations">Integrations</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_theme_system">Follow system</string>
<string name="preference_themed_icons">Themed icons</string> <string name="preference_themed_icons">Themed icons</string>
<string name="preference_themed_icons_summary">Color icons with the application\'s color scheme</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.checkPermission
import de.mm20.launcher2.ktx.isAtLeastApiLevel import de.mm20.launcher2.ktx.isAtLeastApiLevel
import de.mm20.launcher2.ktx.tryStartActivity import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.plugin.contracts.PluginContract
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -63,6 +64,7 @@ enum class PermissionGroup {
Notifications, Notifications,
AppShortcuts, AppShortcuts,
Accessibility, Accessibility,
Plugins,
} }
internal class PermissionsManagerImpl( internal class PermissionsManagerImpl(
@ -83,6 +85,9 @@ internal class PermissionsManagerImpl(
private val locationPermissionState = MutableStateFlow( private val locationPermissionState = MutableStateFlow(
checkPermissionOnce(PermissionGroup.Location) checkPermissionOnce(PermissionGroup.Location)
) )
private val pluginsPermissionState = MutableStateFlow(
checkPermissionOnce(PermissionGroup.Plugins)
)
private val notificationsPermissionState = MutableStateFlow(false) private val notificationsPermissionState = MutableStateFlow(false)
private val accessibilityPermissionState = MutableStateFlow(false) private val accessibilityPermissionState = MutableStateFlow(false)
private val appShortcutsPermissionState = MutableStateFlow( private val appShortcutsPermissionState = MutableStateFlow(
@ -153,6 +158,14 @@ internal class PermissionsManagerImpl(
CrashReporter.logException(e) CrashReporter.logException(e)
} }
} }
PermissionGroup.Plugins -> {
ActivityCompat.requestPermissions(
context,
pluginPermissions,
permissionGroup.ordinal
)
}
} }
} }
@ -170,6 +183,10 @@ internal class PermissionsManagerImpl(
contactPermissions.all { context.checkPermission(it) } contactPermissions.all { context.checkPermission(it) }
} }
PermissionGroup.Plugins -> {
pluginPermissions.all { context.checkPermission(it) }
}
PermissionGroup.ExternalStorage -> { PermissionGroup.ExternalStorage -> {
if (isAtLeastApiLevel(Build.VERSION_CODES.R)) { if (isAtLeastApiLevel(Build.VERSION_CODES.R)) {
Environment.isExternalStorageManager() Environment.isExternalStorageManager()
@ -201,6 +218,7 @@ internal class PermissionsManagerImpl(
PermissionGroup.Notifications -> notificationsPermissionState PermissionGroup.Notifications -> notificationsPermissionState
PermissionGroup.AppShortcuts -> appShortcutsPermissionState PermissionGroup.AppShortcuts -> appShortcutsPermissionState
PermissionGroup.Accessibility -> accessibilityPermissionState PermissionGroup.Accessibility -> accessibilityPermissionState
PermissionGroup.Plugins -> pluginsPermissionState
} }
} }
@ -209,7 +227,7 @@ internal class PermissionsManagerImpl(
permissions: Array<out String>, permissions: Array<out String>,
grantResults: IntArray grantResults: IntArray
) { ) {
val permissionGroup = PermissionGroup.values().getOrNull(requestCode) ?: return val permissionGroup = PermissionGroup.entries.getOrNull(requestCode) ?: return
val granted = grantResults.all { it == PackageManager.PERMISSION_GRANTED } val granted = grantResults.all { it == PackageManager.PERMISSION_GRANTED }
when (permissionGroup) { when (permissionGroup) {
PermissionGroup.Calendar -> calendarPermissionState.value = granted PermissionGroup.Calendar -> calendarPermissionState.value = granted
@ -219,6 +237,7 @@ internal class PermissionsManagerImpl(
PermissionGroup.Notifications -> notificationsPermissionState.value = granted PermissionGroup.Notifications -> notificationsPermissionState.value = granted
PermissionGroup.AppShortcuts -> appShortcutsPermissionState.value = granted PermissionGroup.AppShortcuts -> appShortcutsPermissionState.value = granted
PermissionGroup.Accessibility -> accessibilityPermissionState.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.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_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.Dao
import androidx.room.Delete import androidx.room.Delete
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.Update import androidx.room.Update
import de.mm20.launcher2.database.entities.PluginEntity import de.mm20.launcher2.database.entities.PluginEntity
@ -26,15 +27,15 @@ interface PluginDao {
@Query("SELECT * FROM Plugins WHERE authority = :authority") @Query("SELECT * FROM Plugins WHERE authority = :authority")
fun get(authority: String): Flow<PluginEntity> fun get(authority: String): Flow<PluginEntity>
@Insert @Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertMany(plugins: List<PluginEntity>) suspend fun insertMany(plugins: List<PluginEntity>)
@Insert @Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(plugin: PluginEntity) suspend fun insert(plugin: PluginEntity)
@Update @Update
fun update(plugin: PluginEntity) suspend fun update(plugin: PluginEntity)
@Query("DELETE FROM Plugins") @Query("DELETE FROM Plugins")
fun deleteMany() suspend fun deleteMany()
} }

View File

@ -42,6 +42,7 @@ dependencies {
implementation(libs.bundles.androidx.lifecycle) implementation(libs.bundles.androidx.lifecycle)
implementation(libs.koin.android) implementation(libs.koin.android)
implementation(libs.coil.core)
implementation(project(":core:base")) implementation(project(":core:base"))
implementation(project(":core:ktx")) implementation(project(":core:ktx"))

View File

@ -2,9 +2,16 @@ package de.mm20.launcher2.files.providers
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.drawable.Drawable
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import coil.imageLoader
import coil.request.ImageRequest
import de.mm20.launcher2.files.PluginFileSerializer 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.ktx.tryStartActivity
import de.mm20.launcher2.plugin.config.StorageStrategy import de.mm20.launcher2.plugin.config.StorageStrategy
import de.mm20.launcher2.search.File import de.mm20.launcher2.search.File
@ -47,6 +54,21 @@ data class PluginFile(
return PluginFileSerializer() 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 { companion object {
const val Domain = "plugin.file" const val Domain = "plugin.file"
} }

View File

@ -4,6 +4,7 @@ import android.content.Context
import android.database.Cursor import android.database.Cursor
import android.net.Uri import android.net.Uri
import android.os.CancellationSignal import android.os.CancellationSignal
import android.util.Log
import androidx.core.database.getStringOrNull import androidx.core.database.getStringOrNull
import de.mm20.launcher2.plugin.Plugin import de.mm20.launcher2.plugin.Plugin
import de.mm20.launcher2.plugin.config.StorageStrategy import de.mm20.launcher2.plugin.config.StorageStrategy
@ -36,7 +37,13 @@ class PluginFileProvider(
null, null,
null, null,
cancellationSignal 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() val results = fromCursor(cursor) ?: emptyList()
it.resume(results) 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.Plugin
import de.mm20.launcher2.plugin.PluginRepository import de.mm20.launcher2.plugin.PluginRepository
import de.mm20.launcher2.plugin.PluginType 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.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
internal class PluginRepositoryImpl( internal class PluginRepositoryImpl(
private val dao: PluginDao, private val dao: PluginDao,
): PluginRepository { ) : PluginRepository {
private val scope = CoroutineScope(Job() + Dispatchers.IO)
override fun findMany( override fun findMany(
type: PluginType?, type: PluginType?,
enabled: Boolean?, enabled: Boolean?,
@ -28,19 +34,27 @@ internal class PluginRepositoryImpl(
return dao.get(authority).map { Plugin(it) } return dao.get(authority).map { Plugin(it) }
} }
override fun insertMany(plugins: List<Plugin>) { override fun insertMany(plugins: List<Plugin>): Job {
TODO("Not yet implemented") return scope.launch {
dao.insertMany(plugins.map { PluginEntity(it) })
}
} }
override fun insert(plugin: Plugin) { override fun insert(plugin: Plugin): Job {
dao.insert(PluginEntity(plugin)) return scope.launch {
dao.insert(PluginEntity(plugin))
}
} }
override fun update(plugin: Plugin) { override fun update(plugin: Plugin): Job {
dao.update(PluginEntity(plugin)) return scope.launch {
dao.update(PluginEntity(plugin))
}
} }
override fun deleteMany() { override fun deleteMany(): Job {
dao.deleteMany() return scope.launch {
dao.deleteMany()
}
} }
} }

View File

@ -1,6 +1,8 @@
package de.mm20.launcher2.sdk.base package de.mm20.launcher2.sdk.base
import android.content.ContentProvider import android.content.ContentProvider
import android.content.Context
import android.content.pm.PackageManager
import android.os.Bundle import android.os.Bundle
import de.mm20.launcher2.plugin.PluginState import de.mm20.launcher2.plugin.PluginState
import de.mm20.launcher2.plugin.PluginType import de.mm20.launcher2.plugin.PluginType
@ -43,4 +45,11 @@ abstract class BasePluginProvider : ContentProvider() {
return PluginState.Ready 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 val context = context ?: return null
checkPermissionOrThrow(context) checkPermissionOrThrow(context)
when { when {
uri.path == SearchPluginContract.Paths.Search -> { uri.pathSegments.size == 1 && uri.pathSegments.first() == SearchPluginContract.Paths.Search -> {
val query = val query =
uri.getQueryParameter(SearchPluginContract.Paths.QueryParam) ?: return null uri.getQueryParameter(SearchPluginContract.Paths.QueryParam) ?: return null
val results = search(query, cancellationSignal) val results = search(query, cancellationSignal)
@ -53,7 +53,7 @@ abstract class SearchPluginProvider<T> : BasePluginProvider() {
for (result in results) { for (result in results) {
writeToCursor(cursor, result) writeToCursor(cursor, result)
} }
return null return cursor
} }
uri.pathSegments.size == 2 && uri.pathSegments.first() == SearchPluginContract.Paths.Root -> { uri.pathSegments.size == 2 && uri.pathSegments.first() == SearchPluginContract.Paths.Root -> {
val id = uri.pathSegments[1] val id = uri.pathSegments[1]
@ -110,12 +110,4 @@ abstract class SearchPluginProvider<T> : BasePluginProvider() {
internal abstract fun createCursor(capacity: Int): MatrixCursor internal abstract fun createCursor(capacity: Int): MatrixCursor
internal abstract fun writeToCursor(cursor: MatrixCursor, item: T) 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 package de.mm20.launcher2.sdk.files
import android.database.Cursor
import android.database.MatrixCursor 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.plugin.contracts.FilePluginContract
import de.mm20.launcher2.sdk.base.SearchPluginProvider import de.mm20.launcher2.sdk.base.SearchPluginProvider
abstract class FileProvider : SearchPluginProvider<File>() { abstract class FileProvider : SearchPluginProvider<File>() {
abstract override suspend fun search(query: String): List<File> abstract override suspend fun search(query: String): List<File>
final override fun getPluginType(): de.mm20.launcher2.plugin.PluginType { final override fun getPluginType(): PluginType {
return de.mm20.launcher2.plugin.PluginType.FileSearch return PluginType.FileSearch
} }
override fun createCursor(capacity: Int): MatrixCursor { override fun createCursor(capacity: Int): MatrixCursor {

View File

@ -2,6 +2,8 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri 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.Plugin
import de.mm20.launcher2.plugin.PluginType import de.mm20.launcher2.plugin.PluginType
@ -16,37 +18,44 @@ class PluginScanner(
val plugins = mutableListOf<Plugin>() val plugins = mutableListOf<Plugin>()
for (cr in contentResolvers) { for (cr in contentResolvers) {
val providerInfo = cr.providerInfo ?: continue try {
val authority = providerInfo.authority ?: continue val providerInfo = cr.providerInfo ?: continue
val bundle = context.contentResolver.call( val authority = providerInfo.authority ?: continue
Uri.Builder() val bundle = context.contentResolver.call(
.scheme("content") Uri.Builder()
.authority(authority) .scheme("content")
.build(), .authority(authority)
"getType", .build(),
null, "getType",
null, null,
) ?: continue null,
val type = bundle.getString("type") ) ?: continue
?.let { val type = bundle.getString("type")
try { ?.let {
PluginType.valueOf(it) try {
} catch (e: IllegalArgumentException) { PluginType.valueOf(it)
null } catch (e: IllegalArgumentException) {
} null
} ?: continue }
plugins.add( } ?: continue
Plugin( plugins.add(
label = cr.loadLabel(context.packageManager).toString(), Plugin(
description = providerInfo.metaData?.getString("de.mm20.launcher2.plugin.description"), label = cr.loadLabel(context.packageManager).toString(),
packageName = providerInfo.packageName, description = providerInfo.metaData?.getString("de.mm20.launcher2.plugin.description"),
className = providerInfo.name, packageName = providerInfo.packageName,
type = type, className = providerInfo.name,
authority = authority, type = type,
settingsActivity = providerInfo.metaData?.getString("de.mm20.launcher2.plugin.settings"), authority = authority,
enabled = false, settingsActivity = providerInfo.metaData?.getString("de.mm20.launcher2.plugin.settings"),
enabled = false,
)
) )
) } catch (e: SecurityException) {
continue
} catch (e: Exception) {
CrashReporter.logException(e)
continue
}
} }
return plugins return plugins

View File

@ -2,9 +2,12 @@ package de.mm20.launcher2.plugins
import PluginScanner import PluginScanner
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import android.net.Uri import android.net.Uri
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import de.mm20.launcher2.plugin.Plugin import de.mm20.launcher2.plugin.Plugin
@ -16,6 +19,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -32,7 +36,11 @@ interface PluginService {
fun enablePlugin(plugin: Plugin) fun enablePlugin(plugin: Plugin)
fun disablePlugin(plugin: Plugin) fun disablePlugin(plugin: Plugin)
fun getPluginsWithState(type: PluginType? = null): Flow<List<PluginWithState>> fun getPluginsWithState(type: PluginType? = null): Flow<List<PluginWithState>>
fun isPluginHostInstalled(): Flow<Boolean>
suspend fun getPluginState(plugin: Plugin): PluginState? suspend fun getPluginState(plugin: Plugin): PluginState?
suspend fun getPluginIcon(plugin: Plugin): Drawable?
} }
internal class PluginServiceImpl( internal class PluginServiceImpl(
@ -42,6 +50,10 @@ internal class PluginServiceImpl(
private val scope: CoroutineScope = CoroutineScope(Job() + Dispatchers.Default) private val scope: CoroutineScope = CoroutineScope(Job() + Dispatchers.Default)
private val pluginHostInstalled = MutableStateFlow(false)
private val mutex = Mutex()
init { init {
refreshPlugins() refreshPlugins()
ContextCompat.registerReceiver( ContextCompat.registerReceiver(
@ -65,9 +77,16 @@ internal class PluginServiceImpl(
repository.update(plugin.copy(enabled = false)) repository.update(plugin.copy(enabled = false))
} }
private val mutex = Mutex()
private fun refreshPlugins() { private fun refreshPlugins() {
scope.launch { 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 { mutex.withLock {
val enabledPlugins = val enabledPlugins =
repository.findMany(enabled = true).first().map { it.authority } repository.findMany(enabled = true).first().map { it.authority }
@ -79,8 +98,8 @@ internal class PluginServiceImpl(
it it
} }
} }
repository.deleteMany() repository.deleteMany().join()
repository.insertMany(plugins) 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() { private inner class AppUpdateReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
refreshPlugins() refreshPlugins()