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

View File

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

View File

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

View File

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

View File

@ -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<PluginService>()
private val fileSearchSettings: FileSearchSettings by inject()
private var pluginPackageName = MutableStateFlow<String?>(null)
@ -47,11 +50,17 @@ class PluginSettingsScreenVM : ViewModel(), KoinComponent {
it?.plugins?.map { it.type }?.distinct() ?: emptyList()
}
val states: Flow<List<PluginState?>> = 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)
}
}

View File

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

View File

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

View File

@ -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<File> {
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,
)
)
}

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

View File

@ -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<List<PluginWithState>>
fun getPluginsWithState(
type: PluginType? = null,
enabled: Boolean? = null,
): Flow<List<PluginWithState>>
fun isPluginHostInstalled(): Flow<Boolean>
@ -127,9 +129,11 @@ internal class PluginServiceImpl(
override fun getPluginsWithState(
type: PluginType?,
enabled: Boolean?,
): Flow<List<PluginWithState>> {
return repository.findMany(
type = type,
enabled = enabled,
).map {
it.map {
PluginWithState(