Skip to content

Commit 0c11337

Browse files
Refactor(search): Implement advanced fuzzy search algorithm
This commit replaces the basic fuzzy search logic with a more advanced scoring algorithm in `FuzzyFinder`. The new `calculateFuzzyScore` implementation provides more relevant search results by assigning weighted scores for: - Character matches - Consecutive character matches - Matches at the beginning of a word - Matches at the start of the string Normalization logic has been refined to distinguish between search queries and target strings (`normalizeSearch`, `normalizeTarget`), preserving spaces in the target to accurately identify word boundaries for scoring. The old, simpler scoring logic has been removed. The `AppDrawerAdapter` is updated to utilize this new scoring mechanism, improving the filtering logic for app searches. Additionally, the JUnit dependency has been added for future testing.
1 parent f35a5e4 commit 0c11337

File tree

4 files changed

+119
-58
lines changed

4 files changed

+119
-58
lines changed

app/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,4 +232,6 @@ dependencies {
232232

233233
debugImplementation(libs.fragment.testing)
234234
androidTestImplementation(libs.navigation.testing)
235+
236+
testImplementation(libs.junit)
235237
}

app/src/main/java/com/github/codeworkscreativehub/fuzzywuzzy/FuzzyFinder.kt

Lines changed: 100 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -8,81 +8,132 @@ import java.util.Locale
88

99
object FuzzyFinder {
1010

11+
/**
12+
* Scores an AppListItem based on its activity label.
13+
*/
1114
fun scoreApp(app: AppListItem, searchChars: String, topScore: Int): Int {
1215
val appLabel = app.activityLabel
13-
val normalizedAppLabel = normalizeString(appLabel)
14-
val normalizedSearchChars = normalizeString(searchChars)
16+
val normalizedAppLabel = normalizeTarget(appLabel)
17+
val normalizedSearchChars = normalizeSearch(searchChars)
1518

1619
val fuzzyScore = calculateFuzzyScore(normalizedAppLabel, normalizedSearchChars)
1720
return (fuzzyScore * topScore).toInt()
1821
}
1922

23+
/**
24+
* Scores a ContactListItem based on its display name.
25+
*/
2026
fun scoreContact(contact: ContactListItem, searchChars: String, topScore: Int): Int {
2127
val contactLabel = contact.displayName
22-
val normalizedContactLabel = normalizeString(contactLabel)
23-
val normalizedSearchChars = normalizeString(searchChars)
28+
val normalizedContactLabel = normalizeTarget(contactLabel)
29+
val normalizedSearchChars = normalizeSearch(searchChars)
2430

2531
val fuzzyScore = calculateFuzzyScore(normalizedContactLabel, normalizedSearchChars)
2632
return (fuzzyScore * topScore).toInt()
2733
}
2834

29-
fun scoreString(appLabel: String, searchChars: String, topScore: Int): Int {
30-
val normalizedAppLabel = normalizeString(appLabel)
31-
val normalizedSearchChars = normalizeString(searchChars)
35+
/**
36+
* Scores a raw string against a search query.
37+
*/
38+
fun scoreString(target: String, searchChars: String, topScore: Int): Int {
39+
val normalizedTarget = normalizeTarget(target)
40+
val normalizedSearchChars = normalizeSearch(searchChars)
3241

33-
val fuzzyScore = calculateFuzzyScore(normalizedAppLabel, normalizedSearchChars)
42+
val fuzzyScore = calculateFuzzyScore(normalizedTarget, normalizedSearchChars)
3443
return (fuzzyScore * topScore).toInt()
3544
}
3645

37-
// Simplified normalization for app label and search string
38-
private fun normalizeString(input: String): String {
39-
return input
40-
.uppercase(Locale.getDefault())
41-
.let { normalizeDiacritics(it) }
42-
.replace(Regex("[-_+,. ]"), emptyString())
43-
}
46+
/**
47+
* Advanced Fuzzy Matching Score.
48+
* Returns a float between 0.0 and 1.0.
49+
* 1.0 represents a perfect prefix match.
50+
*/
51+
internal fun calculateFuzzyScore(haystack: String, needle: String): Float {
52+
if (needle.isEmpty() || haystack.isEmpty()) return 0f
53+
54+
val n = needle.length
55+
val m = haystack.length
56+
if (n > m) return 0f
57+
58+
val scoreMatch = 100
59+
val scoreConsecutive = 90
60+
val scoreWordStart = 80
61+
val scoreStartIdxBonus = 15
62+
63+
var currentScore = 0
64+
var needleIdx = 0
65+
var prevHaystackIdx = -1
66+
67+
// 1. Actual Score Loop
68+
for (i in 0 until m) {
69+
if (needleIdx >= n) break
70+
if (haystack[i] == needle[needleIdx]) {
71+
var charScore = scoreMatch
72+
if (prevHaystackIdx != -1 && i == prevHaystackIdx + 1) charScore += scoreConsecutive
73+
74+
val isWordStart = if (i == 0) true else {
75+
val prevChar = haystack[i - 1]
76+
prevChar == ' ' || prevChar == '.' || prevChar == '_' || prevChar == '-' || prevChar == ','
77+
}
78+
if (isWordStart) charScore += scoreWordStart
79+
if (i < 3) charScore += scoreStartIdxBonus
4480

45-
// Remove diacritics from a string
46-
private fun normalizeDiacritics(input: String): String {
47-
return Normalizer.normalize(input, Normalizer.Form.NFD)
48-
.replace(Regex("\\p{InCombiningDiacriticalMarks}+"), emptyString())
49-
}
81+
currentScore += charScore
82+
prevHaystackIdx = i
83+
needleIdx++
84+
}
85+
}
5086

51-
// Function to check if normalized strings match
52-
fun isMatch(appLabel: String, searchChars: String): Boolean {
53-
val normalizedAppLabel = normalizeString(appLabel)
54-
val normalizedSearchChars = normalizeString(searchChars)
87+
if (needleIdx < n) return 0f
5588

56-
return normalizedAppLabel.contains(normalizedSearchChars, ignoreCase = true)
57-
}
89+
// 2. Corrected Perfect Max Score
90+
// We only calculate the potential score for characters that ARE NOT spaces.
91+
var perfectMaxScore = 0
92+
for (i in 0 until m) {
93+
if (haystack[i] == ' ') continue // <--- SKIP spaces in the denominator!
5894

59-
// Fuzzy matching logic (kept as it is)
60-
private fun calculateFuzzyScore(s1: String, s2: String): Float {
61-
val m = s1.length
62-
val n = s2.length
63-
var matchCount = 0
64-
var s1Index = 0
65-
66-
// Iterate over each character in s2 and check if it exists in s1
67-
for (c2 in s2) {
68-
var found = false
69-
70-
// Start searching for c2 from the current s1Index
71-
for (j in s1Index until m) {
72-
if (s1[j] == c2) {
73-
found = true
74-
s1Index = j + 1 // Move to the next position in s1
75-
break
76-
}
95+
var potential = scoreMatch
96+
val isWordStart = if (i == 0) true else {
97+
val prevChar = haystack[i - 1]
98+
prevChar == ' ' || prevChar == '.' || prevChar == '_' || prevChar == '-' || prevChar == ','
7799
}
78100

79-
// If the current character in s2 is not found in s1, return a score of 0
80-
if (!found) return 0f
101+
potential += if (isWordStart) scoreWordStart else scoreConsecutive
102+
if (i < 3) potential += scoreStartIdxBonus
81103

82-
matchCount++
104+
perfectMaxScore += potential
83105
}
84106

85-
// Return score based on the ratio of matched characters to the longer string length
86-
return matchCount.toFloat() / maxOf(m, n)
107+
return if (perfectMaxScore == 0) 0f else currentScore.toFloat() / perfectMaxScore
108+
}
109+
110+
/**
111+
* Simple boolean match for legacy support or low-power filtering.
112+
*/
113+
fun isMatch(target: String, searchChars: String): Boolean {
114+
val normalizedTarget = normalizeString(target)
115+
val normalizedSearch = normalizeString(searchChars)
116+
return normalizedTarget.contains(normalizedSearch)
117+
}
118+
119+
// --- Normalization Helpers ---
120+
private fun normalizeString(input: String): String {
121+
return normalizeDiacritics(input.uppercase(Locale.getDefault()))
122+
.replace(Regex("[-_+,. ]"), emptyString())
123+
}
124+
125+
private fun normalizeSearch(input: String): String {
126+
return normalizeDiacritics(input.uppercase(Locale.getDefault()))
127+
.replace(Regex("[-_+,. ]"), emptyString())
128+
}
129+
130+
private fun normalizeTarget(input: String): String {
131+
return normalizeDiacritics(input.uppercase(Locale.getDefault()))
132+
.replace(Regex("[-_+,.]"), " ") // Keep spaces to detect word boundaries
133+
}
134+
135+
private fun normalizeDiacritics(input: String): String {
136+
return Normalizer.normalize(input, Normalizer.Form.NFD)
137+
.replace(Regex("\\p{InCombiningDiacriticalMarks}+"), emptyString())
87138
}
88139
}

app/src/main/java/com/github/codeworkscreativehub/mlauncher/ui/adapter/AppDrawerAdapter.kt

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -216,39 +216,45 @@ class AppDrawerAdapter(
216216
val normalizeField: (AppListItem) -> String = { app -> if (isTagSearch) normalize(app.tag) else normalize(app.activityLabel) }
217217

218218
// Scoring logic
219+
// 1. Calculate the scores for all apps in the list
219220
val scoredApps: Map<AppListItem, Int> = if (prefs.enableFilterStrength) {
220221
appsList.associateWith { app ->
221222
if (isTagSearch) {
222-
FuzzyFinder.scoreString(normalize(app.tag), query, Constants.MAX_FILTER_STRENGTH)
223+
// Normalizing the app tag and scoring it against the query
224+
FuzzyFinder.scoreString(app.tag, query, Constants.MAX_FILTER_STRENGTH)
223225
} else {
226+
// Using the specialized scoreApp helper for AppListItems
224227
FuzzyFinder.scoreApp(app, query, Constants.MAX_FILTER_STRENGTH)
225228
}
226229
}
227230
} else {
231+
// If filter strength is disabled, we don't calculate fuzzy scores
228232
emptyMap()
229233
}
230234

235+
// 2. Filter the list based on the fuzzy score and the user's preferences
231236
filteredApps = if (searchChars.isEmpty()) {
232237
appsList.toMutableList()
233238
} else {
234239
val filtered = if (prefs.enableFilterStrength) {
235-
// Filter using scores
240+
// Logic: Use the pre-calculated scores.
241+
// If the score is above the threshold, it's a valid match.
236242
scoredApps.filter { (app, score) ->
237-
(prefs.searchFromStart && normalizeField(app).startsWith(query) ||
238-
!prefs.searchFromStart && normalizeField(app).contains(query))
239-
&& score > prefs.filterStrength
243+
AppLogger.d("appScore", "app: ${app.activityLabel} | score: $score")
244+
score > prefs.filterStrength
240245
}.map { it.key }
241246
} else {
242-
// Filter without scores
247+
// Logic: Simple Boolean matching without scoring.
243248
appsList.filter { app ->
249+
val target = normalizeField(app)
244250
if (prefs.searchFromStart) {
245-
normalizeField(app).startsWith(query)
251+
target.startsWith(query)
246252
} else {
247-
FuzzyFinder.isMatch(normalizeField(app), query)
253+
// Uses your new isMatch helper for a simple true/false fuzzy check
254+
FuzzyFinder.isMatch(target, query)
248255
}
249256
}
250257
}
251-
252258
filtered.toMutableList()
253259
}
254260

gradle/libs.versions.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
agp = "8.13.2"
55
kotlin = "2.3.0"
66
viewpager2 = "1.1.0"
7+
junit = "4.13.2"
78

89
# AndroidX Core and AppCompat
910
core-ktx = "1.17.0"
@@ -117,6 +118,7 @@ test-rules = { group = "androidx.test", name = "rules", version.ref = "test-rule
117118
fragment-testing = { group = "androidx.fragment", name = "fragment-testing", version.ref = "fragment-testing" }
118119
ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "ui-test-junit4" }
119120
ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version.ref = "ui-test-manifest" }
121+
junit = { group = "junit", name = "junit", version.ref = "junit" }
120122

121123
[plugins]
122124
android-application = { id = "com.android.application", version.ref = "agp" }

0 commit comments

Comments
 (0)