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..8209f1b 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 = "26.2.11394342" 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 = "3.22.1" + } + } + + 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/main/java/com/parallelc/micts/ModuleMain.kt b/app/src/main/java/com/parallelc/micts/ModuleMain.kt index 47bef44..dd0bbcd 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 @@ -35,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 { @@ -51,6 +55,26 @@ 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 (supportsNativeLauncherTrigger) { + runCatching { + NativeLauncherTriggerHooker.hook(param) + }.onFailure { e -> + log(Log.ERROR, BuildConfig.APP_NAME, "hook native launcher trigger fail", e) + } } if (Build.MANUFACTURER == "Xiaomi") { 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..fd013d5 100644 --- a/app/src/main/java/com/parallelc/micts/hooker/CSMSHooker.kt +++ b/app/src/main/java/com/parallelc/micts/hooker/CSMSHooker.kt @@ -7,44 +7,84 @@ 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) { + 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") + 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 +100,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..16f2df3 --- /dev/null +++ b/app/src/main/java/com/parallelc/micts/hooker/NativeLauncherTriggerHooker.kt @@ -0,0 +1,195 @@ +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.BuildConfig +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 + + /** 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, "native-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 starts = findStartMethods(stub) + if (starts.isEmpty()) { + throw NoSuchMethodException( + "${stub.name}.startContextualSearch/startContextualSearchForApp" + ) + } + starts.forEach { start -> + module!!.hook(start).intercept(StartHooker(start.name)) + } + } + + /** 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 (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 + } + } + + 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 + } + + // 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") + .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, BuildConfig.APP_NAME, "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(private val methodName: String) : Hooker { + override fun intercept(chain: Chain): Any? { + val decision = runCatching { decide(chain) }.getOrElse { error -> + module!!.log( + Log.ERROR, BuildConfig.APP_NAME, + "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 + + 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, + DEFAULT_CONFIG[KEY_GESTURE_TRIGGER] as Boolean + ) + ) { + 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 = 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.firstOrNull { it is Int } 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/app/src/main/resources/META-INF/xposed/native_init.list b/app/src/main/resources/META-INF/xposed/native_init.list new file mode 100644 index 0000000..f284751 --- /dev/null +++ b/app/src/main/resources/META-INF/xposed/native_init.list @@ -0,0 +1 @@ +libmicts_hyos_lsp.so 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/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/README.md b/native/README.md new file mode 100644 index 0000000..c945458 --- /dev/null +++ b/native/README.md @@ -0,0 +1,80 @@ +# 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 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`. + +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. +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 +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..e34df41 --- /dev/null +++ b/native/lsposed_hook_backend.cpp @@ -0,0 +1,646 @@ +// 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; +} + +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{}; + 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", request.path); + return false; + } + return true; +} + +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; +} + +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. + 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; + } + __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..e870fd7 --- /dev/null +++ b/native/lsposed_hook_backend.h @@ -0,0 +1,20 @@ +// 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); +// 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 new file mode 100644 index 0000000..0e22684 --- /dev/null +++ b/native/micts_native_hook.cpp @@ -0,0 +1,659 @@ +// 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*); + +// 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; +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); +} + +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; + 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; + } + + // 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, + 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); + } +} + +/** 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; + + 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); + // 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 || + (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 (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"); + 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, + 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)); + // 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; + } + + __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);