From bf6e96354520c792584dec5d458c53b9a556a8b6 Mon Sep 17 00:00:00 2001 From: MM20 <15646950+MM2-0@users.noreply.github.com> Date: Sat, 12 Oct 2024 22:16:10 +0200 Subject: [PATCH] Improve app result ranking --- .../launcher2/ui/launcher/search/SearchVM.kt | 23 ++--- core/base/build.gradle.kts | 2 + .../launcher2/licenses/OpenSourceLicenses.kt | 8 ++ .../de/mm20/launcher2/search/ResultScore.kt | 94 +++++++++++++++++++ .../de/mm20/launcher2/search/Searchable.kt | 5 +- .../launcher2/applications/AppRepository.kt | 11 ++- .../launcher2/applications/LauncherApp.kt | 7 +- .../mm20/launcher2/database/SearchableDao.kt | 5 +- .../searchable/SavableSearchableRepository.kt | 7 ++ gradle/libs.versions.toml | 1 + 10 files changed, 143 insertions(+), 20 deletions(-) create mode 100644 core/base/src/main/java/de/mm20/launcher2/search/ResultScore.kt 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 e4971b54..67f758a4 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 @@ -472,20 +472,15 @@ class SearchVM : ViewModel(), KoinComponent { private suspend fun List.applyRanking(order: SearchResultOrder): List { if (size <= 1) return this val sequence = asSequence() - val sorted = if (order == SearchResultOrder.Weighted) { - val sortedKeys = searchableRepository.sortByWeight(map { it.key }).first() - sequence.sortedWith { a, b -> - val aRank = sortedKeys.indexOf(a.key) - val bRank = sortedKeys.indexOf(b.key) - when { - aRank != -1 && bRank != -1 -> aRank.compareTo(bRank) - aRank == -1 && bRank != -1 -> 1 - aRank != -1 && bRank == -1 -> -1 - else -> a.compareTo(b) - } - } - } else { - sequence.sorted() + val weights = searchableRepository.getWeights(map { it.key }).first() + val sorted = sequence.sortedWith { a, b -> + val aWeight = weights[a.key] ?: 0.0 + val bWeight = weights[b.key] ?: 0.0 + + val aScore = a.score.score * 0.7f + aWeight.toFloat() * 0.3f + val bScore = b.score.score * 0.7f + bWeight.toFloat() * 0.3f + + bScore.compareTo(aScore) } return sorted.distinctBy { it.key }.toList() } diff --git a/core/base/build.gradle.kts b/core/base/build.gradle.kts index a20c62c0..9dec8236 100644 --- a/core/base/build.gradle.kts +++ b/core/base/build.gradle.kts @@ -52,6 +52,8 @@ dependencies { runtimeOnly(libs.androidx.compose.runtime) implementation(libs.androidx.compose.materialicons) + implementation(libs.stringsimilarity) + implementation(project(":core:ktx")) implementation(project(":core:i18n")) implementation(project(":libs:material-color-utilities")) diff --git a/core/base/src/main/java/de/mm20/launcher2/licenses/OpenSourceLicenses.kt b/core/base/src/main/java/de/mm20/launcher2/licenses/OpenSourceLicenses.kt index 54e7c489..4aa22861 100644 --- a/core/base/src/main/java/de/mm20/launcher2/licenses/OpenSourceLicenses.kt +++ b/core/base/src/main/java/de/mm20/launcher2/licenses/OpenSourceLicenses.kt @@ -220,4 +220,12 @@ val OpenSourceLicenses = arrayOf( url = "https://github.com/woheller69/AndroidAddressFormatter", copyrightNote = "Copyright (c) 2022 woheller69", ), + OpenSourceLibrary( + name = "String Similarity for Kotlin", + description = "A library that implements various measures of string similarity and distance.", + licenseName = R.string.mit_license_name, + licenseText = R.raw.license_mit, + url = "https://github.com/aallam/string-similarity-kotlin", + copyrightNote = "Copyright (c) 2023 Mouaad Aallam", + ), ) \ No newline at end of file diff --git a/core/base/src/main/java/de/mm20/launcher2/search/ResultScore.kt b/core/base/src/main/java/de/mm20/launcher2/search/ResultScore.kt new file mode 100644 index 00000000..6e3d868c --- /dev/null +++ b/core/base/src/main/java/de/mm20/launcher2/search/ResultScore.kt @@ -0,0 +1,94 @@ +package de.mm20.launcher2.search + +import com.aallam.similarity.JaroWinkler +import de.mm20.launcher2.ktx.normalize + +@JvmInline +value class ResultScore private constructor(private val packed: Long) : Comparable { + constructor( + isPrefix: Boolean, + isSubstring: Boolean, + isPrimary: Boolean, + similarity: Float, + ) : this( + (similarity.toRawBits().toLong()) or + (if (isPrefix) (1L shl 32) else 0) or + (if (isSubstring) (1L shl 33) else 0) or + (if (isPrimary) (1L shl 34) else 0) + ) + + /** + * Whether the query is a literal prefix of the result. + */ + val isPrefix: Boolean + get() = (packed and (1L shl 32)) != 0L + + /** + * Whether the query is a substring of the result. + */ + val isSubstring: Boolean + get() = (packed and (1L shl 33)) != 0L + + /** + * Whether the query was matched against a primary field. + */ + val isPrimary: Boolean + get() = (packed and (1L shl 34)) != 0L + + /** + * The Jaro-Winkler similarity between the query and the result. + */ + val similarity: Float + get() = Float.fromBits((packed and 0xffffffffL).toInt()) + + /** + * A total score for the result, combining the similarity with additional factors. + */ + val score: Float + get() = (similarity + (if (isPrefix) 0.8f else 0f) + (if (isSubstring) 0.8f else 0f)) * (if (isPrimary) 1f else 0.5f) + + override fun compareTo(other: ResultScore): Int { + return score.compareTo(other.score) + } + + companion object { + operator fun invoke( + query: String, + primaryFields: Iterable = emptyList(), + secondaryFields: Iterable = emptyList(), + ): ResultScore { + val normalizedQuery = query.normalize() + val jaroWinkler = JaroWinkler() + val bestPrimaryScore = primaryFields.maxOfOrNull { + val normalizedTerm = it.normalize() + val sim = jaroWinkler.similarity(normalizedQuery, normalizedTerm).toFloat() + ResultScore( + isPrefix = normalizedTerm.startsWith(normalizedQuery), + isSubstring = normalizedQuery in normalizedTerm, + isPrimary = true, + similarity = sim + ) + } ?: Zero + val bestSecondaryScore = secondaryFields.maxOfOrNull { + val normalizedTerm = it.normalize() + val sim = jaroWinkler.similarity(normalizedQuery, normalizedTerm).toFloat() + ResultScore( + isPrefix = normalizedTerm.startsWith(normalizedQuery), + isSubstring = normalizedQuery in normalizedTerm, + isPrimary = false, + similarity = sim + ) + } ?: Zero + + return maxOf(bestPrimaryScore, bestSecondaryScore) + } + + val Zero = ResultScore( + isPrefix = false, + isSubstring = false, + isPrimary = false, + similarity = 0f + ) + } + +} \ No newline at end of file diff --git a/core/base/src/main/java/de/mm20/launcher2/search/Searchable.kt b/core/base/src/main/java/de/mm20/launcher2/search/Searchable.kt index 7c2040ed..bc3c21b1 100644 --- a/core/base/src/main/java/de/mm20/launcher2/search/Searchable.kt +++ b/core/base/src/main/java/de/mm20/launcher2/search/Searchable.kt @@ -2,4 +2,7 @@ package de.mm20.launcher2.search import kotlinx.coroutines.Deferred -interface Searchable \ No newline at end of file +interface Searchable { + val score: ResultScore + get() = ResultScore.Zero +} \ No newline at end of file diff --git a/data/applications/src/main/java/de/mm20/launcher2/applications/AppRepository.kt b/data/applications/src/main/java/de/mm20/launcher2/applications/AppRepository.kt index 211af10b..3db58c90 100644 --- a/data/applications/src/main/java/de/mm20/launcher2/applications/AppRepository.kt +++ b/data/applications/src/main/java/de/mm20/launcher2/applications/AppRepository.kt @@ -14,6 +14,7 @@ import de.mm20.launcher2.ktx.normalize import de.mm20.launcher2.profiles.Profile import de.mm20.launcher2.profiles.ProfileManager import de.mm20.launcher2.search.Application +import de.mm20.launcher2.search.ResultScore import de.mm20.launcher2.search.SearchableRepository import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -37,6 +38,7 @@ interface AppRepository : SearchableRepository { packageName: String, user: UserHandle, ): Flow + fun findMany(): Flow> } @@ -244,8 +246,13 @@ internal class AppRepositoryImpl( if (query.isEmpty()) { appResults.addAll(apps) } else { - appResults.addAll(apps.filter { - matches(it.label, query) + appResults.addAll(apps.mapNotNull { + it.copy( + score = ResultScore( + query = query, + primaryFields = listOf(it.label), + ) + ).takeIf { it.score.score >= 0.8f } }) val componentName = ComponentName.unflattenFromString(query) diff --git a/data/applications/src/main/java/de/mm20/launcher2/applications/LauncherApp.kt b/data/applications/src/main/java/de/mm20/launcher2/applications/LauncherApp.kt index d52ddb25..0ba39f23 100644 --- a/data/applications/src/main/java/de/mm20/launcher2/applications/LauncherApp.kt +++ b/data/applications/src/main/java/de/mm20/launcher2/applications/LauncherApp.kt @@ -28,6 +28,7 @@ import de.mm20.launcher2.icons.TransparentLayer import de.mm20.launcher2.ktx.getSerialNumber import de.mm20.launcher2.ktx.isAtLeastApiLevel import de.mm20.launcher2.search.Application +import de.mm20.launcher2.search.ResultScore import de.mm20.launcher2.search.SearchableSerializer import de.mm20.launcher2.search.StoreLink import kotlinx.coroutines.Dispatchers @@ -40,6 +41,7 @@ internal data class LauncherApp( override val isSuspended: Boolean = false, internal val userSerialNumber: Long, override val labelOverride: String? = null, + override val score: ResultScore = ResultScore.Zero, ) : Application { override val componentName: ComponentName @@ -48,11 +50,12 @@ internal data class LauncherApp( override val label: String = launcherActivityInfo.label.toString() - constructor(context: Context, launcherActivityInfo: LauncherActivityInfo) : this( + constructor(context: Context, launcherActivityInfo: LauncherActivityInfo, score: ResultScore = ResultScore.Zero) : this( launcherActivityInfo, versionName = getPackageVersionName(context, launcherActivityInfo.applicationInfo.packageName), isSuspended = launcherActivityInfo.applicationInfo.flags and ApplicationInfo.FLAG_SUSPENDED != 0, - userSerialNumber = launcherActivityInfo.user.getSerialNumber(context) + userSerialNumber = launcherActivityInfo.user.getSerialNumber(context), + score = score, ) override val user: UserHandle diff --git a/data/database/src/main/java/de/mm20/launcher2/database/SearchableDao.kt b/data/database/src/main/java/de/mm20/launcher2/database/SearchableDao.kt index f8febdb2..92d6e41f 100644 --- a/data/database/src/main/java/de/mm20/launcher2/database/SearchableDao.kt +++ b/data/database/src/main/java/de/mm20/launcher2/database/SearchableDao.kt @@ -1,8 +1,8 @@ package de.mm20.launcher2.database -import android.util.Log import androidx.room.Dao import androidx.room.Insert +import androidx.room.MapColumn import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction @@ -201,6 +201,9 @@ interface SearchableDao { @Query("SELECT `key` FROM Searchable WHERE `key` IN (:keys) ORDER BY `weight` DESC, pinPosition DESC") fun sortByWeight(keys: List): Flow> + @Query("SELECT `key`, `weight` FROM Searchable WHERE `key` IN (:keys)") + fun getWeights(keys: List): Flow> + @Query("SELECT hidden FROM Searchable WHERE `key` = :key UNION SELECT 0 as hidden ORDER BY hidden DESC LIMIT 1") fun getVisibility(key: String): Flow diff --git a/data/searchable/src/main/java/de/mm20/launcher2/searchable/SavableSearchableRepository.kt b/data/searchable/src/main/java/de/mm20/launcher2/searchable/SavableSearchableRepository.kt index 6eb5173a..65c27303 100644 --- a/data/searchable/src/main/java/de/mm20/launcher2/searchable/SavableSearchableRepository.kt +++ b/data/searchable/src/main/java/de/mm20/launcher2/searchable/SavableSearchableRepository.kt @@ -103,6 +103,8 @@ interface SavableSearchableRepository : Backupable { fun sortByWeight(keys: List): Flow> + fun getWeights(keys: List): Flow> + /** * Remove this item from the Searchable database */ @@ -370,6 +372,11 @@ internal class SavableSearchableRepositoryImpl( return database.searchableDao().sortByWeight(keys) } + override fun getWeights(keys: List): Flow> { + if (keys.size > 999) return flowOf(emptyMap()) + return database.searchableDao().getWeights(keys) + } + private suspend fun fromDatabaseEntity(entity: SavedSearchableEntity): SavedSearchable { val deserializer: SearchableDeserializer? = try { get(named(entity.type)) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5069a7e6..173295bf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -118,6 +118,7 @@ leakcanary = { group = "com.squareup.leakcanary", name = "leakcanary", version = suncalc = { group = "org.shredzone.commons", name = "commons-suncalc", version = "3.7" } jsoup = { group = "org.jsoup", name = "jsoup", version = "1.16.1" } commons-text = { group = "org.apache.commons", name = "commons-text", version = "1.10.0" } +stringsimilarity = { group = "com.aallam.similarity", name = "string-similarity-kotlin", version = "0.1.0" } # 4.4.2 is the last GPL compatible version, don't update to 5.x mathparser = { group = "org.mariuszgromada.math", name = "MathParser.org-mXparser", version = "4.4.2" }