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/debug
diff.*
/.pi

View File

@@ -19,8 +19,8 @@ android {
applicationId = "app.easy.launcher"
minSdk = 24
targetSdk = 36
versionCode = 33
versionName = "0.3.3"
versionCode = 34
versionName = "0.3.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
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 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 {
fun scoreApp(app: AppInfo, searchChars: String, topScore: Int): Int {
val appChars = app.appName
val fuzzyScore = calculateFuzzyScore(
normalizeString(appChars),
normalizeString(searchChars)
)
/** One app with its precomputed search data. */
class IndexedApp(val app: AppInfo) {
/** 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 {
return (appLabel.contains(searchChars, true) or
Normalizer.normalize(appLabel, Normalizer.Form.NFD)
.replace(Regex("\\p{InCombiningDiacriticalMarks}+"), "")
.replace(Regex("[-_+,. ]"), "")
.contains(searchChars, true))
/** Precomputes search data for a list of apps. Call once per app list change. */
fun buildIndex(apps: List<AppInfo>): List<IndexedApp> = apps.map(::IndexedApp)
/** Normalizes a name or a query: uppercase, no diacritics, no separators. */
fun normalize(input: String): String {
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 {
// Remove diacritical marks and special characters, and convert to uppercase
return input
.uppercase(Locale.getDefault())
.replace(Regex("[\\p{InCombiningDiacriticalMarks}-_+,.]"), "")
}
private fun wordStartsOf(original: String, normalized: String): BooleanArray {
val nfd = Normalizer.normalize(original, Normalizer.Form.NFD)
val starts = BooleanArray(normalized.length)
var atWordStart = true
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 {
val m = s1.length
val n = s2.length
var matchCount = 0
var s1Index = 0
Character.getType(ch) == Character.NON_SPACING_MARK.toInt() -> {
// Combining mark left over from NFD (the accent of "é") — ignore.
}
// Iterate over each character in s2 and check if it exists in s1
for (c2 in s2) {
var found = 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
else -> {
// Separator: the next letter begins a new word.
atWordStart = true
prevWasLower = false
}
}
}
return starts
}
// If the current character in s2 is not found in s1, return a score of 0
if (!found) {
return 0f
}
/**
* Scores [entry] against an already normalized [query] (see [normalize]).
*
* 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
matchCount++
var nameIndex = 0
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
return matchCount.toFloat() / maxOf(m, n)
if (firstMatch == 0 && qLen == nLen) return 100 // exact match
// 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
import android.annotation.SuppressLint
import android.view.LayoutInflater
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
@@ -49,10 +48,4 @@ class DrawAdapter(
override fun areContentsTheSame(oldItem: AppInfo, newItem: AppInfo) =
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")
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
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) {
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
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
@@ -165,10 +171,15 @@ class DrawFragment : Fragment(),
repeatOnLifecycle(Lifecycle.State.CREATED) {
// Collect the drawer apps from the ViewModel
viewModel.drawApps.collect { apps ->
// Update the adapter with the new list of apps
drawAdapter.submitList(apps)
// Update the adapter's data with the new state flow
drawAdapter.updateDataWithStateFlow(apps)
// Rebuild the search index from the fresh app list
searchIndex = FuzzyFinder.buildIndex(apps)
// Keep whatever search results are currently shown, or show the full list
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("!")
requireContext().searchCustomSearchEngine(preferenceHelper, searchQuery)
} else {
searchApp(trimmedQuery, false)
return true // Exit the function
val results = performSearch(trimmedQuery)
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 {
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
}
})
@@ -308,91 +332,41 @@ class DrawFragment : Fragment(),
}
}
private fun searchApp(query: String, isSearching: Boolean) {
// Launch a coroutine tied to the lifecycle of the view
viewLifecycleOwner.lifecycleScope.launch {
// Repeat the block when the lifecycle is at least CREATED
repeatOnLifecycle(Lifecycle.State.CREATED) {
val trimmedQuery = query.trim()
/**
* Runs the fuzzy search synchronously over the in-memory index.
*
* Called on every keystroke, including the very first character. This is
* fast enough to run on the main thread — the index holds plain strings
* 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
viewModel.searchAppInfo().collect { searchResults ->
// Filter and score results using FuzzyFinder
val filteredResults = searchResults
.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
// Normalize the query once, not once per app
val normalizedQuery = FuzzyFinder.normalize(trimmedQuery)
val minScore = preferenceHelper.filterStrength
val searchFromStart = preferenceHelper.searchFromStart
// Applying additional filtering based on preferences
val scoredApps = filteredResults.toMap()
val finalResults = if (preferenceHelper.filterStrength >= 1) {
// Filtering based on score strength
if (preferenceHelper.searchFromStart) {
// Filter apps that start with the search query and score higher than the filter strength
scoredApps.filter { (app, _) ->
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)
}
}
}
val scored = ArrayList<Pair<FuzzyFinder.IndexedApp, Int>>()
for (entry in index) {
val score = FuzzyFinder.score(entry, normalizedQuery)
// Compare against the normalized name, so "fdroid" matches "F-Droid"
val matchesFromStart = !searchFromStart ||
entry.normalizedName.startsWith(normalizedQuery)
if (score > minScore && matchesFromStart) {
scored.add(entry to score)
}
}
// 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) {

View File

@@ -76,6 +76,4 @@ class AppViewModel @Inject constructor(
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.