From 59885133f8f89077bd5d6fc8d515699b617b6f7b Mon Sep 17 00:00:00 2001 From: Jan Koll Date: Wed, 25 Jun 2025 16:13:15 +0200 Subject: [PATCH 1/9] Add subsequent & fuzzy search --- .../jrpie/android/launcher/apps/AppFilter.kt | 61 ++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt b/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt index ca387c0..38643da 100644 --- a/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt +++ b/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt @@ -10,6 +10,7 @@ import de.jrpie.android.launcher.actions.ShortcutAction import de.jrpie.android.launcher.preferences.LauncherPreferences import java.util.Locale import kotlin.text.Regex.Companion.escape +import kotlin.text.iterator class AppFilter( var context: Context, @@ -70,9 +71,31 @@ class AppFilter( val itemLabel: String = normalize(item.getCustomLabel(context)) if (itemLabel.startsWith(normalizedQuery)) { - r.add(item) - } else if (itemLabel.contains(normalizedQuery)) { + appsSecondary.add(item); + } + // todo: maybe re-enable this with preferences? I think it's a bit clunky with the "fuzzy"-search though + /*else if (itemLabel.contains(normalizedQuery)) { appsSecondary.add(item) + }*/ + } + if (appsSecondary.size != 1) { + val subsequentResult: MutableList = mutableListOf(); + val occurrences: MutableMap = mutableMapOf(); + for (item in apps) { + if (appsSecondary.contains(item)) continue; + val itemLabel: String = normalize(item.getCustomLabel(context)) + if (isSubsequent(itemLabel, normalizedQuery)) { + subsequentResult.add(item) + } + occurrences[item] = countOccurrences(itemLabel, normalizedQuery) + } + if (subsequentResult.isNotEmpty()) { + appsSecondary.addAll(subsequentResult) + } else { + val maxOccurrences = occurrences.values.maxOrNull() + if (maxOccurrences == 0) return apps + val result = occurrences.filter { it.value == maxOccurrences } + appsSecondary.addAll(result.keys) } } r.addAll(appsSecondary) @@ -81,6 +104,40 @@ class AppFilter( } } + /** + * Returns true if `search` is a subsequence of `text`. + * A subsequence means all characters in `search` appear in `text` + * in the same order, but not necessarily contiguously. + */ + fun isSubsequent(text: String, search: String): Boolean { + var i = 0 + for (char in text) { + if (char != search[i]) continue + i++; + if (i == search.length) { + return true + } + } + return false + } + + /** + * Returns the amount of characters from `search` that occur inside `text`. + * If `text` contains the same character multiple times, it is only counted + * as often as it occurs in `search`. + */ + fun countOccurrences(text: String, search: String): Int { + val foundCharacters = mutableListOf() + var mutText = text + for (char in search) { + if (mutText.contains(char)) { + foundCharacters.add(char) + mutText = mutText.replaceFirst(char.toString(), "") + } + } + return foundCharacters.size + } + companion object { enum class AppSetVisibility( val predicate: (set: Set, AbstractDetailedAppInfo) -> Boolean From 19e40bb1b7518ca1f3bf6b81491d72e9fe0ee142 Mon Sep 17 00:00:00 2001 From: Jan Koll Date: Thu, 26 Jun 2025 21:16:14 +0200 Subject: [PATCH 2/9] Add preference for fuzzy search, improve search efficiency --- .../de/jrpie/android/launcher/apps/AppFilter.kt | 17 ++++++----------- .../preferences/LauncherPreferences$Config.java | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values/donottranslate.xml | 1 + app/src/main/res/values/strings.xml | 1 + app/src/main/res/xml/preferences.xml | 5 +++++ 6 files changed, 15 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt b/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt index 38643da..60312cb 100644 --- a/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt +++ b/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt @@ -67,28 +67,23 @@ class AppFilter( val r: MutableList = ArrayList() val appsSecondary: MutableList = ArrayList() val normalizedQuery: String = normalize(query) + val subsequentResult: MutableList = mutableListOf(); + val occurrences: MutableMap = mutableMapOf(); for (item in apps) { val itemLabel: String = normalize(item.getCustomLabel(context)) if (itemLabel.startsWith(normalizedQuery)) { appsSecondary.add(item); - } - // todo: maybe re-enable this with preferences? I think it's a bit clunky with the "fuzzy"-search though - /*else if (itemLabel.contains(normalizedQuery)) { + } else if (itemLabel.contains(normalizedQuery) && !LauncherPreferences.functionality().searchFuzzy()) { appsSecondary.add(item) - }*/ - } - if (appsSecondary.size != 1) { - val subsequentResult: MutableList = mutableListOf(); - val occurrences: MutableMap = mutableMapOf(); - for (item in apps) { - if (appsSecondary.contains(item)) continue; - val itemLabel: String = normalize(item.getCustomLabel(context)) + } else { if (isSubsequent(itemLabel, normalizedQuery)) { subsequentResult.add(item) } occurrences[item] = countOccurrences(itemLabel, normalizedQuery) } + } + if (LauncherPreferences.functionality().searchFuzzy() && appsSecondary.size != 1) { if (subsequentResult.isNotEmpty()) { appsSecondary.addAll(subsequentResult) } else { diff --git a/app/src/main/java/de/jrpie/android/launcher/preferences/LauncherPreferences$Config.java b/app/src/main/java/de/jrpie/android/launcher/preferences/LauncherPreferences$Config.java index d509ef2..e73e548 100644 --- a/app/src/main/java/de/jrpie/android/launcher/preferences/LauncherPreferences$Config.java +++ b/app/src/main/java/de/jrpie/android/launcher/preferences/LauncherPreferences$Config.java @@ -75,6 +75,7 @@ import eu.jonahbauer.android.preference.annotations.Preferences; @Preference(name = "search_web", type = boolean.class, description = "false"), @Preference(name = "search_auto_open_keyboard", type = boolean.class, defaultValue = "true"), @Preference(name = "search_auto_close_keyboard", type = boolean.class, defaultValue = "false"), + @Preference(name = "search_fuzzy", type = boolean.class, defaultValue = "true"), }), @PreferenceGroup(name = "enabled_gestures", prefix = "settings_enabled_gestures_", suffix = "_key", value = { @Preference(name = "double_swipe", type = boolean.class, defaultValue = "true"), diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 0b4090c..82d6474 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -309,4 +309,5 @@ Privater Bereich Musik: Wiedergabe / Pause Appliste umkehren + Fuzzy-Suche verwenden diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index 9f15b22..d23e5c6 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -148,6 +148,7 @@ enabled_gestures.edge_actions.edge_width functionality.search_auto_launch functionality.search_web + functionality.search_fuzzy functionality.search_auto_keyboard functionality.search_auto_close_keyboard diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6fb9d61..0cd98dd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -456,4 +456,5 @@ Navigate next Lock Remove binding + Use fuzzy search diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 0ee7c17..c100e32 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -100,6 +100,11 @@ android:defaultValue="false" android:summary="@string/settings_functionality_search_web_summary" /> + Date: Thu, 26 Jun 2025 22:57:29 +0200 Subject: [PATCH 3/9] Re-enable check when fuzzy search is enabled --- app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt b/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt index 60312cb..131f394 100644 --- a/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt +++ b/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt @@ -74,7 +74,7 @@ class AppFilter( if (itemLabel.startsWith(normalizedQuery)) { appsSecondary.add(item); - } else if (itemLabel.contains(normalizedQuery) && !LauncherPreferences.functionality().searchFuzzy()) { + } else if (itemLabel.contains(normalizedQuery)) { appsSecondary.add(item) } else { if (isSubsequent(itemLabel, normalizedQuery)) { From 7744974abbe60c45e190407b1411fc3d4ea15546 Mon Sep 17 00:00:00 2001 From: Jan Koll Date: Thu, 26 Jun 2025 23:05:48 +0200 Subject: [PATCH 4/9] Reduce computation when fuzzy search disabled Respect the fuzzy search preference again to stop computing the fuzzy results when it is not enabled --- app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt b/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt index 131f394..21ee7ac 100644 --- a/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt +++ b/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt @@ -76,7 +76,7 @@ class AppFilter( appsSecondary.add(item); } else if (itemLabel.contains(normalizedQuery)) { appsSecondary.add(item) - } else { + } else if (LauncherPreferences.functionality().searchFuzzy()) { if (isSubsequent(itemLabel, normalizedQuery)) { subsequentResult.add(item) } From c5eb0664c0a353f642328ba60600dfb08905e6bb Mon Sep 17 00:00:00 2001 From: Jan Koll Date: Fri, 27 Jun 2025 00:38:18 +0200 Subject: [PATCH 5/9] Refactor, improve search accuracy when multiple `contains` results If multiple items contain the search string perfectly the fuzzy search still expand it with suggestions. These fuzzy-suggestions, however, might have **less** occurrences in the search string than the items that contained it. The changes ensure that fuzzy-suggestions must have at least the same amount of occurrences as apps that contain the search string. Also, I removed the `appsSecondary` wrapper-list and directly work on `r`. --- .../jrpie/android/launcher/apps/AppFilter.kt | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt b/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt index 21ee7ac..a777e4c 100644 --- a/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt +++ b/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt @@ -64,8 +64,7 @@ class AppFilter( if (query.isEmpty()) { return apps } else { - val r: MutableList = ArrayList() - val appsSecondary: MutableList = ArrayList() + val r: MutableSet = hashSetOf() val normalizedQuery: String = normalize(query) val subsequentResult: MutableList = mutableListOf(); val occurrences: MutableMap = mutableMapOf(); @@ -73,29 +72,28 @@ class AppFilter( val itemLabel: String = normalize(item.getCustomLabel(context)) if (itemLabel.startsWith(normalizedQuery)) { - appsSecondary.add(item); + r.add(item); } else if (itemLabel.contains(normalizedQuery)) { - appsSecondary.add(item) - } else if (LauncherPreferences.functionality().searchFuzzy()) { + r.add(item) + } + if (LauncherPreferences.functionality().searchFuzzy()) { if (isSubsequent(itemLabel, normalizedQuery)) { subsequentResult.add(item) } occurrences[item] = countOccurrences(itemLabel, normalizedQuery) } } - if (LauncherPreferences.functionality().searchFuzzy() && appsSecondary.size != 1) { + if (LauncherPreferences.functionality().searchFuzzy() && r.size != 1) { if (subsequentResult.isNotEmpty()) { - appsSecondary.addAll(subsequentResult) + r.addAll(subsequentResult) } else { val maxOccurrences = occurrences.values.maxOrNull() if (maxOccurrences == 0) return apps val result = occurrences.filter { it.value == maxOccurrences } - appsSecondary.addAll(result.keys) + r.addAll(result.keys) } } - r.addAll(appsSecondary) - - return r + return r.toList() } } From 51cc55af9863f90c331e5154d058958ec1c7137f Mon Sep 17 00:00:00 2001 From: Jan Koll Date: Fri, 27 Jun 2025 11:36:28 +0200 Subject: [PATCH 6/9] Remove semicolons --- .../main/java/de/jrpie/android/launcher/apps/AppFilter.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt b/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt index a777e4c..1d29179 100644 --- a/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt +++ b/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt @@ -66,13 +66,13 @@ class AppFilter( } else { val r: MutableSet = hashSetOf() val normalizedQuery: String = normalize(query) - val subsequentResult: MutableList = mutableListOf(); - val occurrences: MutableMap = mutableMapOf(); + val subsequentResult: MutableList = mutableListOf() + val occurrences: MutableMap = mutableMapOf() for (item in apps) { val itemLabel: String = normalize(item.getCustomLabel(context)) if (itemLabel.startsWith(normalizedQuery)) { - r.add(item); + r.add(item) } else if (itemLabel.contains(normalizedQuery)) { r.add(item) } @@ -106,7 +106,7 @@ class AppFilter( var i = 0 for (char in text) { if (char != search[i]) continue - i++; + i++ if (i == search.length) { return true } From 3917e34239a0546901127d9eef324bb3e11e2719 Mon Sep 17 00:00:00 2001 From: Jan Koll Date: Fri, 27 Jun 2025 11:48:30 +0200 Subject: [PATCH 7/9] Move algorithms to separate file --- .../jrpie/android/launcher/apps/AppFilter.kt | 89 ++++++------------- .../jrpie/android/launcher/util/Algorithms.kt | 35 ++++++++ 2 files changed, 63 insertions(+), 61 deletions(-) create mode 100644 app/src/main/java/de/jrpie/android/launcher/util/Algorithms.kt diff --git a/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt b/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt index 1d29179..d1566e4 100644 --- a/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt +++ b/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt @@ -8,9 +8,10 @@ import de.jrpie.android.launcher.actions.AppAction import de.jrpie.android.launcher.actions.Gesture import de.jrpie.android.launcher.actions.ShortcutAction import de.jrpie.android.launcher.preferences.LauncherPreferences +import de.jrpie.android.launcher.util.countOccurrences +import de.jrpie.android.launcher.util.isSubsequent import java.util.Locale import kotlin.text.Regex.Companion.escape -import kotlin.text.iterator class AppFilter( var context: Context, @@ -63,74 +64,40 @@ class AppFilter( if (query.isEmpty()) { return apps - } else { - val r: MutableSet = hashSetOf() - val normalizedQuery: String = normalize(query) - val subsequentResult: MutableList = mutableListOf() - val occurrences: MutableMap = mutableMapOf() - for (item in apps) { - val itemLabel: String = normalize(item.getCustomLabel(context)) - - if (itemLabel.startsWith(normalizedQuery)) { - r.add(item) - } else if (itemLabel.contains(normalizedQuery)) { - r.add(item) - } - if (LauncherPreferences.functionality().searchFuzzy()) { - if (isSubsequent(itemLabel, normalizedQuery)) { - subsequentResult.add(item) - } - occurrences[item] = countOccurrences(itemLabel, normalizedQuery) - } - } - if (LauncherPreferences.functionality().searchFuzzy() && r.size != 1) { - if (subsequentResult.isNotEmpty()) { - r.addAll(subsequentResult) - } else { - val maxOccurrences = occurrences.values.maxOrNull() - if (maxOccurrences == 0) return apps - val result = occurrences.filter { it.value == maxOccurrences } - r.addAll(result.keys) - } - } - return r.toList() } - } + val r: MutableSet = hashSetOf() + val normalizedQuery: String = normalize(query) + val subsequentResult: MutableList = mutableListOf() + val occurrences: MutableMap = mutableMapOf() + for (item in apps) { + val itemLabel: String = normalize(item.getCustomLabel(context)) - /** - * Returns true if `search` is a subsequence of `text`. - * A subsequence means all characters in `search` appear in `text` - * in the same order, but not necessarily contiguously. - */ - fun isSubsequent(text: String, search: String): Boolean { - var i = 0 - for (char in text) { - if (char != search[i]) continue - i++ - if (i == search.length) { - return true + if (itemLabel.startsWith(normalizedQuery)) { + r.add(item) + } else if (itemLabel.contains(normalizedQuery)) { + r.add(item) + } + if (LauncherPreferences.functionality().searchFuzzy()) { + if (isSubsequent(itemLabel, normalizedQuery)) { + subsequentResult.add(item) + } + occurrences[item] = countOccurrences(itemLabel, normalizedQuery) } } - return false - } - - /** - * Returns the amount of characters from `search` that occur inside `text`. - * If `text` contains the same character multiple times, it is only counted - * as often as it occurs in `search`. - */ - fun countOccurrences(text: String, search: String): Int { - val foundCharacters = mutableListOf() - var mutText = text - for (char in search) { - if (mutText.contains(char)) { - foundCharacters.add(char) - mutText = mutText.replaceFirst(char.toString(), "") + if (LauncherPreferences.functionality().searchFuzzy() && r.size != 1) { + if (subsequentResult.isNotEmpty()) { + r.addAll(subsequentResult) + } else { + val maxOccurrences = occurrences.values.maxOrNull() + if (maxOccurrences == 0) return apps + val result = occurrences.filter { it.value == maxOccurrences } + r.addAll(result.keys) } } - return foundCharacters.size + return r.toList() } + companion object { enum class AppSetVisibility( val predicate: (set: Set, AbstractDetailedAppInfo) -> Boolean diff --git a/app/src/main/java/de/jrpie/android/launcher/util/Algorithms.kt b/app/src/main/java/de/jrpie/android/launcher/util/Algorithms.kt new file mode 100644 index 0000000..a75d08d --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/util/Algorithms.kt @@ -0,0 +1,35 @@ +package de.jrpie.android.launcher.util + +/** + * Returns true if `search` is a subsequence of `text`. + * A subsequence means all characters in `search` appear in `text` + * in the same order, but not necessarily contiguously. + */ +fun isSubsequent(text: String, search: String): Boolean { + var i = 0 + for (char in text) { + if (char != search[i]) continue + i++ + if (i == search.length) { + return true + } + } + return false +} + +/** + * Returns the amount of characters from `search` that occur inside `text`. + * If `text` contains the same character multiple times, it is only counted + * as often as it occurs in `search`. + */ +fun countOccurrences(text: String, search: String): Int { + val foundCharacters = mutableListOf() + var mutText = text + for (char in search) { + if (mutText.contains(char)) { + foundCharacters.add(char) + mutText = mutText.replaceFirst(char.toString(), "") + } + } + return foundCharacters.size +} From 90c6147fc9c9105d8f1634e7ac5ae360fe3f2ae2 Mon Sep 17 00:00:00 2001 From: Jan Koll Date: Fri, 27 Jun 2025 17:28:18 +0200 Subject: [PATCH 8/9] Fix sorting of fuzzy results --- app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt b/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt index d1566e4..521a1fe 100644 --- a/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt +++ b/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt @@ -24,7 +24,6 @@ class AppFilter( operator fun invoke(apps: List): List { var apps = apps.sortedBy { app -> app.getCustomLabel(context).lowercase(Locale.ROOT) } - val hidden = LauncherPreferences.apps().hidden() ?: setOf() val favorites = LauncherPreferences.apps().favorites() ?: setOf() val private = apps.filter { it.isPrivate() } @@ -94,7 +93,7 @@ class AppFilter( r.addAll(result.keys) } } - return r.toList() + return r.toList().sortedBy { it.getCustomLabel(context).lowercase(Locale.ROOT) } } From 911bfac1622729cb8457f84cb203c8cce2e7bda1 Mon Sep 17 00:00:00 2001 From: Jan Koll Date: Fri, 27 Jun 2025 17:31:38 +0200 Subject: [PATCH 9/9] Improve performance of occurrence counting Changed the algorithm for occurrence counting to improve performance. Previously it was based on expensive string manipulations and checks. The new approach is based on a frequency map with fewer iterations. --- .../de/jrpie/android/launcher/util/Algorithms.kt | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/de/jrpie/android/launcher/util/Algorithms.kt b/app/src/main/java/de/jrpie/android/launcher/util/Algorithms.kt index a75d08d..6e7151c 100644 --- a/app/src/main/java/de/jrpie/android/launcher/util/Algorithms.kt +++ b/app/src/main/java/de/jrpie/android/launcher/util/Algorithms.kt @@ -23,13 +23,17 @@ fun isSubsequent(text: String, search: String): Boolean { * as often as it occurs in `search`. */ fun countOccurrences(text: String, search: String): Int { - val foundCharacters = mutableListOf() - var mutText = text + val frequencies = mutableMapOf() + for (char in text) { + frequencies[char] = frequencies.getOrElse(char) { 0 } + 1 + } + var result = 0 for (char in search) { - if (mutText.contains(char)) { - foundCharacters.add(char) - mutText = mutText.replaceFirst(char.toString(), "") + val charFrequency = frequencies[char] ?: 0 + if (charFrequency > 0) { + result++ + frequencies[char] = charFrequency - 1 } } - return foundCharacters.size + return result }