diff --git a/app/src/main/java/com/github/droidworksstudio/fuzzywuzzy/FuzzyFinder.kt b/app/src/main/java/com/github/droidworksstudio/fuzzywuzzy/FuzzyFinder.kt index 06d16ba..b12d52f 100644 --- a/app/src/main/java/com/github/droidworksstudio/fuzzywuzzy/FuzzyFinder.kt +++ b/app/src/main/java/com/github/droidworksstudio/fuzzywuzzy/FuzzyFinder.kt @@ -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): List = 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() } } diff --git a/app/src/main/java/com/github/droidworksstudio/launcher/adapter/drawer/DrawAdapter.kt b/app/src/main/java/com/github/droidworksstudio/launcher/adapter/drawer/DrawAdapter.kt index 83ad477..ab3ee24 100644 --- a/app/src/main/java/com/github/droidworksstudio/launcher/adapter/drawer/DrawAdapter.kt +++ b/app/src/main/java/com/github/droidworksstudio/launcher/adapter/drawer/DrawAdapter.kt @@ -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) { - submitList(newData.toMutableList()) - notifyDataSetChanged() - } } \ No newline at end of file diff --git a/app/src/main/java/com/github/droidworksstudio/launcher/data/dao/AppInfoDAO.kt b/app/src/main/java/com/github/droidworksstudio/launcher/data/dao/AppInfoDAO.kt index b8d3840..4d78632 100644 --- a/app/src/main/java/com/github/droidworksstudio/launcher/data/dao/AppInfoDAO.kt +++ b/app/src/main/java/com/github/droidworksstudio/launcher/data/dao/AppInfoDAO.kt @@ -49,9 +49,6 @@ interface AppInfoDAO { @Query("SELECT * FROM app WHERE is_lock = 1 ORDER BY app_order ASC") fun getLockAppsFlow(): Flow> - @Query("SELECT * FROM app WHERE is_hidden = 0 ORDER BY app_name COLLATE NOCASE ASC") - fun searchApps(): Flow> - @Update suspend fun updateAppInfo(appInfo: AppInfo) diff --git a/app/src/main/java/com/github/droidworksstudio/launcher/repository/AppInfoRepository.kt b/app/src/main/java/com/github/droidworksstudio/launcher/repository/AppInfoRepository.kt index 31ae23f..4bdc914 100644 --- a/app/src/main/java/com/github/droidworksstudio/launcher/repository/AppInfoRepository.kt +++ b/app/src/main/java/com/github/droidworksstudio/launcher/repository/AppInfoRepository.kt @@ -53,10 +53,6 @@ class AppInfoRepository @Inject constructor( } } - fun searchNote(): Flow> { - return appDao.searchApps() - } - suspend fun updateFavoriteAppInfo(appInfo: AppInfo) = withContext(Dispatchers.IO) { if (appInfo.favorite) { diff --git a/app/src/main/java/com/github/droidworksstudio/launcher/ui/drawer/DrawFragment.kt b/app/src/main/java/com/github/droidworksstudio/launcher/ui/drawer/DrawFragment.kt index c640047..709715c 100644 --- a/app/src/main/java/com/github/droidworksstudio/launcher/ui/drawer/DrawFragment.kt +++ b/app/src/main/java/com/github/droidworksstudio/launcher/ui/drawer/DrawFragment.kt @@ -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 = 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 { + 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>() + 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> { it.second } + .thenBy { it.first.app.appName.lowercase() } + ) + return scored.map { it.first.app } } private fun showSelectedApp(appInfo: AppInfo) { diff --git a/app/src/main/java/com/github/droidworksstudio/launcher/viewmodel/AppViewModel.kt b/app/src/main/java/com/github/droidworksstudio/launcher/viewmodel/AppViewModel.kt index 20e1793..c213d5b 100644 --- a/app/src/main/java/com/github/droidworksstudio/launcher/viewmodel/AppViewModel.kt +++ b/app/src/main/java/com/github/droidworksstudio/launcher/viewmodel/AppViewModel.kt @@ -76,6 +76,4 @@ class AppViewModel @Inject constructor( appInfoRepository.updateInfo(appInfo) } } - - fun searchAppInfo() = appInfoRepository.searchNote() } diff --git a/dist/EasyLauncher-Internet-v0.3.3-Signed.apk b/dist/EasyLauncher-Internet-v0.3.3-Signed.apk new file mode 100644 index 0000000..a456f52 Binary files /dev/null and b/dist/EasyLauncher-Internet-v0.3.3-Signed.apk differ diff --git a/dist/EasyLauncher-Internet-v0.3.3-Signed.apk.idsig b/dist/EasyLauncher-Internet-v0.3.3-Signed.apk.idsig new file mode 100644 index 0000000..41dfed5 Binary files /dev/null and b/dist/EasyLauncher-Internet-v0.3.3-Signed.apk.idsig differ diff --git a/dist/EasyLauncher-v0.3.3-Signed.apk b/dist/EasyLauncher-v0.3.3-Signed.apk new file mode 100644 index 0000000..ce3ac75 Binary files /dev/null and b/dist/EasyLauncher-v0.3.3-Signed.apk differ diff --git a/dist/EasyLauncher-v0.3.3-Signed.apk.idsig b/dist/EasyLauncher-v0.3.3-Signed.apk.idsig new file mode 100644 index 0000000..b2e81e6 Binary files /dev/null and b/dist/EasyLauncher-v0.3.3-Signed.apk.idsig differ