diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/PreferenceCategory.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/PreferenceCategory.kt index d9be212f..4c6df136 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/PreferenceCategory.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/PreferenceCategory.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.unit.dp @Composable fun PreferenceCategory( title: String? = null, + iconPadding: Boolean = true, content: @Composable ColumnScope.() -> Unit ) { Column { @@ -21,7 +22,7 @@ fun PreferenceCategory( .padding(start = 16.dp, top = 16.dp, end = 16.dp) ) { Text( - modifier = Modifier.padding(start = 56.dp), + modifier = Modifier.padding(start = if (iconPadding) 56.dp else 0.dp), text = title, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/filesearch/FileSearchSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/filesearch/FileSearchSettingsScreen.kt index ef7ef245..9eaa6b0e 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/filesearch/FileSearchSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/filesearch/FileSearchSettingsScreen.kt @@ -1,12 +1,14 @@ package de.mm20.launcher2.ui.settings.filesearch +import android.app.PendingIntent import androidx.appcompat.app.AppCompatActivity import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.AccountBox +import androidx.compose.material.icons.rounded.ErrorOutline +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -19,10 +21,13 @@ import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import de.mm20.launcher2.accounts.AccountType +import de.mm20.launcher2.crashreporter.CrashReporter import de.mm20.launcher2.ktx.isAtLeastApiLevel +import de.mm20.launcher2.plugin.PluginState import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.component.Banner import de.mm20.launcher2.ui.component.MissingPermissionBanner @@ -40,6 +45,13 @@ fun FileSearchSettingsScreen() { viewModel.onResume() } } + + val plugins by viewModel.availablePlugins.collectAsStateWithLifecycle( + emptyList(), + minActiveState = Lifecycle.State.RESUMED, + ) + val enabledPlugins by viewModel.enabledPlugins.collectAsStateWithLifecycle(null) + val loading by viewModel.loading PreferenceScreen(title = stringResource(R.string.preference_search_files)) { if (loading == true) { @@ -174,6 +186,38 @@ fun FileSearchSettingsScreen() { enabled = googleAccount != null ) } + for (plugin in plugins) { + val state = plugin.state + if (state is PluginState.SetupRequired) { + Banner( + modifier = Modifier.padding(16.dp), + text = state.message ?: "You need to setup this plugin first", + icon = Icons.Rounded.ErrorOutline, + primaryAction = { + TextButton(onClick = { + try { + state.setupActivity.send() + } catch (e: PendingIntent.CanceledException) { + CrashReporter.logException(e) + } + }) { + Text("Set up") + } + } + ) + } + SwitchPreference( + title = plugin.plugin.label, + enabled = enabledPlugins != null && state is PluginState.Ready, + summary = (state as? PluginState.Ready)?.text + ?: (state as? PluginState.SetupRequired)?.message + ?: plugin.plugin.description, + value = enabledPlugins?.contains(plugin.plugin.authority) == true && state is PluginState.Ready, + onValueChanged = { + viewModel.setPluginEnabled(plugin.plugin.authority, it) + }, + ) + } } } } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/filesearch/FileSearchSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/filesearch/FileSearchSettingsScreenVM.kt index a1181d9a..be2eed2a 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/filesearch/FileSearchSettingsScreenVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/filesearch/FileSearchSettingsScreenVM.kt @@ -10,9 +10,9 @@ import de.mm20.launcher2.accounts.AccountsRepository import de.mm20.launcher2.files.settings.FileSearchSettings import de.mm20.launcher2.permissions.PermissionGroup import de.mm20.launcher2.permissions.PermissionsManager -import de.mm20.launcher2.preferences.LauncherDataStore +import de.mm20.launcher2.plugin.PluginType +import de.mm20.launcher2.plugins.PluginService import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent @@ -22,6 +22,7 @@ class FileSearchSettingsScreenVM : ViewModel(), KoinComponent { private val fileSearchSettings: FileSearchSettings by inject() private val accountsRepository: AccountsRepository by inject() private val permissionsManager: PermissionsManager by inject() + private val pluginService: PluginService by inject() val hasFilePermission = permissionsManager.hasPermission(PermissionGroup.ExternalStorage) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) @@ -33,6 +34,14 @@ class FileSearchSettingsScreenVM : ViewModel(), KoinComponent { val googleAvailable = accountsRepository.isSupported(AccountType.Google) + val availablePlugins = pluginService.getPluginsWithState( + type = PluginType.FileSearch, + enabled = true, + ) + + val enabledPlugins = fileSearchSettings.enabledPlugins + + fun onResume() { viewModelScope.launch { nextcloudAccount.value = @@ -75,4 +84,8 @@ class FileSearchSettingsScreenVM : ViewModel(), KoinComponent { fun login(context: AppCompatActivity, accountType: AccountType) { accountsRepository.signin(context, accountType) } + + fun setPluginEnabled(authority: String, enabled: Boolean) { + fileSearchSettings.setPluginEnabled(authority, enabled) + } } \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginSettingsScreen.kt index 3a8be167..5343e118 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginSettingsScreen.kt @@ -1,5 +1,6 @@ package de.mm20.launcher2.ui.settings.plugins +import android.app.PendingIntent import androidx.appcompat.app.AppCompatActivity import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState @@ -16,6 +17,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.InsertDriveFile import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.ErrorOutline +import androidx.compose.material.icons.rounded.FileCopy import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Verified @@ -25,6 +28,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -37,7 +41,10 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage +import de.mm20.launcher2.crashreporter.CrashReporter +import de.mm20.launcher2.plugin.PluginState import de.mm20.launcher2.plugin.PluginType +import de.mm20.launcher2.ui.component.Banner import de.mm20.launcher2.ui.component.preferences.PreferenceCategory import de.mm20.launcher2.ui.component.preferences.SwitchPreference import de.mm20.launcher2.ui.locals.LocalNavController @@ -55,11 +62,14 @@ fun PluginSettingsScreen(pluginId: String) { val pluginPackage by viewModel.pluginPackage.collectAsStateWithLifecycle(null) val icon by viewModel.icon.collectAsStateWithLifecycle(null) val types by viewModel.types.collectAsStateWithLifecycle(emptyList()) - val states by viewModel.states.collectAsStateWithLifecycle( + + val filePlugins by viewModel.filePlugins.collectAsStateWithLifecycle( emptyList(), minActiveState = Lifecycle.State.RESUMED ) + val enabledFileSearchPlugins by viewModel.enabledFileSearchPlugins.collectAsStateWithLifecycle(null) + Scaffold( topBar = { TopAppBar( @@ -247,8 +257,45 @@ fun PluginSettingsScreen(pluginId: String) { ) } AnimatedVisibility(pluginPackage?.enabled == true) { - PreferenceCategory { - + if (filePlugins.isNotEmpty()) { + PreferenceCategory( + "File search", + iconPadding = false, + ) { + for (plugin in filePlugins) { + val state = plugin.state + if (state is PluginState.SetupRequired) { + Banner( + modifier = Modifier.padding(16.dp), + text = state.message ?: "You need to setup this plugin first", + icon = Icons.Rounded.ErrorOutline, + primaryAction = { + TextButton(onClick = { + try { + state.setupActivity.send() + } catch (e: PendingIntent.CanceledException) { + CrashReporter.logException(e) + } + }) { + Text("Set up") + } + } + ) + } + SwitchPreference( + title = plugin.plugin.label, + enabled = enabledFileSearchPlugins != null && state is PluginState.Ready, + summary = (state as? PluginState.Ready)?.text + ?: (state as? PluginState.SetupRequired)?.message + ?: plugin.plugin.description, + value = enabledFileSearchPlugins?.contains(plugin.plugin.authority) == true && state is PluginState.Ready, + onValueChanged = { + viewModel.setFileSearchPluginEnabled(plugin.plugin.authority, it) + }, + iconPadding = false, + ) + } + } } } } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginSettingsScreenVM.kt index 2b097275..984935c1 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginSettingsScreenVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginSettingsScreenVM.kt @@ -7,11 +7,13 @@ import android.net.Uri import android.provider.Settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import de.mm20.launcher2.files.settings.FileSearchSettings import de.mm20.launcher2.ktx.tryStartActivity import de.mm20.launcher2.plugin.PluginPackage import de.mm20.launcher2.plugin.PluginState import de.mm20.launcher2.plugin.PluginType import de.mm20.launcher2.plugins.PluginService +import de.mm20.launcher2.plugins.PluginWithState import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -26,6 +28,7 @@ import org.koin.core.component.inject class PluginSettingsScreenVM : ViewModel(), KoinComponent { private val pluginService by inject() + private val fileSearchSettings: FileSearchSettings by inject() private var pluginPackageName = MutableStateFlow(null) @@ -47,11 +50,17 @@ class PluginSettingsScreenVM : ViewModel(), KoinComponent { it?.plugins?.map { it.type }?.distinct() ?: emptyList() } - val states: Flow> = pluginPackage.map { - it?.plugins?.map { - pluginService.getPluginState(it) - } ?: emptyList() - } + val filePlugins = pluginPackage + .map { + it?.plugins?.mapNotNull { + if (it.type == PluginType.FileSearch) { + val state = pluginService.getPluginState(it) + PluginWithState(it, state) + } else { + null + } + } ?: emptyList() + } fun init(pluginId: String) { @@ -78,4 +87,10 @@ class PluginSettingsScreenVM : ViewModel(), KoinComponent { val plugin = pluginPackage.value ?: return pluginService.uninstallPluginPackage(context, plugin) } + + + val enabledFileSearchPlugins = fileSearchSettings.enabledPlugins + fun setFileSearchPluginEnabled(authority: String, enabled: Boolean) { + fileSearchSettings.setPluginEnabled(authority, enabled) + } } \ No newline at end of file diff --git a/data/files/src/main/java/de/mm20/launcher2/files/FileSerialization.kt b/data/files/src/main/java/de/mm20/launcher2/files/FileSerialization.kt index 8decede5..a2508fa7 100644 --- a/data/files/src/main/java/de/mm20/launcher2/files/FileSerialization.kt +++ b/data/files/src/main/java/de/mm20/launcher2/files/FileSerialization.kt @@ -361,7 +361,7 @@ internal class PluginFileDeserializer( val id = obj.getString("id") val plugin = pluginRepository.get(authority).firstOrNull() ?: return null if (!plugin.enabled) return null - val provider = PluginFileProvider(context, plugin) + val provider = PluginFileProvider(context, authority) return provider.getFile(id) } catch (e: Exception) { CrashReporter.logException(e) diff --git a/data/files/src/main/java/de/mm20/launcher2/files/FilesRepository.kt b/data/files/src/main/java/de/mm20/launcher2/files/FilesRepository.kt index 0aa8006f..7a6341dc 100644 --- a/data/files/src/main/java/de/mm20/launcher2/files/FilesRepository.kt +++ b/data/files/src/main/java/de/mm20/launcher2/files/FilesRepository.kt @@ -53,9 +53,7 @@ internal class FileRepository( enabled = true, ) - settings.data.combine(filePlugins) { settings, plugins -> - settings to plugins - }.collectLatest { (settings, plugins) -> + settings.data.collectLatest { settings -> val providers = mutableListOf() if (settings.localFiles) providers.add( @@ -68,7 +66,7 @@ internal class FileRepository( if (settings.nextcloudFiles) providers.add(NextcloudFileProvider(nextcloudClient)) if (settings.owncloudFiles) providers.add(OwncloudFileProvider(owncloudClient)) - for (plugin in plugins) { + for (plugin in settings.plugins) { providers.add(PluginFileProvider(context, plugin)) } diff --git a/data/files/src/main/java/de/mm20/launcher2/files/providers/PluginFileProvider.kt b/data/files/src/main/java/de/mm20/launcher2/files/providers/PluginFileProvider.kt index c7c36e0e..06568fff 100644 --- a/data/files/src/main/java/de/mm20/launcher2/files/providers/PluginFileProvider.kt +++ b/data/files/src/main/java/de/mm20/launcher2/files/providers/PluginFileProvider.kt @@ -20,12 +20,12 @@ import kotlin.coroutines.resume class PluginFileProvider( private val context: Context, - private val plugin: Plugin, + private val pluginAuthority: String, ) : FileProvider { override suspend fun search(query: String): List { val uri = Uri.Builder() .scheme("content") - .authority(plugin.authority) + .authority(pluginAuthority) .path(SearchPluginContract.Paths.Search) .appendQueryParameter(SearchPluginContract.Paths.QueryParam, query) .build() @@ -43,14 +43,14 @@ class PluginFileProvider( cancellationSignal ) } catch (e: Exception) { - Log.e("MM20", "Plugin ${plugin.authority} threw exception") + Log.e("MM20", "Plugin ${pluginAuthority} threw exception") CrashReporter.logException(e) it.resume(emptyList()) return@suspendCancellableCoroutine } if (cursor == null) { - Log.e("MM20", "Plugin ${plugin.authority} returned null cursor") + Log.e("MM20", "Plugin ${pluginAuthority} returned null cursor") it.resume(emptyList()) return@suspendCancellableCoroutine } @@ -65,14 +65,14 @@ class PluginFileProvider( context.contentResolver.call( Uri.Builder() .scheme("content") - .authority(plugin.authority) + .authority(pluginAuthority) .build(), PluginContract.Methods.GetConfig, null, null ) ?: return null } catch (e: Exception) { - Log.e("MM20", "Plugin ${plugin.authority} threw exception") + Log.e("MM20", "Plugin ${pluginAuthority} threw exception") CrashReporter.logException(e) return null } @@ -83,7 +83,7 @@ class PluginFileProvider( suspend fun getFile(id: String): File? { val uri = Uri.Builder() .scheme("content") - .authority(plugin.authority) + .authority(pluginAuthority) .path(SearchPluginContract.Paths.Root) .appendPath(id) .build() @@ -109,7 +109,7 @@ class PluginFileProvider( val config = getPluginConfig() if (config == null) { - Log.e("MM20", "Plugin ${plugin.authority} returned null config") + Log.e("MM20", "Plugin ${pluginAuthority} returned null config") cursor.close() return null } @@ -153,7 +153,7 @@ class PluginFileProvider( }?.let { Uri.parse(it) }, storageStrategy = config.storageStrategy, isDirectory = directoryIndex?.let { cursor.getInt(it) } == 1, - authority = plugin.authority, + authority = pluginAuthority, ) ) } diff --git a/data/files/src/main/java/de/mm20/launcher2/files/settings/FileSearchSettings.kt b/data/files/src/main/java/de/mm20/launcher2/files/settings/FileSearchSettings.kt index e631a283..3a9cdd33 100644 --- a/data/files/src/main/java/de/mm20/launcher2/files/settings/FileSearchSettings.kt +++ b/data/files/src/main/java/de/mm20/launcher2/files/settings/FileSearchSettings.kt @@ -87,6 +87,17 @@ class FileSearchSettings( } } + val enabledPlugins: Flow> + get(): Flow> { + return context.dataStore.data.map { it.plugins } + } + + fun setEnabledPlugins(enabledPlugins: Set) { + updateData { + it.copy(plugins = enabledPlugins) + } + } + override suspend fun backup(toDir: File) { val data = context.dataStore.data.first() val file = File(toDir, "file_search.json") @@ -109,4 +120,14 @@ class FileSearchSettings( CrashReporter.logException(e) } } + + fun setPluginEnabled(authority: String, enabled: Boolean) { + updateData { + if (enabled) { + it.copy(plugins = it.plugins + authority) + } else { + it.copy(plugins = it.plugins - authority) + } + } + } } \ No newline at end of file diff --git a/services/plugins/src/main/java/de/mm20/launcher2/plugins/PluginService.kt b/services/plugins/src/main/java/de/mm20/launcher2/plugins/PluginService.kt index 385ab5a4..2cb28437 100644 --- a/services/plugins/src/main/java/de/mm20/launcher2/plugins/PluginService.kt +++ b/services/plugins/src/main/java/de/mm20/launcher2/plugins/PluginService.kt @@ -12,7 +12,6 @@ import android.net.Uri import android.os.Build import android.util.Base64 import android.util.Log -import androidx.core.content.ContextCompat import de.mm20.launcher2.ktx.tryStartActivity import de.mm20.launcher2.plugin.Plugin import de.mm20.launcher2.plugin.PluginPackage @@ -43,7 +42,10 @@ data class PluginWithState( interface PluginService { fun enablePluginPackage(plugin: PluginPackage) fun disablePluginPackage(plugin: PluginPackage) - fun getPluginsWithState(type: PluginType? = null): Flow> + fun getPluginsWithState( + type: PluginType? = null, + enabled: Boolean? = null, + ): Flow> fun isPluginHostInstalled(): Flow @@ -127,9 +129,11 @@ internal class PluginServiceImpl( override fun getPluginsWithState( type: PluginType?, + enabled: Boolean?, ): Flow> { return repository.findMany( type = type, + enabled = enabled, ).map { it.map { PluginWithState(