better fuzzy search
This commit is contained in:
@@ -2,65 +2,133 @@ package com.github.droidworksstudio.fuzzywuzzy
|
|||||||
|
|
||||||
import com.github.droidworksstudio.launcher.data.entities.AppInfo
|
import com.github.droidworksstudio.launcher.data.entities.AppInfo
|
||||||
import java.text.Normalizer
|
import java.text.Normalizer
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fast in-memory fuzzy search index for app names.
|
||||||
|
*
|
||||||
|
* Matching runs on a normalized form of the app name: uppercase, without
|
||||||
|
* diacritical marks and without separators, so "fdroid" finds "F-Droid".
|
||||||
|
* A query matches when all of its characters appear in the name in order
|
||||||
|
* (a subsequence match), but not necessarily consecutively: "fro" finds
|
||||||
|
* "F-Droid" too.
|
||||||
|
*
|
||||||
|
* The normalized names and word boundaries are precomputed once per app list
|
||||||
|
* ([buildIndex]), so scoring one keystroke is a plain scan over plain strings
|
||||||
|
* — no regex, no allocations per app, no database query. A few hundred apps
|
||||||
|
* score in well under a millisecond.
|
||||||
|
*/
|
||||||
object FuzzyFinder {
|
object FuzzyFinder {
|
||||||
fun scoreApp(app: AppInfo, searchChars: String, topScore: Int): Int {
|
|
||||||
val appChars = app.appName
|
|
||||||
|
|
||||||
val fuzzyScore = calculateFuzzyScore(
|
/** One app with its precomputed search data. */
|
||||||
normalizeString(appChars),
|
class IndexedApp(val app: AppInfo) {
|
||||||
normalizeString(searchChars)
|
/** App name, uppercased, without diacritics and separators. */
|
||||||
)
|
val normalizedName: String = normalize(app.appName)
|
||||||
|
|
||||||
return (fuzzyScore * topScore).toInt()
|
/** True at positions in [normalizedName] that begin a word. */
|
||||||
|
val wordStarts: BooleanArray = wordStartsOf(app.appName, normalizedName)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun normalizeString(appLabel: String, searchChars: String): Boolean {
|
/** Precomputes search data for a list of apps. Call once per app list change. */
|
||||||
return (appLabel.contains(searchChars, true) or
|
fun buildIndex(apps: List<AppInfo>): List<IndexedApp> = apps.map(::IndexedApp)
|
||||||
Normalizer.normalize(appLabel, Normalizer.Form.NFD)
|
|
||||||
.replace(Regex("\\p{InCombiningDiacriticalMarks}+"), "")
|
/** Normalizes a name or a query: uppercase, no diacritics, no separators. */
|
||||||
.replace(Regex("[-_+,. ]"), "")
|
fun normalize(input: String): String {
|
||||||
.contains(searchChars, true))
|
val nfd = Normalizer.normalize(input, Normalizer.Form.NFD)
|
||||||
|
val sb = StringBuilder(nfd.length)
|
||||||
|
for (ch in nfd) {
|
||||||
|
if (ch.isLetterOrDigit()) {
|
||||||
|
sb.append(ch.uppercaseChar())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun normalizeString(input: String): String {
|
private fun wordStartsOf(original: String, normalized: String): BooleanArray {
|
||||||
// Remove diacritical marks and special characters, and convert to uppercase
|
val nfd = Normalizer.normalize(original, Normalizer.Form.NFD)
|
||||||
return input
|
val starts = BooleanArray(normalized.length)
|
||||||
.uppercase(Locale.getDefault())
|
var atWordStart = true
|
||||||
.replace(Regex("[\\p{InCombiningDiacriticalMarks}-_+,.]"), "")
|
var prevWasLower = false
|
||||||
}
|
var normalizedIndex = 0
|
||||||
|
for (ch in nfd) {
|
||||||
|
when {
|
||||||
|
ch.isLetterOrDigit() -> {
|
||||||
|
// Camel case: "easyLauncher" starts a word at the uppercase letter.
|
||||||
|
starts[normalizedIndex] = atWordStart || (prevWasLower && ch.isUpperCase())
|
||||||
|
atWordStart = false
|
||||||
|
prevWasLower = ch.isLowerCase()
|
||||||
|
normalizedIndex++
|
||||||
|
}
|
||||||
|
|
||||||
private fun calculateFuzzyScore(s1: String, s2: String): Float {
|
Character.getType(ch) == Character.NON_SPACING_MARK.toInt() -> {
|
||||||
val m = s1.length
|
// Combining mark left over from NFD (the accent of "é") — ignore.
|
||||||
val n = s2.length
|
}
|
||||||
var matchCount = 0
|
|
||||||
var s1Index = 0
|
|
||||||
|
|
||||||
// Iterate over each character in s2 and check if it exists in s1
|
else -> {
|
||||||
for (c2 in s2) {
|
// Separator: the next letter begins a new word.
|
||||||
var found = false
|
atWordStart = true
|
||||||
|
prevWasLower = false
|
||||||
// Start searching for c2 from the current s1Index
|
|
||||||
for (j in s1Index until m) {
|
|
||||||
if (s1[j] == c2) {
|
|
||||||
found = true
|
|
||||||
// Update s1Index to the next position for the next iteration
|
|
||||||
s1Index = j + 1
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return starts
|
||||||
|
}
|
||||||
|
|
||||||
// If the current character in s2 is not found in s1, return a score of 0
|
/**
|
||||||
if (!found) {
|
* Scores [entry] against an already normalized [query] (see [normalize]).
|
||||||
return 0f
|
*
|
||||||
}
|
* Returns 0 when the query is not a subsequence of the app name, otherwise
|
||||||
|
* a score from 1 to 100. Matches at the start of the name, consecutive
|
||||||
|
* runs and word boundaries score higher, so the best match ranks on top.
|
||||||
|
* An exact match always scores 100.
|
||||||
|
*/
|
||||||
|
fun score(entry: IndexedApp, query: String): Int {
|
||||||
|
val name = entry.normalizedName
|
||||||
|
val qLen = query.length
|
||||||
|
val nLen = name.length
|
||||||
|
if (qLen == 0) return 100
|
||||||
|
if (qLen > nLen) return 0
|
||||||
|
|
||||||
// Increment the match count
|
var nameIndex = 0
|
||||||
matchCount++
|
var prevMatch = -2
|
||||||
|
var run = 0
|
||||||
|
var runPoints = 0
|
||||||
|
var boundaryHits = 0
|
||||||
|
var firstMatch = -1
|
||||||
|
|
||||||
|
for (i in 0 until qLen) {
|
||||||
|
val c = query[i]
|
||||||
|
while (nameIndex < nLen && name[nameIndex] != c) nameIndex++
|
||||||
|
if (nameIndex == nLen) return 0
|
||||||
|
if (firstMatch < 0) firstMatch = nameIndex
|
||||||
|
run = if (nameIndex == prevMatch + 1) run + 1 else 1
|
||||||
|
runPoints += run
|
||||||
|
if (entry.wordStarts[nameIndex]) boundaryHits++
|
||||||
|
prevMatch = nameIndex
|
||||||
|
nameIndex++
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate the score as the ratio of matched characters to the longer string length
|
if (firstMatch == 0 && qLen == nLen) return 100 // exact match
|
||||||
return matchCount.toFloat() / maxOf(m, n)
|
|
||||||
|
// Where does the match start? 40 points at the start, falling off quickly.
|
||||||
|
val startScore = when {
|
||||||
|
firstMatch == 0 -> 40
|
||||||
|
firstMatch == 1 -> 30
|
||||||
|
else -> (20 - firstMatch).coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Are the query characters consecutive? Up to 30 points. runPoints is
|
||||||
|
// the sum of run lengths: 1 + 2 + ... + qLen for a perfectly
|
||||||
|
// consecutive match, qLen for a fully scattered one.
|
||||||
|
val consecutiveScore = if (qLen > 1) {
|
||||||
|
val scattered = runPoints.toDouble() / qLen
|
||||||
|
((scattered - 1.0) / ((qLen + 1.0) / 2.0 - 1.0)).coerceIn(0.0, 1.0)
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do matches hit word starts? Up to 30 points.
|
||||||
|
val boundaryScore = boundaryHits.toDouble() / qLen
|
||||||
|
|
||||||
|
return (startScore + 30 * consecutiveScore + 30 * boundaryScore).toInt()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.github.droidworksstudio.launcher.adapter.drawer
|
package com.github.droidworksstudio.launcher.adapter.drawer
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.ListAdapter
|
import androidx.recyclerview.widget.ListAdapter
|
||||||
@@ -49,10 +48,4 @@ class DrawAdapter(
|
|||||||
override fun areContentsTheSame(oldItem: AppInfo, newItem: AppInfo) =
|
override fun areContentsTheSame(oldItem: AppInfo, newItem: AppInfo) =
|
||||||
oldItem == newItem
|
oldItem == newItem
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("NotifyDataSetChanged")
|
|
||||||
fun updateDataWithStateFlow(newData: List<AppInfo>) {
|
|
||||||
submitList(newData.toMutableList())
|
|
||||||
notifyDataSetChanged()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -49,9 +49,6 @@ interface AppInfoDAO {
|
|||||||
@Query("SELECT * FROM app WHERE is_lock = 1 ORDER BY app_order ASC")
|
@Query("SELECT * FROM app WHERE is_lock = 1 ORDER BY app_order ASC")
|
||||||
fun getLockAppsFlow(): Flow<List<AppInfo>>
|
fun getLockAppsFlow(): Flow<List<AppInfo>>
|
||||||
|
|
||||||
@Query("SELECT * FROM app WHERE is_hidden = 0 ORDER BY app_name COLLATE NOCASE ASC")
|
|
||||||
fun searchApps(): Flow<List<AppInfo>>
|
|
||||||
|
|
||||||
@Update
|
@Update
|
||||||
suspend fun updateAppInfo(appInfo: AppInfo)
|
suspend fun updateAppInfo(appInfo: AppInfo)
|
||||||
|
|
||||||
|
|||||||
@@ -53,10 +53,6 @@ class AppInfoRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun searchNote(): Flow<List<AppInfo>> {
|
|
||||||
return appDao.searchApps()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun updateFavoriteAppInfo(appInfo: AppInfo) = withContext(Dispatchers.IO) {
|
suspend fun updateFavoriteAppInfo(appInfo: AppInfo) = withContext(Dispatchers.IO) {
|
||||||
|
|
||||||
if (appInfo.favorite) {
|
if (appInfo.favorite) {
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ class DrawFragment : Fragment(),
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory search index, rebuilt whenever the app list changes. Searching
|
||||||
|
* reads only from this index — no database query per keystroke.
|
||||||
|
*/
|
||||||
|
private var searchIndex: List<FuzzyFinder.IndexedApp> = emptyList()
|
||||||
|
|
||||||
private lateinit var context: Context
|
private lateinit var context: Context
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater, container: ViewGroup?,
|
inflater: LayoutInflater, container: ViewGroup?,
|
||||||
@@ -165,10 +171,15 @@ class DrawFragment : Fragment(),
|
|||||||
repeatOnLifecycle(Lifecycle.State.CREATED) {
|
repeatOnLifecycle(Lifecycle.State.CREATED) {
|
||||||
// Collect the drawer apps from the ViewModel
|
// Collect the drawer apps from the ViewModel
|
||||||
viewModel.drawApps.collect { apps ->
|
viewModel.drawApps.collect { apps ->
|
||||||
// Update the adapter with the new list of apps
|
// Rebuild the search index from the fresh app list
|
||||||
drawAdapter.submitList(apps)
|
searchIndex = FuzzyFinder.buildIndex(apps)
|
||||||
// Update the adapter's data with the new state flow
|
// Keep whatever search results are currently shown, or show the full list
|
||||||
drawAdapter.updateDataWithStateFlow(apps)
|
val query = binding.searchViewText.query?.toString().orEmpty()
|
||||||
|
if (query.isBlank()) {
|
||||||
|
drawAdapter.submitList(apps)
|
||||||
|
} else {
|
||||||
|
drawAdapter.submitList(performSearch(query))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,8 +196,15 @@ class DrawFragment : Fragment(),
|
|||||||
val searchQuery = trimmedQuery.substringAfter("!")
|
val searchQuery = trimmedQuery.substringAfter("!")
|
||||||
requireContext().searchCustomSearchEngine(preferenceHelper, searchQuery)
|
requireContext().searchCustomSearchEngine(preferenceHelper, searchQuery)
|
||||||
} else {
|
} else {
|
||||||
searchApp(trimmedQuery, false)
|
val results = performSearch(trimmedQuery)
|
||||||
return true // Exit the function
|
if (results.isEmpty()) {
|
||||||
|
if (!requireContext().searchOnPlayStore(trimmedQuery)) {
|
||||||
|
requireContext().openSearch(trimmedQuery)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
observeBioAuthCheck(results.first())
|
||||||
|
drawAdapter.submitList(results)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,7 +212,13 @@ class DrawFragment : Fragment(),
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onQueryTextChange(newText: String?): Boolean {
|
override fun onQueryTextChange(newText: String?): Boolean {
|
||||||
searchApp(newText.toString(), true)
|
val query = newText.orEmpty()
|
||||||
|
val results = performSearch(query)
|
||||||
|
// Auto-open when exactly one app matches the query
|
||||||
|
if (query.isNotBlank() && results.size == 1 && preferenceHelper.automaticOpenApp) {
|
||||||
|
observeBioAuthCheck(results.first())
|
||||||
|
}
|
||||||
|
drawAdapter.submitList(results)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -308,91 +332,41 @@ class DrawFragment : Fragment(),
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun searchApp(query: String, isSearching: Boolean) {
|
/**
|
||||||
// Launch a coroutine tied to the lifecycle of the view
|
* Runs the fuzzy search synchronously over the in-memory index.
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
*
|
||||||
// Repeat the block when the lifecycle is at least CREATED
|
* Called on every keystroke, including the very first character. This is
|
||||||
repeatOnLifecycle(Lifecycle.State.CREATED) {
|
* fast enough to run on the main thread — the index holds plain strings
|
||||||
val trimmedQuery = query.trim()
|
* and a few hundred apps score in well under a millisecond — so results
|
||||||
|
* appear in the same frame the character is typed.
|
||||||
|
*/
|
||||||
|
private fun performSearch(query: String): List<AppInfo> {
|
||||||
|
val index = searchIndex
|
||||||
|
val trimmedQuery = query.trim()
|
||||||
|
if (trimmedQuery.isEmpty()) return index.map { it.app }
|
||||||
|
|
||||||
// Collect search results from the ViewModel
|
// Normalize the query once, not once per app
|
||||||
viewModel.searchAppInfo().collect { searchResults ->
|
val normalizedQuery = FuzzyFinder.normalize(trimmedQuery)
|
||||||
// Filter and score results using FuzzyFinder
|
val minScore = preferenceHelper.filterStrength
|
||||||
val filteredResults = searchResults
|
val searchFromStart = preferenceHelper.searchFromStart
|
||||||
.map { appInfo ->
|
|
||||||
val score = FuzzyFinder.scoreApp(appInfo, trimmedQuery, Constants.FILTER_STRENGTH_MAX)
|
|
||||||
appInfo to score // Pairing app info with its score
|
|
||||||
}
|
|
||||||
.filter { it.second > 25 } // Only keep results with a positive score
|
|
||||||
.sortedByDescending { it.second } // Sort results by score, descending
|
|
||||||
|
|
||||||
// Applying additional filtering based on preferences
|
val scored = ArrayList<Pair<FuzzyFinder.IndexedApp, Int>>()
|
||||||
val scoredApps = filteredResults.toMap()
|
for (entry in index) {
|
||||||
|
val score = FuzzyFinder.score(entry, normalizedQuery)
|
||||||
val finalResults = if (preferenceHelper.filterStrength >= 1) {
|
// Compare against the normalized name, so "fdroid" matches "F-Droid"
|
||||||
// Filtering based on score strength
|
val matchesFromStart = !searchFromStart ||
|
||||||
if (preferenceHelper.searchFromStart) {
|
entry.normalizedName.startsWith(normalizedQuery)
|
||||||
// Filter apps that start with the search query and score higher than the filter strength
|
if (score > minScore && matchesFromStart) {
|
||||||
scoredApps.filter { (app, _) ->
|
scored.add(entry to score)
|
||||||
app.appName.startsWith(trimmedQuery, ignoreCase = true)
|
|
||||||
}
|
|
||||||
.filter { (_, score) -> score > preferenceHelper.filterStrength }
|
|
||||||
.map { it.key }
|
|
||||||
.toMutableList()
|
|
||||||
} else {
|
|
||||||
// Filter based on score strength alone
|
|
||||||
scoredApps.filterValues { it > preferenceHelper.filterStrength }
|
|
||||||
.keys
|
|
||||||
.toMutableList()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (preferenceHelper.searchFromStart) {
|
|
||||||
// Filter apps that start with the search query and score higher than the filter strength
|
|
||||||
searchResults.filter { app ->
|
|
||||||
FuzzyFinder.normalizeString(app.appName, trimmedQuery) ||
|
|
||||||
app.appName.startsWith(trimmedQuery, ignoreCase = true)
|
|
||||||
}.toMutableList()
|
|
||||||
} else {
|
|
||||||
// If filter strength is less than 1, normalize app names for both cases
|
|
||||||
searchResults.filter { app ->
|
|
||||||
FuzzyFinder.normalizeString(app.appName, trimmedQuery)
|
|
||||||
}.toMutableList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
val numberOfItemsLeft = finalResults.size
|
|
||||||
val appResults = finalResults.firstOrNull()
|
|
||||||
|
|
||||||
if (isSearching) {
|
|
||||||
when (numberOfItemsLeft) {
|
|
||||||
1 -> {
|
|
||||||
appResults?.let { appInfo ->
|
|
||||||
if (preferenceHelper.automaticOpenApp) observeBioAuthCheck(appInfo)
|
|
||||||
}
|
|
||||||
drawAdapter.submitList(finalResults)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
drawAdapter.submitList(finalResults)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (trimmedQuery.isEmpty()) {
|
|
||||||
drawAdapter.submitList(searchResults)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (numberOfItemsLeft == 0 && !requireContext().searchOnPlayStore(trimmedQuery)) {
|
|
||||||
requireContext().openSearch(trimmedQuery)
|
|
||||||
} else {
|
|
||||||
appResults?.let { appInfo ->
|
|
||||||
observeBioAuthCheck(appInfo)
|
|
||||||
}
|
|
||||||
drawAdapter.submitList(searchResults)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Best score first, alphabetical as a tie breaker
|
||||||
|
scored.sortWith(
|
||||||
|
compareByDescending<Pair<FuzzyFinder.IndexedApp, Int>> { it.second }
|
||||||
|
.thenBy { it.first.app.appName.lowercase() }
|
||||||
|
)
|
||||||
|
return scored.map { it.first.app }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showSelectedApp(appInfo: AppInfo) {
|
private fun showSelectedApp(appInfo: AppInfo) {
|
||||||
|
|||||||
@@ -76,6 +76,4 @@ class AppViewModel @Inject constructor(
|
|||||||
appInfoRepository.updateInfo(appInfo)
|
appInfoRepository.updateInfo(appInfo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun searchAppInfo() = appInfoRepository.searchNote()
|
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
dist/EasyLauncher-Internet-v0.3.3-Signed.apk
vendored
Normal file
BIN
dist/EasyLauncher-Internet-v0.3.3-Signed.apk
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-Internet-v0.3.3-Signed.apk.idsig
vendored
Normal file
BIN
dist/EasyLauncher-Internet-v0.3.3-Signed.apk.idsig
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-v0.3.3-Signed.apk
vendored
Normal file
BIN
dist/EasyLauncher-v0.3.3-Signed.apk
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-v0.3.3-Signed.apk.idsig
vendored
Normal file
BIN
dist/EasyLauncher-v0.3.3-Signed.apk.idsig
vendored
Normal file
Binary file not shown.
Reference in New Issue
Block a user