Improve app result ranking
This commit is contained in:
parent
422928321e
commit
bf6e963545
@ -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()
|
||||
}
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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",
|
||||
),
|
||||
)
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@ -2,4 +2,7 @@ package de.mm20.launcher2.search
|
||||
|
||||
import kotlinx.coroutines.Deferred
|
||||
|
||||
interface Searchable
|
||||
interface Searchable {
|
||||
val score: ResultScore
|
||||
get() = ResultScore.Zero
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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" }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user