Search: introduce weighted reordering (#272)

* Database: backend for weights & timestamps

- Migration 21->22
- SearchableLaunchTimestampEntity.kt
- `weight`-column in SavedSearchableEntity
- Queries for sorting by and adjusting weight
- Queries for sorting by recent use

* move weightfactor access to favoritesRepository

* reorder calls in SearchVM

* no more datahoarding

* add settings screen for ordering

* ui fix, animations

* move ordering settings out of own screen

* remove unused localization

* weight factors

* larger factor for WeightFactor.High

* cleanup

* sort favorites by weight

* icons for favorite screen

* I hate coming up with descriptive strings

* add default weight factor to settings migration

* Add default values for search result ordering preferences

* Favorites settings: change order of preferences

* Change strings

* Replace favorites variability slider with list preference

* migration initial weight

* Change labels

* Include searchable weight column in backup

---------

Co-authored-by: MM20 <15646950+MM2-0@users.noreply.github.com>
This commit is contained in:
Christoph 2023-03-10 22:17:03 +01:00 committed by GitHub
parent 003789f310
commit 2c9cba72a9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 838 additions and 163 deletions

View File

@ -25,7 +25,8 @@ fun SliderPreference(
max: Float = 1f,
step: Float? = null,
onValueChanged: (Float) -> Unit,
enabled: Boolean = true
enabled: Boolean = true,
label: (@Composable (Float) -> Unit)? = null
) {
var sliderValue by remember(value) { mutableStateOf(value) }
Row(
@ -72,16 +73,20 @@ fun SliderPreference(
onValueChanged(sliderValue)
}
)
val decimalPlaces = -log(step ?: 0.01f, 10f)
val format = remember { DecimalFormat().apply {
maximumFractionDigits = floor(decimalPlaces).toInt()
minimumFractionDigits = 0
} }
Text(
modifier = Modifier.width(56.dp).padding(start = 24.dp),
text = format.format(sliderValue),
style = MaterialTheme.typography.titleSmall
)
if (label != null) {
label(sliderValue)
} else {
val decimalPlaces = -log(step ?: 0.01f, 10f)
val format = remember { DecimalFormat().apply {
maximumFractionDigits = floor(decimalPlaces).toInt()
minimumFractionDigits = 0
} }
Text(
modifier = Modifier.width(56.dp).padding(start = 24.dp),
text = format.format(sliderValue),
style = MaterialTheme.typography.titleSmall
)
}
}
}
}
@ -96,7 +101,8 @@ fun SliderPreference(
max: Int = 100,
step: Int = 1,
onValueChanged: (Int) -> Unit,
enabled: Boolean = true
enabled: Boolean = true,
label: (@Composable (Int) -> Unit)? = null
) {
SliderPreference(
title = title,
@ -108,6 +114,45 @@ fun SliderPreference(
step = step.toFloat(),
onValueChanged = {
onValueChanged(it.roundToInt())
},
label = if (label == null) null else {
{ label(it.roundToInt()) }
}
)
}
@Composable
inline fun <reified T: Enum<T>> SliderPreference(
title: String,
icon: ImageVector? = null,
value: T,
enabled: Boolean = true,
labels: List<EnumLocalization<T>>? = null,
crossinline onValueChanged: (T) -> Unit
) {
val values = labels?.map { it.value }?.toTypedArray() ?: enumValues()
SliderPreference(
title = title,
icon = icon,
value = values.indexOf(value),
min = 0,
max = values.size - 1,
step = 1,
onValueChanged = {
onValueChanged(values[it])
},
enabled = enabled,
label = if (labels == null) null else {
{
val idx = labels.indexOfFirst { l -> l.value == values[it] }
Text(
modifier = Modifier.width(68.dp).padding(start = 12.dp),
text = if (idx != -1) labels[idx].label else "",
style = MaterialTheme.typography.titleSmall
)
}
}
)
}
typealias EnumLocalization<T> = ListPreferenceItem<T>

View File

@ -10,7 +10,8 @@ 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.preferences.Settings
import de.mm20.launcher2.preferences.Settings.SearchResultOrderingSettings.Ordering
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchService
import de.mm20.launcher2.search.Searchable
@ -136,14 +137,23 @@ class SearchVM : ViewModel(), KoinComponent {
.sortedBy { (it as? SavableSearchable) }
}
val relevance =
if (query.isNotEmpty() && settings.searchBar.searchResultOrdering == SearchResultOrdering.Relevance) {
favoritesRepository.sortByRelevance(
resultsList.mapNotNull { (it as? SavableSearchable)?.key }
).first()
} else {
if (query.isEmpty()) {
emptyList()
} else {
val keys = resultsList.mapNotNull { (it as? SavableSearchable)?.key }
when (settings.resultOrdering.ordering) {
Ordering.LaunchCount -> favoritesRepository.sortByRelevance(
keys
).first()
Ordering.Weighted -> favoritesRepository.sortByWeight(
keys
).first()
else -> emptyList()
}
}
resultsList = resultsList.sortedWith { a, b ->

View File

@ -10,10 +10,13 @@ import de.mm20.launcher2.favorites.FavoritesRepository
import de.mm20.launcher2.icons.IconRepository
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.ktx.isAtLeastApiLevel
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.preferences.Settings
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.data.AppShortcut
import de.mm20.launcher2.search.data.LauncherApp
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject

View File

@ -1,5 +1,11 @@
package de.mm20.launcher2.ui.settings.favorites
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Insights
import androidx.compose.material.icons.rounded.Sort
import androidx.compose.material.icons.rounded.SwapVert
import androidx.compose.material.icons.rounded.TableRows
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@ -8,7 +14,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.preferences.Settings.SearchResultOrderingSettings.WeightFactor
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.preferences.ListPreference
import de.mm20.launcher2.ui.component.preferences.Preference
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
@ -28,6 +36,7 @@ fun FavoritesSettingsScreen() {
Preference(
title = stringResource(R.string.menu_item_edit_favs),
summary = stringResource(R.string.preference_edit_favorites_summary),
icon = Icons.Rounded.Sort,
onClick = {
showEditSheet = true
}
@ -43,7 +52,8 @@ fun FavoritesSettingsScreen() {
value = frequentlyUsed == true,
onValueChanged = {
viewModel.setFrequentlyUsed(it)
}
},
icon = Icons.Rounded.Insights
)
val frequentlyUsedRows by viewModel.frequentlyUsedRows.observeAsState(1)
SliderPreference(
@ -54,7 +64,20 @@ fun FavoritesSettingsScreen() {
max = 4,
onValueChanged = {
viewModel.setFrequentlyUsedRows(it)
}
},
icon = Icons.Rounded.TableRows
)
val searchResultWeightFactor by viewModel.searchResultWeightFactor.observeAsState(WeightFactor.Default)
ListPreference(
title = stringResource(R.string.preference_search_result_ordering_weight_factor),
icon = Icons.Rounded.SwapVert,
value = searchResultWeightFactor,
items = listOf(
stringResource(R.string.preference_search_result_ordering_weight_factor_low) to WeightFactor.Low,
stringResource(R.string.preference_search_result_ordering_weight_factor_default) to WeightFactor.Default,
stringResource(R.string.preference_search_result_ordering_weight_factor_high) to WeightFactor.High
),
onValueChanged = { viewModel.setSearchResultWeightFactor(it) }
)
}
}
@ -67,7 +90,8 @@ fun FavoritesSettingsScreen() {
value = editButton == true,
onValueChanged = {
viewModel.setEditButton(it)
}
},
icon = Icons.Rounded.Edit
)
}
}

View File

@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.preferences.Settings.SearchResultOrderingSettings.WeightFactor
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
@ -53,4 +54,18 @@ class FavoritesSettingsScreenVM: ViewModel(), KoinComponent {
}
}
}
val searchResultWeightFactor = dataStore.data.map { it.resultOrdering.weightFactor }.asLiveData()
fun setSearchResultWeightFactor(searchResultWeightFactor: WeightFactor) {
viewModelScope.launch {
dataStore.updateData {
it.toBuilder()
.setResultOrdering(
it.resultOrdering.toBuilder()
.setWeightFactor(searchResultWeightFactor)
)
.build()
}
}
}
}

View File

@ -207,16 +207,17 @@ fun SearchSettingsScreen() {
)
val searchResultOrdering by viewModel.searchResultOrdering.observeAsState()
ListPreference(
title = stringResource(R.string.preference_search_bar_ordering),
value = searchResultOrdering,
icon = Icons.Rounded.Sort,
title = stringResource(R.string.preference_search_result_ordering),
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
stringResource(R.string.preference_search_result_ordering_alphabetic) to Settings.SearchResultOrderingSettings.Ordering.Alphabetic,
stringResource(R.string.preference_search_result_ordering_launch_count) to Settings.SearchResultOrderingSettings.Ordering.LaunchCount,
stringResource(R.string.preference_search_result_ordering_weighted) to Settings.SearchResultOrderingSettings.Ordering.Weighted
),
value = searchResultOrdering,
onValueChanged = {
if (it != null) viewModel.setSearchResultOrdering(it)
}
},
icon = Icons.Rounded.Sort
)
}
}

View File

@ -7,7 +7,8 @@ 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 de.mm20.launcher2.preferences.Settings
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
@ -21,29 +22,22 @@ class SearchSettingsScreenVM : ViewModel(), KoinComponent {
fun setFavorites(favorites: Boolean) {
viewModelScope.launch {
dataStore.updateData {
it.toBuilder()
.setFavorites(
it.favorites.toBuilder()
.setEnabled(favorites)
)
.build()
it.toBuilder().setFavorites(
it.favorites.toBuilder().setEnabled(favorites)
).build()
}
}
}
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 {
dataStore.updateData {
it.toBuilder()
.setContactsSearch(
it.contactsSearch.toBuilder()
.setEnabled(contacts)
)
.build()
it.toBuilder().setContactsSearch(
it.contactsSearch.toBuilder().setEnabled(contacts)
).build()
}
}
}
@ -52,18 +46,14 @@ class SearchSettingsScreenVM : ViewModel(), KoinComponent {
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 {
dataStore.updateData {
it.toBuilder()
.setCalendarSearch(
it.calendarSearch.toBuilder()
.setEnabled(calendar)
)
.build()
it.toBuilder().setCalendarSearch(
it.calendarSearch.toBuilder().setEnabled(calendar)
).build()
}
}
}
@ -76,12 +66,9 @@ class SearchSettingsScreenVM : ViewModel(), KoinComponent {
fun setCalculator(calculator: Boolean) {
viewModelScope.launch {
dataStore.updateData {
it.toBuilder()
.setCalculatorSearch(
it.calculatorSearch.toBuilder()
.setEnabled(calculator)
)
.build()
it.toBuilder().setCalculatorSearch(
it.calculatorSearch.toBuilder().setEnabled(calculator)
).build()
}
}
}
@ -90,12 +77,9 @@ class SearchSettingsScreenVM : ViewModel(), KoinComponent {
fun setUnitConverter(unitConverter: Boolean) {
viewModelScope.launch {
dataStore.updateData {
it.toBuilder()
.setUnitConverterSearch(
it.unitConverterSearch.toBuilder()
.setEnabled(unitConverter)
)
.build()
it.toBuilder().setUnitConverterSearch(
it.unitConverterSearch.toBuilder().setEnabled(unitConverter)
).build()
}
}
}
@ -104,12 +88,9 @@ class SearchSettingsScreenVM : ViewModel(), KoinComponent {
fun setWikipedia(wikipedia: Boolean) {
viewModelScope.launch {
dataStore.updateData {
it.toBuilder()
.setWikipediaSearch(
it.wikipediaSearch.toBuilder()
.setEnabled(wikipedia)
)
.build()
it.toBuilder().setWikipediaSearch(
it.wikipediaSearch.toBuilder().setEnabled(wikipedia)
).build()
}
}
}
@ -118,12 +99,9 @@ class SearchSettingsScreenVM : ViewModel(), KoinComponent {
fun setWebsites(websites: Boolean) {
viewModelScope.launch {
dataStore.updateData {
it.toBuilder()
.setWebsiteSearch(
it.websiteSearch.toBuilder()
.setEnabled(websites)
)
.build()
it.toBuilder().setWebsiteSearch(
it.websiteSearch.toBuilder().setEnabled(websites)
).build()
}
}
}
@ -132,12 +110,9 @@ class SearchSettingsScreenVM : ViewModel(), KoinComponent {
fun setWebSearch(webSearch: Boolean) {
viewModelScope.launch {
dataStore.updateData {
it.toBuilder()
.setWebSearch(
it.webSearch.toBuilder()
.setEnabled(webSearch)
)
.build()
it.toBuilder().setWebSearch(
it.webSearch.toBuilder().setEnabled(webSearch)
).build()
}
}
}
@ -146,12 +121,9 @@ class SearchSettingsScreenVM : ViewModel(), KoinComponent {
fun setAutoFocus(autoFocus: Boolean) {
viewModelScope.launch {
dataStore.updateData {
it.toBuilder()
.setSearchBar(
it.searchBar.toBuilder()
.setAutoFocus(autoFocus)
)
.build()
it.toBuilder().setSearchBar(
it.searchBar.toBuilder().setAutoFocus(autoFocus)
).build()
}
}
}
@ -160,43 +132,32 @@ class SearchSettingsScreenVM : ViewModel(), KoinComponent {
fun setLaunchOnEnter(launchOnEnter: Boolean) {
viewModelScope.launch {
dataStore.updateData {
it.toBuilder()
.setSearchBar(
it.searchBar.toBuilder()
.setLaunchOnEnter(launchOnEnter)
)
.build()
it.toBuilder().setSearchBar(
it.searchBar.toBuilder().setLaunchOnEnter(launchOnEnter)
).build()
}
}
}
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 {
dataStore.updateData {
it.toBuilder()
.setAppShortcutSearch(
it.appShortcutSearch.toBuilder()
.setEnabled(appShortcuts)
)
.build()
it.toBuilder().setAppShortcutSearch(
it.appShortcutSearch.toBuilder().setEnabled(appShortcuts)
).build()
}
}
}
val searchResultOrdering = dataStore.data.map { it.resultOrdering.ordering }.asLiveData()
fun setSearchResultOrdering(searchResultOrdering: Settings.SearchResultOrderingSettings.Ordering) {
viewModelScope.launch {
dataStore.updateData {
it.toBuilder().setResultOrdering(
it.resultOrdering.toBuilder().setOrdering(searchResultOrdering)
).build()
}
}
}

View File

@ -0,0 +1,499 @@
{
"formatVersion": 1,
"database": {
"version": 22,
"identityHash": "5d3853b609231cdcab5fb3c681d05ebb",
"entities": [
{
"tableName": "forecasts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `temperature` REAL NOT NULL, `minTemp` REAL NOT NULL, `maxTemp` REAL NOT NULL, `pressure` REAL NOT NULL, `humidity` REAL NOT NULL, `icon` INTEGER NOT NULL, `condition` TEXT NOT NULL, `clouds` INTEGER NOT NULL, `windSpeed` REAL NOT NULL, `windDirection` REAL NOT NULL, `rain` REAL NOT NULL, `snow` REAL NOT NULL, `night` INTEGER NOT NULL, `location` TEXT NOT NULL, `provider` TEXT NOT NULL, `providerUrl` TEXT NOT NULL, `rainProbability` INTEGER NOT NULL, `snowProbability` INTEGER NOT NULL, `updateTime` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))",
"fields": [
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "temperature",
"columnName": "temperature",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "minTemp",
"columnName": "minTemp",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "maxTemp",
"columnName": "maxTemp",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "pressure",
"columnName": "pressure",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "humidity",
"columnName": "humidity",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "condition",
"columnName": "condition",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "clouds",
"columnName": "clouds",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "windSpeed",
"columnName": "windSpeed",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "windDirection",
"columnName": "windDirection",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "precipitation",
"columnName": "rain",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "snow",
"columnName": "snow",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "night",
"columnName": "night",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "location",
"columnName": "location",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "provider",
"columnName": "provider",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "providerUrl",
"columnName": "providerUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "precipProbability",
"columnName": "rainProbability",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "snowProbability",
"columnName": "snowProbability",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updateTime",
"columnName": "updateTime",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"timestamp"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "Searchable",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `type` TEXT NOT NULL, `searchable` TEXT NOT NULL, `launchCount` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `hidden` INTEGER NOT NULL, `weight` REAL NOT NULL DEFAULT 0.0, PRIMARY KEY(`key`))",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serializedSearchable",
"columnName": "searchable",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "launchCount",
"columnName": "launchCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pinPosition",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hidden",
"columnName": "hidden",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "weight",
"columnName": "weight",
"affinity": "REAL",
"notNull": true,
"defaultValue": "0.0"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "Currency",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`symbol` TEXT NOT NULL, `value` REAL NOT NULL, `lastUpdate` INTEGER NOT NULL, PRIMARY KEY(`symbol`))",
"fields": [
{
"fieldPath": "symbol",
"columnName": "symbol",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "lastUpdate",
"columnName": "lastUpdate",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"symbol"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "Icons",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` TEXT NOT NULL, `packageName` TEXT, `activityName` TEXT, `drawable` TEXT, `extras` TEXT, `iconPack` TEXT NOT NULL, `name` TEXT, `themed` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT)",
"fields": [
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "activityName",
"columnName": "activityName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "drawable",
"columnName": "drawable",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "extras",
"columnName": "extras",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "iconPack",
"columnName": "iconPack",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "themed",
"columnName": "themed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "IconPack",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `packageName` TEXT NOT NULL, `version` TEXT NOT NULL, `scale` REAL NOT NULL, `themed` INTEGER NOT NULL, PRIMARY KEY(`packageName`))",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "scale",
"columnName": "scale",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "themed",
"columnName": "themed",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"packageName"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "Widget",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` TEXT NOT NULL, `data` TEXT NOT NULL, `height` INTEGER NOT NULL, `position` INTEGER NOT NULL, `label` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT)",
"fields": [
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "data",
"columnName": "data",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "height",
"columnName": "height",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "label",
"columnName": "label",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "CustomAttributes",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT)",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "SearchAction",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`position` INTEGER NOT NULL, `type` TEXT NOT NULL, `data` TEXT, `label` TEXT, `icon` INTEGER, `color` INTEGER, `customIcon` TEXT, `options` TEXT, PRIMARY KEY(`position`))",
"fields": [
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "data",
"columnName": "data",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "label",
"columnName": "label",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "customIcon",
"columnName": "customIcon",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "options",
"columnName": "options",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"position"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5d3853b609231cdcab5fb3c681d05ebb')"
]
}
}

View File

@ -7,7 +7,6 @@ import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import de.mm20.launcher2.database.entities.*
import de.mm20.launcher2.database.migrations.Migration_10_11
@ -21,6 +20,7 @@ import de.mm20.launcher2.database.migrations.Migration_17_18
import de.mm20.launcher2.database.migrations.Migration_18_19
import de.mm20.launcher2.database.migrations.Migration_19_20
import de.mm20.launcher2.database.migrations.Migration_20_21
import de.mm20.launcher2.database.migrations.Migration_21_22
import de.mm20.launcher2.database.migrations.Migration_6_7
import de.mm20.launcher2.database.migrations.Migration_7_8
import de.mm20.launcher2.database.migrations.Migration_8_9
@ -35,8 +35,8 @@ import de.mm20.launcher2.database.migrations.Migration_9_10
IconPackEntity::class,
WidgetEntity::class,
CustomAttributeEntity::class,
SearchActionEntity::class,
], version = 21, exportSchema = true
SearchActionEntity::class
], version = 22, exportSchema = true
)
@TypeConverters(ComponentNameConverter::class, StringListConverter::class)
abstract class AppDatabase : RoomDatabase() {
@ -104,6 +104,7 @@ abstract class AppDatabase : RoomDatabase() {
Migration_18_19(),
Migration_19_20(),
Migration_20_21(),
Migration_21_22()
).build()
if (_instance == null) _instance = instance
return instance

View File

@ -7,7 +7,7 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface SearchDao {
@Insert()
@Insert
fun insertAll(items: List<SavedSearchableEntity>)
@Insert(onConflict = OnConflictStrategy.IGNORE)
@ -19,12 +19,13 @@ interface SearchDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAllReplaceExisting(items: List<SavedSearchableEntity>)
@Query("SELECT * FROM Searchable " +
"WHERE ((:manuallySorted AND pinned > 1) OR " +
"(:automaticallySorted AND pinned = 1) OR" +
"(:frequentlyUsed AND pinned = 0 AND launchCount > 0)" +
") ORDER BY pinned DESC, launchCount DESC LIMIT :limit")
@Query(
"SELECT * FROM Searchable " +
"WHERE ((:manuallySorted AND pinned > 1) OR " +
"(:automaticallySorted AND pinned = 1) OR" +
"(:frequentlyUsed AND pinned = 0 AND launchCount > 0)" +
") ORDER BY pinned DESC, weight DESC, launchCount DESC LIMIT :limit"
)
fun getFavorites(
manuallySorted: Boolean = false,
automaticallySorted: Boolean = false,
@ -32,12 +33,14 @@ interface SearchDao {
limit: Int,
): Flow<List<SavedSearchableEntity>>
@Query("SELECT * FROM Searchable " +
"WHERE SUBSTR(`key`, 0, INSTR(`key`, '://')) IN (:includeTypes) AND (" +
"(:manuallySorted AND pinned > 1) OR " +
"(:automaticallySorted AND pinned = 1) OR" +
"(:frequentlyUsed AND pinned = 0 AND launchCount > 0)" +
") ORDER BY pinned DESC, launchCount DESC LIMIT :limit")
@Query(
"SELECT * FROM Searchable " +
"WHERE SUBSTR(`key`, 0, INSTR(`key`, '://')) IN (:includeTypes) AND (" +
"(:manuallySorted AND pinned > 1) OR " +
"(:automaticallySorted AND pinned = 1) OR" +
"(:frequentlyUsed AND pinned = 0 AND launchCount > 0)" +
") ORDER BY pinned DESC, weight DESC, launchCount DESC LIMIT :limit"
)
fun getFavoritesWithTypes(
includeTypes: List<String>,
manuallySorted: Boolean = false,
@ -46,12 +49,14 @@ interface SearchDao {
limit: Int,
): Flow<List<SavedSearchableEntity>>
@Query("SELECT * FROM Searchable " +
"WHERE `type` NOT IN (:excludeTypes) AND (" +
"(:manuallySorted AND pinned > 1) OR " +
"(:automaticallySorted AND pinned = 1) OR" +
"(:frequentlyUsed AND pinned = 0 AND launchCount > 0)" +
") ORDER BY pinned DESC, launchCount DESC LIMIT :limit")
@Query(
"SELECT * FROM Searchable " +
"WHERE `type` NOT IN (:excludeTypes) AND (" +
"(:manuallySorted AND pinned > 1) OR " +
"(:automaticallySorted AND pinned = 1) OR" +
"(:frequentlyUsed AND pinned = 0 AND launchCount > 0)" +
") ORDER BY pinned DESC, weight DESC, launchCount DESC LIMIT :limit"
)
fun getFavoritesWithoutTypes(
excludeTypes: List<String>,
manuallySorted: Boolean = false,
@ -114,8 +119,10 @@ interface SearchDao {
fun incrementExistingLaunchCount(key: String)
@Transaction
fun incrementLaunchCount(item: SavedSearchableEntity) {
fun incrementLaunchCount(item: SavedSearchableEntity, alpha: Double) {
incrementExistingLaunchCount(item.key)
increaseWeightWhere(item.key, alpha)
reduceWeightExcept(item.key, alpha)
insertSkipExisting(item)
}
@ -140,9 +147,18 @@ interface SearchDao {
@Query("UPDATE Searchable SET `pinned` = 0")
fun unpinAll()
@Query("UPDATE Searchable Set `pinned` = 0, `launchCount` = 0 WHERE `key` = :key")
@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>>
@Query("SELECT `key` FROM Searchable WHERE `key` IN (:keys) ORDER BY `weight` DESC, pinned DESC")
fun sortByWeight(keys: List<String>): Flow<List<String>>
@Query("UPDATE Searchable SET `weight` = `weight` * (1.0 - :alpha) WHERE `key` != :key")
fun reduceWeightExcept(key: String, alpha: Double)
@Query("UPDATE Searchable SET `weight` = `weight` + :alpha * (1.0 - `weight`) WHERE `key` == :key")
fun increaseWeightWhere(key: String, alpha: Double)
}

View File

@ -11,5 +11,6 @@ data class SavedSearchableEntity(
@ColumnInfo(name = "searchable") val serializedSearchable: String,
var launchCount: Int,
@ColumnInfo(name = "pinned") var pinPosition: Int,
var hidden: Boolean
var hidden: Boolean,
@ColumnInfo(defaultValue = "0.0") var weight: Double
)

View File

@ -0,0 +1,37 @@
package de.mm20.launcher2.database.migrations
import android.util.Log
import androidx.core.database.getIntOrNull
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration_21_22: Migration(21, 22) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("""
ALTER TABLE `Searchable`
ADD `weight` DOUBLE NOT NULL DEFAULT 0.0
""")
database.query("""
SELECT MAX(`launchCount`)
FROM `Searchable`
""")
.runCatching {
if (!this.moveToFirst()) {
return
}
this.getIntOrNull(0)
?.run {
database.execSQL("""
UPDATE `Searchable`
SET `weight` = `launchCount` / $this
""")
}
}.onFailure {
Log.e("Migration_21_22", "Setting default values for weight failed", it)
}
}
}

View File

@ -574,7 +574,12 @@
<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>
<string name="preference_search_result_ordering">Sortierung der Suchergebnisse</string>
<string name="preference_search_result_ordering_alphabetic">Alphabetisch</string>
<string name="preference_search_result_ordering_launch_count">Aufrufhäufigkeit</string>
<string name="preference_search_result_ordering_weighted">Dynamisch</string>
<string name="preference_search_result_ordering_weight_factor">Ranking-Flexibilität</string>
<string name="preference_search_result_ordering_weight_factor_low">Stabil</string>
<string name="preference_search_result_ordering_weight_factor_default">Ausgewogen</string>
<string name="preference_search_result_ordering_weight_factor_high">Variabel</string>
</resources>

View File

@ -761,7 +761,12 @@
<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>
<string name="preference_search_result_ordering">Order of search results</string>
<string name="preference_search_result_ordering_alphabetic">Alphabetic</string>
<string name="preference_search_result_ordering_launch_count">Launch count</string>
<string name="preference_search_result_ordering_weighted">Dynamic</string>
<string name="preference_search_result_ordering_weight_factor">Ranking flexibility</string>
<string name="preference_search_result_ordering_weight_factor_low">Stable</string>
<string name="preference_search_result_ordering_weight_factor_default">Balanced</string>
<string name="preference_search_result_ordering_weight_factor_high">Variable</string>
</resources>

View File

@ -183,6 +183,11 @@ fun createFactorySettings(context: Context): Settings {
.setSwipeLeft(Settings.GestureSettings.GestureAction.None)
.setSwipeRight(Settings.GestureSettings.GestureAction.None)
)
.setResultOrdering(
Settings.SearchResultOrderingSettings.newBuilder()
.setOrdering(Settings.SearchResultOrderingSettings.Ordering.Weighted)
.setWeightFactor(Settings.SearchResultOrderingSettings.WeightFactor.Default)
)
.build()
}

View File

@ -1,6 +1,7 @@
package de.mm20.launcher2.preferences.migrations
import de.mm20.launcher2.preferences.Settings
import de.mm20.launcher2.preferences.Settings.SearchResultOrderingSettings.WeightFactor
class Migration_12_13: VersionedMigration(12, 13) {
override suspend fun applyMigrations(builder: Settings.Builder): Settings.Builder {
@ -10,5 +11,10 @@ class Migration_12_13: VersionedMigration(12, 13) {
.setDatePart(true)
.build()
)
.setResultOrdering(
builder.resultOrdering.toBuilder()
.setWeightFactor(WeightFactor.Default)
.build()
)
}
}

View File

@ -223,11 +223,6 @@ 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;
@ -332,4 +327,20 @@ message Settings {
string long_press_app = 10;
}
GestureSettings gestures = 28;
message SearchResultOrderingSettings {
enum Ordering {
Alphabetic = 0;
LaunchCount = 1;
Weighted = 2;
}
Ordering ordering = 1;
enum WeightFactor {
Low = 0;
Default = 1;
High = 2;
}
WeightFactor weight_factor = 2;
}
SearchResultOrderingSettings result_ordering = 29;
}

View File

@ -6,6 +6,8 @@ import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.database.AppDatabase
import de.mm20.launcher2.database.entities.SavedSearchableEntity
import de.mm20.launcher2.ktx.jsonObjectOf
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.preferences.Settings.SearchResultOrderingSettings.WeightFactor
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchableDeserializer
import kotlinx.coroutines.*
@ -58,6 +60,8 @@ interface FavoritesRepository {
*/
fun sortByRelevance(keys: List<String>): Flow<List<String>>
fun sortByWeight(keys: List<String>): Flow<List<String>>
/**
* Remove this item from the Searchable database
*/
@ -94,6 +98,7 @@ interface FavoritesRepository {
internal class FavoritesRepositoryImpl(
private val context: Context,
private val database: AppDatabase,
private val dataStore: LauncherDataStore
) : FavoritesRepository, KoinComponent {
private val scope = CoroutineScope(Job() + Dispatchers.Default)
@ -160,7 +165,8 @@ internal class FavoritesRepositoryImpl(
searchable = searchable,
launchCount = databaseItem?.launchCount ?: 0,
pinPosition = 1,
hidden = false
hidden = false,
weight = databaseItem?.weight ?: 0.0
)
savedSearchable.toDatabaseEntity()?.let { dao.insertReplaceExisting(it) }
}
@ -189,7 +195,8 @@ internal class FavoritesRepositoryImpl(
searchable = searchable,
launchCount = databaseItem?.launchCount ?: 0,
pinPosition = 0,
hidden = true
hidden = true,
weight = databaseItem?.weight ?: 0.0
)
savedSearchable.toDatabaseEntity()?.let { dao.insertReplaceExisting(it) }
}
@ -207,10 +214,16 @@ internal class FavoritesRepositoryImpl(
override fun incrementLaunchCounter(searchable: SavableSearchable) {
scope.launch {
withContext(Dispatchers.IO) {
val item = SavedSearchable(searchable.key, searchable, 0, 0, false)
val weightFactor =
when (dataStore.data.map { it.resultOrdering.weightFactor }.firstOrNull()) {
WeightFactor.Low -> 0.1
WeightFactor.High -> 0.5
else -> 0.2
}
val item = SavedSearchable(searchable.key, searchable, 0, 0, false, 0.0)
item.toDatabaseEntity()?.let {
database.searchDao()
.incrementLaunchCount(it)
.incrementLaunchCount(it, weightFactor)
}
}
}
@ -249,6 +262,7 @@ internal class FavoritesRepositoryImpl(
launchCount = 0,
pinPosition = 0,
hidden = false,
weight = 0.0
).toDatabaseEntity() ?: return@withContext
database.searchDao().insertSkipExisting(entity)
}
@ -271,6 +285,7 @@ internal class FavoritesRepositoryImpl(
launchCount = 0,
pinPosition = 0,
hidden = false,
weight = 0.0
).toDatabaseEntity() ?: return@mapIndexedNotNull null
entity.pinPosition = manuallySorted.size - index + 1
entity
@ -283,6 +298,7 @@ internal class FavoritesRepositoryImpl(
launchCount = 0,
pinPosition = 0,
hidden = false,
weight = 0.0
).toDatabaseEntity() ?: return@mapIndexedNotNull null
entity.pinPosition = 1
entity
@ -300,6 +316,10 @@ internal class FavoritesRepositoryImpl(
return database.searchDao().sortByRelevance(keys)
}
override fun sortByWeight(keys: List<String>): Flow<List<String>> {
return database.searchDao().sortByWeight(keys)
}
private fun fromDatabaseEntity(entity: SavedSearchableEntity): SavedSearchable {
val deserializer: SearchableDeserializer =
getDeserializer(context, entity.type)
@ -310,7 +330,8 @@ internal class FavoritesRepositoryImpl(
searchable = searchable,
launchCount = entity.launchCount,
pinPosition = entity.pinPosition,
hidden = entity.hidden
hidden = entity.hidden,
weight = entity.weight
)
}
@ -340,7 +361,8 @@ internal class FavoritesRepositoryImpl(
"hidden" to fav.hidden,
"launchCount" to fav.launchCount,
"pinPosition" to fav.pinPosition,
"searchable" to fav.serializedSearchable
"searchable" to fav.serializedSearchable,
"weight" to fav.weight,
)
)
}
@ -373,7 +395,8 @@ internal class FavoritesRepositoryImpl(
serializedSearchable = json.getString("searchable"),
launchCount = json.getInt("launchCount"),
hidden = json.getBoolean("hidden"),
pinPosition = json.getInt("pinPosition")
pinPosition = json.getInt("pinPosition"),
weight = json.optDouble("weight").takeIf { !it.isNaN() } ?: 0.0
)
favorites.add(entity)
}

View File

@ -4,5 +4,5 @@ import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val favoritesModule = module {
single<FavoritesRepository> { FavoritesRepositoryImpl(androidContext(), get()) }
single<FavoritesRepository> { FavoritesRepositoryImpl(androidContext(), get(), get()) }
}

View File

@ -11,7 +11,8 @@ data class SavedSearchable(
val searchable: SavableSearchable?,
var launchCount: Int,
var pinPosition: Int,
var hidden: Boolean
var hidden: Boolean,
var weight: Double
) {
fun toDatabaseEntity(): SavedSearchableEntity? {
val serializer = getSerializer(searchable)
@ -24,7 +25,8 @@ data class SavedSearchable(
serializedSearchable = data,
hidden = hidden,
pinPosition = pinPosition,
launchCount = launchCount
launchCount = launchCount,
weight = weight
)
}
}

View File

@ -182,8 +182,13 @@ class BackupManager(
}
companion object {
/**
* Format changelog:
* - 1.5: added `weight` to favorites
*/
private const val BackupFormatMajor = 1
private const val BackupFormatMinor = 4
private const val BackupFormatMinor = 5
internal const val BackupFormat = "$BackupFormatMajor.$BackupFormatMinor"
}
}