diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2960449..ec7ee13 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,7 +15,7 @@ android { versionName = property.project.app.versionName versionCode = property.project.app.versionCode testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - + buildConfigField("String", "SUPPORTED_LAUNCHER_VERSION", "\"15.8.17\"") } @@ -44,7 +44,10 @@ android { release { isMinifyEnabled = true isShrinkResources = true - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) // Use the signing configuration signingConfig = signingConfigs.getByName("release") } @@ -83,4 +86,5 @@ dependencies { testImplementation(junit.junit) androidTestImplementation(androidx.test.ext.junit) androidTestImplementation(androidx.test.espresso.espresso.core) + implementation(me.xdrop.fuzzywuzzy) } \ No newline at end of file diff --git a/app/src/main/java/com/wizpizz/onepluspluslauncher/hook/features/FuzzySearchHook.kt b/app/src/main/java/com/wizpizz/onepluspluslauncher/hook/features/FuzzySearchHook.kt index d6b4a7d..f4f0796 100644 --- a/app/src/main/java/com/wizpizz/onepluspluslauncher/hook/features/FuzzySearchHook.kt +++ b/app/src/main/java/com/wizpizz/onepluspluslauncher/hook/features/FuzzySearchHook.kt @@ -1,52 +1,51 @@ package com.wizpizz.onepluspluslauncher.hook.features import android.util.Log -import com.highcapable.yukihookapi.hook.param.PackageParam import com.highcapable.yukihookapi.hook.factory.current import com.highcapable.yukihookapi.hook.factory.field import com.highcapable.yukihookapi.hook.factory.method +import com.highcapable.yukihookapi.hook.param.PackageParam import com.wizpizz.onepluspluslauncher.hook.features.HookUtils.PREF_USE_FUZZY_SEARCH import com.wizpizz.onepluspluslauncher.hook.features.HookUtils.TAG -import java.util.ArrayList - -/** - * Implements fuzzy search with intelligent ranking: - * - Prefix matches (highest priority) - * - Substring matches (medium priority) - * - Subsequence matches (lowest priority) - */ +import me.xdrop.fuzzywuzzy.FuzzySearch +import kotlin.math.roundToInt + + object FuzzySearchHook { - - private const val SEARCH_CONTAINER_CLASS = "com.android.launcher3.allapps.search.LauncherTaskbarAppsSearchContainerLayout" - private const val BASE_ADAPTER_ITEM_CLASS = "com.android.launcher3.allapps.BaseAllAppsAdapter\$AdapterItem" + + private const val SEARCH_CONTAINER_CLASS = + "com.android.launcher3.allapps.search.LauncherTaskbarAppsSearchContainerLayout" + private const val BASE_ADAPTER_ITEM_CLASS = + "com.android.launcher3.allapps.BaseAllAppsAdapter\$AdapterItem" private const val APP_INFO_CLASS = "com.android.launcher3.model.data.AppInfo" private const val ARRAY_LIST_CLASS = "java.util.ArrayList" - - // Scoring constants - private const val PREFIX_SCORE = 3 - private const val SUBSTRING_SCORE = 2 - private const val SUBSEQUENCE_SCORE = 1 - + + private const val MATCH_THRESHOLD = 50 + private const val PREFIX_MATCH_MULTIPLIER = 1.5 + private const val SUBSTRING_MATCH_MULTIPLIER = 1.3 + private const val SUBSEQUENCE_MATCH_MULTIPLIER = 1.1 + data class FuzzyMatchResult(val appInfo: Any, val score: Int, val appName: String) - + fun apply(packageParam: PackageParam) { packageParam.apply { SEARCH_CONTAINER_CLASS.toClassOrNull(appClassLoader)?.method { name = "onSearchResult" param(String::class.java.name, ARRAY_LIST_CLASS) - }?.hook { - before { - val query = args[0] as? String ?: return@before - + }?.hook { + before { + val rawQuery = args[0] as? String ?: return@before + // Read preference - default to true for better search experience val useFuzzySearch = prefs.getBoolean(PREF_USE_FUZZY_SEARCH, true) + if (!useFuzzySearch) return@before - if (!useFuzzySearch || query.isBlank()) { - return@before - } + // IME delimiters: strip spaces and single quotes from the query + val sanitizedQuery = sanitizeSearchQuery(rawQuery) + if (sanitizedQuery.isBlank()) return@before try { - val sortedResults = performFuzzySearch(instance, query) + val sortedResults = performFuzzySearch(instance, sanitizedQuery) if (sortedResults.isNotEmpty()) { args[1] = sortedResults } @@ -57,25 +56,40 @@ object FuzzySearchHook { } ?: Log.e(TAG, "[FuzzySearch] Could not find onSearchResult method") } } - - private fun PackageParam.performFuzzySearch(containerInstance: Any, query: String): ArrayList { + + private fun sanitizeSearchQuery(input: String): String { + // Remove spaces and single quote marks which are often used as IME delimiters + if (input.isEmpty()) return input + val builder = StringBuilder(input.length) + input.forEach { ch -> + if (ch != ' ' && ch != '\'') builder.append(ch) + } + return builder.toString() + } + + private fun PackageParam.performFuzzySearch( + containerInstance: Any, + query: String + ): ArrayList { // Get apps list val appsList = getAppsListFromContainer(containerInstance) ?: return ArrayList() val allAppInfos = getAllAppInfos(appsList) ?: return ArrayList() - + // Score and filter results val scoredResults = scoreSearchResults(allAppInfos, query) - + // Sort by score and convert to adapter items return convertToAdapterItems(scoredResults) } - + private fun getAppsListFromContainer(containerInstance: Any): Any? { return try { - val appsViewField = containerInstance.javaClass.field { name = "mAppsView"; superClass(true) } + val appsViewField = + containerInstance.javaClass.field { name = "mAppsView"; superClass(true) } val appsViewInstance = appsViewField.get(containerInstance).any() ?: return null - - appsViewInstance.current().method { name = "getAlphabeticalAppsList"; superClass() }.call() + + appsViewInstance.current().method { name = "getAlphabeticalAppsList"; superClass() } + .call() ?: appsViewInstance.current().method { name = "getAppsList"; superClass() }.call() ?: appsViewInstance.current().method { name = "getApps"; superClass() }.call() } catch (e: Throwable) { @@ -83,41 +97,46 @@ object FuzzySearchHook { null } } - + private fun getAllAppInfos(appsList: Any): List<*>? { return try { - appsList.current().method { + appsList.current().method { name = "getApps" superClass(true) }.call() as? List<*> } catch (e: Throwable) { try { - val allAppsStore = appsList.current().method { name = "getAllAppsStore"; superClass(true) }.call() - allAppsStore?.current()?.method { name = "getApps"; superClass(true) }?.call() as? List<*> + val allAppsStore = + appsList.current().method { name = "getAllAppsStore"; superClass(true) }.call() + allAppsStore?.current()?.method { name = "getApps"; superClass(true) } + ?.call() as? List<*> } catch (e2: Throwable) { Log.e(TAG, "[FuzzySearch] Failed to get app infos: ${e2.message}") null } } } - - private fun PackageParam.scoreSearchResults(appInfos: List<*>, query: String): List { + + private fun PackageParam.scoreSearchResults( + appInfos: List<*>, + query: String + ): List { val scoredResults = ArrayList() val appInfoClass = APP_INFO_CLASS.toClass(appClassLoader) val queryLower = query.lowercase() - + appInfos.filterNotNull().forEach { appInfoObj -> try { if (!appInfoClass.isInstance(appInfoObj)) return@forEach - + val appInfo = appInfoClass.cast(appInfoObj) val titleField = appInfo?.javaClass?.field { name = "title"; superClass(true) } val appName = titleField?.get(appInfo)?.any()?.toString() ?: "" val appNameLower = appName.lowercase() - + val score = calculateMatchScore(appNameLower, queryLower) - - if (score > 0) { + + if (score >= MATCH_THRESHOLD) { appInfo?.let { FuzzyMatchResult(it, score, appName) } ?.let { scoredResults.add(it) } } @@ -125,55 +144,58 @@ object FuzzySearchHook { Log.e(TAG, "[FuzzySearch] Error processing app: ${e.message}") } } - + return scoredResults } - + private fun calculateMatchScore(appNameLower: String, queryLower: String): Int { - return when { - appNameLower.startsWith(queryLower) -> PREFIX_SCORE - appNameLower.contains(queryLower) -> SUBSTRING_SCORE - isSubsequence(queryLower, appNameLower) -> SUBSEQUENCE_SCORE - else -> 0 + // Base score using Weighted Ratio from FuzzyWuzzy (0..100) + val baseScore = try { + FuzzySearch.weightedRatio(appNameLower, queryLower) + } catch (t: Throwable) { + 0 } + + // Apply boosts based on match type + val multiplier = when { + queryLower.isEmpty() -> 1.0 + appNameLower.startsWith(queryLower) -> PREFIX_MATCH_MULTIPLIER + appNameLower.contains(queryLower) -> SUBSTRING_MATCH_MULTIPLIER + isSubsequence(appNameLower, queryLower) -> SUBSEQUENCE_MATCH_MULTIPLIER + else -> 1.0 + } + + return (baseScore * multiplier).roundToInt() } - - private fun isSubsequence(query: String, appName: String): Boolean { - if (query.isEmpty()) return false - - var queryIndex = 0 - var appNameIndex = 0 - - while (queryIndex < query.length && appNameIndex < appName.length) { - if (query[queryIndex] == appName[appNameIndex]) { - queryIndex++ + + private fun isSubsequence(text: String, pattern: String): Boolean { + if (pattern.isEmpty()) return true + var textIndex = 0 + var patternIndex = 0 + while (textIndex < text.length && patternIndex < pattern.length) { + if (text[textIndex] == pattern[patternIndex]) { + patternIndex++ } - appNameIndex++ + textIndex++ } - - return queryIndex == query.length + return patternIndex == pattern.length } - + private fun PackageParam.convertToAdapterItems(scoredResults: List): ArrayList { - // Sort by score (descending), then alphabetically (ascending) - val sortedResults = scoredResults.sortedWith { o1, o2 -> - val scoreCompare = o2.score.compareTo(o1.score) - if (scoreCompare != 0) scoreCompare - else o1.appName.compareTo(o2.appName, ignoreCase = true) - } + val sortedResults = scoredResults.sortedByDescending { it.score } val finalAdapterItems = ArrayList() val adapterItemClass = BASE_ADAPTER_ITEM_CLASS.toClass(appClassLoader) val appInfoClass = APP_INFO_CLASS.toClass(appClassLoader) - - sortedResults.forEach { result -> + + sortedResults.forEach { result -> try { - val adapterItem = adapterItemClass.method { + val adapterItem = adapterItemClass.method { name = "asApp" - param(appInfoClass) + param(appInfoClass) modifiers { isStatic } }.get().call(result.appInfo) - + if (adapterItem != null) { finalAdapterItems.add(adapterItem) } @@ -181,7 +203,7 @@ object FuzzySearchHook { Log.e(TAG, "[FuzzySearch] Error converting ${result.appName}: ${e.message}") } } - + return finalAdapterItems } } \ No newline at end of file diff --git a/gradle/sweet-dependency/sweet-dependency-config.yaml b/gradle/sweet-dependency/sweet-dependency-config.yaml index 6e4a824..91046a3 100644 --- a/gradle/sweet-dependency/sweet-dependency-config.yaml +++ b/gradle/sweet-dependency/sweet-dependency-config.yaml @@ -62,4 +62,7 @@ libraries: version: 3.6.1 junit: junit: - version: 4.13.2 \ No newline at end of file + version: 4.13.2 + me.xdrop: + fuzzywuzzy: + version: 1.4.0 \ No newline at end of file