Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
93 changes: 93 additions & 0 deletions app/src/main/java/app/olauncher/helper/AppLabelFilter.kt
Original file line number Diff line number Diff line change
@@ -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<AppModel>, private val matcher: StringMetric) {

private val filteredApps = mutableListOf<AppScore>()

fun filterApps(query: String): List<AppModel> {
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<AppScore> {
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
}
}
45 changes: 45 additions & 0 deletions app/src/main/java/app/olauncher/helper/StringUtils.kt
Original file line number Diff line number Diff line change
@@ -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<Int>(m + 1) { 0 };
var v1 = MutableList<Int>(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(" ", "")
5 changes: 3 additions & 2 deletions app/src/main/java/app/olauncher/helper/Utils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -109,7 +110,7 @@ suspend fun getAppsList(
}
}
}
appList.sortBy { it.appLabel.lowercase() }
appList.sort()

} catch (e: Exception) {
e.printStackTrace()
Expand Down
32 changes: 17 additions & 15 deletions app/src/main/java/app/olauncher/ui/AppDrawerAdapter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<AppModel, AppDrawerAdapter.ViewHolder>(DIFF_CALLBACK), Filterable {

companion object {
Expand Down Expand Up @@ -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<AppModel>)
val appFilteredList = if (charSearch.isNullOrBlank())
appsList
else
getSortedMatches(charSearch, appsList)

val filterResults = FilterResults()
filterResults.values = appFilteredList
Expand All @@ -100,13 +101,22 @@ class AppDrawerAdapter(
val items = it as MutableList<AppModel>
appFilteredList = items
submitList(appFilteredList) {
filterListSubmitListener()
autoLaunch()
}
}
}
}
}

private fun getSortedMatches(charSearch: CharSequence, appsList: List<AppModel>): List<AppModel> {
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
Expand All @@ -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<AppModel>) {
// Add empty app for bottom padding in recyclerview
appsList.add(AppModel("", null, "", "", false, android.os.Process.myUserHandle()))
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/app/olauncher/ui/AppDrawerFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -161,6 +162,9 @@ class AppDrawerFragment : Fragment() {
appRenameListener = { appModel, renameLabel ->
prefs.setAppRenameLabel(appModel.appPackage, renameLabel)
viewModel.getAppList()
},
filterListSubmitListener = {
binding.recyclerView.scrollToPosition(0)
}
)

Expand Down
3 changes: 2 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" }
simmetrics-core = { module = "com.github.mpkorstanje:simmetrics-core", version = "4.1.1" }