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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
.DS_Store
/build
/captures
.cxx
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)功能

*本应用只负责触发圈定即搜,无法处理触发成功后可能出现的问题*

Expand Down Expand Up @@ -41,7 +41,7 @@
需要在LSPosed里激活模块

- 系统触发服务:触发所使用的系统服务,只会显示当前支持的选项,依赖作用域选择系统框架
- VIS:支持Android 9–16,需要将默认助理应用设置为Google,触发时一些设备的屏幕边缘会闪,没有激活模块的情况下只能使用此服务
- VIS:支持Android 9–17,需要将默认助理应用设置为Google,触发时一些设备的屏幕边缘会闪,没有激活模块的情况下只能使用此服务
- CSHelper:支持Android 14 QPR3及以上,不需要设置默认助理应用,触发时屏幕边缘不会闪
- CSService:支持Android 15及以上,圈定即搜专用的服务,效果同CSHelper

Expand Down
2 changes: 1 addition & 1 deletion README_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*

Expand Down
2 changes: 1 addition & 1 deletion README_ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@



Триггер для Circle to Search на любом устройстве Android 9–16
Триггер для Circle to Search на любом устройстве Android 9–17



Expand Down
26 changes: 25 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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\"")
}
}
Expand All @@ -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 {
Expand Down
24 changes: 24 additions & 0 deletions app/src/main/java/com/parallelc/micts/ModuleMain.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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") {
Expand Down
64 changes: 52 additions & 12 deletions app/src/main/java/com/parallelc/micts/hooker/CSMSHooker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Boolean>()

@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 <T> 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<HookHandle>()
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<Any?>(entryPoint)
}.getOrElse {
val configClass =
Class.forName("android.app.contextualsearch.ContextualSearchConfig")
icsmClass.getDeclaredMethod(
"startContextualSearch", Int::class.java, configClass
) to arrayOf<Any?>(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
}

Expand All @@ -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()
}
}
}
}
}
67 changes: 56 additions & 11 deletions app/src/main/java/com/parallelc/micts/hooker/LongPressHomeHooker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
Loading
Loading