Compare commits
3 Commits
v0.3.3
...
16a75a9e7e
| Author | SHA1 | Date | |
|---|---|---|---|
| 16a75a9e7e | |||
| bea62e8d88 | |||
| e81b4e8ac1 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,3 +12,4 @@ git-cliff*
|
||||
/app/build
|
||||
/app/debug
|
||||
diff.*
|
||||
/.pi
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -76,6 +76,4 @@ class AppViewModel @Inject constructor(
|
||||
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-Internet-v0.3.4-Signed.apk
vendored
Normal file
BIN
dist/EasyLauncher-Internet-v0.3.4-Signed.apk
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-Internet-v0.3.4-Signed.apk.idsig
vendored
Normal file
BIN
dist/EasyLauncher-Internet-v0.3.4-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.
BIN
dist/EasyLauncher-v0.3.4-Signed.apk
vendored
Normal file
BIN
dist/EasyLauncher-v0.3.4-Signed.apk
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-v0.3.4-Signed.apk.idsig
vendored
Normal file
BIN
dist/EasyLauncher-v0.3.4-Signed.apk.idsig
vendored
Normal file
Binary file not shown.
Reference in New Issue
Block a user