From 8f0cf812dc92de6a8cdcb2e9e25f809e0614f2be Mon Sep 17 00:00:00 2001
From: XcBc <77261372+Ashurinnn@users.noreply.github.com>
Date: Fri, 16 Oct 2026 05:35:06 +0100
Subject: [PATCH] =?UTF-8?q?feat(packageinstaller):=20=E5=AE=9E=E9=AA=8C?=
=?UTF-8?q?=E6=80=A7=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=96=B0=E5=A2=9E=E5=BA=94?=
=?UTF-8?q?=E7=94=A8=E5=8C=85=E7=AE=A1=E7=90=86=E7=BB=84=E4=BB=B6=E5=87=80?=
=?UTF-8?q?=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 1 +
app/src/main/AndroidManifest.xml | 13 +
.../InstallerDiagnosticCard.kt | 190 +++++
.../InstallerDiagnosticReceiver.kt | 39 +
.../InstallerDiagnosticStore.kt | 68 ++
.../InstallerDiagnosticValidation.kt | 40 +
.../packageinstaller/InstallerExperiment.kt | 45 ++
.../PackageInstallerActivity.kt | 59 ++
.../PackageInstallerOptions.kt | 50 ++
.../ui/screen/about/LicensePage.kt | 2 +-
.../screen/features/ExperimentalActivity.kt | 4 +-
.../ui/screen/features/FeaturesPage.kt | 5 +-
.../res/values-en/installer_experiment.xml | 37 +
.../main/res/values/installer_experiment.xml | 37 +
.../InstallerDiagnosticValidationTest.kt | 72 ++
changelog.md | 1 +
docs/CUSTOMIZE.md | 2 +
.../packageinstaller/EXPERIMENT.md | 36 +
.../hook/base/HookEntryRegistry.kt | 2 +
.../packageinstaller/InstallerBusinessHook.kt | 385 ++++++++++
.../packageinstaller/InstallerCodeIdentity.kt | 32 +
.../packageinstaller/InstallerContract.kt | 44 ++
.../InstallerDeoptimization.kt | 44 ++
.../packageinstaller/InstallerDexKitIndex.kt | 177 +++++
.../packageinstaller/InstallerDexModel.kt | 82 +++
.../InstallerDiagnosticContract.kt | 21 +
.../InstallerDiagnosticReporter.kt | 125 ++++
.../InstallerDiagnosticSnapshot.kt | 28 +
.../InstallerDiscoveryProbe.kt | 42 ++
.../InstallerGuardBannerRules.kt | 182 +++++
.../packageinstaller/InstallerHitLedger.kt | 36 +
.../packageinstaller/InstallerLocalRules.kt | 290 ++++++++
.../InstallerMethodSnapshots.kt | 33 +
.../packageinstaller/InstallerNetworkRules.kt | 568 ++++++++++++++
.../InstallerNullReturnProof.kt | 560 ++++++++++++++
.../InstallerPromotionRules.kt | 386 ++++++++++
.../packageinstaller/InstallerReflection.kt | 33 +
.../packageinstaller/PackageInstallerKeys.kt | 14 +
.../packageinstaller/PackageInstallerLoad.kt | 63 ++
.../main/resources/META-INF/xposed/scope.list | 1 +
.../InstallerAdapterMaterializationTest.kt | 138 ++++
.../InstallerCodeIdentityTest.kt | 73 ++
.../packageinstaller/InstallerContractTest.kt | 84 +++
.../InstallerDiagnosticSnapshotTest.kt | 54 ++
.../InstallerGuardBannerRulesTest.kt | 277 +++++++
.../InstallerHitLedgerTest.kt | 76 ++
.../InstallerLocalRulesTest.kt | 433 +++++++++++
.../InstallerNetworkRulesTest.kt | 633 ++++++++++++++++
.../InstallerNullReturnProofTest.kt | 210 ++++++
.../InstallerPromotionRulesTest.kt | 692 ++++++++++++++++++
50 files changed, 6516 insertions(+), 3 deletions(-)
create mode 100644 app/src/main/java/cn/ianzb/hyperrefine/experimental/packageinstaller/InstallerDiagnosticCard.kt
create mode 100644 app/src/main/java/cn/ianzb/hyperrefine/experimental/packageinstaller/InstallerDiagnosticReceiver.kt
create mode 100644 app/src/main/java/cn/ianzb/hyperrefine/experimental/packageinstaller/InstallerDiagnosticStore.kt
create mode 100644 app/src/main/java/cn/ianzb/hyperrefine/experimental/packageinstaller/InstallerDiagnosticValidation.kt
create mode 100644 app/src/main/java/cn/ianzb/hyperrefine/experimental/packageinstaller/InstallerExperiment.kt
create mode 100644 app/src/main/java/cn/ianzb/hyperrefine/experimental/packageinstaller/PackageInstallerActivity.kt
create mode 100644 app/src/main/java/cn/ianzb/hyperrefine/experimental/packageinstaller/PackageInstallerOptions.kt
create mode 100644 app/src/main/res/values-en/installer_experiment.xml
create mode 100644 app/src/main/res/values/installer_experiment.xml
create mode 100644 app/src/test/java/cn/ianzb/hyperrefine/experimental/packageinstaller/InstallerDiagnosticValidationTest.kt
create mode 100644 docs/experimental/packageinstaller/EXPERIMENT.md
create mode 100644 hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerBusinessHook.kt
create mode 100644 hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerCodeIdentity.kt
create mode 100644 hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerContract.kt
create mode 100644 hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDeoptimization.kt
create mode 100644 hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDexKitIndex.kt
create mode 100644 hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDexModel.kt
create mode 100644 hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDiagnosticContract.kt
create mode 100644 hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDiagnosticReporter.kt
create mode 100644 hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDiagnosticSnapshot.kt
create mode 100644 hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDiscoveryProbe.kt
create mode 100644 hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerGuardBannerRules.kt
create mode 100644 hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerHitLedger.kt
create mode 100644 hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerLocalRules.kt
create mode 100644 hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerMethodSnapshots.kt
create mode 100644 hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerNetworkRules.kt
create mode 100644 hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerNullReturnProof.kt
create mode 100644 hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerPromotionRules.kt
create mode 100644 hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerReflection.kt
create mode 100644 hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/PackageInstallerKeys.kt
create mode 100644 hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/PackageInstallerLoad.kt
create mode 100644 hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerAdapterMaterializationTest.kt
create mode 100644 hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerCodeIdentityTest.kt
create mode 100644 hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerContractTest.kt
create mode 100644 hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDiagnosticSnapshotTest.kt
create mode 100644 hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerGuardBannerRulesTest.kt
create mode 100644 hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerHitLedgerTest.kt
create mode 100644 hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerLocalRulesTest.kt
create mode 100644 hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerNetworkRulesTest.kt
create mode 100644 hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerNullReturnProofTest.kt
create mode 100644 hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerPromotionRulesTest.kt
diff --git a/README.md b/README.md
index 0ca5cf3..fd2e048 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,7 @@
# 功能
- **通知**(目标 `com.android.systemui` / `com.android.settings` / `system`)— 功能 → 系统界面 → 通知,提供锁屏保留、锁屏显示、悬浮显示、受保护通知修改、重要程度、历史折叠与每应用数量限制七项独立开关,默认均关闭。使用和验证说明见 [通知功能说明](docs/NOTIFICATION_PORT.md)。
+- **应用包管理组件净化(实验性)**(目标 `com.miui.packageinstaller`)— 功能 → 实验性功能 → 应用包管理组件净化,提供推广、风险检测、云配置、安全守护提示、频繁安装检查与安装信息上传控制六项独立开关,默认关闭;保留诊断供反馈。功能代码与资源集中管理,使用现有基础扩展接口,可随整项提交撤回。使用范围与状态含义见 [应用包管理组件净化说明](docs/experimental/packageinstaller/EXPERIMENT.md)。
**模块功能**(目标应用 `com.android.systemui` / `com.miui.securitycenter`)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5d87c8b..ffa7e92 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -131,6 +131,10 @@
android:name=".ui.screen.features.SecurityCenterActivity"
android:exported="false"
android:theme="@style/Theme.HyperRefine" />
+
+
+
+
+
+
+
"${activity.packageName}/${activity.name}".take(384) } }
+ .distinct().sorted()
+ val chosen = resolved?.let { "${it.packageName}/${it.name}".take(384) } ?: "none"
+ "resolved=$chosen; candidates=${candidates.size}\n${candidates.take(12).joinToString("\n")}"
+}.getOrElse { "unavailable (${it.javaClass.simpleName})" }
+
+/** Read installed component metadata and resolve two fixed probe intents. Never launch or open the URI. */
+private fun collectInstallerProbe(context: Context): InstallerLocalProbe {
+ val pm = context.packageManager
+ val module = runCatching {
+ val info = pm.getPackageInfo(context.packageName, 0)
+ "${info.versionName?.take(128)} (${info.longVersionCode})"
+ }.getOrElse { "unavailable (${it.javaClass.simpleName})" }
+ val installer = runCatching { pm.getPackageInfo(Contract.TARGET_PACKAGE, 0) }
+ val version = installer.fold(
+ { "${it.versionName?.take(128)} (${it.longVersionCode})" },
+ { "unavailable (${it.javaClass.simpleName})" },
+ )
+ val identity = runCatching {
+ val info = pm.getApplicationInfo(Contract.TARGET_PACKAGE, 0)
+ val result = ZipFile(info.sourceDir).use { InstallerCodeIdentity().inspect(it) }
+ "readable=${result.readable}; diagnostic_only=true; splits=${info.splitSourceDirs?.size ?: 0}\n${result.diagnostic.take(4_096)}"
+ }.getOrElse { "unavailable (${it.javaClass.simpleName})" }
+ val localMask = runCatching {
+ val mask = PackageInstallerKeys.ALL.mapIndexed { index, key ->
+ if (PrefsStore.getBoolean(key, false)) 1 shl index else 0
+ }.fold(0, Int::or)
+ "0x${mask.toString(16).padStart(2, '0')}"
+ }.getOrElse { "unavailable (${it.javaClass.simpleName})" }
+ val remoteMask = runCatching {
+ val service = XposedServiceManager.getService() ?: return@runCatching "unavailable (service unbound)"
+ val prefs = service.getRemotePreferences(PrefsStore.REMOTE_GROUP)
+ val mask = PackageInstallerKeys.ALL.mapIndexed { index, key ->
+ if (prefs.getBoolean(PrefsStore.key(key), false)) 1 shl index else 0
+ }.fold(0, Int::or)
+ val present = PackageInstallerKeys.ALL.count { prefs.contains(PrefsStore.key(it)) }
+ "0x${mask.toString(16).padStart(2, '0')}; present=$present/6"
+ }.getOrElse { "unavailable (${it.javaClass.simpleName})" }
+ return InstallerLocalProbe(
+ System.currentTimeMillis(), InstallerDiagnosticStore.currentBoot(context), module, version, localMask, remoteMask, identity,
+ collectInstallerHandlers(pm, Intent.ACTION_VIEW), collectInstallerHandlers(pm, Intent.ACTION_INSTALL_PACKAGE),
+ )
+}
+
+@Composable
+internal fun InstallerDiagnosticCard() {
+ val context = LocalContext.current
+ val resources = LocalResources.current
+ var refreshToken by remember { mutableIntStateOf(0) }
+ var probe by remember { mutableStateOf(null) }
+ var loading by remember { mutableStateOf(false) }
+ val lastReport by InstallerDiagnosticStore.latest.collectAsState()
+ val serviceBound = XposedServiceManager.isActivated
+ val inScope = Contract.TARGET_PACKAGE in XposedServiceManager.scope
+ val remoteAttached = PrefsStore.isRemoteAttached()
+ val enabled = PackageInstallerKeys.ALL.count { ConfigState.bool(it, false) }
+
+ LaunchedEffect(refreshToken) {
+ loading = true
+ InstallerDiagnosticStore.reload(context)
+ XposedServiceManager.refreshScope()
+ probe = withContext(Dispatchers.IO) { collectInstallerProbe(context.applicationContext) }
+ loading = false
+ }
+
+ val text = buildString {
+ appendLine(resources.getString(R.string.installer_diag_build, probe?.moduleVersion ?: "…", Contract.BUILD_ID))
+ appendLine(resources.getString(R.string.installer_diag_service, serviceBound.toString(), remoteAttached.toString(), inScope.toString()))
+ appendLine(resources.getString(R.string.installer_diag_settings, enabled, Build.VERSION.SDK_INT))
+ probe?.let { local ->
+ appendLine(resources.getString(R.string.installer_diag_checked_at, formatDiagnosticTime(local.checkedAt), local.bootCount))
+ appendLine(resources.getString(R.string.installer_diag_masks, local.localMask, local.remoteMask))
+ appendLine(resources.getString(R.string.installer_diag_installer, local.installerVersion))
+ appendLine(resources.getString(R.string.installer_diag_identity, local.identity))
+ appendLine(resources.getString(R.string.installer_diag_view_handlers, local.viewHandlers))
+ appendLine(resources.getString(R.string.installer_diag_install_handlers, local.installHandlers))
+ }
+ val report = lastReport
+ if (report == null) {
+ appendLine(resources.getString(R.string.installer_diag_no_report))
+ } else {
+ appendLine(resources.getString(
+ R.string.installer_diag_latest_report,
+ formatDiagnosticTime(report.receivedAtMillis), report.sourcePid,
+ ((System.currentTimeMillis() - report.receivedAtMillis).coerceAtLeast(0) / 1_000L),
+ ))
+ report.trace.forEach { appendLine(it) }
+ appendLine(resources.getString(R.string.installer_diag_not_live))
+ }
+ append(resources.getString(R.string.installer_diag_boundary))
+ }.let { if (it.length <= 32_768) it else it.take(32_384) + "\n" + resources.getString(R.string.installer_diag_truncated) }
+
+ Card(modifier = Modifier.padding(horizontal = 12.dp).padding(bottom = 12.dp)) {
+ Column(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ MiuixText(text = stringResource(R.string.installer_diag_title), style = MiuixTheme.textStyles.body2)
+ SelectionContainer {
+ MiuixText(text = text, style = MiuixTheme.textStyles.footnote2, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ TextButton(
+ text = stringResource(if (loading) R.string.installer_diag_loading else R.string.installer_diag_refresh),
+ onClick = { if (!loading) refreshToken++ },
+ minWidth = 0.dp,
+ )
+ TextButton(
+ text = stringResource(R.string.installer_diag_copy),
+ onClick = {
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ clipboard.setPrimaryClip(ClipData.newPlainText("HyperRefine installer diagnostics", text))
+ Toast.makeText(context, R.string.installer_diag_copied, Toast.LENGTH_SHORT).show()
+ },
+ minWidth = 0.dp,
+ )
+ TextButton(
+ text = stringResource(R.string.installer_diag_clear),
+ onClick = { InstallerDiagnosticStore.clear(context) },
+ minWidth = 0.dp,
+ )
+ }
+ }
+ }
+}
+
+private fun formatDiagnosticTime(value: Long): String =
+ DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM).format(Date(value))
diff --git a/app/src/main/java/cn/ianzb/hyperrefine/experimental/packageinstaller/InstallerDiagnosticReceiver.kt b/app/src/main/java/cn/ianzb/hyperrefine/experimental/packageinstaller/InstallerDiagnosticReceiver.kt
new file mode 100644
index 0000000..8356656
--- /dev/null
+++ b/app/src/main/java/cn/ianzb/hyperrefine/experimental/packageinstaller/InstallerDiagnosticReceiver.kt
@@ -0,0 +1,39 @@
+package cn.ianzb.hyperrefine.experimental.packageinstaller
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.os.SystemClock
+import cn.ianzb.hyperrefine.hook.packageinstaller.InstallerDiagnosticContract as Contract
+
+/** Only the installed installer's real UID may submit a current-build, current-boot report. */
+class InstallerDiagnosticReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action != Contract.ACTION) return
+ val sender = runCatching { sentFromUid }.getOrDefault(-1)
+ val expectedUid = runCatching {
+ context.packageManager.getApplicationInfo(Contract.TARGET_PACKAGE, 0).uid
+ }.getOrNull()
+ if (sender < 0 || expectedUid == null || sender != expectedUid) return
+ // Untrusted extras may have wrong parcel/value types. Reject the whole report.
+ runCatching {
+ val report = InstallerDiagnosticValidation.Envelope(
+ buildId = intent.getStringExtra(Contract.EXTRA_BUILD_ID),
+ moduleVersion = intent.getLongExtra(Contract.EXTRA_MODULE_VERSION, 0L),
+ bootCount = intent.getIntExtra(Contract.EXTRA_BOOT_COUNT, -1),
+ sourcePackage = intent.getStringExtra(Contract.EXTRA_SOURCE_PACKAGE),
+ sourceProcess = intent.getStringExtra(Contract.EXTRA_SOURCE_PROCESS),
+ sourcePid = intent.getIntExtra(Contract.EXTRA_SOURCE_PID, 0),
+ generatedElapsed = intent.getLongExtra(Contract.EXTRA_GENERATED_ELAPSED, -1L),
+ trace = intent.getStringArrayListExtra(Contract.EXTRA_TRACE) ?: return,
+ )
+ if (InstallerDiagnosticValidation.accepts(
+ report, sender, expectedUid, InstallerDiagnosticStore.currentVersion(context),
+ InstallerDiagnosticStore.currentBoot(context), SystemClock.elapsedRealtime(),
+ )
+ ) {
+ InstallerDiagnosticStore.record(context, report)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/cn/ianzb/hyperrefine/experimental/packageinstaller/InstallerDiagnosticStore.kt b/app/src/main/java/cn/ianzb/hyperrefine/experimental/packageinstaller/InstallerDiagnosticStore.kt
new file mode 100644
index 0000000..497f08e
--- /dev/null
+++ b/app/src/main/java/cn/ianzb/hyperrefine/experimental/packageinstaller/InstallerDiagnosticStore.kt
@@ -0,0 +1,68 @@
+package cn.ianzb.hyperrefine.experimental.packageinstaller
+
+import android.content.Context
+import android.provider.Settings
+import androidx.core.content.edit
+import cn.ianzb.hyperrefine.hook.packageinstaller.InstallerDiagnosticContract as Contract
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import org.json.JSONArray
+
+/** A single bounded report in module-private data; independent of HookStatusStore. */
+internal object InstallerDiagnosticStore {
+ data class Report(val receivedAtMillis: Long, val sourcePid: Int, val trace: List)
+
+ private const val PREFS = "installer_diagnostic_r3"
+ private val mutableLatest = MutableStateFlow(null)
+ val latest: StateFlow = mutableLatest.asStateFlow()
+
+ fun currentBoot(context: Context): Int = runCatching {
+ Settings.Global.getInt(context.contentResolver, Settings.Global.BOOT_COUNT, -1)
+ }.getOrDefault(-1)
+
+ fun currentVersion(context: Context): Long = runCatching {
+ context.packageManager.getPackageInfo(context.packageName, 0).longVersionCode
+ }.getOrDefault(0L)
+
+ fun reload(context: Context) {
+ val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ val boot = currentBoot(context)
+ val version = currentVersion(context)
+ val scoped = boot >= 0 && version > 0 &&
+ prefs.getInt("boot", -1) == boot && prefs.getLong("version", 0) == version &&
+ prefs.getString("build", null) == Contract.BUILD_ID
+ if (!scoped) {
+ prefs.edit { clear() }
+ mutableLatest.value = null
+ return
+ }
+ mutableLatest.value = runCatching {
+ val json = JSONArray(prefs.getString("trace", "[]"))
+ check(json.length() in 1..Contract.MAX_TRACE_LINES)
+ val trace = List(json.length()) { json.getString(it) }
+ check(InstallerDiagnosticValidation.validTrace(trace))
+ Report(prefs.getLong("received", 0), prefs.getInt("pid", 0), trace)
+ }.getOrNull()
+ if (mutableLatest.value == null) prefs.edit { clear() }
+ }
+
+ fun record(context: Context, report: InstallerDiagnosticValidation.Envelope) {
+ val received = System.currentTimeMillis()
+ context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit {
+ clear()
+ putInt("boot", report.bootCount)
+ putLong("version", report.moduleVersion)
+ putString("build", report.buildId)
+ putLong("received", received)
+ putInt("pid", report.sourcePid)
+ putString("trace", JSONArray(report.trace).toString())
+ }
+ mutableLatest.value = Report(received, report.sourcePid, report.trace.toList())
+ }
+
+ fun clear(context: Context) {
+ context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit { clear() }
+ mutableLatest.value = null
+ }
+}
diff --git a/app/src/main/java/cn/ianzb/hyperrefine/experimental/packageinstaller/InstallerDiagnosticValidation.kt b/app/src/main/java/cn/ianzb/hyperrefine/experimental/packageinstaller/InstallerDiagnosticValidation.kt
new file mode 100644
index 0000000..5533987
--- /dev/null
+++ b/app/src/main/java/cn/ianzb/hyperrefine/experimental/packageinstaller/InstallerDiagnosticValidation.kt
@@ -0,0 +1,40 @@
+package cn.ianzb.hyperrefine.experimental.packageinstaller
+
+import cn.ianzb.hyperrefine.hook.packageinstaller.InstallerDiagnosticContract as Contract
+
+/** Pure validation shared by the receiver and host unit tests. No anonymous-sender fallback. */
+internal object InstallerDiagnosticValidation {
+ data class Envelope(
+ val buildId: String?,
+ val moduleVersion: Long,
+ val bootCount: Int,
+ val sourcePackage: String?,
+ val sourceProcess: String?,
+ val sourcePid: Int,
+ val generatedElapsed: Long,
+ val trace: List,
+ )
+
+ fun accepts(
+ report: Envelope,
+ senderUid: Int,
+ installerUid: Int?,
+ currentVersion: Long,
+ currentBoot: Int,
+ nowElapsed: Long,
+ ): Boolean {
+ if (senderUid < 0 || installerUid == null || senderUid != installerUid) return false
+ if (report.sourcePackage != Contract.TARGET_PACKAGE || report.sourceProcess != Contract.TARGET_PACKAGE) return false
+ if (report.buildId != Contract.BUILD_ID || currentVersion <= 0 || report.moduleVersion != currentVersion) return false
+ if (currentBoot < 0 || report.bootCount != currentBoot || report.sourcePid <= 0) return false
+ if (report.generatedElapsed < 0 || report.generatedElapsed > nowElapsed + 2_000L) return false
+ if (nowElapsed - report.generatedElapsed > Contract.MAX_REPORT_AGE_MS) return false
+ return validTrace(report.trace)
+ }
+
+ fun validTrace(trace: List): Boolean =
+ trace.isNotEmpty() && trace.size <= Contract.MAX_TRACE_LINES && trace.all { line ->
+ line.isNotBlank() && line.length <= Contract.MAX_LINE_CHARS &&
+ line.none { it < ' ' || it == '\u007f' }
+ }
+}
diff --git a/app/src/main/java/cn/ianzb/hyperrefine/experimental/packageinstaller/InstallerExperiment.kt b/app/src/main/java/cn/ianzb/hyperrefine/experimental/packageinstaller/InstallerExperiment.kt
new file mode 100644
index 0000000..b2c8fa5
--- /dev/null
+++ b/app/src/main/java/cn/ianzb/hyperrefine/experimental/packageinstaller/InstallerExperiment.kt
@@ -0,0 +1,45 @@
+package cn.ianzb.hyperrefine.experimental.packageinstaller
+
+import android.content.Context
+import android.content.Intent
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+import cn.ianzb.hyperrefine.R
+import cn.ianzb.hyperrefine.prefs.OptionSpec
+import cn.ianzb.hyperrefine.prefs.OptionType
+import cn.ianzb.hyperrefine.ui.component.pref.HookOptionView
+import cn.ianzb.hyperrefine.ui.component.pref.HookSection
+import cn.ianzb.hyperrefine.ui.component.pref.HookSectionCard
+import cn.ianzb.hyperrefine.ui.component.pref.HookSubPage
+
+/** The app's only integration surface for this removable experimental feature. */
+internal object InstallerExperiment {
+ private const val ENTRY_KEY = "feature_packageinstaller"
+
+ fun entrySpec() = OptionSpec(
+ key = ENTRY_KEY,
+ type = OptionType.ARROW,
+ titleRes = R.string.section_packageinstaller,
+ )
+
+ fun registrySpecs(): List = listOf(entrySpec()) + packageInstallerSpecs()
+
+ fun searchPage(context: Context) = HookSubPage(
+ titleRes = R.string.section_packageinstaller,
+ specs = packageInstallerSpecs(),
+ onOpen = { open(context) },
+ )
+
+ @Composable
+ fun EntryCard() {
+ val context = LocalContext.current
+ val entry = entrySpec()
+ HookSectionCard(HookSection(specs = listOf(entry))) {
+ HookOptionView(spec = entry, onArrowClick = { open(context) })
+ }
+ }
+
+ private fun open(context: Context) {
+ context.startActivity(Intent(context, PackageInstallerActivity::class.java))
+ }
+}
diff --git a/app/src/main/java/cn/ianzb/hyperrefine/experimental/packageinstaller/PackageInstallerActivity.kt b/app/src/main/java/cn/ianzb/hyperrefine/experimental/packageinstaller/PackageInstallerActivity.kt
new file mode 100644
index 0000000..1884ce8
--- /dev/null
+++ b/app/src/main/java/cn/ianzb/hyperrefine/experimental/packageinstaller/PackageInstallerActivity.kt
@@ -0,0 +1,59 @@
+package cn.ianzb.hyperrefine.experimental.packageinstaller
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import cn.ianzb.hyperrefine.R
+import cn.ianzb.hyperrefine.hook.packageinstaller.PackageInstallerKeys
+import cn.ianzb.hyperrefine.ui.component.QuickActionsAction
+import cn.ianzb.hyperrefine.ui.component.pref.HookOptionView
+import cn.ianzb.hyperrefine.ui.component.pref.HookSection
+import cn.ianzb.hyperrefine.ui.component.pref.HookSectionCard
+import cn.ianzb.hyperrefine.ui.screen.subpage.BaseSubPageActivity
+import cn.ianzb.hyperrefine.ui.util.LocalSubPageScrollBehavior
+import cn.ianzb.hyperrefine.ui.util.pageScrollModifiers
+import top.yukonga.miuix.kmp.basic.BasicComponent
+
+class PackageInstallerActivity : BaseSubPageActivity() {
+
+ override val titleRes: Int = R.string.section_packageinstaller
+
+ override val topBarActions: (@Composable () -> Unit)? =
+ { QuickActionsAction(listOf(PackageInstallerKeys.PACKAGE_NAME)) }
+
+ @Composable
+ override fun SubPageContent(isBlurEnabled: Boolean, contentPadding: PaddingValues) {
+ val scrollBehavior = LocalSubPageScrollBehavior.current
+ val section = HookSection(specs = packageInstallerSpecs())
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .then(
+ if (scrollBehavior != null) {
+ Modifier.pageScrollModifiers(
+ showTopAppBar = true,
+ topAppBarScrollBehavior = scrollBehavior,
+ )
+ } else {
+ Modifier
+ }
+ ),
+ contentPadding = contentPadding,
+ ) {
+ item {
+ HookSectionCard(HookSection(specs = emptyList())) {
+ BasicComponent(title = stringResource(R.string.installer_experiment_notice))
+ }
+ }
+ item {
+ HookSectionCard(section) {
+ section.specs.forEach { HookOptionView(it) }
+ }
+ }
+ item { InstallerDiagnosticCard() }
+ }
+ }
+}
diff --git a/app/src/main/java/cn/ianzb/hyperrefine/experimental/packageinstaller/PackageInstallerOptions.kt b/app/src/main/java/cn/ianzb/hyperrefine/experimental/packageinstaller/PackageInstallerOptions.kt
new file mode 100644
index 0000000..2aee8a6
--- /dev/null
+++ b/app/src/main/java/cn/ianzb/hyperrefine/experimental/packageinstaller/PackageInstallerOptions.kt
@@ -0,0 +1,50 @@
+package cn.ianzb.hyperrefine.experimental.packageinstaller
+
+import cn.ianzb.hyperrefine.R
+import cn.ianzb.hyperrefine.hook.packageinstaller.PackageInstallerKeys
+import cn.ianzb.hyperrefine.prefs.OptionSpec
+import cn.ianzb.hyperrefine.prefs.OptionType
+
+/** Six independent installer options, shared by the page and its search entry. */
+internal fun packageInstallerSpecs(): List = listOf(
+ installerSwitch(
+ PackageInstallerKeys.DISABLE_ADS,
+ R.string.packageinstaller_disable_ads,
+ R.string.packageinstaller_disable_ads_summary,
+ ),
+ installerSwitch(
+ PackageInstallerKeys.DISABLE_RISK,
+ R.string.packageinstaller_disable_risk,
+ R.string.packageinstaller_disable_risk_summary,
+ ),
+ installerSwitch(
+ PackageInstallerKeys.BLOCK_CLOUD,
+ R.string.packageinstaller_block_cloud,
+ R.string.packageinstaller_block_cloud_summary,
+ ),
+ installerSwitch(
+ PackageInstallerKeys.DISABLE_GUARD,
+ R.string.packageinstaller_disable_guard,
+ R.string.packageinstaller_disable_guard_summary,
+ ),
+ installerSwitch(
+ PackageInstallerKeys.DISABLE_FREQUENT_CHECK,
+ R.string.packageinstaller_disable_frequent_check,
+ R.string.packageinstaller_disable_frequent_check_summary,
+ ),
+ installerSwitch(
+ PackageInstallerKeys.BLOCK_UPLOAD,
+ R.string.packageinstaller_block_upload,
+ R.string.packageinstaller_block_upload_summary,
+ ),
+)
+
+private fun installerSwitch(key: String, titleRes: Int, summaryRes: Int) = OptionSpec(
+ key = key,
+ type = OptionType.SWITCH,
+ titleRes = titleRes,
+ summaryRes = summaryRes,
+ defaultBoolean = false,
+ targetPackages = listOf(PackageInstallerKeys.PACKAGE_NAME),
+ showStatus = true,
+)
diff --git a/app/src/main/java/cn/ianzb/hyperrefine/ui/screen/about/LicensePage.kt b/app/src/main/java/cn/ianzb/hyperrefine/ui/screen/about/LicensePage.kt
index af6ef3e..e6d39a9 100644
--- a/app/src/main/java/cn/ianzb/hyperrefine/ui/screen/about/LicensePage.kt
+++ b/app/src/main/java/cn/ianzb/hyperrefine/ui/screen/about/LicensePage.kt
@@ -197,7 +197,7 @@ private val licenseSections: List = listOf(
LibraryInfo(
"HyperCeiler",
"-",
- "AGPL-3.0,仅思路参考",
+ "AGPL-3.0,设备判定与应用包安装器六项功能参考;独立实现,未复用代码或资源",
"https://github.com/ReChronoRain/HyperCeiler",
),
LibraryInfo(
diff --git a/app/src/main/java/cn/ianzb/hyperrefine/ui/screen/features/ExperimentalActivity.kt b/app/src/main/java/cn/ianzb/hyperrefine/ui/screen/features/ExperimentalActivity.kt
index 2dfc531..5aae6dc 100644
--- a/app/src/main/java/cn/ianzb/hyperrefine/ui/screen/features/ExperimentalActivity.kt
+++ b/app/src/main/java/cn/ianzb/hyperrefine/ui/screen/features/ExperimentalActivity.kt
@@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import cn.ianzb.hyperrefine.R
+import cn.ianzb.hyperrefine.experimental.packageinstaller.InstallerExperiment
import cn.ianzb.hyperrefine.hook.weather.WeatherKeys
import cn.ianzb.hyperrefine.ui.component.QuickActionsAction
import cn.ianzb.hyperrefine.ui.component.pref.HookOptionView
@@ -20,7 +21,7 @@ import cn.ianzb.hyperrefine.ui.util.pageScrollModifiers
/**
* 「实验性功能」二级页。
*
- * 收纳仍在验证中的功能;目前包含「天气高级外观」。
+ * 收纳仍在验证中的功能。
*/
class ExperimentalActivity : BaseSubPageActivity() {
@@ -71,6 +72,7 @@ class ExperimentalActivity : BaseSubPageActivity() {
}
}
}
+ item { InstallerExperiment.EntryCard() }
}
}
}
diff --git a/app/src/main/java/cn/ianzb/hyperrefine/ui/screen/features/FeaturesPage.kt b/app/src/main/java/cn/ianzb/hyperrefine/ui/screen/features/FeaturesPage.kt
index 17eee87..4093986 100644
--- a/app/src/main/java/cn/ianzb/hyperrefine/ui/screen/features/FeaturesPage.kt
+++ b/app/src/main/java/cn/ianzb/hyperrefine/ui/screen/features/FeaturesPage.kt
@@ -9,6 +9,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import cn.ianzb.hyperrefine.R
+import cn.ianzb.hyperrefine.experimental.packageinstaller.InstallerExperiment
import cn.ianzb.hyperrefine.hook.assistant.AssistantKeys
import cn.ianzb.hyperrefine.hook.browser.BrowserKeys
import cn.ianzb.hyperrefine.hook.connect.ConnectKeys
@@ -212,7 +213,7 @@ fun FeaturesPageView(
),
HookSubPage(
titleRes = R.string.section_experimental,
- specs = listOf(specByKey(specs, KEY_WEATHER)),
+ specs = listOf(specByKey(specs, KEY_WEATHER), InstallerExperiment.entrySpec()),
onOpen = { context.startActivity(Intent(context, ExperimentalActivity::class.java)) },
subPages = listOf(
HookSubPage(
@@ -220,6 +221,7 @@ fun FeaturesPageView(
specs = weatherSpecs(specs),
onOpen = { context.startActivity(Intent(context, WeatherActivity::class.java)) },
),
+ InstallerExperiment.searchPage(context),
),
),
)
@@ -1230,6 +1232,7 @@ internal fun featureSpecs(): List {
specs += ccRadiusSpecs(systemUi)
specs += notificationFeatureSpecs()
specs += statusBarFeatureSpecs()
+ specs += InstallerExperiment.registrySpecs()
return specs
}
diff --git a/app/src/main/res/values-en/installer_experiment.xml b/app/src/main/res/values-en/installer_experiment.xml
new file mode 100644
index 0000000..83d645f
--- /dev/null
+++ b/app/src/main/res/values-en/installer_experiment.xml
@@ -0,0 +1,37 @@
+
+
+ Package installer cleanup
+ Experimental feature. After changing options, restart the package installer; changes take effect the next time it is invoked. Active/inactive status updates may be delayed. After invoking the package installer, return to this page to check the actual status. Enabling these features may delay installer startup.
+ Disable promotions
+ Control only advertising promotions in the package installer.
+ Disable risk checks
+ Block local and cloud risk checks in the installer while retaining Android signature and installation authorization checks.
+ Block cloud configuration
+ Intercept installer requests for cloud configuration. Existing cache is retained.
+ Disable security guard prompts
+ Control only installer security guard prompts. This may not take effect.
+ Disable frequent installation checks
+ Prevent the installer from checking information about frequently installed apps.
+ Block installation information uploads
+ Prevent the installer from automatically uploading information about apps being installed. This also affects the corresponding cloud risk assessment results, additional verification and promotions.
+ Installer capability diagnostics (r9)
+ Module: %1$s · %2$s
+ API service bound: %1$s; remote preferences attached: %2$s; installer scope: %3$s
+ Current local switches: %1$d/6; device SDK: %2$d
+ Local read-only check: %1$s; boot count: %2$d (-1 is unknown)
+ Six-setting masks at check time: local %1$s / remote %2$s (page order, first option is bit zero)
+ Installed installer version (PackageManager): %1$s
+ Installed installer primary DEX fingerprints (diagnostics only, not compatibility): %1$s
+ VIEW resolved component and candidates: %1$s
+ INSTALL_PACKAGE resolved component and candidates: %1$s
+ No trusted installer report for this build and boot. Diagnostics begin when this experimental feature enters its Load. The installer may not have started, the host may have skipped it earlier, or report delivery/validation may have failed. This alone does not prove missing injection.
+ Last report received: %1$s; PID: %2$d; age when displayed (s): %3$d
+ This is the last report snapshot, not proof that the installer is still running or all six features work.
+ Refresh queries components and fixed probe intents without launching the installer or reading its input. Partial means some required groups are registered; each option status confirms registration of its required groups only. Display_only hooks are optional and do not count toward 6/6. Hits show callback observations and replacements; completion counts successful asynchronous callbacks. Zero does not prove the original never ran. Counts are from the last snapshot. Diagnostics start at this feature Load and cannot report earlier host skip reasons. Clear removes only this module diagnostic record.
+ Checking…
+ Refresh
+ Copy
+ Clear
+ Diagnostics copied
+ The diagnostic length limit was reached; the remaining content was omitted.
+
diff --git a/app/src/main/res/values/installer_experiment.xml b/app/src/main/res/values/installer_experiment.xml
new file mode 100644
index 0000000..d584da1
--- /dev/null
+++ b/app/src/main/res/values/installer_experiment.xml
@@ -0,0 +1,37 @@
+
+
+ 应用包管理组件净化
+ 实验性功能,更改后需重启应用包管理组件,在下次调用应用包管理组件时生效。已生效/未生效状态可能有延迟,在调用应用包管理组件后回到本页查看实际生效状态。 开启功能后可能会造成安装器启动延迟。
+ 禁用推广
+ 仅控制应用安装器的广告推广。
+ 禁用风险检测
+ 阻断安装器本地和云端的风险检测,保留Android签名和安装授权校验。
+ 阻断云端配置下发
+ 拦截安装器从云端获取配置,已有缓存不会被清除。
+ 禁用安全守护提示
+ 仅控制安装器安全守护提示,可能不生效。
+ 禁用频繁安装应用信息检查
+ 阻止安装器检查频繁安装的应用信息。
+ 禁止安装时上传应用信息
+ 阻止安装器自动上传安装应用的信息,同时会影响相应的云风险判定结果,附加验证和推广。
+ 安装器业务匹配诊断(r9)
+ 模块:%1$s · %2$s
+ API 服务已绑定:%1$s;远程偏好已连接:%2$s;安装器作用域:%3$s
+ 当前本地开关:%1$d/6;设备 SDK:%2$d
+ 本机只读检查时间:%1$s;开机号:%2$d(-1 为未知)
+ 检查时六项配置位图:本地 %1$s / 远程 %2$s(按页面顺序,首项为最低位)
+ 已安装安装器版本(PackageManager):%1$s
+ 已安装安装器主 DEX 指纹(仅诊断,不决定功能兼容):%1$s
+ VIEW 默认处理器与候选:%1$s
+ INSTALL_PACKAGE 默认处理器与候选:%1$s
+ 当前构建及本次开机尚未收到可信安装器回报。诊断从本实验功能的 Load 进入后开始;尚未启动、在宿主早期被跳过或发送/校验失败均可能无回报,不能单凭此处认定未注入。
+ 最后回报接收时间:%1$s;PID:%2$d;本次显示时年龄:%3$d 秒
+ 以上为最后一份回报快照,不代表安装器当前仍在运行或六项业务均已生效。
+ 刷新仅查询组件及固定探测 Intent,不启动安装器、不读取待安装文件。partial 表示必需组部分挂载;六项状态仅标记必需组完整注册。display_only 是可选推广展示入口,其失败不计入 6/6。hits 的 callback_calls/blocked 为回调观测/实际替换次数;completion 为异步回调成功执行次数。零次不证明原方法未执行;计数为最后快照。诊断从本功能 Load 开始,不报告此前宿主跳过的原因。清除仅删除模块自己的诊断记录。
+ 检查中…
+ 刷新诊断
+ 复制诊断
+ 清除记录
+ 诊断已复制
+ 诊断达到长度上限,后续内容已省略。
+
diff --git a/app/src/test/java/cn/ianzb/hyperrefine/experimental/packageinstaller/InstallerDiagnosticValidationTest.kt b/app/src/test/java/cn/ianzb/hyperrefine/experimental/packageinstaller/InstallerDiagnosticValidationTest.kt
new file mode 100644
index 0000000..fe64cef
--- /dev/null
+++ b/app/src/test/java/cn/ianzb/hyperrefine/experimental/packageinstaller/InstallerDiagnosticValidationTest.kt
@@ -0,0 +1,72 @@
+package cn.ianzb.hyperrefine.experimental.packageinstaller
+
+import cn.ianzb.hyperrefine.hook.packageinstaller.InstallerDiagnosticContract as Contract
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class InstallerDiagnosticValidationTest {
+ private val good = InstallerDiagnosticValidation.Envelope(
+ Contract.BUILD_ID, 22L, 8, Contract.TARGET_PACKAGE, Contract.TARGET_PACKAGE,
+ 1234, 900_000L, listOf("entry sdk=37 enabled=6/6"),
+ )
+
+ private fun accept(
+ report: InstallerDiagnosticValidation.Envelope = good,
+ sender: Int = 10123,
+ expectedUid: Int? = 10123,
+ version: Long = 22L,
+ boot: Int = 8,
+ now: Long = 1_000_000L,
+ ) = InstallerDiagnosticValidation.accepts(report, sender, expectedUid, version, boot, now)
+
+ @Test fun acceptsCurrentTrustedReport() {
+ assertTrue(accept())
+ assertFalse(accept(good.copy(sourcePid = 0)))
+ }
+
+ @Test fun rejectsUnknownOrWrongSenderEvenWithCorrectPackageExtras() {
+ assertFalse(accept(sender = -1))
+ assertFalse(accept(sender = 10124))
+ assertFalse(accept(expectedUid = null))
+ }
+
+ @Test fun rejectsOtherPackagesAndSecondaryProcesses() {
+ assertFalse(accept(good.copy(sourcePackage = "android")))
+ assertFalse(accept(good.copy(sourceProcess = Contract.TARGET_PACKAGE + ":worker")))
+ assertFalse(accept(good.copy(sourcePackage = null)))
+ }
+
+ @Test fun rejectsOldBuildOrWrongVersion() {
+ assertFalse(accept(good.copy(buildId = "installer-r2")))
+ assertFalse(accept(good.copy(moduleVersion = 21L)))
+ assertFalse(accept(version = 0L))
+ }
+
+ @Test fun rejectsOtherBootOrUnknownCurrentBoot() {
+ assertFalse(accept(good.copy(bootCount = 7)))
+ assertFalse(accept(boot = -1))
+ }
+
+ @Test fun boundsReportAgeAndRejectsImpossibleTimestamps() {
+ assertTrue(accept(good.copy(generatedElapsed = 1_000_000L - Contract.MAX_REPORT_AGE_MS)))
+ assertFalse(accept(good.copy(generatedElapsed = 1_000_000L - Contract.MAX_REPORT_AGE_MS - 1L)))
+ assertFalse(accept(good.copy(generatedElapsed = 1_002_001L)))
+ assertFalse(accept(good.copy(generatedElapsed = -1L)))
+ assertFalse(accept(good.copy(sourcePid = -1)))
+ }
+
+ @Test fun rejectsEmptyOrOversizeTraceWithoutTruncatingTrustedMeaning() {
+ assertFalse(accept(good.copy(trace = emptyList())))
+ assertFalse(accept(good.copy(trace = List(Contract.MAX_TRACE_LINES + 1) { "entry" })))
+ assertFalse(accept(good.copy(trace = listOf("x".repeat(Contract.MAX_LINE_CHARS + 1)))))
+ assertTrue(accept(good.copy(trace = List(Contract.MAX_TRACE_LINES) { "x".repeat(Contract.MAX_LINE_CHARS) })))
+ }
+
+ @Test fun rejectsMultilineAndControlCharacterInjection() {
+ assertFalse(accept(good.copy(trace = listOf("entry\nforged result"))))
+ assertFalse(accept(good.copy(trace = listOf("entry\u0000"))))
+ assertFalse(accept(good.copy(trace = listOf("entry\u007f"))))
+ assertFalse(accept(good.copy(trace = listOf(" "))))
+ }
+}
diff --git a/changelog.md b/changelog.md
index 0b69b4d..38d3615 100644
--- a/changelog.md
+++ b/changelog.md
@@ -11,6 +11,7 @@
- **负一屏 · 打开应用动画与过渡遮罩重绘**(目标 `com.miui.personalassistant`):**应用打开动画**让负一屏点开的应用从图标位置缩放展开(options 逐级剥离重试,失败回退框架动画);**过渡遮罩重绘**修复过渡中遮罩显示异常内容。
- **负一屏 · 返回缩回动画**(目标 `com.miui.personalassistant`):关闭应用回到负一屏时,部件快照从全屏缩回原位,接近末尾渐隐;需先开启「应用打开动画」。
- **通知**(目标 `com.android.systemui` / `com.android.settings` / `system`):新增「系统界面 → 通知」页与七项开关(锁屏保留、锁屏显示、悬浮资格、受保护通知修改、重要程度、历史折叠、每应用 10 条限制),默认关闭;涉及系统框架的修改需完整重启。说明见 [通知功能说明](docs/NOTIFICATION_PORT.md)。
+- **应用包管理组件净化(实验性)**(目标 `com.miui.packageinstaller`):在「功能 → 实验性功能」中新增推广、风险检测、云配置、安全守护提示、频繁安装检查与安装信息上传六项独立开关,默认关闭。提供挂载状态及可刷新、复制、清除的诊断;保留 Android 签名与安装授权校验,新增说明同步提供中英文。功能使用现有基础接口独立接入,未新增运行依赖、权限或原生库,可整体撤回。
### 修复
diff --git a/docs/CUSTOMIZE.md b/docs/CUSTOMIZE.md
index 77c2f08..acb5857 100644
--- a/docs/CUSTOMIZE.md
+++ b/docs/CUSTOMIZE.md
@@ -415,6 +415,8 @@ HookOptionsPage(
状态栏功能的参考来源也在该分组登记;仅借鉴思路,未引入参考模块或小米 SystemUI 的代码、图片及原生库。各项开关的配置、目标进程和双排堆叠信号实现见 [状态栏功能说明](STATUSBAR_PORT.md)。
+应用包管理组件净化的参考来源同样登记在应用内参考项目分组;六项功能独立实现,未复用参考模块或目标应用的代码、资源及原生库。实验性入口、状态含义和整项撤回方式见 [应用包管理组件净化说明](experimental/packageinstaller/EXPERIMENT.md)。
+
本模板的定位是**个人 Android 模块项目的脚手架模板**。具体参考与致谢清单统一维护在应用内「第三方许可证与致谢」页面(`LicensePage.kt` → `licenses_section_refs` 分组),衍生项目**可增不可删**。
> 文档不展开对具体模块的借鉴说明;复用任何第三方代码前须先核对许可证,无许可证 / 非开源项目只能借鉴思路并致谢,完整流程见 [模块开发工作流](WORKFLOW.md)。
diff --git a/docs/experimental/packageinstaller/EXPERIMENT.md b/docs/experimental/packageinstaller/EXPERIMENT.md
new file mode 100644
index 0000000..e3639d0
--- /dev/null
+++ b/docs/experimental/packageinstaller/EXPERIMENT.md
@@ -0,0 +1,36 @@
+# 应用包管理组件净化(实验性)
+
+入口:**功能 → 实验性功能 → 应用包管理组件净化**。目标为 `com.miui.packageinstaller`,六个开关独立配置、默认关闭,沿用模块既有的偏好同步、作用域申请和 Hook 状态机制。
+
+## 使用与范围
+
+将应用包管理组件加入模块作用域,启用所需选项后重启该组件进程;更改在下次调用组件时生效。状态回报可能延迟,调用安装器后可返回本页查看。启用功能可能增加安装器启动耗时,当前没有量化耗时数据。
+
+| 选项 | 行为 |
+| --- | --- |
+| 禁用推广 | 控制应用安装器的广告推广。 |
+| 禁用风险检测 | 阻断安装器本地和云端的风险检测,保留 Android 签名及安装授权校验。 |
+| 阻断云端配置下发 | 拦截安装器从云端获取配置,不清除已有缓存。 |
+| 禁用安全守护提示 | 控制安装器安全守护提示,部分场景可能不生效。 |
+| 禁用频繁安装应用信息检查 | 阻止安装器检查频繁安装的应用信息。 |
+| 禁止安装时上传应用信息 | 阻止已识别通道自动上传安装应用的信息,同时影响相应的云风险判定结果、附加验证和推广。 |
+
+当前环境检查限定 Android 17 / SDK 37、安装器主进程和无 split APK 的安装器。通过业务特征识别 Hook 入口,不将安装器版本号或 DEX 摘要作为兼容白名单。特征不明确时保留原行为并记录原因;这不保证所有设备、安装器构建或后续更新均兼容。
+
+功能快照已由用户在 HyperOS 4 / Android 17、安装器 `5.4.5.1.2-20260618P (54512)` 的环境确认实机测试通过。该反馈覆盖当前测试环境,未进行所有网络路径的抓包验证,也不代表所有组合和场景均已覆盖。
+
+## 状态与诊断
+
+开关下的“已生效”表示对应功能的必需 Hook 组完整注册,不等同于所有业务路径均已触发或所有请求均被阻断。可选的推广展示入口不计入六项完整挂载状态。
+
+页面保留刷新、复制、清除诊断。诊断区分业务识别、挂载、部分挂载、回调命中、实际替换、保留原行为及异步完成;计数为最后快照,零次不能证明原方法从未执行。诊断从本功能 Load 进入后开始,缺少回报不能单独证明模块未注入。
+
+接收端校验发送 UID、模块版本、构建标识、开机号、目标进程及时间;记录最多 64 行、每行 384 字符。刷新只进行组件和固定 Intent 查询,不启动安装器、不读取待安装文件。清除只删除本模块诊断记录,不清除安装器数据或云配置缓存。
+
+## 集成与撤回
+
+Hook 实现在 `hook/.../packageinstaller/`,页面、诊断接收与诊断记录存储在 `app/.../experimental/packageinstaller/`,中英文资源分别位于 `values/installer_experiment.xml` 和 `values-en/installer_experiment.xml`。规则与诊断测试位于对应测试包。
+
+app 侧通过 `InstallerExperiment` 接入配置注册、实验页入口及多级搜索;Hook 侧通过既有 `BaseLoad`、`BaseHook`、`HookEntryRegistry` 接入,只为安装器增加默认作用域。没有修改公共 Hook、偏好或状态接口,没有调用其他业务 Hook,没有新增运行依赖、权限、原生库或 Gradle 模块。
+
+关闭某项开关后,需要重启目标进程使更改生效。开发侧撤回时,应撤回整个功能提交,包括注册表、作用域、Manifest、页面接线、文档及致谢;只删除业务目录会遗留编译引用。宿主文件已有后续修改时,应逐段审查 `git revert` 的冲突,避免覆盖其他功能。撤回不需要重构宿主接口,也不通过全局解除 Hook 或清除目标数据实现。
diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/base/HookEntryRegistry.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/base/HookEntryRegistry.kt
index e33efe1..8022963 100644
--- a/hook/src/main/java/cn/ianzb/hyperrefine/hook/base/HookEntryRegistry.kt
+++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/base/HookEntryRegistry.kt
@@ -9,6 +9,7 @@ import cn.ianzb.hyperrefine.hook.misound.MiSoundLoad
import cn.ianzb.hyperrefine.hook.miuix.MiuixAppLoad
import cn.ianzb.hyperrefine.hook.notification.NotificationLoad
import cn.ianzb.hyperrefine.hook.passkey.PasskeyLoad
+import cn.ianzb.hyperrefine.hook.packageinstaller.PackageInstallerLoad
import cn.ianzb.hyperrefine.hook.securitycenter.SecurityCenterLoad
import cn.ianzb.hyperrefine.hook.systemui.SystemUiLoad
import cn.ianzb.hyperrefine.hook.voiceassist.VoiceAssistLoad
@@ -30,6 +31,7 @@ object HookEntryRegistry {
SystemUiLoad(),
NotificationLoad(),
SecurityCenterLoad(),
+ PackageInstallerLoad(),
MiLinkLoad(),
XiaomiMirrorLoad(),
MiSoundLoad(),
diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerBusinessHook.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerBusinessHook.kt
new file mode 100644
index 0000000..0a5b56c
--- /dev/null
+++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerBusinessHook.kt
@@ -0,0 +1,385 @@
+/*
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Feature references: HyperCeiler Contributors (2023-2026),
+ * ReChronoRain/HyperCeiler@861be0473847d5c020102191f2c2891acda5d628.
+ * Independent matching against ROM business logic; no proprietary implementation copied.
+ */
+package cn.ianzb.hyperrefine.hook.packageinstaller
+
+import android.os.Handler
+import android.os.Looper
+import android.annotation.SuppressLint
+import android.content.Context
+import android.view.View
+import cn.ianzb.hyperrefine.hook.base.BaseHook
+import cn.ianzb.hyperrefine.hook.rule.HookSkippedException
+import cn.ianzb.hyperrefine.hook.xposed.HookHelper
+import io.github.libxposed.api.XposedInterface.HookHandle
+import java.lang.reflect.Method
+import java.lang.reflect.Modifier
+import java.util.concurrent.ConcurrentHashMap
+
+/** Per-feature discovery, contracts and transactions; component hashes never decide compatibility. */
+internal class InstallerBusinessHook @JvmOverloads constructor(
+ private val enabled: List,
+ private val openIndex: (String) -> InstallerDexIndex = { InstallerDexKitIndex.open(it) },
+ private val bannerString: (String) -> String? = ::installedInstallerString,
+ private val adCardIdentity: (View) -> Boolean = ::installedAdCardIdentity,
+) : BaseHook() {
+ override val key = "packageinstaller_business"
+ private val installedOptions = mutableListOf()
+ override val keys: List get() = installedOptions.toList()
+ private val reported = ConcurrentHashMap.newKeySet()
+ private class Point(val id: String, val method: Method, val callers: List, val install: () -> HookHandle)
+ private class Group(val name: String, val points: List, val displayOnly: Boolean = false)
+ private class Plan(val option: String, val expectedGroups: Int, val groups: List)
+
+ override fun init() {
+ val plans = mutableListOf()
+ val resolved = mutableMapOf>()
+ val source = target.applicationInfo?.sourceDir ?: throw HookSkippedException("installer source unavailable")
+ val index = try { openIndex(source) } catch (failure: Throwable) {
+ InstallerDiagnosticReporter.record("discovery_unavailable type=${failure.javaClass.simpleName}")
+ throw failure
+ }
+ try {
+ val local = InstallerLocalRules(index)
+ val nullProof = InstallerNullReturnProof(source)
+ val network = InstallerNetworkRules(index) { method -> nullProof.hasNullReturn(method.descriptor) }
+ val promotion = InstallerPromotionRules(index)
+ val banner = InstallerGuardBannerRules(index)
+ fun resolveGroup(option: String, name: String, ids: List, displayOnly: Boolean = false): Group? =
+ try {
+ // Discover both completion styles even if the first one fails. Mounting remains
+ // atomic for the group; a matched sibling is never mistaken for a registered hook.
+ val outcomes = ids.map { id ->
+ resolved.getOrPut(id) {
+ runCatching {
+ val match = when (id) {
+ in LOCAL_POINTS -> local.resolve(id)
+ "ads_before", "ads_after", "ads_cancel" -> promotion.resolve(id)
+ "guard_banner" -> banner.resolve()
+ else -> network.resolve(id)
+ }
+ val callers = runCatching { InstallerDeoptimization.callers(index, match) }
+ .onFailure { InstallerDiagnosticReporter.record("caller_discovery_optional point=$id type=${it.javaClass.simpleName}") }
+ .getOrDefault(emptyList())
+ prepare(match, callers).also {
+ InstallerDiagnosticReporter.record("matched point=$id method=${match.method.descriptor}")
+ }
+ }
+ }
+ }
+ val points = outcomes.map { it.getOrThrow() }
+ check(points.map { it.method }.distinct().size == points.size) { "different business roles matched one method" }
+ Group(name, points, displayOnly)
+ } catch (failure: Throwable) {
+ val reason = if (failure is InstallerMatchException) failure.message else failure.javaClass.simpleName
+ InstallerDiagnosticReporter.record("match_failed option=$option group=$name reason=$reason")
+ HookHelper.log("PackageInstaller[r9]: capability unavailable $option/$name", failure)
+ null
+ }
+ enabled.forEach { option ->
+ val definitions = pointGroups(option)
+ val groups = definitions.mapNotNull { (name, ids) -> resolveGroup(option, name, ids) }.toMutableList()
+ if (option == PackageInstallerKeys.DISABLE_ADS) {
+ // Also suppress cached recommendation displays. These optional UI groups do
+ // not claim that layout requests stopped or complete the upload option.
+ resolveGroup(option, "display_only_before", listOf("ads_before"), displayOnly = true)?.let(groups::add)
+ resolveGroup(option, "display_only_after", listOf("ads_after"), displayOnly = true)?.let(groups::add)
+ resolveGroup(option, "display_only_cancel", listOf("ads_cancel"), displayOnly = true)?.let(groups::add)
+ }
+ plans += Plan(option, definitions.size, groups)
+ }
+ runCatching { nullProof.diagnosticLines() }
+ .onSuccess { it.forEach(InstallerDiagnosticReporter::record) }
+ .onFailure { InstallerDiagnosticReporter.record("probe layout_null_proof detail=unavailable") }
+ if (resolved.values.any { it.isFailure }) {
+ listOf(network::diagnosticLines, promotion::diagnosticLines).forEach { diagnostics ->
+ runCatching { diagnostics() }
+ .onSuccess { it.forEach(InstallerDiagnosticReporter::record) }
+ .onFailure { InstallerDiagnosticReporter.record("probe rule_details detail=${it.javaClass.simpleName}") }
+ }
+ runCatching { InstallerDiscoveryProbe.collect(index) }
+ .onSuccess { it.forEach(InstallerDiagnosticReporter::record) }
+ .onFailure { InstallerDiagnosticReporter.record("probe unavailable detail=${it.javaClass.simpleName}") }
+ }
+ } finally {
+ runCatching { index.close() }
+ .onFailure { InstallerDiagnosticReporter.record("discovery_close_failed type=${it.javaClass.simpleName}") }
+ }
+ // Discovery metadata is detached before the native bridge closes; callbacks never search DEX.
+ val installed = linkedMapOf()
+ var partialOptions = 0
+ plans.forEach { plan ->
+ val completed = mutableListOf()
+ val active = mutableListOf()
+ plan.groups.forEach { group ->
+ try {
+ group.points.forEach { check(installed[it.method]?.let { id -> id == it.id } != false) { "incompatible shared business roles" } }
+ val fresh = group.points.filterNot { it.method in installed }
+ InstallerContract.installAtomically(fresh.map { point ->
+ {
+ InstallerDeoptimization.prepare(point.id, point.callers, target.classLoader)
+ val handle = point.install()
+ val remove: () -> Unit = { handle.unhook() }
+ remove
+ }
+ })
+ fresh.forEach {
+ installed[it.method] = it.id
+ InstallerDiagnosticReporter.registered(it.id)
+ InstallerDiagnosticReporter.record("registered point=${it.id}")
+ }
+ active += group.name
+ if (!group.displayOnly) completed += group.name
+ } catch (failure: Throwable) {
+ InstallerDiagnosticReporter.record("installation_failed option=${plan.option} group=${group.name} type=${failure.javaClass.simpleName}")
+ HookHelper.log("PackageInstaller[r9]: installation failed ${plan.option}/${group.name}", failure)
+ }
+ }
+ if (completed.size == plan.expectedGroups) {
+ installedOptions += plan.option
+ InstallerDiagnosticReporter.record("installed option=${plan.option}")
+ } else if (active.isNotEmpty()) {
+ partialOptions++
+ InstallerDiagnosticReporter.record("partial option=${plan.option} groups=${completed.size}/${plan.expectedGroups} active=${active.joinToString()}; full_status=false")
+ }
+ }
+ val summary = "summary installed=${installedOptions.size}/${enabled.size} partial=$partialOptions points=${installed.values.sorted().joinToString()}"
+ InstallerDiagnosticReporter.record(summary)
+ HookHelper.log("PackageInstaller[r9]: $summary")
+ if (installed.isEmpty()) throw HookSkippedException("no verified installer capability installed")
+ }
+
+ private fun prepare(data: InstallerResolvedPoint, callers: List): Point {
+ val method = InstallerReflection.method(data.method, target.classLoader)
+ fun hit(event: InstallerHitLedger.Event) = InstallerDiagnosticReporter.hit(data.id, event)
+ fun point(install: () -> HookHandle) = Point(data.id, method, callers, install)
+ fun constant(value: Any?) = point {
+ HookHelper.hookBefore(method) { param ->
+ hit(InstallerHitLedger.Event.CALL)
+ param.setResultValue(value)
+ hit(InstallerHitLedger.Event.BLOCK)
+ reportOnce(data.id, "blocked ${data.id}")
+ }
+ }
+ return when (data.id) {
+ "risk_scan", "ads_before", "ads_after" -> { check(method.returnType == Boolean::class.javaPrimitiveType); constant(false) }
+ "frequent_check" -> { check(method.returnType == Int::class.javaPrimitiveType); constant(0) }
+ "layout_sync" -> { check(method.returnType == Any::class.java); constant(null) }
+ "cloud_v2", "avl_upload" -> { check(method.returnType == Void.TYPE); constant(null) }
+ "layout_async" -> {
+ check(method.returnType == Void.TYPE && method.parameterCount >= 2)
+ val callback = InstallerReflection.method(checkNotNull(data.callback), target.classLoader)
+ check(callback.returnType == Void.TYPE && callback.parameterCount == 1 &&
+ method.parameterTypes.last() == callback.declaringClass)
+ val main = Handler(Looper.getMainLooper())
+ point {
+ HookHelper.hookBefore(method) { param ->
+ hit(InstallerHitLedger.Event.CALL)
+ val receiver = param.args.last()
+ if (receiver == null || main.post {
+ try {
+ callback.invoke(receiver, null)
+ hit(InstallerHitLedger.Event.COMPLETE)
+ }
+ catch (failure: ReflectiveOperationException) {
+ hit(InstallerHitLedger.Event.FAULT)
+ reportOnce("layout_callback_failed", "callback failed (${failure.javaClass.simpleName})")
+ }
+ }
+ ) {
+ param.setResultValue(null)
+ hit(InstallerHitLedger.Event.BLOCK)
+ reportOnce(data.id, "blocked ${data.id}")
+ } else {
+ hit(InstallerHitLedger.Event.RETAIN)
+ reportOnce("layout_post_failed", "main queue rejected completion; original layout retained")
+ }
+ }
+ }
+ }
+ "intercept_check" -> {
+ val payload = InstallerReflection.type(checkNotNull(data.payloadClass), target.classLoader)
+ val success = InstallerReflection.type(checkNotNull(data.resultClass), target.classLoader)
+ val base = InstallerReflection.type(checkNotNull(data.resultBaseClass), target.classLoader)
+ val payloadConstructor = payload.getDeclaredConstructor().apply { isAccessible = true }
+ val successConstructor = success.getDeclaredConstructor(payload).apply { isAccessible = true }
+ check(base.isAssignableFrom(success))
+ check(success.isInstance(successConstructor.newInstance(payloadConstructor.newInstance())))
+ point {
+ HookHelper.hookBefore(method) { param ->
+ hit(InstallerHitLedger.Event.CALL)
+ try {
+ param.setResultValue(successConstructor.newInstance(payloadConstructor.newInstance()))
+ hit(InstallerHitLedger.Event.BLOCK)
+ reportOnce(data.id, "blocked ${data.id}")
+ } catch (failure: ReflectiveOperationException) {
+ hit(InstallerHitLedger.Event.FAULT)
+ hit(InstallerHitLedger.Event.RETAIN)
+ reportOnce("intercept_fallback", "typed result unavailable; original request retained (${failure.javaClass.simpleName})")
+ }
+ }
+ }
+ }
+ "cloud_v3" -> {
+ val fieldData = checkNotNull(data.singletonField)
+ val owner = InstallerReflection.type(fieldData.owner, target.classLoader)
+ val field = owner.getDeclaredField(fieldData.name).apply { isAccessible = true }
+ check(Modifier.isStatic(field.modifiers) && field.type == owner)
+ val unit = field.get(null)
+ check(owner.isInstance(unit) && method.returnType == Any::class.java)
+ constant(unit)
+ }
+ "guard_tip" -> {
+ val tip = InstallerReflection.type(checkNotNull(data.guardedItemClass), target.classLoader)
+ check(method.returnType == Boolean::class.javaPrimitiveType && method.parameterCount == 1 &&
+ method.parameterTypes.single().isAssignableFrom(tip))
+ point {
+ HookHelper.hookBefore(method) { param ->
+ hit(InstallerHitLedger.Event.CALL)
+ if (param.args[0]?.javaClass == tip) {
+ param.setResultValue(false)
+ hit(InstallerHitLedger.Event.BLOCK)
+ reportOnce(data.id, "blocked ${data.id}")
+ } else hit(InstallerHitLedger.Event.RETAIN)
+ }
+ }
+ }
+ "ads_cancel" -> {
+ check(method.returnType == Void.TYPE && method.parameterCount == 0)
+ val holderData = checkNotNull(data.holderField)
+ val owner = InstallerReflection.type(holderData.owner, target.classLoader)
+ val holder = owner.getDeclaredField(holderData.name).apply { isAccessible = true }
+ val container = InstallerReflection.method(checkNotNull(data.containerGetter), target.classLoader)
+ check(!Modifier.isStatic(holder.modifiers) && owner.isAssignableFrom(method.declaringClass) &&
+ holder.type == InstallerReflection.type(holderData.type, target.classLoader) &&
+ container.declaringClass.isAssignableFrom(holder.type) && container.parameterCount == 0 &&
+ container.returnType == android.widget.FrameLayout::class.java)
+ point {
+ // Display only: keep every original bind side effect and then hide this ad card.
+ HookHelper.hookAfter(method) { param ->
+ hit(InstallerHitLedger.Event.CALL)
+ try {
+ val bound = holder.get(param.thisObject)
+ val card = bound?.let { container.invoke(it) as? View }
+ if (card != null && adCardIdentity(card)) {
+ card.visibility = View.GONE
+ hit(InstallerHitLedger.Event.BLOCK)
+ reportOnce(data.id, "blocked ${data.id}")
+ } else {
+ hit(InstallerHitLedger.Event.RETAIN)
+ reportOnce("ads_cancel_unbound", "ads_cancel retained; ad container unavailable or resource identity unverified")
+ }
+ } catch (failure: Exception) {
+ hit(InstallerHitLedger.Event.FAULT)
+ hit(InstallerHitLedger.Event.RETAIN)
+ reportOnce("ads_cancel_retained", "ads_cancel retained (${failure.javaClass.simpleName})")
+ }
+ }
+ }
+ }
+ "guard_banner" -> {
+ val access = checkNotNull(data.guardBanner)
+ val model = InstallerReflection.type(access.modelClass, target.classLoader)
+ val state = InstallerReflection.method(access.stateGetter, target.classLoader)
+ val texts = access.textGetters.map { InstallerReflection.method(it, target.classLoader) }
+ val kind = InstallerReflection.method(access.kindGetter, target.classLoader)
+ check(method.returnType.isAssignableFrom(model) && method.parameterCount == 0 &&
+ state.declaringClass.isAssignableFrom(model) && state.parameterCount == 0 &&
+ texts.size == 6 && texts.distinct().size == 6 && texts.all {
+ it.declaringClass.isAssignableFrom(state.returnType) && it.parameterCount == 0 &&
+ CharSequence::class.java.isAssignableFrom(it.returnType)
+ } && kind.declaringClass.isAssignableFrom(state.returnType) &&
+ kind.parameterCount == 0 && kind.returnType.isEnum)
+ point {
+ HookHelper.hookAfter(method) { param ->
+ hit(InstallerHitLedger.Event.CALL)
+ val result = param.result
+ if (result != null && result.javaClass == model) {
+ try {
+ val value = state.invoke(result)
+ if (value == null) { hit(InstallerHitLedger.Event.RETAIN); return@hookAfter }
+ val warning = (kind.invoke(value) as? Enum<*>)?.name
+ if (warning == null) { hit(InstallerHitLedger.Event.RETAIN); return@hookAfter }
+ val title = bannerString("install_safety_hint")
+ val body = bannerString("install_hint_not_checked_hint")
+ if (title == null || body == null) {
+ hit(InstallerHitLedger.Event.RETAIN)
+ reportOnce("guard_banner_resources", "guard_banner retained; default resource text unavailable")
+ return@hookAfter
+ }
+ val links = setOf("about_safe_mode", "full_safe_about_protect_mode")
+ .mapNotNull(bannerString).toSet()
+ val values = texts.map { it.invoke(value) as? CharSequence }
+ if (InstallerGuardBannerPolicy.matches(warning, values, title, body, links)) {
+ param.setResultValue(null)
+ hit(InstallerHitLedger.Event.BLOCK)
+ reportOnce(data.id, "blocked ${data.id}")
+ } else {
+ hit(InstallerHitLedger.Event.RETAIN)
+ reportOnce("guard_banner_preserved", "guard_banner retained; content is not the default informational banner")
+ }
+ } catch (failure: Exception) {
+ hit(InstallerHitLedger.Event.FAULT)
+ hit(InstallerHitLedger.Event.RETAIN)
+ reportOnce("guard_banner_retained", "guard_banner retained (${failure.javaClass.simpleName})")
+ }
+ } else hit(InstallerHitLedger.Event.RETAIN)
+ }
+ }
+ }
+ else -> error("unknown verified installer point ${data.id}")
+ }
+ }
+
+ private fun reportOnce(id: String, message: String) {
+ if (reported.add(id)) {
+ InstallerDiagnosticReporter.record("event $message")
+ HookHelper.log("PackageInstaller: $message")
+ }
+ }
+
+ private fun pointGroups(option: String): List>> = when (option) {
+ PackageInstallerKeys.DISABLE_ADS -> listOf(
+ "layout_async" to listOf("layout_async"),
+ "layout_sync" to listOf("layout_sync"),
+ )
+ PackageInstallerKeys.DISABLE_RISK -> listOf("main" to listOf("risk_scan", "intercept_check"))
+ PackageInstallerKeys.BLOCK_CLOUD -> listOf("main" to listOf("intercept_check", "cloud_v2", "cloud_v3"))
+ PackageInstallerKeys.DISABLE_GUARD -> listOf(
+ "switch_tip" to listOf("guard_tip"),
+ "default_banner" to listOf("guard_banner"),
+ )
+ PackageInstallerKeys.DISABLE_FREQUENT_CHECK -> listOf("main" to listOf("frequent_check"))
+ // Each completion style is independent. A sync mismatch cannot discard a verified async
+ // hook; all three required groups must commit before upload earns the full status.
+ PackageInstallerKeys.BLOCK_UPLOAD -> listOf(
+ "upload_producers" to listOf("intercept_check", "avl_upload"),
+ "layout_async" to listOf("layout_async"),
+ "layout_sync" to listOf("layout_sync"),
+ )
+ else -> error("unknown installer option $option")
+ }
+
+ private companion object { val LOCAL_POINTS = setOf("risk_scan", "guard_tip", "frequent_check") }
+}
+
+/** Called only when the target is constructing a UI banner, after its Application exists. */
+@SuppressLint("DiscouragedApi") // Four fixed resource names avoid coupling to a build's numeric IDs.
+private fun installedInstallerString(name: String): String? {
+ val context = Class.forName("android.app.ActivityThread").getMethod("currentApplication").invoke(null) as? Context
+ ?: return null
+ if (context.packageName != PackageInstallerKeys.PACKAGE_NAME) return null
+ val resources = context.resources
+ val id = resources.getIdentifier(name, "string", PackageInstallerKeys.PACKAGE_NAME)
+ return if (id == 0) null else resources.getText(id).toString()
+}
+
+/** Exact local resource identity. No UI-tree scan and no numeric resource ID compatibility gate. */
+private fun installedAdCardIdentity(view: View): Boolean = runCatching {
+ view.id > 0 && view.resources.getResourcePackageName(view.id) == PackageInstallerKeys.PACKAGE_NAME &&
+ view.resources.getResourceTypeName(view.id) == "id" &&
+ view.resources.getResourceEntryName(view.id) == "fl_ad_card"
+}.getOrDefault(false)
diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerCodeIdentity.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerCodeIdentity.kt
new file mode 100644
index 0000000..a1a6544
--- /dev/null
+++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerCodeIdentity.kt
@@ -0,0 +1,32 @@
+package cn.ianzb.hyperrefine.hook.packageinstaller
+
+import java.io.InputStream
+import java.security.MessageDigest
+import java.util.zip.ZipFile
+
+/** Component fingerprints for diagnostics only. No version or hash allowlist. */
+class InstallerCodeIdentity {
+ data class Result(val readable: Boolean, val diagnostic: String)
+
+ fun inspect(apk: ZipFile): Result {
+ val entries = apk.entries().asSequence().filter { ROOT_DEX.matches(it.name) }.toList()
+ if (entries.isEmpty()) return Result(false, "no primary DEX entries")
+ if (entries.map { it.name }.distinct().size != entries.size) return Result(false, "duplicate primary DEX entries")
+ val actual = entries.associate { entry -> entry.name to apk.getInputStream(entry).use(::sha256) }
+ return Result(true, actual.toSortedMap().entries.joinToString { "${it.key}=${it.value}" })
+ }
+
+ companion object {
+ private val ROOT_DEX = Regex("classes(?:[0-9]+)?\\.dex")
+ internal fun sha256(input: InputStream): String {
+ val digest = MessageDigest.getInstance("SHA-256")
+ val buffer = ByteArray(64 * 1024)
+ while (true) {
+ val count = input.read(buffer)
+ if (count < 0) break
+ if (count > 0) digest.update(buffer, 0, count)
+ }
+ return digest.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) }
+ }
+ }
+}
diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerContract.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerContract.kt
new file mode 100644
index 0000000..43fc0eb
--- /dev/null
+++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerContract.kt
@@ -0,0 +1,44 @@
+package cn.ianzb.hyperrefine.hook.packageinstaller
+
+import java.lang.reflect.Method
+import java.lang.reflect.Modifier
+
+/** Exact contract for the supplied ROM. Unknown builds preserve their own behaviour. */
+internal object InstallerContract {
+ fun supports(sdk: Int, packageName: String, processName: String, hasSplits: Boolean): Boolean =
+ sdk == 37 && packageName == PackageInstallerKeys.PACKAGE_NAME &&
+ processName == PackageInstallerKeys.PACKAGE_NAME && !hasSplits
+
+ fun method(
+ owner: Class<*>,
+ name: String,
+ returnType: String,
+ isStatic: Boolean,
+ vararg parameters: String,
+ ): Method {
+ val matches = owner.declaredMethods.filter {
+ it.name == name && it.returnType.name == returnType &&
+ Modifier.isStatic(it.modifiers) == isStatic &&
+ it.parameterTypes.map(Class<*>::getName) == parameters.toList()
+ }
+ check(matches.size == 1) { "${owner.name}#$name: expected one exact method, found ${matches.size}" }
+ return matches.single().apply { isAccessible = true }
+ }
+
+ /** Roll back this feature's handles if any install fails; other features keep running. */
+ fun installAtomically(installers: List<() -> (() -> Unit)>) {
+ val removers = mutableListOf<() -> Unit>()
+ try {
+ installers.forEach { removers += it() }
+ } catch (failure: Throwable) {
+ removers.asReversed().forEach { remove ->
+ try {
+ remove()
+ } catch (rollback: Throwable) {
+ failure.addSuppressed(rollback)
+ }
+ }
+ throw failure
+ }
+ }
+}
diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDeoptimization.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDeoptimization.kt
new file mode 100644
index 0000000..1a999b0
--- /dev/null
+++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDeoptimization.kt
@@ -0,0 +1,44 @@
+package cn.ianzb.hyperrefine.hook.packageinstaller
+
+import cn.ianzb.hyperrefine.hook.xposed.HookHelper
+
+/** Derive bounded callers from discovered methods, never from a version's obfuscated names. */
+internal object InstallerDeoptimization {
+ fun callers(index: InstallerDexIndex, point: InstallerResolvedPoint): List {
+ val found = linkedMapOf()
+ var frontier = listOf(point.method.descriptor)
+ repeat(2) {
+ val next = mutableListOf()
+ frontier.forEach { descriptor ->
+ index.callers(descriptor).forEach { caller ->
+ if (found.size < 64 && caller.name != "" && caller.name != "" &&
+ caller.descriptor != point.method.descriptor && caller.descriptor !in found
+ ) {
+ found[caller.descriptor] = caller
+ next += caller.descriptor
+ }
+ }
+ }
+ frontier = next
+ }
+ return found.values.toList()
+ }
+
+ fun prepare(id: String, callers: List, loader: ClassLoader?) {
+ var success = 0
+ callers.forEach { caller ->
+ try {
+ if (HookHelper.deoptimize(InstallerReflection.method(caller, loader))) success++
+ } catch (failure: ReflectiveOperationException) {
+ HookHelper.log("PackageInstaller[r9]: optional caller unavailable ($id, ${failure.javaClass.simpleName})")
+ } catch (failure: LinkageError) {
+ HookHelper.log("PackageInstaller[r9]: optional caller linkage unavailable ($id, ${failure.javaClass.simpleName})")
+ } catch (failure: IllegalStateException) {
+ HookHelper.log("PackageInstaller[r9]: optional caller contract changed ($id)")
+ } catch (failure: SecurityException) {
+ HookHelper.log("PackageInstaller[r9]: optional caller access denied ($id)")
+ }
+ }
+ InstallerDiagnosticReporter.record("deoptimization point=$id success=$success/${callers.size}")
+ }
+}
diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDexKitIndex.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDexKitIndex.kt
new file mode 100644
index 0000000..a239973
--- /dev/null
+++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDexKitIndex.kt
@@ -0,0 +1,177 @@
+package cn.ianzb.hyperrefine.hook.packageinstaller
+
+import org.luckypray.dexkit.DexKitBridge
+import org.luckypray.dexkit.query.FindMethod
+import org.luckypray.dexkit.query.enums.AnnotationEncodeValueType
+import org.luckypray.dexkit.query.enums.StringMatchType
+import org.luckypray.dexkit.query.matchers.MethodMatcher
+import org.luckypray.dexkit.result.AnnotationData
+import org.luckypray.dexkit.result.AnnotationEncodeValue
+import org.luckypray.dexkit.result.FieldUsingType
+import org.luckypray.dexkit.result.MethodData
+import org.luckypray.dexkit.wrap.DexMethod
+import java.lang.reflect.Modifier
+
+/**
+ * Installer-only, read-only DexKit session. This does not initialize the shared cache manager,
+ * create a cache directory, export DEX, or retain native-backed data after close.
+ */
+internal class InstallerDexKitIndex private constructor(private val bridge: DexKitBridge) : InstallerDexIndex {
+ private var closed = false
+ private val methods = InstallerMethodSnapshots()
+ private val classes = mutableMapOf()
+ private val fields = mutableMapOf()
+
+ @Synchronized
+ override fun findMethods(query: InstallerMethodQuery): List {
+ requireOpen()
+ val types = query.parameterTypes
+ if (types != null && query.parameterCount != null && types.size != query.parameterCount) return emptyList()
+ val matcher = MethodMatcher.create()
+ if (query.strings.isNotEmpty()) matcher.usingEqStrings(query.strings)
+ query.owner?.let { matcher.declaredClass(it, StringMatchType.Equals) }
+ query.returnType?.let { matcher.returnType(it, StringMatchType.Equals) }
+ (query.parameterCount ?: types?.size)?.let { matcher.paramCount(it) }
+ // The model describes integral values, independent of const versus const-wide encoding.
+ // Match either native Int or Long encoding when the requested value fits both.
+ query.numbers.forEach { number ->
+ val alternatives = mutableListOf(MethodMatcher.create().usingNumbers(number))
+ if (number in Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong()) {
+ alternatives += MethodMatcher.create().usingNumbers(number.toInt())
+ }
+ matcher.addAllOf(MethodMatcher.create().anyOf(alternatives))
+ }
+ val found = bridge.findMethod(FindMethod.create().matcher(matcher)).asSequence()
+ .filter { data ->
+ (query.isStatic == null || Modifier.isStatic(data.modifiers) == query.isStatic) &&
+ (types == null || data.paramTypeNames.size == types.size &&
+ types.indices.all { index -> types[index] == null || types[index] == data.paramTypeNames[index] })
+ }
+ .map(::materialize)
+ .toList()
+ return canonicalSnapshots(found)
+ }
+
+ @Synchronized
+ override fun method(descriptor: String): InstallerMethod? {
+ requireOpen()
+ methods.complete(descriptor)?.let { return it }
+ // A descriptor lookup may identify a reference. Ask the definition search with its full
+ // signature before accepting metadata-free results. Missing results are not cached.
+ val definitions = exactMethods(descriptor).map(::materialize)
+ return methods.complete(descriptor) ?: definitions.firstOrNull() ?: bridge.getMethodData(descriptor)?.let(::materialize)
+ }
+
+ @Synchronized
+ override fun callers(descriptor: String): List {
+ requireOpen()
+ val targets = exactMethods(descriptor).ifEmpty { listOfNotNull(bridge.getMethodData(descriptor)) }
+ return canonicalSnapshots(targets.flatMap { it.callers }.map(::materialize))
+ }
+
+ @Synchronized
+ override fun clazz(name: String): InstallerClass? {
+ requireOpen()
+ if (classes.containsKey(name)) return classes[name]
+ val result = bridge.getClassData(name)?.let { data ->
+ InstallerClass(
+ name = data.name,
+ superName = data.superClass?.name,
+ interfaces = data.interfaces.map { it.name }.distinct().sorted(),
+ methods = data.methods.map { it.descriptor }.distinct().sorted(),
+ fields = data.fields.map { it.descriptor }.distinct().sorted(),
+ annotationStrings = annotationStrings(data.annotations),
+ )
+ }
+ classes[name] = result
+ return result
+ }
+
+ @Synchronized
+ override fun field(descriptor: String): InstallerField? {
+ requireOpen()
+ if (fields.containsKey(descriptor)) return fields[descriptor]
+ val result = bridge.getFieldData(descriptor)?.let { data ->
+ InstallerField(
+ descriptor = data.descriptor,
+ owner = data.className,
+ name = data.name,
+ type = data.typeName,
+ isStatic = Modifier.isStatic(data.modifiers),
+ annotationStrings = annotationStrings(data.annotations),
+ )
+ }
+ fields[descriptor] = result
+ return result
+ }
+
+ private fun materialize(data: MethodData): InstallerMethod {
+ val usingFields = data.usingFields
+ val result = InstallerMethod(
+ descriptor = data.descriptor,
+ owner = data.className,
+ name = data.name,
+ returnType = data.returnTypeName,
+ parameterTypes = data.paramTypeNames.toList(),
+ isStatic = Modifier.isStatic(data.modifiers),
+ strings = data.usingStrings.toSet(),
+ annotationStrings = annotationStrings(data.annotations),
+ invokes = data.invokes.map { it.descriptor },
+ readFields = usingFields.filter { it.usingType == FieldUsingType.Read }.map { it.field.descriptor },
+ writtenFields = usingFields.filter { it.usingType == FieldUsingType.Write }.map { it.field.descriptor },
+ opcodes = data.opCodes.toList(),
+ )
+ return methods.remember(result, Modifier.isAbstract(data.modifiers) || Modifier.isNative(data.modifiers))
+ }
+
+ private fun exactMethods(descriptor: String): List {
+ val signature = DexMethod(descriptor)
+ val matcher = MethodMatcher.create()
+ .declaredClass(signature.className, StringMatchType.Equals)
+ .name(signature.name, StringMatchType.Equals)
+ .returnType(signature.returnTypeName, StringMatchType.Equals)
+ .paramTypes(signature.paramTypeNames)
+ return bridge.findMethod(FindMethod.create().matcher(matcher)).filter { it.descriptor == descriptor }
+ }
+
+ private fun canonicalSnapshots(found: List): List = found
+ .map { methods.complete(it.descriptor) ?: it }.distinctBy { it.descriptor }.sortedBy { it.descriptor }
+
+ private fun annotationStrings(annotations: List): Set {
+ val values = linkedSetOf()
+ annotations.forEach { annotation -> annotation.elements.forEach { flatten(it.value, values, 0) } }
+ return values
+ }
+
+ private fun flatten(value: AnnotationEncodeValue, strings: MutableSet, depth: Int) {
+ check(depth <= 32) { "installer annotation nesting exceeds limit" }
+ when (value.type) {
+ AnnotationEncodeValueType.StringValue -> strings += value.stringValue()
+ AnnotationEncodeValueType.ArrayValue -> value.arrayValue().values.forEach { flatten(it, strings, depth + 1) }
+ AnnotationEncodeValueType.AnnotationValue -> value.annotationValue().elements.forEach { flatten(it.value, strings, depth + 1) }
+ else -> Unit
+ }
+ }
+
+ private fun requireOpen() = check(!closed) { "installer DEX index is closed" }
+
+ @Synchronized
+ override fun close() {
+ if (closed) return
+ closed = true
+ try {
+ bridge.close()
+ } finally {
+ methods.clear()
+ classes.clear()
+ fields.clear()
+ }
+ }
+
+ companion object {
+ fun open(sourceDir: String): InstallerDexKitIndex {
+ System.loadLibrary("dexkit")
+ return InstallerDexKitIndex(DexKitBridge.create(sourceDir))
+ }
+ }
+}
diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDexModel.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDexModel.kt
new file mode 100644
index 0000000..93b6b98
--- /dev/null
+++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDexModel.kt
@@ -0,0 +1,82 @@
+package cn.ianzb.hyperrefine.hook.packageinstaller
+
+/** Pure metadata: no target code is invoked while discovering a business capability. */
+internal data class InstallerMethod(
+ val descriptor: String,
+ val owner: String,
+ val name: String,
+ val returnType: String,
+ val parameterTypes: List,
+ val isStatic: Boolean,
+ val strings: Set = emptySet(),
+ val annotationStrings: Set = emptySet(),
+ val invokes: List = emptyList(),
+ val readFields: List = emptyList(),
+ val writtenFields: List = emptyList(),
+ val opcodes: List = emptyList(),
+)
+
+internal data class InstallerField(
+ val descriptor: String,
+ val owner: String,
+ val name: String,
+ val type: String,
+ val isStatic: Boolean,
+ val annotationStrings: Set = emptySet(),
+)
+
+internal data class InstallerClass(
+ val name: String,
+ val superName: String?,
+ val interfaces: List = emptyList(),
+ val methods: List = emptyList(),
+ val fields: List = emptyList(),
+ val annotationStrings: Set = emptySet(),
+)
+
+internal data class InstallerMethodQuery(
+ val strings: Set = emptySet(),
+ val numbers: Set = emptySet(),
+ val owner: String? = null,
+ val returnType: String? = null,
+ val parameterTypes: List? = null,
+ val parameterCount: Int? = null,
+ val isStatic: Boolean? = null,
+)
+
+internal interface InstallerDexIndex : AutoCloseable {
+ fun findMethods(query: InstallerMethodQuery): List
+ fun method(descriptor: String): InstallerMethod?
+ fun callers(descriptor: String): List
+ fun clazz(name: String): InstallerClass?
+ fun field(descriptor: String): InstallerField?
+ override fun close() {}
+}
+
+/** An independently matched point plus only the extra types needed by its return policy. */
+internal data class InstallerResolvedPoint(
+ val id: String,
+ val method: InstallerMethod,
+ val callback: InstallerMethod? = null,
+ val payloadClass: String? = null,
+ val resultClass: String? = null,
+ val resultBaseClass: String? = null,
+ val singletonField: InstallerField? = null,
+ val guardedItemClass: String? = null,
+ val evidence: List = emptyList(),
+ val holderField: InstallerField? = null,
+ val containerGetter: InstallerMethod? = null,
+ val guardBanner: InstallerGuardBannerAccess? = null,
+)
+
+internal class InstallerMatchException(val role: String, val candidateCount: Int, detail: String) :
+ IllegalStateException("$role: candidates=$candidateCount; $detail")
+
+internal fun uniqueInstallerMatch(role: String, candidates: List): T {
+ if (candidates.size != 1) throw InstallerMatchException(role, candidates.size, "expected one verified business boundary")
+ return candidates.single()
+}
+
+internal fun requireInstallerMatch(role: String, condition: Boolean, detail: String) {
+ if (!condition) throw InstallerMatchException(role, 0, detail)
+}
diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDiagnosticContract.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDiagnosticContract.kt
new file mode 100644
index 0000000..2496069
--- /dev/null
+++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDiagnosticContract.kt
@@ -0,0 +1,21 @@
+package cn.ianzb.hyperrefine.hook.packageinstaller
+
+/** Installer-only troubleshooting transport. This never grants an active feature status. */
+object InstallerDiagnosticContract {
+ const val MODULE_PACKAGE = "cn.ianzb.hyperrefine"
+ const val TARGET_PACKAGE = "com.miui.packageinstaller"
+ const val RECEIVER_CLASS = "cn.ianzb.hyperrefine.experimental.packageinstaller.InstallerDiagnosticReceiver"
+ const val ACTION = "cn.ianzb.hyperrefine.action.INSTALLER_DIAGNOSTIC"
+ const val BUILD_ID = "installer-r9"
+ const val EXTRA_BUILD_ID = "build_id"
+ const val EXTRA_MODULE_VERSION = "module_version"
+ const val EXTRA_BOOT_COUNT = "boot_count"
+ const val EXTRA_SOURCE_PACKAGE = "source_package"
+ const val EXTRA_SOURCE_PROCESS = "source_process"
+ const val EXTRA_SOURCE_PID = "source_pid"
+ const val EXTRA_GENERATED_ELAPSED = "generated_elapsed"
+ const val EXTRA_TRACE = "trace"
+ const val MAX_TRACE_LINES = 64
+ const val MAX_LINE_CHARS = 384
+ const val MAX_REPORT_AGE_MS = 300_000L
+}
diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDiagnosticReporter.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDiagnosticReporter.kt
new file mode 100644
index 0000000..b15ddf0
--- /dev/null
+++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDiagnosticReporter.kt
@@ -0,0 +1,125 @@
+package cn.ianzb.hyperrefine.hook.packageinstaller
+
+import android.app.Application
+import android.app.BroadcastOptions
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.os.Process
+import android.os.SystemClock
+import android.provider.Settings
+import cn.ianzb.hyperrefine.hook.xposed.HookHelper
+
+/** Bounded, best-effort diagnostics for this installer only. Never receives installation arguments. */
+internal object InstallerDiagnosticReporter {
+ private val lines = ArrayDeque()
+ private var applicationContext: Context? = null
+ private var attachAttempted = false
+ private var scheduled = false
+ private var active = false
+
+ /** Starts only from this feature's Load, after the host has completed its shared entry checks. */
+ fun begin() = safely {
+ val firstLoad = synchronized(this) {
+ if (active) false else { active = true; true }
+ }
+ if (firstLoad) { InstallerHitLedger.reset(); InstallerDiagnosticSnapshot.reset() }
+ record("ready stage=load sdk=${Build.VERSION.SDK_INT} pid=${Process.myPid()}")
+ val context = currentApplication()
+ if (context != null) {
+ synchronized(this) { applicationContext = context.applicationContext ?: context }
+ schedule()
+ } else if (!attachAttempted) {
+ attachAttempted = true
+ val attach = Application::class.java.getDeclaredMethod("attach", Context::class.java)
+ .apply { isAccessible = true }
+ HookHelper.hookAfter(attach) { param ->
+ safely {
+ val attached = param.thisObject as? Application
+ val base = param.args.firstOrNull() as? Context
+ val ready = attached?.applicationContext ?: base?.applicationContext ?: base
+ if (ready != null) {
+ synchronized(this) { applicationContext = ready }
+ schedule()
+ }
+ }
+ }
+ }
+ }
+
+ /** Call only with component metadata, static point names and exception classes. */
+ fun record(line: String) = safely {
+ synchronized(this) {
+ if (!active) return@safely
+ InstallerDiagnosticSnapshot.observe(line)
+ if (lines.size == InstallerDiagnosticContract.MAX_TRACE_LINES) lines.removeFirst()
+ lines.addLast(line.take(InstallerDiagnosticContract.MAX_LINE_CHARS))
+ }
+ schedule()
+ }
+
+ fun registered(point: String) = safely {
+ InstallerHitLedger.register(point)
+ schedule()
+ }
+
+ fun hit(point: String, event: InstallerHitLedger.Event) = safely {
+ if (InstallerHitLedger.record(point, event)) schedule()
+ }
+
+ private fun schedule() {
+ val shouldPost = synchronized(this) {
+ if (applicationContext == null || scheduled) false else { scheduled = true; true }
+ }
+ if (!shouldPost) return
+ val posted = try {
+ Handler(Looper.getMainLooper()).post {
+ safely {
+ val snapshot = synchronized(this) {
+ scheduled = false
+ applicationContext to ArrayList(lines)
+ }
+ snapshot.first?.let { send(it, snapshot.second) }
+ }
+ }
+ } catch (failure: Throwable) {
+ synchronized(this) { scheduled = false }
+ throw failure
+ }
+ if (!posted) synchronized(this) { scheduled = false }
+ }
+
+ private fun send(context: Context, trace: ArrayList) {
+ val contract = InstallerDiagnosticContract
+ val snapshot = ArrayList(InstallerDiagnosticSnapshot.merge(trace, contract.MAX_TRACE_LINES))
+ val version = context.packageManager.getPackageInfo(contract.MODULE_PACKAGE, 0).longVersionCode
+ val boot = Settings.Global.getInt(context.contentResolver, Settings.Global.BOOT_COUNT, -1)
+ val intent = Intent(contract.ACTION)
+ .setComponent(ComponentName(contract.MODULE_PACKAGE, contract.RECEIVER_CLASS))
+ .putExtra(contract.EXTRA_BUILD_ID, contract.BUILD_ID)
+ .putExtra(contract.EXTRA_MODULE_VERSION, version)
+ .putExtra(contract.EXTRA_BOOT_COUNT, boot)
+ .putExtra(contract.EXTRA_SOURCE_PACKAGE, context.packageName)
+ .putExtra(contract.EXTRA_SOURCE_PROCESS, Application.getProcessName())
+ .putExtra(contract.EXTRA_SOURCE_PID, Process.myPid())
+ .putExtra(contract.EXTRA_GENERATED_ELAPSED, SystemClock.elapsedRealtime())
+ .putStringArrayListExtra(contract.EXTRA_TRACE, snapshot)
+ .addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES or Intent.FLAG_RECEIVER_FOREGROUND)
+ val options = BroadcastOptions.makeBasic().apply { setShareIdentityEnabled(true) }
+ context.sendBroadcast(intent, null, options.toBundle())
+ }
+
+ private fun currentApplication(): Context? = runCatching {
+ Class.forName("android.app.ActivityThread").getMethod("currentApplication").invoke(null) as? Context
+ }.getOrNull()
+
+ /** Diagnostic failures must not stop registration or change a business callback result. */
+ private inline fun safely(block: () -> Unit) {
+ try { block() } catch (failure: Throwable) {
+ runCatching { HookHelper.log("PackageInstaller[r9]: diagnostic unavailable (${failure.javaClass.simpleName})") }
+ }
+ }
+}
diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDiagnosticSnapshot.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDiagnosticSnapshot.kt
new file mode 100644
index 0000000..c6e8f36
--- /dev/null
+++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDiagnosticSnapshot.kt
@@ -0,0 +1,28 @@
+package cn.ianzb.hyperrefine.hook.packageinstaller
+
+/** Retains bounded discovery failures separately from the rolling event log. No target arguments. */
+internal object InstallerDiagnosticSnapshot {
+ private val retained = linkedMapOf()
+ @Synchronized fun reset() = retained.clear()
+ @Synchronized fun observe(line: String) {
+ val key = when {
+ line.startsWith("match_failed option=") -> line.substringBefore(" reason=")
+ line.startsWith("installation_failed option=") -> line.substringBefore(" type=")
+ line.startsWith("probe ") -> line.substringBefore(" detail=")
+ line.startsWith("summary ") -> "summary"
+ else -> return
+ }
+ // More independent groups can fill the bounded probe area before mounting finishes.
+ // Always retain the final registration summary without increasing transport capacity.
+ if (key == "summary" && key !in retained && retained.size == 25) {
+ retained.remove(retained.keys.lastOrNull { it.startsWith("probe ") } ?: retained.keys.last())
+ }
+ if (key in retained || retained.size < 25) retained[key] = line.take(384)
+ }
+ @Synchronized fun merge(trace: List, maxLines: Int): List {
+ val pinned = retained.values.toList()
+ val hits = InstallerHitLedger.snapshotLines()
+ require(maxLines >= 38)
+ return trace.filterNot { it in pinned }.takeLast(maxLines - pinned.size - hits.size) + pinned + hits
+ }
+}
diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDiscoveryProbe.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDiscoveryProbe.kt
new file mode 100644
index 0000000..7a8f4c0
--- /dev/null
+++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDiscoveryProbe.kt
@@ -0,0 +1,42 @@
+package cn.ianzb.hyperrefine.hook.packageinstaller
+
+/** Static, bounded diagnostics. Never invokes installer methods or reads pending APK information. */
+internal object InstallerDiscoveryProbe {
+ fun collect(index: InstallerDexIndex): List {
+ val lines = mutableListOf()
+ fun probe(name: String, block: () -> String) {
+ val detail = runCatching(block).getOrElse { "unavailable=${it.javaClass.simpleName}" }
+ lines += "probe $name detail=$detail".take(384)
+ }
+ probe("layout_maps") {
+ val maps = index.findMethods(InstallerMethodQuery(strings = setOf("packageName", "installSource",
+ "newInstall", "appName", "isPersonalizedAdEnabled"))).distinctBy { it.descriptor }
+ "count=${maps.size}; " + maps.take(2).joinToString("; ") {
+ "${it.descriptor} invokes=${it.invokes.size}/${it.invokes.distinct().size} reads=${it.readFields.size}/${it.readFields.distinct().size}"
+ }
+ }
+ val mapApis = linkedMapOf()
+ for (type in listOf("java.util.Map", "java.util.HashMap")) {
+ for (params in listOf(listOf(type), listOf(type, null))) {
+ runCatching { index.findMethods(InstallerMethodQuery(parameterTypes = params)) }
+ .getOrDefault(emptyList()).forEach { mapApis[it.descriptor] = it }
+ }
+ }
+ probe("map_api_annotations") {
+ val annotated = mapApis.values.filter { it.annotationStrings.isNotEmpty() }
+ "map_first=${mapApis.size}; annotated=${annotated.size}; layout_named=${annotated.count { method -> method.annotationStrings.any { it.contains("layout", true) } }}"
+ }
+ mapApis.values.filter { method -> method.annotationStrings.any { it.contains("layout", true) } }
+ .take(2).forEachIndexed { ordinal, method ->
+ probe("layout_api_$ordinal") {
+ // Only fixed path-like layout annotation constants, never URL queries/hosts.
+ val paths = method.annotationStrings.filter { it.contains("layout", true) }.mapNotNull {
+ runCatching { java.net.URI(it).rawPath }.getOrNull()
+ ?.takeIf { path -> path.length <= 96 && path.matches(Regex("[A-Za-z0-9_./-]+")) }
+ }.distinct().take(2)
+ "${method.descriptor}; paths=${paths.joinToString()}; annotations=${method.annotationStrings.size}"
+ }
+ }
+ return lines
+ }
+}
diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerGuardBannerRules.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerGuardBannerRules.kt
new file mode 100644
index 0000000..b8b8de5
--- /dev/null
+++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerGuardBannerRules.kt
@@ -0,0 +1,182 @@
+package cn.ianzb.hyperrefine.hook.packageinstaller
+
+/** Detached accessors for the information banner's own state, never for the bottom confirmation UI. */
+internal data class InstallerGuardBannerAccess(
+ val modelClass: String,
+ val stateGetter: InstallerMethod,
+ val textGetters: List,
+ val kindGetter: InstallerMethod,
+)
+
+/** Finds only the nullable default-audit-information factory; the callback must still validate its result. */
+internal class InstallerGuardBannerRules(private val index: InstallerDexIndex) {
+ fun resolve(): InstallerResolvedPoint {
+ val factories = index.findMethods(InstallerMethodQuery(parameterCount = 0, isStatic = false)).filter { method ->
+ method.returnType !in PRIMITIVES && method.writtenFields.isEmpty() &&
+ RETURN_OBJECT in method.opcodes
+ }
+ val stats = Stats()
+ val accessesByModel = mutableMapOf>()
+ val structural = factories.mapNotNull { factory ->
+ val models = factory.invokes.mapNotNull(index::method).filter { constructor ->
+ constructor.name == "" && constructor.parameterTypes.firstOrNull() == "android.content.Context" &&
+ constructor.parameterTypes.count { it in TEXT_TYPES } == 6 &&
+ factory.returnType in ancestors(constructor.owner)
+ }.map { it.owner }.distinct()
+ stats.construction += models.size
+ val accesses = models.flatMap { model -> accessesByModel.getOrPut(model) { modelAccess(model) } }
+ stats.verifiedModels += accesses.size
+ if (accesses.size != 1) return@mapNotNull null
+ // Factories assembling their own detail lists represent composite risk information.
+ if (factory.invokes.any(::listAppend)) {
+ stats.internalCollections++
+ return@mapNotNull null
+ }
+ if (!nullableInformationConsumers(factory, stats)) null else factory to accesses.single()
+ }
+ // Cloud layout varies. It is a positive discriminator only when several independently
+ // verified factories share the model; it is never required for a unique structural match.
+ val cloud = structural.filter { informationHint(it.first) }
+ val candidates = when {
+ structural.size == 1 -> structural
+ cloud.size == 1 -> cloud
+ else -> structural
+ }
+ if (candidates.size != 1) throw InstallerMatchException("guard_banner.default_information_factory", candidates.size,
+ "guard_banner structural=${structural.size} security_tip=${cloud.size} factory_inputs=${factories.size} construction=${stats.construction} model_classes=${accessesByModel.size} " +
+ "verified_models=${stats.verifiedModels} internal_collections=${stats.internalCollections} " +
+ "consumers=${stats.consumers} nullable_consumers=${stats.nullableConsumers} " +
+ "outer_consumers=${stats.outerConsumers} list_consumers=${stats.listConsumers}; unknown/ambiguous structures preserve original result")
+ val (factory, access) = candidates.single()
+ return InstallerResolvedPoint("guard_banner", factory, guardBanner = access, evidence = listOf(
+ "six-text model constructor + pure state access + observed nullable fallback/list consumers",
+ "all observed direct consumers satisfy the nullable/list relationship; optional securityTip only disambiguates",
+ "one pure state getter + six distinct pure text getters + pure enum kind getter",
+ "after callback must match exact model class, WARNING and current-locale default texts; no fullsafe class filter",
+ ))
+ }
+
+ private fun informationHint(method: InstallerMethod): Boolean {
+ val reads = method.readFields.toMutableSet()
+ // A field access can be compiled as a getter. Only traverse already proven pure getters,
+ // with a hard cap; never execute them or traverse arbitrary producer calls.
+ if (method.invokes.distinct().size > 64) return false
+ method.invokes.distinct().mapNotNull(index::method).filter(::pureObjectGetter).forEach { reads += it.readFields }
+ return reads.mapNotNull(index::field).any { !it.isStatic && key(it, "securityTip") && payloadShape(it.type) }
+ }
+
+ private fun payloadShape(type: String): Boolean {
+ val fields = index.clazz(type)?.fields.orEmpty().mapNotNull(index::field)
+ return listOf("title", "text", "jumpText").all { name ->
+ fields.count { !it.isStatic && it.type == "java.lang.String" && key(it, name) } == 1
+ }
+ }
+
+ private fun key(field: InstallerField, name: String): Boolean = field.name == name || name in field.annotationStrings
+
+ private fun modelAccess(model: String): List = methods(model).filter(::pureObjectGetter).mapNotNull { getter ->
+ val state = getter.returnType
+ val stateFields = index.clazz(state)?.fields.orEmpty().mapNotNull(index::field).filterNot { it.isStatic }
+ val textFields = stateFields.filter { it.type in TEXT_TYPES }
+ if (stateFields.size != 7 || textFields.size != 6) return@mapNotNull null
+ val stateMethods = methods(state)
+ if (stateMethods.none { it.parameterTypes.isEmpty() && it.returnType == "java.lang.String" &&
+ it.strings.containsAll(STATE_LABELS) && it.readFields.containsAll(textFields.map { field -> field.descriptor }) }) return@mapNotNull null
+ val texts = stateMethods.filter { pureObjectGetter(it) && it.returnType in TEXT_TYPES }
+ if (texts.size != 6 || texts.map { it.readFields.single() }.toSet() != textFields.map { it.descriptor }.toSet()) return@mapNotNull null
+ val kinds = stateMethods.filter { pureObjectGetter(it) && index.clazz(it.returnType)?.superName == "java.lang.Enum" }
+ if (kinds.size != 1) return@mapNotNull null
+ InstallerGuardBannerAccess(model, getter, texts.sortedBy { it.descriptor }, kinds.single())
+ }
+
+ private fun pureObjectGetter(method: InstallerMethod): Boolean {
+ if (method.isStatic || method.parameterTypes.isNotEmpty() || method.invokes.isNotEmpty() || method.strings.isNotEmpty() ||
+ method.writtenFields.isNotEmpty() || method.readFields.size != 1 || method.returnType in PRIMITIVES) return false
+ val field = index.field(method.readFields.single()) ?: return false
+ val code = method.opcodes.filter { it != 0x00 }
+ return !field.isStatic && field.owner == method.owner && field.type == method.returnType &&
+ code.lastOrNull() == RETURN_OBJECT && code.count { it == IGET_OBJECT } == 1 &&
+ code.all { it in PURE_GETTER_OPCODES }
+ }
+
+ private fun nullableInformationConsumers(factory: InstallerMethod, stats: Stats): Boolean {
+ val consumers = index.callers(factory.descriptor).distinctBy { it.descriptor }
+ stats.consumers += consumers.size
+ if (consumers.isEmpty() || consumers.size > 32) return false
+ var accepted = true
+ for (consumer in consumers) {
+ val matches = consumer.owner == factory.owner && consumer.returnType in ancestors(factory.returnType) &&
+ consumer.opcodes.any { it in NULL_BRANCHES } &&
+ consumer.invokes.distinct().mapNotNull(index::method).count { alternative ->
+ alternative.owner == factory.owner && alternative.descriptor != factory.descriptor && alternative.returnType == factory.returnType
+ } >= 2
+ if (!matches) {
+ accepted = false
+ continue
+ }
+ stats.nullableConsumers++
+ val outers = index.callers(consumer.descriptor).distinctBy { it.descriptor }
+ stats.outerConsumers += outers.size
+ if (outers.isEmpty() || outers.size > 32) accepted = false
+ for (outer in outers) {
+ if (outer.returnType == "void" && outer.opcodes.any { it in NULL_BRANCHES } && outer.invokes.any(::listAppend)) {
+ stats.listConsumers++
+ } else accepted = false
+ }
+ }
+ return accepted
+ }
+
+ private fun ancestors(type: String): Set {
+ val found = linkedSetOf(type)
+ var next = index.clazz(type)?.superName
+ while (next != null && found.size < 32 && found.add(next)) next = index.clazz(next)?.superName
+ return found
+ }
+
+ private fun methods(owner: String): List = index.clazz(owner)?.methods.orEmpty().mapNotNull(index::method)
+
+ private fun listAppend(descriptor: String): Boolean {
+ val suffix = ";->add(Ljava/lang/Object;)Z"
+ if (!descriptor.startsWith("L") || !descriptor.endsWith(suffix)) return false
+ val owner = descriptor.substring(1, descriptor.length - suffix.length).replace('/', '.')
+ return owner in setOf("java.util.List", "java.util.ArrayList", "java.util.LinkedList", "java.util.Collection")
+ }
+
+ private companion object {
+ private val TEXT_TYPES = setOf("java.lang.CharSequence", "java.lang.String")
+ private val PRIMITIVES = setOf("void", "boolean", "byte", "short", "char", "int", "long", "float", "double")
+ private val STATE_LABELS = setOf("Data(type=", ", title1=", ", content1=", ", title2=", ", content2=", ", content2extra=", ", content3=")
+ private val PURE_GETTER_OPCODES = setOf(0x07, 0x08, 0x09, 0x54, 0x11)
+ private val NULL_BRANCHES = setOf(0x38, 0x39)
+ private const val IGET_OBJECT = 0x54
+ private const val RETURN_OBJECT = 0x11
+ }
+
+ private class Stats {
+ var construction = 0
+ var verifiedModels = 0
+ var internalCollections = 0
+ var consumers = 0
+ var nullableConsumers = 0
+ var outerConsumers = 0
+ var listConsumers = 0
+ }
+}
+
+/** No prefix/substring acceptance: unknown server text, extra rows and risk kinds retain the original result. */
+internal object InstallerGuardBannerPolicy {
+ fun matches(kind: String, texts: List, title: String, body: String, links: Set): Boolean {
+ if (kind != "WARNING" || texts.size != 6 || title.length > 4_096 || body.length > 4_096 ||
+ texts.any { it != null && it.length > 4_096 } || links.any { it.length > 512 }) return false
+ val expectedTitle = normalize(title)
+ val expectedBody = normalize(body)
+ if (expectedTitle.isEmpty() || expectedBody.isEmpty() || expectedTitle == expectedBody) return false
+ val present = texts.map { normalize(it?.toString().orEmpty()) }.filter { it.isNotEmpty() }
+ if (present.size != 2 || present.count { it == expectedTitle } != 1) return false
+ val bodies = setOf(expectedBody) + links.map(::normalize).filter { it.isNotEmpty() }.map { expectedBody + it }
+ return present.count { it in bodies } == 1
+ }
+
+ private fun normalize(text: String): String = text.filterNot { it.isWhitespace() || it == '\u200b' }
+}
diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerHitLedger.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerHitLedger.kt
new file mode 100644
index 0000000..cd878e3
--- /dev/null
+++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerHitLedger.kt
@@ -0,0 +1,36 @@
+package cn.ianzb.hyperrefine.hook.packageinstaller
+
+/** Process-local callback observations. No arguments, package details, text or filesystem state. */
+internal object InstallerHitLedger {
+ enum class Event { CALL, BLOCK, RETAIN, COMPLETE, FAULT }
+ private val points = setOf("layout_async", "layout_sync", "risk_scan", "intercept_check", "cloud_v2", "cloud_v3",
+ "guard_tip", "frequent_check", "avl_upload", "ads_before", "ads_after", "ads_cancel", "guard_banner")
+ private val counters = linkedMapOf()
+
+ @Synchronized fun reset() = counters.clear()
+
+ /** Call only after the owning group's transaction commits; reuse does not reset counts. */
+ @Synchronized fun register(point: String) {
+ if (point in points) counters.getOrPut(point) { LongArray(Event.entries.size) }
+ }
+
+ /** Returns true at first/power-of-two observations, limiting diagnostic scheduling. */
+ @Synchronized fun record(point: String, event: Event): Boolean {
+ val values = counters[point] ?: return false
+ val value = values[event.ordinal]
+ val next = if (value == Long.MAX_VALUE) value else value + 1
+ values[event.ordinal] = next
+ return next == 1L || next > 0 && (next and (next - 1)) == 0L
+ }
+
+ @Synchronized fun snapshotLines(): List = counters.toSortedMap().map { (point, values) ->
+ "hits point=$point registered=true callback_calls=${values[0]} blocked=${values[1]} retained=${values[2]} completion=${values[3]} callback_faults=${values[4]}"
+ }
+
+ /** Counters are appended separately so a first-hit FIFO entry being evicted cannot erase them. */
+ @Synchronized fun mergeTrace(trace: List, maxLines: Int): List {
+ require(maxLines >= points.size)
+ val snapshots = snapshotLines()
+ return trace.takeLast(maxLines - snapshots.size) + snapshots
+ }
+}
diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerLocalRules.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerLocalRules.kt
new file mode 100644
index 0000000..ac542ac
--- /dev/null
+++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerLocalRules.kt
@@ -0,0 +1,290 @@
+package cn.ianzb.hyperrefine.hook.packageinstaller
+
+/** Local business capabilities. Discovery never invokes target code or changes global authorization. */
+internal class InstallerLocalRules(private val index: InstallerDexIndex) {
+ fun resolve(id: String): InstallerResolvedPoint = when (id) {
+ "risk_scan" -> riskScan()
+ "guard_tip" -> guardTip()
+ "frequent_check" -> frequentCheck()
+ else -> throw IllegalArgumentException("unknown local installer point: $id")
+ }
+
+ private fun riskScan(): InstallerResolvedPoint {
+ val virusPreference = preferenceReader("risk_scan.virus_preference", "virus_scan_install")
+ val safePreference = preferenceReader("risk_scan.safe_preference", "miui_safe_mode")
+ val candidates = index.findMethods(InstallerMethodQuery(
+ returnType = "boolean", parameterCount = 0, isStatic = false,
+ )).filter { gate ->
+ gate.writtenFields.isEmpty() &&
+ virusPreference.descriptor in gate.invokes && safePreference.descriptor in gate.invokes &&
+ ownerStrings(gate.owner).contains("InstallTask") &&
+ hasOnlyScanConsumers(gate)
+ }
+ return InstallerResolvedPoint(
+ id = "risk_scan", method = uniqueInstallerMatch("risk_scan.local_scan_gate", candidates),
+ evidence = listOf("virus_scan_install + miui_safe_mode readers", "every consumer is a scan coroutine or pure accessor used only by scan coroutines"),
+ )
+ }
+
+ private fun hasOnlyScanConsumers(gate: InstallerMethod): Boolean {
+ val consumers = index.callers(gate.descriptor).distinctBy { it.descriptor }
+ return consumers.isNotEmpty() && consumers.all { consumer ->
+ isScanConsumer(consumer) || isPureGateAccessor(consumer, gate) &&
+ index.callers(consumer.descriptor).distinctBy { it.descriptor }.let { accessorConsumers ->
+ accessorConsumers.isNotEmpty() && accessorConsumers.all(::isScanConsumer)
+ }
+ }
+ }
+
+ private fun isScanConsumer(caller: InstallerMethod): Boolean =
+ caller.returnType == "java.lang.Object" && caller.parameterTypes == listOf("java.lang.Object") &&
+ 0x38 in caller.opcodes && 0x11 in caller.opcodes &&
+ caller.invokes.mapNotNull(index::method).any { scan ->
+ scan.returnType == "java.lang.Object" &&
+ scan.parameterTypes.firstOrNull() == "java.lang.String" && scan.parameterTypes.size >= 3 &&
+ ownerStrings(scan.owner).let { strings ->
+ "NewVirusScanner" in strings && strings.any {
+ it.startsWith("Exception when check virus:") || it == "using new VirusScanManager SDK"
+ }
+ }
+ }
+
+ private fun isPureGateAccessor(accessor: InstallerMethod, gate: InstallerMethod): Boolean =
+ accessor.owner == gate.owner && accessor.isStatic && accessor.returnType == "boolean" &&
+ accessor.parameterTypes == listOf(gate.owner) && accessor.invokes.distinct() == listOf(gate.descriptor) &&
+ accessor.strings.isEmpty() && accessor.readFields.isEmpty() && accessor.writtenFields.isEmpty() &&
+ accessor.opcodes.isNotEmpty() && accessor.opcodes.last() == 0x0f &&
+ accessor.opcodes.all { it in ACCESSOR_OPCODES } &&
+ accessor.opcodes.count { it in ACCESSOR_INVOKES } == 1 && accessor.opcodes.count { it == 0x0a } == 1
+
+ private fun preferenceReader(role: String, key: String): InstallerMethod = uniqueInstallerMatch(
+ role,
+ index.findMethods(InstallerMethodQuery(
+ strings = setOf(key), returnType = "boolean",
+ parameterTypes = listOf("android.content.Context"), isStatic = true,
+ )).filter { method ->
+ method.invokes.any { it == "Landroid/content/Context;->getContentResolver()Landroid/content/ContentResolver;" }
+ },
+ )
+
+ private fun guardTip(): InstallerResolvedPoint {
+ val stages = linkedMapOf("model" to 0, "construction" to 0, "builder" to 0,
+ "adapter" to 0, "predicate" to 0, "register" to 0, "consumer" to 0, "collection" to 0)
+ // These are the actual switch/action analytics, not a class-name substring such as "Tip".
+ val constructors = index.findMethods(InstallerMethodQuery(
+ strings = setOf("safe_mode_switch", "safe_mode_know_btn", "switch_action"),
+ returnType = "void", isStatic = false,
+ )).filter { constructor ->
+ constructor.name == "" && constructor.parameterTypes.size >= 2 &&
+ constructor.parameterTypes[0] == "android.content.Context" &&
+ index.clazz(constructor.owner)?.superName.let { it != null && it != "java.lang.Object" }
+ }
+ stages["model"] = constructors.map { it.owner }.distinct().size
+ stages["construction"] = constructors.size
+ val tipOwner = guardUnique("guard_tip.switch_model", constructors.map { it.owner }.distinct(), stages)
+ val anchor = guardUnique("guard_tip.switch_constructor", constructors.filter { it.owner == tipOwner }, stages)
+ val dataClass = anchor.parameterTypes[1]
+ val modelConstructors = classMethods(tipOwner).filter { it.name == "" }
+ val builders = modelConstructors.flatMap { index.callers(it.descriptor) }.distinctBy { it.descriptor }.filter { builder ->
+ builder.readFields.mapNotNull(index::field).any {
+ it.owner == dataClass && it.type == "java.lang.Boolean"
+ } && builder.invokes.any { guardListCall(it, indexed = false) }
+ }
+ stages["builder"] = builders.size
+ if (builders.isEmpty()) throw InstallerMatchException("guard_tip.model_builder", 0, guardStages(stages))
+
+ val adapters = index.findMethods(InstallerMethodQuery(
+ returnType = "boolean", parameterCount = 1, isStatic = false,
+ )).filter { guardAssignable(tipOwner, it.parameterTypes.single()) && !guardPureForwarder(it) }
+ stages["adapter"] = adapters.size
+ val candidates = adapters.filter { accepts ->
+ // The registry may inherit helpers, erase generic types, or inline its query/put helpers.
+ // Verify its bounded call/field relationships rather than one compiler's helper prototypes.
+ val family = guardRegistryClosure(accepts)
+ val predicates = family.filter { call -> call.invokes.any { guardMapCall(it, "containsKey") } }
+ if (predicates.isEmpty()) return@filter false
+ stages["predicate"] = stages.getValue("predicate") + 1
+ val queryFields = predicates.flatMap { guardMapFields(it, accepts.owner) }.toSet()
+ val registrations = family.filter { call -> call.invokes.any { guardMapCall(it, "put") } }
+ val registrationFields = registrations.flatMap { guardMapFields(it, accepts.owner) }.toSet()
+ if (queryFields.intersect(registrationFields).isEmpty()) return@filter false
+ stages["register"] = stages.getValue("register") + 1
+ val consumers = guardConsumers(accepts).filter { insert ->
+ insert.returnType in setOf("int", "boolean", "void") &&
+ insert.parameterTypes.count { guardAssignable(tipOwner, it) } == 1 &&
+ insert.readFields.mapNotNull(index::field).any { field ->
+ !field.isStatic && guardRelated(field.type, accepts.owner)
+ }
+ }
+ if (consumers.isEmpty()) return@filter false
+ stages["consumer"] = stages.getValue("consumer") + 1
+ // DTO metadata proves joint calls and typed fields, not register-level argument/branch dataflow.
+ val insertion = consumers.any { insert ->
+ val lists = insert.readFields.mapNotNull(index::field).filter { field ->
+ !field.isStatic && guardListType(field.type) && guardAssignable(insert.owner, field.owner)
+ }.distinctBy { it.descriptor }
+ lists.size == 1 && insert.invokes.any {
+ guardListCall(it, indexed = true) || guardListCall(it, indexed = false)
+ }
+ }
+ if (insertion) stages["collection"] = stages.getValue("collection") + 1
+ insertion
+ }
+ return InstallerResolvedPoint(
+ id = "guard_tip", method = guardUnique("guard_tip.adapter_accepts_model", candidates, stages),
+ guardedItemClass = tipOwner,
+ evidence = listOf("safe mode switch model + cloud flag list builder",
+ "registry query/put share a map field; typed registry consumer and list insertion",
+ "joint metadata relationships, not register-level dataflow", guardStages(stages)),
+ )
+ }
+
+ private fun guardStages(stages: Map): String =
+ "guard stages " + stages.entries.joinToString(" ") { "${it.key}=${it.value}" }
+
+ private fun guardUnique(role: String, candidates: List, stages: Map): T {
+ if (candidates.size != 1) throw InstallerMatchException(role, candidates.size, guardStages(stages))
+ return candidates.single()
+ }
+
+ private fun guardAncestors(type: String): Set {
+ val found = linkedSetOf()
+ val pending = ArrayDeque()
+ pending.add(type)
+ while (pending.isNotEmpty() && found.size < 64) {
+ val next = pending.removeFirst()
+ if (!found.add(next)) continue
+ index.clazz(next)?.let { clazz ->
+ clazz.superName?.let(pending::addLast)
+ clazz.interfaces.forEach(pending::addLast)
+ }
+ }
+ return found
+ }
+
+ private fun guardAssignable(type: String, parent: String): Boolean =
+ parent == "java.lang.Object" || parent in guardAncestors(type)
+
+ private fun guardRelated(left: String, right: String): Boolean =
+ left != "java.lang.Object" && right != "java.lang.Object" &&
+ (guardAssignable(left, right) || guardAssignable(right, left))
+
+ private fun guardMapType(type: String): Boolean = guardAncestors(type).any { it in GUARD_MAP_TYPES }
+
+ private fun guardListType(type: String): Boolean = guardAncestors(type).any { it in GUARD_LIST_TYPES }
+
+ private fun guardMapCall(descriptor: String, name: String): Boolean {
+ val suffix = when (name) {
+ "containsKey" -> ";->containsKey(Ljava/lang/Object;)Z"
+ "put" -> ";->put(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;"
+ else -> return false
+ }
+ return descriptor.startsWith("L") && descriptor.endsWith(suffix) &&
+ guardMapType(descriptor.substring(1, descriptor.length - suffix.length).replace('/', '.'))
+ }
+
+ private fun guardListCall(descriptor: String, indexed: Boolean): Boolean {
+ val suffix = if (indexed) ";->add(ILjava/lang/Object;)V" else ";->add(Ljava/lang/Object;)Z"
+ if (!descriptor.startsWith("L") || !descriptor.endsWith(suffix)) return false
+ val owner = descriptor.substring(1, descriptor.length - suffix.length).replace('/', '.')
+ return guardListType(owner) || !indexed && owner == "java.util.Collection"
+ }
+
+ private fun guardMapFields(method: InstallerMethod, registry: String): List =
+ method.readFields.mapNotNull(index::field).filter {
+ !it.isStatic && guardMapType(it.type) && guardAssignable(registry, it.owner)
+ }.map { it.descriptor }
+
+ private fun guardRegistryClosure(start: InstallerMethod): List {
+ val found = linkedMapOf()
+ val pending = ArrayDeque>()
+ pending.add(start to 0)
+ while (pending.isNotEmpty() && found.size < 32) {
+ val (next, depth) = pending.removeFirst()
+ if (found.putIfAbsent(next.descriptor, next) != null || depth >= 3) continue
+ next.invokes.mapNotNull(index::method).filter { call ->
+ !call.isStatic && call.name != "" && guardAssignable(start.owner, call.owner)
+ }.forEach { pending.addLast(it to depth + 1) }
+ }
+ return found.values.toList()
+ }
+
+ private fun guardConsumers(accepts: InstallerMethod): List {
+ val found = linkedMapOf()
+ val pending = ArrayDeque>()
+ index.callers(accepts.descriptor).forEach { pending.add(it to 0) }
+ while (pending.isNotEmpty() && found.size < 32) {
+ val (next, depth) = pending.removeFirst()
+ if (found.putIfAbsent(next.descriptor, next) != null || depth >= 2) continue
+ if (guardPureForwarder(next)) index.callers(next.descriptor).forEach { pending.add(it to depth + 1) }
+ }
+ return found.values.toList()
+ }
+
+ private fun guardPureForwarder(method: InstallerMethod): Boolean =
+ method.returnType == "boolean" && method.parameterTypes.size == 1 &&
+ method.strings.isEmpty() && method.readFields.isEmpty() && method.writtenFields.isEmpty() &&
+ method.invokes.size == 1 && method.opcodes.isNotEmpty() && method.opcodes.last() == 0x0f &&
+ method.opcodes.all { it in GUARD_FORWARDER_OPCODES } &&
+ method.opcodes.count { it in GUARD_FORWARDER_INVOKES } == 1
+
+ private fun frequentCheck(): InstallerResolvedPoint {
+ val candidates = index.findMethods(InstallerMethodQuery(
+ numbers = setOf(86_400_000L, 0L, 1L, 2L, 3L), returnType = "int", parameterCount = 0, isStatic = false,
+ )).filter { level ->
+ val ownFields = level.readFields.mapNotNull(index::field).filter { it.owner == level.owner }
+ .distinctBy { it.descriptor }
+ val countFields = ownFields.filter { it.type == "int" && !it.isStatic }
+ val packageFields = ownFields.filter { it.type == "java.lang.String" && !it.isStatic }
+ val countColumns = countFields.flatMap { it.annotationStrings }.toSet()
+ val packageColumns = packageFields.flatMap { it.annotationStrings }.toSet()
+ countFields.size == 3 && packageFields.size == 1 &&
+ countColumns.containsAll(setOf("rcafvc", "rchvc", "rcsvc")) && "package_name" in packageColumns &&
+ level.writtenFields.isEmpty() && LIST_SIZE in level.invokes &&
+ level.invokes.mapNotNull(index::method).any { history ->
+ history.parameterTypes == listOf("java.lang.String", "long") &&
+ history.returnType == "java.util.List" &&
+ history.strings.map(::compactSql).let { sql ->
+ "installer_package_name=?" in sql && "install_time>?" in sql
+ }
+ } && index.callers(level.descriptor).any { caller ->
+ caller.returnType == "void" && caller.parameterTypes.isEmpty() &&
+ isSubclassOf(caller.owner, "android.app.Activity")
+ }
+ }
+ return InstallerResolvedPoint(
+ id = "frequent_check", method = uniqueInstallerMatch("frequent_check.install_history_level", candidates),
+ evidence = listOf("24h install history + three annotated frequency thresholds", "pre-install Activity consumes level"),
+ )
+ }
+
+ private fun classMethods(owner: String): List =
+ index.clazz(owner)?.methods.orEmpty().mapNotNull(index::method)
+
+ private fun ownerStrings(owner: String): Set = classMethods(owner).flatMap { it.strings }.toSet()
+
+ private fun isSubclassOf(type: String, parent: String): Boolean {
+ val visited = mutableSetOf()
+ var current: String? = type
+ while (current != null && visited.add(current)) {
+ if (current == parent) return true
+ current = index.clazz(current)?.superName
+ }
+ return false
+ }
+
+ private fun compactSql(value: String): String = value.filterNot(Char::isWhitespace).lowercase()
+
+ companion object {
+ private const val LIST_SIZE = "Ljava/util/List;->size()I"
+ private val GUARD_MAP_TYPES = setOf("java.util.Map", "java.util.HashMap", "java.util.LinkedHashMap",
+ "java.util.TreeMap", "java.util.AbstractMap", "java.util.concurrent.ConcurrentMap", "java.util.concurrent.ConcurrentHashMap")
+ private val GUARD_LIST_TYPES = setOf("java.util.List", "java.util.ArrayList", "java.util.LinkedList",
+ "java.util.AbstractList", "java.util.AbstractSequentialList", "java.util.Vector", "java.util.concurrent.CopyOnWriteArrayList")
+ private val GUARD_FORWARDER_OPCODES = setOf(0x00, 0x01, 0x02, 0x03, 0x07, 0x08, 0x09, 0x0a,
+ 0x0f, 0x1f, 0x6e, 0x70, 0x72, 0x74, 0x76, 0x78)
+ private val GUARD_FORWARDER_INVOKES = setOf(0x6e, 0x70, 0x72, 0x74, 0x76, 0x78)
+ private val ACCESSOR_INVOKES = setOf(0x6e, 0x70, 0x74, 0x76) // virtual/direct, ordinary/range
+ private val ACCESSOR_OPCODES = ACCESSOR_INVOKES + setOf(0x01, 0x02, 0x03, 0x07, 0x08, 0x09, 0x0a, 0x0f)
+ }
+}
diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerMethodSnapshots.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerMethodSnapshots.kt
new file mode 100644
index 0000000..f633578
--- /dev/null
+++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerMethodSnapshots.kt
@@ -0,0 +1,33 @@
+package cn.ianzb.hyperrefine.hook.packageinstaller
+
+/** Session-only snapshots. A reference with no metadata must never hide a later definition. */
+internal class InstallerMethodSnapshots {
+ private data class Snapshot(val method: InstallerMethod, val complete: Boolean)
+ private val snapshots = mutableMapOf()
+
+ fun complete(descriptor: String): InstallerMethod? = snapshots[descriptor]?.takeIf { it.complete }?.method
+
+ fun remember(method: InstallerMethod, declaredWithoutCode: Boolean): InstallerMethod {
+ val normalized = method.copy(
+ invokes = method.invokes.distinct().sorted(),
+ readFields = method.readFields.distinct().sorted(),
+ writtenFields = method.writtenFields.distinct().sorted(),
+ )
+ val complete = declaredWithoutCode || normalized.opcodes.isNotEmpty() ||
+ normalized.strings.isNotEmpty() || normalized.annotationStrings.isNotEmpty() ||
+ normalized.invokes.isNotEmpty() || normalized.readFields.isNotEmpty() || normalized.writtenFields.isNotEmpty()
+ val previous = snapshots[normalized.descriptor]
+ if (previous?.complete == true) {
+ if (complete && previous.method != normalized) {
+ throw InstallerMatchException("dex_index.metadata_conflict", 2,
+ "inconsistent complete method snapshots; dependent point preserves original behavior")
+ }
+ return previous.method
+ }
+ // Access flags in an empty reference are unknown. In particular, false isStatic is not evidence.
+ snapshots[normalized.descriptor] = Snapshot(normalized, complete)
+ return normalized
+ }
+
+ fun clear() = snapshots.clear()
+}
diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerNetworkRules.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerNetworkRules.kt
new file mode 100644
index 0000000..1a111cb
--- /dev/null
+++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerNetworkRules.kt
@@ -0,0 +1,568 @@
+package cn.ianzb.hyperrefine.hook.packageinstaller
+
+import java.net.URI
+
+/** Business-role discovery. No obfuscated owner or method name is an input to these rules. */
+internal class InstallerNetworkRules @JvmOverloads constructor(private val index: InstallerDexIndex,
+ private val nullReturnProof: (InstallerMethod) -> Boolean = { false }) {
+ fun resolve(id: String): InstallerResolvedPoint {
+ failedLayoutDiagnostics.remove(id)
+ pendingLayoutDiagnostics.remove(id)
+ if (id == "layout_async" || id == "layout_sync") {
+ pendingLayoutDiagnostics[id] = listOf("probe ${id}_0 detail=response_model_stage_not_reached")
+ }
+ return try {
+ when (id) {
+ "layout_async" -> layoutAsync()
+ "layout_sync" -> layoutSync()
+ "intercept_check" -> intercept()
+ "avl_upload" -> upload()
+ "cloud_v2" -> cloudV2()
+ "cloud_v3" -> cloudV3()
+ else -> throw InstallerMatchException(id, 0, "unknown network business role")
+ }
+ } catch (failure: InstallerMatchException) {
+ pendingLayoutDiagnostics[id]?.let { failedLayoutDiagnostics[id] = it }
+ throw failure
+ }
+ }
+
+ /** Static metadata only. A failed role contributes at most two bounded lines. */
+ internal fun diagnosticLines(): List = failedLayoutDiagnostics.values.flatten()
+
+ private data class LayoutSource(val method: InstallerMethod, val requestTypes: List)
+ private data class LayoutRoot(val method: InstallerMethod, val requestTypes: List)
+ private data class LayoutGraph(val sources: List, val roots: List)
+ private data class LayoutClosure(val methods: List, val visited: Int,
+ val truncated: Boolean, val edges: Int, val depthStops: Int)
+ private data class LayoutPayloadEvidence(val payloads: Set, val fields: Set,
+ val getters: Set, val apiTypes: Set, val nullablePayloads: Set)
+ private data class LayoutModelCandidate(val root: LayoutRoot, val callback: InstallerMethod?,
+ val evidence: LayoutPayloadEvidence)
+ private val layoutGraph by lazy { discoverLayoutGraph() }
+ private val layoutCoreKeys = setOf("packageName", "installSource", "newInstall", "appName", "isPersonalizedAdEnabled")
+ private val layoutClosures = mutableMapOf()
+ private val layoutReferences = mutableMapOf()
+ private val ambiguousLayoutReferences = mutableSetOf()
+ private val pendingLayoutDiagnostics = linkedMapOf>()
+ private val failedLayoutDiagnostics = linkedMapOf>()
+ private val layoutSchemas = mutableMapOf()
+
+ /** Logs, optional request fields and the other completion style are not compatibility gates. */
+ private fun layoutSync(): InstallerResolvedPoint {
+ val stages = layoutStages()
+ val endpoint = layoutEndpointRoots(stages)
+ val signature = endpoint.filter { root ->
+ root.method.returnType == "java.lang.Object" && requestShape(root) &&
+ layoutContinuation(root.method.parameterTypes.last())
+ }.distinctBy { it.method.descriptor }
+ stages["signature"] = signature.size
+ val evaluated = signature.map { LayoutModelCandidate(it, null, layoutPayload(it.method)) }
+ recordLayoutModels("layout_sync", stages, evaluated)
+ val model = evaluated.mapNotNull { candidate -> candidate.evidence.payloads.singleOrNull()?.let { candidate to it } }
+ stages["model"] = model.size
+ val metadataNull = model.mapNotNull { (candidate, payload) ->
+ val method = candidate.root.method
+ method.descriptor.takeIf { hasZero(method) && method.opcodes.any { it in NULL_CONSTANTS } &&
+ RETURN_OBJECT in method.opcodes && (hasEmptyObjectReturn(method) || payload in candidate.evidence.nullablePayloads) }
+ }.toSet()
+ val operandNull = model.mapNotNull { (candidate, _) ->
+ val method = candidate.root.method
+ val proven = method.descriptor !in metadataNull && try { nullReturnProof(method) } catch (_: Throwable) { false }
+ method.descriptor.takeIf { proven }
+ }.toSet()
+ stages["operand_null"] = operandNull.size
+ val nullable = model.filter { (candidate, _) ->
+ val method = candidate.root.method
+ // The producer may keep its null register across logging calls. The typed response mapper
+ // can independently prove the narrow empty return. An inlined/direct response needs that
+ // narrow empty return on the producer itself; an erased Object return is insufficient.
+ method.descriptor in operandNull || method.descriptor in metadataNull
+ }
+ stages["nullable"] = nullable.size
+ val outer = outermostLayoutRoots(nullable.map { it.first.root })
+ stages["entry"] = outer.size
+ val root = uniqueLayout("layout_sync", outer, stages)
+ val payload = nullable.single { it.first.root.method.descriptor == root.method.descriptor }.second
+ return InstallerResolvedPoint("layout_sync", root.method, payloadClass = payload, evidence = listOf(
+ "layout endpoint + core request-map keys + matching request argument roles",
+ "unique layout payload schema; metadata empty completion or bounded operand-CFG zero return (not full path feasibility)",
+ "routes=${layoutRoutes(root.method).joinToString()}; route alone never satisfies the response/null policy",
+ "sync discovery is independent of callback API and diagnostic log text; ${stageText(stages)}",
+ ))
+ }
+
+ private fun layoutAsync(): InstallerResolvedPoint {
+ val stages = layoutStages()
+ val endpoint = layoutEndpointRoots(stages)
+ val signature = endpoint.filter { root ->
+ root.method.returnType == "void" && requestShape(root) &&
+ !layoutContinuation(root.method.parameterTypes.last())
+ }.distinctBy { it.method.descriptor }
+ stages["signature"] = signature.size
+ val callbacks = signature.mapNotNull { root ->
+ val contracts = index.findMethods(InstallerMethodQuery(owner = root.method.parameterTypes.last(),
+ returnType = "void", parameterCount = 1, isStatic = false)).filter { it.name != "" }
+ contracts.singleOrNull()?.let { root to it }
+ }
+ stages["callback"] = callbacks.size
+ val evaluated = callbacks.map { (root, callback) ->
+ LayoutModelCandidate(root, callback, layoutPayload(root.method, callback.owner))
+ }
+ recordLayoutModels("layout_async", stages, evaluated)
+ val model = evaluated.filter { candidate ->
+ val callback = checkNotNull(candidate.callback)
+ callback.owner != callback.parameterTypes.single() &&
+ candidate.evidence.payloads.singleOrNull() == callback.parameterTypes.single()
+ }
+ stages["model"] = model.size
+ val nullable = model.filter { hasNullCallbackFailure(checkNotNull(it.callback)) }
+ stages["nullable"] = nullable.size
+ val outer = outermostLayoutRoots(nullable.map { it.root })
+ stages["entry"] = outer.size
+ val root = uniqueLayout("layout_async", outer, stages)
+ val callback = checkNotNull(nullable.single { it.root.method.descriptor == root.method.descriptor }.callback)
+ return InstallerResolvedPoint("layout_async", root.method, callback = callback,
+ payloadClass = callback.parameterTypes.single(), evidence = listOf(
+ "layout endpoint + core request map + explicit call/Runnable execution chain",
+ "callback agrees with unique layout response payload schema; existing null-only completion helper",
+ "routes=${layoutRoutes(root.method).joinToString()}; route alone never satisfies the callback policy",
+ "async discovery is independent of sync method and diagnostic log text; ${stageText(stages)}",
+ ))
+ }
+
+ private fun discoverLayoutGraph(): LayoutGraph {
+ val sources = index.findMethods(InstallerMethodQuery(strings = layoutCoreKeys)).mapNotNull { method ->
+ val types = if (method.returnType in MAP_TYPES) method.parameterTypes else method.parameterTypes.dropLast(1)
+ if (!requestRoles(types) || method.invokes.none { it in MAP_PUTS }) null else LayoutSource(method, types)
+ }
+ val roots = sources.flatMap { source ->
+ reverseExecution(source.method).map { LayoutRoot(it, source.requestTypes) }
+ }.distinctBy { it.method.descriptor to it.requestTypes }
+ return LayoutGraph(sources, roots)
+ }
+
+ // Model counters come first so a bounded diagnostic retains the first failing response contract.
+ private fun layoutStages() = linkedMapOf("payload_types" to 0, "field_reads" to 0, "getters" to 0,
+ "api_types" to 0, "empty_model" to 0, "multi_model" to 0, "type_mismatch" to 0,
+ "map_sources" to layoutGraph.sources.size,
+ "producer_roots" to layoutGraph.roots.distinctBy { it.method.descriptor }.size)
+ private fun stageText(stages: Map) = "stages=" + stages.entries.joinToString(",") { "${it.key}:${it.value}" }
+ private fun uniqueLayout(role: String, values: List, stages: Map): T {
+ if (values.size != 1) throw InstallerMatchException(role, values.size, stageText(stages))
+ return values.single()
+ }
+
+ // Current business role: textual app/channel values and one integer new-install value.
+ // These are scenario constraints, not a claim about all future installer protocols.
+ private fun requestRoles(types: List) = types.count { it == "java.lang.String" } >= 3 &&
+ types.any { it == "java.lang.Integer" || it == "int" }
+ private fun requestShape(root: LayoutRoot): Boolean = root.method.parameterTypes.size == root.requestTypes.size + 1 &&
+ root.method.parameterTypes.dropLast(1).map(::requestType).sorted() == root.requestTypes.map(::requestType).sorted()
+ private fun requestType(type: String) = if (type == "java.lang.Integer") "int" else type
+ private fun layoutEndpointRoots(stages: MutableMap): List {
+ val roots = layoutGraph.roots.filter { layoutRoutes(it.method).isNotEmpty() }
+ val closures = layoutGraph.roots.distinctBy { it.method.descriptor }.map { layoutClosure(it.method) }
+ stages["endpoint_roots"] = roots.distinctBy { it.method.descriptor }.size
+ stages["visited"] = closures.sumOf { it.visited }
+ stages["truncated"] = closures.count { it.truncated }
+ stages["depth_stops"] = closures.sumOf { it.depthStops }
+ stages["path_methods"] = closures.flatMap { it.methods }.filter { layoutApiRoutes(it).isNotEmpty() }
+ .distinctBy { it.descriptor }.size
+ stages["ambiguous_refs"] = ambiguousLayoutReferences.size
+ return roots
+ }
+
+ private fun layoutRoutes(method: InstallerMethod) = executionClosure(method).flatMap(::layoutApiRoutes).toSortedSet()
+ private fun layoutApiRoutes(method: InstallerMethod): Set =
+ if (method.returnType in PRIMITIVES || method.parameterTypes.none { it in MAP_TYPES }) emptySet()
+ else method.annotationStrings.mapNotNull(::normalizeLayoutRoute).toSet()
+
+ /** Constant route equivalence only; similar statistics/reporting paths are not layout APIs. */
+ private fun normalizeLayoutRoute(value: String): String? = runCatching {
+ val uri = URI(value.trim())
+ if (uri.isOpaque || uri.rawQuery != null || uri.rawFragment != null || uri.rawUserInfo != null) return@runCatching null
+ if (uri.scheme != null && (uri.scheme.lowercase() !in setOf("http", "https") || uri.host.isNullOrEmpty())) return@runCatching null
+ val path = uri.rawPath?.let { if (it.startsWith('/')) it else "/$it" }?.trimEnd('/') ?: return@runCatching null
+ path.takeIf { LAYOUT_ROUTE.matches(it) }
+ }.getOrNull()
+
+ /** Resolve an inherited reference only when its owner has no same-signature declaration. */
+ private fun layoutMethod(reference: String): InstallerMethod? {
+ if (layoutReferences.containsKey(reference)) return layoutReferences[reference]
+ val direct = index.method(reference)
+ val marker = reference.indexOf(";->")
+ if (!reference.startsWith('L') || marker < 1 || direct?.let {
+ it.descriptor == reference && (it.invokes.isNotEmpty() || it.opcodes.isNotEmpty())
+ } == true) {
+ layoutReferences[reference] = direct
+ return direct
+ }
+ val owner = reference.substring(1, marker).replace('/', '.')
+ val signature = reference.substring(marker + 3)
+ val clazz = index.clazz(owner)
+ if (clazz == null || clazz.methods.any { it.substringAfter(";->", "") == signature }) {
+ layoutReferences[reference] = direct
+ return direct
+ }
+ val queue = ArrayDeque()
+ queue.addAll(clazz.interfaces)
+ clazz.superName?.takeIf { it != "java.lang.Object" }?.let(queue::addLast)
+ val seen = mutableSetOf()
+ val declarations = linkedMapOf()
+ while (queue.isNotEmpty() && seen.size < 32) {
+ val type = queue.removeFirst()
+ if (!seen.add(type)) continue
+ val parent = index.clazz(type) ?: continue
+ val own = parent.methods.filter { it.substringAfter(";->", "") == signature }
+ if (own.isNotEmpty()) own.mapNotNull(index::method).forEach { declarations[it.descriptor] = it }
+ else {
+ queue.addAll(parent.interfaces)
+ parent.superName?.takeIf { it != "java.lang.Object" }?.let(queue::addLast)
+ }
+ }
+ val result = when {
+ declarations.size > 1 || queue.isNotEmpty() -> null
+ declarations.size == 1 -> declarations.values.single()
+ else -> direct
+ }
+ if (declarations.size > 1 || queue.isNotEmpty()) ambiguousLayoutReferences += reference
+ layoutReferences[reference] = result
+ return result
+ }
+
+ /** Inverse edges are explicit invokes, plus Runnable.run -> callers of that Runnable's constructor. */
+ private fun reverseExecution(start: InstallerMethod): List {
+ val found = linkedMapOf()
+ val queue = ArrayDeque>()
+ queue.addLast(start to 0)
+ while (queue.isNotEmpty() && found.size < 128) {
+ val (method, depth) = queue.removeFirst()
+ if (found.putIfAbsent(method.descriptor, method) != null || depth >= 5) continue
+ index.callers(method.descriptor).forEach { queue.addLast(it to depth + 1) }
+ if (method.name == "run" && runnable(method.owner)) {
+ index.clazz(method.owner)?.methods.orEmpty().mapNotNull(index::method)
+ .filter { it.name == "" }.flatMap { index.callers(it.descriptor) }
+ .forEach { queue.addLast(it to depth + 1) }
+ }
+ }
+ return found.values.toList()
+ }
+
+ private fun outermostLayoutRoots(roots: List): List = roots.filter { root ->
+ val ancestors = reverseExecution(root.method).drop(1).map { it.descriptor }.toSet()
+ roots.none { it.method.descriptor in ancestors }
+ }
+
+ private fun layoutPayload(entry: InstallerMethod, callbackType: String? = null): LayoutPayloadEvidence {
+ val body = executionClosure(entry).toMutableList()
+ if (callbackType != null) {
+ // Retrofit completion objects capture the public callback. Inspect only those constructed objects.
+ val replies = body.filter { it.name == "" && classFields(it.owner).any { field -> field.type == callbackType } }
+ replies.flatMap { index.clazz(it.owner)?.methods.orEmpty() }.mapNotNull(index::method)
+ .filter { it.name != "" && it.name != "" }
+ .forEach { body += executionClosure(it) }
+ }
+ val methods = body.distinctBy { it.descriptor }
+ val fieldReads = linkedMapOf()
+ val getters = linkedMapOf()
+ val nullablePayloads = mutableSetOf()
+ for (method in methods) {
+ val reads = layoutResponseReads(method)
+ reads.forEach { fieldReads[it.descriptor] = it }
+ if (reads.any { pureLayoutGetter(method, it) }) getters[method.descriptor] = method
+ // A mapper can inline the response field read or delegate to a pure accessor. Its
+ // parameter type/name is deliberately irrelevant: the v2 wrapper is not the protocol.
+ val accessedTypes = reads.map { it.type }.toMutableSet()
+ method.invokes.mapNotNull(::layoutMethod).forEach { called ->
+ layoutResponseReads(called).filter { field ->
+ pureLayoutGetter(called, field) ||
+ // Preserve the previously verified typed wrapper-mapper contract even when
+ // the bounded closure observes its accessor only on the direct frontier.
+ method.returnType == field.type && field.owner in method.parameterTypes &&
+ called.owner == field.owner && called.parameterTypes.isEmpty() && called.returnType == field.type
+ }.forEach { field ->
+ fieldReads[field.descriptor] = field
+ if (pureLayoutGetter(called, field)) getters[called.descriptor] = called
+ accessedTypes += field.type
+ }
+ }
+ if (method.returnType in accessedTypes && hasEmptyObjectReturn(method)) nullablePayloads += method.returnType
+ }
+ // Annotation values are flattened by the index. Do not join fragments or pretend to parse a
+ // complete Signature annotation. Only complete class tokens on an actual layout API count,
+ // and only classes with the same concrete advertisement schema can supply a payload type.
+ val apiTypes = methods.filter { layoutApiRoutes(it).isNotEmpty() }.flatMap { api ->
+ (layoutAnnotationTypes(api.annotationStrings) + api.returnType).filter(::layoutPayloadSchema)
+ }.toSet()
+ return LayoutPayloadEvidence(fieldReads.values.map { it.type }.toSet() + apiTypes,
+ fieldReads.keys, getters.keys, apiTypes, nullablePayloads)
+ }
+
+ private fun layoutPayloadSchema(type: String): Boolean = layoutSchemas.getOrPut(type) {
+ if (type in PRIMITIVES || type == "java.lang.Object" || type.endsWith("[]")) false
+ else classFields(type).let { payload ->
+ payload.any { named(it, "data") && it.type in setOf("java.util.List", "java.util.ArrayList") } &&
+ payload.any { named(it, "channel") && it.type == "java.lang.String" } &&
+ payload.any { (named(it, "layoutFormat") || named(it, "layoutVersionType")) && it.type == "java.lang.String" }
+ }
+ }
+
+ private fun layoutResponseReads(method: InstallerMethod): List = method.readFields.mapNotNull(index::field)
+ .filter { !it.isStatic && (named(it, "data") || named(it, "adData")) && layoutPayloadSchema(it.type) }
+
+ private fun pureLayoutGetter(method: InstallerMethod, field: InstallerField): Boolean {
+ val opcodes = method.opcodes.filter { it != NOP && it !in OBJECT_MOVES }
+ return !method.isStatic && method.owner == field.owner && method.parameterTypes.isEmpty() &&
+ method.returnType == field.type && method.readFields == listOf(field.descriptor) &&
+ method.writtenFields.isEmpty() && method.invokes.isEmpty() && opcodes == listOf(IGET_OBJECT, RETURN_OBJECT)
+ }
+
+ private fun layoutAnnotationTypes(values: Set): Set = values.flatMap { value ->
+ LAYOUT_CLASS_TOKEN.findAll(value).map { it.groupValues[1].replace('/', '.') }.toList()
+ }.toSet()
+
+ private fun recordLayoutModels(role: String, stages: MutableMap, candidates: List) {
+ fun types(candidate: LayoutModelCandidate) = candidate.evidence.payloads
+ val payloads = candidates.flatMap(::types).toSortedSet()
+ stages["payload_types"] = payloads.size
+ stages["field_reads"] = candidates.flatMap { it.evidence.fields }.toSet().size
+ stages["getters"] = candidates.flatMap { it.evidence.getters }.toSet().size
+ stages["api_types"] = candidates.flatMap { it.evidence.apiTypes }.toSet().size
+ stages["empty_model"] = candidates.count { types(it).isEmpty() }
+ stages["multi_model"] = candidates.count { types(it).size > 1 }
+ stages["type_mismatch"] = candidates.count { candidate -> candidate.callback?.let { callback ->
+ val payload = types(candidate).singleOrNull()
+ payload != null && (callback.owner == callback.parameterTypes.single() || payload != callback.parameterTypes.single())
+ } == true }
+ val counts = listOf("payload_types", "field_reads", "getters", "api_types", "empty_model", "multi_model", "type_mismatch")
+ .joinToString(",") { "$it:${stages.getValue(it)}" }
+ val callbackTypes = candidates.mapNotNull { it.callback?.parameterTypes?.single() }.distinct().sorted()
+ val schemas = callbackTypes.joinToString(",") { "${layoutTypeDescriptor(it)}:${layoutPayloadSchema(it)}" }
+ val callbackDescriptors = candidates.mapNotNull { it.callback?.descriptor }.distinct().sorted()
+ pendingLayoutDiagnostics[role] = listOf(
+ "probe ${role}_0 detail=candidates=${candidates.size};$counts;callback_schema=$schemas",
+ "probe ${role}_1 detail=payloads=${payloads.joinToString(",", transform = ::layoutTypeDescriptor)};callbacks=${callbackDescriptors.joinToString(",")}",
+ ).map { it.take(384) }
+ }
+
+ private fun layoutTypeDescriptor(type: String) = "L${type.replace('.', '/')};"
+
+ private fun named(field: InstallerField, name: String) = !field.isStatic && (field.name == name || name in field.annotationStrings)
+ private fun hasZero(method: InstallerMethod) = index.findMethods(InstallerMethodQuery(owner = method.owner,
+ numbers = setOf(0L))).any { it.descriptor == method.descriptor }
+ private fun hasEmptyObjectReturn(method: InstallerMethod): Boolean {
+ val opcodes = method.opcodes.filter { it != NOP && it !in OBJECT_MOVES }
+ return hasZero(method) && opcodes.zipWithNext().any { (first, second) -> first in NULL_CONSTANTS && second == RETURN_OBJECT }
+ }
+
+ private fun layoutContinuation(type: String): Boolean {
+ val pending = ArrayDeque()
+ val seen = mutableSetOf()
+ val methods = mutableListOf()
+ pending.addLast(type)
+ while (pending.isNotEmpty() && seen.size < 16) {
+ val current = pending.removeFirst()
+ if (!seen.add(current)) continue
+ if (current == "kotlin.coroutines.Continuation") return true
+ methods += index.findMethods(InstallerMethodQuery(owner = current, isStatic = false))
+ .filter { it.name != "" && it.name != "" }
+ index.clazz(current)?.let { clazz ->
+ pending.addAll(clazz.interfaces)
+ clazz.superName?.takeIf { it != "java.lang.Object" }?.let(pending::addLast)
+ }
+ }
+ return methods.any { it.returnType == "void" && it.parameterTypes == listOf("java.lang.Object") } &&
+ methods.any { it.parameterTypes.isEmpty() && it.returnType !in PRIMITIVES && it.returnType != "java.lang.Object" }
+ }
+
+ private fun hasNullCallbackFailure(callback: InstallerMethod): Boolean = index.findMethods(InstallerMethodQuery(
+ numbers = setOf(0L), returnType = "void", parameterTypes = listOf(callback.owner), isStatic = true,
+ )).any { helper ->
+ val opcodes = helper.opcodes.filter { it != NOP && it !in OBJECT_MOVES }
+ // The DTO has no register operands. Keep a narrow zero-only helper: no field access,
+ // allocation, cast, other invoke or payload producer; tolerate only control/move encoding changes.
+ helper.readFields.isEmpty() && helper.writtenFields.isEmpty() && helper.invokes == listOf(callback.descriptor) &&
+ opcodes.size in 3..7 && opcodes.all { it in EMPTY_CALLBACK_OPS } &&
+ opcodes.count { it in NULL_CONSTANTS } == 1 && opcodes.count { it in CALLBACK_INVOKES } == 1 &&
+ opcodes.count { it == RETURN_VOID } in 1..2 && opcodes.count { it == IF_EQZ || it == IF_NEZ } <= 1 &&
+ opcodes.last() == RETURN_VOID
+ }
+
+ private fun intercept(): InstallerResolvedPoint {
+ val method = uniqueInstallerMatch("intercept_check", index.findMethods(InstallerMethodQuery(
+ strings = setOf("device_type", "packageName", "installationMode", "apk_bit", "apkSignature",
+ "androidSdkVersion", "sourceAppName"), returnType = "java.lang.Object", parameterCount = 6,
+ isStatic = false,
+ )).filter {
+ it.parameterTypes[0] == "android.content.Context" && it.parameterTypes[2] == "int" &&
+ it.parameterTypes[4] == "java.util.HashMap" && continuation(it.parameterTypes[5]) &&
+ it.invokes.mapNotNull(index::method).map { api -> api.annotationStrings.map(String::trim).toSet() }
+ .let { paths -> paths.any { "/v4/game/interceptcheck/normal" in it } &&
+ paths.any { "/v4/game/interceptcheck/safemode" in it } &&
+ paths.any { "/v4/game/interceptcheck/enhancemode" in it } }
+ })
+ val constructors = method.invokes.mapNotNull(index::method).filter { constructor ->
+ constructor.name == "" && constructor.parameterTypes.size == 1 &&
+ cloudPayload(constructor.parameterTypes.single()) &&
+ index.clazz(constructor.owner)?.superName?.let { it != "java.lang.Object" } == true &&
+ classFields(constructor.owner).any { it.type == constructor.parameterTypes.single() && !it.isStatic }
+ }.distinctBy { it.descriptor }
+ val success = uniqueInstallerMatch("intercept_success_constructor", constructors)
+ val payload = success.parameterTypes.single()
+ requireInstallerMatch("intercept_payload_constructor", index.findMethods(InstallerMethodQuery(
+ owner = payload, returnType = "void", parameterTypes = emptyList(), isStatic = false,
+ )).count { it.name == "" } == 1, "empty CloudParams role requires one no-arg constructor")
+ val resultBase = index.clazz(success.owner)?.superName
+ requireInstallerMatch("intercept_success_base", resultBase != null && index.clazz(resultBase) != null,
+ "typed success result must extend a target-loader result base")
+ return InstallerResolvedPoint("intercept_check", method, payloadClass = payload,
+ resultClass = success.owner, resultBaseClass = resultBase, evidence = listOf(
+ "seven installation/device payload keys + three exact intercept API route annotations",
+ "typed one-payload success constructor + cloud fields + no-arg payload constructor",
+ ))
+ }
+
+ private fun cloudPayload(type: String): Boolean {
+ val fields = classFields(type)
+ fun has(name: String, expectedType: String? = null) = fields.any {
+ (it.name == name || name in it.annotationStrings) && (expectedType == null || it.type == expectedType)
+ }
+ return has("showAdsBefore", "java.lang.Boolean") && has("showAdsAfter", "java.lang.Boolean") &&
+ has("showSafeModeTip", "java.lang.Boolean") && has("uiConfig") && has("rc") && has("verifyAccount", "java.lang.Integer")
+ }
+
+ private fun cloudV2(): InstallerResolvedPoint {
+ // Use the actual void producer, before data gathering, rather than a name-dependent scheduler wrapper.
+ val method = uniqueInstallerMatch("cloud_v2", index.findMethods(InstallerMethodQuery(
+ strings = setOf("cloudConfig", "last_sync_time", "firstboot_time", "source_auth_reset_v",
+ "skip sync cloud config"), returnType = "void", parameterTypes = emptyList(), isStatic = true,
+ )).filter { directEndpoint(it, "/v2/cloud_config/query") &&
+ "Landroid/content/SharedPreferences;->getLong(Ljava/lang/String;J)J" in it.invokes
+ })
+ return InstallerResolvedPoint("cloud_v2", method, evidence = listOf(
+ "v2 endpoint annotation + cloudConfig/last_sync_time + firstboot/source-auth request fields",
+ "actual zero-arg static void producer; SharedPreferences freshness reader",
+ ))
+ }
+
+ private fun cloudV3(): InstallerResolvedPoint {
+ val method = uniqueInstallerMatch("cloud_v3", index.findMethods(InstallerMethodQuery(
+ strings = setOf("BlockApkListManager", "V3 config sync failed", "http.agent"),
+ returnType = "java.lang.Object", parameterCount = 2, isStatic = true,
+ )).filter { it.parameterTypes[0] == "java.lang.Runnable" && continuation(it.parameterTypes[1]) &&
+ directEndpoint(it, "/v3/cloud_config/query") && "Ljava/lang/Runnable;->run()V" in it.invokes
+ })
+ val singleton = uniqueInstallerMatch("cloud_v3_unit", method.readFields.mapNotNull(index::field).filter { field ->
+ field.isStatic && field.owner == field.type && index.findMethods(InstallerMethodQuery(
+ owner = field.type, strings = setOf("kotlin.Unit"), returnType = "java.lang.String",
+ parameterTypes = emptyList(), isStatic = false,
+ )).size == 1
+ }.distinctBy { it.descriptor })
+ requireInstallerMatch("cloud_v3_return", method.opcodes.lastOrNull() == RETURN_OBJECT,
+ "suspend producer requires an object completion")
+ return InstallerResolvedPoint("cloud_v3", method, singletonField = singleton,
+ evidence = listOf("v3 endpoint + failure diagnostic + Runnable success callback",
+ "target-loader Unit singleton read with literal kotlin.Unit identity"))
+ }
+
+ private fun upload(): InstallerResolvedPoint {
+ val method = uniqueInstallerMatch("avl_upload", index.findMethods(InstallerMethodQuery(
+ strings = setOf("appSourcepackageName", "appSourceValue", "packageName", "touchInstallCnt",
+ "installCnt", "installTime", "fingerprint", "apkHash", "apkSize", "permission", "cdMd5"),
+ returnType = "void", parameterCount = 4, isStatic = true,
+ )).filter { candidate ->
+ candidate.parameterTypes[2] == "boolean" && candidate.parameterTypes.all { it != "void" } &&
+ candidate.invokes.mapNotNull(index::method).any { call ->
+ call.returnType == "java.lang.String" && call.parameterTypes.size == 2 &&
+ call.parameterTypes[0] in MAP_TYPES + "java.lang.Object" &&
+ call.parameterTypes[1] == "java.lang.String" &&
+ call.strings.containsAll(setOf("NetUtil", "request start : ")) &&
+ "Ljava/net/URL;->(Ljava/lang/String;)V" in call.invokes &&
+ "Ljava/net/HttpURLConnection;->getResponseCode()I" in call.invokes
+ } && candidate.readFields.mapNotNull(index::field).any { field ->
+ field.type == "java.lang.String" && field.isStatic && index.findMethods(InstallerMethodQuery(
+ owner = field.owner, strings = setOf("/avl/upload"), returnType = "void",
+ parameterTypes = emptyList(), isStatic = true,
+ )).any { it.name == "" && field.descriptor in it.writtenFields }
+ }
+ })
+ return InstallerResolvedPoint("avl_upload", method, evidence = listOf(
+ "eleven exact install-upload payload keys + static four-argument void producer + boolean role",
+ "payload/String HTTP helper (generic body may erase to Object) + real URL/HTTP calls",
+ "read URL-holder field; its class initializer contains /avl/upload",
+ "URL association is class-level, not a claimed field-to-literal dataflow proof",
+ ))
+ }
+
+ private fun directEndpoint(method: InstallerMethod, path: String): Boolean = method.invokes.mapNotNull(index::method)
+ .any { path in it.annotationStrings.map(String::trim) }
+
+ private fun classFields(owner: String): List = index.clazz(owner)?.fields.orEmpty().mapNotNull(index::field)
+
+ private fun continuation(type: String): Boolean {
+ val methods = index.findMethods(InstallerMethodQuery(owner = type, isStatic = false)).filter { it.name != "" }
+ return methods.any { it.returnType == "void" && it.parameterTypes == listOf("java.lang.Object") } &&
+ methods.any { it.parameterTypes.isEmpty() && it.returnType !in PRIMITIVES && it.returnType != "java.lang.Object" }
+ }
+
+ private fun runnable(type: String): Boolean = index.clazz(type)?.interfaces?.contains("java.lang.Runnable") == true
+
+ private fun executionClosure(start: InstallerMethod): List = layoutClosure(start).methods
+
+ /** Observe every direct frontier before spending the expansion budget. Bodyless leaves cannot crowd out an API. */
+ private fun layoutClosure(start: InstallerMethod): LayoutClosure = layoutClosures.getOrPut(start.descriptor) {
+ val queue = ArrayDeque>()
+ val found = linkedMapOf()
+ val expanded = mutableSetOf()
+ var edges = 0
+ var depthStops = 0
+ queue.addLast(start to 0)
+ while (queue.isNotEmpty() && expanded.size < 192 && edges < 8_192) {
+ val (method, depth) = queue.removeFirst()
+ if (!expanded.add(method.descriptor)) continue
+ found.putIfAbsent(method.descriptor, method)
+ for (reference in method.invokes) {
+ if (edges++ >= 8_192) break
+ val callee = layoutMethod(reference) ?: continue
+ found.putIfAbsent(callee.descriptor, callee)
+ if (callee.invokes.isNotEmpty()) {
+ if (depth < 6) queue.addLast(callee to depth + 1) else depthStops++
+ }
+ if (callee.name == "" && runnable(callee.owner)) {
+ index.findMethods(InstallerMethodQuery(owner = callee.owner, returnType = "void",
+ parameterTypes = emptyList(), isStatic = false)).filter { it.name == "run" }
+ .forEach {
+ found.putIfAbsent(it.descriptor, it)
+ if (depth < 6) queue.addLast(it to depth + 1) else depthStops++
+ }
+ }
+ }
+ }
+ LayoutClosure(found.values.toList(), expanded.size, queue.isNotEmpty() || edges >= 8_192 || depthStops > 0,
+ edges, depthStops)
+ }
+
+ companion object {
+ private val MAP_TYPES = setOf("java.util.Map", "java.util.HashMap")
+ private val LAYOUT_ROUTE = Regex("/info/layout(?:/v[1-9][0-9]*)?")
+ private val PRIMITIVES = setOf("void", "boolean", "byte", "short", "char", "int", "long", "float", "double")
+ private const val NOP = 0x00
+ private const val RETURN_VOID = 0x0e
+ private const val RETURN_OBJECT = 0x11
+ private const val CONST_4 = 0x12
+ private const val IGET_OBJECT = 0x54
+ private const val IF_EQZ = 0x38
+ private const val IF_NEZ = 0x39
+ private const val INVOKE_INTERFACE = 0x72
+ private const val INVOKE_INTERFACE_RANGE = 0x78
+ private val OBJECT_MOVES = setOf(0x07, 0x08, 0x09)
+ private val NULL_CONSTANTS = setOf(CONST_4, 0x13, 0x14)
+ private val CALLBACK_INVOKES = setOf(INVOKE_INTERFACE, INVOKE_INTERFACE_RANGE, 0x6e, 0x74)
+ private val EMPTY_CALLBACK_OPS = NULL_CONSTANTS + CALLBACK_INVOKES + setOf(RETURN_VOID, IF_EQZ, IF_NEZ, 0x28, 0x29, 0x2a)
+ private val MAP_PUTS = setOf("Ljava/util/Map;->put(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;",
+ "Ljava/util/HashMap;->put(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;")
+ // A complete class token ends in ';' or opens a generic '<'. Arrays are not direct payloads.
+ private val LAYOUT_CLASS_TOKEN = Regex("(?()
+ private var dexFiles: List? = null
+
+ @Synchronized fun hasNullReturn(descriptor: String): Boolean {
+ results[descriptor]?.let { return it.proven }
+ val result = try {
+ demand(results.size < 8, "request_budget")
+ demand(validDescriptor(descriptor), "descriptor")
+ val files = dexFiles ?: readApk().also { dexFiles = it }
+ val matches = files.mapNotNull { Dex(it).selected(descriptor) }
+ demand(matches.size <= 1, "duplicate_method")
+ if (matches.isEmpty()) Result(false, "method_missing", dexFiles = files.size)
+ else analyze(matches.single()).copy(dexFiles = files.size)
+ } catch (failure: Rejected) {
+ Result(false, failure.reason)
+ } catch (_: Throwable) {
+ Result(false, "read_or_analysis_failed")
+ }
+ if (results.size < 8) results[descriptor] = result
+ return result.proven
+ }
+
+ @Synchronized fun diagnosticLines(): List = results.entries.toList().takeLast(2).map { (descriptor, result) ->
+ ("probe layout_null_proof detail=proven=${result.proven};reason=${result.reason};" +
+ "insns=${result.instructions};edges=${result.edges};regs=${result.registers};dex=${result.dexFiles};" +
+ "method=${descriptor.take(240)}").take(384)
+ }
+
+ private fun readApk(): List = ZipFile(source).use { zip ->
+ val entries = zip.entries()
+ val dex = mutableListOf()
+ val names = mutableSetOf()
+ var visited = 0
+ while (entries.hasMoreElements()) {
+ demand(++visited <= 32_768, "zip_entry_budget")
+ val entry = entries.nextElement()
+ if (ROOT_DEX.matches(entry.name)) {
+ demand(names.add(entry.name), "duplicate_root_dex")
+ demand(!entry.isDirectory && entry.size in 112..MAX_DEX_BYTES.toLong(), "dex_size")
+ dex += entry
+ demand(dex.size <= 16, "dex_count_budget")
+ }
+ }
+ demand(dex.isNotEmpty(), "no_root_dex")
+ var total = 0L
+ dex.sortedBy { it.name }.map { entry ->
+ total += entry.size
+ demand(total <= 96L * 1_024 * 1_024, "apk_dex_budget")
+ zip.getInputStream(entry).use { it.boundedBytes(MAX_DEX_BYTES) }.also {
+ demand(it.size.toLong() == entry.size, "dex_size")
+ }
+ }
+ }
+
+ private data class TryRange(val start: Int, val end: Int, val handlers: List)
+ private data class Code(val units: IntArray, val registers: Int, val tries: List,
+ val strings: Int, val types: Int, val fields: Int, val methods: Int)
+ private data class Insn(val address: Int, val opcode: Int, val width: Int, val dest: Int?,
+ val source: Int?, val zero: Boolean, val wide: Boolean, val canThrow: Boolean,
+ val next: List, val payload: Boolean = false, val operands: List = emptyList(), val nonzero: Boolean = false)
+ private class Rejected(val reason: String) : IllegalStateException()
+
+ /** Classic standalone DEX 035/037-040 only. New container formats deliberately fail closed. */
+ private class Dex(private val bytes: ByteArray) {
+ private val size = bytes.size
+ private val stringCount: Int
+ private val stringOffset: Int
+ private val typeCount: Int
+ private val typeOffset: Int
+ private val fieldCount: Int
+ private val methodCount: Int
+ private val methodOffset: Int
+ private val protoCount: Int
+ private val protoOffset: Int
+ private val classCount: Int
+ private val classOffset: Int
+ private val dataOffset: Int
+ private val strings = mutableMapOf()
+ private val types = mutableMapOf()
+
+ init {
+ demand(size in 112..MAX_DEX_BYTES, "dex_size")
+ demand(bytes.sliceArray(0..3).contentEquals(byteArrayOf(100, 101, 120, 10)) && bytes[7] == 0.toByte(), "dex_magic")
+ demand(String(bytes, 4, 3, Charsets.US_ASCII) in setOf("035", "037", "038", "039", "040"), "dex_version")
+ demand(u4(32) == size && u4(36) == 112 && uint(40) == 0x12345678L, "dex_header")
+ val checksum = Adler32().apply { update(bytes, 12, size - 12) }.value
+ demand(uint(8) == checksum, "dex_checksum")
+ stringCount = u4(56); stringOffset = u4(60)
+ typeCount = u4(64); typeOffset = u4(68)
+ protoCount = u4(72); protoOffset = u4(76)
+ fieldCount = u4(80); methodCount = u4(88); methodOffset = u4(92)
+ classCount = u4(96); classOffset = u4(100); dataOffset = u4(108)
+ demand(stringCount <= 262_144 && typeCount <= 65_535 && protoCount <= 65_535 &&
+ fieldCount <= 65_535 && methodCount <= 65_535 && classCount <= 65_535, "table_budget")
+ table(stringOffset, stringCount, 4); table(typeOffset, typeCount, 4)
+ table(protoOffset, protoCount, 12); table(u4(84), fieldCount, 8)
+ table(methodOffset, methodCount, 8); table(classOffset, classCount, 32)
+ demand(dataOffset in 112..size && u4(104).toLong() + dataOffset == size.toLong(), "data_bounds")
+ }
+
+ private fun table(offset: Int, count: Int, width: Int) {
+ if (count == 0) demand(offset == 0, "table_bounds")
+ else { demand(offset >= 112 && offset % 4 == 0, "table_bounds"); range(offset, count.toLong() * width) }
+ }
+ private fun range(offset: Int, length: Long) {
+ demand(offset >= 0 && length >= 0 && offset.toLong() + length <= size, "bounds")
+ }
+ private fun u2(at: Int): Int { range(at, 2); return (bytes[at].toInt() and 255) or ((bytes[at + 1].toInt() and 255) shl 8) }
+ private fun uint(at: Int): Long { range(at, 4); return (0..3).fold(0L) { value, i -> value or ((bytes[at + i].toLong() and 255) shl (8 * i)) } }
+ private fun u4(at: Int): Int { val value = uint(at); demand(value <= Int.MAX_VALUE, "integer_range"); return value.toInt() }
+ private inner class Cursor(var at: Int) {
+ fun leb(): Int {
+ var value = 0L
+ repeat(5) { i ->
+ range(at, 1); val byte = bytes[at++].toInt() and 255
+ value = value or ((byte.toLong() and 127) shl (7 * i))
+ if (byte and 128 == 0) { demand(value <= Int.MAX_VALUE, "uleb_range"); return value.toInt() }
+ }
+ throw Rejected("uleb_range")
+ }
+ fun signedLeb(): Int {
+ var value = 0L
+ repeat(5) { i ->
+ range(at, 1); val byte = bytes[at++].toInt() and 255
+ value = value or ((byte.toLong() and 127) shl (7 * i))
+ if (byte and 128 == 0) {
+ val bits = 7 * (i + 1)
+ if (byte and 64 != 0) value = value or (-1L shl bits)
+ demand(value in Int.MIN_VALUE..Int.MAX_VALUE, "sleb_range")
+ return value.toInt()
+ }
+ }
+ throw Rejected("sleb_range")
+ }
+ }
+ private fun string(index: Int): String = strings.getOrPut(index) {
+ demand(index in 0 until stringCount, "string_index")
+ val offset = u4(stringOffset + index * 4)
+ demand(offset in dataOffset until size, "string_bounds")
+ val cursor = Cursor(offset); val length = cursor.leb()
+ demand(length <= 4_096, "string_budget")
+ val result = StringBuilder(length)
+ while (true) {
+ range(cursor.at, 1); val byte = bytes[cursor.at++].toInt() and 255
+ if (byte == 0) break
+ val character = when {
+ byte in 1..127 -> byte
+ byte and 224 == 192 -> {
+ range(cursor.at, 1); val next = bytes[cursor.at++].toInt() and 255
+ demand(next and 192 == 128, "mutf8")
+ val value = ((byte and 31) shl 6) or (next and 63)
+ demand(value >= 128 || byte == 192 && next == 128, "mutf8")
+ value
+ }
+ byte and 240 == 224 -> {
+ range(cursor.at, 2); val first = bytes[cursor.at++].toInt() and 255; val second = bytes[cursor.at++].toInt() and 255
+ demand(first and 192 == 128 && second and 192 == 128, "mutf8")
+ val value = ((byte and 15) shl 12) or ((first and 63) shl 6) or (second and 63)
+ demand(value >= 2_048, "mutf8"); value
+ }
+ else -> throw Rejected("mutf8")
+ }
+ result.append(character.toChar()); demand(result.length <= length, "mutf8_length")
+ }
+ demand(result.length == length, "mutf8_length"); result.toString()
+ }
+ private fun type(index: Int): String = types.getOrPut(index) {
+ demand(index in 0 until typeCount, "type_index"); string(u4(typeOffset + index * 4))
+ }
+ private fun method(index: Int): String {
+ demand(index in 0 until methodCount, "method_index")
+ val at = methodOffset + index * 8; val proto = u2(at + 2)
+ demand(proto in 0 until protoCount, "proto_index")
+ val protoAt = protoOffset + proto * 12; val parameters = u4(protoAt + 8)
+ val args = if (parameters == 0) "" else {
+ demand(parameters >= dataOffset && parameters % 4 == 0, "parameters_bounds")
+ val count = u4(parameters); demand(count <= 256, "parameter_budget"); range(parameters + 4, count.toLong() * 2)
+ (0 until count).joinToString("") { type(u2(parameters + 4 + it * 2)) }
+ }
+ return "${type(u2(at))}->${string(u4(at + 4))}($args)${type(u4(protoAt + 4))}"
+ }
+ fun selected(descriptor: String): Code? {
+ val owner = descriptor.substringBefore(";->") + ";"
+ var selectedData: Int? = null
+ for (i in 0 until classCount) {
+ val at = classOffset + i * 32
+ if (type(u4(at)) == owner) {
+ demand(selectedData == null, "duplicate_class")
+ selectedData = u4(at + 24)
+ }
+ }
+ val start = selectedData ?: return null
+ if (start == 0) return null
+ demand(start in dataOffset until size, "class_data_bounds")
+ val cursor = Cursor(start)
+ val staticFields = cursor.leb(); val instanceFields = cursor.leb()
+ val directMethods = cursor.leb(); val virtualMethods = cursor.leb()
+ demand(staticFields.toLong() + instanceFields <= 16_384 && directMethods.toLong() + virtualMethods <= 8_192, "class_data_budget")
+ for (count in listOf(staticFields, instanceFields)) {
+ var index = 0L
+ repeat(count) { i -> val delta = cursor.leb(); demand(i == 0 || delta > 0, "field_order"); index += delta
+ demand(index < fieldCount, "field_index"); cursor.leb() }
+ }
+ var selectedCode: Int? = null
+ var access = 0
+ val seen = mutableSetOf()
+ for (count in listOf(directMethods, virtualMethods)) {
+ var index = 0L
+ repeat(count) {
+ index += cursor.leb(); demand(index < methodCount && seen.add(index.toInt()), "method_order")
+ demand(type(u2(methodOffset + index.toInt() * 8)) == owner, "method_owner")
+ val flags = cursor.leb(); val code = cursor.leb()
+ if (method(index.toInt()) == descriptor) {
+ demand(selectedCode == null, "duplicate_method"); selectedCode = code; access = flags
+ }
+ }
+ }
+ val code = selectedCode ?: return null
+ demand(code != 0 && access and (0x100 or 0x400) == 0, "no_code")
+ demand(code >= dataOffset && code % 4 == 0, "code_bounds")
+ range(code, 16)
+ val registers = u2(code); val inputs = u2(code + 2); val tries = u2(code + 6); val units = u4(code + 12)
+ demand(registers in 1..512 && inputs <= registers && units in 1..8_192 && tries <= 128, "code_budget")
+ val args = descriptor.substringAfter('(').substringBefore(')')
+ var argumentAt = 0; var words = if (access and 8 == 0) 1 else 0
+ while (argumentAt < args.length) {
+ var array = false
+ while (argumentAt < args.length && args[argumentAt] == '[') { array = true; argumentAt++ }
+ demand(argumentAt < args.length, "parameter_type")
+ val kind = args[argumentAt++]
+ if (kind == 'L') {
+ val end = args.indexOf(';', argumentAt); demand(end >= argumentAt, "parameter_type"); argumentAt = end + 1
+ } else demand(kind in "ZBSCIJFD", "parameter_type")
+ words += if (!array && kind in "JD") 2 else 1
+ }
+ demand(inputs == words, "input_registers")
+ range(code + 16, units.toLong() * 2)
+ val instructions = IntArray(units) { u2(code + 16 + it * 2) }
+ var after = code + 16 + units * 2
+ if (tries > 0 && units % 2 != 0) { demand(u2(after) == 0, "code_padding"); after += 2 }
+ range(after, tries.toLong() * 8)
+ val ranges = if (tries == 0) emptyList() else {
+ val handlersStart = after + tries * 8; val handlersCursor = Cursor(handlersStart)
+ val count = handlersCursor.leb(); demand(count in 1..256, "handler_budget")
+ val handlers = mutableMapOf>()
+ repeat(count) {
+ val offset = handlersCursor.at - handlersStart; val encoded = handlersCursor.signedLeb()
+ demand(encoded in -128..128, "handler_budget")
+ val addresses = mutableListOf()
+ repeat(kotlin.math.abs(encoded)) {
+ demand(handlersCursor.leb() < typeCount, "catch_type_index"); addresses += handlersCursor.leb()
+ }
+ if (encoded <= 0) addresses += handlersCursor.leb()
+ demand(addresses.all { it in 0 until units }, "handler_bounds"); handlers[offset] = addresses
+ }
+ var end = 0
+ (0 until tries).map { i ->
+ val at = after + i * 8; val begin = u4(at); val length = u2(at + 4)
+ demand(length > 0 && begin >= end && begin.toLong() + length <= units, "try_bounds")
+ end = begin + length
+ TryRange(begin, end, handlers[u2(at + 6)] ?: throw Rejected("handler_offset"))
+ }
+ }
+ return Code(instructions, registers, ranges, stringCount, typeCount, fieldCount, methodCount)
+ }
+ }
+
+ companion object {
+ private const val MAX_DEX_BYTES = 64 * 1_024 * 1_024
+ private val ROOT_DEX = Regex("classes(?:[2-9]|[1-9][0-9]+)?\\.dex")
+ private fun validDescriptor(descriptor: String) = descriptor.length in 5..1_024 &&
+ descriptor.startsWith('L') && ";->" in descriptor && descriptor.endsWith(")Ljava/lang/Object;")
+ private fun demand(value: Boolean, reason: String) { if (!value) throw Rejected(reason) }
+ private fun InputStream.boundedBytes(limit: Int): ByteArray {
+ val output = java.io.ByteArrayOutputStream()
+ val buffer = ByteArray(16_384)
+ while (true) { val read = read(buffer); if (read < 0) break
+ demand(output.size().toLong() + read <= limit, "dex_size"); output.write(buffer, 0, read) }
+ return output.toByteArray()
+ }
+
+ /** The test seam consumes bytes, never code loaded by ART or the host JVM. */
+ internal fun proveDex(bytes: ByteArray, descriptor: String): Result = try {
+ demand(validDescriptor(descriptor), "descriptor")
+ val code = Dex(bytes).selected(descriptor)
+ if (code == null) Result(false, "method_missing") else analyze(code)
+ } catch (failure: Rejected) { Result(false, failure.reason) }
+ catch (_: Throwable) { Result(false, "read_or_analysis_failed") }
+
+ private fun analyze(code: Code): Result {
+ val insns = decode(code)
+ val executable = insns.filterValues { !it.payload }
+ demand(executable.containsKey(0), "entry_payload")
+ val catches = mutableMapOf>()
+ var edges = 0
+ for (range in code.tries) {
+ demand(range.start in executable && (range.end == code.units.size || range.end in insns), "try_instruction_bounds")
+ demand(range.handlers.all { it in executable }, "handler_instruction_bounds")
+ }
+ for ((address, instruction) in executable) {
+ // Only potentially throwing instructions lead to handlers. A fake edge from a NOP
+ // could otherwise invent an unreachable null-return witness. Handler state is PRE-write.
+ val handlers = if (!instruction.canThrow) emptyList() else
+ code.tries.filter { address >= it.start && address < it.end }.flatMap { it.handlers }.distinct()
+ catches[address] = handlers
+ edges += instruction.next.size + handlers.size
+ demand(edges <= 32_768, "edge_budget")
+ // Explicit targets must be valid even in an unreachable block. A padding NOP's
+ // fallthrough is checked only if reached, since unreachable payload padding is legal.
+ if (instruction.opcode in 0x28..0x2c || instruction.opcode in 0x32..0x3d) {
+ demand(instruction.next.all { it in executable }, "branch_instruction_bounds")
+ }
+ }
+ // UNKNOWN=0, ZERO=1, NONZERO=2. NONZERO only prunes obvious impossible zero tests;
+ // it does not encode signs, arithmetic relations, object identities or path feasibility.
+ val states = mutableMapOf()
+ val queue = ArrayDeque()
+ val pending = mutableSetOf()
+ fun offer(address: Int, incoming: ByteArray) {
+ demand(address in executable, "flow_instruction_bounds")
+ val old = states[address]
+ val changed = if (old == null) { states[address] = incoming.copyOf(); true } else {
+ var different = false
+ for (i in old.indices) if (old[i] != UNKNOWN && old[i] != incoming[i]) { old[i] = UNKNOWN; different = true }
+ different
+ }
+ if (changed && pending.add(address)) queue.addLast(address)
+ }
+ offer(0, ByteArray(code.registers))
+ var iterations = 0
+ while (queue.isNotEmpty()) {
+ demand(++iterations <= 131_072, "flow_budget")
+ val address = queue.removeFirst(); pending.remove(address)
+ val instruction = executable.getValue(address)
+ val before = states.getValue(address)
+ catches.getValue(address).forEach { offer(it, before) }
+ val after = before.copyOf()
+ instruction.dest?.let { destination ->
+ after[destination] = when {
+ instruction.zero -> ZERO
+ instruction.nonzero -> NONZERO
+ instruction.opcode in setOf(0x01, 0x02, 0x03, 0x07, 0x08, 0x09) -> before[checkNotNull(instruction.source)]
+ instruction.opcode == 0x1f -> before[destination] // check-cast does not change null.
+ else -> UNKNOWN
+ }
+ if (instruction.wide) after[destination + 1] = UNKNOWN
+ }
+ if (instruction.opcode in 0x32..0x3d && instruction.next.size == 2) {
+ val a = instruction.operands[0]
+ val b = instruction.operands.getOrNull(1)
+ val left = after[a]
+ val right = b?.let { after[it] } ?: ZERO
+ val equalKnown = when {
+ a == b || left == ZERO && right == ZERO -> true
+ left == ZERO && right == NONZERO || left == NONZERO && right == ZERO -> false
+ else -> null
+ }
+ val condition = when (instruction.opcode) {
+ 0x32, 0x38 -> equalKnown
+ 0x33, 0x39 -> equalKnown?.not()
+ 0x34, 0x36, 0x3a, 0x3c -> if (a == b || left == ZERO && right == ZERO) false else null
+ 0x35, 0x37, 0x3b, 0x3d -> if (a == b || left == ZERO && right == ZERO) true else null
+ else -> null
+ }
+ for ((index, next) in instruction.next.withIndex()) {
+ if (condition != null && condition != (index == 0)) continue
+ val edge = after.copyOf()
+ if (instruction.opcode in setOf(0x32, 0x33, 0x38, 0x39)) {
+ val equalityEdge = (index == 0) == (instruction.opcode == 0x32 || instruction.opcode == 0x38)
+ if (b == null) edge[a] = if (equalityEdge) ZERO else NONZERO
+ else if (equalityEdge) {
+ if (left != UNKNOWN) edge[b] = left
+ if (right != UNKNOWN) edge[a] = right
+ } else {
+ if (left == ZERO) edge[b] = NONZERO
+ if (right == ZERO) edge[a] = NONZERO
+ }
+ }
+ offer(next, edge)
+ }
+ } else instruction.next.forEach { offer(it, after) }
+ }
+ // Evaluate after convergence. A later UNKNOWN join must be able to revoke an early ZERO.
+ val proven = executable.values.any { it.opcode == 0x11 && states[it.address]?.get(checkNotNull(it.source)) == ZERO }
+ return Result(proven, if (proven) "must_null_return" else "no_proven_null_return", executable.size, edges, code.registers)
+ }
+
+ private fun decode(code: Code): Map {
+ val units = code.units
+ fun word(at: Int): Int { demand(at in units.indices, "instruction_bounds"); return units[at] }
+ fun signed32(at: Int) = word(at) or (word(at + 1) shl 16)
+ fun unsigned32(at: Int) = word(at).toLong() or (word(at + 1).toLong() shl 16)
+ fun target(at: Int, offset: Int): Int {
+ val value = at.toLong() + offset
+ demand(value in 0 until units.size.toLong(), "branch_bounds"); return value.toInt()
+ }
+ fun register(value: Int): Int { demand(value in 0 until code.registers, "register_bounds"); return value }
+ val result = linkedMapOf()
+ var address = 0
+ while (address < units.size) {
+ demand(result.size < 4_096, "instruction_budget")
+ val encoded = word(address); val opcode = encoded and 255
+ if (opcode == 0 && encoded != 0) {
+ demand(address % 2 == 0, "payload_alignment")
+ val width = when (encoded) {
+ 0x0100 -> { val count = word(address + 1); demand(count <= 1_024, "switch_budget"); 4 + 2 * count }
+ 0x0200 -> { val count = word(address + 1); demand(count <= 1_024, "switch_budget"); 2 + 4 * count }
+ 0x0300 -> {
+ val elementWidth = word(address + 1); val count = unsigned32(address + 2)
+ demand(elementWidth in setOf(1, 2, 4, 8) && count <= 16_384, "array_payload_budget")
+ val bytes = elementWidth * count; demand(bytes <= 16_384, "array_payload_budget")
+ 4 + ((bytes + 1) / 2).toInt()
+ }
+ else -> throw Rejected("unknown_payload")
+ }
+ demand(address.toLong() + width <= units.size, "payload_bounds")
+ result[address] = Insn(address, opcode, width, null, null, false, false, false, emptyList(), true)
+ address += width; continue
+ }
+ val format = FORMATS[opcode]
+ demand(format != "-", "unsupported_opcode")
+ val width = format[0] - '0'
+ demand(width in 1..5 && address + width <= units.size, "instruction_bounds")
+ val a4 = (encoded ushr 8) and 15; val b4 = (encoded ushr 12) and 15; val a8 = encoded ushr 8
+ val registers = when (format) {
+ "10x", "10t", "20t", "30t" -> emptyList()
+ "11n" -> listOf(a4)
+ "12x", "22c", "22s", "22t" -> listOf(a4, b4)
+ "11x", "21c", "21ih", "21lh", "21s", "21t", "31c", "31i", "31t", "51l" -> listOf(a8)
+ "22x" -> listOf(a8, word(address + 1))
+ "32x" -> listOf(word(address + 1), word(address + 2))
+ "23x" -> listOf(a8, word(address + 1) and 255, word(address + 1) ushr 8)
+ "22b" -> listOf(a8, word(address + 1) and 255)
+ "35c" -> {
+ val count = encoded ushr 12; demand(count <= 5, "invoke_register_count")
+ val list = word(address + 2)
+ listOf(list and 15, (list ushr 4) and 15, (list ushr 8) and 15, list ushr 12, a4).take(count)
+ }
+ "3rc" -> {
+ val first = word(address + 2); demand(first.toLong() + a8 <= code.registers, "register_bounds")
+ (first until first + a8).toList()
+ }
+ else -> throw Rejected("unsupported_format")
+ }.onEach(::register)
+ val flags = FLAGS[opcode]
+ val destination = if (flags and 1 == 0) null else registers.firstOrNull() ?: throw Rejected("destination")
+ val wide = flags and 2 != 0
+ if (wide) demand(checkNotNull(destination) + 1 < code.registers, "wide_register_bounds")
+ if (opcode in 0x04..0x06) demand(registers[1] + 1 < code.registers, "wide_register_bounds")
+ val reference = if (width >= 2) word(address + 1) else 0
+ when (opcode) {
+ 0x1a -> demand(reference < code.strings, "instruction_string_index")
+ 0x1b -> demand(unsigned32(address + 1) < code.strings, "instruction_string_index")
+ 0x1c, 0x1f, 0x20, 0x22, 0x23, 0x24, 0x25 -> demand(reference < code.types, "instruction_type_index")
+ in 0x52..0x6d -> demand(reference < code.fields, "instruction_field_index")
+ in 0x6e..0x72, in 0x74..0x78 -> demand(reference < code.methods, "instruction_method_index")
+ }
+ demand(opcode !in 0x0e..0x10, "non_object_return")
+ val moveSource = when (opcode) {
+ 0x01, 0x04, 0x07 -> b4
+ 0x02, 0x05, 0x08 -> word(address + 1)
+ 0x03, 0x06, 0x09 -> word(address + 2)
+ 0x11 -> a8
+ else -> null
+ }
+ val zero = when (opcode) {
+ 0x12 -> (encoded ushr 12) == 0
+ 0x13, 0x15 -> word(address + 1) == 0
+ 0x14 -> signed32(address + 1) == 0
+ else -> false
+ }
+ val fallthrough = address + width
+ val next = when (opcode) {
+ 0x28 -> listOf(target(address, a8.toByte().toInt()))
+ 0x29 -> listOf(target(address, word(address + 1).toShort().toInt()))
+ 0x2a -> listOf(target(address, signed32(address + 1)))
+ in 0x32..0x3d -> listOf(target(address, word(address + 1).toShort().toInt()), fallthrough).distinct()
+ 0x26, 0x2b, 0x2c -> {
+ val payload = target(address, signed32(address + 1))
+ val expected = when (opcode) { 0x26 -> 0x0300; 0x2b -> 0x0100; else -> 0x0200 }
+ demand(payload % 2 == 0 && word(payload) == expected, "payload_reference")
+ if (opcode == 0x26) listOf(fallthrough) else {
+ val count = word(payload + 1); demand(count <= 1_024, "switch_budget")
+ val targetsAt = if (opcode == 0x2b) payload + 4 else payload + 2 + count * 2
+ if (opcode == 0x2c) for (i in 1 until count) {
+ demand(signed32(payload + 2 + (i - 1) * 2) < signed32(payload + 2 + i * 2), "switch_keys")
+ }
+ listOf(fallthrough) + (0 until count).map { target(address, signed32(targetsAt + it * 2)) }
+ }
+ }
+ else -> if (flags and 8 != 0) listOf(fallthrough) else emptyList()
+ }
+ val nonzero = opcode in setOf(0x0d, 0x1a, 0x1b, 0x1c, 0x22, 0x23) || opcode in 0x12..0x15 && !zero
+ // dexlib's historical flags omit fill-array-data's null/bounds exceptions.
+ result[address] = Insn(address, opcode, width, destination, moveSource, zero, wide,
+ flags and 4 != 0 || opcode == 0x26, next, operands = registers, nonzero = nonzero)
+ address += width
+ }
+ // A reference must identify a decoded payload start, never a lookalike data word inside code.
+ for (instruction in result.values.filter { !it.payload && it.opcode in setOf(0x26, 0x2b, 0x2c) }) {
+ val payload = target(instruction.address, signed32(instruction.address + 1))
+ demand(result[payload]?.payload == true, "payload_instruction_bounds")
+ }
+ return result
+ }
+
+ // Generated from smali-dexlib2 3.0.10 Opcodes.forDexVersion(39); standard 00..e2 only.
+ // FLAGS: 1=setsRegister, 2=setsWideRegister, 4=canThrow, 8=canContinue.
+ private val FORMATS = arrayOf(
+ "10x", "12x", "22x", "32x", "12x", "22x", "32x", "12x", "22x", "32x", "11x", "11x", "11x", "11x", "10x", "11x",
+ "11x", "11x", "11n", "21s", "31i", "21ih", "21s", "31i", "51l", "21lh", "21c", "31c", "21c", "11x", "11x", "21c",
+ "22c", "12x", "21c", "22c", "35c", "3rc", "31t", "11x", "10t", "20t", "30t", "31t", "31t", "23x", "23x", "23x",
+ "23x", "23x", "22t", "22t", "22t", "22t", "22t", "22t", "21t", "21t", "21t", "21t", "21t", "21t", "-", "-",
+ "-", "-", "-", "-", "23x", "23x", "23x", "23x", "23x", "23x", "23x", "23x", "23x", "23x", "23x", "23x",
+ "23x", "23x", "22c", "22c", "22c", "22c", "22c", "22c", "22c", "22c", "22c", "22c", "22c", "22c", "22c", "22c",
+ "21c", "21c", "21c", "21c", "21c", "21c", "21c", "21c", "21c", "21c", "21c", "21c", "21c", "21c", "35c", "35c",
+ "35c", "35c", "35c", "-", "3rc", "3rc", "3rc", "3rc", "3rc", "-", "-", "12x", "12x", "12x", "12x", "12x",
+ "12x", "12x", "12x", "12x", "12x", "12x", "12x", "12x", "12x", "12x", "12x", "12x", "12x", "12x", "12x", "12x",
+ "23x", "23x", "23x", "23x", "23x", "23x", "23x", "23x", "23x", "23x", "23x", "23x", "23x", "23x", "23x", "23x",
+ "23x", "23x", "23x", "23x", "23x", "23x", "23x", "23x", "23x", "23x", "23x", "23x", "23x", "23x", "23x", "23x",
+ "12x", "12x", "12x", "12x", "12x", "12x", "12x", "12x", "12x", "12x", "12x", "12x", "12x", "12x", "12x", "12x",
+ "12x", "12x", "12x", "12x", "12x", "12x", "12x", "12x", "12x", "12x", "12x", "12x", "12x", "12x", "12x", "12x",
+ "22s", "22s", "22s", "22s", "22s", "22s", "22s", "22s", "22b", "22b", "22b", "22b", "22b", "22b", "22b", "22b",
+ "22b", "22b", "22b", "-", "-", "-", "-", "-", "-", "-", "-", "-", "-", "-", "-", "-",
+ "-", "-", "-", "-", "-", "-", "-", "-", "-", "-", "-", "-", "-", "-", "-", "-",
+ )
+ private val FLAGS = intArrayOf(
+ 8, 9, 9, 9, 11, 11, 11, 9, 9, 9, 9, 11, 9, 9, 0, 0,
+ 0, 0, 9, 9, 9, 9, 11, 11, 11, 11, 13, 13, 13, 12, 12, 13,
+ 13, 13, 13, 13, 12, 12, 8, 4, 0, 0, 0, 8, 8, 9, 9, 9,
+ 9, 9, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 0, 0,
+ 0, 0, 0, 0, 13, 15, 13, 13, 13, 13, 13, 12, 12, 12, 12, 12,
+ 12, 12, 13, 15, 13, 13, 13, 13, 13, 12, 12, 12, 12, 12, 12, 12,
+ 13, 15, 13, 13, 13, 13, 13, 12, 12, 12, 12, 12, 12, 12, 12, 12,
+ 12, 12, 12, 0, 12, 12, 12, 12, 12, 0, 0, 9, 9, 11, 11, 9,
+ 11, 11, 9, 11, 9, 9, 11, 9, 11, 11, 9, 11, 9, 9, 9, 9,
+ 9, 9, 9, 13, 13, 9, 9, 9, 9, 9, 9, 11, 11, 11, 15, 15,
+ 11, 11, 11, 11, 11, 11, 9, 9, 9, 9, 9, 11, 11, 11, 11, 11,
+ 9, 9, 9, 13, 13, 9, 9, 9, 9, 9, 9, 11, 11, 11, 15, 15,
+ 11, 11, 11, 11, 11, 11, 9, 9, 9, 9, 9, 11, 11, 11, 11, 11,
+ 9, 9, 9, 13, 13, 9, 9, 9, 9, 9, 9, 13, 13, 9, 9, 9,
+ 9, 9, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ )
+ private const val UNKNOWN: Byte = 0
+ private const val ZERO: Byte = 1
+ private const val NONZERO: Byte = 2
+ }
+}
diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerPromotionRules.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerPromotionRules.kt
new file mode 100644
index 0000000..5ea94f0
--- /dev/null
+++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerPromotionRules.kt
@@ -0,0 +1,386 @@
+package cn.ianzb.hyperrefine.hook.packageinstaller
+
+/** Display-only roles, with bounded static failure facts. No request/upload completion is inferred. */
+internal class InstallerPromotionRules(private val index: InstallerDexIndex) {
+ private val failureDiagnostics = linkedMapOf()
+ /** Static metadata only: one fields line per failed role, plus at most one card-schema line. */
+ fun diagnosticLines(): List = failureDiagnostics.values.toList()
+
+ fun resolve(id: String): InstallerResolvedPoint = when (id) {
+ "ads_before" -> before()
+ "ads_after" -> after()
+ "ads_cancel" -> cancel()
+ else -> throw InstallerMatchException(id, 0, "unknown promotion display role")
+ }
+
+ private inner class Trace(private val role: String) {
+ private val counts = linkedMapOf()
+ private val missing = mutableMapOf()
+ private val near = sortedMapOf()
+ private val unresolvedFields = sortedSetOf()
+ private var failedCard: String? = null
+ fun count(stage: String, value: Int) { counts[stage] = value }
+ fun miss(reason: String) { missing[reason] = (missing[reason] ?: 0) + 1 }
+ fun fieldMissing(reference: String) {
+ miss("field_ref")
+ unresolvedFields += reference
+ if (unresolvedFields.size > 2) unresolvedFields.remove(unresolvedFields.last())
+ }
+ fun card(constructor: InstallerMethod) {
+ if (failedCard != null) return
+ failedCard = runCatching {
+ fun fields(type: String) = index.clazz(type)?.fields.orEmpty().distinct().mapNotNull(index::field).filterNot { it.isStatic }
+ val types = fields(constructor.owner).filter { it.type in constructor.parameterTypes && it.type !in PRIMITIVES &&
+ !it.type.startsWith("java.") && !it.type.startsWith("android.") }.map { it.type }.distinct().take(4)
+ val candidates = types.flatMap { type -> listOf(type) + fields(type).filter { role(it, "data") }.take(1).map { it.type } }.distinct()
+ val keys = listOf("packageName", "iconUrl", "landingPageUrl", "allDownloadNum", "viewMonitorUrls", "clickMonitorUrls", "traceKv", "appId", "apkSizeV2", "data")
+ val selected = candidates.maxByOrNull { type -> fields(type).count { field -> keys.any { role(field, it) } } }
+ val members = selected?.let(::fields).orEmpty()
+ val labels = listOf("pkg", "icon", "url", "dl", "view", "click", "trace", "app", "size", "data")
+ val roles = keys.mapIndexed { ordinal, key ->
+ val matches = members.filter { role(it, key) }
+ val shapes = matches.map { when (it.type) { "java.lang.String" -> "S"; "java.lang.String[]" -> "[S"; "long" -> "J"; "int" -> "I"; "boolean" -> "Z"; else -> "O" } }.distinct().joinToString("/")
+ "${labels[ordinal]}=${matches.size}:${shapes.ifEmpty { "-" }}"
+ }.joinToString(",")
+ "card=L${constructor.owner.replace('.', '/')};payload=${selected?.let { "L${it.replace('.', '/')};" } ?: "none"};roles=$roles"
+ }.getOrElse { "unavailable=${it.javaClass.simpleName}" }
+ }
+ fun near(method: InstallerMethod) {
+ near[method.descriptor] = "${method.owner.substringAfterLast('.')}#${method.name}".take(32)
+ if (near.size > 2) near.remove(near.lastKey())
+ }
+ fun stage(name: String, source: List, test: (T) -> Boolean): List =
+ source.filter(test).also { count(name, it.size) }
+ fun unique(candidates: List): T {
+ if (candidates.size == 1) return candidates.single()
+ val stages = counts.entries.joinToString(",") { "${it.key}=${it.value}" }
+ val reasons = missing.entries.sortedWith(compareByDescending> { it.value }.thenBy { it.key })
+ .take(3).joinToString(",") { "${it.key}:${it.value}" }
+ val refs = unresolvedFields.joinToString("|") { it.take(112) }
+ val detail = "$stages miss=${reasons.ifEmpty { "none" }} near=${near.values.joinToString("|").ifEmpty { "none" }}"
+ failureDiagnostics[role] = "probe ${role.substringBefore('.')}_fields detail=refs=${refs.ifEmpty { "none" }};stages=$stages".take(384)
+ failedCard?.let { failureDiagnostics["$role.models"] = "probe ${role.substringBefore('.')}_models detail=$it".take(384) }
+ throw InstallerMatchException(role, candidates.size, detail)
+ }
+ }
+
+ private fun before(): InstallerResolvedPoint {
+ val trace = Trace("ads_before.display_gate")
+ val query = index.findMethods(InstallerMethodQuery(returnType = "boolean", parameterCount = 1, isStatic = false))
+ trace.count("q", query.size)
+ val cloud = trace.stage("cloud", query) { gate ->
+ cloudShape(gate.parameterTypes.single(), trace).also { if (it) trace.near(gate) }
+ }
+ val read = trace.stage("read", cloud) { gate ->
+ val fields = readFields(gate, trace)?.plus(cloudGetterReads(gate, trace))
+ ?.distinctBy { it.descriptor } ?: return@stage false
+ fields.count { it.owner == gate.parameterTypes.single() && semantic(it, "showAdsBefore", BOOLEAN) } == 1 &&
+ fields.all { it.owner == gate.parameterTypes.single() && semantic(it, "showAdsBefore", BOOLEAN) || it.descriptor == BOOLEAN_TRUE }
+ }
+ val operation = trace.stage("op", read) { gate ->
+ gate.writtenFields.isEmpty() && gate.strings.isEmpty() && beforeOperation(gate, trace)
+ }
+ val consumers = trace.stage("consumer", operation) { gate ->
+ val callers = index.callers(gate.descriptor).distinctBy { it.descriptor }
+ callers.isNotEmpty() && callers.all { call ->
+ call.owner == gate.owner && !call.isStatic && listType(call.returnType) && call.parameterTypes.size == 2 &&
+ call.parameterTypes.first() == gate.parameterTypes.single() && call.writtenFields.isEmpty()
+ }
+ }
+ val models = trace.stage("model", consumers) { gate ->
+ index.callers(gate.descriptor).distinctBy { it.descriptor }.all { hasPromotionModels(it, trace) }
+ }
+ return InstallerResolvedPoint("ads_before", trace.unique(models), evidence = listOf(
+ "showAdsBefore field/serialized alias and pure Boolean operation",
+ "all consumers build advertisement models; display only, not request/upload blocking",
+ ))
+ }
+
+ private fun beforeOperation(gate: InstallerMethod, trace: Trace): Boolean {
+ val code = gate.opcodes.filter { it != 0x00 }
+ if (code.lastOrNull() != RETURN || code.any { it !in BEFORE_OPCODES }) return false
+ val calls = gate.invokes.distinct()
+ if (calls.isEmpty()) return BOOLEAN_TRUE in gate.readFields && code.any { it == 0x32 || it == 0x33 }
+ return calls.all { reference ->
+ when (reference) {
+ BOOLEAN_VALUE -> true
+ BOOLEAN_EQUALS, OBJECT_EQUALS -> BOOLEAN_TRUE in gate.readFields
+ else -> index.method(reference)?.let { called ->
+ booleanEquality(called) || called.owner == gate.parameterTypes.single() &&
+ pureGetter(called, BOOLEAN, trace)?.let { semantic(it, "showAdsBefore", BOOLEAN) } == true
+ } ?: run { trace.miss("method_ref"); false }
+ }
+ }
+ }
+
+ private fun after(): InstallerResolvedPoint {
+ val trace = Trace("ads_after.display_gate")
+ val query = index.findMethods(InstallerMethodQuery(returnType = "boolean", parameterTypes = emptyList(), isStatic = false))
+ trace.count("q", query.size)
+ val fields = trace.stage("field", query) { gate ->
+ // The gate also reads Activity, policy and framework state. Its complete consumer
+ // chain below proves that its result only controls merging advertisement models.
+ // Missing unrelated reference metadata must not erase verified cloud-role reads.
+ val reads = (readFields(gate, trace, requireComplete = false).orEmpty() + cloudGetterReads(gate, trace))
+ .distinctBy { it.descriptor }
+ val cloud = reads.filter { semantic(it, "showAdsAfter", BOOLEAN) }.map { it.owner }.distinct().singleOrNull()
+ if (cloud != null) trace.near(gate)
+ cloud != null && cloudShape(cloud, trace) && reads.any { it.owner == cloud && semantic(it, "useSystemAppRules", BOOLEAN) }
+ }
+ val activities = trace.stage("activity", fields) { gate ->
+ gate.writtenFields.isEmpty() && gate.opcodes.lastOrNull() == RETURN && subclass(gate.owner, "android.app.Activity", trace)
+ }
+ val merge = trace.stage("merge", activities) { gate ->
+ val callers = index.callers(gate.descriptor).distinctBy { it.descriptor }
+ callers.isNotEmpty() && callers.all { method ->
+ method.owner == gate.owner && !method.isStatic && method.returnType == "void" &&
+ method.parameterTypes.size == 2 && method.parameterTypes.all(::listType) && method.writtenFields.isEmpty() &&
+ method.invokes.any { listCall(it, "addAll", "(Ljava/util/Collection;)Z") } &&
+ method.invokes.any { listCall(it, "isEmpty", "()Z") }
+ }
+ }
+ val producers = trace.stage("producer", merge) { gate ->
+ index.callers(gate.descriptor).distinctBy { it.descriptor }.all { merger ->
+ val callers = index.callers(merger.descriptor).distinctBy { it.descriptor }
+ callers.isNotEmpty() && callers.all { producer ->
+ if (producer.owner != gate.owner || !listType(producer.returnType) || producer.parameterTypes.isNotEmpty() || producer.isStatic) {
+ trace.miss("producer_shape")
+ false
+ } else {
+ val suppliers = producer.invokes.distinct().mapNotNull { reference ->
+ index.method(reference) ?: run { trace.miss("method_ref"); null }
+ }.filter { listType(it.returnType) && it.parameterTypes.isEmpty() && subclass(gate.owner, it.owner, trace) }
+ if (suppliers.isEmpty()) { trace.miss("supplier_shape"); false }
+ else suppliers.any { hasPromotionModels(it, trace) }.also { if (!it) trace.miss("supplier_card") }
+ }
+ }
+ }
+ }
+ return InstallerResolvedPoint("ads_after", trace.unique(producers), evidence = listOf(
+ "showAdsAfter/useSystemAppRules local Activity gate",
+ "all consumers merge verified advertisement-model lists; display only, not request/upload blocking",
+ ))
+ }
+
+ private data class CancelSeed(val bind: InstallerMethod, val holders: List)
+ private data class CancelPoint(val bind: InstallerMethod, val holder: InstallerField, val container: InstallerMethod)
+
+ private fun cancel(): InstallerResolvedPoint {
+ val trace = Trace("ads_cancel.ad_grid_bind")
+ // Discover from a declared, pure getter for the exact advertisement container. Analytics
+ // calls/strings may move into lambdas or helpers and are not the component's identity.
+ val getters = index.findMethods(InstallerMethodQuery(returnType = FRAME,
+ parameterTypes = emptyList(), isStatic = false)).distinctBy { it.descriptor }
+ trace.count("frame_getters", getters.size)
+ val adGetters = trace.stage("pure_frame", getters) { getter -> pureGetter(getter, FRAME, trace) != null }
+ val query = adGetters.flatMap { index.callers(it.descriptor) }.distinctBy { it.descriptor }.sortedBy { it.descriptor }
+ trace.count("callers", query.size)
+ query.take(2).forEach(trace::near)
+ val frames = trace.stage("frame", query) { bind -> !bind.isStatic && bind.returnType == "void" &&
+ bind.parameterTypes.isEmpty() && bind.writtenFields.isEmpty() && bind.invokes.any { it in FRAME_VISIBILITY } }
+ val holders = frames.mapNotNull { bind ->
+ val fields = readFields(bind, trace)?.filter { it.owner == bind.owner && !it.isStatic } ?: return@mapNotNull null
+ if (fields.count { it.type == "boolean" } != 1) { trace.miss("bool_role"); return@mapNotNull null }
+ val possible = fields.filter { holder ->
+ if (holder.type in PRIMITIVES) false
+ else if (index.clazz(holder.type) == null) { trace.miss("holder_class"); false }
+ else index.findMethods(InstallerMethodQuery(owner = holder.type, returnType = FRAME,
+ parameterTypes = emptyList(), isStatic = false)).any { it.descriptor in bind.invokes }
+ }
+ if (possible.isEmpty()) { trace.miss("getter_absent"); null } else CancelSeed(bind, possible)
+ }
+ trace.count("holder", holders.size)
+ val selectedGetters = holders.mapNotNull { seed ->
+ val points = seed.holders.flatMap { holder ->
+ index.findMethods(InstallerMethodQuery(owner = holder.type, returnType = FRAME,
+ parameterTypes = emptyList(), isStatic = false)).filter { getter ->
+ getter.descriptor in seed.bind.invokes && pureGetter(getter, FRAME, trace)?.let { field ->
+ getterRole(getter, field, "flAdCard") || readFields(seed.bind, trace).orEmpty().any {
+ it.owner == seed.bind.owner && adCollection(it.type, trace)
+ }
+ } == true
+ }.map { CancelPoint(seed.bind, holder, it) }
+ }.distinctBy { it.holder.descriptor to it.container.descriptor }
+ if (points.size != 1) { trace.miss("getter_role"); null } else points.single()
+ }
+ trace.count("getter", selectedGetters.size)
+ val binders = trace.stage("binder", selectedGetters) { selected ->
+ val callers = index.callers(selected.bind.descriptor).distinctBy { it.descriptor }
+ if (callers.size != 1) return@stage false
+ val binder = callers.single()
+ if (binder.owner != selected.bind.owner || binder.isStatic || binder.returnType != "void" ||
+ binder.parameterTypes != listOf(selected.holder.type) || binder.writtenFields.distinct() != listOf(selected.holder.descriptor) ||
+ "Landroid/widget/TextView;->setText(Ljava/lang/CharSequence;)V" !in binder.invokes) return@stage false
+ val titles = binder.invokes.distinct().mapNotNull { reference ->
+ val method = index.method(reference)
+ if (method == null) { trace.miss("method_ref"); null }
+ else if (method.owner == selected.holder.type) pureGetter(method, "android.widget.TextView", trace)?.let { method to it } else null
+ }.distinctBy { it.second.descriptor }
+ val namedTitles = listOf("tvCancelTitle", "tvCancelSubtitle").all { key -> titles.count { getterRole(it.first, it.second, key) } == 1 }
+ namedTitles || titles.size == 2 && readFields(selected.bind, trace).orEmpty().any {
+ it.owner == selected.bind.owner && adCollection(it.type, trace)
+ }
+ }
+ val selected = trace.unique(binders)
+ return InstallerResolvedPoint("ads_cancel", selected.bind, holderField = selected.holder, containerGetter = selected.container,
+ evidence = listOf("pure unique FrameLayout getter + local holder and independent title/subtitle binder",
+ "requires runtime resource identity com.miui.packageinstaller:id/fl_ad_card before after-bind hiding; display only"))
+ }
+
+ private fun pureGetter(method: InstallerMethod, result: String, trace: Trace): InstallerField? {
+ val code = method.opcodes.filter { it != 0x00 }
+ if (method.isStatic || method.returnType != result || method.parameterTypes.isNotEmpty() || method.invokes.isNotEmpty() ||
+ method.writtenFields.isNotEmpty() || method.strings.isNotEmpty() || code.lastOrNull() != 0x11 ||
+ code.count { it == 0x54 } != 1 || code.count { it == 0x11 } != 1 || code.any { it !in GETTER_OPCODES }) return null
+ val descriptor = method.readFields.distinct().singleOrNull() ?: return null
+ val field = index.field(descriptor) ?: run { trace.fieldMissing(descriptor); return null }
+ return field.takeIf { !it.isStatic && it.owner == method.owner && it.type == result }
+ }
+
+ /** Explicit same-owner list-factory calls only; unknown constructors and consumers remain rejected. */
+ private fun hasPromotionModels(start: InstallerMethod, trace: Trace): Boolean {
+ val queue = ArrayDeque>()
+ val seen = mutableSetOf()
+ var found = false
+ queue.addLast(start to 0)
+ while (queue.isNotEmpty() && seen.size < 24) {
+ val (method, depth) = queue.removeFirst()
+ if (!seen.add(method.descriptor)) continue
+ if (method.writtenFields.isNotEmpty()) return false
+ for (reference in method.invokes.distinct()) {
+ val called = index.method(reference)
+ if (called == null) {
+ trace.miss("method_ref")
+ if (";->(" in reference && !reference.startsWith("Ljava/")) return false
+ continue
+ }
+ if (called.name == "" && !called.owner.startsWith("java.")) {
+ if (!promotionModel(called, trace)) return false
+ found = true
+ }
+ if (depth < 2 && called.owner == start.owner && listType(called.returnType)) queue.addLast(called to depth + 1)
+ }
+ }
+ return queue.isEmpty() && found
+ }
+
+ private fun promotionModel(constructor: InstallerMethod, trace: Trace): Boolean {
+ fun reject(reason: String): Boolean { trace.miss(reason); trace.card(constructor); return false }
+ val clazz = index.clazz(constructor.owner) ?: return reject("model_class")
+ if (clazz.superName == null || clazz.superName == "java.lang.Object") return reject("card_base")
+ val payloads = declaredFields(clazz.name, trace).filter { advertisementPayload(it.type, trace) }
+ if (payloads.isEmpty() || payloads.map { it.type }.distinct().size != 1) return reject("typed_card")
+ val methods = clazz.methods.distinct().mapNotNull(index::method)
+ val used = payloads.filter { field -> methods.any { field.descriptor in it.readFields } }
+ if (used.isEmpty()) return reject("payload_use")
+ val seen = mutableSetOf()
+ var current = constructor
+ repeat(4) {
+ if (!seen.add(current.descriptor) || current.isStatic || current.returnType != "void" ||
+ current.parameterTypes.firstOrNull() != "android.content.Context" ||
+ used.none { it.type in current.parameterTypes }) return reject("constructor_role")
+ if (used.any { it.descriptor in current.writtenFields }) return true
+ val delegated = current.invokes.distinct().mapNotNull(index::method).filter {
+ it.owner == constructor.owner && it.name == ""
+ }.singleOrNull() ?: return reject("constructor_store")
+ current = delegated
+ }
+ return reject("constructor_depth")
+ }
+
+ private fun declaredFields(type: String, trace: Trace): List = index.clazz(type)?.fields.orEmpty()
+ .distinct().mapNotNull { reference -> index.field(reference) ?: run { trace.fieldMissing(reference); null } }.filterNot { it.isStatic }
+
+ private fun advertisementPayload(type: String, trace: Trace, depth: Int = 0): Boolean {
+ if (type in PRIMITIVES || type.startsWith("java.") || type.startsWith("android.") || depth > 1) return false
+ val fields = declaredFields(type, trace)
+ fun has(name: String, shape: String) = fields.count { semantic(it, name, shape) } == 1
+ val common = listOf("packageName", "iconUrl", "landingPageUrl").all { has(it, "java.lang.String") } && has("allDownloadNum", "long")
+ val monitored = common && has("viewMonitorUrls", "java.lang.String[]") && has("clickMonitorUrls", "java.lang.String[]")
+ val game = common && has("traceKv", "java.lang.String") && has("appId", "long") && has("apkSizeV2", "long")
+ if (monitored || game) return true
+ if (depth == 0) {
+ val data = fields.filter { role(it, "data") }.singleOrNull()
+ if (data != null && advertisementPayload(data.type, trace, 1)) return true
+ }
+ return false
+ }
+
+ private fun adCollection(type: String, trace: Trace): Boolean {
+ val fields = declaredFields(type, trace)
+ val data = fields.filter { listType(it.type) && (role(it, "data") || role(it, "dataList")) }
+ val formats = listOf("layoutFormat", "layoutVersionType").map { key -> fields.count { semantic(it, key, "java.lang.String") } }
+ return data.size == 1 && fields.count { semantic(it, "channel", "java.lang.String") } == 1 &&
+ formats.any { it == 1 } && formats.all { it <= 1 }
+ }
+
+ private fun getterRole(method: InstallerMethod, field: InstallerField, key: String): Boolean = role(field, key) ||
+ normalizeRole(method.name) == normalizeRole("get$key")
+
+ private fun cloudShape(type: String, trace: Trace): Boolean {
+ val clazz = index.clazz(type) ?: run { trace.miss("cloud_class"); return false }
+ val fields = clazz.fields.distinct().mapNotNull { descriptor -> index.field(descriptor) ?: run { trace.fieldMissing(descriptor); null } }
+ if (fields.size != clazz.fields.distinct().size) return false
+ val matches = listOf("showAdsBefore", "showAdsAfter", "showSafeModeTip").all { key -> fields.count { semantic(it, key, BOOLEAN) } == 1 }
+ if (!matches) trace.miss("cloud_role")
+ return matches
+ }
+
+ private fun readFields(method: InstallerMethod, trace: Trace, requireComplete: Boolean = true): List? {
+ // This exact comparison constant is part of the framework, not an APK declaration.
+ // Its identity is checked from the DEX reference; no FieldData placeholder is required.
+ val descriptors = method.readFields.distinct().filterNot { it == BOOLEAN_TRUE }
+ val fields = descriptors.mapNotNull { reference -> index.field(reference) ?: run { trace.fieldMissing(reference); null } }
+ return fields.takeIf { !requireComplete || it.size == descriptors.size }
+ }
+
+ /** Direct pure property accessors are equivalent to iget; no arbitrary call graph is followed. */
+ private fun cloudGetterReads(method: InstallerMethod, trace: Trace): List =
+ method.invokes.distinct().mapNotNull { reference ->
+ val called = index.method(reference) ?: return@mapNotNull null
+ pureGetter(called, BOOLEAN, trace)?.takeIf { field ->
+ listOf("showAdsBefore", "showAdsAfter", "useSystemAppRules").any { semantic(field, it, BOOLEAN) }
+ }
+ }.distinctBy { it.descriptor }
+
+ private fun semantic(field: InstallerField, key: String, type: String) = !field.isStatic && field.type == type && role(field, key)
+ private fun role(field: InstallerField, key: String): Boolean =
+ sequenceOf(field.name).plus(field.annotationStrings.asSequence()).any { normalizeRole(it) == normalizeRole(key) }
+ private fun normalizeRole(value: String) = value.replace("_", "").replace("-", "").lowercase()
+ private fun listType(type: String) = type in LIST_TYPES
+ private fun listCall(reference: String, name: String, suffix: String): Boolean =
+ (LIST_TYPES + "java.util.Collection").any { reference == "L${it.replace('.', '/')};->$name$suffix" }
+
+ private fun booleanEquality(method: InstallerMethod): Boolean = method.isStatic && method.returnType == "boolean" &&
+ method.parameterTypes == listOf("java.lang.Object", "java.lang.Object") && method.writtenFields.isEmpty() &&
+ method.readFields.isEmpty() && method.strings.isEmpty() && method.invokes.distinct() == listOf(OBJECT_EQUALS) &&
+ method.opcodes.lastOrNull() == RETURN
+
+ private fun subclass(type: String, parent: String, trace: Trace): Boolean {
+ val seen = mutableSetOf()
+ var current: String? = type
+ while (current != null && seen.add(current)) {
+ if (current == parent) return true
+ val clazz = index.clazz(current) ?: run { trace.miss("super_class"); return false }
+ current = clazz.superName
+ }
+ return false
+ }
+
+ private companion object {
+ const val BOOLEAN = "java.lang.Boolean"
+ const val FRAME = "android.widget.FrameLayout"
+ const val RETURN = 0x0f
+ const val BOOLEAN_TRUE = "Ljava/lang/Boolean;->TRUE:Ljava/lang/Boolean;"
+ const val BOOLEAN_VALUE = "Ljava/lang/Boolean;->booleanValue()Z"
+ const val BOOLEAN_EQUALS = "Ljava/lang/Boolean;->equals(Ljava/lang/Object;)Z"
+ const val OBJECT_EQUALS = "Ljava/lang/Object;->equals(Ljava/lang/Object;)Z"
+ val LIST_TYPES = setOf("java.util.List", "java.util.ArrayList", "java.util.LinkedList", "java.util.AbstractList", "java.util.AbstractSequentialList")
+ val FRAME_VISIBILITY = setOf("android.view.View", "android.view.ViewGroup", "android.widget.FrameLayout", "android.widget.LinearLayout", "android.widget.RelativeLayout")
+ .map { "L${it.replace('.', '/')};->setVisibility(I)V" }.toSet()
+ val GETTER_OPCODES = setOf(0x07, 0x08, 0x09, 0x54, 0x11)
+ val BEFORE_OPCODES = setOf(0x00, 0x01, 0x02, 0x03, 0x07, 0x08, 0x09, 0x0a, 0x0f,
+ 0x12, 0x28, 0x29, 0x2a, 0x32, 0x33, 0x38, 0x39, 0x54, 0x62, 0x6e, 0x71, 0x74, 0x77)
+ val PRIMITIVES = setOf("void", "boolean", "byte", "short", "char", "int", "long", "float", "double")
+ }
+}
diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerReflection.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerReflection.kt
new file mode 100644
index 0000000..5bfc095
--- /dev/null
+++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerReflection.kt
@@ -0,0 +1,33 @@
+package cn.ianzb.hyperrefine.hook.packageinstaller
+
+import java.lang.reflect.Array as JavaArray
+import java.lang.reflect.Method
+import java.lang.reflect.Modifier
+
+/** Resolve only descriptors produced by the semantic discovery session. */
+internal object InstallerReflection {
+ fun type(name: String, loader: ClassLoader?): Class<*> = when (name) {
+ "void" -> Void.TYPE
+ "boolean" -> Boolean::class.javaPrimitiveType!!
+ "byte" -> Byte::class.javaPrimitiveType!!
+ "short" -> Short::class.javaPrimitiveType!!
+ "char" -> Char::class.javaPrimitiveType!!
+ "int" -> Int::class.javaPrimitiveType!!
+ "long" -> Long::class.javaPrimitiveType!!
+ "float" -> Float::class.javaPrimitiveType!!
+ "double" -> Double::class.javaPrimitiveType!!
+ else -> if (name.endsWith("[]")) JavaArray.newInstance(type(name.dropLast(2), loader), 0).javaClass
+ else Class.forName(name, false, loader)
+ }
+
+ fun method(data: InstallerMethod, loader: ClassLoader?): Method {
+ val owner = type(data.owner, loader)
+ val result = type(data.returnType, loader)
+ val parameters = data.parameterTypes.map { type(it, loader) }.toTypedArray()
+ val matches = owner.declaredMethods.filter {
+ it.name == data.name && it.returnType == result && Modifier.isStatic(it.modifiers) == data.isStatic &&
+ it.parameterTypes.contentEquals(parameters)
+ }
+ return uniqueInstallerMatch("reflection ${data.descriptor}", matches).apply { isAccessible = true }
+ }
+}
diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/PackageInstallerKeys.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/PackageInstallerKeys.kt
new file mode 100644
index 0000000..84d6707
--- /dev/null
+++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/PackageInstallerKeys.kt
@@ -0,0 +1,14 @@
+package cn.ianzb.hyperrefine.hook.packageinstaller
+
+/** Independent installer business options; all default to false in both processes. */
+object PackageInstallerKeys {
+ const val PACKAGE_NAME = "com.miui.packageinstaller"
+ const val DISABLE_ADS = "packageinstaller_disable_ads"
+ const val DISABLE_RISK = "packageinstaller_disable_risk"
+ const val BLOCK_CLOUD = "packageinstaller_block_cloud"
+ const val DISABLE_GUARD = "packageinstaller_disable_guard"
+ const val DISABLE_FREQUENT_CHECK = "packageinstaller_disable_frequent_check"
+ const val BLOCK_UPLOAD = "packageinstaller_block_upload"
+
+ val ALL = listOf(DISABLE_ADS, DISABLE_RISK, BLOCK_CLOUD, DISABLE_GUARD, DISABLE_FREQUENT_CHECK, BLOCK_UPLOAD)
+}
diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/PackageInstallerLoad.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/PackageInstallerLoad.kt
new file mode 100644
index 0000000..ed10524
--- /dev/null
+++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/packageinstaller/PackageInstallerLoad.kt
@@ -0,0 +1,63 @@
+package cn.ianzb.hyperrefine.hook.packageinstaller
+
+import android.os.Build
+import cn.ianzb.hyperrefine.hook.base.BaseLoad
+import cn.ianzb.hyperrefine.hook.base.PackageTarget
+import cn.ianzb.hyperrefine.hook.prefs.HookPrefs
+import cn.ianzb.hyperrefine.hook.xposed.HookHelper
+import java.io.IOException
+import java.util.zip.ZipFile
+
+class PackageInstallerLoad : BaseLoad() {
+ override val targetPackages = listOf(PackageInstallerKeys.PACKAGE_NAME)
+
+ override fun onPackageLoaded(target: PackageTarget) {
+ InstallerDiagnosticReporter.begin()
+ try {
+ configure(target)
+ } catch (failure: Throwable) {
+ InstallerDiagnosticReporter.record("load_configuration_failed type=${failure.javaClass.simpleName}")
+ throw failure
+ }
+ }
+
+ private fun configure(target: PackageTarget) {
+ val enabled = PackageInstallerKeys.ALL.filter { HookPrefs.getBoolean(it, false) }
+ InstallerDiagnosticReporter.record("settings enabled=${enabled.size}/6 mask=${PackageInstallerKeys.ALL.joinToString("") { if (it in enabled) "1" else "0" }}")
+ InstallerDiagnosticReporter.record("environment sdk=${Build.VERSION.SDK_INT} process=${target.processName} splits=${target.applicationInfo?.splitSourceDirs?.size ?: 0}")
+ HookHelper.log("PackageInstaller[r9]: entry sdk=${Build.VERSION.SDK_INT} process=${target.processName} enabled=${enabled.size}/6")
+ if (enabled.isEmpty()) {
+ InstallerDiagnosticReporter.record("skipped no_enabled_settings")
+ HookHelper.log("PackageInstaller[r9]: no enabled installer settings received; no business hooks requested")
+ return
+ }
+ val info = target.applicationInfo
+ if (info == null) {
+ InstallerDiagnosticReporter.record("skipped no_application_info")
+ HookHelper.log("PackageInstaller[r9]: skipped, ApplicationInfo unavailable")
+ return
+ }
+ if (!InstallerContract.supports(
+ Build.VERSION.SDK_INT, target.packageName, target.processName,
+ !info.splitSourceDirs.isNullOrEmpty(),
+ )
+ ) {
+ InstallerDiagnosticReporter.record("skipped unsupported_environment")
+ HookHelper.log("PackageInstaller[r9]: skipped, environment sdk=${Build.VERSION.SDK_INT} package=${target.packageName} process=${target.processName} splits=${info.splitSourceDirs?.size ?: 0}")
+ return
+ }
+ // Fingerprints describe the component; each feature's semantics decide compatibility.
+ try {
+ val identity = ZipFile(info.sourceDir).use { InstallerCodeIdentity().inspect(it) }
+ InstallerDiagnosticReporter.record("fingerprint readable=${identity.readable} ${identity.diagnostic}")
+ } catch (failure: IOException) {
+ InstallerDiagnosticReporter.record("fingerprint unavailable type=${failure.javaClass.simpleName}; continuing capability discovery")
+ } catch (failure: SecurityException) {
+ InstallerDiagnosticReporter.record("fingerprint read_denied; continuing capability discovery")
+ }
+ InstallerDiagnosticReporter.record("discovery semantic_per_feature; fingerprint_is_not_a_gate")
+ initHook(InstallerBusinessHook(enabled), true)
+ // BaseLoad installs queued hooks after this callback returns; this is not installation success.
+ InstallerDiagnosticReporter.record("load hooks_queued=1")
+ }
+}
diff --git a/hook/src/main/resources/META-INF/xposed/scope.list b/hook/src/main/resources/META-INF/xposed/scope.list
index da06c7b..421f0cb 100644
--- a/hook/src/main/resources/META-INF/xposed/scope.list
+++ b/hook/src/main/resources/META-INF/xposed/scope.list
@@ -3,6 +3,7 @@ android
com.android.systemui
com.miui.home
com.miui.securitycenter
+com.miui.packageinstaller
com.milink.service
com.xiaomi.mirror
com.miui.misound
diff --git a/hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerAdapterMaterializationTest.kt b/hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerAdapterMaterializationTest.kt
new file mode 100644
index 0000000..0efab6a
--- /dev/null
+++ b/hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerAdapterMaterializationTest.kt
@@ -0,0 +1,138 @@
+package cn.ianzb.hyperrefine.hook.packageinstaller
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.luckypray.dexkit.DexKitBridge
+import org.luckypray.dexkit.result.FieldData
+import org.luckypray.dexkit.result.FieldUsingType
+import org.luckypray.dexkit.result.MethodData
+import org.luckypray.dexkit.result.MethodDataList
+import org.luckypray.dexkit.result.UsingFieldData
+import java.lang.reflect.InvocationTargetException
+
+/** Runs the production converter with actual DexKit result types. Lazy values are fixtures, not JNI results. */
+class InstallerAdapterMaterializationTest {
+ @Test fun emptyReferenceCannotHideLaterStaticDefinition() {
+ val f = Fixture()
+ val empty = f.convert(f.method(0))
+ assertFalse(empty.isStatic)
+ val full = f.method(1, modifiers = 8, code = listOf(0x12, 0x11))
+ val complete = f.convert(full)
+ assertTrue(complete.isStatic)
+ assertEquals(listOf(0x12, 0x11), complete.opcodes)
+ assertSame(complete, f.convert(f.method(0)))
+ }
+
+ @Test fun definitionFirstCannotBeDowngradedByReference() {
+ val f = Fixture()
+ val definition = f.convert(f.method(1, code = listOf(0x12, 0x11)))
+ assertSame(definition, f.convert(f.method(0)))
+ }
+
+ @Test fun conflictingCompleteSnapshotsFailInsteadOfMerging() {
+ val f = Fixture()
+ f.convert(f.method(1, code = listOf(0x12, 0x11)))
+ val error = assertThrows(InstallerMatchException::class.java) {
+ f.convert(f.method(2, code = listOf(0x54, 0x11)))
+ }
+ assertEquals("dex_index.metadata_conflict", error.role)
+ assertEquals(2, error.candidateCount)
+ }
+
+ @Test fun repeatedRelationsAreExistenceSetsButOpcodeOrderIsRetained() {
+ val f = Fixture()
+ val value = f.method(1, code = listOf(0x54, 0x54, 0x11))
+ val child = f.method(0, descriptor = "Lfixture/Api;->read()Ljava/lang/String;", prime = false)
+ f.lazy(value, "invokes", MethodDataList(listOf(child, child)))
+ val field = f.field("Lfixture/Owner;->title:Ljava/lang/String;")
+ f.lazy(value, "usingFields", listOf(UsingFieldData(field, FieldUsingType.Read), UsingFieldData(field, FieldUsingType.Read),
+ UsingFieldData(field, FieldUsingType.Write), UsingFieldData(field, FieldUsingType.Write)))
+ val dto = f.convert(value)
+ assertEquals(listOf(child.descriptor), dto.invokes)
+ assertEquals(listOf(field.descriptor), dto.readFields)
+ assertEquals(listOf(field.descriptor), dto.writtenFields)
+ assertEquals(listOf(0x54, 0x54, 0x11), dto.opcodes)
+ // The child's uninitialized lazy metadata would call JNI if conversion recursively visited it.
+ }
+
+ @Test fun duplicateOnlyChangesDoNotCreateConflicts() {
+ val f = Fixture()
+ val child = f.method(0, descriptor = "Lfixture/Api;->read()Ljava/lang/String;", prime = false)
+ val first = f.method(1, code = listOf(0x6e, 0x11))
+ f.lazy(first, "invokes", MethodDataList(listOf(child, child)))
+ val second = f.method(2, code = listOf(0x6e, 0x11))
+ f.lazy(second, "invokes", MethodDataList(listOf(child)))
+ assertSame(f.convert(first), f.convert(second))
+ }
+
+ @Test fun declaredAbstractAndNativeSnapshotsAreNotUnknownReferences() {
+ for (flag in listOf(0x400, 0x100)) {
+ val f = Fixture()
+ val declaration = f.convert(f.method(1, modifiers = flag or 8))
+ assertTrue(declaration.isStatic)
+ assertSame(declaration, f.convert(f.method(0)))
+ }
+ }
+
+ @Test fun emptySnapshotsCanRefreshWithoutMakingUnknownFlagsAuthoritative() {
+ val cache = InstallerMethodSnapshots()
+ val first = cache.remember(method(), declaredWithoutCode = false)
+ assertEquals(null, cache.complete(first.descriptor))
+ val second = cache.remember(method().copy(isStatic = true), declaredWithoutCode = false)
+ assertTrue(second.isStatic)
+ assertEquals(null, cache.complete(second.descriptor))
+ val complete = cache.remember(method().copy(isStatic = true, annotationStrings = setOf("/info/layout/v2")), false)
+ assertSame(complete, cache.complete(complete.descriptor))
+ cache.clear()
+ assertEquals(null, cache.complete(complete.descriptor))
+ }
+
+ private fun method() = InstallerMethod("Lfixture/Owner;->body()Ljava/lang/Object;", "fixture.Owner", "body", "java.lang.Object", emptyList(), false)
+
+ private class Fixture {
+ private val bridge: DexKitBridge
+ private val index: InstallerDexKitIndex
+ init {
+ // Allocate an inert Java object: no native constructor, open(), handle or target code is run.
+ val type = Class.forName("sun.misc.Unsafe")
+ val field = type.getDeclaredField("theUnsafe").apply { isAccessible = true }
+ bridge = type.getMethod("allocateInstance", Class::class.java).invoke(field.get(null), DexKitBridge::class.java) as DexKitBridge
+ index = InstallerDexKitIndex::class.java.getDeclaredConstructor(DexKitBridge::class.java).apply { isAccessible = true }.newInstance(bridge)
+ }
+
+ fun method(dex: Int, modifiers: Int = 0, code: List = emptyList(),
+ descriptor: String = "Lfixture/Owner;->body()Ljava/lang/Object;", prime: Boolean = true): MethodData {
+ val result = MethodData::class.java.getDeclaredConstructor(DexKitBridge::class.java, Int::class.javaPrimitiveType,
+ Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, String::class.java,
+ Int::class.javaPrimitiveType, List::class.java).apply { isAccessible = true }
+ .newInstance(bridge, 9, dex, 2, modifiers, descriptor, 3, emptyList())
+ if (prime) {
+ lazy(result, "usingStrings", emptyList())
+ lazy(result, "annotations", emptyList())
+ lazy(result, "invokes", MethodDataList())
+ lazy(result, "usingFields", emptyList())
+ lazy(result, "opCodes", code)
+ }
+ return result
+ }
+
+ fun field(descriptor: String): FieldData = FieldData::class.java.getDeclaredConstructor(DexKitBridge::class.java,
+ Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType,
+ String::class.java, Int::class.javaPrimitiveType).apply { isAccessible = true }.newInstance(bridge, 4, 0, 2, 0, descriptor, 3)
+
+ fun lazy(value: Any, name: String, content: Any) {
+ value.javaClass.getDeclaredField("${name}\$delegate").apply { isAccessible = true }.set(value, lazyOf(content))
+ }
+
+ fun convert(data: MethodData): InstallerMethod = try {
+ InstallerDexKitIndex::class.java.getDeclaredMethod("materialize", MethodData::class.java).apply { isAccessible = true }
+ .invoke(index, data) as InstallerMethod
+ } catch (error: InvocationTargetException) {
+ throw error.targetException
+ }
+ }
+}
diff --git a/hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerCodeIdentityTest.kt b/hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerCodeIdentityTest.kt
new file mode 100644
index 0000000..e527720
--- /dev/null
+++ b/hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerCodeIdentityTest.kt
@@ -0,0 +1,73 @@
+package cn.ianzb.hyperrefine.hook.packageinstaller
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import java.io.File
+import java.util.zip.ZipEntry
+import java.util.zip.ZipFile
+import java.util.zip.ZipOutputStream
+
+class InstallerCodeIdentityTest {
+ @get:Rule val temporary = TemporaryFolder()
+ private val code = linkedMapOf(
+ "classes.dex" to byteArrayOf(1, 2, 3),
+ "classes2.dex" to byteArrayOf(4, 5, 6),
+ )
+ private val verifier = InstallerCodeIdentity()
+
+ @Test fun sameDexDifferentApkPackagingIsAccepted() {
+ val first = archive(code + ("resources.arsc" to byteArrayOf(10)))
+ val second = archive(code + ("resources.arsc" to byteArrayOf(20, 30)))
+ assertNotEquals(digest(first), digest(second))
+ assertTrue(inspect(first).readable)
+ assertTrue(inspect(second).readable)
+ }
+
+ @Test fun changedDexProducesDiagnosticFingerprintWithoutAnAllowlist() {
+ val apk = archive(code + ("classes2.dex" to byteArrayOf(4, 5, 7)))
+ assertTrue(inspect(apk).readable)
+ assertNotEquals(inspect(archive(code)).diagnostic, inspect(apk).diagnostic)
+ }
+
+ @Test fun additionalDexIsFingerprinted() {
+ val result = inspect(archive(code + ("classes3.dex" to byteArrayOf(9))))
+ assertTrue(result.readable)
+ assertTrue(result.diagnostic.contains("classes3.dex="))
+ }
+
+ @Test fun singleDexIsAlsoFingerprinted() {
+ assertTrue(inspect(archive(code.filterKeys { it == "classes.dex" })).readable)
+ }
+
+ @Test fun emptyDexSetHasNoFingerprint() {
+ assertFalse(inspect(archive(mapOf("resources.arsc" to byteArrayOf(10)))).readable)
+ }
+
+ @Test fun duplicateDexCannotHideBehindTheLastZipEntry() {
+ val apk = archive(code + ("classes.aux" to byteArrayOf(9)))
+ // Equal-length ZIP header names: keep the valid archive structure and create a duplicate name.
+ val renamed = apk.readBytes().toString(Charsets.ISO_8859_1)
+ .replace("classes.aux", "classes.dex").toByteArray(Charsets.ISO_8859_1)
+ apk.writeBytes(renamed)
+ val result = inspect(apk)
+ assertFalse(result.readable)
+ assertTrue(result.diagnostic.contains("duplicate"))
+ }
+
+ private fun inspect(file: File): InstallerCodeIdentity.Result = ZipFile(file).use(verifier::inspect)
+ private fun digest(file: File): String = file.inputStream().use(InstallerCodeIdentity::sha256)
+
+ private fun archive(entries: Map): File = temporary.newFile().apply {
+ ZipOutputStream(outputStream()).use { zip ->
+ entries.forEach { (name, data) ->
+ zip.putNextEntry(ZipEntry(name))
+ zip.write(data)
+ zip.closeEntry()
+ }
+ }
+ }
+}
diff --git a/hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerContractTest.kt b/hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerContractTest.kt
new file mode 100644
index 0000000..23b4864
--- /dev/null
+++ b/hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerContractTest.kt
@@ -0,0 +1,84 @@
+package cn.ianzb.hyperrefine.hook.packageinstaller
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class InstallerContractTest {
+ private val installer = PackageInstallerKeys.PACKAGE_NAME
+
+ @Test fun onlyAnalysedSdkAndMainProcessAreEligible() {
+ assertTrue(InstallerContract.supports(37, installer, installer, false))
+ assertFalse(InstallerContract.supports(36, installer, installer, false))
+ assertFalse(InstallerContract.supports(38, installer, installer, false))
+ assertFalse(InstallerContract.supports(37, installer, "$installer:other", false))
+ assertFalse(InstallerContract.supports(37, "android", installer, false))
+ assertFalse(InstallerContract.supports(37, installer, installer, true))
+ }
+
+ @Test fun exactReturnAndStaticnessAreRequiredEvenWhenNameMatches() {
+ val owner = Fixture::class.java
+ val instance = InstallerContract.method(owner, "value", "int", false, "java.lang.String")
+ assertEquals(4, instance.invoke(Fixture(), "test"))
+ assertThrows(IllegalStateException::class.java) {
+ InstallerContract.method(owner, "value", "boolean", false, "java.lang.String")
+ }
+ assertThrows(IllegalStateException::class.java) {
+ InstallerContract.method(owner, "value", "int", true, "java.lang.String")
+ }
+ assertThrows(IllegalStateException::class.java) {
+ InstallerContract.method(owner, "value", "int", false, "java.lang.Integer")
+ }
+ }
+
+ @Test fun missingMemberCannotFallBackToAnUnrelatedBooleanMethod() {
+ assertThrows(IllegalStateException::class.java) {
+ InstallerContract.method(Fixture::class.java, "missing", "boolean", false)
+ }
+ }
+
+ @Test fun failedFeatureRollsBackInReverseOrderAndStopsInstalling() {
+ val events = mutableListOf()
+ val failure = IllegalStateException("mount failed")
+ val actual = assertThrows(IllegalStateException::class.java) {
+ InstallerContract.installAtomically(listOf(
+ { events += "install-a"; { events += "remove-a"; Unit } },
+ { events += "install-b"; { events += "remove-b"; Unit } },
+ { throw failure },
+ { events += "must-not-run"; { Unit } },
+ ))
+ }
+ assertTrue(actual === failure)
+ assertEquals(listOf("install-a", "install-b", "remove-b", "remove-a"), events)
+ }
+
+ @Test fun successfulFeatureKeepsItsHandles() {
+ val events = mutableListOf()
+ InstallerContract.installAtomically(listOf(
+ { events += "install"; { events += "remove"; Unit } },
+ ))
+ assertEquals(listOf("install"), events)
+ }
+
+ @Test fun rollbackFailureIsVisibleAndDoesNotPreventOtherRemovals() {
+ val events = mutableListOf()
+ val failure = IllegalStateException("install failed")
+ val rollback = IllegalArgumentException("remove failed")
+ assertThrows(IllegalStateException::class.java) {
+ InstallerContract.installAtomically(listOf(
+ { { events += "removed-a"; Unit } },
+ { { throw rollback } },
+ { throw failure },
+ ))
+ }
+ assertEquals(listOf("removed-a"), events)
+ assertEquals(listOf(rollback), failure.suppressed.toList())
+ }
+
+ class Fixture {
+ fun value(value: String): Int = value.length
+ fun unrelated(): Boolean = true
+ }
+}
diff --git a/hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDiagnosticSnapshotTest.kt b/hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDiagnosticSnapshotTest.kt
new file mode 100644
index 0000000..71a4e61
--- /dev/null
+++ b/hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerDiagnosticSnapshotTest.kt
@@ -0,0 +1,54 @@
+package cn.ianzb.hyperrefine.hook.packageinstaller
+
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+
+class InstallerDiagnosticSnapshotTest {
+ @Before fun reset() { InstallerDiagnosticSnapshot.reset(); InstallerHitLedger.reset() }
+
+ @Test fun discoveryFailureAndZeroHitSurviveEventEviction() {
+ val failure = "match_failed option=packageinstaller_disable_ads group=main reason=layout_async: candidates=0"
+ InstallerDiagnosticSnapshot.observe(failure)
+ InstallerHitLedger.register("avl_upload")
+ val lines = InstallerDiagnosticSnapshot.merge((1..100).map { "event $it" }, 64)
+ assertEquals(64, lines.size)
+ assertEquals(1, lines.count { it == failure })
+ assertTrue(lines.last().contains("callback_calls=0 blocked=0"))
+ }
+
+ @Test fun stableFailureKeysUpdateWithoutUnboundedGrowthOrRawArguments() {
+ repeat(100) { InstallerDiagnosticSnapshot.observe("match_failed option=test group=main reason=$it") }
+ InstallerDiagnosticSnapshot.observe("runtime target argument text")
+ val lines = InstallerDiagnosticSnapshot.merge(emptyList(), 64)
+ assertEquals(listOf("match_failed option=test group=main reason=99"), lines)
+ }
+
+ @Test fun pinnedRecordsHaveStrictCountAndLengthBounds() {
+ repeat(100) { InstallerDiagnosticSnapshot.observe("probe role_$it detail=${"x".repeat(500)}") }
+ assertEquals(25, InstallerDiagnosticSnapshot.merge(emptyList(), 64).size)
+ assertTrue(InstallerDiagnosticSnapshot.merge(emptyList(), 64).all { it.length == 384 })
+ }
+
+ @Test fun existingTransportCapacityHoldsPinnedRecordsAndAllThirteenCounters() {
+ repeat(25) { InstallerDiagnosticSnapshot.observe("probe role_$it detail=fixed") }
+ listOf("layout_async", "layout_sync", "risk_scan", "intercept_check", "cloud_v2", "cloud_v3", "guard_tip",
+ "frequent_check", "avl_upload", "ads_before", "ads_after", "ads_cancel", "guard_banner").forEach(InstallerHitLedger::register)
+ val lines = InstallerDiagnosticSnapshot.merge((1..100).map { "event $it" }, 64)
+ assertEquals(64, lines.size)
+ assertEquals(13, lines.count { it.startsWith("hits point=") })
+ assertEquals(25, lines.count { it.startsWith("probe ") })
+ }
+
+ @Test fun finalSummarySurvivesIndependentGroupAndProbeSaturation() {
+ repeat(14) { InstallerDiagnosticSnapshot.observe("match_failed option=test group=$it reason=unavailable") }
+ repeat(20) { InstallerDiagnosticSnapshot.observe("probe role_$it detail=fixed") }
+ InstallerDiagnosticSnapshot.observe("summary installed=4/6 partial=2")
+ InstallerDiagnosticSnapshot.observe("summary installed=5/6 partial=1")
+ val lines = InstallerDiagnosticSnapshot.merge(emptyList(), 64)
+ assertEquals(25, lines.size)
+ assertEquals(14, lines.count { it.startsWith("match_failed ") })
+ assertEquals(1, lines.count { it.startsWith("summary ") })
+ assertTrue(lines.contains("summary installed=5/6 partial=1"))
+ }
+}
diff --git a/hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerGuardBannerRulesTest.kt b/hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerGuardBannerRulesTest.kt
new file mode 100644
index 0000000..4b2fcfe
--- /dev/null
+++ b/hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerGuardBannerRulesTest.kt
@@ -0,0 +1,277 @@
+package cn.ianzb.hyperrefine.hook.packageinstaller
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class InstallerGuardBannerRulesTest {
+ @Test fun resolvesOnlyInformationFactoryAndPureStateAccess() {
+ val f = fixture()
+ val point = InstallerGuardBannerRules(f).resolve()
+ assertEquals("guard_banner", point.id)
+ assertEquals("information", point.method.name)
+ val access = checkNotNull(point.guardBanner)
+ assertEquals("fixture.Banner", access.modelClass)
+ assertEquals("state", access.stateGetter.name)
+ assertEquals(6, access.textGetters.size)
+ assertEquals("kind", access.kindGetter.name)
+ assertTrue((access.textGetters + access.stateGetter + access.kindGetter).all { it.invokes.isEmpty() && it.writtenFields.isEmpty() })
+ }
+
+ @Test fun renamedClassesMethodsAndAnnotatedFieldKeysStillResolve() {
+ val original = fixture()
+ val changed = original.renamed()
+ val point = InstallerGuardBannerRules(changed).resolve()
+ assertNotEquals(InstallerGuardBannerRules(original).resolve().method.descriptor, point.method.descriptor)
+ assertTrue(checkNotNull(point.guardBanner).modelClass.startsWith("relocated."))
+ }
+
+ @Test fun modelNameAloneWithoutConstructionDoesNotIdentifyBanner() {
+ val f = fixture()
+ val changed = f.copy(methods = f.methods.map { if (it.name == "information") it.copy(invokes = emptyList()) else it })
+ assertThrows(InstallerMatchException::class.java) { InstallerGuardBannerRules(changed).resolve() }
+ }
+
+ @Test fun absentCloudFieldsDoNotBlockUniqueStructuralFactory() {
+ val f = fixture()
+ val changed = f.copy(methods = f.methods.map { if (it.name == "information" || it.name == "choose") it.copy(readFields = emptyList()) else it },
+ fields = f.fields.filterNot { it.owner == "fixture.Cloud" || it.owner == "fixture.CloudTip" })
+ assertEquals("information", InstallerGuardBannerRules(changed).resolve().method.name)
+ }
+
+ @Test fun absentOptionalCooperationFieldDoesNotBlockFactory() {
+ val f = fixture()
+ val changed = f.copy(fields = f.fields.filterNot { it.name == "cooperationExemptRiskTip" })
+ assertEquals("information", InstallerGuardBannerRules(changed).resolve().method.name)
+ }
+
+ @Test fun incompleteOptionalServerPayloadDoesNotBlockUniqueStructure() {
+ val f = fixture()
+ val changed = f.copy(fields = f.fields.filterNot { it.owner == "fixture.CloudTip" && it.name == "jumpText" })
+ assertEquals("information", InstallerGuardBannerRules(changed).resolve().method.name)
+ }
+
+ @Test fun pureCloudGetterCanDisambiguateSeveralVerifiedFactories() {
+ val f = fixture()
+ val tip = f.fields.single { it.name == "securityTip" }
+ val getter = method(tip.owner, "readTip", tip.type, reads = listOf(tip.descriptor), code = PURE)
+ val constructor = f.methods.single { it.name == "" }
+ val changed = f.copy(methods = f.methods.map { when (it.name) {
+ "information" -> it.copy(readFields = emptyList(), invokes = it.invokes + getter.descriptor)
+ "risk" -> it.copy(invokes = listOf(constructor.descriptor), opcodes = listOf(0x12, 0x11))
+ else -> it
+ } }).addMethod(getter)
+ assertEquals("information", InstallerGuardBannerRules(changed).resolve().method.name)
+ }
+
+ @Test fun ambiguousStructuresWithoutCloudHintFailWithRealStageCounts() {
+ val f = fixture()
+ val constructor = f.methods.single { it.name == "" }
+ val changed = f.copy(methods = f.methods.map { when (it.name) {
+ "information" -> it.copy(readFields = emptyList())
+ "risk" -> it.copy(invokes = listOf(constructor.descriptor), opcodes = listOf(0x12, 0x11))
+ else -> it
+ } })
+ val error = assertThrows(InstallerMatchException::class.java) { InstallerGuardBannerRules(changed).resolve() }
+ assertEquals(2, error.candidateCount)
+ assertTrue(error.message!!.contains("construction=2"))
+ assertTrue(error.message!!.contains("verified_models=2"))
+ assertTrue(error.message!!.contains("structural=2 security_tip=0"))
+ }
+
+ @Test fun extraNonnullableFactoryConsumerPreventsNullReturningHook() {
+ val f = fixture()
+ val factory = f.methods.single { it.name == "information" }
+ val unsafe = method(factory.owner, "unconditionalUse", "void", calls = listOf(factory.descriptor), code = listOf(0x0e))
+ val error = assertThrows(InstallerMatchException::class.java) { InstallerGuardBannerRules(f.addMethod(unsafe)).resolve() }
+ assertEquals(0, error.candidateCount)
+ assertTrue(error.message!!.contains("consumers=2 nullable_consumers=1"))
+ }
+
+ @Test fun extraNonnullableOuterConsumerIsAlsoRejected() {
+ val f = fixture()
+ val consumer = f.methods.single { it.name == "choose" }
+ val unsafe = method(consumer.owner, "unconditionalAppend", "void", calls = listOf(consumer.descriptor,
+ "Ljava/util/List;->add(Ljava/lang/Object;)Z"), code = listOf(0x0e))
+ assertThrows(InstallerMatchException::class.java) { InstallerGuardBannerRules(f.addMethod(unsafe)).resolve() }
+ }
+
+ @Test fun getterWithTargetCallsOrWritesIsRejected() {
+ for (write in listOf(false, true)) {
+ val f = fixture()
+ val changed = f.copy(methods = f.methods.map { if (it.owner == "fixture.State" && it.name == "text0") {
+ if (write) it.copy(writtenFields = it.readFields) else it.copy(invokes = listOf("Lfixture/Other;->run()V"))
+ } else it })
+ assertThrows(InstallerMatchException::class.java) { InstallerGuardBannerRules(changed).resolve() }
+ }
+ }
+
+ @Test fun duplicateStateAccessorAndMissingEnumKindAreRejected() {
+ val f = fixture()
+ val getter = f.methods.single { it.owner == "fixture.Banner" && it.name == "state" }
+ val duplicate = getter.copy(name = "anotherState", descriptor = descriptor(getter.owner, "anotherState", getter.returnType))
+ assertThrows(InstallerMatchException::class.java) { InstallerGuardBannerRules(f.addMethod(duplicate)).resolve() }
+ val notEnum = f.copy(classes = f.classes.map { if (it.name == "fixture.Kind") it.copy(superName = "java.lang.Object") else it })
+ assertThrows(InstallerMatchException::class.java) { InstallerGuardBannerRules(notEnum).resolve() }
+ }
+
+ @Test fun incompleteOrExtraStateTextIsRejected() {
+ val f = fixture()
+ val missing = f.copy(methods = f.methods.filterNot { it.owner == "fixture.State" && it.name == "text5" })
+ assertThrows(InstallerMatchException::class.java) { InstallerGuardBannerRules(missing).resolve() }
+ val extra = field("fixture.State", "additionalRisk", "java.lang.String")
+ val changed = f.copy(fields = f.fields + extra,
+ classes = f.classes.map { if (it.name == "fixture.State") it.copy(fields = it.fields + extra.descriptor) else it })
+ assertThrows(InstallerMatchException::class.java) { InstallerGuardBannerRules(changed).resolve() }
+ }
+
+ @Test fun absentNullableCallerOrListInsertionIsRejected() {
+ for (name in listOf("choose", "build")) {
+ val f = fixture()
+ val changed = f.copy(methods = f.methods.map { if (it.name == name) it.copy(opcodes = emptyList()) else it })
+ assertThrows(InstallerMatchException::class.java) { InstallerGuardBannerRules(changed).resolve() }
+ }
+ }
+
+ @Test fun arbitraryAddCannotReplaceModelListConsumer() {
+ val f = fixture()
+ val changed = f.copy(methods = f.methods.map { if (it.name == "build") it.copy(invokes = it.invokes.map { call ->
+ call.replace("Ljava/util/ArrayList;", "Lfixture/UnrelatedStore;")
+ }) else it })
+ assertThrows(InstallerMatchException::class.java) { InstallerGuardBannerRules(changed).resolve() }
+ }
+
+ @Test fun ambiguousInformationFactoriesDoNotChooseFirst() {
+ val f = fixture()
+ val factory = f.methods.single { it.name == "information" }
+ val duplicate = factory.copy(name = "anotherInformation", descriptor = descriptor(factory.owner, "anotherInformation", factory.returnType))
+ val changed = f.copy(methods = f.methods.map { if (it.name == "choose") it.copy(invokes = it.invokes + duplicate.descriptor) else it }).addMethod(duplicate)
+ val error = assertThrows(InstallerMatchException::class.java) { InstallerGuardBannerRules(changed).resolve() }
+ assertEquals(2, error.candidateCount)
+ }
+
+ @Test fun defaultBannerMatchesOnlyTwoNonemptyTextsAndKnownLinks() {
+ assertTrue(matches(texts(TITLE, BODY)))
+ assertTrue(matches(texts(TITLE, "$BODY $LINK")))
+ assertTrue(matches(texts(TITLE, "$BODY 了解 应用安装防护")))
+ assertTrue(matches(listOf(null, BODY, "", TITLE, null, " ")))
+ }
+
+ @Test fun dangerAndUnknownServerTextArePreserved() {
+ assertFalse(matches(texts(TITLE, BODY), kind = "DANGER"))
+ assertFalse(matches(texts("检测到高风险应用", BODY)))
+ assertFalse(matches(texts(TITLE, "$BODY 检测到其他风险")))
+ assertFalse(matches(texts(TITLE, "$BODY 未知服务器链接")))
+ assertFalse(matches(listOf(TITLE, BODY, "附加确认", null, null, null)))
+ }
+
+ @Test fun bottomConfirmationTextIsNeverDefaultInformationBody() {
+ assertFalse(matches(texts(TITLE, "已了解此安装包未经安全检测,可能存在风险")))
+ assertFalse(matches(listOf(TITLE, BODY, "已了解此安装包未经安全检测,可能存在风险", null, null, null)))
+ }
+
+ @Test fun policyUsesSuppliedLocaleResourcesWithoutChineseFallback() {
+ val title = "Safety reminder"
+ val body = "This app has not been reviewed. Get apps from the store."
+ val link = "Learn about protection"
+ assertTrue(InstallerGuardBannerPolicy.matches("WARNING", texts(title, "$body $link"), title, body, setOf(link)))
+ assertFalse(InstallerGuardBannerPolicy.matches("WARNING", texts(TITLE, BODY), title, body, setOf(link)))
+ }
+
+ @Test fun onlyFormattingWhitespaceAndProvenZeroWidthSpaceAreIgnored() {
+ assertTrue(matches(texts(" 安全提醒\n", "$BODY\u200b 了解 安全守护")))
+ assertFalse(matches(texts(TITLE, BODY.replace("。", "!"))))
+ assertFalse(matches(texts(TITLE, "$BODY\u2060$LINK")))
+ }
+
+ @Test fun absentResourcesWrongStateShapeAndOversizeValuesArePreserved() {
+ assertFalse(InstallerGuardBannerPolicy.matches("WARNING", texts(TITLE, BODY), "", BODY, setOf(LINK)))
+ assertFalse(InstallerGuardBannerPolicy.matches("WARNING", texts(TITLE, BODY), TITLE, "", setOf(LINK)))
+ assertFalse(matches(listOf(TITLE, BODY)))
+ assertFalse(matches(texts(TITLE, "x".repeat(4_097))))
+ assertFalse(InstallerGuardBannerPolicy.matches("WARNING", texts(TITLE, BODY), TITLE, BODY, setOf("x".repeat(513))))
+ }
+
+ private fun matches(values: List, kind: String = "WARNING"): Boolean =
+ InstallerGuardBannerPolicy.matches(kind, values, TITLE, BODY, setOf(LINK, "了解应用安装防护"))
+
+ private fun texts(title: String, body: String): List = listOf(title, body, null, null, null, null)
+
+ private fun fixture(): Index {
+ val cloud = "fixture.Cloud"
+ val payload = "fixture.CloudTip"
+ val model = "fixture.Banner"
+ val state = "fixture.State"
+ val kind = "fixture.Kind"
+ val base = "fixture.Model"
+ val owner = "fixture.Factory"
+ val exemption = field(cloud, "cooperationExemptRiskTip", "java.lang.Integer")
+ val tip = field(cloud, "securityTip", payload)
+ val system = field(cloud, "useSystemAppRules", "java.lang.Boolean")
+ val payloadFields = listOf("title", "text", "jumpText").map { field(payload, it, "java.lang.String") }
+ val textFields = (0..5).map { field(state, "value$it", "java.lang.CharSequence") }
+ val kindField = field(state, "mode", kind)
+ val stateField = field(model, "data", state)
+ val stateGetter = method(model, "state", state, reads = listOf(stateField.descriptor), code = PURE)
+ val textGetters = textFields.mapIndexed { i, f -> method(state, "text$i", f.type, reads = listOf(f.descriptor), code = PURE) }
+ val kindGetter = method(state, "kind", kind, reads = listOf(kindField.descriptor), code = PURE)
+ val formatter = method(state, "describe", "java.lang.String", strings = LABELS, reads = textFields.map { it.descriptor } + kindField.descriptor)
+ val constructor = method(model, "", "void", listOf("android.content.Context", kind) + List(6) { "java.lang.CharSequence" })
+ val factory = method(owner, "information", model, calls = listOf(constructor.descriptor), reads = listOf(exemption.descriptor, tip.descriptor), code = listOf(0x12, 0x11))
+ val risk = method(owner, "risk", model)
+ val source = method(owner, "sourceAuthorization", model)
+ val consumer = method(owner, "choose", base, listOf(cloud, "fixture.Apk"),
+ calls = listOf(risk.descriptor, source.descriptor, factory.descriptor), reads = listOf(system.descriptor), code = listOf(0x38, 0x11))
+ val outer = method(owner, "build", "void", calls = listOf(consumer.descriptor, "Ljava/util/ArrayList;->add(Ljava/lang/Object;)Z"), code = listOf(0x38, 0x0e))
+ val methods = listOf(stateGetter, kindGetter, formatter, constructor, factory, risk, source, consumer, outer) + textGetters
+ val fields = listOf(exemption, tip, system, stateField, kindField) + payloadFields + textFields
+ val names = (methods.map { it.owner } + fields.map { it.owner } + base + kind).distinct()
+ val classes = names.map { name -> InstallerClass(name, when (name) { model -> base; kind -> "java.lang.Enum"; else -> "java.lang.Object" },
+ methods = methods.filter { it.owner == name }.map { it.descriptor }, fields = fields.filter { it.owner == name }.map { it.descriptor }) }
+ return Index(methods, fields, classes)
+ }
+
+ private data class Index(val methods: List, val fields: List, val classes: List) : InstallerDexIndex {
+ override fun findMethods(query: InstallerMethodQuery): List = methods.filter {
+ it.strings.containsAll(query.strings) && query.numbers.isEmpty() && (query.owner == null || query.owner == it.owner) &&
+ (query.returnType == null || query.returnType == it.returnType) && (query.isStatic == null || query.isStatic == it.isStatic) &&
+ (query.parameterCount == null || query.parameterCount == it.parameterTypes.size) &&
+ (query.parameterTypes == null || query.parameterTypes.size == it.parameterTypes.size && query.parameterTypes.zip(it.parameterTypes).all { (wanted, actual) -> wanted == null || wanted == actual })
+ }
+ override fun method(descriptor: String): InstallerMethod? = methods.find { it.descriptor == descriptor }
+ override fun field(descriptor: String): InstallerField? = fields.find { it.descriptor == descriptor }
+ override fun clazz(name: String): InstallerClass? = classes.find { it.name == name }
+ override fun callers(descriptor: String): List = methods.filter { descriptor in it.invokes }
+ fun addMethod(method: InstallerMethod): Index = copy(methods = methods + method,
+ classes = classes.map { if (it.name == method.owner) it.copy(methods = it.methods + method.descriptor) else it })
+ fun renamed(): Index {
+ val types = classes.mapIndexed { i, c -> c.name to "relocated.Type$i" }.toMap()
+ fun type(name: String) = types[name] ?: name
+ val methodRefs = methods.mapIndexed { i, m -> m.descriptor to descriptor(type(m.owner), if (m.name == "") m.name else "method$i", type(m.returnType), m.parameterTypes.map(::type)) }.toMap()
+ val fieldRefs = fields.mapIndexed { i, f -> f.descriptor to field(type(f.owner), "field$i", type(f.type)).descriptor }.toMap()
+ val changedMethods = methods.mapIndexed { i, m -> m.copy(descriptor = methodRefs.getValue(m.descriptor), owner = type(m.owner),
+ name = if (m.name == "") m.name else "method$i", returnType = type(m.returnType), parameterTypes = m.parameterTypes.map(::type),
+ invokes = m.invokes.map { methodRefs[it] ?: it }, readFields = m.readFields.map { fieldRefs[it] ?: it }, writtenFields = m.writtenFields.map { fieldRefs[it] ?: it }) }
+ val changedFields = fields.mapIndexed { i, f -> f.copy(descriptor = fieldRefs.getValue(f.descriptor), owner = type(f.owner), name = "field$i", type = type(f.type), annotationStrings = f.annotationStrings + f.name) }
+ return Index(changedMethods, changedFields, classes.map { c -> c.copy(name = type(c.name), superName = c.superName?.let(::type),
+ methods = c.methods.map { methodRefs[it] ?: it }, fields = c.fields.map { fieldRefs[it] ?: it }) })
+ }
+ }
+
+ private companion object {
+ private const val TITLE = "安全提醒"
+ private const val BODY = "该应用未经小米安全审核,建议优先使用应用商店下载。"
+ private const val LINK = "了解安全守护"
+ private val PURE = listOf(0x54, 0x11)
+ private val LABELS = setOf("Data(type=", ", title1=", ", content1=", ", title2=", ", content2=", ", content2extra=", ", content3=")
+ private fun dex(type: String): String = when (type) { "void" -> "V"; else -> "L${type.replace('.', '/')};" }
+ private fun descriptor(owner: String, name: String, result: String, params: List = emptyList()): String = "${dex(owner)}->$name(${params.joinToString("") { dex(it) }})${dex(result)}"
+ private fun method(owner: String, name: String, result: String, params: List = emptyList(), strings: Set = emptySet(),
+ calls: List = emptyList(), reads: List = emptyList(), code: List = emptyList()): InstallerMethod =
+ InstallerMethod(descriptor(owner, name, result, params), owner, name, result, params, false, strings = strings, invokes = calls, readFields = reads, opcodes = code)
+ private fun field(owner: String, name: String, type: String): InstallerField = InstallerField("${dex(owner)}->$name:${dex(type)}", owner, name, type, false)
+ }
+}
diff --git a/hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerHitLedgerTest.kt b/hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerHitLedgerTest.kt
new file mode 100644
index 0000000..91c9fba
--- /dev/null
+++ b/hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerHitLedgerTest.kt
@@ -0,0 +1,76 @@
+package cn.ianzb.hyperrefine.hook.packageinstaller
+
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+
+class InstallerHitLedgerTest {
+ @Before fun reset() = InstallerHitLedger.reset()
+
+ @Test fun registeredButUnobservedProducerIsExplicitlyZero() {
+ InstallerHitLedger.register("avl_upload")
+ val line = InstallerHitLedger.snapshotLines().single()
+ assertTrue(line.contains("registered=true callback_calls=0 blocked=0"))
+ }
+
+ @Test fun registerCannotResetSharedPointOrCreateArbitraryKeys() {
+ InstallerHitLedger.register("intercept_check")
+ InstallerHitLedger.record("intercept_check", InstallerHitLedger.Event.CALL)
+ InstallerHitLedger.register("intercept_check")
+ InstallerHitLedger.register("untrusted-argument")
+ assertFalse(InstallerHitLedger.record("untrusted-argument", InstallerHitLedger.Event.CALL))
+ assertEquals(1, InstallerHitLedger.snapshotLines().size)
+ assertTrue(InstallerHitLedger.snapshotLines().single().contains("callback_calls=1"))
+ }
+
+ @Test fun uncommittedPointsDoNotAppearActive() {
+ assertFalse(InstallerHitLedger.record("avl_upload", InstallerHitLedger.Event.CALL))
+ assertTrue(InstallerHitLedger.snapshotLines().isEmpty())
+ }
+
+ @Test fun fifoEvictionCannotEraseCumulativeObservations() {
+ InstallerHitLedger.register("avl_upload")
+ InstallerHitLedger.record("avl_upload", InstallerHitLedger.Event.CALL)
+ InstallerHitLedger.record("avl_upload", InstallerHitLedger.Event.BLOCK)
+ val snapshot = InstallerHitLedger.mergeTrace((0..90).map { "trace $it" }, 64)
+ assertEquals(64, snapshot.size)
+ assertFalse(snapshot.any { it == "trace 0" })
+ assertTrue(snapshot.last().contains("callback_calls=1 blocked=1"))
+ }
+
+ @Test fun callbackCompletionAndCallbackFaultAreIndependentOfShortCircuit() {
+ InstallerHitLedger.register("layout_async")
+ listOf(InstallerHitLedger.Event.CALL, InstallerHitLedger.Event.BLOCK, InstallerHitLedger.Event.FAULT).forEach {
+ InstallerHitLedger.record("layout_async", it)
+ }
+ var line = InstallerHitLedger.snapshotLines().single()
+ assertTrue(line.contains("blocked=1 retained=0 completion=0 callback_faults=1"))
+ InstallerHitLedger.record("layout_async", InstallerHitLedger.Event.COMPLETE)
+ line = InstallerHitLedger.snapshotLines().single()
+ assertTrue(line.contains("completion=1"))
+ }
+
+ @Test fun allKnownPointsRemainInsideExistingTransportLimits() {
+ listOf("layout_async", "layout_sync", "risk_scan", "intercept_check", "cloud_v2", "cloud_v3", "guard_tip",
+ "frequent_check", "avl_upload", "ads_before", "ads_after", "ads_cancel", "guard_banner").forEach(InstallerHitLedger::register)
+ val lines = InstallerHitLedger.mergeTrace((0..90).map { "trace $it" }, 64)
+ assertEquals(64, lines.size)
+ assertEquals(13, lines.count { it.startsWith("hits point=") })
+ assertTrue(lines.all { it.length <= 384 })
+ }
+
+ @Test fun repeatedCallsAccumulateButScheduleAtBoundedIntervals() {
+ InstallerHitLedger.register("guard_tip")
+ val scheduled = (1..9).filter { InstallerHitLedger.record("guard_tip", InstallerHitLedger.Event.CALL) }
+ assertEquals(listOf(1, 2, 4, 8), scheduled)
+ assertTrue(InstallerHitLedger.snapshotLines().single().contains("callback_calls=9"))
+ }
+
+ @Test fun concurrentCallbacksHaveNoLostUpdates() {
+ InstallerHitLedger.register("intercept_check")
+ val workers = List(4) { Thread { repeat(1_000) { InstallerHitLedger.record("intercept_check", InstallerHitLedger.Event.CALL) } } }
+ workers.forEach(Thread::start)
+ workers.forEach(Thread::join)
+ assertTrue(InstallerHitLedger.snapshotLines().single().contains("callback_calls=4000"))
+ }
+}
diff --git a/hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerLocalRulesTest.kt b/hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerLocalRulesTest.kt
new file mode 100644
index 0000000..0ce36b5
--- /dev/null
+++ b/hook/src/test/java/cn/ianzb/hyperrefine/hook/packageinstaller/InstallerLocalRulesTest.kt
@@ -0,0 +1,433 @@
+package cn.ianzb.hyperrefine.hook.packageinstaller
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertThrows
+import org.junit.Test
+
+class InstallerLocalRulesTest {
+ @Test fun resolvesThreeIndependentBusinessCapabilities() {
+ val f = fixture()
+ val rules = InstallerLocalRules(f.index)
+ assertEquals(f.risk.descriptor, rules.resolve("risk_scan").method.descriptor)
+ assertEquals(f.accept.descriptor, rules.resolve("guard_tip").method.descriptor)
+ assertEquals(f.tip, rules.resolve("guard_tip").guardedItemClass)
+ assertEquals(f.level.descriptor, rules.resolve("frequent_check").method.descriptor)
+ }
+
+ @Test fun classMethodAndFieldRenamingKeepsSemanticCapabilities() {
+ val f = fixture()
+ val renamed = f.index.renamed()
+ val rules = InstallerLocalRules(renamed)
+ for (id in listOf("risk_scan", "guard_tip", "frequent_check")) {
+ val original = InstallerLocalRules(f.index).resolve(id)
+ val moved = rules.resolve(id)
+ assertNotEquals(original.method.descriptor, moved.method.descriptor)
+ assertEquals(original.method.returnType, moved.method.returnType)
+ }
+ assertNotEquals(f.tip, rules.resolve("guard_tip").guardedItemClass)
+ }
+
+ @Test fun duplicatePreferenceReaderDoesNotPickFirst() {
+ val f = fixture()
+ val leaf = f.index.methods.single { "virus_scan_install" in it.strings }
+ val duplicate = leaf.copy(name = "duplicate", descriptor = descriptor(leaf.owner, "duplicate", leaf.returnType, leaf.parameterTypes))
+ val error = assertThrows(InstallerMatchException::class.java) {
+ InstallerLocalRules(f.index.withMethods(f.index.methods + duplicate)).resolve("risk_scan")
+ }
+ assertEquals(2, error.candidateCount)
+ }
+
+ @Test fun ambiguousLocalScanGateIsRejected() {
+ val f = fixture()
+ val duplicate = f.risk.copy(name = "anotherGate", descriptor = descriptor(f.risk.owner, "anotherGate", "boolean", emptyList()))
+ val scannerCaller = f.index.methods.single { it.name == "scanCoroutine" }
+ val changed = f.index.methods.map {
+ if (it == scannerCaller) it.copy(invokes = it.invokes + duplicate.descriptor) else it
+ } + duplicate
+ val error = assertThrows(InstallerMatchException::class.java) {
+ InstallerLocalRules(f.index.withMethods(changed)).resolve("risk_scan")
+ }
+ assertEquals(2, error.candidateCount)
+ }
+
+ @Test fun permissionBooleanWithSamePreferencesIsNotScanGate() {
+ val f = fixture()
+ val decoy = f.risk.copy(name = "sourceAuthorization", descriptor = descriptor(f.risk.owner, "sourceAuthorization", "boolean", emptyList()))
+ val consumer = method("fixture.permission.Auth", "check", "void", invokes = listOf(decoy.descriptor))
+ val rules = InstallerLocalRules(f.index.withMethods(f.index.methods + decoy + consumer))
+ assertEquals(f.risk.descriptor, rules.resolve("risk_scan").method.descriptor)
+ }
+
+ @Test fun missingScannerRelationshipStopsRiskOnly() {
+ val f = fixture()
+ val index = f.index.withMethods(f.index.methods.filterNot { it.name == "scanCoroutine" })
+ val rules = InstallerLocalRules(index)
+ assertThrows(InstallerMatchException::class.java) { rules.resolve("risk_scan") }
+ assertEquals(f.accept.descriptor, rules.resolve("guard_tip").method.descriptor)
+ assertEquals(f.level.descriptor, rules.resolve("frequent_check").method.descriptor)
+ }
+
+ @Test fun gateSharedByScanAndAuthorizationIsRejected() {
+ val f = fixture()
+ val authorization = method("fixture.permission.Auth", "authorize", "boolean", invokes = listOf(f.risk.descriptor))
+ val rules = InstallerLocalRules(f.index.withMethods(f.index.methods + authorization))
+ assertThrows(InstallerMatchException::class.java) { rules.resolve("risk_scan") }
+ assertEquals(f.accept.descriptor, rules.resolve("guard_tip").method.descriptor)
+ assertEquals(f.level.descriptor, rules.resolve("frequent_check").method.descriptor)
+ }
+
+ @Test fun pureAccessorSharedByScanAndAuthorizationIsRejected() {
+ val f = fixture()
+ val accessor = f.index.methods.single { it.name == "accessGate" }
+ val authorization = method("fixture.permission.Auth", "authorize", "boolean", invokes = listOf(accessor.descriptor))
+ assertThrows(InstallerMatchException::class.java) {
+ InstallerLocalRules(f.index.withMethods(f.index.methods + authorization)).resolve("risk_scan")
+ }
+ }
+
+ @Test fun accessorWithAnotherBusinessCallIsRejected() {
+ val f = fixture()
+ val changed = f.index.methods.map { if (it.name == "accessGate") it.copy(invokes = it.invokes + CONTENT_RESOLVER) else it }
+ assertThrows(InstallerMatchException::class.java) {
+ InstallerLocalRules(f.index.withMethods(changed)).resolve("risk_scan")
+ }
+ }
+
+ @Test fun globalPreferenceReaderIsNeverTheHookTarget() {
+ val f = fixture()
+ val result = InstallerLocalRules(f.index).resolve("risk_scan")
+ assertEquals(emptyList(), result.method.parameterTypes)
+ assertEquals(false, result.method.isStatic)
+ assertEquals(emptySet(), result.method.strings)
+ }
+
+ @Test fun mapPredicateCannotReplaceAdapterAcceptance() {
+ val f = fixture()
+ val rules = InstallerLocalRules(f.index.withMethods(f.index.methods.filterNot { it == f.accept }))
+ val error = assertThrows(InstallerMatchException::class.java) { rules.resolve("guard_tip") }
+ assertEquals("guard_tip.adapter_accepts_model", error.role)
+ assertEquals(0, error.candidateCount)
+ }
+
+ @Test fun fullSafeModelDoesNotBecomeGuardedItem() {
+ val f = fixture()
+ val fullSafe = method("fixture.ui.FullSafe", "", "void", listOf("android.content.Context", f.cloud))
+ val index = f.index.withMethods(f.index.methods + fullSafe).withClass(InstallerClass("fixture.ui.FullSafe", f.base, methods = listOf(fullSafe.descriptor)))
+ assertEquals(f.tip, InstallerLocalRules(index).resolve("guard_tip").guardedItemClass)
+ }
+
+ @Test fun duplicateSwitchModelIsAmbiguous() {
+ val f = fixture()
+ val ctor = f.index.methods.single { it.owner == f.tip }
+ val clone = ctor.copy(owner = "fixture.ui.OtherSwitch", descriptor = descriptor("fixture.ui.OtherSwitch", "", "void", ctor.parameterTypes))
+ val index = f.index.withMethods(f.index.methods + clone).withClass(InstallerClass(clone.owner, f.base, methods = listOf(clone.descriptor)))
+ val error = assertThrows(InstallerMatchException::class.java) { InstallerLocalRules(index).resolve("guard_tip") }
+ assertEquals(2, error.candidateCount)
+ }
+
+ @Test fun switchModelWithoutCloudFlagBuilderIsRejected() {
+ val f = fixture()
+ val index = f.index.withMethods(f.index.methods.map { if (it.name == "buildList") it.copy(readFields = emptyList()) else it })
+ val error = assertThrows(InstallerMatchException::class.java) { InstallerLocalRules(index).resolve("guard_tip") }
+ assertEquals("guard_tip.model_builder", error.role)
+ }
+
+ @Test fun guardDoesNotRequireCommonAdapterLogLiterals() {
+ val f = fixture()
+ val changed = f.index.methods.map { if (it.name == "insert") it.copy(strings = emptySet()) else it }
+ assertEquals(f.accept.descriptor, InstallerLocalRules(f.index.withMethods(changed)).resolve("guard_tip").method.descriptor)
+ }
+
+ @Test fun guardAcceptsConcreteMapAndListInvokeOwners() {
+ val f = fixture()
+ val changed = f.index.methods.map { method -> method.copy(invokes = method.invokes.map {
+ it.replace("Ljava/util/Map;->", "Ljava/util/HashMap;->").replace("Ljava/util/List;->add", "Ljava/util/LinkedList;->add")
+ }) }
+ assertEquals(f.accept.descriptor, InstallerLocalRules(f.index.withMethods(changed)).resolve("guard_tip").method.descriptor)
+ }
+
+ @Test fun guardAcceptsDeclaredCustomCollectionImplementations() {
+ val f = fixture()
+ val changed = f.index.methods.map { method -> method.copy(invokes = method.invokes.map {
+ it.replace("Ljava/util/Map;->", "Lfixture/collections/RegistryMap;->")
+ .replace("Ljava/util/List;->add", "Lfixture/collections/ModelList;->add")
+ }) }
+ val index = f.index.withMethods(changed)
+ .withClass(InstallerClass("fixture.collections.RegistryMap", "java.util.HashMap"))
+ .withClass(InstallerClass("fixture.collections.ModelList", "java.lang.Object", interfaces = listOf("java.util.List")))
+ assertEquals(f.accept.descriptor, InstallerLocalRules(index).resolve("guard_tip").method.descriptor)
+ }
+
+ @Test fun guardAcceptsModelAncestorAndErasedParameter() {
+ val f = fixture()
+ val intermediate = "fixture.ui.IntermediateModel"
+ val withAncestor = f.index.withClass(InstallerClass(intermediate, f.base))
+ .withClass(checkNotNull(f.index.clazz(f.tip)).copy(superName = intermediate))
+ assertEquals(f.accept.descriptor, InstallerLocalRules(withAncestor).resolve("guard_tip").method.descriptor)
+ val erased = f.accept.copy(parameterTypes = listOf("java.lang.Object"),
+ descriptor = descriptor(f.accept.owner, f.accept.name, "boolean", listOf("java.lang.Object")))
+ val changed = f.index.methods.map { method ->
+ if (method == f.accept) erased else method.copy(invokes = method.invokes.map { if (it == f.accept.descriptor) erased.descriptor else it })
+ }
+ assertEquals(erased.descriptor, InstallerLocalRules(f.index.withMethods(changed)).resolve("guard_tip").method.descriptor)
+ }
+
+ @Test fun guardFollowsInheritedRegistryHelpersAndErasedFluentReturn() {
+ val f = fixture()
+ val parent = "fixture.ui.BaseRegistry"
+ val helpers = f.index.methods.filter { it.name in setOf("has", "register", "putModel") }
+ val moved = helpers.associate { old ->
+ old.descriptor to old.copy(owner = parent,
+ returnType = if (old.returnType == f.accept.owner) "java.lang.Object" else old.returnType,
+ descriptor = descriptor(parent, old.name, if (old.returnType == f.accept.owner) "java.lang.Object" else old.returnType, old.parameterTypes))
+ }
+ val mapField = f.index.fields.single { it.name == "delegates" }
+ val movedField = field(parent, mapField.name, mapField.type)
+ val changed = f.index.methods.map { original ->
+ val method = moved[original.descriptor] ?: original
+ method.copy(invokes = method.invokes.map { moved[it]?.descriptor ?: it },
+ readFields = method.readFields.map { if (it == mapField.descriptor) movedField.descriptor else it })
+ }
+ val index = f.index.withMethods(changed).copy(fields = f.index.fields.filterNot { it == mapField } + movedField)
+ .withClass(checkNotNull(f.index.clazz(f.accept.owner)).copy(superName = parent,
+ methods = changed.filter { it.owner == f.accept.owner }.map { it.descriptor }))
+ .withClass(InstallerClass(parent, "java.lang.Object", methods = changed.filter { it.owner == parent }.map { it.descriptor }, fields = listOf(movedField.descriptor)))
+ assertEquals(f.accept.descriptor, InstallerLocalRules(index).resolve("guard_tip").method.descriptor)
+ }
+
+ @Test fun guardAcceptsInlinedRegistryQueryAndRegistration() {
+ val f = fixture()
+ val map = f.index.fields.single { it.name == "delegates" }
+ val changed = f.index.methods.map {
+ if (it == f.accept) it.copy(invokes = listOf(CONTAINS_KEY, MAP_PUT), readFields = listOf(map.descriptor)) else it
+ }
+ assertEquals(f.accept.descriptor, InstallerLocalRules(f.index.withMethods(changed)).resolve("guard_tip").method.descriptor)
+ }
+
+ @Test fun guardFollowsPureErasedBridgeButHooksBusinessBodyOnce() {
+ val f = fixture()
+ val bridge = method(f.accept.owner, "bridgeAccept", "boolean", listOf("java.lang.Object"), invokes = listOf(f.accept.descriptor))
+ .copy(opcodes = listOf(0x1f, 0x6e, 0x0a, 0x0f))
+ val changed = f.index.methods.map {
+ if (it.name == "insert") it.copy(invokes = it.invokes.map { call -> if (call == f.accept.descriptor) bridge.descriptor else call }) else it
+ } + bridge
+ assertEquals(f.accept.descriptor, InstallerLocalRules(f.index.withMethods(changed)).resolve("guard_tip").method.descriptor)
+ }
+
+ @Test fun guardRejectsArbitraryContainsKeyAndPutMethods() {
+ val f = fixture()
+ val changed = f.index.methods.map { method -> method.copy(invokes = method.invokes.map {
+ it.replace("Ljava/util/Map;->", "Lfixture/unrelated/Dictionary;->")
+ }) }
+ val error = assertThrows(InstallerMatchException::class.java) {
+ InstallerLocalRules(f.index.withMethods(changed)).resolve("guard_tip")
+ }
+ assertEquals(0, error.candidateCount)
+ assertEquals(true, error.message.orEmpty().contains("predicate=0"))
+ }
+
+ @Test fun guardRequiresActualRegistrationPutRelationship() {
+ val f = fixture()
+ val changed = f.index.methods.map { if (it.name == "putModel") it.copy(invokes = emptyList()) else it }
+ val error = assertThrows(InstallerMatchException::class.java) {
+ InstallerLocalRules(f.index.withMethods(changed)).resolve("guard_tip")
+ }
+ assertEquals(true, error.message.orEmpty().contains("register=0"))
+ }
+
+ @Test fun guardRejectsPutToAnUnrelatedMapField() {
+ val f = fixture()
+ val other = field(f.accept.owner, "unrelatedMap", "java.util.Map")
+ val changed = f.index.methods.map { if (it.name == "putModel") it.copy(readFields = listOf(other.descriptor)) else it }
+ val index = f.index.withMethods(changed).copy(fields = f.index.fields + other)
+ assertThrows(InstallerMatchException::class.java) { InstallerLocalRules(index).resolve("guard_tip") }
+ }
+
+ @Test fun guardRejectsConsumerWithoutTypedRegistryRelationship() {
+ val f = fixture()
+ val changed = f.index.methods.map {
+ if (it.name == "insert") it.copy(readFields = it.readFields.filterNot { field -> field == f.index.fields.single { it.name == "registry" }.descriptor }) else it
+ }
+ val error = assertThrows(InstallerMatchException::class.java) {
+ InstallerLocalRules(f.index.withMethods(changed)).resolve("guard_tip")
+ }
+ assertEquals(true, error.message.orEmpty().contains("consumer=0"))
+ }
+
+ @Test fun guardRejectsUnrelatedAddMethodAndMultipleListFields() {
+ val f = fixture()
+ val arbitraryAdd = f.index.methods.map {
+ if (it.name == "insert") it.copy(invokes = it.invokes.map { call -> call.replace("Ljava/util/List;->add", "Lfixture/unrelated/Store;->add") }) else it
+ }
+ assertThrows(InstallerMatchException::class.java) { InstallerLocalRules(f.index.withMethods(arbitraryAdd)).resolve("guard_tip") }
+ val other = field("fixture.ui.ListAdapter", "unrelatedList", "java.util.List")
+ val twoLists = f.index.methods.map { if (it.name == "insert") it.copy(readFields = it.readFields + other.descriptor) else it }
+ val index = f.index.withMethods(twoLists).copy(fields = f.index.fields + other)
+ val error = assertThrows(InstallerMatchException::class.java) { InstallerLocalRules(index).resolve("guard_tip") }
+ assertEquals(true, error.message.orEmpty().contains("collection=0"))
+ }
+
+ @Test fun guardRejectsEquivalentAcceptanceAmbiguityAndReportsAllStages() {
+ val f = fixture()
+ val duplicate = f.accept.copy(name = "otherAcceptance", descriptor = descriptor(f.accept.owner, "otherAcceptance", "boolean", f.accept.parameterTypes))
+ val changed = f.index.methods.map { if (it.name == "insert") it.copy(invokes = it.invokes + duplicate.descriptor) else it } + duplicate
+ val error = assertThrows(InstallerMatchException::class.java) {
+ InstallerLocalRules(f.index.withMethods(changed)).resolve("guard_tip")
+ }
+ assertEquals(2, error.candidateCount)
+ for (stage in listOf("model", "construction", "builder", "adapter", "predicate", "register", "consumer", "collection")) {
+ assertEquals(true, error.message.orEmpty().contains("$stage="))
+ }
+ }
+
+ @Test fun frequencyNeedsTheInstallHistoryQuery() {
+ val f = fixture()
+ val index = f.index.withMethods(f.index.methods.map {
+ if (it.name == "historyQuery") it.copy(strings = setOf("permission=?", "time>?")) else it
+ })
+ val rules = InstallerLocalRules(index)
+ assertThrows(InstallerMatchException::class.java) { rules.resolve("frequent_check") }
+ assertEquals(f.risk.descriptor, rules.resolve("risk_scan").method.descriptor)
+ assertEquals(f.accept.descriptor, rules.resolve("guard_tip").method.descriptor)
+ }
+
+ @Test fun frequencyNeedsThreeAnnotatedThresholds() {
+ val f = fixture()
+ val missingColumn = f.index.fields.map { if ("rcsvc" in it.annotationStrings) it.copy(annotationStrings = emptySet()) else it }
+ assertThrows(InstallerMatchException::class.java) {
+ InstallerLocalRules(f.index.copy(fields = missingColumn)).resolve("frequent_check")
+ }
+ }
+
+ @Test fun frequencyAmbiguityDoesNotPickFirst() {
+ val f = fixture()
+ val duplicate = f.level.copy(name = "otherLevel", descriptor = descriptor(f.level.owner, "otherLevel", "int", emptyList()))
+ val methods = f.index.methods.map { if (it.name == "prepareHints") it.copy(invokes = it.invokes + duplicate.descriptor) else it } + duplicate
+ val index = f.index.withMethods(methods).copy(numbers = f.index.numbers + (duplicate.descriptor to LEVEL_NUMBERS))
+ val error = assertThrows(InstallerMatchException::class.java) { InstallerLocalRules(index).resolve("frequent_check") }
+ assertEquals(2, error.candidateCount)
+ }
+
+ @Test fun twentyFourHourIntegerGetterWithoutDomainFieldsIsIgnored() {
+ val f = fixture()
+ val decoy = method("fixture.cache.Cache", "age", "int")
+ val index = f.index.withMethods(f.index.methods + decoy).copy(numbers = f.index.numbers + (decoy.descriptor to setOf(86_400_000L)))
+ assertEquals(f.level.descriptor, InstallerLocalRules(index).resolve("frequent_check").method.descriptor)
+ }
+
+ @Test fun frequencyWithoutZeroAndThreeLevelsIsRejected() {
+ val f = fixture()
+ val index = f.index.copy(numbers = f.index.numbers + (f.level.descriptor to (LEVEL_NUMBERS - 0L)))
+ assertThrows(InstallerMatchException::class.java) { InstallerLocalRules(index).resolve("frequent_check") }
+ }
+
+ private data class Fixture(val index: FakeIndex, val risk: InstallerMethod, val accept: InstallerMethod,
+ val level: InstallerMethod, val tip: String, val base: String, val cloud: String)
+
+ private fun fixture(): Fixture {
+ val task = "fixture.task.Task"
+ val scanner = "fixture.scan.Scanner"
+ val base = "fixture.ui.Model"
+ val tip = "fixture.ui.SafeSwitch"
+ val cloud = "fixture.data.Cloud"
+ val adapter = "fixture.ui.Registry"
+ val counter = "fixture.data.Counts"
+ val virus = method("fixture.settings.Reader", "virus", "boolean", listOf("android.content.Context"), true,
+ setOf("virus_scan_install"), listOf(CONTENT_RESOLVER))
+ val safe = method("fixture.settings.Reader", "safe", "boolean", listOf("android.content.Context"), true,
+ setOf("miui_safe_mode"), listOf(CONTENT_RESOLVER))
+ val risk = method(task, "localGate", "boolean", invokes = listOf(virus.descriptor, safe.descriptor))
+ val access = method(task, "accessGate", "boolean", listOf(task), true, invokes = listOf(risk.descriptor))
+ .copy(opcodes = listOf(0x70, 0x0a, 0x0f))
+ val scan = method(scanner, "scan", "java.lang.Object", listOf("java.lang.String", "fixture.data.Apk", "fixture.coroutines.Token"),
+ strings = setOf("NewVirusScanner", "Exception when check virus: "))
+ val coroutine = method("fixture.task.ScanJob", "scanCoroutine", "java.lang.Object", listOf("java.lang.Object"),
+ invokes = listOf(access.descriptor, scan.descriptor)).copy(opcodes = listOf(0x38, 0x11))
+ val taskLog = method(task, "status", "void", strings = setOf("InstallTask"))
+ val ctor = method(tip, "", "void", listOf("android.content.Context", cloud),
+ strings = setOf("safe_mode_switch", "safe_mode_know_btn", "switch_action"))
+ val cloudFlag = field(cloud, "flag", "java.lang.Boolean")
+ val builder = method("fixture.ui.Builder", "buildList", "void", invokes = listOf(ctor.descriptor, LIST_ADD), reads = listOf(cloudFlag.descriptor))
+ val registryMap = field(adapter, "delegates", "java.util.Map")
+ val registryField = field("fixture.ui.ListAdapter", "registry", adapter)
+ val modelList = field("fixture.ui.ListAdapter", "models", "java.util.List")
+ val predicate = method(adapter, "has", "boolean", listOf(base), invokes = listOf(CONTAINS_KEY), reads = listOf(registryMap.descriptor))
+ val put = method(adapter, "putModel", adapter, listOf("int", base), invokes = listOf(MAP_PUT), reads = listOf(registryMap.descriptor))
+ val register = method(adapter, "register", adapter, listOf(base), invokes = listOf(CONTAINS_KEY, put.descriptor), reads = listOf(registryMap.descriptor))
+ val accept = method(adapter, "accept", "boolean", listOf(base), invokes = listOf(predicate.descriptor, register.descriptor, predicate.descriptor))
+ val insert = method("fixture.ui.ListAdapter", "insert", "int", listOf("int", base, "boolean", "boolean"),
+ strings = setOf("CommonAdapter", "add viewObject = "), invokes = listOf(accept.descriptor, INDEXED_ADD),
+ reads = listOf(registryField.descriptor, modelList.descriptor))
+ val packageField = field(counter, "source", "java.lang.String", setOf("package_name"))
+ val thresholds = listOf("rcafvc", "rchvc", "rcsvc").mapIndexed { i, column -> field(counter, "count$i", "int", setOf(column)) }
+ val history = method("fixture.db.History", "historyQuery", "java.util.List", listOf("java.lang.String", "long"),
+ strings = setOf("installer_package_name= ?", "install_time> ?"))
+ val level = method(counter, "currentLevel", "int", invokes = listOf(history.descriptor, LIST_SIZE),
+ reads = thresholds.map { it.descriptor } + packageField.descriptor)
+ val prepare = method("fixture.ui.Prepare", "prepareHints", "void", invokes = listOf(level.descriptor))
+ val methods = listOf(virus, safe, risk, access, scan, coroutine, taskLog, ctor, builder, predicate, put, register, accept, insert, history, level, prepare)
+ val fields = listOf(cloudFlag, packageField, registryMap, registryField, modelList) + thresholds
+ val classes = methods.groupBy { it.owner }.map { (owner, members) -> InstallerClass(owner,
+ when (owner) { tip -> base; "fixture.ui.Prepare" -> "android.app.Activity"; else -> "java.lang.Object" },
+ methods = members.map { it.descriptor }, fields = fields.filter { it.owner == owner }.map { it.descriptor }) }
+ return Fixture(FakeIndex(methods, fields, classes, mapOf(level.descriptor to LEVEL_NUMBERS)), risk, accept, level, tip, base, cloud)
+ }
+
+ private data class FakeIndex(val methods: List, val fields: List,
+ val classes: List, val numbers: Map>) : InstallerDexIndex {
+ override fun findMethods(query: InstallerMethodQuery): List = methods.filter { method ->
+ method.strings.containsAll(query.strings) && numbers[method.descriptor].orEmpty().containsAll(query.numbers) &&
+ (query.owner == null || query.owner == method.owner) && (query.returnType == null || query.returnType == method.returnType) &&
+ (query.isStatic == null || query.isStatic == method.isStatic) &&
+ (query.parameterCount == null || query.parameterCount == method.parameterTypes.size) &&
+ (query.parameterTypes == null || query.parameterTypes.size == method.parameterTypes.size &&
+ query.parameterTypes.zip(method.parameterTypes).all { (required, actual) -> required == null || required == actual })
+ }
+ override fun method(descriptor: String): InstallerMethod? = methods.find { it.descriptor == descriptor }
+ override fun callers(descriptor: String): List = methods.filter { descriptor in it.invokes }
+ override fun clazz(name: String): InstallerClass? = classes.find { it.name == name }
+ override fun field(descriptor: String): InstallerField? = fields.find { it.descriptor == descriptor }
+ fun withMethods(changed: List): FakeIndex = copy(methods = changed, classes = classes.map { clazz ->
+ clazz.copy(methods = changed.filter { it.owner == clazz.name }.map { it.descriptor })
+ })
+ fun withClass(clazz: InstallerClass): FakeIndex = copy(classes = classes.filterNot { it.name == clazz.name } + clazz)
+
+ fun renamed(): FakeIndex {
+ val custom = (classes.map { it.name } + classes.mapNotNull { it.superName } + methods.flatMap { it.parameterTypes + it.returnType })
+ .filter { it.startsWith("fixture.") }.distinct()
+ val names = custom.mapIndexed { i, name -> name to "relocated.a$i" }.toMap()
+ fun type(name: String) = names[name] ?: name
+ val methodRefs = methods.mapIndexed { i, m -> m.descriptor to descriptor(type(m.owner), if (m.name == "") m.name else "m$i", type(m.returnType), m.parameterTypes.map(::type)) }.toMap()
+ val fieldRefs = fields.mapIndexed { i, f -> f.descriptor to field(type(f.owner), "f$i", type(f.type)).descriptor }.toMap()
+ val movedMethods = methods.mapIndexed { i, m -> m.copy(descriptor = methodRefs.getValue(m.descriptor), owner = type(m.owner),
+ name = if (m.name == "") m.name else "m$i", returnType = type(m.returnType), parameterTypes = m.parameterTypes.map(::type),
+ invokes = m.invokes.map { methodRefs[it] ?: it }, readFields = m.readFields.map { fieldRefs[it] ?: it },
+ writtenFields = m.writtenFields.map { fieldRefs[it] ?: it }) }
+ val movedFields = fields.mapIndexed { i, f -> f.copy(descriptor = fieldRefs.getValue(f.descriptor), owner = type(f.owner), name = "f$i", type = type(f.type)) }
+ return copy(methods = movedMethods, fields = movedFields, classes = classes.map { c -> c.copy(name = type(c.name),
+ superName = c.superName?.let(::type), methods = c.methods.map { methodRefs[it] ?: it }, fields = c.fields.map { fieldRefs[it] ?: it }) },
+ numbers = numbers.mapKeys { methodRefs[it.key] ?: it.key })
+ }
+ }
+
+ companion object {
+ private const val CONTENT_RESOLVER = "Landroid/content/Context;->getContentResolver()Landroid/content/ContentResolver;"
+ private const val CONTAINS_KEY = "Ljava/util/Map;->containsKey(Ljava/lang/Object;)Z"
+ private const val MAP_PUT = "Ljava/util/Map;->put(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;"
+ private const val LIST_ADD = "Ljava/util/ArrayList;->add(Ljava/lang/Object;)Z"
+ private const val INDEXED_ADD = "Ljava/util/List;->add(ILjava/lang/Object;)V"
+ private const val LIST_SIZE = "Ljava/util/List;->size()I"
+ private val LEVEL_NUMBERS = setOf(86_400_000L, 0L, 1L, 2L, 3L)
+ private fun dex(type: String): String = when (type) {
+ "void" -> "V"; "boolean" -> "Z"; "int" -> "I"; "long" -> "J"; else -> "L${type.replace('.', '/')};"
+ }
+ private fun descriptor(owner: String, name: String, result: String, params: List): String =
+ "${dex(owner)}->$name(${params.joinToString("") { dex(it) }})${dex(result)}"
+ private fun method(owner: String, name: String, result: String, params: List = emptyList(), static: Boolean = false,
+ strings: Set = emptySet(), invokes: List = emptyList(), reads: List