3 Commits

Author SHA1 Message Date
16a75a9e7e release build v0.3.4
Some checks failed
Android Main Branch CI / Build, Sign & Upload (push) Has been cancelled
Update CHANGELOG.md / changelog (push) Has been cancelled
Validate Gradle Wrapper / Validation (push) Has been cancelled
Android Release CI / Build, Sign & Release (push) Has been cancelled
Nightly Release / release (push) Has been cancelled
Nightly Release / Build, Sign & Release (push) Has been cancelled
Delete Unused Caches / delete (push) Has been cancelled
Close Inactive Issues & Pull Requests / close-issues (push) Has been cancelled
2026-08-18 10:22:03 +02:00
bea62e8d88 better fuzzy search
Some checks failed
Android Main Branch CI / Build, Sign & Upload (push) Has been cancelled
Update CHANGELOG.md / changelog (push) Has been cancelled
Validate Gradle Wrapper / Validation (push) Has been cancelled
2026-08-18 10:13:25 +02:00
e81b4e8ac1 ignore .pi 2026-08-18 10:13:11 +02:00
16 changed files with 177 additions and 150 deletions

1
.gitignore vendored
View File

@@ -12,3 +12,4 @@ git-cliff*
/app/build /app/build
/app/debug /app/debug
diff.* diff.*
/.pi

View File

@@ -19,8 +19,8 @@ android {
applicationId = "app.easy.launcher" applicationId = "app.easy.launcher"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 33 versionCode = 34
versionName = "0.3.3" versionName = "0.3.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
manifestPlaceholders["internetPermission"] = "android.permission.INTERNET" manifestPlaceholders["internetPermission"] = "android.permission.INTERNET"

View File

@@ -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()
} }
} }

View File

@@ -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()
}
} }

View File

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

View File

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

View File

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

View File

@@ -76,6 +76,4 @@ class AppViewModel @Inject constructor(
appInfoRepository.updateInfo(appInfo) appInfoRepository.updateInfo(appInfo)
} }
} }
fun searchAppInfo() = appInfoRepository.searchNote()
} }

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
dist/EasyLauncher-v0.3.3-Signed.apk vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
dist/EasyLauncher-v0.3.4-Signed.apk vendored Normal file

Binary file not shown.

Binary file not shown.