Add file plugin settings screens

This commit is contained in:
MM20 2023-12-03 19:44:26 +01:00
parent 7487b65247
commit a94cc2ae01
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
10 changed files with 171 additions and 28 deletions

View File

@ -12,6 +12,7 @@ import androidx.compose.ui.unit.dp
@Composable @Composable
fun PreferenceCategory( fun PreferenceCategory(
title: String? = null, title: String? = null,
iconPadding: Boolean = true,
content: @Composable ColumnScope.() -> Unit content: @Composable ColumnScope.() -> Unit
) { ) {
Column { Column {
@ -21,7 +22,7 @@ fun PreferenceCategory(
.padding(start = 16.dp, top = 16.dp, end = 16.dp) .padding(start = 16.dp, top = 16.dp, end = 16.dp)
) { ) {
Text( Text(
modifier = Modifier.padding(start = 56.dp), modifier = Modifier.padding(start = if (iconPadding) 56.dp else 0.dp),
text = title, text = title,
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary

View File

@ -1,12 +1,14 @@
package de.mm20.launcher2.ui.settings.filesearch package de.mm20.launcher2.ui.settings.filesearch
import android.app.PendingIntent
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.AccountBox 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.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable 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.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.accounts.AccountType import de.mm20.launcher2.accounts.AccountType
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.ktx.isAtLeastApiLevel import de.mm20.launcher2.ktx.isAtLeastApiLevel
import de.mm20.launcher2.plugin.PluginState
import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.Banner import de.mm20.launcher2.ui.component.Banner
import de.mm20.launcher2.ui.component.MissingPermissionBanner import de.mm20.launcher2.ui.component.MissingPermissionBanner
@ -40,6 +45,13 @@ fun FileSearchSettingsScreen() {
viewModel.onResume() 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 val loading by viewModel.loading
PreferenceScreen(title = stringResource(R.string.preference_search_files)) { PreferenceScreen(title = stringResource(R.string.preference_search_files)) {
if (loading == true) { if (loading == true) {
@ -174,6 +186,38 @@ fun FileSearchSettingsScreen() {
enabled = googleAccount != null 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)
},
)
}
} }
} }
} }

View File

@ -10,9 +10,9 @@ import de.mm20.launcher2.accounts.AccountsRepository
import de.mm20.launcher2.files.settings.FileSearchSettings import de.mm20.launcher2.files.settings.FileSearchSettings
import de.mm20.launcher2.permissions.PermissionGroup import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.LauncherDataStore import de.mm20.launcher2.plugin.PluginType
import de.mm20.launcher2.plugins.PluginService
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -22,6 +22,7 @@ class FileSearchSettingsScreenVM : ViewModel(), KoinComponent {
private val fileSearchSettings: FileSearchSettings by inject() private val fileSearchSettings: FileSearchSettings by inject()
private val accountsRepository: AccountsRepository by inject() private val accountsRepository: AccountsRepository by inject()
private val permissionsManager: PermissionsManager by inject() private val permissionsManager: PermissionsManager by inject()
private val pluginService: PluginService by inject()
val hasFilePermission = permissionsManager.hasPermission(PermissionGroup.ExternalStorage) val hasFilePermission = permissionsManager.hasPermission(PermissionGroup.ExternalStorage)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
@ -33,6 +34,14 @@ class FileSearchSettingsScreenVM : ViewModel(), KoinComponent {
val googleAvailable = accountsRepository.isSupported(AccountType.Google) val googleAvailable = accountsRepository.isSupported(AccountType.Google)
val availablePlugins = pluginService.getPluginsWithState(
type = PluginType.FileSearch,
enabled = true,
)
val enabledPlugins = fileSearchSettings.enabledPlugins
fun onResume() { fun onResume() {
viewModelScope.launch { viewModelScope.launch {
nextcloudAccount.value = nextcloudAccount.value =
@ -75,4 +84,8 @@ class FileSearchSettingsScreenVM : ViewModel(), KoinComponent {
fun login(context: AppCompatActivity, accountType: AccountType) { fun login(context: AppCompatActivity, accountType: AccountType) {
accountsRepository.signin(context, accountType) accountsRepository.signin(context, accountType)
} }
fun setPluginEnabled(authority: String, enabled: Boolean) {
fileSearchSettings.setPluginEnabled(authority, enabled)
}
} }

View File

@ -1,5 +1,6 @@
package de.mm20.launcher2.ui.settings.plugins package de.mm20.launcher2.ui.settings.plugins
import android.app.PendingIntent
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState 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.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.InsertDriveFile import androidx.compose.material.icons.automirrored.rounded.InsertDriveFile
import androidx.compose.material.icons.rounded.Delete 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.Info
import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Verified 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.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -37,7 +41,10 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage 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.plugin.PluginType
import de.mm20.launcher2.ui.component.Banner
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.SwitchPreference import de.mm20.launcher2.ui.component.preferences.SwitchPreference
import de.mm20.launcher2.ui.locals.LocalNavController import de.mm20.launcher2.ui.locals.LocalNavController
@ -55,11 +62,14 @@ fun PluginSettingsScreen(pluginId: String) {
val pluginPackage by viewModel.pluginPackage.collectAsStateWithLifecycle(null) val pluginPackage by viewModel.pluginPackage.collectAsStateWithLifecycle(null)
val icon by viewModel.icon.collectAsStateWithLifecycle(null) val icon by viewModel.icon.collectAsStateWithLifecycle(null)
val types by viewModel.types.collectAsStateWithLifecycle(emptyList()) val types by viewModel.types.collectAsStateWithLifecycle(emptyList())
val states by viewModel.states.collectAsStateWithLifecycle(
val filePlugins by viewModel.filePlugins.collectAsStateWithLifecycle(
emptyList(), emptyList(),
minActiveState = Lifecycle.State.RESUMED minActiveState = Lifecycle.State.RESUMED
) )
val enabledFileSearchPlugins by viewModel.enabledFileSearchPlugins.collectAsStateWithLifecycle(null)
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@ -247,8 +257,45 @@ fun PluginSettingsScreen(pluginId: String) {
) )
} }
AnimatedVisibility(pluginPackage?.enabled == true) { 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,
)
}
}
} }
} }
} }

View File

@ -7,11 +7,13 @@ import android.net.Uri
import android.provider.Settings import android.provider.Settings
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.files.settings.FileSearchSettings
import de.mm20.launcher2.ktx.tryStartActivity import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.plugin.PluginPackage import de.mm20.launcher2.plugin.PluginPackage
import de.mm20.launcher2.plugin.PluginState import de.mm20.launcher2.plugin.PluginState
import de.mm20.launcher2.plugin.PluginType import de.mm20.launcher2.plugin.PluginType
import de.mm20.launcher2.plugins.PluginService import de.mm20.launcher2.plugins.PluginService
import de.mm20.launcher2.plugins.PluginWithState
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@ -26,6 +28,7 @@ import org.koin.core.component.inject
class PluginSettingsScreenVM : ViewModel(), KoinComponent { class PluginSettingsScreenVM : ViewModel(), KoinComponent {
private val pluginService by inject<PluginService>() private val pluginService by inject<PluginService>()
private val fileSearchSettings: FileSearchSettings by inject()
private var pluginPackageName = MutableStateFlow<String?>(null) private var pluginPackageName = MutableStateFlow<String?>(null)
@ -47,11 +50,17 @@ class PluginSettingsScreenVM : ViewModel(), KoinComponent {
it?.plugins?.map { it.type }?.distinct() ?: emptyList() it?.plugins?.map { it.type }?.distinct() ?: emptyList()
} }
val states: Flow<List<PluginState?>> = pluginPackage.map { val filePlugins = pluginPackage
it?.plugins?.map { .map {
pluginService.getPluginState(it) it?.plugins?.mapNotNull {
} ?: emptyList() if (it.type == PluginType.FileSearch) {
} val state = pluginService.getPluginState(it)
PluginWithState(it, state)
} else {
null
}
} ?: emptyList()
}
fun init(pluginId: String) { fun init(pluginId: String) {
@ -78,4 +87,10 @@ class PluginSettingsScreenVM : ViewModel(), KoinComponent {
val plugin = pluginPackage.value ?: return val plugin = pluginPackage.value ?: return
pluginService.uninstallPluginPackage(context, plugin) pluginService.uninstallPluginPackage(context, plugin)
} }
val enabledFileSearchPlugins = fileSearchSettings.enabledPlugins
fun setFileSearchPluginEnabled(authority: String, enabled: Boolean) {
fileSearchSettings.setPluginEnabled(authority, enabled)
}
} }

View File

@ -361,7 +361,7 @@ internal class PluginFileDeserializer(
val id = obj.getString("id") val id = obj.getString("id")
val plugin = pluginRepository.get(authority).firstOrNull() ?: return null val plugin = pluginRepository.get(authority).firstOrNull() ?: return null
if (!plugin.enabled) return null if (!plugin.enabled) return null
val provider = PluginFileProvider(context, plugin) val provider = PluginFileProvider(context, authority)
return provider.getFile(id) return provider.getFile(id)
} catch (e: Exception) { } catch (e: Exception) {
CrashReporter.logException(e) CrashReporter.logException(e)

View File

@ -53,9 +53,7 @@ internal class FileRepository(
enabled = true, enabled = true,
) )
settings.data.combine(filePlugins) { settings, plugins -> settings.data.collectLatest { settings ->
settings to plugins
}.collectLatest { (settings, plugins) ->
val providers = mutableListOf<FileProvider>() val providers = mutableListOf<FileProvider>()
if (settings.localFiles) providers.add( if (settings.localFiles) providers.add(
@ -68,7 +66,7 @@ internal class FileRepository(
if (settings.nextcloudFiles) providers.add(NextcloudFileProvider(nextcloudClient)) if (settings.nextcloudFiles) providers.add(NextcloudFileProvider(nextcloudClient))
if (settings.owncloudFiles) providers.add(OwncloudFileProvider(owncloudClient)) if (settings.owncloudFiles) providers.add(OwncloudFileProvider(owncloudClient))
for (plugin in plugins) { for (plugin in settings.plugins) {
providers.add(PluginFileProvider(context, plugin)) providers.add(PluginFileProvider(context, plugin))
} }

View File

@ -20,12 +20,12 @@ import kotlin.coroutines.resume
class PluginFileProvider( class PluginFileProvider(
private val context: Context, private val context: Context,
private val plugin: Plugin, private val pluginAuthority: String,
) : FileProvider { ) : FileProvider {
override suspend fun search(query: String): List<File> { override suspend fun search(query: String): List<File> {
val uri = Uri.Builder() val uri = Uri.Builder()
.scheme("content") .scheme("content")
.authority(plugin.authority) .authority(pluginAuthority)
.path(SearchPluginContract.Paths.Search) .path(SearchPluginContract.Paths.Search)
.appendQueryParameter(SearchPluginContract.Paths.QueryParam, query) .appendQueryParameter(SearchPluginContract.Paths.QueryParam, query)
.build() .build()
@ -43,14 +43,14 @@ class PluginFileProvider(
cancellationSignal cancellationSignal
) )
} catch (e: Exception) { } catch (e: Exception) {
Log.e("MM20", "Plugin ${plugin.authority} threw exception") Log.e("MM20", "Plugin ${pluginAuthority} threw exception")
CrashReporter.logException(e) CrashReporter.logException(e)
it.resume(emptyList()) it.resume(emptyList())
return@suspendCancellableCoroutine return@suspendCancellableCoroutine
} }
if (cursor == null) { if (cursor == null) {
Log.e("MM20", "Plugin ${plugin.authority} returned null cursor") Log.e("MM20", "Plugin ${pluginAuthority} returned null cursor")
it.resume(emptyList()) it.resume(emptyList())
return@suspendCancellableCoroutine return@suspendCancellableCoroutine
} }
@ -65,14 +65,14 @@ class PluginFileProvider(
context.contentResolver.call( context.contentResolver.call(
Uri.Builder() Uri.Builder()
.scheme("content") .scheme("content")
.authority(plugin.authority) .authority(pluginAuthority)
.build(), .build(),
PluginContract.Methods.GetConfig, PluginContract.Methods.GetConfig,
null, null,
null null
) ?: return null ) ?: return null
} catch (e: Exception) { } catch (e: Exception) {
Log.e("MM20", "Plugin ${plugin.authority} threw exception") Log.e("MM20", "Plugin ${pluginAuthority} threw exception")
CrashReporter.logException(e) CrashReporter.logException(e)
return null return null
} }
@ -83,7 +83,7 @@ class PluginFileProvider(
suspend fun getFile(id: String): File? { suspend fun getFile(id: String): File? {
val uri = Uri.Builder() val uri = Uri.Builder()
.scheme("content") .scheme("content")
.authority(plugin.authority) .authority(pluginAuthority)
.path(SearchPluginContract.Paths.Root) .path(SearchPluginContract.Paths.Root)
.appendPath(id) .appendPath(id)
.build() .build()
@ -109,7 +109,7 @@ class PluginFileProvider(
val config = getPluginConfig() val config = getPluginConfig()
if (config == null) { if (config == null) {
Log.e("MM20", "Plugin ${plugin.authority} returned null config") Log.e("MM20", "Plugin ${pluginAuthority} returned null config")
cursor.close() cursor.close()
return null return null
} }
@ -153,7 +153,7 @@ class PluginFileProvider(
}?.let { Uri.parse(it) }, }?.let { Uri.parse(it) },
storageStrategy = config.storageStrategy, storageStrategy = config.storageStrategy,
isDirectory = directoryIndex?.let { cursor.getInt(it) } == 1, isDirectory = directoryIndex?.let { cursor.getInt(it) } == 1,
authority = plugin.authority, authority = pluginAuthority,
) )
) )
} }

View File

@ -87,6 +87,17 @@ class FileSearchSettings(
} }
} }
val enabledPlugins: Flow<Set<String>>
get(): Flow<Set<String>> {
return context.dataStore.data.map { it.plugins }
}
fun setEnabledPlugins(enabledPlugins: Set<String>) {
updateData {
it.copy(plugins = enabledPlugins)
}
}
override suspend fun backup(toDir: File) { override suspend fun backup(toDir: File) {
val data = context.dataStore.data.first() val data = context.dataStore.data.first()
val file = File(toDir, "file_search.json") val file = File(toDir, "file_search.json")
@ -109,4 +120,14 @@ class FileSearchSettings(
CrashReporter.logException(e) 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)
}
}
}
} }

View File

@ -12,7 +12,6 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.util.Base64 import android.util.Base64
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat
import de.mm20.launcher2.ktx.tryStartActivity import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.plugin.Plugin import de.mm20.launcher2.plugin.Plugin
import de.mm20.launcher2.plugin.PluginPackage import de.mm20.launcher2.plugin.PluginPackage
@ -43,7 +42,10 @@ data class PluginWithState(
interface PluginService { interface PluginService {
fun enablePluginPackage(plugin: PluginPackage) fun enablePluginPackage(plugin: PluginPackage)
fun disablePluginPackage(plugin: PluginPackage) fun disablePluginPackage(plugin: PluginPackage)
fun getPluginsWithState(type: PluginType? = null): Flow<List<PluginWithState>> fun getPluginsWithState(
type: PluginType? = null,
enabled: Boolean? = null,
): Flow<List<PluginWithState>>
fun isPluginHostInstalled(): Flow<Boolean> fun isPluginHostInstalled(): Flow<Boolean>
@ -127,9 +129,11 @@ internal class PluginServiceImpl(
override fun getPluginsWithState( override fun getPluginsWithState(
type: PluginType?, type: PluginType?,
enabled: Boolean?,
): Flow<List<PluginWithState>> { ): Flow<List<PluginWithState>> {
return repository.findMany( return repository.findMany(
type = type, type = type,
enabled = enabled,
).map { ).map {
it.map { it.map {
PluginWithState( PluginWithState(