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> {
|
private suspend fun <T : SavableSearchable> List<T>.applyRanking(order: SearchResultOrder): List<T> {
|
||||||
if (size <= 1) return this
|
if (size <= 1) return this
|
||||||
val sequence = asSequence()
|
val sequence = asSequence()
|
||||||
val sorted = if (order == SearchResultOrder.Weighted) {
|
val weights = searchableRepository.getWeights(map { it.key }).first()
|
||||||
val sortedKeys = searchableRepository.sortByWeight(map { it.key }).first()
|
val sorted = sequence.sortedWith { a, b ->
|
||||||
sequence.sortedWith { a, b ->
|
val aWeight = weights[a.key] ?: 0.0
|
||||||
val aRank = sortedKeys.indexOf(a.key)
|
val bWeight = weights[b.key] ?: 0.0
|
||||||
val bRank = sortedKeys.indexOf(b.key)
|
|
||||||
when {
|
val aScore = a.score.score * 0.7f + aWeight.toFloat() * 0.3f
|
||||||
aRank != -1 && bRank != -1 -> aRank.compareTo(bRank)
|
val bScore = b.score.score * 0.7f + bWeight.toFloat() * 0.3f
|
||||||
aRank == -1 && bRank != -1 -> 1
|
|
||||||
aRank != -1 && bRank == -1 -> -1
|
bScore.compareTo(aScore)
|
||||||
else -> a.compareTo(b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
sequence.sorted()
|
|
||||||
}
|
}
|
||||||
return sorted.distinctBy { it.key }.toList()
|
return sorted.distinctBy { it.key }.toList()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,6 +52,8 @@ dependencies {
|
|||||||
runtimeOnly(libs.androidx.compose.runtime)
|
runtimeOnly(libs.androidx.compose.runtime)
|
||||||
implementation(libs.androidx.compose.materialicons)
|
implementation(libs.androidx.compose.materialicons)
|
||||||
|
|
||||||
|
implementation(libs.stringsimilarity)
|
||||||
|
|
||||||
implementation(project(":core:ktx"))
|
implementation(project(":core:ktx"))
|
||||||
implementation(project(":core:i18n"))
|
implementation(project(":core:i18n"))
|
||||||
implementation(project(":libs:material-color-utilities"))
|
implementation(project(":libs:material-color-utilities"))
|
||||||
|
|||||||
@ -220,4 +220,12 @@ val OpenSourceLicenses = arrayOf(
|
|||||||
url = "https://github.com/woheller69/AndroidAddressFormatter",
|
url = "https://github.com/woheller69/AndroidAddressFormatter",
|
||||||
copyrightNote = "Copyright (c) 2022 woheller69",
|
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
|
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.Profile
|
||||||
import de.mm20.launcher2.profiles.ProfileManager
|
import de.mm20.launcher2.profiles.ProfileManager
|
||||||
import de.mm20.launcher2.search.Application
|
import de.mm20.launcher2.search.Application
|
||||||
|
import de.mm20.launcher2.search.ResultScore
|
||||||
import de.mm20.launcher2.search.SearchableRepository
|
import de.mm20.launcher2.search.SearchableRepository
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
@ -37,6 +38,7 @@ interface AppRepository : SearchableRepository<Application> {
|
|||||||
packageName: String,
|
packageName: String,
|
||||||
user: UserHandle,
|
user: UserHandle,
|
||||||
): Flow<Application?>
|
): Flow<Application?>
|
||||||
|
|
||||||
fun findMany(): Flow<ImmutableList<Application>>
|
fun findMany(): Flow<ImmutableList<Application>>
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -244,8 +246,13 @@ internal class AppRepositoryImpl(
|
|||||||
if (query.isEmpty()) {
|
if (query.isEmpty()) {
|
||||||
appResults.addAll(apps)
|
appResults.addAll(apps)
|
||||||
} else {
|
} else {
|
||||||
appResults.addAll(apps.filter {
|
appResults.addAll(apps.mapNotNull {
|
||||||
matches(it.label, query)
|
it.copy(
|
||||||
|
score = ResultScore(
|
||||||
|
query = query,
|
||||||
|
primaryFields = listOf(it.label),
|
||||||
|
)
|
||||||
|
).takeIf { it.score.score >= 0.8f }
|
||||||
})
|
})
|
||||||
|
|
||||||
val componentName = ComponentName.unflattenFromString(query)
|
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.getSerialNumber
|
||||||
import de.mm20.launcher2.ktx.isAtLeastApiLevel
|
import de.mm20.launcher2.ktx.isAtLeastApiLevel
|
||||||
import de.mm20.launcher2.search.Application
|
import de.mm20.launcher2.search.Application
|
||||||
|
import de.mm20.launcher2.search.ResultScore
|
||||||
import de.mm20.launcher2.search.SearchableSerializer
|
import de.mm20.launcher2.search.SearchableSerializer
|
||||||
import de.mm20.launcher2.search.StoreLink
|
import de.mm20.launcher2.search.StoreLink
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@ -40,6 +41,7 @@ internal data class LauncherApp(
|
|||||||
override val isSuspended: Boolean = false,
|
override val isSuspended: Boolean = false,
|
||||||
internal val userSerialNumber: Long,
|
internal val userSerialNumber: Long,
|
||||||
override val labelOverride: String? = null,
|
override val labelOverride: String? = null,
|
||||||
|
override val score: ResultScore = ResultScore.Zero,
|
||||||
) : Application {
|
) : Application {
|
||||||
|
|
||||||
override val componentName: ComponentName
|
override val componentName: ComponentName
|
||||||
@ -48,11 +50,12 @@ internal data class LauncherApp(
|
|||||||
override val label: String = launcherActivityInfo.label.toString()
|
override val label: String = launcherActivityInfo.label.toString()
|
||||||
|
|
||||||
|
|
||||||
constructor(context: Context, launcherActivityInfo: LauncherActivityInfo) : this(
|
constructor(context: Context, launcherActivityInfo: LauncherActivityInfo, score: ResultScore = ResultScore.Zero) : this(
|
||||||
launcherActivityInfo,
|
launcherActivityInfo,
|
||||||
versionName = getPackageVersionName(context, launcherActivityInfo.applicationInfo.packageName),
|
versionName = getPackageVersionName(context, launcherActivityInfo.applicationInfo.packageName),
|
||||||
isSuspended = launcherActivityInfo.applicationInfo.flags and ApplicationInfo.FLAG_SUSPENDED != 0,
|
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
|
override val user: UserHandle
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
package de.mm20.launcher2.database
|
package de.mm20.launcher2.database
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
|
import androidx.room.MapColumn
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import androidx.room.Transaction
|
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")
|
@Query("SELECT `key` FROM Searchable WHERE `key` IN (:keys) ORDER BY `weight` DESC, pinPosition DESC")
|
||||||
fun sortByWeight(keys: List<String>): Flow<List<String>>
|
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")
|
@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>
|
fun getVisibility(key: String): Flow<Int>
|
||||||
|
|
||||||
|
|||||||
@ -103,6 +103,8 @@ interface SavableSearchableRepository : Backupable {
|
|||||||
|
|
||||||
fun sortByWeight(keys: List<String>): Flow<List<String>>
|
fun sortByWeight(keys: List<String>): Flow<List<String>>
|
||||||
|
|
||||||
|
fun getWeights(keys: List<String>): Flow<Map<String, Double>>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove this item from the Searchable database
|
* Remove this item from the Searchable database
|
||||||
*/
|
*/
|
||||||
@ -370,6 +372,11 @@ internal class SavableSearchableRepositoryImpl(
|
|||||||
return database.searchableDao().sortByWeight(keys)
|
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 {
|
private suspend fun fromDatabaseEntity(entity: SavedSearchableEntity): SavedSearchable {
|
||||||
val deserializer: SearchableDeserializer? = try {
|
val deserializer: SearchableDeserializer? = try {
|
||||||
get(named(entity.type))
|
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" }
|
suncalc = { group = "org.shredzone.commons", name = "commons-suncalc", version = "3.7" }
|
||||||
jsoup = { group = "org.jsoup", name = "jsoup", version = "1.16.1" }
|
jsoup = { group = "org.jsoup", name = "jsoup", version = "1.16.1" }
|
||||||
commons-text = { group = "org.apache.commons", name = "commons-text", version = "1.10.0" }
|
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
|
# 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" }
|
mathparser = { group = "org.mariuszgromada.math", name = "MathParser.org-mXparser", version = "4.4.2" }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user