Migrate file search preferences

This commit is contained in:
MM20 2022-01-17 00:08:03 +01:00
parent fe2da9a60e
commit d031003845
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
25 changed files with 785 additions and 258 deletions

View File

@ -111,7 +111,6 @@ class PreferencesSearchFragment : PreferenceFragmentCompat() {
val user = client.getLoggedInUser()
if (user == null) {
nextcloudPref.setSummary(R.string.preference_summary_not_logged_in)
LauncherPreferences.instance.searchNextcloud = false
nextcloudPref.setOnPreferenceChangeListener { _, value ->
if (value as Boolean) {
lifecycleScope.launch launch2@{
@ -122,7 +121,7 @@ class PreferencesSearchFragment : PreferenceFragmentCompat() {
}
} else {
nextcloudPref.summary = context?.getString(
R.string.preference_search_nextcloud_summary,
R.string.preference_search_cloud_summary,
user.displayName
)
}
@ -136,7 +135,6 @@ class PreferencesSearchFragment : PreferenceFragmentCompat() {
val user = client.getLoggedInUser()
if (user == null) {
owncloudPref.setSummary(R.string.preference_summary_not_logged_in)
LauncherPreferences.instance.searchOwncloud = false
owncloudPref.setOnPreferenceChangeListener { _, value ->
if (value as Boolean) {
lifecycleScope.launch launch2@{
@ -148,7 +146,7 @@ class PreferencesSearchFragment : PreferenceFragmentCompat() {
}
} else {
owncloudPref.summary = context?.getString(
R.string.preference_search_nextcloud_summary,
R.string.preference_search_cloud_summary,
user.displayName,
)
}

View File

@ -1,14 +1,18 @@
package de.mm20.launcher2.files
import android.content.Context
import de.mm20.launcher2.files.providers.*
import de.mm20.launcher2.files.providers.GDriveFileProvider
import de.mm20.launcher2.files.providers.LocalFileProvider
import de.mm20.launcher2.files.providers.NextcloudFileProvider
import de.mm20.launcher2.files.providers.OwncloudFileProvider
import de.mm20.launcher2.hiddenitems.HiddenItemsRepository
import de.mm20.launcher2.nextcloud.NextcloudApiHelper
import de.mm20.launcher2.owncloud.OwncloudClient
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.search.data.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.*
interface FileRepository {
fun search(query: String): Flow<List<File>>
@ -17,11 +21,16 @@ interface FileRepository {
class FileRepositoryImpl(
private val context: Context,
hiddenItemsRepository: HiddenItemsRepository
hiddenItemsRepository: HiddenItemsRepository,
private val dataStore: LauncherDataStore
) : FileRepository {
private val scope = CoroutineScope(Job() + Dispatchers.Default)
private val hiddenItems = hiddenItemsRepository.hiddenItemsKeys
private val providers = MutableStateFlow<List<FileProvider>>(emptyList())
private val nextcloudClient by lazy {
NextcloudApiHelper(context)
}
@ -29,13 +38,59 @@ class FileRepositoryImpl(
OwncloudClient(context)
}
init {
scope.launch {
dataStore.data.map { it.fileSearch }.distinctUntilChanged().collectLatest {
val provs = mutableListOf<FileProvider>()
if (it.localFiles) {
provs += LocalFileProvider(context)
}
if (it.nextcloud) {
provs += NextcloudFileProvider(nextcloudClient)
}
if (it.owncloud) {
provs += OwncloudFileProvider(owncloudClient)
}
if (it.gdrive) {
provs += GDriveFileProvider(context)
}
if (it.onedrive) {
provs += OneDriveFileProvider(context)
}
providers.value = provs
}
}
}
override fun search(query: String): Flow<List<File>> = channelFlow {
if (query.isBlank()) {
send(emptyList())
return@channelFlow
}
hiddenItems.collectLatest { hiddenItems ->
//TODO SearchListView crashes if we send too many updates at once. Rewrite this code
// once SearchListView has been replaced with a Jetpack Compose version of itself
providers.collectLatest { providers ->
hiddenItems.collectLatest { hiddenItems ->
if (providers.first() is LocalFileProvider) {
val localFiles = providers.first().takeIf { it is LocalFileProvider }?.search(query) ?: emptyList()
delay(300)
if (providers.size > 1) {
val cloudFiles = providers.subList(1, providers.size).map {
async { it.search(query) }
}.awaitAll().flatten()
send(localFiles + cloudFiles)
}
} else {
val files = providers.map {
async { it.search(query) }
}.awaitAll().flatten()
send(files)
}
}
}
/*hiddenItems.collectLatest { hiddenItems ->
val files = mutableListOf<File>()
val localFiles = withContext(Dispatchers.IO) {
@ -56,7 +111,7 @@ class FileRepositoryImpl(
yield()
files.addAll(cloudFiles.filter { !hiddenItems.contains(it.key) })
send(files)
}
}*/
}
override suspend fun deleteFile(file: File) {

View File

@ -5,6 +5,6 @@ import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
val filesModule = module {
single<FileRepository> { FileRepositoryImpl(androidContext(), get()) }
single<FileRepository> { FileRepositoryImpl(androidContext(), get(), get()) }
viewModel { FilesViewModel(get()) }
}

View File

@ -0,0 +1,7 @@
package de.mm20.launcher2.files.providers
import de.mm20.launcher2.search.data.File
interface FileProvider {
suspend fun search(query: String): List<File>
}

View File

@ -0,0 +1,40 @@
package de.mm20.launcher2.files.providers
import android.content.Context
import de.mm20.launcher2.files.R
import de.mm20.launcher2.gservices.DriveFileMeta
import de.mm20.launcher2.gservices.GoogleApiHelper
import de.mm20.launcher2.search.data.File
import de.mm20.launcher2.search.data.GDriveFile
internal class GDriveFileProvider(
private val context: Context
) : FileProvider {
override suspend fun search(query: String): List<File> {
if (query.length < 4) return emptyList()
val driveFiles = GoogleApiHelper.getInstance(context).queryGDriveFiles(query)
return driveFiles.map {
GDriveFile(
fileId = it.fileId,
label = it.label,
size = it.size,
mimeType = it.mimeType,
isDirectory = it.isDirectory,
path = "",
directoryColor = it.directoryColor,
viewUri = it.viewUri,
metaData = getMetadata(it.metadata)
)
}.sorted()
}
private fun getMetadata(file: DriveFileMeta): List<Pair<Int, String>> {
val metaData = mutableListOf<Pair<Int, String>>()
val owners = file.owners
metaData.add(R.string.file_meta_owner to owners.joinToString(separator = ", "))
val width = file.width ?: file.width
val height = file.height ?: file.height
if (width != null && height != null) metaData.add(R.string.file_meta_dimensions to "${width}x$height")
return metaData
}
}

View File

@ -0,0 +1,59 @@
package de.mm20.launcher2.files.providers
import android.content.Context
import android.provider.MediaStore
import androidx.core.database.getStringOrNull
import de.mm20.launcher2.search.data.File
import de.mm20.launcher2.search.data.LocalFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
internal class LocalFileProvider(
private val context: Context
): FileProvider {
override suspend fun search(query: String): List<File> = withContext(Dispatchers.IO) {
val results = mutableListOf<LocalFile>()
val uri = MediaStore.Files.getContentUri("external").buildUpon()
.appendQueryParameter("limit", "10").build()
val projection = arrayOf(
MediaStore.Files.FileColumns.DISPLAY_NAME,
MediaStore.Files.FileColumns._ID,
MediaStore.Files.FileColumns.SIZE,
MediaStore.Files.FileColumns.DATA,
MediaStore.Files.FileColumns.MIME_TYPE
)
val selection =
if (query.length > 3) "${MediaStore.Files.FileColumns.TITLE} LIKE ?" else "${MediaStore.Files.FileColumns.TITLE} = ?"
val selArgs = if (query.length > 3) arrayOf("%$query%") else arrayOf(query)
val sort = "${MediaStore.Files.FileColumns.DISPLAY_NAME} COLLATE NOCASE ASC"
val cursor = context.contentResolver.query(uri, projection, selection, selArgs, sort)
?: return@withContext results
while (cursor.moveToNext()) {
if (results.size >= 10) {
break
}
val path = cursor.getString(3)
if (!java.io.File(path).exists()) continue
val directory = java.io.File(path).isDirectory
val mimeType = (cursor.getStringOrNull(4)
?: if (directory) "inode/directory" else LocalFile.getMimetypeByFileExtension(
path.substringAfterLast(
'.'
)
))
val file = LocalFile(
path = path,
mimeType = mimeType,
size = cursor.getLong(2),
isDirectory = directory,
id = cursor.getLong(1),
metaData = LocalFile.getMetaData(context, mimeType, path)
)
results.add(file)
}
cursor.close()
return@withContext results.sortedBy { it }
}
}

View File

@ -0,0 +1,33 @@
package de.mm20.launcher2.files.providers
import de.mm20.launcher2.files.R
import de.mm20.launcher2.nextcloud.NextcloudApiHelper
import de.mm20.launcher2.search.data.File
import de.mm20.launcher2.search.data.NextcloudFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.math.min
internal class NextcloudFileProvider(
private val nextcloudClient: NextcloudApiHelper
) : FileProvider {
override suspend fun search(query: String): List<File> {
if (query.length < 4) return emptyList()
val server = nextcloudClient.getServer() ?: return emptyList()
return withContext(Dispatchers.IO) {
nextcloudClient.files.search(query).let { it.subList(0, min(10, it.size)) }.map {
NextcloudFile(
fileId = it.id,
label = it.name,
path = server + it.url,
mimeType = it.mimeType,
size = it.size,
isDirectory = it.isDirectory,
server = server,
metaData = it.owner?.let { listOf(R.string.file_meta_owner to it) }
?: emptyList()
)
}
}
}
}

View File

@ -0,0 +1,48 @@
package de.mm20.launcher2.files.providers
import android.content.Context
import de.mm20.launcher2.files.R
import de.mm20.launcher2.msservices.DriveItem
import de.mm20.launcher2.msservices.MicrosoftGraphApiHelper
import de.mm20.launcher2.preferences.LauncherPreferences
import de.mm20.launcher2.search.data.File
import de.mm20.launcher2.search.data.OneDriveFile
internal class OneDriveFileProvider(
private val context: Context
): FileProvider {
override suspend fun search(query: String): List<File> {
if (query.length < 4) return emptyList()
val driveItems = MicrosoftGraphApiHelper.getInstance(context).queryOneDriveFiles(query) ?: return emptyList()
val files = mutableListOf<OneDriveFile>()
for (driveItem in driveItems) {
files += OneDriveFile(
fileId = driveItem.id,
label = driveItem.label,
path = "",
mimeType = driveItem.mimeType,
size = driveItem.size,
isDirectory = driveItem.isDirectory,
metaData = getMetaData(driveItem),
webUrl = driveItem.webUrl
)
}
return files.sorted()
}
private fun getMetaData(driveItem: DriveItem): List<Pair<Int, String>> {
val metaData = mutableListOf<Pair<Int, String>>()
driveItem.meta.owner?.let {
metaData.add(R.string.file_meta_owner to it)
} ?: driveItem.meta.createdBy?.let {
metaData.add(R.string.file_meta_owner to it)
}
val width = driveItem.meta.width
val height = driveItem.meta.height
if (width != null && height != null) {
metaData.add(R.string.file_meta_dimensions to "${width}x${height}")
}
return metaData
}
}

View File

@ -0,0 +1,29 @@
package de.mm20.launcher2.files.providers
import de.mm20.launcher2.files.R
import de.mm20.launcher2.helper.NetworkUtils
import de.mm20.launcher2.owncloud.OwncloudClient
import de.mm20.launcher2.preferences.LauncherPreferences
import de.mm20.launcher2.search.data.File
import de.mm20.launcher2.search.data.OwncloudFile
internal class OwncloudFileProvider(
private val owncloudClient: OwncloudClient
): FileProvider {
override suspend fun search(query: String): List<File> {
if (query.length < 4) return emptyList()
val server = owncloudClient.getServer() ?: return emptyList()
return owncloudClient.files.query(query).map {
OwncloudFile(
fileId = it.id,
label = it.name,
path = server + it.url,
mimeType = it.mimeType,
size = it.size,
isDirectory = it.isDirectory,
server = server,
metaData = it.owner?.let { listOf(R.string.file_meta_owner to it) } ?: emptyList()
)
}
}
}

View File

@ -39,37 +39,4 @@ class GDriveFile(
override suspend fun loadIcon(context: Context, size: Int): LauncherIcon? {
return null
}
companion object {
suspend fun search(context: Context, query: String): List<File> {
if (query.length < 4) return emptyList()
val prefs = LauncherPreferences.instance
if (!prefs.searchGDrive) return emptyList()
if (NetworkUtils.isOffline(context, prefs.searchGDriveMobileData)) return emptyList()
val driveFiles = GoogleApiHelper.getInstance(context).queryGDriveFiles(query)
return driveFiles.map {
GDriveFile(
fileId = it.fileId,
label = it.label,
size = it.size,
mimeType = it.mimeType,
isDirectory = it.isDirectory,
path = "",
directoryColor = it.directoryColor,
viewUri = it.viewUri,
metaData = getMetadata(it.metadata)
)
}.sorted()
}
private fun getMetadata(file: DriveFileMeta): List<Pair<Int, String>> {
val metaData = mutableListOf<Pair<Int, String>>()
val owners = file.owners
metaData.add(R.string.file_meta_owner to owners.joinToString(separator = ", "))
val width = file.width ?: file.width
val height = file.height ?: file.height
if (width != null && height != null) metaData.add(R.string.file_meta_dimensions to "${width}x$height")
return metaData
}
}
}

View File

@ -163,59 +163,6 @@ open class LocalFile(
companion object: KoinComponent {
fun search(context: Context, query: String): List<LocalFile> {
val results = mutableListOf<LocalFile>()
if (!LauncherPreferences.instance.searchFiles) return results
if (query.isBlank()) return results
val permissionsManager: PermissionsManager = get()
if (!permissionsManager.checkPermissionOnce(
PermissionGroup.ExternalStorage
)
) return results
val uri = MediaStore.Files.getContentUri("external").buildUpon()
.appendQueryParameter("limit", "10").build()
val projection = arrayOf(
MediaStore.Files.FileColumns.DISPLAY_NAME,
MediaStore.Files.FileColumns._ID,
MediaStore.Files.FileColumns.SIZE,
MediaStore.Files.FileColumns.DATA,
MediaStore.Files.FileColumns.MIME_TYPE
)
val selection =
if (query.length > 3) "${MediaStore.Files.FileColumns.TITLE} LIKE ?" else "${MediaStore.Files.FileColumns.TITLE} = ?"
val selArgs = if (query.length > 3) arrayOf("%$query%") else arrayOf(query)
val sort = "${MediaStore.Files.FileColumns.DISPLAY_NAME} COLLATE NOCASE ASC"
val cursor = context.contentResolver.query(uri, projection, selection, selArgs, sort)
?: return results
while (cursor.moveToNext()) {
if (results.size >= 10) {
break
}
val path = cursor.getString(3)
if (!JavaIOFile(path).exists()) continue
val directory = JavaIOFile(path).isDirectory
val mimeType = (cursor.getStringOrNull(4)
?: if (directory) "inode/directory" else getMimetypeByFileExtension(
path.substringAfterLast(
'.'
)
))
val file = LocalFile(
path = path,
mimeType = mimeType,
size = cursor.getLong(2),
isDirectory = directory,
id = cursor.getLong(1),
metaData = getMetaData(context, mimeType, path)
)
results.add(file)
}
cursor.close()
return results.sortedBy { it }
}
internal fun getMimetypeByFileExtension(extension: String): String {
return when (extension) {
"apk" -> "application/vnd.android.package-archive"

View File

@ -31,26 +31,4 @@ class NextcloudFile(
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
}
companion object {
suspend fun search(context: Context, query: String, nextcloudClient: NextcloudApiHelper) : List<NextcloudFile> {
if (!LauncherPreferences.instance.searchNextcloud) return emptyList()
if (query.length < 4) return emptyList()
val server = nextcloudClient.getServer() ?: return emptyList()
if (NetworkUtils.isOffline(context, LauncherPreferences.instance.searchGDriveMobileData)) return emptyList()
return nextcloudClient.files.search(query).map {
NextcloudFile(
fileId = it.id,
label = it.name,
path = server + it.url,
mimeType = it.mimeType,
size = it.size,
isDirectory = it.isDirectory,
server = server,
metaData = it.owner?.let { listOf(R.string.file_meta_owner to it) } ?: emptyList()
)
}
}
}
}

View File

@ -36,42 +36,4 @@ class OneDriveFile(
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
}
companion object {
suspend fun search(context: Context, query: String): List<File> {
if (query.length < 4) return emptyList()
if (!LauncherPreferences.instance.searchOneDrive) return emptyList()
val driveItems = MicrosoftGraphApiHelper.getInstance(context).queryOneDriveFiles(query) ?: return emptyList()
val files = mutableListOf<OneDriveFile>()
for (driveItem in driveItems) {
files += OneDriveFile(
fileId = driveItem.id,
label = driveItem.label,
path = "",
mimeType = driveItem.mimeType,
size = driveItem.size,
isDirectory = driveItem.isDirectory,
metaData = getMetaData(driveItem),
webUrl = driveItem.webUrl
)
}
return files.sorted()
}
private fun getMetaData(driveItem: DriveItem): List<Pair<Int, String>> {
val metaData = mutableListOf<Pair<Int, String>>()
driveItem.meta.owner?.let {
metaData.add(R.string.file_meta_owner to it)
} ?: driveItem.meta.createdBy?.let {
metaData.add(R.string.file_meta_owner to it)
}
val width = driveItem.meta.width
val height = driveItem.meta.height
if (width != null && height != null) {
metaData.add(R.string.file_meta_dimensions to "${width}x${height}")
}
return metaData
}
}
}

View File

@ -31,26 +31,4 @@ class OwncloudFile(
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
}
companion object {
suspend fun search(context: Context, query: String, owncloudClient: OwncloudClient) : List<OwncloudFile> {
if (!LauncherPreferences.instance.searchOwncloud) return emptyList()
if (query.length < 4) return emptyList()
val server = owncloudClient.getServer() ?: return emptyList()
if (NetworkUtils.isOffline(context, LauncherPreferences.instance.searchGDriveMobileData)) return emptyList()
return owncloudClient.files.query(query).map {
OwncloudFile(
fileId = it.id,
label = it.name,
path = server + it.url,
mimeType = it.mimeType,
size = it.size,
isDirectory = it.isDirectory,
server = server,
metaData = it.owner?.let { listOf(R.string.file_meta_owner to it) } ?: emptyList()
)
}
}
}
}

View File

@ -181,9 +181,6 @@
<string name="preference_signin_user">Angemeldet als %1$s</string>
<string name="preference_signin_logout">Abmelden</string>
<string name="file_meta_owner">Eigentümer</string>
<string name="preference_search_gdrive">Google Drive</string>
<string name="preference_summary_not_logged_in">Sie sind im Moment nicht angemeldet</string>
<string name="preference_search_gdrive_summary">%1$ss Dateien auf Google Drive durchsuchen</string>
<string name="file_type_drawing">Zeichnung</string>
<string name="file_type_ebook">E-Book</string>
<string name="file_type_form">Formular</string>
@ -311,10 +308,6 @@
<string name="preference_nextcloud_signin">Bei Nextcloud anmelden</string>
<string name="preference_nextcloud_signin_summary">Anmelden, um Ihren Nextcloud-Server durchsuchen zu können</string>
<string name="preference_account_checking_status">Status wird abgerufen…</string>
<string name="preference_search_onedrive">OneDrive durchsuchen</string>
<string name="preference_search_onedrive_summary">%1$ss Dateien auf OneDrive durchsuchen</string>
<string name="preference_search_nextcloud">Nextcloud durchsuchen</string>
<string name="preference_search_nextcloud_summary">%1$ss Dateien durchsuchen</string>
<string name="storage_google_drive">Google Drive</string>
<string name="storage_onedrive">OneDrive</string>
<string name="preference_about_telegram">Telegram-Gruppe</string>
@ -346,7 +339,6 @@
<string name="preference_owncloud_signin_summary">Anmelden, um Ihren Owncloud-Server durchsuchen zu können</string>
<string name="owncloud_username_empty">Nutzername darf nicht leer sein</string>
<string name="owncloud_password_empty">Passwort darf nicht leer sein</string>
<string name="preference_search_owncloud">Owncloud durchsuchen</string>
<string name="preference_cards_summary">Erscheinungsbild von Karten anpassen</string>
<string name="preference_cards">Karten</string>
<string name="preference_cards_corner_radius">Eckradius</string>
@ -455,6 +447,16 @@
<string name="preference_search_websearch_summary">Shortcuts zu verschiedenen Websuch-Engines anzeigen</string>
<string name="preference_screen_buildinfo">Build-Information</string>
<string name="preference_screen_buildinfo_summary">Weitere Informationen über diesen Build dieser App</string>
<string name="preference_search_localfiles">Lokale Dateien</string>
<string name="preference_search_localfiles_summary">Dokumente, Fotos und andere Dateien auf diesem Gerät durchsuchen</string>
<string name="preference_search_owncloud">Owncloud</string>
<string name="preference_search_onedrive">OneDrive</string>
<string name="preference_search_onedrive_summary">%1$ss Dateien auf OneDrive durchsuchen</string>
<string name="preference_search_nextcloud">Nextcloud</string>
<string name="preference_search_cloud_summary">%1$ss Dateien durchsuchen</string>
<string name="preference_search_gdrive">Google Drive</string>
<string name="preference_summary_not_logged_in">Sie sind im Moment nicht angemeldet</string>
<string name="preference_search_gdrive_summary">%1$ss Dateien auf Google Drive durchsuchen</string>
<string name="preference_wikipedia_customurl">Wikipedia-URL</string>
@ -462,4 +464,12 @@
<string name="music_widget_no_data">Bisher wurden keine Medien abgespielt</string>
<string name="missing_permission_calendar_widget_settings">Kalenderzugriff wird benötigt um Termine abzurufen</string>
<string name="missing_permission_file_search">Speicher-Berechtigung wird benötigt um lokale Dateien zu durchsuchen</string>
<string name="missing_permission_file_search_android10">Alle Dateien verwalten-Berechtigung wird benötigt um lokale Dateien zu durchsuchen</string>
<string name="no_account_nextcloud">Sie haben noch kein Nextcloud-Konto verbunden</string>
<string name="no_account_owncloud">Sie haben noch kein Owncloud-Konto verbunden</string>
<string name="no_account_microsoft">Sie haben noch kein Microsoft-Konto verbunden</string>
<string name="no_account_google">Sie haben noch kein Google-Konto verbunden</string>
<string name="connect_account">Konto verbinden</string>
</resources>

View File

@ -228,9 +228,7 @@
<string name="preference_signin_user">Signed in as %1$s</string>
<string name="preference_signin_logout">Log out</string>
<string name="file_meta_owner">Owner</string>
<string name="preference_search_gdrive">Google Drive</string>
<string name="preference_summary_not_logged_in">You are currently not logged in</string>
<string name="preference_search_gdrive_summary">Search %1$s\'s files on Google Drive</string>
<string name="file_type_ebook">E-book</string>
<string name="file_type_drawing">Drawing</string>
<string name="file_type_form">Form</string>
@ -310,10 +308,6 @@
<string name="preference_nextcloud_signin">Sign in to Nextcloud</string>
<string name="preference_nextcloud_signin_summary">Sign in to search your Nextcloud server</string>
<string name="preference_account_checking_status">Checking status…</string>
<string name="preference_search_onedrive">Search OneDrive</string>
<string name="preference_search_onedrive_summary">Search %1$s\'s files on OneDrive</string>
<string name="preference_search_nextcloud">Search Nextcloud</string>
<string name="preference_search_nextcloud_summary">Search %1$s\'s files</string>
<string name="storage_google_drive">Google Drive</string>
<string name="storage_onedrive">OneDrive</string>
<string name="preference_about_telegram">Telegram group</string>
@ -346,7 +340,6 @@
<string name="preference_owncloud_signin_summary">Sign in to search your Owncloud server</string>
<string name="owncloud_username_empty">User name must not be empty</string>
<string name="owncloud_password_empty">Password must not be empty</string>
<string name="preference_search_owncloud">Search Owncloud</string>
<string name="preference_cards_summary">Customize card appearance</string>
<string name="preference_cards">Cards</string>
<string name="preference_cards_corner_radius">Corner radius</string>
@ -439,6 +432,8 @@
<string name="missing_permission_contact_search">Contact permission is required to search contacts</string>
<string name="missing_permission_calendar_search">Calendar permission is required to search calendar</string>
<string name="missing_permission_calendar_widget_settings">This widget requires calendar permission</string>
<string name="missing_permission_file_search_android10">Manage all files permission is required to search local files</string>
<string name="missing_permission_file_search">External storage permission is required to search local files</string>
<string name="weather_widget_set_location">Set location</string>
<string name="preference_screen_debug">Debug</string>
@ -481,6 +476,15 @@
<string name="preference_search_websites_summary">Show a preview of a website if the search query is a URL</string>
<string name="preference_search_websearch">Web search</string>
<string name="preference_search_websearch_summary">Show shortcuts to different search engines</string>
<string name="preference_search_localfiles">Local files</string>
<string name="preference_search_localfiles_summary">Search documents, photos and other files stored on this device</string>
<string name="preference_search_gdrive">Google Drive</string>
<string name="preference_search_gdrive_summary">Search %1$s\'s files on Google Drive</string>
<string name="preference_search_onedrive">OneDrive</string>
<string name="preference_search_onedrive_summary">Search %1$s\'s files on OneDrive</string>
<string name="preference_search_nextcloud">Nextcloud</string>
<string name="preference_search_cloud_summary">Search %1$s\'s files</string>
<string name="preference_search_owncloud">Owncloud</string>
<string name="preference_screen_calendarwidget">Calendar</string>
<string name="preference_calendar_calendars">Calendars</string>
@ -501,4 +505,10 @@
<string name="music_widget_default_title">%1$s is playing media</string>
<string name="music_widget_no_data">No media has been played yet</string>
<string name="no_account_nextcloud">You haven\'t connected a Nextcloud account yet</string>
<string name="no_account_owncloud">You haven\'t connected an Owncloud account yet</string>
<string name="no_account_microsoft">You haven\'t connected a Microsoft account yet</string>
<string name="no_account_google">You haven\'t connected a Google account yet</string>
<string name="connect_account">Connect account</string>
</resources>

View File

@ -9,14 +9,12 @@ import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.Settings
import android.util.Log
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationManagerCompat
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.ktx.checkPermission
import de.mm20.launcher2.ktx.isAtLeastApiLevel
import de.mm20.launcher2.ktx.tryStartActivity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@ -36,6 +34,10 @@ interface PermissionsManager {
grantResults: IntArray
)
fun onResume() {
}
fun hasPermission(permissionGroup: PermissionGroup): Flow<Boolean>
/**
@ -57,6 +59,8 @@ class PermissionsManagerImpl(
private val context: Context
) : PermissionsManager {
private val pendingPermissionRequests = mutableSetOf<PermissionGroup>()
private val calendarPermissionState = MutableStateFlow(
checkPermissionOnce(PermissionGroup.Calendar)
)
@ -71,25 +75,25 @@ class PermissionsManagerImpl(
)
private val notificationsPermissionState = MutableStateFlow(false)
override fun requestPermission(activity: AppCompatActivity, permissionGroup: PermissionGroup) {
override fun requestPermission(context: AppCompatActivity, permissionGroup: PermissionGroup) {
when (permissionGroup) {
PermissionGroup.Calendar -> {
ActivityCompat.requestPermissions(
activity,
context,
calendarPermissions,
permissionGroup.ordinal
)
}
PermissionGroup.Location -> {
ActivityCompat.requestPermissions(
activity,
context,
locationPermissions,
permissionGroup.ordinal
)
}
PermissionGroup.Contacts -> {
ActivityCompat.requestPermissions(
activity,
context,
contactPermissions,
permissionGroup.ordinal
)
@ -98,12 +102,13 @@ class PermissionsManagerImpl(
if (isAtLeastApiLevel(Build.VERSION_CODES.R)) {
val intent =
Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).also {
it.data = Uri.parse("package:${activity.packageName}")
it.data = Uri.parse("package:${context.packageName}")
}
activity.startActivity(intent)
context.tryStartActivity(intent)
pendingPermissionRequests.add(PermissionGroup.ExternalStorage)
} else {
ActivityCompat.requestPermissions(
activity,
context,
externalStoragePermissions,
permissionGroup.ordinal
)
@ -111,7 +116,7 @@ class PermissionsManagerImpl(
}
PermissionGroup.Notifications -> {
try {
activity.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
} catch (e: ActivityNotFoundException) {
CrashReporter.logException(e)
}
@ -169,6 +174,20 @@ class PermissionsManagerImpl(
}
}
override fun onResume() {
val iterator = pendingPermissionRequests.iterator()
while (iterator.hasNext()) {
when (iterator.next()) {
PermissionGroup.ExternalStorage -> {
externalStoragePermissionState.value =
checkPermissionOnce(PermissionGroup.ExternalStorage)
}
else -> {}
}
iterator.remove()
}
}
override fun reportNotificationListenerState(running: Boolean) {
notificationsPermissionState.value = running
}

View File

@ -58,11 +58,6 @@ class LauncherPreferences(val context: Application, version: Int = 3) {
var searchActivities by BooleanPreference("search_activities", default = true)
var searchCalendars by BooleanPreference("search_calendars", default = true)
var searchContacts by BooleanPreference("search_contacts", default = true)
var searchOwncloud by BooleanPreference("search_owncloud", default = false)
var searchNextcloud by BooleanPreference("search_nextcloud", default = false)
var searchOneDrive by BooleanPreference("search_onedrive", default = false)
var searchGDrive by BooleanPreference("search_gdrive", default = false)
var searchGDriveMobileData by BooleanPreference("search_gdrive_mobile_data", default = false)
var profileBadges by BooleanPreference("profile_badges", default = true)

View File

@ -2,10 +2,9 @@ package de.mm20.launcher2.ui.base
import androidx.appcompat.app.AppCompatActivity
import de.mm20.launcher2.permissions.PermissionsManager
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koin.android.ext.android.inject
abstract class BaseActivity : AppCompatActivity(), KoinComponent {
abstract class BaseActivity : AppCompatActivity() {
private val permissionsManager: PermissionsManager by inject()
override fun onRequestPermissionsResult(
@ -16,4 +15,9 @@ abstract class BaseActivity : AppCompatActivity(), KoinComponent {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
permissionsManager.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
override fun onResume() {
super.onResume()
permissionsManager.onResume()
}
}

View File

@ -0,0 +1,66 @@
package de.mm20.launcher2.ui.component
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@Composable
fun Banner(
modifier: Modifier = Modifier,
text: String,
icon: ImageVector,
primaryAction: @Composable () -> Unit,
secondaryAction: @Composable () -> Unit = {}
) {
Surface(
modifier = modifier,
color = MaterialTheme.colorScheme.secondaryContainer,
shape = RoundedCornerShape(8.dp),
shadowElevation = 2.dp,
tonalElevation = 2.dp
) {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier.padding(16.dp),
imageVector = icon,
contentDescription = null
)
Text(
text = text,
modifier = Modifier
.weight(1f)
.padding(vertical = 16.dp)
.padding(end = 16.dp),
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium)
)
}
Row(
Modifier
.align(Alignment.End)
.padding(8.dp)
) {
Box {
secondaryAction()
}
Box(modifier = Modifier.padding(start = 8.dp)) {
primaryAction()
}
}
}
}
}

View File

@ -1,18 +1,14 @@
package de.mm20.launcher2.ui.component
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Lock
import androidx.compose.material3.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.ui.R
@ -23,46 +19,21 @@ fun MissingPermissionBanner(
onClick: () -> Unit,
secondaryAction: @Composable () -> Unit = {}
) {
Surface(
Banner(
modifier = modifier,
color = MaterialTheme.colorScheme.secondaryContainer,
shape = RoundedCornerShape(8.dp),
shadowElevation = 2.dp,
tonalElevation = 2.dp
) {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
text = text,
icon = Icons.Rounded.Lock,
primaryAction = {
TextButton(
modifier = Modifier.padding(start = 8.dp),
onClick = onClick
) {
Icon(
modifier = Modifier.padding(16.dp),
imageVector = Icons.Rounded.Lock,
contentDescription = null
)
Text(
text = text,
modifier = Modifier
.weight(1f)
.padding(vertical = 16.dp)
.padding(end = 16.dp),
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium)
stringResource(R.string.grant_permission),
style = MaterialTheme.typography.labelLarge
)
}
Row(
Modifier
.align(Alignment.End)
.padding(8.dp)
) {
secondaryAction()
TextButton(
modifier = Modifier.padding(start = 8.dp),
onClick = onClick) {
Text(stringResource(R.string.grant_permission), style = MaterialTheme.typography.labelLarge)
}
}
}
}
},
secondaryAction = secondaryAction
)
}

View File

@ -33,6 +33,7 @@ import de.mm20.launcher2.ui.settings.musicwidget.MusicWidgetSettingsScreen
import de.mm20.launcher2.ui.settings.search.SearchSettingsScreen
import de.mm20.launcher2.ui.settings.accounts.AccountsSettingsScreen
import de.mm20.launcher2.ui.settings.buildinfo.BuildInfoSettingsScreen
import de.mm20.launcher2.ui.settings.filesearch.FileSearchSettingsScreen
import de.mm20.launcher2.ui.settings.weatherwidget.WeatherWidgetSettingsScreen
import de.mm20.launcher2.ui.settings.widgets.WidgetsSettingsScreen
import de.mm20.launcher2.ui.settings.wikipedia.WikipediaSettingsScreen
@ -97,6 +98,9 @@ class SettingsActivity : BaseActivity() {
composable("settings/search/wikipedia") {
WikipediaSettingsScreen()
}
composable("settings/search/files") {
FileSearchSettingsScreen()
}
composable("settings/widgets") {
WidgetsSettingsScreen()
}

View File

@ -0,0 +1,216 @@
package de.mm20.launcher2.ui.settings.filesearch
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.material.LinearProgressIndicator
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.AccountBox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
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.repeatOnLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.accounts.AccountType
import de.mm20.launcher2.ktx.isAtLeastApiLevel
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.Banner
import de.mm20.launcher2.ui.component.MissingPermissionBanner
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
import de.mm20.launcher2.ui.component.preferences.SwitchPreference
@Composable
fun FileSearchSettingsScreen() {
val viewModel: FileSearchSettingsScreenVM = viewModel()
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(null) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
viewModel.onResume()
}
}
val loading by viewModel.loading.observeAsState()
PreferenceScreen(title = stringResource(R.string.preference_search_files)) {
if (loading == true) {
item {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth()
)
}
return@PreferenceScreen
}
item {
PreferenceCategory {
val localFiles by viewModel.localFiles.observeAsState()
val hasFilePermission by viewModel.hasFilePermission.observeAsState()
AnimatedVisibility(hasFilePermission == false) {
MissingPermissionBanner(
text = stringResource(
if (isAtLeastApiLevel(29)) R.string.missing_permission_file_search_android10 else R.string.missing_permission_file_search
), onClick = {
viewModel.requestFilePermission(context as AppCompatActivity)
},
modifier = Modifier.padding(16.dp)
)
}
SwitchPreference(
title = stringResource(R.string.preference_search_localfiles),
summary = stringResource(R.string.preference_search_localfiles_summary),
value = localFiles == true && hasFilePermission == true,
onValueChanged = {
viewModel.setLocalFiles(it)
},
enabled = hasFilePermission == true
)
val nextcloud by viewModel.nextcloud.observeAsState()
val nextcloudAccount by viewModel.nextcloudAccount.observeAsState()
AnimatedVisibility(nextcloudAccount == null) {
Banner(
text = stringResource(R.string.no_account_nextcloud),
icon = Icons.Rounded.AccountBox,
primaryAction = {
TextButton(onClick = {
viewModel.login(
context as AppCompatActivity,
AccountType.Nextcloud
)
}) {
Text(
stringResource(R.string.connect_account),
style = MaterialTheme.typography.labelLarge
)
}
},
modifier = Modifier.padding(16.dp)
)
}
SwitchPreference(
title = stringResource(R.string.preference_search_nextcloud),
summary = nextcloudAccount?.let {
stringResource(R.string.preference_search_cloud_summary, it.userName)
} ?: stringResource(R.string.preference_summary_not_logged_in),
value = nextcloud == true && nextcloudAccount != null,
onValueChanged = {
viewModel.setNextcloud(it)
},
enabled = nextcloudAccount != null
)
val owncloud by viewModel.owncloud.observeAsState()
val owncloudAccount by viewModel.owncloudAccount.observeAsState()
AnimatedVisibility(owncloudAccount == null) {
Banner(
text = stringResource(R.string.no_account_owncloud),
icon = Icons.Rounded.AccountBox,
primaryAction = {
TextButton(onClick = {
viewModel.login(
context as AppCompatActivity,
AccountType.Owncloud
)
}) {
Text(
stringResource(R.string.connect_account),
style = MaterialTheme.typography.labelLarge
)
}
},
modifier = Modifier.padding(16.dp)
)
}
SwitchPreference(
title = stringResource(R.string.preference_search_owncloud),
summary = owncloudAccount?.let {
stringResource(R.string.preference_search_cloud_summary, it.userName)
} ?: stringResource(R.string.preference_summary_not_logged_in),
value = owncloud == true && owncloudAccount != null,
onValueChanged = {
viewModel.setOwncloud(it)
},
enabled = owncloudAccount != null
)
val onedrive by viewModel.onedrive.observeAsState()
val microsoftAccount by viewModel.microsoftAccount.observeAsState()
AnimatedVisibility(microsoftAccount == null) {
Banner(
text = stringResource(R.string.no_account_microsoft),
icon = Icons.Rounded.AccountBox,
primaryAction = {
TextButton(onClick = {
viewModel.login(
context as AppCompatActivity,
AccountType.Microsoft
)
}) {
Text(
stringResource(R.string.connect_account),
style = MaterialTheme.typography.labelLarge
)
}
},
modifier = Modifier.padding(16.dp)
)
}
SwitchPreference(
title = stringResource(R.string.preference_search_onedrive),
summary = microsoftAccount?.let {
stringResource(R.string.preference_search_onedrive_summary, it.userName)
} ?: stringResource(R.string.preference_summary_not_logged_in),
value = onedrive == true && microsoftAccount != null,
onValueChanged = {
viewModel.setOneDrive(it)
},
enabled = microsoftAccount != null
)
val gdrive by viewModel.gdrive.observeAsState()
val googleAccount by viewModel.googleAccount.observeAsState()
AnimatedVisibility(googleAccount == null) {
Banner(
text = stringResource(R.string.no_account_google),
icon = Icons.Rounded.AccountBox,
primaryAction = {
TextButton(onClick = {
viewModel.login(
context as AppCompatActivity,
AccountType.Google
)
}) {
Text(
stringResource(R.string.connect_account),
style = MaterialTheme.typography.labelLarge
)
}
},
modifier = Modifier.padding(16.dp)
)
}
SwitchPreference(
title = stringResource(R.string.preference_search_gdrive),
summary = googleAccount?.let {
stringResource(R.string.preference_search_gdrive_summary, it.userName)
} ?: stringResource(R.string.preference_summary_not_logged_in),
value = gdrive == true && googleAccount != null,
onValueChanged = {
viewModel.setGdrive(it)
},
enabled = googleAccount != null
)
}
}
}
}

View File

@ -0,0 +1,128 @@
package de.mm20.launcher2.ui.settings.filesearch
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.accounts.Account
import de.mm20.launcher2.accounts.AccountType
import de.mm20.launcher2.accounts.AccountsRepository
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.LauncherDataStore
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class FileSearchSettingsScreenVM : ViewModel(), KoinComponent {
private val dataStore: LauncherDataStore by inject()
private val accountsRepository: AccountsRepository by inject()
private val permissionsManager: PermissionsManager by inject()
val hasFilePermission =
permissionsManager.hasPermission(PermissionGroup.ExternalStorage).asLiveData()
val loading = MutableLiveData(true)
val nextcloudAccount = MutableLiveData<Account?>(null)
val owncloudAccount = MutableLiveData<Account?>(null)
val microsoftAccount = MutableLiveData<Account?>(null)
val googleAccount = MutableLiveData<Account?>(null)
fun onResume() {
viewModelScope.launch {
nextcloudAccount.value =
accountsRepository.getCurrentlySignedInAccount(AccountType.Nextcloud)
owncloudAccount.value =
accountsRepository.getCurrentlySignedInAccount(AccountType.Owncloud)
microsoftAccount.value =
accountsRepository.getCurrentlySignedInAccount(AccountType.Microsoft)
googleAccount.value = accountsRepository.getCurrentlySignedInAccount(AccountType.Google)
loading.value = false
}
}
val localFiles = dataStore.data.map { it.fileSearch.localFiles }.asLiveData()
fun setLocalFiles(localFiles: Boolean) {
viewModelScope.launch {
dataStore.updateData {
it.toBuilder()
.setFileSearch(
it.fileSearch
.toBuilder()
.setLocalFiles(localFiles)
)
.build()
}
}
}
val nextcloud = dataStore.data.map { it.fileSearch.nextcloud }.asLiveData()
fun setNextcloud(nextcloud: Boolean) {
viewModelScope.launch {
dataStore.updateData {
it.toBuilder()
.setFileSearch(
it.fileSearch
.toBuilder()
.setNextcloud(nextcloud)
)
.build()
}
}
}
val gdrive = dataStore.data.map { it.fileSearch.gdrive }.asLiveData()
fun setGdrive(gdrive: Boolean) {
viewModelScope.launch {
dataStore.updateData {
it.toBuilder()
.setFileSearch(
it.fileSearch
.toBuilder()
.setGdrive(gdrive)
)
.build()
}
}
}
val onedrive = dataStore.data.map { it.fileSearch.onedrive }.asLiveData()
fun setOneDrive(onedrive: Boolean) {
viewModelScope.launch {
dataStore.updateData {
it.toBuilder()
.setFileSearch(
it.fileSearch
.toBuilder()
.setOnedrive(onedrive)
)
.build()
}
}
}
val owncloud = dataStore.data.map { it.fileSearch.owncloud }.asLiveData()
fun setOwncloud(owncloud: Boolean) {
viewModelScope.launch {
dataStore.updateData {
it.toBuilder()
.setFileSearch(
it.fileSearch
.toBuilder()
.setOwncloud(owncloud)
)
.build()
}
}
}
fun requestFilePermission(context: AppCompatActivity) {
permissionsManager.requestPermission(context, PermissionGroup.ExternalStorage)
}
fun login(context: AppCompatActivity, accountType: AccountType) {
accountsRepository.signin(context, accountType)
}
}

View File

@ -44,7 +44,10 @@ fun SearchSettingsScreen() {
Preference(
title = stringResource(R.string.preference_search_files),
summary = stringResource(R.string.preference_search_files_summary),
icon = Icons.Rounded.Description
icon = Icons.Rounded.Description,
onClick = {
navController?.navigate("settings/search/files")
}
)
val hasContactsPermission by viewModel.hasContactsPermission.observeAsState()