From 0b2aa716ec65299bd5dfdfd9aff383cef45ff7b1 Mon Sep 17 00:00:00 2001 From: Christoph <47949835+Sir-Photch@users.noreply.github.com> Date: Tue, 28 Feb 2023 20:45:25 +0100 Subject: [PATCH] Search: reorder results by launchCount (#267) * add app search result ordering by launchCount * add reordering for other domains * add preference in settings * bruh moment - database optimizaiton - fixing obvious fallacy while reordering * no reordering on empty queries && reordering early return * avoiding deserialization * listpreference * search settings screen icons & categories * regrouping * early-return tweak * Change ranking algorithm * Pepega * Prioritize launchCount over pinned for relevance sorting --------- Co-authored-by: MM20 <15646950+MM2-0@users.noreply.github.com> --- .../ui/launcher/LauncherScaffoldVM.kt | 3 - .../ui/launcher/search/SearchColumn.kt | 1 + .../launcher2/ui/launcher/search/SearchVM.kt | 76 ++++++++++++++++--- .../search/common/SearchableItemVM.kt | 1 - .../settings/search/SearchSettingsScreen.kt | 31 +++++++- .../settings/search/SearchSettingsScreenVM.kt | 27 ++++++- .../de/mm20/launcher2/database/SearchDao.kt | 6 +- core/i18n/src/main/res/values-de/strings.xml | 3 + core/i18n/src/main/res/values/strings.xml | 3 + .../preferences/src/main/proto/settings.proto | 5 ++ .../favorites/FavoritesRepository.kt | 27 +++++-- .../favorites/SavedSearchableRankInfo.kt | 7 ++ 12 files changed, 158 insertions(+), 32 deletions(-) create mode 100644 data/favorites/src/main/java/de/mm20/launcher2/favorites/SavedSearchableRankInfo.kt diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherScaffoldVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherScaffoldVM.kt index 1dc63a11..09e9d9ec 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherScaffoldVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherScaffoldVM.kt @@ -105,12 +105,10 @@ class LauncherScaffoldVM : ViewModel(), KoinComponent { } val wallpaperBlur = dataStore.data.map { it.appearance.blurWallpaper }.asLiveData() - val fillClockHeight = dataStore.data.map { it.clockWidget.fillHeight }.asLiveData() val searchBarColor = dataStore.data.map { it.searchBar.color }.asLiveData() val searchBarStyle = dataStore.data.map { it.searchBar.searchBarStyle }.asLiveData() - val gestureState: StateFlow = dataStore .data.map { it.gestures } .distinctUntilChanged() @@ -155,7 +153,6 @@ class LauncherScaffoldVM : ViewModel(), KoinComponent { ) }.stateIn(viewModelScope, SharingStarted.Eagerly, GestureState()) - var failedGestureState by mutableStateOf(null) fun handleGesture(context: Context, gesture: Gesture): Boolean { val action = when (gesture) { diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt index f8d7a3cc..6b0d155d 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt @@ -198,6 +198,7 @@ fun SearchColumn( highlightedItem = bestMatch as? SavableSearchable ) } + GridResults( items = if ((showWorkProfileApps || apps.isEmpty()) && workApps.isNotEmpty()) workApps.toImmutableList() else apps.toImmutableList(), columns = columns, diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt index f349da99..4f681af0 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt @@ -6,9 +6,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import de.mm20.launcher2.favorites.FavoritesRepository +import de.mm20.launcher2.favorites.SavedSearchableRankInfo import de.mm20.launcher2.permissions.PermissionGroup import de.mm20.launcher2.permissions.PermissionsManager import de.mm20.launcher2.preferences.LauncherDataStore +import de.mm20.launcher2.preferences.Settings.SearchBarSettings.SearchResultOrdering import de.mm20.launcher2.search.SavableSearchable import de.mm20.launcher2.search.SearchService import de.mm20.launcher2.search.Searchable @@ -29,6 +31,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn @@ -103,19 +106,19 @@ class SearchVM : ViewModel(), KoinComponent { } hideFavorites.value = query.isNotEmpty() searchJob = viewModelScope.launch { - dataStore.data.collectLatest { + dataStore.data.collectLatest { settings -> searchService.search( query, - calculator = it.calculatorSearch, - unitConverter = it.unitConverterSearch, - calendars = it.calendarSearch, - contacts = it.contactsSearch, - files = it.fileSearch, - shortcuts = it.appShortcutSearch, - websites = it.websiteSearch, - wikipedia = it.wikipediaSearch, + calculator = settings.calculatorSearch, + unitConverter = settings.unitConverterSearch, + calendars = settings.calendarSearch, + contacts = settings.contactsSearch, + files = settings.fileSearch, + shortcuts = settings.appShortcutSearch, + websites = settings.websiteSearch, + wikipedia = settings.wikipediaSearch, ).collectLatest { results -> - val resultsList = withContext(Dispatchers.Default) { + var resultsList = withContext(Dispatchers.Default) { listOfNotNull( results.apps, results.other, @@ -129,10 +132,42 @@ class SearchVM : ViewModel(), KoinComponent { results.unitConverters, results.searchActions, ).flatten() - .sortedBy { (it as? SavableSearchable) } .distinctBy { if (it is SavableSearchable) it.key else it } + .sortedBy { (it as? SavableSearchable) } } + + val relevance = + if (query.isNotEmpty() && settings.searchBar.searchResultOrdering == SearchResultOrdering.Relevance) { + favoritesRepository.sortByRelevance( + resultsList.mapNotNull { (it as? SavableSearchable)?.key } + ).first() + } else { + emptyList() + } + + resultsList = resultsList.sortedWith { a, b -> + when { + a is SavableSearchable && b !is SavableSearchable -> -1 + a !is SavableSearchable && b is SavableSearchable -> 1 + a is SavableSearchable && b is SavableSearchable -> { + val aKey = a.key + val bKey = b.key + val aRank = relevance.indexOf(aKey) + val bRank = relevance.indexOf(bKey) + when { + aRank != -1 && bRank != -1 -> aRank.compareTo(bRank) + aRank == -1 && bRank != -1 -> 1 + aRank != -1 && bRank == -1 -> -1 + else -> a.compareTo(b) + } + } + + else -> 0 + } + } + + hiddenItemKeys.collectLatest { hiddenKeys -> val hidden = mutableListOf() val apps = mutableListOf() @@ -151,6 +186,7 @@ class SearchVM : ViewModel(), KoinComponent { r is SavableSearchable && hiddenKeys.contains(r.key) -> { hidden.add(r) } + r is LauncherApp && !r.isMainProfile -> workApps.add(r) r is LauncherApp -> apps.add(r) r is AppShortcut -> shortcuts.add(r) @@ -165,7 +201,7 @@ class SearchVM : ViewModel(), KoinComponent { } } - if (query.isNotEmpty() && launchOnEnter.value) { + if (query.isNotEmpty() && launchOnEnter.value) { bestMatch.value = listOf( apps, workApps, @@ -274,4 +310,20 @@ class SearchVM : ViewModel(), KoinComponent { } } } + + private fun MutableList.reorderByRanks(ranks: List) { + if (this.size < 2) // one element does not need reordering + return + + var i = 0 + + for (item in ranks) { + val idx = this.indexOfFirst { it.key == item.key } + if (idx == -1) continue + + this.add(i++, this.removeAt(idx)) + + if (i >= this.size) break + } + } } \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/SearchableItemVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/SearchableItemVM.kt index 709f85b5..e7fa0bcc 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/SearchableItemVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/SearchableItemVM.kt @@ -53,7 +53,6 @@ abstract class SearchableItemVM( return customAttributesRepository.getTags(searchable) } - open fun launch(context: Context, bounds: Rect? = null): Boolean { val view = (context as? AppCompatActivity)?.window?.decorView val options = if (bounds != null && view != null) { diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt index f074f34c..ee041585 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import de.mm20.launcher2.preferences.Settings import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.component.MissingPermissionBanner import de.mm20.launcher2.ui.component.preferences.* @@ -100,7 +101,10 @@ fun SearchSettingsScreen() { val hasAppShortcutsPermission by viewModel.hasAppShortcutPermission.observeAsState() AnimatedVisibility(hasAppShortcutsPermission == false) { MissingPermissionBanner( - text = stringResource(R.string.missing_permission_appshortcuts_search_settings, stringResource(R.string.app_name)), + text = stringResource( + R.string.missing_permission_appshortcuts_search_settings, + stringResource(R.string.app_name) + ), onClick = { viewModel.requestAppShortcutsPermission(context as AppCompatActivity) }, @@ -180,28 +184,48 @@ fun SearchSettingsScreen() { } } item { - val autoFocus by viewModel.autoFocus.observeAsState() - val launchOnEnter by viewModel.launchOnEnter.observeAsState() PreferenceCategory { + val autoFocus by viewModel.autoFocus.observeAsState() SwitchPreference( title = stringResource(R.string.preference_search_bar_auto_focus), summary = stringResource(R.string.preference_search_bar_auto_focus_summary), + icon = Icons.Rounded.Keyboard, value = autoFocus == true, onValueChanged = { viewModel.setAutoFocus(it) } ) + val launchOnEnter by viewModel.launchOnEnter.observeAsState() SwitchPreference( title = stringResource(R.string.preference_search_bar_launch_on_enter), summary = stringResource(R.string.preference_search_bar_launch_on_enter_summary), + icon = Icons.Rounded.ArrowRightAlt, value = launchOnEnter == true, onValueChanged = { viewModel.setLaunchOnEnter(it) } ) + val searchResultOrdering by viewModel.searchResultOrdering.observeAsState() + ListPreference( + title = stringResource(R.string.preference_search_bar_ordering), + value = searchResultOrdering, + icon = Icons.Rounded.Sort, + items = listOf( + stringResource(R.string.preference_search_bar_ordering_alphabetic) to Settings.SearchBarSettings.SearchResultOrdering.Alphabetic, + stringResource(R.string.preference_search_bar_ordering_relevance) to Settings.SearchBarSettings.SearchResultOrdering.Relevance + ), + onValueChanged = { + if (it != null) viewModel.setSearchResultOrdering(it) + } + ) + } + } + item { + PreferenceCategory { Preference( title = stringResource(R.string.preference_hidden_items), summary = stringResource(R.string.preference_hidden_items_summary), + icon = Icons.Rounded.VisibilityOff, onClick = { navController?.navigate("settings/search/hiddenitems") } @@ -209,6 +233,7 @@ fun SearchSettingsScreen() { Preference( title = stringResource(R.string.preference_screen_tags), summary = stringResource(R.string.preference_screen_tags_summary), + icon = Icons.Rounded.Tag, onClick = { navController?.navigate("settings/search/tags") } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreenVM.kt index ff20495e..8f48f2e6 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreenVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreenVM.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope import de.mm20.launcher2.permissions.PermissionGroup import de.mm20.launcher2.permissions.PermissionsManager import de.mm20.launcher2.preferences.LauncherDataStore +import de.mm20.launcher2.preferences.Settings.SearchBarSettings.SearchResultOrdering import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent @@ -31,7 +32,8 @@ class SearchSettingsScreenVM : ViewModel(), KoinComponent { } - val hasContactsPermission = permissionsManager.hasPermission(PermissionGroup.Contacts).asLiveData() + val hasContactsPermission = + permissionsManager.hasPermission(PermissionGroup.Contacts).asLiveData() val contacts = dataStore.data.map { it.contactsSearch.enabled }.asLiveData() fun setContacts(contacts: Boolean) { viewModelScope.launch { @@ -45,11 +47,13 @@ class SearchSettingsScreenVM : ViewModel(), KoinComponent { } } } + fun requestContactsPermission(activity: AppCompatActivity) { permissionsManager.requestPermission(activity, PermissionGroup.Contacts) } - val hasCalendarPermission = permissionsManager.hasPermission(PermissionGroup.Calendar).asLiveData() + val hasCalendarPermission = + permissionsManager.hasPermission(PermissionGroup.Calendar).asLiveData() val calendar = dataStore.data.map { it.calendarSearch.enabled }.asLiveData() fun setCalendar(calendar: Boolean) { viewModelScope.launch { @@ -63,6 +67,7 @@ class SearchSettingsScreenVM : ViewModel(), KoinComponent { } } } + fun requestCalendarPermission(activity: AppCompatActivity) { permissionsManager.requestPermission(activity, PermissionGroup.Calendar) } @@ -165,8 +170,23 @@ class SearchSettingsScreenVM : ViewModel(), KoinComponent { } } + val searchResultOrdering = dataStore.data.map { it.searchBar.searchResultOrdering }.asLiveData() + fun setSearchResultOrdering(searchResultOrdering: SearchResultOrdering) { + viewModelScope.launch { + dataStore.updateData { + it.toBuilder() + .setSearchBar( + it.searchBar.toBuilder() + .setSearchResultOrdering(searchResultOrdering) + ) + .build() + } + } + } - val hasAppShortcutPermission = permissionsManager.hasPermission(PermissionGroup.AppShortcuts).asLiveData() + + val hasAppShortcutPermission = + permissionsManager.hasPermission(PermissionGroup.AppShortcuts).asLiveData() val appShortcuts = dataStore.data.map { it.appShortcutSearch.enabled }.asLiveData() fun setAppShortcuts(appShortcuts: Boolean) { viewModelScope.launch { @@ -180,6 +200,7 @@ class SearchSettingsScreenVM : ViewModel(), KoinComponent { } } } + fun requestAppShortcutsPermission(activity: AppCompatActivity) { permissionsManager.requestPermission(activity, PermissionGroup.AppShortcuts) } diff --git a/core/database/src/main/java/de/mm20/launcher2/database/SearchDao.kt b/core/database/src/main/java/de/mm20/launcher2/database/SearchDao.kt index dc613d04..ea6488db 100644 --- a/core/database/src/main/java/de/mm20/launcher2/database/SearchDao.kt +++ b/core/database/src/main/java/de/mm20/launcher2/database/SearchDao.kt @@ -63,12 +63,9 @@ interface SearchDao { @Query("SELECT `key` FROM Searchable WHERE hidden = 1 AND type = 'calendar'") fun getHiddenCalendarEventKeys(): Flow> - - @Query("DELETE FROM Searchable WHERE `key` IN (:keys)") fun deleteAll(keys: List) - @Query("UPDATE Searchable SET pinned = 1, hidden = 0 WHERE `key` = :key") fun pinExistingItem(key: String) @@ -145,4 +142,7 @@ interface SearchDao { @Query("UPDATE Searchable Set `pinned` = 0, `launchCount` = 0 WHERE `key` = :key") suspend fun resetPinStatusAndLaunchCounter(key: String) + + @Query("SELECT `key` FROM Searchable WHERE `key` IN (:keys) AND launchCount > 0 ORDER BY launchCount DESC, pinned DESC") + fun sortByRelevance(keys: List): Flow> } diff --git a/core/i18n/src/main/res/values-de/strings.xml b/core/i18n/src/main/res/values-de/strings.xml index 4de60757..fa9d74f2 100644 --- a/core/i18n/src/main/res/values-de/strings.xml +++ b/core/i18n/src/main/res/values-de/strings.xml @@ -574,4 +574,7 @@ Feste Bildschirmausrichtung Porträtmodus erzwingen Dynamische Farben + Anordnung der Suchergebnisse + Alphabetisch + Relevanz \ No newline at end of file diff --git a/core/i18n/src/main/res/values/strings.xml b/core/i18n/src/main/res/values/strings.xml index 2931c873..1ae586d0 100644 --- a/core/i18n/src/main/res/values/strings.xml +++ b/core/i18n/src/main/res/values/strings.xml @@ -761,4 +761,7 @@ You have performed a \"%1$s\" gesture. This gesture is currently set to trigger a \"%2$s\" action. However, the action could not be performed for the following reason: The launcher\'s accessibility service needs to be enabled to perform this action. This action requires the launcher\'s accessibility service to be enabled. + Search-result order + Alphabetic + Relevance \ No newline at end of file diff --git a/core/preferences/src/main/proto/settings.proto b/core/preferences/src/main/proto/settings.proto index 9335fb87..bcc97408 100644 --- a/core/preferences/src/main/proto/settings.proto +++ b/core/preferences/src/main/proto/settings.proto @@ -223,6 +223,11 @@ message Settings { } SearchBarColors color = 3; bool launch_on_enter = 4; + enum SearchResultOrdering { + Alphabetic = 0; + Relevance = 1; + } + SearchResultOrdering search_result_ordering = 5; } SearchBarSettings search_bar = 20; diff --git a/data/favorites/src/main/java/de/mm20/launcher2/favorites/FavoritesRepository.kt b/data/favorites/src/main/java/de/mm20/launcher2/favorites/FavoritesRepository.kt index 3c7fcf88..b301189c 100644 --- a/data/favorites/src/main/java/de/mm20/launcher2/favorites/FavoritesRepository.kt +++ b/data/favorites/src/main/java/de/mm20/launcher2/favorites/FavoritesRepository.kt @@ -51,6 +51,13 @@ interface FavoritesRepository { fun getHiddenItems(): Flow> fun getHiddenItemKeys(): Flow> + /** + * Returns the given keys sorted by relevance. + * The first item in the list is the most relevant. + * Unknown keys will not be included in the result. + */ + fun sortByRelevance(keys: List): Flow> + /** * Remove this item from the Searchable database */ @@ -107,6 +114,7 @@ internal class FavoritesRepositoryImpl( frequentlyUsed = frequentlyUsed, limit = limit ) + includeTypes != null && excludeTypes == null -> { dao.getFavoritesWithTypes( includeTypes = includeTypes, @@ -116,6 +124,7 @@ internal class FavoritesRepositoryImpl( limit = limit ) } + excludeTypes != null && includeTypes == null -> { dao.getFavoritesWithoutTypes( excludeTypes = excludeTypes, @@ -125,6 +134,7 @@ internal class FavoritesRepositoryImpl( limit = limit ) } + else -> throw IllegalArgumentException("You can either use includeTypes or excludeTypes, not both") } return entities.map { @@ -137,13 +147,13 @@ internal class FavoritesRepositoryImpl( } override fun isPinned(searchable: SavableSearchable): Flow { - return AppDatabase.getInstance(context).searchDao().isPinned(searchable.key) + return database.searchDao().isPinned(searchable.key) } override fun pinItem(searchable: SavableSearchable) { scope.launch { withContext(Dispatchers.IO) { - val dao = AppDatabase.getInstance(context).searchDao() + val dao = database.searchDao() val databaseItem = dao.getFavorite(searchable.key) val savedSearchable = SavedSearchable( key = searchable.key, @@ -160,19 +170,19 @@ internal class FavoritesRepositoryImpl( override fun unpinItem(searchable: SavableSearchable) { scope.launch { withContext(Dispatchers.IO) { - AppDatabase.getInstance(context).searchDao().unpinFavorite(searchable.key) + database.searchDao().unpinFavorite(searchable.key) } } } override fun isHidden(searchable: SavableSearchable): Flow { - return AppDatabase.getInstance(context).searchDao().isHidden(searchable.key) + return database.searchDao().isHidden(searchable.key) } override fun hideItem(searchable: SavableSearchable) { scope.launch { withContext(Dispatchers.IO) { - val dao = AppDatabase.getInstance(context).searchDao() + val dao = database.searchDao() val databaseItem = dao.getFavorite(searchable.key) val savedSearchable = SavedSearchable( key = searchable.key, @@ -189,7 +199,7 @@ internal class FavoritesRepositoryImpl( override fun unhideItem(searchable: SavableSearchable) { scope.launch { withContext(Dispatchers.IO) { - AppDatabase.getInstance(context).searchDao().unhideItem(searchable.key) + database.searchDao().unhideItem(searchable.key) } } } @@ -199,7 +209,7 @@ internal class FavoritesRepositoryImpl( withContext(Dispatchers.IO) { val item = SavedSearchable(searchable.key, searchable, 0, 0, false) item.toDatabaseEntity()?.let { - AppDatabase.getInstance(context).searchDao() + database.searchDao() .incrementLaunchCount(it) } } @@ -286,6 +296,9 @@ internal class FavoritesRepositoryImpl( } } + override fun sortByRelevance(keys: List): Flow> { + return database.searchDao().sortByRelevance(keys) + } private fun fromDatabaseEntity(entity: SavedSearchableEntity): SavedSearchable { val deserializer: SearchableDeserializer = diff --git a/data/favorites/src/main/java/de/mm20/launcher2/favorites/SavedSearchableRankInfo.kt b/data/favorites/src/main/java/de/mm20/launcher2/favorites/SavedSearchableRankInfo.kt new file mode 100644 index 00000000..fec43207 --- /dev/null +++ b/data/favorites/src/main/java/de/mm20/launcher2/favorites/SavedSearchableRankInfo.kt @@ -0,0 +1,7 @@ +package de.mm20.launcher2.favorites + +data class SavedSearchableRankInfo( + val key: String, + val type: String, + var launchCount: Int +) \ No newline at end of file