Feature: Added Fuzzy Finding

Closes#: #150
This commit is contained in:
HeCodes2Much
2024-11-23 21:43:50 +00:00
parent 61b5c749f7
commit 29d2954314
13 changed files with 314 additions and 37 deletions

View File

@@ -0,0 +1,66 @@
package com.github.droidworksstudio.fuzzywuzzy
import com.github.droidworksstudio.launcher.data.entities.AppInfo
import java.text.Normalizer
import java.util.*
object FuzzyFinder {
fun scoreApp(app: AppInfo, searchChars: String, topScore: Int): Int {
val appChars = app.appName
val fuzzyScore = calculateFuzzyScore(
normalizeString(appChars),
normalizeString(searchChars)
)
return (fuzzyScore * topScore).toInt()
}
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))
}
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 calculateFuzzyScore(s1: String, s2: String): Float {
val m = s1.length
val n = s2.length
var matchCount = 0
var s1Index = 0
// 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
}
}
// If the current character in s2 is not found in s1, return a score of 0
if (!found) {
return 0f
}
// Increment the match count
matchCount++
}
// Calculate the score as the ratio of matched characters to the longer string length
return matchCount.toFloat() / maxOf(m, n)
}
}

View File

@@ -43,8 +43,8 @@ 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 app_name LIKE :query COLLATE NOCASE AND is_hidden = 0")
fun searchApps(query: String?): Flow<List<AppInfo>>
@Query("SELECT * FROM app ORDER BY app_name COLLATE NOCASE ASC")
fun searchApps(): Flow<List<AppInfo>>
@Update
suspend fun updateAppInfo(appInfo: AppInfo)

View File

@@ -103,6 +103,14 @@ class PreferenceHelper @Inject constructor(@ApplicationContext context: Context)
get() = prefs.getBoolean(Constants.AUTOMATIC_OPEN_APP, false)
set(value) = prefs.edit().putBoolean(Constants.AUTOMATIC_OPEN_APP, value).apply()
var searchFromStart: Boolean
get() = prefs.getBoolean(Constants.SEARCH_FROM_START, true)
set(value) = prefs.edit().putBoolean(Constants.SEARCH_FROM_START, value).apply()
var filterStrength: Int
get() = prefs.getInt(Constants.FILTER_STRENGTH, 25)
set(value) = prefs.edit().putInt(Constants.FILTER_STRENGTH, value).apply()
var homeAppAlignment: Int
get() = prefs.getInt(Constants.HOME_APP_ALIGNMENT, Gravity.START)
set(value) = prefs.edit().putInt(Constants.HOME_APP_ALIGNMENT, value).apply()

View File

@@ -53,8 +53,8 @@ class AppInfoRepository @Inject constructor(
}
}
fun searchNote(query: String?): Flow<List<AppInfo>> {
return appDao.searchApps(query)
fun searchNote(): Flow<List<AppInfo>> {
return appDao.searchApps()
}
suspend fun updateFavoriteAppInfo(appInfo: AppInfo) = withContext(Dispatchers.IO) {

View File

@@ -24,6 +24,7 @@ import com.github.droidworksstudio.common.searchCustomSearchEngine
import com.github.droidworksstudio.common.searchOnPlayStore
import com.github.droidworksstudio.common.showKeyboard
import com.github.droidworksstudio.common.showLongToast
import com.github.droidworksstudio.fuzzywuzzy.FuzzyFinder
import com.github.droidworksstudio.launcher.R
import com.github.droidworksstudio.launcher.adapter.drawer.DrawAdapter
import com.github.droidworksstudio.launcher.data.entities.AppInfo
@@ -35,6 +36,7 @@ import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
import com.github.droidworksstudio.launcher.listener.OnSwipeTouchListener
import com.github.droidworksstudio.launcher.listener.ScrollEventListener
import com.github.droidworksstudio.launcher.ui.bottomsheetdialog.AppInfoBottomSheetFragment
import com.github.droidworksstudio.launcher.utils.Constants
import com.github.droidworksstudio.launcher.viewmodel.AppViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
@@ -194,7 +196,7 @@ class DrawFragment : Fragment(),
// Use repeatOnLifecycle to manage the lifecycle state
repeatOnLifecycle(Lifecycle.State.CREATED) {
val trimmedQuery = searchQuery.trim()
viewModel.searchAppInfo(trimmedQuery).collect { searchResults ->
viewModel.searchAppInfo().collect { searchResults ->
val numberOfItemsLeft = searchResults.size
val appResults = searchResults.firstOrNull()
if (numberOfItemsLeft == 0 && !requireContext().searchOnPlayStore(trimmedQuery)) {
@@ -211,26 +213,63 @@ class DrawFragment : Fragment(),
}
private fun searchApp(query: String) {
val searchQuery = "%$query%"
// 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()
// Collect search results from the ViewModel
viewModel.searchAppInfo(searchQuery).collect { searchResults ->
val numberOfItemsLeft = searchResults.size
val appResults = searchResults.firstOrNull()
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
// 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 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()
when (numberOfItemsLeft) {
1 -> {
appResults?.let { appInfo ->
if (preferenceHelper.automaticOpenApp) observeBioAuthCheck(appInfo)
}
drawAdapter.submitList(searchResults)
drawAdapter.submitList(finalResults)
}
else -> {
drawAdapter.submitList(searchResults)
drawAdapter.submitList(finalResults)
}
}
}
@@ -238,7 +277,6 @@ class DrawFragment : Fragment(),
}
}
private fun showSelectedApp(appInfo: AppInfo) {
binding.searchViewText.setQuery("", false)

View File

@@ -1,12 +1,17 @@
package com.github.droidworksstudio.launcher.ui.settings
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.SeekBar
import android.widget.TextView
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
@@ -62,6 +67,7 @@ class SettingsFeaturesFragment : Fragment(),
}
// Called after the fragment view is created
@RequiresApi(Build.VERSION_CODES.O)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
navController = findNavController()
// Set according to the system theme mode
@@ -75,6 +81,7 @@ class SettingsFeaturesFragment : Fragment(),
binding.apply {
miscellaneousSearchEngineControl.text = preferenceHelper.searchEngines.getString(context)
miscellaneousFilterStrengthControl.text = "${preferenceHelper.filterStrength}"
}
val actions = listOf(
@@ -118,10 +125,12 @@ class SettingsFeaturesFragment : Fragment(),
binding.apply {
automaticKeyboardSwitchCompat.isChecked = preferenceHelper.automaticKeyboard
automaticOpenAppSwitchCompat.isChecked = preferenceHelper.automaticOpenApp
searchFromStartSwitchCompat.isChecked = preferenceHelper.searchFromStart
lockSettingsSwitchCompat.isChecked = preferenceHelper.settingsLock
}
}
@RequiresApi(Build.VERSION_CODES.O)
private fun observeClickListener() {
setupSwitchListeners()
@@ -149,6 +158,10 @@ class SettingsFeaturesFragment : Fragment(),
miscellaneousSearchEngineControl.setOnClickListener {
showSearchEngineDialog()
}
miscellaneousFilterStrengthControl.setOnClickListener {
showFilterStrengthDialog()
}
}
}
@@ -163,19 +176,83 @@ class SettingsFeaturesFragment : Fragment(),
// Map the enum values to their string representations
val itemStrings = items.map { it.getString(context) }.toTypedArray()
val dialogBuilder = MaterialAlertDialogBuilder(context)
dialogBuilder.setTitle(getString(R.string.settings_select_search_engine))
dialogBuilder.setItems(itemStrings) { _, which ->
val selectedItem = items[which]
preferenceViewModel.setSearchEngine(selectedItem)
binding.miscellaneousSearchEngineControl.text = preferenceHelper.searchEngines.name
val dialogBuilder = MaterialAlertDialogBuilder(context).apply {
setTitle(getString(R.string.settings_select_search_engine))
setItems(itemStrings) { _, which ->
val selectedItem = items[which]
preferenceViewModel.setSearchEngine(selectedItem)
binding.miscellaneousSearchEngineControl.text = preferenceHelper.searchEngines.name
}
}
// Assign the created dialog to launcherFontDialog
searchEngineDialog = dialogBuilder.create()
searchEngineDialog?.show()
}
private var filterStrengthDialog: AlertDialog? = null
@RequiresApi(Build.VERSION_CODES.O)
private fun showFilterStrengthDialog() {
// Dismiss any existing dialog to prevent multiple dialogs open simultaneously
filterStrengthDialog?.dismiss()
var currentValue = preferenceHelper.filterStrength
// Create a layout to hold the SeekBar and the value display
val seekBarLayout = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER
setPadding(16, 16, 16, 16)
// TextView to display the current value
val valueText = TextView(context).apply {
text = "$currentValue"
textSize = 16f
gravity = Gravity.CENTER
}
// SeekBar for horizontal number selection
val seekBar = SeekBar(context).apply {
min = Constants.FILTER_STRENGTH_MIN // Maximum value
max = Constants.FILTER_STRENGTH_MAX // Maximum value
progress = currentValue // Default value
setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
currentValue = progress
valueText.text = "$currentValue"
}
override fun onStartTrackingTouch(seekBar: SeekBar) {
// Not used
}
override fun onStopTrackingTouch(seekBar: SeekBar) {
// Not used
}
})
}
// Add TextView and SeekBar to the layout
addView(valueText)
addView(seekBar)
}
// Create the dialog
val dialogBuilder = MaterialAlertDialogBuilder(context).apply {
setTitle(getString(R.string.settings_select_filter_strength))
setView(seekBarLayout) // Add the slider directly to the dialog
setPositiveButton("ok") { _, _ ->
// Save the slider value when OK is pressed
preferenceViewModel.setFilterStrength(currentValue)
binding.miscellaneousFilterStrengthControl.text = "$currentValue"
}
setNegativeButton("cancel", null)
}
// Assign the created dialog to launcherFontDialog
filterStrengthDialog = dialogBuilder.create()
filterStrengthDialog?.show()
}
private var appSelectionDialog: AlertDialog? = null
private fun showAppSelectionDialog(swipeType: Constants.Swipe) {
@@ -191,18 +268,18 @@ class SettingsFeaturesFragment : Fragment(),
val packageNames = installedApps.map { it.packageName }
// Build and display the dialog
val dialogBuilder = MaterialAlertDialogBuilder(context)
dialogBuilder.setTitle("Select an App")
dialogBuilder.setItems(appNames) { _, which ->
val selectedPackageName = packageNames[which]
when (swipeType) {
Constants.Swipe.DoubleTap,
Constants.Swipe.Up,
Constants.Swipe.Down,
Constants.Swipe.Left,
Constants.Swipe.Right -> handleSwipeAction(swipeType, selectedPackageName)
val dialogBuilder = MaterialAlertDialogBuilder(context).apply {
setTitle("Select an App")
setItems(appNames) { _, which ->
val selectedPackageName = packageNames[which]
when (swipeType) {
Constants.Swipe.DoubleTap,
Constants.Swipe.Up,
Constants.Swipe.Down,
Constants.Swipe.Left,
Constants.Swipe.Right -> handleSwipeAction(swipeType, selectedPackageName)
}
}
}
// Assign the created dialog to launcherFontDialog
@@ -222,6 +299,10 @@ class SettingsFeaturesFragment : Fragment(),
preferenceViewModel.setAutoOpenApp(isChecked)
}
searchFromStartSwitchCompat.setOnCheckedChangeListener { _, isChecked ->
preferenceViewModel.setSearchFromStart(isChecked)
}
lockSettingsSwitchCompat.setOnCheckedChangeListener { _, isChecked ->
preferenceViewModel.setLockSettings(isChecked)
}
@@ -335,6 +416,7 @@ class SettingsFeaturesFragment : Fragment(),
private fun dismissDialogs() {
searchEngineDialog?.dismiss()
filterStrengthDialog?.dismiss()
appSelectionDialog?.dismiss()
}
}

View File

@@ -57,6 +57,9 @@ object Constants {
const val AUTOMATIC_KEYBOARD = "AUTOMATIC_KEYBOARD"
const val AUTOMATIC_OPEN_APP = "AUTOMATIC_OPEN_APP"
const val SEARCH_FROM_START = "SEARCH_FROM_START"
const val FILTER_STRENGTH = "FILTER_STRENGTH"
const val TOGGLE_SETTING_LOCK = "TOGGLE_SETTING_LOCK"
const val HOME_DATE_ALIGNMENT = "HOME_DATE_ALIGNMENT"
const val HOME_TIME_ALIGNMENT = "HOME_TIME_ALIGNMENT"
@@ -71,8 +74,6 @@ object Constants {
const val QUICKSETTINGS_MANAGER = "android.app.StatusBarManager"
const val QUICKSETTINGS_METHOD = "expandSettingsPanel"
const val TOGGLE_SETTING_LOCK = "TOGGLE_SETTING_LOCK"
const val SEARCH_ENGINE = "SEARCH_ENGINE"
const val URL_DUCK_SEARCH = "https://duckduckgo.com/?q="
const val URL_GOOGLE_SEARCH = "https://google.com/search?q="
@@ -88,6 +89,9 @@ object Constants {
const val REQUEST_INSTALL_PERMISSION = 123
const val REQUEST_LOCATION_PERMISSION_CODE = 234
const val FILTER_STRENGTH_MIN = 0
const val FILTER_STRENGTH_MAX = 100
const val BACKUP_WRITE = 987
const val BACKUP_READ = 876

View File

@@ -77,5 +77,5 @@ class AppViewModel @Inject constructor(
}
}
fun searchAppInfo(query: String?) = appInfoRepository.searchNote(query)
fun searchAppInfo() = appInfoRepository.searchNote()
}

View File

@@ -39,12 +39,14 @@ class PreferenceViewModel @Inject constructor(
private val appTextSizeLiveData: MutableLiveData<Float> = MutableLiveData()
private val batteryTextSizeLiveData: MutableLiveData<Float> = MutableLiveData()
private val autoOpenAppsLiveData: MutableLiveData<Boolean> = MutableLiveData()
private val searchFromStartLiveData: MutableLiveData<Boolean> = MutableLiveData()
private val autoKeyboardLiveData: MutableLiveData<Boolean> = MutableLiveData()
private val lockSettingsLiveData: MutableLiveData<Boolean> = MutableLiveData()
private val appPaddingSizeLiveData: MutableLiveData<Float> = MutableLiveData()
private val weatherOrderNumberLiveData: MutableLiveData<Int> = MutableLiveData()
private val batteryOrderNumberLiveData: MutableLiveData<Int> = MutableLiveData()
private val filterStrengthLiveData: MutableLiveData<Int> = MutableLiveData()
private val searchEngineLiveData: MutableLiveData<Constants.SearchEngines> = MutableLiveData()
private val launcherFontLiveData: MutableLiveData<Constants.Fonts> = MutableLiveData()
@@ -234,6 +236,11 @@ class PreferenceViewModel @Inject constructor(
autoOpenAppsLiveData.postValue((preferenceHelper.automaticOpenApp))
}
fun setSearchFromStart(searchFromStart: Boolean) {
preferenceHelper.searchFromStart = searchFromStart
searchFromStartLiveData.postValue((preferenceHelper.searchFromStart))
}
fun setLockSettings(lockSettings: Boolean) {
preferenceHelper.settingsLock = lockSettings
lockSettingsLiveData.postValue((preferenceHelper.settingsLock))
@@ -244,6 +251,11 @@ class PreferenceViewModel @Inject constructor(
searchEngineLiveData.postValue((preferenceHelper.searchEngines))
}
fun setFilterStrength(filterStrength: Int) {
preferenceHelper.filterStrength = filterStrength
filterStrengthLiveData.postValue((preferenceHelper.filterStrength))
}
fun setLauncherFont(launcherFont: Constants.Fonts) {
preferenceHelper.launcherFont = launcherFont
launcherFontLiveData.postValue((preferenceHelper.launcherFont))

View File

@@ -122,6 +122,37 @@
</androidx.appcompat.widget.LinearLayoutCompat>
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
tools:ignore="MissingConstraints">
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/searchFromStart_text"
style="@style/TextDefaultStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="left|center"
android:text="@string/settings_search_from_start"
android:textSize="@dimen/text_large"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="RtlHardcoded" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/searchFromStart_switchCompat"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:scaleX="0.7"
android:scaleY="0.8"
android:thumb="@drawable/shape_switch_thumb"
app:track="@drawable/selector_switch"
tools:ignore="TouchTargetSizeCheck" />
</androidx.appcompat.widget.LinearLayoutCompat>
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -373,7 +404,39 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="left|center"
android:text="@string/search"
android:text="@string/settings_search_engine"
android:textSize="@dimen/text_large"
tools:ignore="RtlHardcoded" />
</androidx.appcompat.widget.LinearLayoutCompat>
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="10dp"
android:orientation="horizontal"
tools:ignore="MissingConstraints">
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/miscellaneous_filterStrength_text"
style="@style/TextDefaultStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="left|center"
android:text="@string/settings_filter_strength"
android:textSize="@dimen/text_large"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="RtlHardcoded" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/miscellaneous_filterStrength_control"
style="@style/TextDefaultStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="left|center"
android:text="@string/settings_filter_strength"
android:textSize="@dimen/text_large"
tools:ignore="RtlHardcoded" />

View File

@@ -1,6 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.Launcher" parent="Theme.MaterialComponents.NoActionBar">
<style name="Theme.Launcher" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="android:fontFamily">@android:fontFamily/system_font</item>

View File

@@ -100,6 +100,7 @@
<string name="settings_display_app_icon_dots">Show App Icons As Dots</string>
<string name="settings_display_automatic_keyboard">Auto Show Keyboard</string>
<string name="settings_display_auto_open_apps">Auto Open Last App</string>
<string name="settings_search_from_start">Search From Start</string>
<string name="settings_display_lock_settings">Lock Settings</string>
<string name="settings_appearance_text_size_title">Size</string>
@@ -129,6 +130,9 @@
<string name="settings_select_search_engine">Select a Search Engine</string>
<string name="settings_search_engine">Search Engine</string>
<string name="settings_select_filter_strength">Select a Filter Strength</string>
<string name="settings_filter_strength">Filter Strength</string>
<string name="settings_select_launcher_font">Select a Font Family</string>
<string name="settings_launcher_font">Font Family</string>

View File

@@ -1,6 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.Launcher" parent="Theme.MaterialComponents.Light.NoActionBar">
<style name="Theme.Launcher" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="android:fontFamily">@android:fontFamily/system_font</item>