Improve app result ranking

This commit is contained in:
MM20 2024-10-12 22:16:10 +02:00
parent 422928321e
commit bf6e963545
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
10 changed files with 143 additions and 20 deletions

View File

@ -472,20 +472,15 @@ class SearchVM : ViewModel(), KoinComponent {
private suspend fun <T : SavableSearchable> List<T>.applyRanking(order: SearchResultOrder): List<T> {
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()
}

View File

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

View File

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

View File

@ -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<ResultScore> {
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<String> = emptyList(),
secondaryFields: Iterable<String> = 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
)
}
}

View File

@ -2,4 +2,7 @@ package de.mm20.launcher2.search
import kotlinx.coroutines.Deferred
interface Searchable
interface Searchable {
val score: ResultScore
get() = ResultScore.Zero
}

View File

@ -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<Application> {
packageName: String,
user: UserHandle,
): Flow<Application?>
fun findMany(): Flow<ImmutableList<Application>>
}
@ -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)

View File

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

View File

@ -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<String>): Flow<List<String>>
@Query("SELECT `key`, `weight` FROM Searchable WHERE `key` IN (:keys)")
fun getWeights(keys: List<String>): Flow<Map<@MapColumn(columnName = "key") String, @MapColumn(columnName = "weight") Double>>
@Query("SELECT hidden FROM Searchable WHERE `key` = :key UNION SELECT 0 as hidden ORDER BY hidden DESC LIMIT 1")
fun getVisibility(key: String): Flow<Int>

View File

@ -103,6 +103,8 @@ interface SavableSearchableRepository : Backupable {
fun sortByWeight(keys: List<String>): Flow<List<String>>
fun getWeights(keys: List<String>): Flow<Map<String, Double>>
/**
* Remove this item from the Searchable database
*/
@ -370,6 +372,11 @@ internal class SavableSearchableRepositoryImpl(
return database.searchableDao().sortByWeight(keys)
}
override fun getWeights(keys: List<String>): Flow<Map<String, Double>> {
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))

View File

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