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>
This commit is contained in:
Christoph 2023-02-28 20:45:25 +01:00 committed by GitHub
parent 600e319c99
commit 0b2aa716ec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 158 additions and 32 deletions

View File

@ -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<GestureState> = dataStore
.data.map { it.gestures }
.distinctUntilChanged()
@ -155,7 +153,6 @@ class LauncherScaffoldVM : ViewModel(), KoinComponent {
)
}.stateIn(viewModelScope, SharingStarted.Eagerly, GestureState())
var failedGestureState by mutableStateOf<FailedGesture?>(null)
fun handleGesture(context: Context, gesture: Gesture): Boolean {
val action = when (gesture) {

View File

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

View File

@ -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<SavableSearchable>()
val apps = mutableListOf<LauncherApp>()
@ -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 <T : SavableSearchable> MutableList<T>.reorderByRanks(ranks: List<SavedSearchableRankInfo>) {
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
}
}
}

View File

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

View File

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

View File

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

View File

@ -63,12 +63,9 @@ interface SearchDao {
@Query("SELECT `key` FROM Searchable WHERE hidden = 1 AND type = 'calendar'")
fun getHiddenCalendarEventKeys(): Flow<List<String>>
@Query("DELETE FROM Searchable WHERE `key` IN (:keys)")
fun deleteAll(keys: List<String>)
@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<String>): Flow<List<String>>
}

View File

@ -574,4 +574,7 @@
<string name="preference_layout_fixed_rotation">Feste Bildschirmausrichtung</string>
<string name="preference_layout_fixed_rotation_summary">Porträtmodus erzwingen</string>
<string name="icon_pack_dynamic_colors">Dynamische Farben</string>
<string name="preference_search_bar_ordering">Anordnung der Suchergebnisse</string>
<string name="preference_search_bar_ordering_alphabetic">Alphabetisch</string>
<string name="preference_search_bar_ordering_relevance">Relevanz</string>
</resources>

View File

@ -761,4 +761,7 @@
<string name="gesture_failed_message">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:</string>
<string name="missing_permission_accessibility_gesture_failed">The launcher\'s accessibility service needs to be enabled to perform this action.</string>
<string name="missing_permission_accessibility_gesture_settings">This action requires the launcher\'s accessibility service to be enabled.</string>
<string name="preference_search_bar_ordering">Search-result order</string>
<string name="preference_search_bar_ordering_alphabetic">Alphabetic</string>
<string name="preference_search_bar_ordering_relevance">Relevance</string>
</resources>

View File

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

View File

@ -51,6 +51,13 @@ interface FavoritesRepository {
fun getHiddenItems(): Flow<List<SavableSearchable>>
fun getHiddenItemKeys(): Flow<List<String>>
/**
* 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<String>): Flow<List<String>>
/**
* 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<Boolean> {
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<Boolean> {
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<String>): Flow<List<String>> {
return database.searchDao().sortByRelevance(keys)
}
private fun fromDatabaseEntity(entity: SavedSearchableEntity): SavedSearchable {
val deserializer: SearchableDeserializer =

View File

@ -0,0 +1,7 @@
package de.mm20.launcher2.favorites
data class SavedSearchableRankInfo(
val key: String,
val type: String,
var launchCount: Int
)