From bf7a59522327e7bf82d008d16d8ae41d8ccd6d8d Mon Sep 17 00:00:00 2001 From: Chimioo Date: Sun, 18 Oct 2026 02:55:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(redirect):=20=E4=BF=AE=E5=A4=8D=20HarmonyOS?= =?UTF-8?q?=20=E4=B8=8A=E5=AE=89=E8=A3=85=E9=87=8D=E5=AE=9A=E5=90=91?= =?UTF-8?q?=E8=A2=AB=E7=B3=BB=E7=BB=9F=20Resolver=20=E5=90=9E=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 现象: 鸿蒙 4.2 上 InxLocker 把安装 Intent 的 package 改成目标安装器后, 系统仍无视 package 将其交给 com.huawei.android.internal.app.HwResolverActivity, 重定向完全失效(issue #7)。 - 根因: 该 ROM 在 PMS 的 resolve 侧对安装类 Intent 强制注入自家 Resolver, 只给 package 而不给 component 的 Intent 永远到不了目标安装器;query 侧正常。 - 组件解析: 新增 ComponentResolver,先探测系统会解析到谁,仅在解析结果落在 OEM 自家 Resolver 时才从 queryIntentActivities 的候选里挑出真正的安装入口 并下发显式 component;AOSP 上解析正常时行为完全不变。 - 候选打分: 按类名关键字加权(packageinstaller/install/intercept)并对 scanner/viewer/explorer 等无关界面降权,得分不足时不擅自替用户选择。 - 性能: 按 package/action/type/scheme/categories 缓存 60 秒, 避免在持有 mGlobalLock 的 ActivityStarter.execute 里反复查询 PMS。 - action 规范化: 提前到解析之前,保证按最终的 VIEW 而非 INSTALL_PACKAGE 匹配候选。 - 缓存失效: 安装器/卸载器/强制组件配置变更与热重载时清空缓存。 --- .../github/chimio/inxlocker/hook/HookEntry.kt | 2 + .../inxlocker/ui/activity/MainActivity.kt | 3 + .../inxlocker/util/ComponentResolver.kt | 195 ++++++++++++++++++ .../chimio/inxlocker/util/IntentRedirector.kt | 12 +- .../chimio/inxlocker/util/PrefsProvider.kt | 5 + 5 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/io/github/chimio/inxlocker/util/ComponentResolver.kt diff --git a/app/src/main/java/io/github/chimio/inxlocker/hook/HookEntry.kt b/app/src/main/java/io/github/chimio/inxlocker/hook/HookEntry.kt index 69bad42..844bdbc 100644 --- a/app/src/main/java/io/github/chimio/inxlocker/hook/HookEntry.kt +++ b/app/src/main/java/io/github/chimio/inxlocker/hook/HookEntry.kt @@ -3,6 +3,7 @@ package io.github.chimio.inxlocker.hook import android.annotation.SuppressLint import android.content.Intent import android.os.Build +import io.github.chimio.inxlocker.util.ComponentResolver import io.github.chimio.inxlocker.util.IntentAnalyzer import io.github.chimio.inxlocker.util.IntentRedirector import io.github.chimio.inxlocker.util.PrefsProvider @@ -66,6 +67,7 @@ class HookEntry : XposedModule() { xposed = this initXposed(this) PrefsProvider.init(getRemotePreferences("selected_installer_package")) + ComponentResolver.invalidate() i(TAG, "Old hooks count: ${param.oldHookHandles.size}") i(TAG, "Loading new hooks") i(TAG, "isSystemServer=${param.isSystemServer}, processName=${param.processName}") diff --git a/app/src/main/java/io/github/chimio/inxlocker/ui/activity/MainActivity.kt b/app/src/main/java/io/github/chimio/inxlocker/ui/activity/MainActivity.kt index 593e93f..5001fb4 100644 --- a/app/src/main/java/io/github/chimio/inxlocker/ui/activity/MainActivity.kt +++ b/app/src/main/java/io/github/chimio/inxlocker/ui/activity/MainActivity.kt @@ -69,6 +69,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.graphics.drawable.toBitmap import androidx.core.net.toUri +import io.github.chimio.inxlocker.util.ComponentResolver import io.github.chimio.inxlocker.util.PrefsProvider import io.github.chimio.inxlocker.R import io.github.chimio.inxlocker.ui.activity.ui.theme.InxLockerTheme @@ -132,10 +133,12 @@ class MainActivity : ComponentActivity() { private fun saveSelectedInstaller(packageName: String) { PrefsProvider.putString(PrefsProvider.KEY_SELECTED_INSTALLER_PACKAGE, packageName) + ComponentResolver.invalidate() } private fun clearSelectedInstaller() { PrefsProvider.remove(PrefsProvider.KEY_SELECTED_INSTALLER_PACKAGE) + ComponentResolver.invalidate() } private fun saveForcedInstallerComponents(values: Set) { diff --git a/app/src/main/java/io/github/chimio/inxlocker/util/ComponentResolver.kt b/app/src/main/java/io/github/chimio/inxlocker/util/ComponentResolver.kt new file mode 100644 index 0000000..e2956a8 --- /dev/null +++ b/app/src/main/java/io/github/chimio/inxlocker/util/ComponentResolver.kt @@ -0,0 +1,195 @@ +@file:Suppress("DEPRECATION") + +package io.github.chimio.inxlocker.util + +import android.annotation.SuppressLint +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.SystemClock +import java.util.concurrent.ConcurrentHashMap + +/** + * 部分 OEM ROM(已知 HarmonyOS / EMUI)在安装类 Intent 只指定 package 而未指定 component 时, + * 会无视 package 直接把 Intent 交给自家的 ResolverActivity(com.huawei.android.internal.app.HwResolverActivity), + * 导致重定向被吞掉。 + * + * 这里先看系统自己会解析到谁:如果解析结果就是目标安装器包,说明该 ROM 行为正常,无需干预; + * 一旦发现解析结果落在别人(例如系统 Resolver)手上,就直接在 PMS 里挑出目标包真正处理该 + * Intent 的 Activity,由调用方下发显式 component 绕过劫持。 + */ +internal object ComponentResolver { + + private const val TAG = "ComponentResolver" + private const val CACHE_TTL_MS = 60_000L + private const val CACHE_MAX_SIZE = 128 + + private const val MATCH_FLAGS = PackageManager.MATCH_ALL or PackageManager.MATCH_DEFAULT_ONLY + + /** 候选打分需达到该阈值才下发显式组件,否则保留只设 package 的原有行为。 */ + private const val CONFIDENT_SCORE = 50 + + /** 类名里出现这些片段时多半是“顺便也能打开 apk”的无关界面。 */ + private val negativeHints = listOf( + "scanner", "viewer", "explorer", "details", "manifest", "share", + "settings", "about", "help", "shortcut", "editor", "picker" + ) + + private data class CacheEntry(val component: ComponentName?, val timestamp: Long) + + private val cache = ConcurrentHashMap() + + private var packageManager: PackageManager? = null + private var packageManagerLoaded = false + + /** + * @return 需要下发的显式组件;返回 null 表示「保持原有行为(只设 package)」。 + */ + fun resolveExplicitComponent(intent: Intent, targetPackage: String): ComponentName? { + val pm = obtainPackageManager() ?: return null + val key = cacheKey(intent, targetPackage) + + cache[key]?.let { entry -> + if (SystemClock.elapsedRealtime() - entry.timestamp < CACHE_TTL_MS) { + entry.component?.let { + i(TAG, "Resolved $targetPackage -> $it (cached)") + } + return entry.component + } + cache.remove(key) + } + + val component = try { + doResolve(pm, intent, targetPackage) + } catch (t: Throwable) { + w(TAG, "Resolve component for $targetPackage failed: ${t.message}") + null + } + + if (cache.size >= CACHE_MAX_SIZE) cache.clear() + cache[key] = CacheEntry(component, SystemClock.elapsedRealtime()) + return component + } + + fun invalidate() { + cache.clear() + } + + private fun doResolve(pm: PackageManager, intent: Intent, targetPackage: String): ComponentName? { + val probe = Intent(intent).apply { + component = null + `package` = targetPackage + // resolve/query 不需要启动相关 flag,且 MATCH_EXTERNAL 等会干扰候选集合 + flags = flags and (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + } + val resolved = runCatching { pm.resolveActivity(probe, MATCH_FLAGS) }.getOrNull() + val resolvedPackage = resolved?.activityInfo?.packageName + if (resolved == null || resolvedPackage == targetPackage) { + // 系统解析不到(原本就不会跳)或直接解析到目标包:该 ROM 行为正常,保留原有行为 + return null + } + if (!isHijackingResolver(resolved)) { + // 只在被 OEM 自家 resolver 劫持时接管(当前已知 HarmonyOS/EMUI 的 HwResolverActivity)。 + // 其余情况(如 AOSP 上目标包内有多个安装入口时系统给出的 ResolverActivity) + // 仍然交给用户选择,不改变原有行为。 + i(TAG, "System resolved $probe to ${resolvedPackage}/${resolved.activityInfo?.name}, keep package-only behavior") + return null + } + + i( + TAG, + "System resolved $probe to ${resolved.activityInfo?.let { "${it.packageName}/${it.name}" }}, " + + "falling back to explicit component for $targetPackage" + ) + + val candidates = runCatching { pm.queryIntentActivities(probe, MATCH_FLAGS) } + .getOrNull() + .orEmpty() + .filter { info -> + info.activityInfo != null && + info.activityInfo.packageName == targetPackage && + info.activityInfo.exported + } + + if (candidates.isEmpty()) { + i(TAG, "No exported activity in $targetPackage for $probe") + return null + } + + val best = candidates.sortedWith( + compareByDescending { score(it.activityInfo.name) } + .thenByDescending { it.priority } + .thenBy { it.activityInfo.name } + ).first() + val bestScore = score(best.activityInfo.name) + if (bestScore < CONFIDENT_SCORE && candidates.size > 1) { + // 没有能确定是安装入口的候选(例如安装器自己只暴露出很普通的 Activity 名), + // 不擅自替用户选择,保留原有行为 + i(TAG, "No confident install activity in $targetPackage (best=${best.activityInfo.name}, score=$bestScore)") + return null + } + + i(TAG, "Pick ${best.activityInfo.packageName}/${best.activityInfo.name} (score=$bestScore) from ${candidates.size} candidate(s)") + + return ComponentName(best.activityInfo.packageName, best.activityInfo.name) + } + + /** + * HarmonyOS / EMUI 会把安装类 Intent 改交给 con.huawei.android.internal.app.HwResolverActivity, + * 即使 Intent 已经指定了 package(PMS 的 resolve 侧也被改写,query 侧正常)。 + */ + private fun isHijackingResolver(info: android.content.pm.ResolveInfo): Boolean { + val className = info.activityInfo?.name.orEmpty() + return className.contains("HwResolverActivity", ignoreCase = true) + } + + private fun score(className: String): Int { + val simple = className.substringAfterLast('.').lowercase() + var score = 0 + if (simple.contains("packageinstaller")) score += 100 + if (simple.contains("install")) score += 50 + if (simple.contains("intercept")) score += 20 + if (negativeHints.any { simple.contains(it) }) score -= 100 + return score + } + + private fun cacheKey(intent: Intent, targetPackage: String): String { + val categories = intent.categories?.sorted()?.joinToString(",").orEmpty() + return listOf( + targetPackage, + intent.action.orEmpty(), + intent.type.orEmpty(), + intent.data?.scheme.orEmpty(), + categories + ).joinToString("|") + } + + /** + * 在 system_server 与 app 进程里都能拿到一个可用的 PackageManager。 + * 隐藏 API 通过反射访问,取不到时返回 null(调用方回退到原有行为)。 + */ + @SuppressLint("PrivateApi") + private fun obtainPackageManager(): PackageManager? { + packageManager?.let { return it } + if (packageManagerLoaded) return packageManager + packageManagerLoaded = true + + val pm = runCatching { + val activityThreadClass = Class.forName("android.app.ActivityThread") + val current = activityThreadClass.getMethod("currentActivityThread").invoke(null) + ?: return@runCatching null + val context = activityThreadClass.getMethod("getSystemContext").invoke(current) as? Context + ?: return@runCatching null + context.packageManager + }.getOrNull() + + if (pm != null) { + i(TAG, "PackageManager ready: $pm") + packageManager = pm + } else { + w(TAG, "PackageManager unavailable, redirect will keep package-only behavior") + } + return pm + } +} diff --git a/app/src/main/java/io/github/chimio/inxlocker/util/IntentRedirector.kt b/app/src/main/java/io/github/chimio/inxlocker/util/IntentRedirector.kt index f6ad0ac..3b4f76e 100644 --- a/app/src/main/java/io/github/chimio/inxlocker/util/IntentRedirector.kt +++ b/app/src/main/java/io/github/chimio/inxlocker/util/IntentRedirector.kt @@ -60,6 +60,8 @@ object IntentRedirector { private fun applyRedirection(intent: Intent) { val isUninstall = intent.action == ACTION_DELETE || intent.action == ACTION_UNINSTALL_PACKAGE val targetPackage = getTargetPackageForIntent(intent) + // 先规范化 action:INSTALL_PACKAGE 会被转成 VIEW,后面查候选组件时要按最终 action 匹配 + normalizeAction(intent) if (!targetPackage.isNullOrBlank()) { if (isUninstall) { @@ -72,8 +74,16 @@ object IntentRedirector { intent.component = forcedComponent intent.`package` = null } else { + // 先按原有行为只设 package;若系统会把它交给第三方 Resolver(HarmonyOS/EMUI 的 + // HwResolverActivity 会无视 package 直接抢占安装类 Intent),改下发显式 component。 intent.component = null intent.setPackage(targetPackage) + val explicitComponent = ComponentResolver.resolveExplicitComponent(intent, targetPackage) + if (explicitComponent != null) { + intent.component = explicitComponent + intent.`package` = null + i(TAG, "Using explicit component to bypass resolver hijack: $explicitComponent") + } } } intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_GRANT_READ_URI_PERMISSION) @@ -83,7 +93,6 @@ object IntentRedirector { intent.`package` = null } } - normalizeAction(intent) } private fun normalizeAction(intent: Intent) { @@ -100,6 +109,7 @@ object IntentRedirector { private fun logRedirection(current: Intent, tag: String) { i(tag, "Intent redirection:") i(tag, "- Target package: ${current.`package` ?: ""}") + i(tag, "- Target component: ${current.component ?: ""}") i(tag, "- Intent action: ${current.action}") i(tag, "- Intent extras: ${IntentAnalyzer.formatExtras(current)}") if (current.action == ACTION_DELETE || current.action == ACTION_UNINSTALL_PACKAGE) { diff --git a/app/src/main/java/io/github/chimio/inxlocker/util/PrefsProvider.kt b/app/src/main/java/io/github/chimio/inxlocker/util/PrefsProvider.kt index 1914356..f953402 100644 --- a/app/src/main/java/io/github/chimio/inxlocker/util/PrefsProvider.kt +++ b/app/src/main/java/io/github/chimio/inxlocker/util/PrefsProvider.kt @@ -46,6 +46,11 @@ object PrefsProvider { cache.remove(it) } syncField(it, newValue) + if (it == KEY_SELECTED_INSTALLER_PACKAGE || it == KEY_SELECTED_UNINSTALLER_PACKAGE || + it == KEY_FORCED_INSTALLER_COMPONENTS + ) { + ComponentResolver.invalidate() + } d(TAG, "Config updated: $it = $newValue") } }