From 27a94e7e6e04d6a4e110738171ba816381d92638 Mon Sep 17 00:00:00 2001 From: liuran001 Date: Sun, 30 Aug 2026 09:09:54 +0800 Subject: [PATCH 1/6] feat: Support HyperOS 4 (Android 17) --- .gitignore | 1 + README.md | 4 +- README_en.md | 2 +- README_ru.md | 2 +- app/build.gradle.kts | 26 +- .../META-INF/xposed/native_init.list | 1 + .../java/com/parallelc/micts/ModuleMain.kt | 12 + .../com/parallelc/micts/hooker/CSMSHooker.kt | 58 +- .../micts/hooker/LongPressHomeHooker.kt | 67 +- .../hooker/NativeLauncherTriggerHooker.kt | 168 ++++ app/src/main/res/values-el/strings.xml | 2 +- app/src/main/res/values-es/strings.xml | 2 +- app/src/main/res/values-ja/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values-tr/strings.xml | 2 +- app/src/main/res/values-vi/strings.xml | 2 +- app/src/main/res/values-zh-rTW/strings.xml | 2 +- app/src/main/res/values-zh/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- native/CMakeLists.txt | 56 ++ native/README.md | 77 ++ native/exports-lsposed.map | 6 + native/launcher_cs_resolver.cpp | 726 ++++++++++++++++++ native/launcher_cs_resolver.h | 47 ++ native/lsposed_hook_backend.cpp | 599 +++++++++++++++ native/lsposed_hook_backend.h | 17 + native/micts_native_hook.cpp | 572 ++++++++++++++ native/native_api.h | 14 + 28 files changed, 2437 insertions(+), 36 deletions(-) create mode 100644 app/src/MiCTS/resources/META-INF/xposed/native_init.list create mode 100644 app/src/main/java/com/parallelc/micts/hooker/NativeLauncherTriggerHooker.kt create mode 100644 native/CMakeLists.txt create mode 100644 native/README.md create mode 100644 native/exports-lsposed.map create mode 100644 native/launcher_cs_resolver.cpp create mode 100644 native/launcher_cs_resolver.h create mode 100644 native/lsposed_hook_backend.cpp create mode 100644 native/lsposed_hook_backend.h create mode 100644 native/micts_native_hook.cpp create mode 100644 native/native_api.h diff --git a/.gitignore b/.gitignore index 5f94008..49b0022 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ .DS_Store /build /captures +.cxx diff --git a/README.md b/README.md index f3c051b..e6b66fa 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 简体中文  |  [English](/README_en.md)  |  [Русский](/README_ru.md)  |  [Türkçe](/README_tr.md)  |  [فارسی](/README_fa.md) -在任意Android 9–16设备上触发圈定即搜(Circle to Search)功能 +在任意Android 9–17设备上触发圈定即搜(Circle to Search)功能 *本应用只负责触发圈定即搜,无法处理触发成功后可能出现的问题* @@ -41,7 +41,7 @@ 需要在LSPosed里激活模块 - 系统触发服务:触发所使用的系统服务,只会显示当前支持的选项,依赖作用域选择系统框架 - - VIS:支持Android 9–16,需要将默认助理应用设置为Google,触发时一些设备的屏幕边缘会闪,没有激活模块的情况下只能使用此服务 + - VIS:支持Android 9–17,需要将默认助理应用设置为Google,触发时一些设备的屏幕边缘会闪,没有激活模块的情况下只能使用此服务 - CSHelper:支持Android 14 QPR3及以上,不需要设置默认助理应用,触发时屏幕边缘不会闪 - CSService:支持Android 15及以上,圈定即搜专用的服务,效果同CSHelper diff --git a/README_en.md b/README_en.md index c1713b5..ea2908c 100644 --- a/README_en.md +++ b/README_en.md @@ -4,7 +4,7 @@ [简体中文](/README.md)  |  English  |  [Русский](/README_ru.md)  |  [Türkçe](/README_tr.md)  |  [فارسی](/README_fa.md) -Trigger Circle to Search on any Android 9–16 device +Trigger Circle to Search on any Android 9–17 device *This app only aims to trigger Circle to Search and cannot handle issues that may occur after triggering successfully* diff --git a/README_ru.md b/README_ru.md index fda3fc4..af2d7d1 100644 --- a/README_ru.md +++ b/README_ru.md @@ -10,7 +10,7 @@ -Триггер для Circle to Search на любом устройстве Android 9–16 +Триггер для Circle to Search на любом устройстве Android 9–17 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1739f1e..2473021 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -21,6 +21,7 @@ val latestTag = repo?.latestTag?.removePrefix("v") ?: "1.0" android { namespace = "com.parallelc.micts" compileSdk = 36 + ndkVersion = "30.0.14904198" defaultConfig { minSdk = 28 @@ -29,6 +30,16 @@ android { versionName = latestTag testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + externalNativeBuild { + cmake { + targets += "micts_hyos_lsp" + // The payload is arm64 only. Restrict the native build, not + // the APK — that would strip the dependencies' own libraries + // from 32-bit devices, which still use the Java trigger paths. + abiFilters += "arm64-v8a" + } + } } buildTypes { @@ -60,7 +71,7 @@ android { applicationId = "com.parallelc.vistrigger" resValue("string", "app_name", "VISTrigger") resValue("string", "tile_label", "VIS") - resValue("string", "xposed_description", "Trigger Voice Interaction Service on any Android 9–16 device") + resValue("string", "xposed_description", "Trigger Voice Interaction Service on any Android 9–17 device") buildConfigField("String", "APP_NAME", "\"VISTrigger\"") } } @@ -69,6 +80,19 @@ android { compose = true buildConfig = true } + + externalNativeBuild { + cmake { + path = rootProject.file("native/CMakeLists.txt") + version = "4.1.2" + } + } + + packaging { + // LSPosed maps the entry straight out of the APK: keep it uncompressed. + jniLibs.useLegacyPackaging = false + resources.merges += "META-INF/xposed/*" + } } androidComponents { diff --git a/app/src/MiCTS/resources/META-INF/xposed/native_init.list b/app/src/MiCTS/resources/META-INF/xposed/native_init.list new file mode 100644 index 0000000..f284751 --- /dev/null +++ b/app/src/MiCTS/resources/META-INF/xposed/native_init.list @@ -0,0 +1 @@ +libmicts_hyos_lsp.so diff --git a/app/src/main/java/com/parallelc/micts/ModuleMain.kt b/app/src/main/java/com/parallelc/micts/ModuleMain.kt index 47bef44..8ebd148 100644 --- a/app/src/main/java/com/parallelc/micts/ModuleMain.kt +++ b/app/src/main/java/com/parallelc/micts/ModuleMain.kt @@ -14,6 +14,7 @@ import com.parallelc.micts.config.XposedConfig.KEY_SPOOF_MODEL import com.parallelc.micts.hooker.CSMSHooker import com.parallelc.micts.hooker.InvokeOmniHooker import com.parallelc.micts.hooker.LongPressHomeHooker +import com.parallelc.micts.hooker.NativeLauncherTriggerHooker import com.parallelc.micts.hooker.NavBarActionsConfigHooker import com.parallelc.micts.hooker.NavBarEventHelperHooker import com.parallelc.micts.hooker.NavStubGestureEventManagerHooker @@ -50,6 +51,17 @@ class ModuleMain : XposedModule() { }.onFailure { e -> log(Log.ERROR, "MiCTS", "hook CSMS fail", e) } + + // On HyperOS 4 the launcher triggers Circle to Search natively, + // so this is the only place left to apply the gesture switch and + // the haptic. Earlier releases drive it from Java — leave those. + if (Build.MANUFACTURER == "Xiaomi" && NativeLauncherTriggerHooker.isSupported()) { + runCatching { + NativeLauncherTriggerHooker.hook(param) + }.onFailure { e -> + log(Log.ERROR, "MiCTS", "hook native launcher trigger fail", e) + } + } } } diff --git a/app/src/main/java/com/parallelc/micts/hooker/CSMSHooker.kt b/app/src/main/java/com/parallelc/micts/hooker/CSMSHooker.kt index 9473064..8b1a692 100644 --- a/app/src/main/java/com/parallelc/micts/hooker/CSMSHooker.kt +++ b/app/src/main/java/com/parallelc/micts/hooker/CSMSHooker.kt @@ -7,16 +7,24 @@ import android.util.Log import com.parallelc.micts.module import io.github.libxposed.api.XposedInterface.Chain import io.github.libxposed.api.XposedInterface.Hooker -import io.github.libxposed.api.XposedInterface.HookHandle import io.github.libxposed.api.XposedModuleInterface.SystemServerStartingParam import java.lang.reflect.Method class CSMSHooker { companion object { + private const val CONTEXTUAL_SEARCH_PACKAGE = "com.google.android.googlequicksearchbox" + private var enforcePermission: Method? = null private var getContextualSearchPackageName: Method? = null private var contextualSearchPackageName: Int = 0 + /** + * Marks the calls MiCTS is responsible for. The permission and provider + * hooks stay installed but only act while this is set, so unrelated + * requests keep the platform's own behaviour. + */ + private val bypass = ThreadLocal() + @SuppressLint("PrivateApi") fun hook(param: SystemServerStartingParam) { val rString = param.classLoader.loadClass("com.android.internal.R\$string") @@ -28,23 +36,51 @@ class CSMSHooker { val csms = param.classLoader.loadClass("com.android.server.contextualsearch.ContextualSearchManagerService") enforcePermission = csms.getDeclaredMethod("enforcePermission", String::class.java) getContextualSearchPackageName = csms.getDeclaredMethod("getContextualSearchPackageName") + module!!.hook(enforcePermission!!).intercept(EnforcePermissionHooker()) + module!!.hook(getContextualSearchPackageName!!).intercept(GetCSPackageNameHooker()) + } + + /** + * Runs [block] with the permission check bypassed and the provider forced + * to the Google app — neither MiCTS nor the launcher holds + * ACCESS_CONTEXTUAL_SEARCH. + */ + fun withPermissionBypass(block: () -> T): T { + val previous = bypass.get() + bypass.set(true) + return try { + block() + } finally { + if (previous == null) bypass.remove() else bypass.set(previous) + } } @SuppressLint("PrivateApi") fun startContextualSearch(entryPoint: Int): Boolean { - var hooks = mutableListOf() return runCatching { - hooks += module!!.hook(enforcePermission!!).intercept(EnforcePermissionHooker()) - hooks += module!!.hook(getContextualSearchPackageName!!).intercept(GetCSPackageNameHooker()) - val icsmClass = Class.forName("android.app.contextualsearch.IContextualSearchManager") val cs = Class.forName("android.os.ServiceManager").getMethod("getService", String::class.java).invoke(null, "contextual_search") val icsm = Class.forName("android.app.contextualsearch.IContextualSearchManager\$Stub").getMethod("asInterface", IBinder::class.java).invoke(null, cs) - icsmClass.getDeclaredMethod("startContextualSearch", Int::class.java).invoke(icsm, entryPoint) + // QPR1 added a ContextualSearchConfig parameter and dropped the + // single-argument form; take whichever this platform declares. + val (start, arguments) = runCatching { + icsmClass.getDeclaredMethod("startContextualSearch", Int::class.java) to + arrayOf(entryPoint) + }.getOrElse { + val configClass = + Class.forName("android.app.contextualsearch.ContextualSearchConfig") + icsmClass.getDeclaredMethod( + "startContextualSearch", Int::class.java, configClass + ) to arrayOf(entryPoint, null) + } + withPermissionBypass { + // The caller already applied the module's settings. + NativeLauncherTriggerHooker.asSelfInvocation { + start.invoke(icsm, *arguments) + } + } }.onFailure { e -> module!!.log(Log.ERROR, "MiCTS", "invoke startContextualSearch fail", e) - }.also { - hooks.forEach { hook -> hook.unhook() } }.isSuccess } @@ -60,14 +96,14 @@ class CSMSHooker { class EnforcePermissionHooker : Hooker { override fun intercept(chain: Chain): Any? { - return null + return if (bypass.get() == true) null else chain.proceed() } } class GetCSPackageNameHooker : Hooker { override fun intercept(chain: Chain): Any? { - return "com.google.android.googlequicksearchbox" + return if (bypass.get() == true) CONTEXTUAL_SEARCH_PACKAGE else chain.proceed() } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/parallelc/micts/hooker/LongPressHomeHooker.kt b/app/src/main/java/com/parallelc/micts/hooker/LongPressHomeHooker.kt index 98a8c6d..b2e2a66 100644 --- a/app/src/main/java/com/parallelc/micts/hooker/LongPressHomeHooker.kt +++ b/app/src/main/java/com/parallelc/micts/hooker/LongPressHomeHooker.kt @@ -12,6 +12,7 @@ import io.github.libxposed.api.XposedInterface.Chain import io.github.libxposed.api.XposedInterface.Hooker import io.github.libxposed.api.XposedModuleInterface.SystemServerStartingParam import java.lang.reflect.Field +import java.lang.reflect.Method class LongPressHomeHooker { companion object { @@ -20,17 +21,61 @@ class LongPressHomeHooker { @SuppressLint("PrivateApi") fun hook(param: SystemServerStartingParam) { - val miuiSingleKeyRule = param.classLoader.loadClass("com.android.server.policy.MiuiSingleKeyRule") - mContext = miuiSingleKeyRule.getDeclaredField("mContext") - mContext.isAccessible = true - mKeyCode = miuiSingleKeyRule.getDeclaredField("mKeyCode") - mKeyCode.isAccessible = true - module!!.hook( - miuiSingleKeyRule.getDeclaredMethod("onLongPress", Long::class.java) - ).intercept(OnLongPressHooker()) - module!!.hook( - miuiSingleKeyRule.getDeclaredMethod("supportLongPress") - ).intercept(SupportLongPressHooker()) + // HyperOS 4 split the single-key rules into one class per key and + // overrides the callbacks there, so hooking the shared base class no + // longer intercepts anything. Prefer the Home rule and fall back to + // the base class on older releases that still implement it directly. + val rule = listOf( + "com.android.server.input.shortcut.singlekeyrule.HomeKeyRule", + "com.android.server.policy.MiuiSingleKeyRule", + ).firstNotNullOfOrNull { name -> + runCatching { param.classLoader.loadClass(name) }.getOrNull() + } ?: throw ClassNotFoundException("no MIUI single-key rule class") + + mContext = findField(rule, "mContext") + mKeyCode = findField(rule, "mKeyCode") + + val longPress = findCallback(rule, "onMiuiLongPress", "onLongPress") + ?: throw NoSuchMethodException("${rule.name}.onMiuiLongPress") + module!!.hook(longPress).intercept(OnLongPressHooker()) + + val supports = listOfNotNull( + findCallback(rule, "supportLongPress"), + findCallback(rule, "miuiSupportLongPress"), + ) + if (supports.isEmpty()) { + throw NoSuchMethodException("${rule.name}.supportLongPress") + } + supports.forEach { module!!.hook(it).intercept(SupportLongPressHooker()) } + } + + private fun findField(owner: Class<*>, name: String): Field { + var current: Class<*>? = owner + while (current != null) { + runCatching { current!!.getDeclaredField(name) }.getOrNull()?.let { + it.isAccessible = true + return it + } + current = current.superclass + } + throw NoSuchFieldException("${owner.name}.$name") + } + + /** Finds the first declared method matching any of [names], nearest class first. */ + private fun findCallback(owner: Class<*>, vararg names: String): Method? { + var current: Class<*>? = owner + while (current != null) { + for (name in names) { + current.declaredMethods + .filter { it.name == name } + // Each callback is declared once; if a release ever + // overloads one, prefer the simplest form. + .minByOrNull { it.parameterTypes.size } + ?.let { return it } + } + current = current.superclass + } + return null } class OnLongPressHooker : Hooker { diff --git a/app/src/main/java/com/parallelc/micts/hooker/NativeLauncherTriggerHooker.kt b/app/src/main/java/com/parallelc/micts/hooker/NativeLauncherTriggerHooker.kt new file mode 100644 index 0000000..534fc36 --- /dev/null +++ b/app/src/main/java/com/parallelc/micts/hooker/NativeLauncherTriggerHooker.kt @@ -0,0 +1,168 @@ +package com.parallelc.micts.hooker + +import android.annotation.SuppressLint +import android.content.Context +import android.media.AudioAttributes +import android.os.Binder +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.util.Log +import com.parallelc.micts.config.TriggerService +import com.parallelc.micts.config.XposedConfig.CONFIG_NAME +import com.parallelc.micts.config.XposedConfig.DEFAULT_CONFIG +import com.parallelc.micts.config.XposedConfig.KEY_GESTURE_TRIGGER +import com.parallelc.micts.config.XposedConfig.KEY_TRIGGER_SERVICE +import com.parallelc.micts.config.XposedConfig.KEY_VIBRATE +import com.parallelc.micts.module +import com.parallelc.micts.ui.activity.triggerCircleToSearch +import io.github.libxposed.api.XposedInterface.Chain +import io.github.libxposed.api.XposedInterface.Hooker +import io.github.libxposed.api.XposedModuleInterface.SystemServerStartingParam +import java.lang.reflect.Method +import java.util.concurrent.Executors + +object NativeLauncherTriggerHooker { + private const val ANDROID_17 = 37 + + private val LAUNCHER_PACKAGES = setOf( + "com.miui.home", + "com.mi.android.globallauncher", + ) + + fun isSupported(): Boolean = Build.VERSION.SDK_INT >= ANDROID_17 + + /** MiCTS's own trigger already applied the settings before it got here. */ + private val selfInvocation = ThreadLocal() + + private var launcherUid: Int = -1 + private var launcherUidResolved = false + private val triggerExecutor = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "micts-launcher-trigger").apply { isDaemon = true } + } + + fun asSelfInvocation(block: () -> T): T { + val previous = selfInvocation.get() + selfInvocation.set(true) + return try { + block() + } finally { + if (previous == null) selfInvocation.remove() else selfInvocation.set(previous) + } + } + + @SuppressLint("PrivateApi") + fun hook(param: SystemServerStartingParam) { + val stub = param.classLoader.loadClass( + "com.android.server.contextualsearch.ContextualSearchManagerService\$ContextualSearchManagerStub" + ) + val start = findStartMethod(stub) + ?: throw NoSuchMethodException("${stub.name}.startContextualSearch") + module!!.hook(start).intercept(StartHooker()) + } + + /** The entry point gained a `ContextualSearchConfig` overload in QPR1. */ + private fun findStartMethod(stub: Class<*>): Method? = stub.declaredMethods.firstOrNull { + if (it.name != "startContextualSearch" || it.returnType != Void.TYPE) return@firstOrNull false + val parameters = it.parameterTypes + when (parameters.size) { + 1 -> parameters[0] == Int::class.javaPrimitiveType + 2 -> parameters[0] == Int::class.javaPrimitiveType && + parameters[1].name == "android.app.contextualsearch.ContextualSearchConfig" + else -> false + } + } + + private fun isLauncherCaller(uid: Int): Boolean { + if (launcherUidResolved) return uid == launcherUid + val context = runCatching { + Class.forName("android.app.ActivityThread") + .getDeclaredMethod("currentApplication") + .invoke(null) as? Context + }.getOrNull() ?: return false + val resolved = LAUNCHER_PACKAGES.firstNotNullOfOrNull { pkg -> + runCatching { context.packageManager.getPackageUid(pkg, 0) }.getOrNull() + } ?: return false // Not resolvable yet — retry on the next request. + launcherUid = resolved + launcherUidResolved = true + return uid == launcherUid + } + + @SuppressLint("MissingPermission") // Runs in the system server, which owns VIBRATE. + private fun vibrate() { + runCatching { + val context = Class.forName("android.app.ActivityThread") + .getDeclaredMethod("currentApplication") + .invoke(null) as? Context ?: return + val vibrator = context.getSystemService(Vibrator::class.java) ?: return + if (!vibrator.hasVibrator()) return + val attributes = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY) + .setFlags(128) + .build() + vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK), attributes) + }.onFailure { e -> + module!!.log(Log.ERROR, "MiCTS", "native launcher trigger vibrate fail", e) + } + } + + /** What to do with a contextual-search request seen in the system server. */ + private enum class Decision { + /** Not ours — let the platform handle it unchanged. */ + PASS_THROUGH, + + /** The launcher's request: let it through, but with our bypass applied. */ + BRIDGE, + + /** Already handled (or deliberately dropped); stop the platform call. */ + CONSUME, + } + + class StartHooker : Hooker { + override fun intercept(chain: Chain): Any? { + val decision = runCatching { decide(chain) }.getOrElse { error -> + module!!.log( + Log.ERROR, "MiCTS", + "native launcher trigger gate failed; passing the request through", + error + ) + Decision.PASS_THROUGH + } + return when (decision) { + Decision.PASS_THROUGH -> chain.proceed() + Decision.BRIDGE -> CSMSHooker.withPermissionBypass { chain.proceed() } + Decision.CONSUME -> null + } + } + + private fun decide(chain: Chain): Decision { + if (selfInvocation.get() == true) return Decision.PASS_THROUGH + if (!isLauncherCaller(Binder.getCallingUid())) return Decision.PASS_THROUGH + + val prefs = module!!.getRemotePreferences(CONFIG_NAME) + if (!prefs.getBoolean( + KEY_GESTURE_TRIGGER, + DEFAULT_CONFIG[KEY_GESTURE_TRIGGER] as Boolean + ) + ) { + module!!.log(Log.INFO, "MiCTS", "native launcher trigger is disabled; ignoring") + return Decision.CONSUME + } + if (prefs.getBoolean(KEY_VIBRATE, DEFAULT_CONFIG[KEY_VIBRATE] as Boolean)) { + vibrate() + } + val selected = prefs.getInt( + KEY_TRIGGER_SERVICE, + DEFAULT_CONFIG[KEY_TRIGGER_SERVICE] as Int + ) + if (selected == TriggerService.CSService.ordinal) return Decision.BRIDGE + + val entryPoint = chain.args.getOrNull(0) as? Int ?: 1 + triggerExecutor.execute { + // The haptic already played above; do not repeat it. + asSelfInvocation { triggerCircleToSearch(entryPoint, null, false) } + } + return Decision.CONSUME + } + } +} diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 3644987..7551d4d 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1,7 +1,7 @@ Κυκλώστε για Αναζήτηση - Εκκινήστε το Κυκλώστε για Αναζήτηση σε οποιαδήποτε συσκευή με Android 9–16 + Εκκινήστε το Κυκλώστε για Αναζήτηση σε οποιαδήποτε συσκευή με Android 9–17 Ρυθμίσεις Το έναυσμα απέτυχε! Ρυθμίσεις Εφαρμογής diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 2dd7cfd..b29a84a 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1,7 +1,7 @@ Busca con un círculo - Activar Busca con un círculo en cualquier dispositivo Android 9–16 + Activar Busca con un círculo en cualquier dispositivo Android 9–17 Configuración ¡No se pudo activar! Ajustes de la aplicación diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index fbaf26b..ae2054a 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1,7 +1,7 @@ かこって検索 - Android 9–16 のデバイスで、かこって検索をトリガーします + Android 9–17 のデバイスで、かこって検索をトリガーします 設定 トリガーに失敗しました! アプリの設定 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 56c18f6..b599a96 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1,7 +1,7 @@ Circle to Search - Триггер для Circle to Search на любом устройстве Android 9–16 + Триггер для Circle to Search на любом устройстве Android 9–17 Настройки Триггер не сработал! Настройки приложения diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 640dc34..54fa31b 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1,7 +1,7 @@ Circle to Search - Circle to Search özelliğini herhangi bir Android 9–16 cihazda tetikleme + Circle to Search özelliğini herhangi bir Android 9–17 cihazda tetikleme Ayarlar Tetikleme başarısız! Uygulama Ayarları diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 41549fa..cde12f0 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -1,7 +1,7 @@ Tìm kiếm vòng tròn - Kích hoạt tính năng Tìm kiếm vòng tròn trên bất kỳ thiết bị Android 9–16 + Kích hoạt tính năng Tìm kiếm vòng tròn trên bất kỳ thiết bị Android 9–17 Cài đặt Kích hoạt thất bại! Cài đặt ứng dụng diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 99072fc..d59e5a7 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1,7 +1,7 @@ 畫圈搜尋 - 在任何Android 9–16裝置上觸發畫圈搜尋功能 + 在任何Android 9–17裝置上觸發畫圈搜尋功能 設定 觸發失敗! 應用程式設定 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index b3c7024..ec20dba 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1,7 +1,7 @@ 圈定即搜 - 在任意Android 9–16设备上触发圈定即搜(Circle to Search)功能 + 在任意Android 9–17设备上触发圈定即搜(Circle to Search)功能 设置 触发失败! 应用设置 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8c32a77..afb44a2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,7 +2,7 @@ MiCTS Circle to Search - Trigger Circle to Search on any Android 9–16 device + Trigger Circle to Search on any Android 9–17 device Settings Trigger failed! App Settings diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt new file mode 100644 index 0000000..abb8458 --- /dev/null +++ b/native/CMakeLists.txt @@ -0,0 +1,56 @@ +cmake_minimum_required(VERSION 3.22) + +project(micts_hyos_native LANGUAGES CXX) + +if(NOT ANDROID) + message(FATAL_ERROR "micts_hyos_native must use the Android NDK toolchain") +endif() + +if(NOT "${ANDROID_ABI}" STREQUAL "arm64-v8a") + message(FATAL_ERROR "Only arm64-v8a is supported; got '${ANDROID_ABI}'") +endif() + +set(MICTS_NATIVE_TARGET micts_hyos_lsp) + +add_library(${MICTS_NATIVE_TARGET} SHARED + micts_native_hook.cpp + launcher_cs_resolver.cpp + lsposed_hook_backend.cpp +) + +target_include_directories(${MICTS_NATIVE_TARGET} PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} +) + +target_compile_features(${MICTS_NATIVE_TARGET} PRIVATE cxx_std_17) +target_compile_options(${MICTS_NATIVE_TARGET} PRIVATE + -fno-exceptions + -fno-rtti + -fno-threadsafe-statics + -fvisibility=hidden + -fvisibility-inlines-hidden + -ffunction-sections + -fdata-sections + -mbranch-protection=standard + -Wall + -Wextra + -Werror +) + +target_link_options(${MICTS_NATIVE_TARGET} PRIVATE + # Android 16+ warns about APKs whose native libraries are not 16 KB page + # aligned; be explicit rather than relying on the NDK default. + -Wl,-z,max-page-size=16384 + "-Wl,--version-script=${CMAKE_CURRENT_SOURCE_DIR}/exports-lsposed.map" + -Wl,--gc-sections + -Wl,--no-undefined + -Wl,-z,relro + -Wl,-z,now +) + +target_link_libraries(${MICTS_NATIVE_TARGET} PRIVATE log dl) + +set_target_properties(${MICTS_NATIVE_TARGET} PROPERTIES + CXX_EXTENSIONS OFF + OUTPUT_NAME "${MICTS_NATIVE_TARGET}" +) diff --git a/native/README.md b/native/README.md new file mode 100644 index 0000000..69757f8 --- /dev/null +++ b/native/README.md @@ -0,0 +1,77 @@ +# HyperOS 4 launcher payload + +An LSPosed native entry that restores the "long press the gesture handle" +trigger on Android 17 / HyperOS 4. + +## Why this exists + +HyperOS 4 runs `com.miui.home` as a pure native process — it has no ART runtime +at all, so it carries no `[anon:dalvik-*]` mappings and LSPosed's Java callbacks +never fire for it. Every Java hook MiCTS uses for that trigger +(`NavStubView`, `NavStubGestureEventManager`, `NavBarEventHelper`, +`CircleToSearchHelper`) therefore stops working: the classes are not merely +renamed, the whole gesture pipeline moved into `libapp_launcher.so`. + +This payload inline hooks the launcher's contextual-search long-press handler +instead, and routes the gesture into the launcher's own contextual-search entry +point. The resulting call reaches `ContextualSearchManagerService` in the system +server, where `NativeLauncherTriggerHooker` applies the module's settings and +`CSMSHooker` grants it the permission and provider it needs. + +Nothing here is version-pinned: the addresses are resolved out of the mapped +image at runtime by matching the code's structure, so an unknown launcher build +either resolves exactly or fails closed and leaves the launcher stock. + +## Attribution + +Most of this directory is taken from or derived from **MiuiBackGestureHook**, +which is licensed under Apache-2.0: + +- Upstream: +- Licence text: [LICENSE-Apache-2.0](LICENSE-Apache-2.0) + +| File | Relationship to upstream | +| --- | --- | +| `native_api.h` | unmodified copy | +| `lsposed_hook_backend.h` | unmodified copy | +| `lsposed_hook_backend.cpp` | copied, then modified (see below) | +| `launcher_cs_resolver.{h,cpp}` | derived from `runtime_profile_resolver.{h,cpp}` | +| `micts_native_hook.cpp` | derived from `miui_home_native_hook.cpp` | + +Apache-2.0 is one-way compatible with the GPL-3.0 this project uses, so these +files may be distributed as part of MiCTS. + +### What changed + +`lsposed_hook_backend.cpp` + +- Dropped the LSPlt dependency. The `madvise` guard now reuses this file's own + `PltHookRaw`, which removed the archive/direct split and its + `/proc/self/maps` parsing along with it. +- Guards are tracked per image base, so both copies of + `libhyper_os_flutter.so` (the system one and the one mapped from the APK) get + protected. +- Renamed the log tag so it does not collide with upstream's when both modules + are installed on the same device. + +`launcher_cs_resolver.{h,cpp}` + +- Kept only the contextual-search family. The side-boundary, runtime-singleton, + RString and XiaoAi resolvers are dropped — MiCTS needs none of them. + +`micts_native_hook.cpp` + +- Kept the launcher/spawner process identification, the long-press closure + protocol, the `PackageManager` feature-probe override and the motion snapshot + used for upward-intent detection. +- Dropped everything specific to upstream's own purpose: back-gesture + arbitration, return-home, the Dart state hooks and the authenticated + broadcast bridge. +- The terminal routing calls the launcher's own contextual-search invoke rather + than upstream's private broadcast bridge. + +## Scope + +arm64-v8a only, and only inside the `hyos_spawner` process family. The build +restricts the native ABI rather than the APK's, so 32-bit devices still install +and use the pure Java trigger paths. diff --git a/native/exports-lsposed.map b/native/exports-lsposed.map new file mode 100644 index 0000000..2264ec0 --- /dev/null +++ b/native/exports-lsposed.map @@ -0,0 +1,6 @@ +MICTS_HYOS_LSPOSED_1.0 { + global: + native_init; + local: + *; +}; diff --git a/native/launcher_cs_resolver.cpp b/native/launcher_cs_resolver.cpp new file mode 100644 index 0000000..ca8a7d0 --- /dev/null +++ b/native/launcher_cs_resolver.cpp @@ -0,0 +1,726 @@ +// SPDX-License-Identifier: Apache-2.0 +// Derived from MiuiBackGestureHook; see native/README.md for attribution. +// +// Locates the feature-support probe, the invoke helper and the long-press Fn +// closure in the mapped launcher image by matching code structure, not offsets. +// The ELF view, instruction decoders and fingerprints come from upstream's +// resolver; only its contextual-search family is kept. + +#include "launcher_cs_resolver.h" + +#include +#include + +namespace micts_launcher { +namespace { + +constexpr size_t kMaxLoadSegments = 16u; +constexpr uintptr_t kMaxImageSpan = 0x4000000u; + +constexpr char kBundleDrop[] = "Bundle_drop"; +constexpr char kPackageManagerDefault[] = "PackageManager_default"; +constexpr char kPackageManagerHasSystemFeature[] = + "PackageManager_has_system_feature"; + +// Legacy shape (HyperOS 4 launcher 8.01.02.4371 family). +constexpr uint32_t kSupportPrologue[] = { + 0xd10383ffu, 0xa90c7bfdu, 0xa90d4ff4u, 0x910303fdu, +}; +constexpr uint32_t kInvokePrologue[] = { + 0xd10543ffu, 0xa9117bfdu, 0xa9125ffcu, 0xa91357f6u, + 0xa9144ff4u, 0x910443fdu, 0x9100c3f6u, 0xb90007e0u, +}; +constexpr uint32_t kLongPressPrologue[] = { + 0xd102c3ffu, 0xa9097bfdu, 0xa90a4ff4u, 0x910243fdu, +}; + +// Modern shape: 8.01.02.5465 and 8.01.02.6144 moved the contextual-search Rust +// graph to a smaller support helper and changed the Fn closure frame. These +// fingerprints stay structural — the resolver proves the two exact feature +// strings, the helper's PackageManager call, and the completion release store. +constexpr uint32_t kSupportModernPrologue[] = { + 0xd10243ffu, 0xa9067bfdu, 0xf9003bf5u, 0xa9084ff4u, + 0x910183fdu, +}; +constexpr uint32_t kFeatureHelperModernPrologue[] = { + 0xd10203ffu, 0xa9067bfdu, 0xa9074ff4u, 0x910183fdu, + 0xaa0003f3u, 0x9100c3e8u, +}; +constexpr uint32_t kInvokeModernPrologue[] = { + 0xd10403ffu, 0xa90c7bfdu, 0xf9006bf7u, 0xa90e57f6u, + 0xa90f4ff4u, 0x910303fdu, 0x2a0003f3u, +}; +constexpr uint32_t kLongPressModernPrologue[] = { + 0xd10183ffu, 0xa9047bfdu, 0xa9054ff4u, 0x910103fdu, +}; + +struct LoadSegment { + uintptr_t start; + uintptr_t end; + uint32_t flags; +}; + +struct ElfView { + const uint8_t* base; + LoadSegment loads[kMaxLoadSegments]; + size_t load_count; + uintptr_t image_span; + uintptr_t string_table; + size_t string_table_size; + uintptr_t symbol_table; + uintptr_t jump_relocations; + size_t jump_relocations_size; +}; + +struct RequiredImports { + uintptr_t bundle_drop; + uintptr_t package_manager_default; + uintptr_t package_manager_has_system_feature; +}; + +bool AddOverflows(uintptr_t left, uintptr_t right) { + return right > UINTPTR_MAX - left; +} + +bool Contains(const ElfView& view, uintptr_t offset, size_t size, + uint32_t required_flags, uint32_t forbidden_flags = 0u) { + if (size == 0u || AddOverflows(offset, size)) return false; + const uintptr_t end = offset + size; + for (size_t index = 0u; index < view.load_count; ++index) { + const LoadSegment& load = view.loads[index]; + if (offset >= load.start && end <= load.end && + (load.flags & required_flags) == required_flags && + (load.flags & forbidden_flags) == 0u) { + return true; + } + } + return false; +} + +bool ReadInstruction(const ElfView& view, uintptr_t offset, + uint32_t* instruction) { + if (instruction == nullptr || + !Contains(view, offset, sizeof(*instruction), PF_R | PF_X)) { + return false; + } + memcpy(instruction, view.base + offset, sizeof(*instruction)); + return true; +} + +bool InstructionEquals(const ElfView& view, uintptr_t offset, + uint32_t expected) { + uint32_t instruction = 0u; + return ReadInstruction(view, offset, &instruction) && + instruction == expected; +} + +bool MatchesWords(const ElfView& view, uintptr_t offset, + const uint32_t* words, size_t count) { + if (words == nullptr || count == 0u || + !Contains(view, offset, count * sizeof(uint32_t), PF_R | PF_X)) { + return false; + } + return memcmp(view.base + offset, words, count * sizeof(uint32_t)) == 0; +} + +bool ReadOnlyBytesEqual(const ElfView& view, uintptr_t offset, + const char* expected, size_t size) { + return expected != nullptr && + Contains(view, offset, size, PF_R, PF_W) && + memcmp(view.base + offset, expected, size) == 0; +} + +bool NormalizeDynamicPointer(const ElfView& view, Elf64_Addr value, + uintptr_t* offset) { + if (offset == nullptr) return false; + const uintptr_t raw = static_cast(value); + const uintptr_t base_address = reinterpret_cast(view.base); + if (raw >= base_address && raw - base_address < view.image_span) { + *offset = raw - base_address; + return true; + } + if (raw < view.image_span) { + *offset = raw; + return true; + } + return false; +} + +bool ParseElf(const uint8_t* base, ElfView* output) { + if (base == nullptr || output == nullptr) return false; + Elf64_Ehdr header{}; + memcpy(&header, base, sizeof(header)); + if (memcmp(header.e_ident, ELFMAG, SELFMAG) != 0 || + header.e_ident[EI_CLASS] != ELFCLASS64 || + header.e_ident[EI_DATA] != ELFDATA2LSB || + header.e_type != ET_DYN || header.e_machine != EM_AARCH64 || + header.e_phentsize != sizeof(Elf64_Phdr) || + header.e_phnum == 0u || header.e_phnum > 64u || + header.e_phoff > 0x1000u || + AddOverflows(header.e_phoff, + static_cast(header.e_phnum) * + sizeof(Elf64_Phdr)) || + header.e_phoff + static_cast(header.e_phnum) * + sizeof(Elf64_Phdr) > 0x1000u) { + return false; + } + + ElfView view{}; + view.base = base; + const auto* program_headers = + reinterpret_cast(base + header.e_phoff); + uintptr_t dynamic_offset = 0u; + size_t dynamic_size = 0u; + bool headers_covered = false; + const uintptr_t headers_end = header.e_phoff + + static_cast(header.e_phnum) * sizeof(Elf64_Phdr); + for (size_t index = 0u; index < header.e_phnum; ++index) { + Elf64_Phdr program_header{}; + memcpy(&program_header, program_headers + index, + sizeof(program_header)); + if (program_header.p_type == PT_LOAD) { + if (view.load_count >= kMaxLoadSegments || + program_header.p_memsz == 0u || + AddOverflows(program_header.p_vaddr, + program_header.p_memsz)) { + return false; + } + const uintptr_t start = program_header.p_vaddr; + const uintptr_t end = start + program_header.p_memsz; + if (end > kMaxImageSpan) return false; + view.loads[view.load_count++] = {start, end, program_header.p_flags}; + if (start == 0u && headers_end <= program_header.p_filesz) { + headers_covered = true; + } + if (end > view.image_span) view.image_span = end; + } else if (program_header.p_type == PT_DYNAMIC) { + if (program_header.p_memsz == 0u) return false; + dynamic_offset = program_header.p_vaddr; + dynamic_size = static_cast(program_header.p_memsz); + } + } + if (!headers_covered || view.load_count == 0u || view.image_span == 0u || + dynamic_size < sizeof(Elf64_Dyn) || + !Contains(view, dynamic_offset, dynamic_size, PF_R)) { + return false; + } + + Elf64_Addr string_table = 0u; + Elf64_Addr symbol_table = 0u; + Elf64_Addr jump_relocations = 0u; + size_t string_table_size = 0u; + size_t symbol_entry_size = 0u; + size_t jump_relocations_size = 0u; + size_t relocation_entry_size = sizeof(Elf64_Rela); + Elf64_Sxword relocation_kind = 0; + bool terminated = false; + const size_t dynamic_count = dynamic_size / sizeof(Elf64_Dyn); + for (size_t index = 0u; index < dynamic_count; ++index) { + Elf64_Dyn entry{}; + memcpy(&entry, view.base + dynamic_offset + index * sizeof(Elf64_Dyn), + sizeof(entry)); + if (entry.d_tag == DT_NULL) { + terminated = true; + break; + } + switch (entry.d_tag) { + case DT_STRTAB: string_table = entry.d_un.d_ptr; break; + case DT_STRSZ: string_table_size = entry.d_un.d_val; break; + case DT_SYMTAB: symbol_table = entry.d_un.d_ptr; break; + case DT_SYMENT: symbol_entry_size = entry.d_un.d_val; break; + case DT_JMPREL: jump_relocations = entry.d_un.d_ptr; break; + case DT_PLTRELSZ: jump_relocations_size = entry.d_un.d_val; break; + case DT_PLTREL: relocation_kind = entry.d_un.d_val; break; + case DT_RELAENT: relocation_entry_size = entry.d_un.d_val; break; + default: break; + } + } + if (!terminated || string_table_size == 0u || + symbol_entry_size != sizeof(Elf64_Sym) || + relocation_kind != DT_RELA || + relocation_entry_size != sizeof(Elf64_Rela) || + jump_relocations_size == 0u || + jump_relocations_size % sizeof(Elf64_Rela) != 0u || + !NormalizeDynamicPointer(view, string_table, &view.string_table) || + !NormalizeDynamicPointer(view, symbol_table, &view.symbol_table) || + !NormalizeDynamicPointer(view, jump_relocations, + &view.jump_relocations)) { + return false; + } + view.string_table_size = string_table_size; + view.jump_relocations_size = jump_relocations_size; + if (!Contains(view, view.string_table, view.string_table_size, PF_R) || + !Contains(view, view.symbol_table, sizeof(Elf64_Sym), PF_R) || + !Contains(view, view.jump_relocations, view.jump_relocations_size, + PF_R)) { + return false; + } + *output = view; + return true; +} + +bool BoundedStringEquals(const char* value, size_t available, + const char* expected) { + if (value == nullptr || expected == nullptr) return false; + size_t index = 0u; + while (expected[index] != '\0') { + if (index >= available || value[index] != expected[index]) return false; + ++index; + } + return index < available && value[index] == '\0'; +} + +/** Returns the sole JUMP_SLOT GOT offset for an exact imported symbol name. */ +bool FindImportGot(const ElfView& view, const char* expected, + uintptr_t* result) { + if (result == nullptr) return false; + uintptr_t matched = 0u; + uint32_t match_count = 0u; + const size_t count = view.jump_relocations_size / sizeof(Elf64_Rela); + for (size_t index = 0u; index < count; ++index) { + Elf64_Rela relocation{}; + memcpy(&relocation, + view.base + view.jump_relocations + index * sizeof(Elf64_Rela), + sizeof(relocation)); + const size_t symbol_index = ELF64_R_SYM(relocation.r_info); + if (symbol_index > (SIZE_MAX - view.symbol_table) / sizeof(Elf64_Sym)) { + return false; + } + const uintptr_t symbol_offset = + view.symbol_table + symbol_index * sizeof(Elf64_Sym); + if (!Contains(view, symbol_offset, sizeof(Elf64_Sym), PF_R)) { + return false; + } + Elf64_Sym symbol{}; + memcpy(&symbol, view.base + symbol_offset, sizeof(symbol)); + if (symbol.st_name >= view.string_table_size) return false; + const char* name = reinterpret_cast( + view.base + view.string_table + symbol.st_name); + if (!BoundedStringEquals(name, view.string_table_size - symbol.st_name, + expected)) { + continue; + } + uintptr_t got_offset = 0u; + if (!NormalizeDynamicPointer(view, relocation.r_offset, &got_offset) || + !Contains(view, got_offset, sizeof(uintptr_t), PF_R)) { + return false; + } + matched = got_offset; + ++match_count; + } + if (match_count != 1u) return false; + *result = matched; + return true; +} + +bool ResolveImports(const ElfView& view, RequiredImports* imports) { + return imports != nullptr && + FindImportGot(view, kBundleDrop, &imports->bundle_drop) && + FindImportGot(view, kPackageManagerDefault, + &imports->package_manager_default) && + FindImportGot(view, kPackageManagerHasSystemFeature, + &imports->package_manager_has_system_feature); +} + +bool DecodeAdrp(uint32_t instruction, uintptr_t pc, uint32_t reg, + uintptr_t* target_page) { + if (target_page == nullptr || reg > 31u || + (instruction & 0x9f00001fu) != (0x90000000u | reg)) { + return false; + } + int64_t immediate = static_cast( + ((instruction >> 29u) & 0x3u) | + (((instruction >> 5u) & 0x7ffffu) << 2u)); + if ((immediate & (int64_t{1} << 20u)) != 0) { + immediate -= int64_t{1} << 21u; + } + const int64_t page = static_cast(pc & ~uintptr_t{0xfffu}); + const int64_t target = page + immediate * int64_t{4096}; + if (target < 0 || static_cast(target) > UINTPTR_MAX) return false; + *target_page = static_cast(target); + return true; +} + +bool DecodeAddImmediate(uint32_t instruction, uint32_t destination, + uint32_t source, uintptr_t* immediate) { + if (immediate == nullptr || + (instruction & 0xffc003ffu) != + (0x91000000u | (source << 5u) | destination)) { + return false; + } + *immediate = (instruction >> 10u) & 0xfffu; + return true; +} + +bool DecodeLdr64Immediate(uint32_t instruction, uint32_t destination, + uint32_t source, uintptr_t* immediate) { + if (immediate == nullptr || + (instruction & 0xffc003ffu) != + (0xf9400000u | (source << 5u) | destination)) { + return false; + } + *immediate = ((instruction >> 10u) & 0xfffu) * sizeof(uintptr_t); + return true; +} + +bool DecodeAddressPair(const ElfView& view, uintptr_t instruction_offset, + uint32_t reg, uintptr_t* target) { + uint32_t adrp = 0u; + uint32_t add = 0u; + uintptr_t page = 0u; + uintptr_t immediate = 0u; + return ReadInstruction(view, instruction_offset, &adrp) && + ReadInstruction(view, instruction_offset + 4u, &add) && + DecodeAdrp(adrp, instruction_offset, reg, &page) && + DecodeAddImmediate(add, reg, reg, &immediate) && + !AddOverflows(page, immediate) && + ((*target = page + immediate), true); +} + +bool DecodeBlTarget(const ElfView& view, uintptr_t instruction_offset, + uintptr_t* target) { + uint32_t instruction = 0u; + if (target == nullptr || + !ReadInstruction(view, instruction_offset, &instruction) || + (instruction & 0xfc000000u) != 0x94000000u) { + return false; + } + int64_t immediate = instruction & 0x03ffffffu; + if ((immediate & (int64_t{1} << 25u)) != 0) { + immediate -= int64_t{1} << 26u; + } + const int64_t destination = + static_cast(instruction_offset) + immediate * int64_t{4}; + if (destination < 0 || static_cast(destination) > UINTPTR_MAX || + !Contains(view, static_cast(destination), 16u, + PF_R | PF_X)) { + return false; + } + *target = static_cast(destination); + return true; +} + +bool DecodePltGot(const ElfView& view, uintptr_t plt_offset, + uintptr_t* got_offset) { + uint32_t adrp = 0u; + uint32_t ldr = 0u; + uint32_t add = 0u; + uint32_t branch = 0u; + uintptr_t page = 0u; + uintptr_t load_immediate = 0u; + uintptr_t add_immediate = 0u; + return got_offset != nullptr && + ReadInstruction(view, plt_offset, &adrp) && + ReadInstruction(view, plt_offset + 4u, &ldr) && + ReadInstruction(view, plt_offset + 8u, &add) && + ReadInstruction(view, plt_offset + 12u, &branch) && + DecodeAdrp(adrp, plt_offset, 16u, &page) && + DecodeLdr64Immediate(ldr, 17u, 16u, &load_immediate) && + DecodeAddImmediate(add, 16u, 16u, &add_immediate) && + load_immediate == add_immediate && branch == 0xd61f0220u && + !AddOverflows(page, load_immediate) && + ((*got_offset = page + load_immediate), true); +} + +bool CallTargetsImport(const ElfView& view, uintptr_t call_offset, + uintptr_t expected_got) { + uintptr_t plt = 0u; + uintptr_t got = 0u; + return DecodeBlTarget(view, call_offset, &plt) && + DecodePltGot(view, plt, &got) && got == expected_got; +} + +bool IsFeatureHelperModern(const ElfView& view, const RequiredImports& imports, + uintptr_t offset) { + return MatchesWords(view, offset, kFeatureHelperModernPrologue, + sizeof(kFeatureHelperModernPrologue) / + sizeof(kFeatureHelperModernPrologue[0])) && + InstructionEquals(view, offset + 0x18u, 0xaa0103e0u) && + InstructionEquals(view, offset + 0x1cu, 0xaa0203e1u) && + InstructionEquals(view, offset + 0x20u, 0xaa0303e2u) && + InstructionEquals(view, offset + 0x24u, 0x2a1f03e3u) && + CallTargetsImport(view, offset + 0x28u, + imports.package_manager_has_system_feature); +} + +bool IsSupportModernCandidate(const ElfView& view, + const RequiredImports& imports, + uintptr_t offset) { + uintptr_t first_string = 0u; + uintptr_t second_string = 0u; + uintptr_t helper = 0u; + return MatchesWords(view, offset, kSupportModernPrologue, + sizeof(kSupportModernPrologue) / + sizeof(kSupportModernPrologue[0])) && + CallTargetsImport(view, offset + 0x14u, + imports.package_manager_default) && + DecodeAddressPair(view, offset + 0x1cu, 2u, &first_string) && + ReadOnlyBytesEqual(view, first_string, + "android.software.contextualsearch", 33u) && + DecodeBlTarget(view, offset + 0x30u, &helper) && + DecodeBlTarget(view, offset + 0x70u, &first_string) && + first_string == helper && + IsFeatureHelperModern(view, imports, helper) && + DecodeAddressPair(view, offset + 0x58u, 2u, &second_string) && + ReadOnlyBytesEqual(view, second_string, + "com.google.android.feature.CONTEXTUAL_SEARCH", + 44u); +} + +bool IsSupportCandidate(const ElfView& view, const RequiredImports& imports, + uintptr_t offset) { + uintptr_t first_string_page = 0u; + uintptr_t first_string_immediate = 0u; + uintptr_t second_string_page = 0u; + uintptr_t second_string_immediate = 0u; + uint32_t instruction = 0u; + const bool legacy = + MatchesWords(view, offset, kSupportPrologue, + sizeof(kSupportPrologue) / + sizeof(kSupportPrologue[0])) && + CallTargetsImport(view, offset + 0x10u, + imports.package_manager_default) && + ReadInstruction(view, offset + 0x14u, &instruction) && + DecodeAdrp(instruction, offset + 0x14u, 1u, &first_string_page) && + ReadInstruction(view, offset + 0x18u, &instruction) && + DecodeAddImmediate(instruction, 1u, 1u, &first_string_immediate) && + !AddOverflows(first_string_page, first_string_immediate) && + Contains(view, first_string_page + first_string_immediate, 33u, + PF_R, PF_X) && + InstructionEquals(view, offset + 0x1cu, 0x910143e8u) && + InstructionEquals(view, offset + 0x20u, 0x52800422u) && + InstructionEquals(view, offset + 0x24u, 0x2a1f03e3u) && + InstructionEquals(view, offset + 0x28u, 0xaa0003f3u) && + CallTargetsImport(view, offset + 0x2cu, + imports.package_manager_has_system_feature) && + ReadInstruction(view, offset + 0xb4u, &instruction) && + DecodeAdrp(instruction, offset + 0xb4u, 1u, &second_string_page) && + ReadInstruction(view, offset + 0xb8u, &instruction) && + DecodeAddImmediate(instruction, 1u, 1u, &second_string_immediate) && + !AddOverflows(second_string_page, second_string_immediate) && + Contains(view, second_string_page + second_string_immediate, 44u, + PF_R, PF_X) && + InstructionEquals(view, offset + 0xc0u, 0x910143e8u) && + InstructionEquals(view, offset + 0xc4u, 0xaa1303e0u) && + InstructionEquals(view, offset + 0xc8u, 0x52800582u) && + InstructionEquals(view, offset + 0xccu, 0x2a1f03e3u) && + CallTargetsImport(view, offset + 0xd0u, + imports.package_manager_has_system_feature); + return legacy || IsSupportModernCandidate(view, imports, offset); +} + +bool ResolveSupport(const ElfView& view, const RequiredImports& imports, + uintptr_t* support_offset, uint32_t* candidate_count) { + uintptr_t matched = 0u; + uint32_t matches = 0u; + for (size_t segment_index = 0u; segment_index < view.load_count; + ++segment_index) { + const LoadSegment& load = view.loads[segment_index]; + if ((load.flags & (PF_R | PF_X)) != (PF_R | PF_X) || + load.end - load.start < 0xd4u) { + continue; + } + const uintptr_t start = (load.start + 3u) & ~uintptr_t{3u}; + for (uintptr_t offset = start; offset <= load.end - 0xd4u; + offset += 4u) { + if (!IsSupportCandidate(view, imports, offset)) continue; + matched = offset; + ++matches; + } + } + if (candidate_count != nullptr) *candidate_count = matches; + if (matches != 1u || support_offset == nullptr) return false; + *support_offset = matched; + return true; +} + +bool IsInvokeCandidate(const ElfView& view, uintptr_t offset, + uintptr_t support_offset) { + uintptr_t called_support = 0u; + uint32_t branch = 0u; + const bool legacy = + MatchesWords(view, offset, kInvokePrologue, + sizeof(kInvokePrologue) / + sizeof(kInvokePrologue[0])) && + DecodeBlTarget(view, offset + 0x20u, &called_support) && + called_support == support_offset && + ReadInstruction(view, offset + 0x28u, &branch) && + (branch & 0xfff8001fu) == 0x36000000u; + if (legacy) return true; + return MatchesWords(view, offset, kInvokeModernPrologue, + sizeof(kInvokeModernPrologue) / + sizeof(kInvokeModernPrologue[0])) && + InstructionEquals(view, offset + 0x1cu, 0x9100e3f4u) && + InstructionEquals(view, offset + 0x20u, 0xb9000fe0u) && + DecodeBlTarget(view, offset + 0x24u, &called_support) && + called_support == support_offset; +} + +bool ResolveInvoke(const ElfView& view, uintptr_t support_offset, + uintptr_t* invoke_offset, uint32_t* candidate_count) { + uintptr_t matched = 0u; + uint32_t matches = 0u; + for (size_t segment_index = 0u; segment_index < view.load_count; + ++segment_index) { + const LoadSegment& load = view.loads[segment_index]; + if ((load.flags & (PF_R | PF_X)) != (PF_R | PF_X) || + load.end - load.start < 0x2cu) { + continue; + } + const uintptr_t start = (load.start + 3u) & ~uintptr_t{3u}; + for (uintptr_t offset = start; offset <= load.end - 0x2cu; + offset += 4u) { + if (!IsInvokeCandidate(view, offset, support_offset)) continue; + matched = offset; + ++matches; + } + } + if (candidate_count != nullptr) *candidate_count = matches; + if (matches != 1u || invoke_offset == nullptr) return false; + *invoke_offset = matched; + return true; +} + +bool IsLongPressCandidate(const ElfView& view, const RequiredImports& imports, + uintptr_t offset) { + uintptr_t fallback = 0u; + uint32_t branch = 0u; + const bool legacy = + MatchesWords(view, offset, kLongPressPrologue, + sizeof(kLongPressPrologue) / + sizeof(kLongPressPrologue[0])) && + InstructionEquals(view, offset + 0x14u, 0xaa0003f3u) && + InstructionEquals(view, offset + 0x58u, 0x2a0103f4u) && + InstructionEquals(view, offset + 0xe0u, 0xd63f0100u) && + InstructionEquals(view, offset + 0xe4u, 0x2a1403e1u) && + InstructionEquals(view, offset + 0xe8u, 0xf9400268u) && + InstructionEquals(view, offset + 0xecu, 0x52800029u) && + InstructionEquals(view, offset + 0xf0u, 0x91004108u) && + InstructionEquals(view, offset + 0xf4u, 0x089ffd09u) && + InstructionEquals(view, offset + 0xf8u, 0xf9400668u) && + ReadInstruction(view, offset + 0xfcu, &branch) && + (branch & 0xff00001fu) == 0xb4000008u && + InstructionEquals(view, offset + 0x100u, 0xf9400a69u) && + InstructionEquals(view, offset + 0x104u, 0xf940092au) && + InstructionEquals(view, offset + 0x108u, 0xf9401529u) && + InstructionEquals(view, offset + 0x10cu, 0xd100054au) && + InstructionEquals(view, offset + 0x110u, 0x927ced4au) && + InstructionEquals(view, offset + 0x114u, 0x8b0a0108u) && + InstructionEquals(view, offset + 0x118u, 0x91004100u) && + InstructionEquals(view, offset + 0x11cu, 0xd63f0120u) && + DecodeBlTarget(view, offset + 0x134u, &fallback) && + fallback != offset && + ReadInstruction(view, offset + 0x138u, &branch) && + (branch & 0xff00001fu) == 0xb4000000u && + CallTargetsImport(view, offset + 0x13cu, imports.bundle_drop); + if (legacy) return true; + + // Newer DefaultLongPressHandler keeps the same captured-completion + // protocol but uses a compact frame and invokes XiaoAi before the + // Bundle_drop fallback. Match the release store and closure dispatch as + // one contiguous graph; this excludes unrelated FnOnce shims. + uintptr_t xiaoai = 0u; + return MatchesWords(view, offset, kLongPressModernPrologue, + sizeof(kLongPressModernPrologue) / + sizeof(kLongPressModernPrologue[0])) && + InstructionEquals(view, offset + 0x14u, 0x2a0103f3u) && + InstructionEquals(view, offset + 0x18u, 0xaa0003f4u) && + InstructionEquals(view, offset + 0x74u, 0xf9400288u) && + InstructionEquals(view, offset + 0x78u, 0x52800029u) && + InstructionEquals(view, offset + 0x7cu, 0x91004108u) && + InstructionEquals(view, offset + 0x80u, 0x089ffd09u) && + InstructionEquals(view, offset + 0x84u, 0xf9400688u) && + ReadInstruction(view, offset + 0x88u, &branch) && + (branch & 0xff00001fu) == 0xb4000008u && + InstructionEquals(view, offset + 0x8cu, 0xf9400a89u) && + InstructionEquals(view, offset + 0x90u, 0x2a1303e1u) && + InstructionEquals(view, offset + 0x94u, 0xf940092au) && + InstructionEquals(view, offset + 0x98u, 0xf9401529u) && + InstructionEquals(view, offset + 0x9cu, 0xd100054au) && + InstructionEquals(view, offset + 0xa0u, 0x927ced4au) && + InstructionEquals(view, offset + 0xa4u, 0x8b0a0108u) && + InstructionEquals(view, offset + 0xa8u, 0x91004100u) && + InstructionEquals(view, offset + 0xacu, 0xd63f0120u) && + DecodeBlTarget(view, offset + 0xb8u, &xiaoai) && xiaoai != offset && + ReadInstruction(view, offset + 0xbcu, &branch) && + (branch & 0xff00001fu) == 0xb4000000u && + CallTargetsImport(view, offset + 0xc0u, imports.bundle_drop); +} + +bool ResolveLongPress(const ElfView& view, const RequiredImports& imports, + uintptr_t* handler_offset, uint32_t* candidate_count) { + uintptr_t matched = 0u; + uint32_t matches = 0u; + for (size_t segment_index = 0u; segment_index < view.load_count; + ++segment_index) { + const LoadSegment& load = view.loads[segment_index]; + if ((load.flags & (PF_R | PF_X)) != (PF_R | PF_X) || + load.end - load.start < 0x140u) { + continue; + } + const uintptr_t start = (load.start + 3u) & ~uintptr_t{3u}; + for (uintptr_t offset = start; offset <= load.end - 0x140u; + offset += 4u) { + if (!IsLongPressCandidate(view, imports, offset)) continue; + matched = offset; + ++matches; + } + } + if (candidate_count != nullptr) *candidate_count = matches; + if (matches != 1u || handler_offset == nullptr) return false; + *handler_offset = matched; + return true; +} + +} // namespace + +bool ResolveContextualSearchProfile(const uint8_t* base, + ContextualSearchProfile* profile, + ResolveDiagnostics* diagnostics) { + ResolveDiagnostics local{}; + local.stage = ResolveStage::kParsingElf; + if (diagnostics != nullptr) *diagnostics = local; + if (base == nullptr || profile == nullptr) return false; + + ElfView view{}; + if (!ParseElf(base, &view)) { + local.stage = ResolveStage::kRejectedElf; + if (diagnostics != nullptr) *diagnostics = local; + return false; + } + local.image_span = view.image_span; + + local.stage = ResolveStage::kResolvingImports; + RequiredImports imports{}; + if (!ResolveImports(view, &imports)) { + local.stage = ResolveStage::kRejectedImports; + if (diagnostics != nullptr) *diagnostics = local; + return false; + } + + local.stage = ResolveStage::kResolvingContextualSearch; + uintptr_t support = 0u; + uintptr_t invoke = 0u; + uintptr_t handler = 0u; + const bool resolved = + ResolveSupport(view, imports, &support, + &local.support_candidate_count) && + ResolveInvoke(view, support, &invoke, + &local.invoke_candidate_count) && + ResolveLongPress(view, imports, &handler, + &local.long_press_candidate_count); + if (!resolved) { + local.stage = ResolveStage::kRejectedContextualSearch; + if (diagnostics != nullptr) *diagnostics = local; + return false; + } + + profile->support_offset = support; + profile->invoke_offset = invoke; + profile->long_press_handler_offset = handler; + local.stage = ResolveStage::kComplete; + if (diagnostics != nullptr) *diagnostics = local; + return true; +} + +} // namespace micts_launcher diff --git a/native/launcher_cs_resolver.h b/native/launcher_cs_resolver.h new file mode 100644 index 0000000..d7831a3 --- /dev/null +++ b/native/launcher_cs_resolver.h @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 +// Derived from MiuiBackGestureHook; see native/README.md for attribution. + +#pragma once + +#include +#include + +namespace micts_launcher { + +enum class ResolveStage : uint32_t { + kNotStarted = 0, + kParsingElf = 1, + kResolvingImports = 2, + kResolvingContextualSearch = 3, + kComplete = 4, + kRejectedElf = 101, + kRejectedImports = 102, + kRejectedContextualSearch = 103, +}; + +struct ContextualSearchProfile { + // Offsets are relative to the mapped launcher image base. + uintptr_t long_press_handler_offset; + uintptr_t invoke_offset; + uintptr_t support_offset; +}; + +struct ResolveDiagnostics { + ResolveStage stage; + uint32_t support_candidate_count; + uint32_t invoke_candidate_count; + uint32_t long_press_candidate_count; + uintptr_t image_span; +}; + +/** + * Resolves the contextual-search graph out of the mapped `libapp_launcher.so`. + * Every address is backed by a mapped executable segment and the expected + * imported call graph, so an unknown build either resolves exactly or fails + * closed. No version-specific offsets are involved. + */ +bool ResolveContextualSearchProfile(const uint8_t* base, + ContextualSearchProfile* profile, + ResolveDiagnostics* diagnostics); + +} // namespace micts_launcher diff --git a/native/lsposed_hook_backend.cpp b/native/lsposed_hook_backend.cpp new file mode 100644 index 0000000..bd1dc85 --- /dev/null +++ b/native/lsposed_hook_backend.cpp @@ -0,0 +1,599 @@ +// SPDX-License-Identifier: Apache-2.0 +// Derived from MiuiBackGestureHook; see native/README.md for attribution. + +#include "lsposed_hook_backend.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr char kLogTag[] = "MiCTSNativeGuard"; +constexpr char kHyperRuntimeName[] = "libhyper_os_flutter.so"; +constexpr char kHyperRuntimePath[] = + "/system_ext/lib64/libhyper_os_flutter.so"; +constexpr size_t kMaxSegments = 16u; +constexpr size_t kMaxProtectedPages = 128u; +constexpr size_t kMaxPltSlots = 16u; +constexpr size_t kMaxSymbols = 1u << 24; +constexpr size_t kInlinePatchGuardSpan = 32u; + +const NativeAPIEntries* g_lsposed_api = nullptr; +using MadviseFn = int (*)(void*, size_t, int); +MadviseFn g_original_madvise = madvise; +volatile uint32_t g_madvise_guard_state = 0u; +// Each image importing madvise needs its own guard — the system runtime and +// the APK-mapped copy import it separately. Track patched bases to stay idempotent. +constexpr size_t kMaxMadviseGuards = 4u; +volatile uintptr_t g_madvise_guarded_bases[kMaxMadviseGuards]{}; +volatile uint32_t g_protected_page_lock = 0u; +uintptr_t g_protected_pages[kMaxProtectedPages]{}; +size_t g_protected_page_count = 0u; + +struct Segment { + uintptr_t begin; + uintptr_t end; + uint32_t flags; +}; + +struct Image { + uintptr_t base; + const ElfW(Phdr)* phdr; + size_t phnum; + Segment segments[kMaxSegments]; + size_t segment_count; + char path[512]; +}; + +struct DynamicView { + const ElfW(Sym)* symbols; + const char* strings; + size_t string_size; + size_t symbol_count; + const ElfW(Rela)* plt_rela; + size_t plt_rela_count; + const ElfW(Rela)* dyn_rela; + size_t dyn_rela_count; +}; + +size_t PageSize() { + static size_t value = 0u; + if (value == 0u) { + const long queried = sysconf(_SC_PAGESIZE); + value = queried > 0 ? static_cast(queried) : size_t{4096}; + } + return value; +} + +uintptr_t PageStart(uintptr_t address) { + return address & ~(static_cast(PageSize()) - 1u); +} + +const char* BaseName(const char* path) { + if (path == nullptr) return nullptr; + const char* slash = strrchr(path, '/'); + return slash == nullptr ? path : slash + 1; +} + +bool AddProtectedPage(uintptr_t address) { + const uintptr_t page = PageStart(address); + while (__atomic_exchange_n(&g_protected_page_lock, uint32_t{1}, + __ATOMIC_ACQUIRE) != 0u) { + } + size_t count = __atomic_load_n(&g_protected_page_count, __ATOMIC_RELAXED); + for (size_t index = 0u; index < count; ++index) { + if (g_protected_pages[index] == page) { + __atomic_store_n(&g_protected_page_lock, uint32_t{0}, + __ATOMIC_RELEASE); + return true; + } + } + if (count >= kMaxProtectedPages) { + __atomic_store_n(&g_protected_page_lock, uint32_t{0}, + __ATOMIC_RELEASE); + return false; + } + g_protected_pages[count] = page; + __atomic_store_n(&g_protected_page_count, count + 1u, __ATOMIC_RELEASE); + __atomic_store_n(&g_protected_page_lock, uint32_t{0}, __ATOMIC_RELEASE); + return true; +} + +int GuardedMadvise(void* address, size_t length, int advice) { + MadviseFn original = __atomic_load_n(&g_original_madvise, __ATOMIC_ACQUIRE); + if (original == nullptr) { + errno = ENOSYS; + return -1; + } + if (advice != MADV_DONTNEED || length == 0u) { + return original(address, length, advice); + } + const uintptr_t begin = reinterpret_cast(address); + if (begin % PageSize() != 0u || length > UINTPTR_MAX - begin) { + return original(address, length, advice); + } + const uintptr_t end = begin + length; + uintptr_t cursor = begin; + bool protected_page_found = false; + while (cursor < end) { + uintptr_t next_page = end; + const size_t count = __atomic_load_n(&g_protected_page_count, + __ATOMIC_ACQUIRE); + for (size_t index = 0u; index < count; ++index) { + const uintptr_t page = g_protected_pages[index]; + if (page >= cursor && page < end && page < next_page) { + next_page = page; + } + } + if (next_page == end) break; + if (next_page > cursor && + original(reinterpret_cast(cursor), next_page - cursor, + advice) != 0) { + return -1; + } + if (next_page > UINTPTR_MAX - PageSize()) { + errno = EINVAL; + return -1; + } + cursor = next_page + PageSize(); + if (cursor > end) cursor = end; + protected_page_found = true; + } + if (!protected_page_found) return original(address, length, advice); + if (cursor < end && + original(reinterpret_cast(cursor), end - cursor, advice) != 0) { + return -1; + } + __android_log_print(ANDROID_LOG_DEBUG, kLogTag, + "preserved hook page in MADV_DONTNEED range %p-%p", + address, reinterpret_cast(end)); + return 0; +} + +bool RangeInImage(const Image& image, uintptr_t address, size_t size, + uint32_t required_flags = 0u) { + if (size > UINTPTR_MAX - address) return false; + const uintptr_t end = address + size; + for (size_t index = 0u; index < image.segment_count; ++index) { + const Segment& segment = image.segments[index]; + if (address >= segment.begin && end <= segment.end && + (segment.flags & required_flags) == required_flags) { + return true; + } + } + return false; +} + +struct FindImageRequest { + const char* path; + uintptr_t base; + Image result; + size_t matches; +}; + +int FindImageCallback(dl_phdr_info* info, size_t, void* opaque) { + auto* request = static_cast(opaque); + const char* mapped_path = info->dlpi_name; + bool matches = false; + if (request->base != 0u) { + matches = static_cast(info->dlpi_addr) == request->base; + } else if (request->path != nullptr && mapped_path != nullptr) { + if (strchr(request->path, '/') != nullptr) { + matches = strcmp(request->path, mapped_path) == 0; + } else { + matches = strcmp(request->path, BaseName(mapped_path)) == 0; + } + } + if (!matches) return 0; + ++request->matches; + if (request->matches != 1u || info->dlpi_phnum == 0u || + info->dlpi_phnum > 128u) { + return 0; + } + Image& image = request->result; + image.base = static_cast(info->dlpi_addr); + image.phdr = info->dlpi_phdr; + image.phnum = info->dlpi_phnum; + if (mapped_path != nullptr) { + const size_t length = strlen(mapped_path); + if (length >= sizeof(image.path)) return 0; + memcpy(image.path, mapped_path, length + 1u); + } + for (size_t index = 0u; index < image.phnum; ++index) { + const ElfW(Phdr)& phdr = image.phdr[index]; + if (phdr.p_type != PT_LOAD || phdr.p_memsz == 0u || + image.segment_count >= kMaxSegments || + phdr.p_vaddr > UINTPTR_MAX - image.base || + phdr.p_memsz > UINTPTR_MAX - (image.base + phdr.p_vaddr)) { + continue; + } + const uintptr_t begin = image.base + phdr.p_vaddr; + image.segments[image.segment_count++] = { + begin, begin + phdr.p_memsz, phdr.p_flags}; + } + return 0; +} + +bool FindImage(const char* path, uintptr_t base, Image* output) { + if (output == nullptr || (path == nullptr && base == 0u)) return false; + FindImageRequest request{path, base, {}, 0u}; + dl_iterate_phdr(FindImageCallback, &request); + if (request.matches != 1u || request.result.segment_count == 0u) { + return false; + } + *output = request.result; + return true; +} + +// Defined below. The guard precedes every other PLT hook, so it uses the raw +// form that does not re-enter EnsureLsposedMadviseGuard. +int PltHookRaw(void* base, const char* symbol, void* replacement, + void** original); + +/** + * Redirects one image's madvise import, so Xiaomi's MADV_DONTNEED sweeps cannot + * discard a page holding one of our hooks. Patching the image's own GOT needs + * only its base, whether it is a plain .so or one mapped out of an APK. + */ +bool RegisterMadviseGuard(const Image& image) { + const uintptr_t base = image.base; + const char* name = image.path[0] == '\0' ? kHyperRuntimePath : image.path; + if (base == 0u) return false; + + // Claim a slot first, so only one thread patches a given image. + size_t claimed = kMaxMadviseGuards; + for (size_t index = 0u; index < kMaxMadviseGuards; ++index) { + uintptr_t expected = 0u; + if (__atomic_compare_exchange_n(&g_madvise_guarded_bases[index], &expected, + base, false, __ATOMIC_ACQ_REL, + __ATOMIC_ACQUIRE)) { + claimed = index; + break; + } + if (expected == base) return true; + } + if (claimed == kMaxMadviseGuards) { + __android_log_print(ANDROID_LOG_ERROR, kLogTag, + "no madvise guard slot left for %s", name); + return false; + } + + MadviseFn backup = nullptr; + if (PltHookRaw(reinterpret_cast(base), "madvise", + reinterpret_cast(GuardedMadvise), + reinterpret_cast(&backup)) != kHookSuccess || + backup == nullptr) { + __atomic_store_n(&g_madvise_guarded_bases[claimed], uintptr_t{0}, + __ATOMIC_RELEASE); + __android_log_print(ANDROID_LOG_ERROR, kLogTag, + "failed to hook %s madvise", name); + return false; + } + __android_log_print(ANDROID_LOG_INFO, kLogTag, "hooked %s madvise", name); + return true; +} + +bool install_madvise_hook(const char* runtime_name) { + Image image{}; + const char* requested = runtime_name == nullptr + ? kHyperRuntimeName : runtime_name; + if (!FindImage(requested, 0u, &image) && + (runtime_name == nullptr || + !FindImage(kHyperRuntimeName, 0u, &image))) { + __android_log_print(ANDROID_LOG_ERROR, kLogTag, + "cannot resolve loaded %s", requested); + return false; + } + return RegisterMadviseGuard(image); +} + +uintptr_t RuntimeAddress(const Image& image, ElfW(Addr) value) { + if (value == 0u) return 0u; + const uintptr_t address = static_cast(value); + if (RangeInImage(image, address, 1u)) return address; + if (address > UINTPTR_MAX - image.base) return 0u; + return image.base + address; +} + +bool ReadGnuSymbolCount(const Image& image, uintptr_t address, size_t* output) { + if (output == nullptr || !RangeInImage(image, address, 16u, PF_R)) { + return false; + } + const auto* header = reinterpret_cast(address); + const uint32_t bucket_count = header[0]; + const uint32_t symbol_offset = header[1]; + const uint32_t bloom_count = header[2]; + if (bucket_count == 0u || bloom_count == 0u || + bucket_count > kMaxSymbols || bloom_count > kMaxSymbols) { + return false; + } + uintptr_t buckets_address = address + 16u; + const size_t bloom_bytes = static_cast(bloom_count) * sizeof(ElfW(Addr)); + if (bloom_bytes > UINTPTR_MAX - buckets_address) return false; + buckets_address += bloom_bytes; + const size_t bucket_bytes = static_cast(bucket_count) * sizeof(uint32_t); + if (!RangeInImage(image, buckets_address, bucket_bytes, PF_R) || + bucket_bytes > UINTPTR_MAX - buckets_address) { + return false; + } + const auto* buckets = reinterpret_cast(buckets_address); + const uintptr_t chains_address = buckets_address + bucket_bytes; + uint32_t maximum = symbol_offset; + for (uint32_t index = 0u; index < bucket_count; ++index) { + uint32_t symbol = buckets[index]; + if (symbol < symbol_offset) continue; + if (symbol >= kMaxSymbols) return false; + while (true) { + const size_t chain_index = static_cast(symbol - symbol_offset); + const uintptr_t chain_address = chains_address + + chain_index * sizeof(uint32_t); + if (!RangeInImage(image, chain_address, sizeof(uint32_t), PF_R)) { + return false; + } + const uint32_t chain = + *reinterpret_cast(chain_address); + if (symbol >= maximum) maximum = symbol + 1u; + if ((chain & 1u) != 0u) break; + if (++symbol >= kMaxSymbols) return false; + } + } + *output = maximum; + return maximum != 0u; +} + +bool BuildDynamicView(const Image& image, DynamicView* output) { + if (output == nullptr) return false; + DynamicView view{}; + uintptr_t dynamic_address = 0u; + size_t dynamic_count = 0u; + for (size_t index = 0u; index < image.phnum; ++index) { + const ElfW(Phdr)& phdr = image.phdr[index]; + if (phdr.p_type == PT_DYNAMIC && phdr.p_memsz >= sizeof(ElfW(Dyn)) && + phdr.p_vaddr <= UINTPTR_MAX - image.base) { + dynamic_address = image.base + phdr.p_vaddr; + dynamic_count = phdr.p_memsz / sizeof(ElfW(Dyn)); + break; + } + } + if (dynamic_address == 0u || dynamic_count == 0u || + !RangeInImage(image, dynamic_address, + dynamic_count * sizeof(ElfW(Dyn)), PF_R)) { + return false; + } + uintptr_t symbols = 0u; + uintptr_t strings = 0u; + uintptr_t sysv_hash = 0u; + uintptr_t gnu_hash = 0u; + uintptr_t plt_rela = 0u; + size_t plt_size = 0u; + uintptr_t dyn_rela = 0u; + size_t dyn_rela_size = 0u; + size_t string_size = 0u; + bool plt_is_rela = false; + const auto* dynamic = reinterpret_cast(dynamic_address); + for (size_t index = 0u; index < dynamic_count; ++index) { + const ElfW(Dyn)& entry = dynamic[index]; + if (entry.d_tag == DT_NULL) break; + switch (entry.d_tag) { + case DT_SYMTAB: symbols = RuntimeAddress(image, entry.d_un.d_ptr); break; + case DT_STRTAB: strings = RuntimeAddress(image, entry.d_un.d_ptr); break; + case DT_STRSZ: string_size = entry.d_un.d_val; break; + case DT_HASH: sysv_hash = RuntimeAddress(image, entry.d_un.d_ptr); break; + case DT_GNU_HASH: gnu_hash = RuntimeAddress(image, entry.d_un.d_ptr); break; + case DT_JMPREL: plt_rela = RuntimeAddress(image, entry.d_un.d_ptr); break; + case DT_PLTRELSZ: plt_size = entry.d_un.d_val; break; + case DT_PLTREL: plt_is_rela = entry.d_un.d_val == DT_RELA; break; + case DT_RELA: dyn_rela = RuntimeAddress(image, entry.d_un.d_ptr); break; + case DT_RELASZ: dyn_rela_size = entry.d_un.d_val; break; + default: break; + } + } + size_t symbol_count = 0u; + if (sysv_hash != 0u && RangeInImage(image, sysv_hash, 8u, PF_R)) { + symbol_count = reinterpret_cast(sysv_hash)[1]; + } else if (gnu_hash != 0u && !ReadGnuSymbolCount( + image, gnu_hash, &symbol_count)) { + return false; + } + if (symbols == 0u || strings == 0u || string_size == 0u || + symbol_count == 0u || symbol_count > kMaxSymbols || + !RangeInImage(image, symbols, + symbol_count * sizeof(ElfW(Sym)), PF_R) || + !RangeInImage(image, strings, string_size, PF_R)) { + return false; + } + view.symbols = reinterpret_cast(symbols); + view.strings = reinterpret_cast(strings); + view.string_size = string_size; + view.symbol_count = symbol_count; + if (plt_rela != 0u && plt_size != 0u && plt_is_rela && + plt_size % sizeof(ElfW(Rela)) == 0u && + RangeInImage(image, plt_rela, plt_size, PF_R)) { + view.plt_rela = reinterpret_cast(plt_rela); + view.plt_rela_count = plt_size / sizeof(ElfW(Rela)); + } + if (dyn_rela != 0u && dyn_rela_size != 0u && + dyn_rela_size % sizeof(ElfW(Rela)) == 0u && + RangeInImage(image, dyn_rela, dyn_rela_size, PF_R)) { + view.dyn_rela = reinterpret_cast(dyn_rela); + view.dyn_rela_count = dyn_rela_size / sizeof(ElfW(Rela)); + } + *output = view; + return true; +} + +bool SymbolNameEquals(const DynamicView& view, uint32_t symbol_index, + const char* expected) { + if (expected == nullptr || symbol_index >= view.symbol_count) return false; + const uint32_t offset = view.symbols[symbol_index].st_name; + if (offset >= view.string_size) return false; + const char* name = view.strings + offset; + const size_t remaining = view.string_size - offset; + const size_t length = strnlen(name, remaining); + return length < remaining && strlen(expected) == length && + memcmp(name, expected, length) == 0; +} + +int ReadProtection(uintptr_t address) { + FILE* maps = fopen("/proc/self/maps", "re"); + if (maps == nullptr) return -1; + char line[768]{}; + int result = -1; + while (fgets(line, sizeof(line), maps) != nullptr) { + unsigned long long begin = 0u; + unsigned long long end = 0u; + char permissions[5]{}; + if (sscanf(line, "%llx-%llx %4s", &begin, &end, permissions) != 3) { + continue; + } + if (address < begin || address >= end) continue; + result = (permissions[0] == 'r' ? PROT_READ : 0) | + (permissions[1] == 'w' ? PROT_WRITE : 0) | + (permissions[2] == 'x' ? PROT_EXEC : 0); + break; + } + fclose(maps); + return result; +} + +bool WritePointer(void** slot, void* value) { + const uintptr_t page = PageStart(reinterpret_cast(slot)); + const int protection = ReadProtection(reinterpret_cast(slot)); + if (protection < 0 || mprotect(reinterpret_cast(page), PageSize(), + protection | PROT_WRITE) != 0) { + return false; + } + __atomic_store_n(slot, value, __ATOMIC_RELEASE); + return mprotect(reinterpret_cast(page), PageSize(), protection) == 0; +} + +bool CollectRelocationSlots(const Image& image, const DynamicView& view, + const ElfW(Rela)* relocations, size_t count, + const char* symbol, void*** slots, + size_t* slot_count) { + for (size_t index = 0u; index < count; ++index) { + const ElfW(Rela)& relocation = relocations[index]; + const uint32_t type = ELF64_R_TYPE(relocation.r_info); + if (type != R_AARCH64_JUMP_SLOT && type != R_AARCH64_GLOB_DAT) continue; + const uint32_t symbol_index = ELF64_R_SYM(relocation.r_info); + if (!SymbolNameEquals(view, symbol_index, symbol)) continue; + const uintptr_t address = RuntimeAddress(image, relocation.r_offset); + if (address == 0u || !RangeInImage(image, address, sizeof(void*))) { + return false; + } + if (*slot_count >= kMaxPltSlots) return false; + slots[(*slot_count)++] = reinterpret_cast(address); + } + return true; +} + +int PltHookRaw(void* base, const char* symbol, void* replacement, + void** original) { + if (base == nullptr || symbol == nullptr || replacement == nullptr || + original == nullptr) { + return kHookFailed; + } + Image image{}; + DynamicView view{}; + if (!FindImage(nullptr, reinterpret_cast(base), &image) || + !BuildDynamicView(image, &view)) { + return kHookFailed; + } + void** slots[kMaxPltSlots]{}; + size_t slot_count = 0u; + if (!CollectRelocationSlots(image, view, view.plt_rela, + view.plt_rela_count, symbol, slots, + &slot_count) || + !CollectRelocationSlots(image, view, view.dyn_rela, + view.dyn_rela_count, symbol, slots, + &slot_count) || + slot_count == 0u) { + return kHookFailed; + } + void* expected = __atomic_load_n(slots[0], __ATOMIC_ACQUIRE); + if (expected == nullptr || expected == replacement) return kHookFailed; + for (size_t index = 1u; index < slot_count; ++index) { + if (__atomic_load_n(slots[index], __ATOMIC_ACQUIRE) != expected) { + return kHookFailed; + } + } + for (size_t index = 0u; index < slot_count; ++index) { + if (!AddProtectedPage(reinterpret_cast(slots[index]))) { + return kHookFailed; + } + } + size_t written = 0u; + for (; written < slot_count; ++written) { + if (!WritePointer(slots[written], replacement)) break; + } + if (written != slot_count) { + while (written > 0u) { + --written; + WritePointer(slots[written], expected); + } + return kHookFailed; + } + *original = expected; + return kHookSuccess; +} + +} // namespace + +int InstallPltHook(void* base, const char* symbol, void* replacement, + void** original) { + if (!EnsureLsposedMadviseGuard()) return kHookFailed; + return PltHookRaw(base, symbol, replacement, original); +} + +int InstallInlineHook(void* target, void* replacement, void** original) { + if (g_lsposed_api == nullptr || g_lsposed_api->hookFunc == nullptr || + !EnsureLsposedMadviseGuard() || target == nullptr || + replacement == nullptr || original == nullptr) { + return kHookFailed; + } + const uintptr_t begin = reinterpret_cast(target); + const uintptr_t end = begin <= UINTPTR_MAX - (kInlinePatchGuardSpan - 1u) + ? begin + kInlinePatchGuardSpan - 1u : begin; + if (!AddProtectedPage(begin) || !AddProtectedPage(end)) { + return kHookFailed; + } + // Publish both possible patch pages before LSPosed writes the trampoline. + // Xiaomi may issue MADV_DONTNEED concurrently with hook installation, so + // registering only after hookFunc returns leaves a small destructive race. + *original = nullptr; + const int result = g_lsposed_api->hookFunc(target, replacement, original); + if (result != 0) return kHookFailed; + if (*original == nullptr) { + g_lsposed_api->unhookFunc(target); + return kHookFailed; + } + return kHookSuccess; +} + +bool EnsureLsposedMadviseGuard(const char* runtime_name) { + if (!install_madvise_hook(runtime_name)) { + __atomic_store_n(&g_madvise_guard_state, uint32_t{3}, __ATOMIC_RELEASE); + return false; + } + __atomic_store_n(&g_madvise_guard_state, uint32_t{2}, __ATOMIC_RELEASE); + return true; +} + +bool InitializeLsposedHookBackend(const NativeAPIEntries* entries) { + if (entries == nullptr || entries->hookFunc == nullptr || + entries->unhookFunc == nullptr) { + return false; + } + g_lsposed_api = entries; + return true; +} diff --git a/native/lsposed_hook_backend.h b/native/lsposed_hook_backend.h new file mode 100644 index 0000000..aba7b9e --- /dev/null +++ b/native/lsposed_hook_backend.h @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 +// Derived from MiuiBackGestureHook; see native/README.md for attribution. + +#pragma once + +#include + +#include "native_api.h" + +constexpr int kHookSuccess = 0; +constexpr int kHookFailed = 1; + +bool InitializeLsposedHookBackend(const NativeAPIEntries* entries); +bool EnsureLsposedMadviseGuard(const char* runtime_name = nullptr); +int InstallPltHook(void* base_addr, const char* symbol, void* hook_handler, + void** original); +int InstallInlineHook(void* target, void* replacement, void** original); diff --git a/native/micts_native_hook.cpp b/native/micts_native_hook.cpp new file mode 100644 index 0000000..8088725 --- /dev/null +++ b/native/micts_native_hook.cpp @@ -0,0 +1,572 @@ +// SPDX-License-Identifier: Apache-2.0 +// Derived from MiuiBackGestureHook; see native/README.md for attribution. +// +// Restores the gesture-handle long press on HyperOS 4, where the launcher runs +// natively and no Java hook can reach it. Resolves the contextual-search graph +// out of the mapped image, hooks the long-press handler, and routes it into the +// launcher's own invoke helper — the China build's stock routing rejects it. +// +// Fails closed: anything unresolved leaves the launcher stock. See README.md. + +#include +#include +#include +#include +#include +#include +#include + +#include "launcher_cs_resolver.h" +#include "lsposed_hook_backend.h" +#include "native_api.h" + +namespace { + +constexpr char kLogTag[] = "MiCTSNative"; +constexpr char kLauncherProcessName[] = "com.miui.home"; +constexpr char kSpawnerPath[] = "/system_ext/bin/hyos_spawner"; +constexpr char kHyperRuntimeName[] = "libhyper_os_flutter.so"; +constexpr char kDataLauncherLibraryTail[] = + "/base.apk!/lib/arm64-v8a/libapp_launcher.so"; +constexpr char kSystemLauncherLibraryTail[] = + "/MiuiHome.apk!/lib/arm64-v8a/libapp_launcher.so"; +constexpr char kLauncherEntrySymbol[] = "app_entry_point"; + +// The launcher gates Circle to Search on these; China builds declare neither. +constexpr char kPlatformContextualSearchFeature[] = + "android.software.contextualsearch"; +constexpr char kGoogleContextualSearchFeature[] = + "com.google.android.feature.CONTEXTUAL_SEARCH"; + +struct NativeResult { + // The public HyperOS PackageManager wrapper returns a 48-byte RResult + // through x8. + uint8_t bytes[48]; +}; + +// An upward swipe past this many pixels is treated as a Home/Recents gesture +// rather than a long press, matching the launcher's own bias. +constexpr float kUpwardIntentPixels = 8.0f; + +using ContextualLongPressHandlerFn = void (*)(void*, uint32_t); +using ContextualClosureCleanupFn = void (*)(void*); +using ContextualSearchInvokeFn = uint8_t (*)(uint32_t); +using PackageManagerHasSystemFeatureFn = + NativeResult (*)(void*, const char*, size_t, uint32_t); +using MotionEventActionFn = int32_t (*)(void*); +using MotionEventLongFn = int64_t (*)(void*); +using MotionEventIntFn = int32_t (*)(void*); +using MotionEventFloatFn = float (*)(void*); + +void* g_original_long_press_handler = nullptr; +void* g_contextual_search_invoke = nullptr; +void* g_original_has_system_feature = nullptr; +void* g_original_motion_get_action_masked = nullptr; +void* g_motion_get_down_time = nullptr; +void* g_motion_get_device_id = nullptr; +void* g_motion_get_source = nullptr; +void* g_motion_get_raw_y = nullptr; +void* g_motion_get_y = nullptr; +void* g_launcher_handle = nullptr; + +// Snapshot of the gesture stream currently being tracked, keyed by the stream +// identity so a stale pointer can never be mistaken for the live gesture. +uint32_t g_motion_snapshot_valid = 0u; +int64_t g_motion_snapshot_down_time = 0; +int32_t g_motion_snapshot_device_id = 0; +int32_t g_motion_snapshot_source = 0; +uint32_t g_motion_snapshot_down_y_bits = 0u; +uint32_t g_motion_snapshot_current_y_bits = 0u; + +void Log(int priority, const char* message) { + __android_log_write(priority, kLogTag, message); +} + +template +T AtomicLoad(T* target) { + return __atomic_load_n(target, __ATOMIC_ACQUIRE); +} + +template +void AtomicStore(T* target, T value) { + __atomic_store_n(target, value, __ATOMIC_RELEASE); +} + +// The diagnostic counters below stay `volatile` so a debugger or a tombstone +// reads the committed value; give the helpers a matching overload. +template +void AtomicStore(volatile T* target, T value) { + __atomic_store_n(target, value, __ATOMIC_RELEASE); +} + +constexpr size_t ConstStringLength(const char* value) { + size_t length = 0u; + if (value == nullptr) return length; + while (value[length] != '\0') ++length; + return length; +} + +constexpr bool StringsEqual(const char* left, const char* right) { + if (left == nullptr || right == nullptr) return false; + size_t index = 0u; + while (left[index] != '\0' && right[index] != '\0') { + if (left[index] != right[index]) return false; + ++index; + } + return left[index] == right[index]; +} + +constexpr bool StartsWith(const char* value, const char* prefix) { + if (value == nullptr || prefix == nullptr) return false; + for (size_t index = 0u; prefix[index] != '\0'; ++index) { + if (value[index] != prefix[index]) return false; + } + return true; +} + +constexpr bool EndsWith(const char* value, const char* suffix) { + if (value == nullptr || suffix == nullptr) return false; + const size_t value_length = ConstStringLength(value); + const size_t suffix_length = ConstStringLength(suffix); + if (suffix_length > value_length) return false; + const size_t start = value_length - suffix_length; + for (size_t index = 0u; index < suffix_length; ++index) { + if (value[start + index] != suffix[index]) return false; + } + return true; +} + +constexpr bool IsLauncherLibraryPath(const char* path) { + if (path == nullptr) return false; + if (StringsEqual(path, "libapp_launcher.so")) return true; + if (StartsWith(path, "/data/app/") && + EndsWith(path, kDataLauncherLibraryTail)) { + return true; + } + return StartsWith(path, "/product/priv-app/MiuiHome/") && + EndsWith(path, kSystemLauncherLibraryTail); +} + +static_assert(IsLauncherLibraryPath( + "/product/priv-app/MiuiHome/MiuiHome.apk!/lib/arm64-v8a/" + "libapp_launcher.so")); +static_assert(IsLauncherLibraryPath( + "/data/app/~~token/com.miui.home-token/base.apk!/lib/arm64-v8a/" + "libapp_launcher.so")); +static_assert(!IsLauncherLibraryPath( + "/data/local/tmp/base.apk!/lib/arm64-v8a/libapp_launcher.so")); +static_assert(!IsLauncherLibraryPath( + "/data/app/token/base.apk!/lib/armeabi-v7a/libapp_launcher.so")); + +bool IsLauncherProcess() { + const int fd = open("/proc/self/cmdline", O_RDONLY | O_CLOEXEC); + if (fd < 0) return false; + char command_line[64]{}; + ssize_t result; + do { + result = read(fd, command_line, sizeof(command_line)); + } while (result < 0 && errno == EINTR); + close(fd); + if (result <= 0) return false; + constexpr size_t expected_length = ConstStringLength(kLauncherProcessName); + return static_cast(result) > expected_length && + command_line[expected_length] == '\0' && + StringsEqual(command_line, kLauncherProcessName); +} + +bool IsHyosSpawnerProcessFamily() { + char executable[128]{}; + const ssize_t length = + readlink("/proc/self/exe", executable, sizeof(executable) - 1u); + if (length <= 0 || static_cast(length) >= sizeof(executable)) { + return false; + } + executable[length] = '\0'; + return StringsEqual(executable, kSpawnerPath); +} + +// LSPosed initializes in the root spawner (cmdline `usap64`) before it forks +// the package process, so accept the spawner and the launcher child alike. +bool IsLauncherHookProcess() { + return IsLauncherProcess() || IsHyosSpawnerProcessFamily(); +} + +/** + * Opens Circle to Search through the launcher's own entry point — the one path + * already wired to the platform service. MiCTS's system server hooks still see + * the resulting call and apply the user's settings to it. + */ +uint32_t FloatBits(float value) { + uint32_t bits = 0u; + memcpy(&bits, &value, sizeof(bits)); + return bits; +} + +float BitsFloat(uint32_t bits) { + float value = 0.0f; + memcpy(&value, &bits, sizeof(value)); + return value; +} + +float ReadMotionY(void* event) { + auto get_raw_y = + reinterpret_cast(AtomicLoad(&g_motion_get_raw_y)); + if (get_raw_y != nullptr) return get_raw_y(event); + auto get_y = reinterpret_cast(AtomicLoad(&g_motion_get_y)); + return get_y == nullptr ? 0.0f : get_y(event); +} + +/** Tracks vertical travel, so a dwelling swipe is not taken for a long press. */ +void PublishMotionSnapshot(void* event, int32_t action) { + // This runs for every motion event the launcher dispatches, so do nothing + // until a DOWN has started a snapshot worth updating. + const bool is_down = static_cast(action & 0xff) == 0u; + if (!is_down && + __atomic_load_n(&g_motion_snapshot_valid, __ATOMIC_ACQUIRE) == 0u) { + return; + } + auto get_down_time = + reinterpret_cast(AtomicLoad(&g_motion_get_down_time)); + auto get_device_id = + reinterpret_cast(AtomicLoad(&g_motion_get_device_id)); + auto get_source = + reinterpret_cast(AtomicLoad(&g_motion_get_source)); + if (event == nullptr || get_down_time == nullptr || + get_device_id == nullptr || get_source == nullptr || + (AtomicLoad(&g_motion_get_raw_y) == nullptr && + AtomicLoad(&g_motion_get_y) == nullptr)) { + return; + } + const int64_t down_time = get_down_time(event); + const int32_t device_id = get_device_id(event); + const int32_t source = get_source(event); + const float y = ReadMotionY(event); + + if (is_down) { + __atomic_store_n(&g_motion_snapshot_valid, uint32_t{0}, __ATOMIC_RELEASE); + __atomic_store_n(&g_motion_snapshot_down_time, down_time, __ATOMIC_RELAXED); + __atomic_store_n(&g_motion_snapshot_device_id, device_id, __ATOMIC_RELAXED); + __atomic_store_n(&g_motion_snapshot_source, source, __ATOMIC_RELAXED); + __atomic_store_n(&g_motion_snapshot_down_y_bits, FloatBits(y), + __ATOMIC_RELAXED); + __atomic_store_n(&g_motion_snapshot_current_y_bits, FloatBits(y), + __ATOMIC_RELAXED); + __atomic_store_n(&g_motion_snapshot_valid, uint32_t{1}, __ATOMIC_RELEASE); + return; + } + if (__atomic_load_n(&g_motion_snapshot_valid, __ATOMIC_ACQUIRE) == 0u || + __atomic_load_n(&g_motion_snapshot_down_time, __ATOMIC_RELAXED) != + down_time || + __atomic_load_n(&g_motion_snapshot_device_id, __ATOMIC_RELAXED) != + device_id || + __atomic_load_n(&g_motion_snapshot_source, __ATOMIC_RELAXED) != source) { + return; + } + __atomic_store_n(&g_motion_snapshot_current_y_bits, FloatBits(y), + __ATOMIC_RELAXED); +} + +int32_t HookMotionEventGetActionMasked(void* event) { + auto original = reinterpret_cast( + AtomicLoad(&g_original_motion_get_action_masked)); + if (original == nullptr) return 0; + const int32_t action = original(event); + PublishMotionSnapshot(event, action); + return action; +} + +/** Whether the tracked stream has already travelled upward far enough. */ +bool HasUpwardGestureIntent() { + if (__atomic_load_n(&g_motion_snapshot_valid, __ATOMIC_ACQUIRE) == 0u) { + return false; + } + const float down_y = BitsFloat( + __atomic_load_n(&g_motion_snapshot_down_y_bits, __ATOMIC_ACQUIRE)); + const float current_y = BitsFloat( + __atomic_load_n(&g_motion_snapshot_current_y_bits, __ATOMIC_ACQUIRE)); + return (down_y - current_y) >= kUpwardIntentPixels; +} + +bool IsExactName(const char* value, size_t length, const char* expected) { + if (value == nullptr || expected == nullptr) return false; + const size_t expected_length = ConstStringLength(expected); + return length == expected_length && + memcmp(value, expected, expected_length) == 0; +} + +bool IsNativeSuccess(const NativeResult& result) { + return (result.bytes[0] & uint8_t{1}) == 0u; +} + +/** Reports the two contextual-search features as present; passes the rest. */ +NativeResult HookPackageManagerHasSystemFeature(void* package_manager, + const char* name, + size_t name_length, + uint32_t flags) { + auto original = reinterpret_cast( + AtomicLoad(&g_original_has_system_feature)); + if (original == nullptr) return NativeResult{}; + NativeResult result = original(package_manager, name, name_length, flags); + const bool contextual_feature = + IsExactName(name, name_length, kPlatformContextualSearchFeature) || + IsExactName(name, name_length, kGoogleContextualSearchFeature); + if (!contextual_feature) return result; + if (IsNativeSuccess(result) && result.bytes[1] == uint8_t{0}) { + // Result in a 48-byte wrapper: byte 0 is the tag, byte 1 the + // value. Leave errors and already-true results alone. + result.bytes[1] = uint8_t{1}; + } + return result; +} + +bool InvokeContextualSearch(uint32_t entry_point) { + auto invoke = reinterpret_cast( + AtomicLoad(&g_contextual_search_invoke)); + if (invoke == nullptr) { + Log(ANDROID_LOG_WARN, "contextual-search invoke is unavailable"); + return false; + } + const uint8_t result = invoke(entry_point); + if (result == 0u) { + Log(ANDROID_LOG_WARN, "launcher rejected the contextual-search request"); + return false; + } + __android_log_print(ANDROID_LOG_INFO, kLogTag, + "requested contextual search entryPoint=%u result=%u", + static_cast(entry_point), + static_cast(result)); + return true; +} + +bool CleanupLongPressClosure(void* closure) { + if (closure == nullptr) return false; + void* storage = *reinterpret_cast( + reinterpret_cast(closure) + 0x8u); + if (storage == nullptr) return true; + void* owner = *reinterpret_cast( + reinterpret_cast(closure) + 0x10u); + if (owner == nullptr) return false; + const uintptr_t object_size = *reinterpret_cast( + reinterpret_cast(owner) + 0x10u); + ContextualClosureCleanupFn cleanup = + *reinterpret_cast( + reinterpret_cast(owner) + 0x28u); + if (object_size == 0u || cleanup == nullptr) return false; + const uintptr_t aligned_offset = + (object_size - 1u) & ~static_cast(0xfu); + cleanup(reinterpret_cast(storage) + aligned_offset + 0x10u); + return true; +} + +/** + * Replaces DefaultLongPressHandler's terminal Fn: keeps its completion marker, + * swaps only the routing China builds reject. Falls back to the original on any + * layout surprise. + */ +void HookLongPressHandler(void* closure, uint32_t trigger_mode) { + ContextualLongPressHandlerFn original = + reinterpret_cast( + AtomicLoad(&g_original_long_press_handler)); + + void* completion_state = + closure == nullptr + ? nullptr + : AtomicLoad(reinterpret_cast(closure)); + if (completion_state == nullptr) { + if (original != nullptr) original(closure, trigger_mode); + return; + } + + // Check the closure tail the captured function itself relies on; on a + // layout mismatch stay stock rather than risk an invalid release. + void* closure_storage = *reinterpret_cast( + reinterpret_cast(closure) + 0x8u); + void* closure_owner = *reinterpret_cast( + reinterpret_cast(closure) + 0x10u); + if (closure_storage != nullptr && + (closure_owner == nullptr || + *reinterpret_cast( + reinterpret_cast(closure_owner) + 0x10u) == 0u || + *reinterpret_cast( + reinterpret_cast(closure_owner) + 0x28u) == + nullptr)) { + if (original != nullptr) original(closure, trigger_mode); + return; + } + + // A swipe toward Home or Recents can dwell long enough to arm the same + // detector. Finish it the way the original does, but without routing, so + // the swipe still completes. + if (HasUpwardGestureIntent()) { + __atomic_store_n(static_cast(completion_state) + 0x10u, + uint8_t{1}, __ATOMIC_RELEASE); + if (!CleanupLongPressClosure(closure)) { + Log(ANDROID_LOG_WARN, + "upward long-press cancellation cleanup unavailable"); + } + return; + } + + // Mark the captured detector state complete before routing, exactly as the + // stock closure does, so the launcher's own detector is never left stale. + __atomic_store_n(static_cast(completion_state) + 0x10u, + uint8_t{1}, __ATOMIC_RELEASE); + InvokeContextualSearch(1u); + if (!CleanupLongPressClosure(closure)) { + // Unreachable after the validation above. Keep the native callback as + // the safety net if the object changed concurrently. + Log(ANDROID_LOG_WARN, "long-press closure cleanup unavailable"); + if (original != nullptr) original(closure, trigger_mode); + } +} + +void InstallLauncherHook(void* app_entry_point) { + if (AtomicLoad(&g_original_long_press_handler) != nullptr) return; + + Dl_info info{}; + if (dladdr(app_entry_point, &info) == 0 || info.dli_fbase == nullptr) { + Log(ANDROID_LOG_ERROR, "could not locate the launcher image base"); + return; + } + auto* base = static_cast(info.dli_fbase); + + micts_launcher::ContextualSearchProfile profile{}; + micts_launcher::ResolveDiagnostics diagnostics{}; + if (!micts_launcher::ResolveContextualSearchProfile(base, &profile, + &diagnostics)) { + __android_log_print( + ANDROID_LOG_ERROR, kLogTag, + "launcher contextual-search graph rejected: stage=%u " + "support=%u invoke=%u longPress=%u span=0x%lx", + static_cast(diagnostics.stage), + diagnostics.support_candidate_count, + diagnostics.invoke_candidate_count, + diagnostics.long_press_candidate_count, + static_cast(diagnostics.image_span)); + return; + } + + // The long-press hook consumes the gesture rather than forwarding it, so + // both prerequisites must be in place first: without motion tracking a + // swipe gets swallowed as a long press, and without the feature probe the + // launcher refuses the request anyway. + void* handle = AtomicLoad(&g_launcher_handle); + if (handle != nullptr) { + AtomicStore(&g_motion_get_down_time, + dlsym(handle, "input_MotionEvent_getDownTime")); + AtomicStore(&g_motion_get_device_id, + dlsym(handle, "input_MotionEvent_getDeviceId")); + AtomicStore(&g_motion_get_source, + dlsym(handle, "input_MotionEvent_getSource")); + AtomicStore(&g_motion_get_raw_y, + dlsym(handle, "input_MotionEvent_getRawY")); + AtomicStore(&g_motion_get_y, dlsym(handle, "input_MotionEvent_getY")); + } + const bool motion_accessors_ready = + AtomicLoad(&g_motion_get_down_time) != nullptr && + AtomicLoad(&g_motion_get_device_id) != nullptr && + AtomicLoad(&g_motion_get_source) != nullptr && + (AtomicLoad(&g_motion_get_raw_y) != nullptr || + AtomicLoad(&g_motion_get_y) != nullptr); + if (!motion_accessors_ready || + InstallPltHook(base, "input_MotionEvent_getActionMasked", + reinterpret_cast(HookMotionEventGetActionMasked), + &g_original_motion_get_action_masked) != kHookSuccess || + AtomicLoad(&g_original_motion_get_action_masked) == nullptr) { + Log(ANDROID_LOG_ERROR, + "motion tracking unavailable; leaving the launcher stock rather " + "than swallowing swipes as long presses"); + return; + } + + if (InstallPltHook(base, "PackageManager_has_system_feature", + reinterpret_cast( + HookPackageManagerHasSystemFeature), + &g_original_has_system_feature) != kHookSuccess || + AtomicLoad(&g_original_has_system_feature) == nullptr) { + Log(ANDROID_LOG_ERROR, + "contextual-search feature probe hook unavailable; leaving the " + "launcher stock rather than swallowing an unusable long press"); + return; + } + + // Publish the invoke helper before the hook goes live, so the very first + // long press already has a route. + AtomicStore(&g_contextual_search_invoke, + static_cast(base + profile.invoke_offset)); + + if (InstallInlineHook(base + profile.long_press_handler_offset, + reinterpret_cast(HookLongPressHandler), + &g_original_long_press_handler) != kHookSuccess || + AtomicLoad(&g_original_long_press_handler) == nullptr) { + AtomicStore(&g_original_long_press_handler, static_cast(nullptr)); + AtomicStore(&g_contextual_search_invoke, static_cast(nullptr)); + Log(ANDROID_LOG_ERROR, "launcher long-press inline hook failed"); + return; + } + + __android_log_print( + ANDROID_LOG_INFO, kLogTag, + "installed launcher long-press hook: longPress=0x%lx invoke=0x%lx " + "support=0x%lx base=%p", + static_cast(profile.long_press_handler_offset), + static_cast(profile.invoke_offset), + static_cast(profile.support_offset), base); +} + +void OnLibraryLoaded(const char* name, void* handle) { + if (name == nullptr || handle == nullptr) return; + + if (EndsWith(name, kHyperRuntimeName)) { + EnsureLsposedMadviseGuard(name); + } + if (!IsLauncherLibraryPath(name) || !IsLauncherHookProcess()) return; + + // Xiaomi issues MADV_DONTNEED over launcher code pages; without the guard + // an installed trampoline can be silently discarded. + if (!EnsureLsposedMadviseGuard()) { + Log(ANDROID_LOG_ERROR, "launcher hook rejected without madvise guard"); + return; + } + void* app_entry_point = dlsym(handle, kLauncherEntrySymbol); + if (app_entry_point == nullptr) return; + AtomicStore(&g_launcher_handle, handle); + InstallLauncherHook(app_entry_point); +} + +// The launcher can preload its images before native_init runs, and the +// callback only covers later loads. RTLD_NOLOAD: never pull in a new library. +void BackfillLoadedLibrary(const char* name) { + if (name == nullptr) return; + void* handle = dlopen(name, RTLD_NOW | RTLD_NOLOAD); + if (handle == nullptr) return; + __android_log_print(ANDROID_LOG_INFO, kLogTag, + "backfilling already-loaded native image: %s", name); + OnLibraryLoaded(name, handle); + dlclose(handle); +} + +} // namespace + +extern "C" __attribute__((visibility("default"), unused)) +NativeOnModuleLoaded native_init(const NativeAPIEntries* entries) { + const bool backend_ready = InitializeLsposedHookBackend(entries); + const bool hyos_process = IsHyosSpawnerProcessFamily(); + const bool launcher_process = IsLauncherProcess(); + __android_log_print( + ANDROID_LOG_INFO, kLogTag, + "native_init: backend=%u hyos_exe=%u launcher_cmdline=%u", + backend_ready ? 1u : 0u, hyos_process ? 1u : 0u, + launcher_process ? 1u : 0u); + if (!backend_ready) return nullptr; + if (!hyos_process) { + Log(ANDROID_LOG_WARN, "native entry rejected a non-HYOS process"); + return nullptr; + } + Log(ANDROID_LOG_INFO, "MiCTS native launcher entry initialized"); + if (IsLauncherHookProcess()) { + BackfillLoadedLibrary("libapp_launcher.so"); + BackfillLoadedLibrary(kHyperRuntimeName); + } + return OnLibraryLoaded; +} diff --git a/native/native_api.h b/native/native_api.h new file mode 100644 index 0000000..c24dca6 --- /dev/null +++ b/native/native_api.h @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +// Derived from MiuiBackGestureHook; see native/README.md for attribution. + +#pragma once + +#include + +struct NativeAPIEntries { + uint32_t version; + int (*hookFunc)(void* target, void* replacement, void** backup); + int (*unhookFunc)(void* target); +}; + +using NativeOnModuleLoaded = void (*)(const char* name, void* handle); From fc9a9b169abee3f351b17e9a18d99f8f2aa2caf8 Mon Sep 17 00:00:00 2001 From: liuran001 Date: Sun, 30 Aug 2026 09:27:16 +0800 Subject: [PATCH 2/6] fix: address review feedback - Bundle the Apache-2.0 licence text that native/README.md links to - Guard every image matching the runtime name instead of requiring a unique match, so an ambiguous basename no longer blocks hook installation - Fall back to the stock handler when the contextual-search invoke helper is unavailable, rather than consuming the gesture for nothing - Use a released NDK and CMake so CI can resolve them --- app/build.gradle.kts | 4 +- native/LICENSE-Apache-2.0 | 161 ++++++++++++++++++++++++++++++++ native/lsposed_hook_backend.cpp | 55 +++++++++-- native/micts_native_hook.cpp | 9 ++ 4 files changed, 219 insertions(+), 10 deletions(-) create mode 100644 native/LICENSE-Apache-2.0 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2473021..8209f1b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -21,7 +21,7 @@ val latestTag = repo?.latestTag?.removePrefix("v") ?: "1.0" android { namespace = "com.parallelc.micts" compileSdk = 36 - ndkVersion = "30.0.14904198" + ndkVersion = "26.2.11394342" defaultConfig { minSdk = 28 @@ -84,7 +84,7 @@ android { externalNativeBuild { cmake { path = rootProject.file("native/CMakeLists.txt") - version = "4.1.2" + version = "3.22.1" } } diff --git a/native/LICENSE-Apache-2.0 b/native/LICENSE-Apache-2.0 new file mode 100644 index 0000000..550ddf8 --- /dev/null +++ b/native/LICENSE-Apache-2.0 @@ -0,0 +1,161 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the +copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other +entities that control, are controlled by, or are under common control with +that entity. For the purposes of this definition, "control" means (i) the +power, direct or indirect, to cause the direction or management of such +entity, whether by contract or otherwise, or (ii) ownership of fifty percent +(50%) or more of the outstanding shares, or (iii) beneficial ownership of +such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising +permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, +including but not limited to software source code, documentation source, and +configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object +code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, +made available under the License, as indicated by a copyright notice that is +included in or attached to the work. + +"Derivative Works" shall mean any work, whether in Source or Object form, +that is based on (or derived from) the Work and for which the editorial +revisions, annotations, elaborations, or other modifications represent, as a +whole, an original work of authorship. For the purposes of this License, +Derivative Works shall not include works that remain separable from, or +merely link (or bind by name) to the interfaces of, the Work and Derivative +Works thereof. + +"Contribution" shall mean any work of authorship, including the original +version of the Work and any modifications or additions to that Work or +Derivative Works thereof, that is intentionally submitted to Licensor for +inclusion in the Work by the copyright owner or by an individual or Legal +Entity authorized to submit on behalf of the copyright owner. For the purposes +of this definition, "submitted" means any form of electronic, verbal, or +written communication sent to the Licensor or its representatives, including +but not limited to communication on electronic mailing lists, source code +control systems, and issue tracking systems that are managed by, or on behalf +of, the Licensor for the purpose of discussing and improving the Work, but +excluding communication that is conspicuously marked or otherwise designated +in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this +License, each Contributor hereby grants to You a perpetual, worldwide, +non-exclusive, no-charge, royalty-free, irrevocable copyright license to +reproduce, prepare Derivative Works of, publicly display, publicly perform, +sublicense, and distribute the Work and such Derivative Works in Source or +Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this +License, each Contributor hereby grants to You a perpetual, worldwide, +non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this +section) patent license to make, have made, use, offer to sell, sell, import, +and otherwise transfer the Work, where such license applies only to those +patent claims licensable by such Contributor that are necessarily infringed by +their Contribution(s) alone or by combination of their Contribution(s) with the +Work to which such Contribution(s) was submitted. If You institute patent +litigation against any entity (including a cross-claim or counterclaim in a +lawsuit) alleging that the Work or a Contribution incorporated within the Work +constitutes direct or contributory patent infringement, then any patent +licenses granted to You under this License for that Work shall terminate as of +the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or +Derivative Works thereof in any medium, with or without modifications, and in +Source or Object form, provided that You meet the following conditions: + +(a) You must give any other recipients of the Work or Derivative Works a copy +of this License; and + +(b) You must cause any modified files to carry prominent notices stating that +You changed the files; and + +(c) You must retain, in the Source form of any Derivative Works that You +distribute, all copyright, patent, trademark, and attribution notices from the +Source form of the Work, excluding those notices that do not pertain to any +part of the Derivative Works; and + +(d) If the Work includes a "NOTICE" text file as part of its distribution, +then any Derivative Works that You distribute must include a readable copy of +the attribution notices contained within such NOTICE file, excluding those +notices that do not pertain to any part of the Derivative Works, in at least +one of the following places: within a NOTICE text file distributed as part of +the Derivative Works; within the Source form or documentation, if provided +along with the Derivative Works; or within a display generated by the +Derivative Works, if and wherever such third-party notices normally appear. +The contents of the NOTICE file are for informational purposes only and do not +modify the License. You may add Your own attribution notices within Derivative +Works that You distribute, alongside or as an addendum to the NOTICE text from +the Work, provided that such additional attribution notices cannot be construed +as modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a +whole, provided Your use, reproduction, and distribution of the Work otherwise +complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any +Contribution intentionally submitted for inclusion in the Work by You to the +Licensor shall be under the terms and conditions of this License, without any +additional terms or conditions. Notwithstanding the above, nothing herein shall +supersede or modify the terms of any separate license agreement you may have +executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, +trademarks, service marks, or product names of the Licensor, except as +required for reasonable and customary use in describing the origin of the Work +and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in +writing, Licensor provides the Work (and each Contributor provides its +Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied, including, without limitation, any warranties +or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A +PARTICULAR PURPOSE. You are solely responsible for determining the +appropriateness of using or redistributing the Work and assume any risks +associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in +tort (including negligence), contract, or otherwise, unless required by +applicable law (such as deliberate and grossly negligent acts) or agreed to in +writing, shall any Contributor be liable to You for damages, including any +direct, indirect, special, incidental, or consequential damages of any +character arising as a result of this License or out of the use or inability to +use the Work (including but not limited to damages for loss of goodwill, work +stoppage, computer failure or malfunction, or any and all other commercial +damages or losses), even if such Contributor has been advised of the +possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or +Derivative Works thereof, You may choose to offer, and charge a fee for, +acceptance of support, warranty, indemnity, or other liability obligations +and/or rights consistent with this License. However, in accepting such +obligations, You may act only on Your own behalf and on Your sole +responsibility, not on behalf of any other Contributor, and only if You agree +to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS diff --git a/native/lsposed_hook_backend.cpp b/native/lsposed_hook_backend.cpp index bd1dc85..98f2f7b 100644 --- a/native/lsposed_hook_backend.cpp +++ b/native/lsposed_hook_backend.cpp @@ -283,18 +283,51 @@ bool RegisterMadviseGuard(const Image& image) { return true; } -bool install_madvise_hook(const char* runtime_name) { +struct GuardAllRequest { + const char* path; + size_t guarded; +}; + +int GuardMatchingImage(dl_phdr_info* info, size_t, void* opaque) { + auto* request = static_cast(opaque); + const char* mapped_path = info->dlpi_name; + if (mapped_path == nullptr || info->dlpi_phnum == 0u || + info->dlpi_phnum > 128u) { + return 0; + } + const bool matches = strchr(request->path, '/') != nullptr + ? strcmp(request->path, mapped_path) == 0 + : strcmp(request->path, BaseName(mapped_path)) == 0; + if (!matches) return 0; + Image image{}; - const char* requested = runtime_name == nullptr - ? kHyperRuntimeName : runtime_name; - if (!FindImage(requested, 0u, &image) && - (runtime_name == nullptr || - !FindImage(kHyperRuntimeName, 0u, &image))) { + image.base = static_cast(info->dlpi_addr); + image.phdr = info->dlpi_phdr; + image.phnum = info->dlpi_phnum; + const size_t length = strlen(mapped_path); + if (length >= sizeof(image.path)) return 0; + memcpy(image.path, mapped_path, length + 1u); + if (RegisterMadviseGuard(image)) ++request->guarded; + return 0; +} + +// Guards every image matching the name, not just a unique one: the runtime is +// present both under /system_ext and mapped from the launcher's APK, and each +// copy imports madvise separately. +bool install_madvise_hook(const char* runtime_name) { + GuardAllRequest request{ + runtime_name == nullptr ? kHyperRuntimeName : runtime_name, 0u}; + dl_iterate_phdr(GuardMatchingImage, &request); + if (request.guarded == 0u && runtime_name != nullptr) { + request.path = kHyperRuntimeName; + dl_iterate_phdr(GuardMatchingImage, &request); + } + if (request.guarded == 0u) { __android_log_print(ANDROID_LOG_ERROR, kLogTag, - "cannot resolve loaded %s", requested); + "cannot resolve loaded %s", request.path); return false; } - return RegisterMadviseGuard(image); + return true; } uintptr_t RuntimeAddress(const Image& image, ElfW(Addr) value) { @@ -581,6 +614,12 @@ int InstallInlineHook(void* target, void* replacement, void** original) { } bool EnsureLsposedMadviseGuard(const char* runtime_name) { + // Callers that name no image just want *a* guard in place before hooking; + // skip re-walking every loaded image once one is installed. + if (runtime_name == nullptr && + __atomic_load_n(&g_madvise_guard_state, __ATOMIC_ACQUIRE) == 2u) { + return true; + } if (!install_madvise_hook(runtime_name)) { __atomic_store_n(&g_madvise_guard_state, uint32_t{3}, __ATOMIC_RELEASE); return false; diff --git a/native/micts_native_hook.cpp b/native/micts_native_hook.cpp index 8088725..6509a4a 100644 --- a/native/micts_native_hook.cpp +++ b/native/micts_native_hook.cpp @@ -407,6 +407,15 @@ void HookLongPressHandler(void* closure, uint32_t trigger_mode) { return; } + // Decide before touching the detector: once the completion marker is set + // the gesture is ours, and there is no going back to the stock route. + if (AtomicLoad(&g_contextual_search_invoke) == nullptr) { + Log(ANDROID_LOG_WARN, + "contextual-search invoke unavailable; keeping the stock route"); + if (original != nullptr) original(closure, trigger_mode); + return; + } + // Mark the captured detector state complete before routing, exactly as the // stock closure does, so the launcher's own detector is never left stale. __atomic_store_n(static_cast(completion_state) + 0x10u, From b26aceece2f1b8c1ca06c4fe665b9410e05d6891 Mon Sep 17 00:00:00 2001 From: liuran001 Date: Sun, 30 Aug 2026 09:36:41 +0800 Subject: [PATCH 3/6] fix: make launcher hook installation retryable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PLT slot already pointing at our replacement cannot be hooked again, so a failure partway through left the earlier hooks in place and made every later attempt fail on them — the feature probe would keep claiming contextual-search support with no routing behind it, until the process restarted. Skip whatever a previous attempt got through, and undo the feature override if the inline hook still cannot be installed. --- native/micts_native_hook.cpp | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/native/micts_native_hook.cpp b/native/micts_native_hook.cpp index 6509a4a..e2b52c7 100644 --- a/native/micts_native_hook.cpp +++ b/native/micts_native_hook.cpp @@ -429,6 +429,16 @@ void HookLongPressHandler(void* closure, uint32_t trigger_mode) { } } +/** Writes a hooked PLT slot back to the original it captured. */ +void RestorePltHook(uint8_t* base, const char* symbol, void** original) { + void* captured = AtomicLoad(original); + if (captured == nullptr) return; + void* discarded = nullptr; + if (InstallPltHook(base, symbol, captured, &discarded) == kHookSuccess) { + AtomicStore(original, static_cast(nullptr)); + } +} + void InstallLauncherHook(void* app_entry_point) { if (AtomicLoad(&g_original_long_press_handler) != nullptr) return; @@ -477,22 +487,26 @@ void InstallLauncherHook(void* app_entry_point) { AtomicLoad(&g_motion_get_source) != nullptr && (AtomicLoad(&g_motion_get_raw_y) != nullptr || AtomicLoad(&g_motion_get_y) != nullptr); + // A PLT slot already pointing at our replacement cannot be hooked again, so + // skip whatever a previous attempt got through and let this one carry on. if (!motion_accessors_ready || - InstallPltHook(base, "input_MotionEvent_getActionMasked", - reinterpret_cast(HookMotionEventGetActionMasked), - &g_original_motion_get_action_masked) != kHookSuccess || - AtomicLoad(&g_original_motion_get_action_masked) == nullptr) { + (AtomicLoad(&g_original_motion_get_action_masked) == nullptr && + (InstallPltHook(base, "input_MotionEvent_getActionMasked", + reinterpret_cast(HookMotionEventGetActionMasked), + &g_original_motion_get_action_masked) != kHookSuccess || + AtomicLoad(&g_original_motion_get_action_masked) == nullptr))) { Log(ANDROID_LOG_ERROR, "motion tracking unavailable; leaving the launcher stock rather " "than swallowing swipes as long presses"); return; } - if (InstallPltHook(base, "PackageManager_has_system_feature", - reinterpret_cast( - HookPackageManagerHasSystemFeature), - &g_original_has_system_feature) != kHookSuccess || - AtomicLoad(&g_original_has_system_feature) == nullptr) { + if (AtomicLoad(&g_original_has_system_feature) == nullptr && + (InstallPltHook(base, "PackageManager_has_system_feature", + reinterpret_cast( + HookPackageManagerHasSystemFeature), + &g_original_has_system_feature) != kHookSuccess || + AtomicLoad(&g_original_has_system_feature) == nullptr)) { Log(ANDROID_LOG_ERROR, "contextual-search feature probe hook unavailable; leaving the " "launcher stock rather than swallowing an unusable long press"); @@ -510,6 +524,10 @@ void InstallLauncherHook(void* app_entry_point) { AtomicLoad(&g_original_long_press_handler) == nullptr) { AtomicStore(&g_original_long_press_handler, static_cast(nullptr)); AtomicStore(&g_contextual_search_invoke, static_cast(nullptr)); + // Undo the feature override: claiming support with no routing behind it + // is worse than reporting the platform's own answer. + RestorePltHook(base, "PackageManager_has_system_feature", + &g_original_has_system_feature); Log(ANDROID_LOG_ERROR, "launcher long-press inline hook failed"); return; } From 142b78973bdce0613e812e82029b1bd9f31161da Mon Sep 17 00:00:00 2001 From: liuran001 Date: Sun, 30 Aug 2026 10:03:48 +0800 Subject: [PATCH 4/6] feat: support VISTrigger on HyperOS 4 --- .../java/com/parallelc/micts/ModuleMain.kt | 20 ++++++------- .../hooker/NativeLauncherTriggerHooker.kt | 30 ++++++++++++------- .../META-INF/xposed/native_init.list | 0 native/README.md | 9 ++++-- 4 files changed, 36 insertions(+), 23 deletions(-) rename app/src/{MiCTS => main}/resources/META-INF/xposed/native_init.list (100%) diff --git a/app/src/main/java/com/parallelc/micts/ModuleMain.kt b/app/src/main/java/com/parallelc/micts/ModuleMain.kt index 8ebd148..9eef4aa 100644 --- a/app/src/main/java/com/parallelc/micts/ModuleMain.kt +++ b/app/src/main/java/com/parallelc/micts/ModuleMain.kt @@ -51,17 +51,17 @@ class ModuleMain : XposedModule() { }.onFailure { e -> log(Log.ERROR, "MiCTS", "hook CSMS fail", e) } + } + } - // On HyperOS 4 the launcher triggers Circle to Search natively, - // so this is the only place left to apply the gesture switch and - // the haptic. Earlier releases drive it from Java — leave those. - if (Build.MANUFACTURER == "Xiaomi" && NativeLauncherTriggerHooker.isSupported()) { - runCatching { - NativeLauncherTriggerHooker.hook(param) - }.onFailure { e -> - log(Log.ERROR, "MiCTS", "hook native launcher trigger fail", e) - } - } + // HyperOS 4 moved the launcher gesture pipeline out of ART. Both MiCTS + // and VISTrigger therefore bridge the native launcher request in the + // system server; earlier releases still use the Java launcher hooks. + if (Build.MANUFACTURER == "Xiaomi" && NativeLauncherTriggerHooker.isSupported()) { + runCatching { + NativeLauncherTriggerHooker.hook(param) + }.onFailure { e -> + log(Log.ERROR, BuildConfig.APP_NAME, "hook native launcher trigger fail", e) } } diff --git a/app/src/main/java/com/parallelc/micts/hooker/NativeLauncherTriggerHooker.kt b/app/src/main/java/com/parallelc/micts/hooker/NativeLauncherTriggerHooker.kt index 534fc36..8dcb9af 100644 --- a/app/src/main/java/com/parallelc/micts/hooker/NativeLauncherTriggerHooker.kt +++ b/app/src/main/java/com/parallelc/micts/hooker/NativeLauncherTriggerHooker.kt @@ -8,6 +8,7 @@ import android.os.Build import android.os.VibrationEffect import android.os.Vibrator import android.util.Log +import com.parallelc.micts.BuildConfig import com.parallelc.micts.config.TriggerService import com.parallelc.micts.config.XposedConfig.CONFIG_NAME import com.parallelc.micts.config.XposedConfig.DEFAULT_CONFIG @@ -32,13 +33,13 @@ object NativeLauncherTriggerHooker { fun isSupported(): Boolean = Build.VERSION.SDK_INT >= ANDROID_17 - /** MiCTS's own trigger already applied the settings before it got here. */ + /** Prevents a bridge-initiated request from being mistaken for a launcher request. */ private val selfInvocation = ThreadLocal() private var launcherUid: Int = -1 private var launcherUidResolved = false private val triggerExecutor = Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "micts-launcher-trigger").apply { isDaemon = true } + Thread(runnable, "native-launcher-trigger").apply { isDaemon = true } } fun asSelfInvocation(block: () -> T): T { @@ -88,7 +89,8 @@ object NativeLauncherTriggerHooker { return uid == launcherUid } - @SuppressLint("MissingPermission") // Runs in the system server, which owns VIBRATE. + // This hook is installed only on Android 17; the system server owns VIBRATE. + @SuppressLint("MissingPermission", "NewApi") private fun vibrate() { runCatching { val context = Class.forName("android.app.ActivityThread") @@ -102,7 +104,7 @@ object NativeLauncherTriggerHooker { .build() vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK), attributes) }.onFailure { e -> - module!!.log(Log.ERROR, "MiCTS", "native launcher trigger vibrate fail", e) + module!!.log(Log.ERROR, BuildConfig.APP_NAME, "native launcher trigger vibrate fail", e) } } @@ -122,7 +124,7 @@ object NativeLauncherTriggerHooker { override fun intercept(chain: Chain): Any? { val decision = runCatching { decide(chain) }.getOrElse { error -> module!!.log( - Log.ERROR, "MiCTS", + Log.ERROR, BuildConfig.APP_NAME, "native launcher trigger gate failed; passing the request through", error ) @@ -145,16 +147,24 @@ object NativeLauncherTriggerHooker { DEFAULT_CONFIG[KEY_GESTURE_TRIGGER] as Boolean ) ) { - module!!.log(Log.INFO, "MiCTS", "native launcher trigger is disabled; ignoring") + module!!.log( + Log.INFO, + BuildConfig.APP_NAME, + "native launcher trigger is disabled; ignoring" + ) return Decision.CONSUME } if (prefs.getBoolean(KEY_VIBRATE, DEFAULT_CONFIG[KEY_VIBRATE] as Boolean)) { vibrate() } - val selected = prefs.getInt( - KEY_TRIGGER_SERVICE, - DEFAULT_CONFIG[KEY_TRIGGER_SERVICE] as Int - ) + val selected = if (BuildConfig.APP_NAME == "VISTrigger") { + TriggerService.VIS.ordinal + } else { + prefs.getInt( + KEY_TRIGGER_SERVICE, + DEFAULT_CONFIG[KEY_TRIGGER_SERVICE] as Int + ) + } if (selected == TriggerService.CSService.ordinal) return Decision.BRIDGE val entryPoint = chain.args.getOrNull(0) as? Int ?: 1 diff --git a/app/src/MiCTS/resources/META-INF/xposed/native_init.list b/app/src/main/resources/META-INF/xposed/native_init.list similarity index 100% rename from app/src/MiCTS/resources/META-INF/xposed/native_init.list rename to app/src/main/resources/META-INF/xposed/native_init.list diff --git a/native/README.md b/native/README.md index 69757f8..c945458 100644 --- a/native/README.md +++ b/native/README.md @@ -7,7 +7,7 @@ trigger on Android 17 / HyperOS 4. HyperOS 4 runs `com.miui.home` as a pure native process — it has no ART runtime at all, so it carries no `[anon:dalvik-*]` mappings and LSPosed's Java callbacks -never fire for it. Every Java hook MiCTS uses for that trigger +never fire for it. Every Java hook MiCTS and VISTrigger use for that trigger (`NavStubView`, `NavStubGestureEventManager`, `NavBarEventHelper`, `CircleToSearchHelper`) therefore stops working: the classes are not merely renamed, the whole gesture pipeline moved into `libapp_launcher.so`. @@ -15,8 +15,11 @@ renamed, the whole gesture pipeline moved into `libapp_launcher.so`. This payload inline hooks the launcher's contextual-search long-press handler instead, and routes the gesture into the launcher's own contextual-search entry point. The resulting call reaches `ContextualSearchManagerService` in the system -server, where `NativeLauncherTriggerHooker` applies the module's settings and -`CSMSHooker` grants it the permission and provider it needs. +server, where `NativeLauncherTriggerHooker` applies the module's settings. +MiCTS can keep the contextual-search route (with `CSMSHooker` supplying the +permission and provider) or redirect through `VoiceInteractionManagerService`; +VISTrigger always redirects the request to the configured Voice Interaction +Service. Nothing here is version-pinned: the addresses are resolved out of the mapped image at runtime by matching the code's structure, so an unknown launcher build From a20b236ca5dc91a6ea59cbe7fef215ee85558317 Mon Sep 17 00:00:00 2001 From: liuran001 Date: Sun, 30 Aug 2026 10:43:45 +0800 Subject: [PATCH 5/6] fix: prevent VISTrigger launcher crash on HyperOS 4 --- .../java/com/parallelc/micts/ModuleMain.kt | 14 ++++++- .../com/parallelc/micts/hooker/CSMSHooker.kt | 6 ++- .../hooker/NativeLauncherTriggerHooker.kt | 41 +++++++++++++------ 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/parallelc/micts/ModuleMain.kt b/app/src/main/java/com/parallelc/micts/ModuleMain.kt index 9eef4aa..dd0bbcd 100644 --- a/app/src/main/java/com/parallelc/micts/ModuleMain.kt +++ b/app/src/main/java/com/parallelc/micts/ModuleMain.kt @@ -36,6 +36,9 @@ class ModuleMain : XposedModule() { override fun onSystemServerStarting(param: SystemServerStartingParam) { super.onSystemServerStarting(param) + val supportsNativeLauncherTrigger = + Build.MANUFACTURER == "Xiaomi" && NativeLauncherTriggerHooker.isSupported() + if (BuildConfig.APP_NAME == "MiCTS") { if (TriggerService.getSupportedServices().contains(TriggerService.CSHelper)) { runCatching { @@ -52,12 +55,21 @@ class ModuleMain : XposedModule() { log(Log.ERROR, "MiCTS", "hook CSMS fail", e) } } + } else if (supportsNativeLauncherTrigger) { + // The native launcher aborts if this service was omitted at boot. + // VISTrigger consumes its request and forwards it to VIS, so it only + // needs the service bootstrap, not MiCTS's provider bypass hooks. + runCatching { + CSMSHooker.hookServiceBootstrap(param) + }.onFailure { e -> + log(Log.ERROR, BuildConfig.APP_NAME, "hook contextual search bootstrap fail", e) + } } // HyperOS 4 moved the launcher gesture pipeline out of ART. Both MiCTS // and VISTrigger therefore bridge the native launcher request in the // system server; earlier releases still use the Java launcher hooks. - if (Build.MANUFACTURER == "Xiaomi" && NativeLauncherTriggerHooker.isSupported()) { + if (supportsNativeLauncherTrigger) { runCatching { NativeLauncherTriggerHooker.hook(param) }.onFailure { e -> diff --git a/app/src/main/java/com/parallelc/micts/hooker/CSMSHooker.kt b/app/src/main/java/com/parallelc/micts/hooker/CSMSHooker.kt index 8b1a692..fd013d5 100644 --- a/app/src/main/java/com/parallelc/micts/hooker/CSMSHooker.kt +++ b/app/src/main/java/com/parallelc/micts/hooker/CSMSHooker.kt @@ -26,13 +26,17 @@ class CSMSHooker { private val bypass = ThreadLocal() @SuppressLint("PrivateApi") - fun hook(param: SystemServerStartingParam) { + fun hookServiceBootstrap(param: SystemServerStartingParam) { val rString = param.classLoader.loadClass("com.android.internal.R\$string") contextualSearchPackageName = rString.getField("config_defaultContextualSearchPackageName").getInt(null) val systemServer = param.classLoader.loadClass("com.android.server.SystemServer") module!!.hook(systemServer.getDeclaredMethod("deviceHasConfigString", Context::class.java, Int::class.java)) .intercept(DeviceHasConfigStringHooker()) + } + @SuppressLint("PrivateApi") + fun hook(param: SystemServerStartingParam) { + hookServiceBootstrap(param) val csms = param.classLoader.loadClass("com.android.server.contextualsearch.ContextualSearchManagerService") enforcePermission = csms.getDeclaredMethod("enforcePermission", String::class.java) getContextualSearchPackageName = csms.getDeclaredMethod("getContextualSearchPackageName") diff --git a/app/src/main/java/com/parallelc/micts/hooker/NativeLauncherTriggerHooker.kt b/app/src/main/java/com/parallelc/micts/hooker/NativeLauncherTriggerHooker.kt index 8dcb9af..16f2df3 100644 --- a/app/src/main/java/com/parallelc/micts/hooker/NativeLauncherTriggerHooker.kt +++ b/app/src/main/java/com/parallelc/micts/hooker/NativeLauncherTriggerHooker.kt @@ -57,19 +57,30 @@ object NativeLauncherTriggerHooker { val stub = param.classLoader.loadClass( "com.android.server.contextualsearch.ContextualSearchManagerService\$ContextualSearchManagerStub" ) - val start = findStartMethod(stub) - ?: throw NoSuchMethodException("${stub.name}.startContextualSearch") - module!!.hook(start).intercept(StartHooker()) + val starts = findStartMethods(stub) + if (starts.isEmpty()) { + throw NoSuchMethodException( + "${stub.name}.startContextualSearch/startContextualSearchForApp" + ) + } + starts.forEach { start -> + module!!.hook(start).intercept(StartHooker(start.name)) + } } - /** The entry point gained a `ContextualSearchConfig` overload in QPR1. */ - private fun findStartMethod(stub: Class<*>): Method? = stub.declaredMethods.firstOrNull { - if (it.name != "startContextualSearch" || it.returnType != Void.TYPE) return@firstOrNull false + /** QPR1 added configs; HyperOS 4's native launcher uses the app-specific entry point. */ + private fun findStartMethods(stub: Class<*>): List = stub.declaredMethods.filter { + if (it.returnType != Void.TYPE) return@filter false val parameters = it.parameterTypes - when (parameters.size) { - 1 -> parameters[0] == Int::class.javaPrimitiveType - 2 -> parameters[0] == Int::class.javaPrimitiveType && - parameters[1].name == "android.app.contextualsearch.ContextualSearchConfig" + when (it.name) { + "startContextualSearch" -> when (parameters.size) { + 1 -> parameters[0] == Int::class.javaPrimitiveType + 2 -> parameters[0] == Int::class.javaPrimitiveType && + parameters[1].name == "android.app.contextualsearch.ContextualSearchConfig" + else -> false + } + "startContextualSearchForApp" -> parameters.size == 1 && + parameters[0].name == "android.app.contextualsearch.ContextualSearchConfig" else -> false } } @@ -120,7 +131,7 @@ object NativeLauncherTriggerHooker { CONSUME, } - class StartHooker : Hooker { + class StartHooker(private val methodName: String) : Hooker { override fun intercept(chain: Chain): Any? { val decision = runCatching { decide(chain) }.getOrElse { error -> module!!.log( @@ -141,6 +152,12 @@ object NativeLauncherTriggerHooker { if (selfInvocation.get() == true) return Decision.PASS_THROUGH if (!isLauncherCaller(Binder.getCallingUid())) return Decision.PASS_THROUGH + module!!.log( + Log.INFO, + BuildConfig.APP_NAME, + "native launcher trigger intercepted via $methodName" + ) + val prefs = module!!.getRemotePreferences(CONFIG_NAME) if (!prefs.getBoolean( KEY_GESTURE_TRIGGER, @@ -167,7 +184,7 @@ object NativeLauncherTriggerHooker { } if (selected == TriggerService.CSService.ordinal) return Decision.BRIDGE - val entryPoint = chain.args.getOrNull(0) as? Int ?: 1 + val entryPoint = chain.args.firstOrNull { it is Int } as? Int ?: 1 triggerExecutor.execute { // The haptic already played above; do not repeat it. asSelfInvocation { triggerCircleToSearch(entryPoint, null, false) } From 05248593c0e06c10bd043d40f3d71d2b86a83f90 Mon Sep 17 00:00:00 2001 From: liuran001 Date: Sun, 30 Aug 2026 12:09:16 +0800 Subject: [PATCH 6/6] fix: reinstall the long-press hook after the launcher text is remapped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The launcher's code is mapped straight out of the APK. When those pages are reclaimed they come back from the file, silently undoing the inline patch while LSPosed still holds a now-dangling trampoline — the process stays up, the payload stays loaded, nothing is logged, and the gesture simply stops working until the spawner is restarted. PLT hooks survive that because they patch the GOT rather than the code, so the motion-event hook is used as the checkpoint: it compares the handler against the prologue captured before patching, and on a match drops LSPosed's stale record before hooking again. --- native/lsposed_hook_backend.cpp | 8 +++++ native/lsposed_hook_backend.h | 3 ++ native/micts_native_hook.cpp | 60 +++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+) diff --git a/native/lsposed_hook_backend.cpp b/native/lsposed_hook_backend.cpp index 98f2f7b..e34df41 100644 --- a/native/lsposed_hook_backend.cpp +++ b/native/lsposed_hook_backend.cpp @@ -613,6 +613,14 @@ int InstallInlineHook(void* target, void* replacement, void** original) { return kHookSuccess; } +int RemoveInlineHook(void* target) { + if (g_lsposed_api == nullptr || g_lsposed_api->unhookFunc == nullptr || + target == nullptr) { + return kHookFailed; + } + return g_lsposed_api->unhookFunc(target) == 0 ? kHookSuccess : kHookFailed; +} + bool EnsureLsposedMadviseGuard(const char* runtime_name) { // Callers that name no image just want *a* guard in place before hooking; // skip re-walking every loaded image once one is installed. diff --git a/native/lsposed_hook_backend.h b/native/lsposed_hook_backend.h index aba7b9e..e870fd7 100644 --- a/native/lsposed_hook_backend.h +++ b/native/lsposed_hook_backend.h @@ -15,3 +15,6 @@ bool EnsureLsposedMadviseGuard(const char* runtime_name = nullptr); int InstallPltHook(void* base_addr, const char* symbol, void* hook_handler, void** original); int InstallInlineHook(void* target, void* replacement, void** original); +// Drops LSPosed's own record of an inline hook. Needed before reinstalling one +// whose patched text was discarded underneath it. +int RemoveInlineHook(void* target); diff --git a/native/micts_native_hook.cpp b/native/micts_native_hook.cpp index e2b52c7..0e22684 100644 --- a/native/micts_native_hook.cpp +++ b/native/micts_native_hook.cpp @@ -58,6 +58,16 @@ using MotionEventLongFn = int64_t (*)(void*); using MotionEventIntFn = int32_t (*)(void*); using MotionEventFloatFn = float (*)(void*); +// The launcher's text is mapped straight out of the APK. When those pages are +// reclaimed they come back from the file, silently undoing an inline patch +// while LSPosed still believes the hook is installed. Keep what is needed to +// notice that and redo it. +constexpr size_t kLongPressPrologueSize = 16u; +uint8_t* g_launcher_base = nullptr; +uintptr_t g_long_press_handler_offset = 0u; +uint8_t g_long_press_prologue[kLongPressPrologueSize]{}; +uint32_t g_repair_in_flight = 0u; + void* g_original_long_press_handler = nullptr; void* g_contextual_search_invoke = nullptr; void* g_original_has_system_feature = nullptr; @@ -266,7 +276,50 @@ void PublishMotionSnapshot(void* event, int32_t action) { __ATOMIC_RELAXED); } +void HookLongPressHandler(void* closure, uint32_t trigger_mode); + +/** + * Reinstalls the long-press hook if the launcher's text was remapped under it. + * + * A PLT hook survives that, because it patches the GOT rather than the code, so + * this runs from one — but the inline patch does not: the page comes back from + * the APK with the original prologue, while LSPosed still holds a now-dangling + * trampoline. Drop its record before hooking again. + */ +void RepairLongPressHookIfLost() { + uint8_t* base = g_launcher_base; + if (base == nullptr || + AtomicLoad(&g_original_long_press_handler) == nullptr) { + return; + } + uint8_t* target = base + g_long_press_handler_offset; + if (memcmp(target, g_long_press_prologue, kLongPressPrologueSize) != 0) { + return; // Still patched. + } + uint32_t idle = 0u; + if (!__atomic_compare_exchange_n(&g_repair_in_flight, &idle, uint32_t{1}, + false, __ATOMIC_ACQ_REL, + __ATOMIC_ACQUIRE)) { + return; + } + RemoveInlineHook(target); + AtomicStore(&g_original_long_press_handler, static_cast(nullptr)); + const bool repaired = + InstallInlineHook(target, + reinterpret_cast(HookLongPressHandler), + &g_original_long_press_handler) == kHookSuccess && + AtomicLoad(&g_original_long_press_handler) != nullptr; + if (!repaired) { + AtomicStore(&g_original_long_press_handler, static_cast(nullptr)); + } + __android_log_print(repaired ? ANDROID_LOG_INFO : ANDROID_LOG_ERROR, kLogTag, + "launcher text was remapped; long-press hook %s", + repaired ? "reinstalled" : "could NOT be reinstalled"); + __atomic_store_n(&g_repair_in_flight, uint32_t{0}, __ATOMIC_RELEASE); +} + int32_t HookMotionEventGetActionMasked(void* event) { + RepairLongPressHookIfLost(); auto original = reinterpret_cast( AtomicLoad(&g_original_motion_get_action_masked)); if (original == nullptr) return 0; @@ -513,6 +566,13 @@ void InstallLauncherHook(void* app_entry_point) { return; } + // Capture the untouched prologue first: it is what tells us later whether + // the patch is still there. + memcpy(g_long_press_prologue, base + profile.long_press_handler_offset, + kLongPressPrologueSize); + g_launcher_base = base; + g_long_press_handler_offset = profile.long_press_handler_offset; + // Publish the invoke helper before the hook goes live, so the very first // long press already has a route. AtomicStore(&g_contextual_search_invoke,