Skip to content

Commit 29d2954

Browse files
Feature: Added Fuzzy Finding
Closes#: #150
1 parent 61b5c74 commit 29d2954

File tree

13 files changed

+314
-37
lines changed

13 files changed

+314
-37
lines changed
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package com.github.droidworksstudio.fuzzywuzzy
2+
3+
import com.github.droidworksstudio.launcher.data.entities.AppInfo
4+
import java.text.Normalizer
5+
import java.util.*
6+
7+
object FuzzyFinder {
8+
fun scoreApp(app: AppInfo, searchChars: String, topScore: Int): Int {
9+
val appChars = app.appName
10+
11+
val fuzzyScore = calculateFuzzyScore(
12+
normalizeString(appChars),
13+
normalizeString(searchChars)
14+
)
15+
16+
return (fuzzyScore * topScore).toInt()
17+
}
18+
19+
fun normalizeString(appLabel: String, searchChars: String): Boolean {
20+
return (appLabel.contains(searchChars, true) or
21+
Normalizer.normalize(appLabel, Normalizer.Form.NFD)
22+
.replace(Regex("\\p{InCombiningDiacriticalMarks}+"), "")
23+
.replace(Regex("[-_+,. ]"), "")
24+
.contains(searchChars, true))
25+
}
26+
27+
private fun normalizeString(input: String): String {
28+
// Remove diacritical marks and special characters, and convert to uppercase
29+
return input
30+
.uppercase(Locale.getDefault())
31+
.replace(Regex("[\\p{InCombiningDiacriticalMarks}-_+,.]"), "")
32+
}
33+
34+
private fun calculateFuzzyScore(s1: String, s2: String): Float {
35+
val m = s1.length
36+
val n = s2.length
37+
var matchCount = 0
38+
var s1Index = 0
39+
40+
// Iterate over each character in s2 and check if it exists in s1
41+
for (c2 in s2) {
42+
var found = false
43+
44+
// Start searching for c2 from the current s1Index
45+
for (j in s1Index until m) {
46+
if (s1[j] == c2) {
47+
found = true
48+
// Update s1Index to the next position for the next iteration
49+
s1Index = j + 1
50+
break
51+
}
52+
}
53+
54+
// If the current character in s2 is not found in s1, return a score of 0
55+
if (!found) {
56+
return 0f
57+
}
58+
59+
// Increment the match count
60+
matchCount++
61+
}
62+
63+
// Calculate the score as the ratio of matched characters to the longer string length
64+
return matchCount.toFloat() / maxOf(m, n)
65+
}
66+
}

app/src/main/java/com/github/droidworksstudio/launcher/data/dao/AppInfoDAO.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ interface AppInfoDAO {
4343
@Query("SELECT * FROM app WHERE is_lock = 1 ORDER BY app_order ASC")
4444
fun getLockAppsFlow(): Flow<List<AppInfo>>
4545

46-
@Query("SELECT * FROM app WHERE app_name LIKE :query COLLATE NOCASE AND is_hidden = 0")
47-
fun searchApps(query: String?): Flow<List<AppInfo>>
46+
@Query("SELECT * FROM app ORDER BY app_name COLLATE NOCASE ASC")
47+
fun searchApps(): Flow<List<AppInfo>>
4848

4949
@Update
5050
suspend fun updateAppInfo(appInfo: AppInfo)

app/src/main/java/com/github/droidworksstudio/launcher/helper/PreferenceHelper.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,14 @@ class PreferenceHelper @Inject constructor(@ApplicationContext context: Context)
103103
get() = prefs.getBoolean(Constants.AUTOMATIC_OPEN_APP, false)
104104
set(value) = prefs.edit().putBoolean(Constants.AUTOMATIC_OPEN_APP, value).apply()
105105

106+
var searchFromStart: Boolean
107+
get() = prefs.getBoolean(Constants.SEARCH_FROM_START, true)
108+
set(value) = prefs.edit().putBoolean(Constants.SEARCH_FROM_START, value).apply()
109+
110+
var filterStrength: Int
111+
get() = prefs.getInt(Constants.FILTER_STRENGTH, 25)
112+
set(value) = prefs.edit().putInt(Constants.FILTER_STRENGTH, value).apply()
113+
106114
var homeAppAlignment: Int
107115
get() = prefs.getInt(Constants.HOME_APP_ALIGNMENT, Gravity.START)
108116
set(value) = prefs.edit().putInt(Constants.HOME_APP_ALIGNMENT, value).apply()

app/src/main/java/com/github/droidworksstudio/launcher/repository/AppInfoRepository.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ class AppInfoRepository @Inject constructor(
5353
}
5454
}
5555

56-
fun searchNote(query: String?): Flow<List<AppInfo>> {
57-
return appDao.searchApps(query)
56+
fun searchNote(): Flow<List<AppInfo>> {
57+
return appDao.searchApps()
5858
}
5959

6060
suspend fun updateFavoriteAppInfo(appInfo: AppInfo) = withContext(Dispatchers.IO) {

app/src/main/java/com/github/droidworksstudio/launcher/ui/drawer/DrawFragment.kt

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import com.github.droidworksstudio.common.searchCustomSearchEngine
2424
import com.github.droidworksstudio.common.searchOnPlayStore
2525
import com.github.droidworksstudio.common.showKeyboard
2626
import com.github.droidworksstudio.common.showLongToast
27+
import com.github.droidworksstudio.fuzzywuzzy.FuzzyFinder
2728
import com.github.droidworksstudio.launcher.R
2829
import com.github.droidworksstudio.launcher.adapter.drawer.DrawAdapter
2930
import com.github.droidworksstudio.launcher.data.entities.AppInfo
@@ -35,6 +36,7 @@ import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
3536
import com.github.droidworksstudio.launcher.listener.OnSwipeTouchListener
3637
import com.github.droidworksstudio.launcher.listener.ScrollEventListener
3738
import com.github.droidworksstudio.launcher.ui.bottomsheetdialog.AppInfoBottomSheetFragment
39+
import com.github.droidworksstudio.launcher.utils.Constants
3840
import com.github.droidworksstudio.launcher.viewmodel.AppViewModel
3941
import dagger.hilt.android.AndroidEntryPoint
4042
import kotlinx.coroutines.launch
@@ -194,7 +196,7 @@ class DrawFragment : Fragment(),
194196
// Use repeatOnLifecycle to manage the lifecycle state
195197
repeatOnLifecycle(Lifecycle.State.CREATED) {
196198
val trimmedQuery = searchQuery.trim()
197-
viewModel.searchAppInfo(trimmedQuery).collect { searchResults ->
199+
viewModel.searchAppInfo().collect { searchResults ->
198200
val numberOfItemsLeft = searchResults.size
199201
val appResults = searchResults.firstOrNull()
200202
if (numberOfItemsLeft == 0 && !requireContext().searchOnPlayStore(trimmedQuery)) {
@@ -211,34 +213,70 @@ class DrawFragment : Fragment(),
211213
}
212214

213215
private fun searchApp(query: String) {
214-
val searchQuery = "%$query%"
215-
216216
// Launch a coroutine tied to the lifecycle of the view
217217
viewLifecycleOwner.lifecycleScope.launch {
218218
// Repeat the block when the lifecycle is at least CREATED
219219
repeatOnLifecycle(Lifecycle.State.CREATED) {
220+
val trimmedQuery = query.trim()
221+
220222
// Collect search results from the ViewModel
221-
viewModel.searchAppInfo(searchQuery).collect { searchResults ->
222-
val numberOfItemsLeft = searchResults.size
223-
val appResults = searchResults.firstOrNull()
223+
viewModel.searchAppInfo().collect { searchResults ->
224+
// Filter and score results using FuzzyFinder
225+
val filteredResults = searchResults
226+
.map { appInfo ->
227+
val score = FuzzyFinder.scoreApp(appInfo, trimmedQuery, Constants.FILTER_STRENGTH_MAX)
228+
appInfo to score // Pairing app info with its score
229+
}
230+
.filter { it.second > 25 } // Only keep results with a positive score
231+
.sortedByDescending { it.second } // Sort results by score, descending
232+
233+
// Applying additional filtering based on preferences
234+
val scoredApps = filteredResults.toMap()
235+
236+
val finalResults = if (preferenceHelper.filterStrength >= 1) {
237+
// Filtering based on score strength
238+
if (preferenceHelper.searchFromStart) {
239+
// Filter apps that start with the search query and score higher than the filter strength
240+
scoredApps.filter { (app, _) ->
241+
app.appName.startsWith(trimmedQuery, ignoreCase = true)
242+
}
243+
.filter { (_, score) -> score > preferenceHelper.filterStrength }
244+
.map { it.key }
245+
.toMutableList()
246+
} else {
247+
// Filter based on score strength alone
248+
scoredApps.filterValues { it > preferenceHelper.filterStrength }
249+
.keys
250+
.toMutableList()
251+
}
252+
} else {
253+
// If filter strength is less than 1, normalize app names for both cases
254+
searchResults.filter { app ->
255+
FuzzyFinder.normalizeString(app.appName, trimmedQuery)
256+
}.toMutableList()
257+
}
258+
259+
260+
val numberOfItemsLeft = finalResults.size
261+
val appResults = finalResults.firstOrNull()
262+
224263
when (numberOfItemsLeft) {
225264
1 -> {
226265
appResults?.let { appInfo ->
227266
if (preferenceHelper.automaticOpenApp) observeBioAuthCheck(appInfo)
228267
}
229-
drawAdapter.submitList(searchResults)
268+
drawAdapter.submitList(finalResults)
230269
}
231270

232271
else -> {
233-
drawAdapter.submitList(searchResults)
272+
drawAdapter.submitList(finalResults)
234273
}
235274
}
236275
}
237276
}
238277
}
239278
}
240279

241-
242280
private fun showSelectedApp(appInfo: AppInfo) {
243281
binding.searchViewText.setQuery("", false)
244282

0 commit comments

Comments
 (0)