diff --git a/app/build.gradle b/app/build.gradle index 678fe293d..db3e65c7c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -62,4 +62,9 @@ dependencies { //Material dependency implementation libs.material + // String similarity algrorithms + implementation libs.simmetrics.core + + // workaround. See https://stackoverflow.com/a/60492942 + implementation 'com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava' } \ No newline at end of file diff --git a/app/src/main/java/app/olauncher/helper/AppLabelFilter.kt b/app/src/main/java/app/olauncher/helper/AppLabelFilter.kt new file mode 100644 index 000000000..1eb2f3e94 --- /dev/null +++ b/app/src/main/java/app/olauncher/helper/AppLabelFilter.kt @@ -0,0 +1,93 @@ +package app.olauncher.helper + +import android.util.Log +import app.olauncher.data.AppModel +import app.olauncher.helper.StringUtils.longestCommonSubsequence +import org.simmetrics.StringMetric + +private const val TAG = "AppFilter" + +class AppLabelFilter(private val appsList: List, private val matcher: StringMetric) { + + private val filteredApps = mutableListOf() + + fun filterApps(query: String): List { + val searchString = query.normalizeNfd().lowercase() + filteredApps.clear() + + appsList.mapNotNullTo(filteredApps) { app -> + val normalized = app.appLabel.normalizeNfd().splitCamelCase().lowercase() + if (appLabelMatches(searchString, normalized)) { + val score = criteria(searchString, normalized) + AppScore(app, score) + } else null + } + + return filteredApps.sorted().map(AppScore::app) + } + + private fun appLabelMatches(charSearch: String, appLabel: String): Boolean = + when (matchAcronym(charSearch, appLabel)) { + AcronymMatch.FULL -> true + AcronymMatch.PARTIAL -> longestCommonSubsequence(charSearch, appLabel) == charSearch.length + else -> appLabel.contains(charSearch) + } + + private fun criteria(searchString: String, label: String): Float { + val rawScore = matcher.compare(searchString, label) + val acronymBonus = computeAcronymBonus(searchString, label, rawScore) + val subStringBonus = computeSubStringBonus(searchString, label) + val score = rawScore + acronymBonus + subStringBonus + Log.d(TAG, "Matching Score[$searchString, $label]: $score ($rawScore, Acronym Bonus: $acronymBonus, Substring Bonus: $subStringBonus)") + return score + } + + fun computeAcronymBonus(searchString: String, label: String, rawScore: Float): Float { + val acronymMatch: AcronymMatch = if (searchString.length > 1) matchAcronym(searchString, label) else AcronymMatch.NONE + return when (acronymMatch) { + AcronymMatch.FULL -> 1f + AcronymMatch.PARTIAL -> { + val multiWordScore = matcher.compare(searchString, label.getAcronym()) + val missisng = 1 - rawScore + missisng * multiWordScore + } + else -> 0f + } + } + + fun computeSubStringBonus(searchString: String, label: String): Float { + val words = label.split(" ") + var matchedWords = words.fold(0) { acc, word -> + if (searchString.contains(word)) acc + 1 else acc + } + return if (matchedWords > 0) + matchedWords.toFloat() / words.size.toFloat() + else if (label.contains(searchString)) { + (searchString.length.toFloat() / label.length.toFloat()) + } else 0f + } + + private fun matchAcronym(searchString: String, label: String): AcronymMatch { + if (label.contains(" ").not()) return AcronymMatch.NONE + val acronym = label.getAcronym() + val lcs = longestCommonSubsequence(searchString, acronym) + return when (2*lcs) { + 0, 2 -> AcronymMatch.NONE + searchString.length + acronym.length -> AcronymMatch.FULL + else -> AcronymMatch.PARTIAL + } + } + + private data class AppScore(val app: AppModel, val score: Float) : Comparable { + override fun compareTo(other: AppScore): Int = + compareValuesBy(other, this, // swapping other and this makes it descending + { it.score }, + { it.app.appLabel }, + { it.app } + ) + } + + private enum class AcronymMatch { + NONE, PARTIAL, FULL + } +} \ No newline at end of file diff --git a/app/src/main/java/app/olauncher/helper/StringUtils.kt b/app/src/main/java/app/olauncher/helper/StringUtils.kt new file mode 100644 index 000000000..6a9608b38 --- /dev/null +++ b/app/src/main/java/app/olauncher/helper/StringUtils.kt @@ -0,0 +1,45 @@ +package app.olauncher.helper + +import java.text.Normalizer +import kotlin.math.max + +object StringUtils { + fun longestCommonSubsequence(a: CharSequence, b: CharSequence): Int { + + val n = a.length + val m = b.length + var v0 = MutableList(m + 1) { 0 }; + var v1 = MutableList(m + 1) { 0 }; + + (1..n).forEach { i -> + (1..m).forEach { j -> + if (a[i - 1] == b[j - 1]) { + v1[j] = v0[j - 1] + 1; + } else { + v1[j] = max(v1[j - 1], v0[j]); + } + } + val swap = v0 + v0 = v1 + v1 = swap; + } + + return v0[m]; + } + + fun normalizeStringNfd(s: String, trimWhitespace: Boolean): String { + return Normalizer.normalize(s, Normalizer.Form.NFD) + .replace(Regex("\\p{InCombiningDiacriticalMarks}+"), "") + .replace(Regex("[^a-zA-Z0-9]"), " ").run { + if (trimWhitespace) trimWhitespace() + else this + } + } +} + +fun String.normalizeNfd(trimWhitespace: Boolean = false) = StringUtils.normalizeStringNfd(this, trimWhitespace) +fun String.getAcronym() = this.split(" ").joinToString("") { it.firstOrNull()?.lowercase() ?: "" } +fun String.splitCamelCase(): String { + return replace(Regex("([a-z])([A-Z])"), "$1 $2") +} +fun String.trimWhitespace() = replace(" ", "") \ No newline at end of file diff --git a/app/src/main/java/app/olauncher/helper/Utils.kt b/app/src/main/java/app/olauncher/helper/Utils.kt index d89946a0a..f5f0f574a 100644 --- a/app/src/main/java/app/olauncher/helper/Utils.kt +++ b/app/src/main/java/app/olauncher/helper/Utils.kt @@ -84,9 +84,10 @@ suspend fun getAppsList( for (app in launcherApps.getActivityList(null, profile)) { val appLabelShown = prefs.getAppRenameLabel(app.applicationInfo.packageName).ifBlank { app.label.toString() } + val appLabelCollationKey = collator.getCollationKey(app.label.toString().lowercase()) val appModel = AppModel( appLabelShown, - collator.getCollationKey(app.label.toString()), + appLabelCollationKey, app.applicationInfo.packageName, app.componentName.className, (System.currentTimeMillis() - app.firstInstallTime) < Constants.ONE_HOUR_IN_MILLIS, @@ -109,7 +110,7 @@ suspend fun getAppsList( } } } - appList.sortBy { it.appLabel.lowercase() } + appList.sort() } catch (e: Exception) { e.printStackTrace() diff --git a/app/src/main/java/app/olauncher/ui/AppDrawerAdapter.kt b/app/src/main/java/app/olauncher/ui/AppDrawerAdapter.kt index cc9dd2c92..e465ff5a0 100644 --- a/app/src/main/java/app/olauncher/ui/AppDrawerAdapter.kt +++ b/app/src/main/java/app/olauncher/ui/AppDrawerAdapter.kt @@ -18,10 +18,12 @@ import app.olauncher.R import app.olauncher.data.AppModel import app.olauncher.data.Constants import app.olauncher.databinding.AdapterAppDrawerBinding +import app.olauncher.helper.AppLabelFilter import app.olauncher.helper.hideKeyboard import app.olauncher.helper.isSystemApp +import app.olauncher.helper.normalizeNfd import app.olauncher.helper.showKeyboard -import java.text.Normalizer +import org.simmetrics.metrics.JaroWinkler class AppDrawerAdapter( private var flag: Int, @@ -31,6 +33,7 @@ class AppDrawerAdapter( private val appDeleteListener: (AppModel) -> Unit, private val appHideListener: (AppModel, Int) -> Unit, private val appRenameListener: (AppModel, String) -> Unit, + private val filterListSubmitListener: () -> Unit ) : ListAdapter(DIFF_CALLBACK), Filterable { companion object { @@ -82,12 +85,10 @@ class AppDrawerAdapter( isBangSearch = charSearch?.startsWith("!") ?: false autoLaunch = charSearch?.startsWith(" ")?.not() ?: true - val appFilteredList = (if (charSearch.isNullOrBlank()) appsList - else appsList.filter { app -> - appLabelMatches(app.appLabel, charSearch) -// }.sortedByDescending { -// charSearch.contentEquals(it.appLabel, true) - } as MutableList) + val appFilteredList = if (charSearch.isNullOrBlank()) + appsList + else + getSortedMatches(charSearch, appsList) val filterResults = FilterResults() filterResults.values = appFilteredList @@ -100,6 +101,7 @@ class AppDrawerAdapter( val items = it as MutableList appFilteredList = items submitList(appFilteredList) { + filterListSubmitListener() autoLaunch() } } @@ -107,6 +109,14 @@ class AppDrawerAdapter( } } + private fun getSortedMatches(charSearch: CharSequence, appsList: List): List { + val searchString = charSearch.toString().normalizeNfd().lowercase() + val matcher = JaroWinkler.createWithBoostThreshold() + val results = AppLabelFilter(appsList, matcher).filterApps(searchString) + return results + } + + private fun autoLaunch() { try { if (itemCount == 1 @@ -120,14 +130,6 @@ class AppDrawerAdapter( } } - private fun appLabelMatches(appLabel: String, charSearch: CharSequence): Boolean { - return (appLabel.contains(charSearch.trim(), true) or - Normalizer.normalize(appLabel, Normalizer.Form.NFD) - .replace(Regex("\\p{InCombiningDiacriticalMarks}+"), "") - .replace(Regex("[-_+,. ]"), "") - .contains(charSearch, true)) - } - fun setAppList(appsList: MutableList) { // Add empty app for bottom padding in recyclerview appsList.add(AppModel("", null, "", "", false, android.os.Process.myUserHandle())) diff --git a/app/src/main/java/app/olauncher/ui/AppDrawerFragment.kt b/app/src/main/java/app/olauncher/ui/AppDrawerFragment.kt index 1986300fd..45c593b1a 100644 --- a/app/src/main/java/app/olauncher/ui/AppDrawerFragment.kt +++ b/app/src/main/java/app/olauncher/ui/AppDrawerFragment.kt @@ -5,6 +5,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.animation.AnimationUtils +import android.widget.Filter import android.widget.TextView import androidx.appcompat.widget.SearchView import androidx.fragment.app.Fragment @@ -161,6 +162,9 @@ class AppDrawerFragment : Fragment() { appRenameListener = { appModel, renameLabel -> prefs.setAppRenameLabel(appModel.appPackage, renameLabel) viewModel.getAppList() + }, + filterListSubmitListener = { + binding.recyclerView.scrollToPosition(0) } ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0ffbdad61..6015a2936 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,4 +20,5 @@ lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx material = { module = "com.google.android.material:material", version.ref = "material" } navigation-fragment-ktx = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "navigationFragmentKtx" } recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" } -work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } \ No newline at end of file +work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } +simmetrics-core = { module = "com.github.mpkorstanje:simmetrics-core", version = "4.1.1" } \ No newline at end of file