From d8c4585d04574ad854c59e43cb9bd6cf51b87a8a Mon Sep 17 00:00:00 2001 From: okhsunrog Date: Tue, 25 Aug 2026 17:38:15 +0300 Subject: [PATCH] refactor(lsposed): make the dashboard's banner logic a pure, tested function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadDashboardState was 623 lines, and the ~25 guards that decide which banners a device gets were ~290 of them, each ending in a res.getString. That shape is what kept them untested: every branch needed a real Resources, this module has no Robolectric, and so the single most user-visible piece of logic in the app had zero coverage while the pure detectors feeding it had plenty. Split along the seam the codebase already uses elsewhere — classifyKmodProblem /renderKmodProblem, classifyKpmProblem/renderKpmProblem. dashboardIssues() takes a DashboardFacts and returns List, no Android types anywhere; DashboardIssue.toMessage() words one. A case carries the data its wording needs rather than a preformatted string, so the split is real and not cosmetic. filesystemHidingDashboardMessage was the odd one out doing both halves itself; it folds in, and its file goes. Emission order is load-bearing — the screen groups by severity but keeps emission order within a group, so this decides which error a user reads first. It used to be implied by the physical layout of one long block, where inserting a guard in the wrong place silently reordered banners. It is now eight named calls in one place, pinned by a test. The four backends' diagnosis blocks were near-identical eighteen-line copies differing only in kind, activator path and classifier; they collapse into deriveModuleFact. Derivation splits into deriveModuleFacts / deriveLsposedFacts / deriveEnvironmentFacts / resolveProtectionFacts, leaving an orchestrator that reads as the four steps it always was. Both detekt suppressions come off — the gate now passes on merit rather than by exemption. Behaviour is unchanged, checked emission by emission against the original: all 34 sites map one-to-one, in the same order, with the same severities. Two things that only looked like changes: the non-kmod module problems dropped their downloadArtifact at the callsite, which was a no-op because only renderKmodProblem ever sets one, and Active.version being null already meant no mismatch banner because versionsMismatch returns false for it. One dead local (zygiskStatusRaw) went with the rewrite. Also fixes a mark the profiler has been asking for and never received: measure-startup.py reads dashboard_issues_done, the code only ever emitted dashboard_messages_done, so the "issues" stage has always measured nothing. --- lsposed/AGENTS.md | 15 +- .../dev/okhsunrog/vpnhide/DashboardData.kt | 951 ++++++------------ .../okhsunrog/vpnhide/DashboardIssueRender.kt | 338 +++++++ .../dev/okhsunrog/vpnhide/DashboardIssues.kt | 585 +++++++++++ .../settings/FilesystemHidingMessages.kt | 66 -- .../okhsunrog/vpnhide/DashboardIssuesTest.kt | 612 +++++++++++ scripts/measure-startup.py | 4 +- 7 files changed, 1867 insertions(+), 704 deletions(-) create mode 100644 lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardIssueRender.kt create mode 100644 lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardIssues.kt delete mode 100644 lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/settings/FilesystemHidingMessages.kt create mode 100644 lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/DashboardIssuesTest.kt diff --git a/lsposed/AGENTS.md b/lsposed/AGENTS.md index 1b7f2f9c..a2d439b1 100644 --- a/lsposed/AGENTS.md +++ b/lsposed/AGENTS.md @@ -109,6 +109,13 @@ directions, since `internal` is module-wide and the compiler will not. - **`NativeChecks`** — `NATIVE_CHECKS` is the single probe list (Dashboard summary + Diagnostics share it); `CheckStatus.toPassed()` is the single tri-state mapping. +- **`DashboardIssue` / `dashboardIssues`** — every dashboard banner is decided + here, purely, from a `DashboardFacts`; `toMessage` (in + `DashboardIssueRender.kt`) is the only half that words it. A new banner is a + new `DashboardIssue` case plus its branch in the renderer — **never** a + `res.getString` inside `loadDashboardState`, which is what made the guard list + untestable for a year. Emission order in `dashboardIssues` is what the user + sees; `DashboardIssuesTest` pins it. - **`watchSystemDataDir`** — the shared `/data/system` FileObserver factory for the three system_server watchers (HookEntry / PackageVisibilityHooks / HookLog). @@ -119,8 +126,12 @@ directions, since `internal` is module-wide and the compiler will not. - **Pure logic goes in top-level functions in `*Data.kt`, with a unit test** — not inside a composable or an orchestrator. `classifyKmodProblem`, - `resolveLsposedState`, `buildNativeInstallRecommendation` are the pattern: - data in, data out, no Android deps, tested. This is what keeps orchestrators + `resolveLsposedState`, `buildNativeInstallRecommendation`, `dashboardIssues` + are the pattern: data in, data out, no Android deps, tested. The recurring + shape is classify-then-render — a pure function returning a decision, and a + thin one turning it into strings. There is no Robolectric here, so anything + that takes a `Context` or `Resources` is a function no test will ever cover: + keep those as small as the wording itself. This is what keeps orchestrators (`loadDashboardState`) from rotting back into god-functions. - **Keep functions short** (detekt fails new non-`@Composable` methods over ~60 lines). If an orchestrator grows, extract a pure helper. diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardData.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardData.kt index 88fc199a..4116ba71 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardData.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardData.kt @@ -17,10 +17,11 @@ import dev.okhsunrog.vpnhide.diagnostics.token import dev.okhsunrog.vpnhide.diagnostics.verdict import dev.okhsunrog.vpnhide.generated.HookIds import dev.okhsunrog.vpnhide.hook.HookEntry +import dev.okhsunrog.vpnhide.picker.TargetsSnapshot import dev.okhsunrog.vpnhide.picker.parseTargetsSnapshot import dev.okhsunrog.vpnhide.settings.SettingsRepository -import dev.okhsunrog.vpnhide.settings.filesystemHidingDashboardMessage import dev.okhsunrog.vpnhide.settings.installedNativeOptionalHooks +import dev.okhsunrog.vpnhide.settings.resolveFilesystemHidingState import dev.okhsunrog.vpnhide.startup.StartupTrace import kotlinx.coroutines.flow.first import kotlinx.serialization.SerialName @@ -1055,87 +1056,6 @@ private fun androidMajorVersionLabel(): String { return "Android $release" } -private fun buildModuleVersionIssue( - res: android.content.res.Resources, - kind: FlashableModuleKind, - moduleVersion: String, - appVersion: String, - // Only meaningful for FlashableModuleKind.Kmod: the GKI-specific zip the - // kernel recommends, so an "update the module" nudge can name the exact - // file instead of sending the user back to KernelSU/Magisk to guess - // which variant they originally flashed (issue #225). - recommendedArtifact: String? = null, -): String = - when (compareSemver(normalizeVersion(moduleVersion), normalizeVersion(appVersion))) { - null, 0 -> { - res.getString( - when (kind) { - FlashableModuleKind.Kmod -> R.string.dashboard_issue_kmod_version_mismatch - FlashableModuleKind.Kpm -> R.string.dashboard_issue_kpm_version_mismatch - FlashableModuleKind.Zygisk -> R.string.dashboard_issue_zygisk_version_mismatch - FlashableModuleKind.Ports -> R.string.dashboard_issue_ports_version_mismatch - }, - moduleVersion, - appVersion, - ) - } - - in Int.MIN_VALUE..-1 -> { - if (kind == FlashableModuleKind.Kmod && recommendedArtifact != null) { - res.getString(R.string.dashboard_issue_update_kmod_named, moduleVersion, appVersion, recommendedArtifact) - } else { - res.getString( - when (kind) { - FlashableModuleKind.Kmod -> R.string.dashboard_issue_update_kmod - FlashableModuleKind.Kpm -> R.string.dashboard_issue_update_kpm - FlashableModuleKind.Zygisk -> R.string.dashboard_issue_update_zygisk - FlashableModuleKind.Ports -> R.string.dashboard_issue_update_ports - }, - moduleVersion, - appVersion, - ) - } - } - - else -> { - res.getString( - when (kind) { - FlashableModuleKind.Kmod -> R.string.dashboard_issue_update_app_for_kmod - FlashableModuleKind.Kpm -> R.string.dashboard_issue_update_app_for_kpm - FlashableModuleKind.Zygisk -> R.string.dashboard_issue_update_app_for_zygisk - FlashableModuleKind.Ports -> R.string.dashboard_issue_update_app_for_ports - }, - moduleVersion, - appVersion, - ) - } - } - -private fun resolveScopeEntryLabel( - context: android.content.Context, - entry: String, -): String { - if (entry == "system" || entry == "system/0") return "System Framework" - - val packageName = entry.substringBefore('/') - val userId = entry.substringAfter('/', "") - return try { - val appInfo = context.packageManager.getApplicationInfo(packageName, 0) - val appLabel = - context.packageManager - .getApplicationLabel(appInfo) - .toString() - .trim() - when { - appLabel.isEmpty() -> packageName - userId.isNotEmpty() && userId != "0" -> "$appLabel ($userId)" - else -> appLabel - } - } catch (_: PackageManager.NameNotFoundException) { - packageName - } -} - private fun detectLsposedFramework(sections: Map): LsposedFramework { val out = sections["lsposed_framework"].orEmpty() val props = parseKeyValueLines(out) @@ -1245,532 +1165,246 @@ internal fun readLsposedConfig( // then a flat list of independent guards builds the dashboard message banners. // Kept as one top-to-bottom narrative — splitting the flat guard list behind a // parameter bundle would add indirection without improving clarity. -@Suppress("LongMethod", "CyclomaticComplexMethod") -internal suspend fun loadDashboardState( - context: android.content.Context, - selfNeedsRestart: Boolean, - rootSnapshot: RootSnapshot, -): DashboardState { - val messages = mutableListOf() - val res = context.resources - val selfPkg = context.packageName - - fun err( - text: String, - downloadArtifact: String? = null, - ) { - messages += DashboardMessage(DashboardMessageSeverity.ERROR, text, downloadArtifact = downloadArtifact) - } - fun warn( - text: String, - downloadArtifact: String? = null, - ) { - messages += DashboardMessage(DashboardMessageSeverity.WARNING, text, downloadArtifact = downloadArtifact) - } - - fun info( - text: String, - action: DashboardMessageAction? = null, - ) { - messages += DashboardMessage(DashboardMessageSeverity.INFO, text, action) - } - - VpnHideLog.i(TAG, "=== Loading dashboard state ===") - StartupTrace.mark("dashboard_derive_start") - val shellSnapshot = rootSnapshot.sections - val targetsSnapshot = parseTargetsSnapshot(rootSnapshot) - - fun countPackages(pkgs: Set): Int = pkgs.count { it != selfPkg } - - // ── Module detection ── - // Each module's state comes from a pure detector (unit-tested). kmod's - // brokenReason is layered on below, once the kernel recommendation and - // load status are known (classifyKmodProblem). - val currentBootId = shellSnapshot["current_boot_id"].orEmpty() - val kpmLoadStatus = parseKpmLoadStatus(shellSnapshot["kpm_load_status"].orEmpty()) - val nativeTargetCount = countPackages(targetsSnapshot.nativeTargets) - val rawNativeBackends = - detectNativeBackendStates( - shellSnapshot, - currentBootId = currentBootId, - kpmLoadStatus = kpmLoadStatus, - ) - val kmodRaw = rawNativeBackends.kmod - val zygiskStatusRaw = shellSnapshot["zygisk_status"].orEmpty() - val zygiskRaw = rawNativeBackends.zygisk - val kpmRaw = rawNativeBackends.kpm - val standaloneKpm = standaloneKpmLoaded(kpmRaw, shellSnapshot["kpm_runtime_modules"].orEmpty()) - val portsRaw = detectPortsModule(shellSnapshot) - val portsTargetCount = countPackages(targetsSnapshot.portsObservers) - VpnHideLog.i( - TAG, - "modules: kmodRaw=$kmodRaw kpmRaw=$kpmRaw standaloneKpm=$standaloneKpm " + - "zygiskRaw=$zygiskRaw portsRaw=$portsRaw", +/** + * One flashable module's derived state: the integrity/load diagnosis, whether + * it is merely staged for the next reboot, and the [ModuleState] with both + * folded in. + * + * The four backends used to do this inline, in four ~18-line blocks that + * differed only in kind, activator path and the backend-specific classifier — + * which is exactly the shape that lets one of them quietly drift. + * + * Order matters: a staged install is not a corrupt one, so [modulePendingReboot] + * suppresses the diagnosis entirely; integrity beats the backend classifier so a + * missing activator is not reported as a load failure. + */ +private fun deriveModuleFact( + kind: FlashableModuleKind, + raw: ModuleState, + sections: Map, + activatorPath: String, + res: android.content.res.Resources, + classifyBackendProblem: () -> ModuleProblem? = { null }, +): ModuleFact { + val pendingReboot = modulePendingReboot(kind, raw, sections) + val problem = + if (pendingReboot) { + null + } else { + moduleIntegrityProblem( + kind = kind, + module = raw, + sections = sections, + activatorPath = activatorPath, + )?.let { renderModuleIntegrityProblem(it, res) } + ?: classifyBackendProblem() + } + return ModuleFact( + state = raw.withBrokenReason(problem?.reason).withPendingReboot(pendingReboot), + problem = problem, + pendingReboot = pendingReboot, ) - StartupTrace.mark("dashboard_modules_done") +} - // Recommendation based purely on the kernel — used by the install card, - // the "kmod-capable kernel, only zygisk installed" warning (W1), and the - // wrong-variant detection below. - val kernelRaw = shellSnapshot["kernel_release"].orEmpty() - val hasKpatchRuntime = kpatchRuntimeAvailable(shellSnapshot["kpatch_runtime"].orEmpty()) - val kernelRecommendation = - buildNativeInstallRecommendation(kernelRaw, androidMajorVersionLabel(), hasKpatchRuntime) +/** Every module's state, as the cards show it and the banners read it. */ +private fun deriveModuleFacts( + sections: Map, + res: android.content.res.Resources, + kernelRecommendation: NativeInstallRecommendation?, + hasKpatchRuntime: Boolean, + appVersion: String, +): ModuleFacts { + val currentBootId = sections["current_boot_id"].orEmpty() + val kpmLoadStatus = parseKpmLoadStatus(sections["kpm_load_status"].orEmpty()) + val raw = detectNativeBackendStates(sections, currentBootId = currentBootId, kpmLoadStatus = kpmLoadStatus) + val portsRaw = detectPortsModule(sections) val kmodLoadStatus = readKmodLoadStatus( currentBootId.trim(), - shellSnapshot["kmod_load_status"].orEmpty(), - shellSnapshot["kmod_load_dmesg"].orEmpty(), + sections["kmod_load_status"].orEmpty(), + sections["kmod_load_dmesg"].orEmpty(), ) VpnHideLog.i(TAG, "kmodLoadStatus=$kmodLoadStatus") - // A freshly-installed module staged in modules_update/ needs a reboot, not - // a reinstall — suppress its integrity/runtime error and warn instead. - val kmodPendingReboot = modulePendingReboot(FlashableModuleKind.Kmod, kmodRaw, shellSnapshot) - val kpmPendingReboot = modulePendingReboot(FlashableModuleKind.Kpm, kpmRaw, shellSnapshot) - val zygiskPendingReboot = modulePendingReboot(FlashableModuleKind.Zygisk, zygiskRaw, shellSnapshot) - val portsPendingReboot = modulePendingReboot(FlashableModuleKind.Ports, portsRaw, shellSnapshot) - - // Integrity takes priority; one problem drives both card color and banner. - val kmodProblem: ModuleProblem? = - if (kmodPendingReboot) { - null - } else { - moduleIntegrityProblem( - kind = FlashableModuleKind.Kmod, - module = kmodRaw, - sections = shellSnapshot, - activatorPath = KMOD_ACTIVATOR, - )?.let { renderModuleIntegrityProblem(it, res) } - ?: classifyKmodProblem(kmodRaw, kernelRecommendation, kmodLoadStatus) - ?.let { renderKmodProblem(it, res) } + val kmod = + deriveModuleFact(FlashableModuleKind.Kmod, raw.kmod, sections, KMOD_ACTIVATOR, res) { + classifyKmodProblem(raw.kmod, kernelRecommendation, kmodLoadStatus)?.let { renderKmodProblem(it, res) } } - val kmod = kmodRaw.withBrokenReason(kmodProblem?.reason).withPendingReboot(kmodPendingReboot) - VpnHideLog.i(TAG, "kmod (with brokenReason): $kmod") - - val kpmProblem: ModuleProblem? = - if (kpmPendingReboot) { - null - } else { - moduleIntegrityProblem( - kind = FlashableModuleKind.Kpm, - module = kpmRaw, - sections = shellSnapshot, - activatorPath = KPM_ACTIVATOR, - )?.let { renderModuleIntegrityProblem(it, res) } - ?: classifyKpmProblem( - kpm = kpmRaw, - status = kpmLoadStatus, - currentBootId = currentBootId, - hasKpatchRuntime = hasKpatchRuntime, - apatchSuperkeySaved = shellSnapshot["superkey_saved"]?.trim() == "1", - )?.let { renderKpmProblem(it, res) } + val kpm = + deriveModuleFact(FlashableModuleKind.Kpm, raw.kpm, sections, KPM_ACTIVATOR, res) { + classifyKpmProblem( + kpm = raw.kpm, + status = kpmLoadStatus, + currentBootId = currentBootId, + hasKpatchRuntime = hasKpatchRuntime, + apatchSuperkeySaved = sections["superkey_saved"]?.trim() == "1", + )?.let { renderKpmProblem(it, res) } } - val kpm = kpmRaw.withBrokenReason(kpmProblem?.reason).withPendingReboot(kpmPendingReboot) - VpnHideLog.i(TAG, "kpm (with brokenReason): $kpm") - - val zygiskProblem = - if (zygiskPendingReboot) { - null - } else { - moduleIntegrityProblem( - kind = FlashableModuleKind.Zygisk, - module = zygiskRaw, - sections = shellSnapshot, - activatorPath = ZYGISK_ACTIVATOR, - )?.let { renderModuleIntegrityProblem(it, res) } - } - val zygisk = zygiskRaw.withBrokenReason(zygiskProblem?.reason).withPendingReboot(zygiskPendingReboot) - - val portsProblem = - if (portsPendingReboot) { - null - } else { - moduleIntegrityProblem( - kind = FlashableModuleKind.Ports, - module = portsRaw, - sections = shellSnapshot, - activatorPath = PORTS_ACTIVATOR, - )?.let { renderModuleIntegrityProblem(it, res) } - } - val ports = portsRaw.withBrokenReason(portsProblem?.reason).withPendingReboot(portsPendingReboot) - - // The one place all three backends are grouped together — every - // "is anything installed / active" gate below reads from this instead of - // re-deriving its own kmod/kpm/zygisk boolean combination. - val backends = NativeBackendStates(kmod = kmod, kpm = kpm, zygisk = zygisk) - // The single native backend the dashboard shows (kmod > KPM > Zygisk). - val nativeBackend = displayNativeBackend(backends) - VpnHideLog.i(TAG, "nativeBackend=$nativeBackend") - // Only surface the blue "what to install" card when nothing is - // installed yet. Wrong-variant / broken / unsupported-kernel cases - // already emit a red error below with the same CTA — showing both - // duplicates the instruction. - val nativeInstallRecommendation = kernelRecommendation?.takeIf { backends.noneInstalled && !standaloneKpm } - VpnHideLog.i( - TAG, - "nativeInstallRecommendation=$nativeInstallRecommendation " + - "(raw=$kernelRecommendation kmodProblem=$kmodProblem kpmProblem=$kpmProblem " + - "zygiskProblem=$zygiskProblem portsProblem=$portsProblem)", + val zygisk = deriveModuleFact(FlashableModuleKind.Zygisk, raw.zygisk, sections, ZYGISK_ACTIVATOR, res) + val ports = deriveModuleFact(FlashableModuleKind.Ports, portsRaw, sections, PORTS_ACTIVATOR, res) + + // The one place the three native backends are grouped: every "is anything + // installed / active" gate reads from this instead of re-deriving its own + // kmod/kpm/zygisk boolean combination. + val backends = NativeBackendStates(kmod = kmod.state, kpm = kpm.state, zygisk = zygisk.state) + VpnHideLog.i(TAG, "modules: kmod=${kmod.state} kpm=${kpm.state} zygisk=${zygisk.state} ports=${ports.state}") + return ModuleFacts( + kmod = kmod, + kpm = kpm, + zygisk = zygisk, + ports = ports, + backends = backends, + // The single native backend the dashboard shows (kmod > KPM > Zygisk). + nativeBackend = displayNativeBackend(backends), + standaloneKpm = standaloneKpmLoaded(raw.kpm, sections["kpm_runtime_modules"].orEmpty()), + kpmLoadStatus = kpmLoadStatus, + kmodLoadStatus = kmodLoadStatus, + currentBootId = currentBootId, + mismatches = + detectModuleMismatches( + listOf( + kmod.state to FlashableModuleKind.Kmod, + kpm.state to FlashableModuleKind.Kpm, + zygisk.state to FlashableModuleKind.Zygisk, + ports.state to FlashableModuleKind.Ports, + ), + appVersion, + ), ) - StartupTrace.mark("dashboard_kernel_done") +} - // lsposed runtime state - val lsposedStateRaw = shellSnapshot["lsposed_state"].orEmpty() - val lsposedStatus = Protocol.parseStatus(lsposedStateRaw) +/** LSPosed's runtime and on-disk state, and the hooks' own install health. */ +private fun deriveLsposedFacts( + context: android.content.Context, + sections: Map, + currentBootId: String, + lsposedTargetCount: Int, +): LsposedFacts { + val lsposedStateRaw = sections["lsposed_state"].orEmpty() val hookProps = parseLsposedStateMetadata(lsposedStateRaw) - val hookVersion = hookProps["version"] - val hookBootId = hookProps["boot_id"] val hooksActiveThisBoot = lsposedHooksActiveThisBoot(lsposedStateRaw, currentBootId) - val lsposedTargetCount = countPackages(targetsSnapshot.lsposedTargets) - val lsposedFramework = detectLsposedFramework(shellSnapshot) - val lsposedConfig = + val framework = detectLsposedFramework(sections) + val config = if (hooksActiveThisBoot) { - // A current-boot hook heartbeat is stronger evidence than the - // on-disk LSPosed DB: the module is active, and config warnings - // are intentionally suppressed for active hooks below. + // A current-boot hook heartbeat is stronger evidence than the on-disk + // LSPosed DB: the module is active, and config warnings are suppressed + // for active hooks anyway. null } else { - when (lsposedFramework) { + when (framework) { LsposedFramework.NotInstalled -> { LsposedConfig.ModuleNotConfigured } is LsposedFramework.Installed -> { - if (lsposedFramework.disabled) { + if (framework.disabled) { LsposedConfig.Disabled } else { - readLsposedConfig(context, selfPkg) + readLsposedConfig(context, context.packageName) } } } } StartupTrace.mark("dashboard_lsposed_config_done") - val lsposed: LsposedState = + val state = resolveLsposedState( hooksActiveThisBoot = hooksActiveThisBoot, - hookVersion = hookVersion, + hookVersion = hookProps["version"], lsposedTargetCount = lsposedTargetCount, - framework = lsposedFramework, - config = lsposedConfig, + framework = framework, + config = config, ) VpnHideLog.i( TAG, - "lsposed: $lsposed (hookBootId=$hookBootId currentBootId=${currentBootId.trim()} " + - "status=$lsposedStatus framework=$lsposedFramework hooksActive=$hooksActiveThisBoot config=$lsposedConfig)", + "lsposed: $state (hookBootId=${hookProps["boot_id"]} currentBootId=${currentBootId.trim()} " + + "status=${Protocol.parseStatus(lsposedStateRaw)} framework=$framework " + + "hooksActive=$hooksActiveThisBoot config=$config)", ) - StartupTrace.mark("dashboard_lsposed_done") - - // ── Messages ── - val hasNative = backends.anyInstalled - if (standaloneKpm) { - err(res.getString(R.string.dashboard_issue_kpm_standalone_install), "vpnhide-kpm.zip") - } else if (!hasNative) { - err(res.getString(R.string.dashboard_issue_no_native)) - } - if (lsposedFramework is LsposedFramework.NotInstalled && lsposed !is LsposedState.Active) { - err(res.getString(R.string.dashboard_issue_lsposed_not_installed)) - } - if (lsposed is LsposedState.NeedsReboot) { - err(res.getString(R.string.dashboard_issue_reboot)) - } - // Only report LSPosed config issues when hooks are not already active at runtime — - // if hooks are active, the config is clearly working regardless of what we detect on disk - if (lsposed !is LsposedState.Active) { - when (lsposedConfig) { - null -> { - err(res.getString(R.string.dashboard_issue_lsposed_config_unreadable)) - } - - LsposedConfig.ModuleNotConfigured -> { - if (lsposedFramework is LsposedFramework.Installed) { - err(res.getString(R.string.dashboard_issue_lsposed_not_enabled)) - } - } - - LsposedConfig.Disabled -> { - err(res.getString(R.string.dashboard_issue_lsposed_not_enabled)) - } - - is LsposedConfig.Enabled -> { - if (!lsposedConfig.hasSystemFramework) { - err(res.getString(R.string.dashboard_issue_lsposed_no_system_scope)) - } - if (lsposedConfig.extraEntries.isNotEmpty()) { - // Extra entries work, they're just cosmetic noise — warn. - warn( - res.getString( - R.string.dashboard_issue_lsposed_extra_scope, - lsposedConfig.extraEntries.joinToString(", ") { resolveScopeEntryLabel(context, it) }, - ), - ) - } - } - } - } - - // AOSP-drift detector: HookEntry's install-time smoke-check on the - // private NetworkCapabilities/NetworkInfo/LinkProperties fields it - // touches by reflection. Non-empty means the running AOSP renamed - // or retyped a field — the corresponding writeToParcel hook was - // skipped at install time, Java-layer protection is degraded for - // that class. Independent of lsposed Active/Inactive state: hooks - // can still be "active" in heartbeat sense but with partial coverage. - val brokenFields = hookProps["broken_fields"]?.takeIf { it.isNotBlank() } - if (brokenFields != null) { - val sdkLabel = hookProps["aosp_sdk"]?.takeIf { it.isNotBlank() } ?: "?" - err(res.getString(R.string.dashboard_issue_lsposed_field_rename, brokenFields, sdkLabel)) - } - val installFailures = hookProps["install_failures"]?.takeIf { it.isNotBlank() } - if (installFailures != null && brokenFields == null) { - err(res.getString(R.string.dashboard_issue_lsposed_install_failures, installFailures)) - } + return LsposedFacts( + state = state, + framework = framework, + config = config, + // The install-time smoke check on the private NetworkCapabilities / + // NetworkInfo / LinkProperties fields the hooks reflect on. Non-empty means + // the running AOSP renamed or retyped one and the matching writeToParcel + // hook was skipped — independent of Active/Inactive, since hooks can be live + // with partial coverage. + brokenFields = hookProps["broken_fields"]?.takeIf { it.isNotBlank() }, + installFailures = hookProps["install_failures"]?.takeIf { it.isNotBlank() }, + aospSdkLabel = hookProps["aosp_sdk"]?.takeIf { it.isNotBlank() } ?: "?", + ) +} - val appVersion = BuildConfig.VERSION_NAME - // Version mismatches are warnings — modules keep working, user just needs to - // update the lagging side. Full coverage is not affected by a patch-level gap. - val moduleMismatches = - detectModuleMismatches( - listOf( - kmod to FlashableModuleKind.Kmod, - kpm to FlashableModuleKind.Kpm, - zygisk to FlashableModuleKind.Zygisk, - ports to FlashableModuleKind.Ports, - ), - appVersion, - ) - moduleMismatches.forEach { mismatch -> - val recommendedArtifact = - if (mismatch.kind == FlashableModuleKind.Kmod && kernelRecommendation?.preferKmod == true) { - kernelRecommendation.recommendedArtifact - } else { - null - } - // Offer the newer module for one-tap download only when the installed - // module is OLDER than the app (module newer means the app is behind — the - // fix there is updating the app, not re-flashing the module). - val moduleOlder = - (compareSemver(baseVersion(mismatch.moduleVersion), baseVersion(mismatch.appVersion)) ?: 0) < 0 - val downloadArtifact = - if (moduleOlder) { - when (mismatch.kind) { - FlashableModuleKind.Kmod -> recommendedArtifact - FlashableModuleKind.Kpm -> "vpnhide-kpm.zip" - FlashableModuleKind.Zygisk -> "vpnhide-zygisk.zip" - FlashableModuleKind.Ports -> "vpnhide-ports.zip" - } - } else { - null - } - warn( - buildModuleVersionIssue(res, mismatch.kind, mismatch.moduleVersion, mismatch.appVersion, recommendedArtifact), - downloadArtifact = downloadArtifact, - ) - } - val totalTargets = lsposedTargetCount + nativeTargetCount - if (totalTargets == 0) { - // A fresh, not-yet-configured install isn't broken — guide the user to add - // apps rather than flag a red error. - info(res.getString(R.string.dashboard_issue_no_targets)) - } - if (ports is ModuleState.Installed && portsTargetCount == 0) { - info(res.getString(R.string.dashboard_issue_ports_no_observers)) - } - detectPortsApplyProblem( - ports, - portsTargetCount, - shellSnapshot["ports_load_status"].orEmpty(), - currentBootId, - portsDisabled = shellSnapshot["ports_disabled"].orEmpty().trim() == "1", - )?.let { problem -> - val detail = problem.failureDetail - warn( - if (detail == null) { - res.getString(R.string.dashboard_issue_ports_rules_inactive) - } else { - res.getString(R.string.dashboard_issue_ports_apply_failed, detail) - }, - ) - } - // The running-LSPosed-vs-installed-APK check compares the FULL version by - // default: the hook code lives in system_server and only swaps on reboot, so - // a dev who reinstalls the APK on the same base keeps running the old hook - // until reboot. Developers who reinstall constantly can flip - // suppressVersionWarnings to fall back to base-compare (release users see no - // difference — release versions carry no dev suffix). +/** The device and the app's own settings — everything not owned by one module. */ +private suspend fun deriveEnvironmentFacts( + context: android.content.Context, + sections: Map, + targetsSnapshot: TargetsSnapshot, + ports: ModuleState, + portsTargetCount: Int, + currentBootId: String, +): EnvironmentFacts { val appSettings = SettingsRepository(context.applicationContext).settings.first() - val suppressVersionWarnings = appSettings.suppressVersionWarnings - var lsposedVersionMismatch: String? = null - if (lsposed is LsposedState.Active) { - val runningVersion = lsposed.version - val mismatch = - if (suppressVersionWarnings) { - versionsMismatch(runningVersion, appVersion) - } else { - versionsMismatchFull(runningVersion, appVersion) - } - if (mismatch) { - VpnHideLog.w(TAG, "version mismatch: running=$runningVersion app=$appVersion") - lsposedVersionMismatch = res.getString(R.string.dashboard_issue_version_mismatch, runningVersion, appVersion) - } - } - - // ── Low-priority info: suboptimal-but-working setups ── - - // A stealthier kernel backend fits this kernel, but the user only installed - // Zygisk. Zygisk is detectable by banking / payment apps when the Native role - // is enabled for them, whereas kmod/KPM are invisible to anti-tamper. - // Only nudge when the better backend is actually installable now: - // kmod always is; KPM only when - // a KPatch runtime is already present (else replacing a working zygisk would - // mean installing two more things — too pushy for a low-priority hint). - if (zygisk is ModuleState.Installed && - kmod is ModuleState.NotInstalled && - kpm is ModuleState.NotInstalled - ) { - when (kernelRecommendation?.recommended) { - NativeBackendId.Kmod -> { - info( - res.getString( - R.string.dashboard_issue_kmod_capable_but_zygisk, - kernelRecommendation.recommendedArtifact, - ), - ) - } - - NativeBackendId.Kpm -> { - if (kernelRecommendation.kpatchRuntimeAvailable) { - info( - res.getString( - R.string.dashboard_issue_kpm_capable_but_zygisk, - kernelRecommendation.recommendedArtifact, - ), - ) - } - } - - else -> {} - } - } - - // More than one native backend active. Disabled / inactive modules may - // still have directories under /data/adb/modules; they are not a runtime - // freeze risk and must not trigger the .ko+KPM conflict banner. - when ( - classifyMultiNative( - kmodActive = moduleActive(kmod), - kpmActive = moduleActive(kpm), - zygiskActive = moduleActive(zygisk), - ) - ) { - MultiNativeSeverity.Error -> { - err(res.getString(R.string.dashboard_issue_native_conflict_kernel)) - } - - MultiNativeSeverity.Warning -> { - warn(res.getString(R.string.dashboard_issue_multiple_native)) - } - - MultiNativeSeverity.None -> { - // The active-pair Error above is effectively unobservable (two live - // kernel hookers freeze the device). The KPM standing down for a - // co-installed .ko is the real state to surface — warn so the user - // removes one of the two kernel backends. - if (kpmDeferredForConflict(kpmLoadStatus, currentBootId)) { - warn(res.getString(R.string.dashboard_issue_native_conflict_deferred)) - } - } - } - - // KPM is installed under APatch/FolkPatch but dormant because neither a - // trusted `su` token nor a saved SuperKey was usable. Without this the module - // just reads as inactive with no reason. - if (kpm is ModuleState.Installed && - kpmAwaitingSuperkey(kpmLoadStatus, currentBootId) - ) { - warn(res.getString(R.string.dashboard_issue_kpm_awaiting_superkey)) - } - - filesystemHidingDashboardMessage( - desiredEnabled = - OPTIONAL_FEATURE_FILESYSTEM_IFACE_PATHS in - targetsSnapshot.canonicalConfig - ?.settings - ?.optionalFeatures - .orEmpty(), - sections = shellSnapshot, - res = res, - )?.let(messages::add) - - // User has debug logging turned on. Only adb/root can read those - // verbose lines, so this is a neutral dashboard note rather than an issue. - if (targetsSnapshot.canonicalConfig?.debug == true) { - info(res.getString(R.string.dashboard_issue_debug_logging_on)) - } - - // The agent control bridge is on: a loopback HTTP server is listening, which - // is an on-device fingerprint. Neutral note (same weight as debug logging) so - // it isn't left running unnoticed; turn it off in Settings when done. - if (appSettings.agentControlEnabled) { - info(res.getString(R.string.dashboard_issue_agent_bridge_on)) - } - - // SELinux Permissive exposes six detection vectors we rely on SELinux - // to block (RTM_GETROUTE, /proc/net/{tcp,tcp6,udp,udp6,dev,fib_trie}, - // /sys/class/net). See the coverage table in the top-level README. - val getenforce = shellSnapshot["getenforce"].orEmpty() - if (getenforce.trim().equals("Permissive", ignoreCase = true)) { - warn(res.getString(R.string.dashboard_issue_selinux_permissive)) - } - - // VPN Hide installed in more than one user profile (work profile, - // MIUI Second Space, etc.). Each instance can write to the shared - // canonical config, but each one's app picker only sees apps from its own - // profile (PackageManager.getInstalledApplications is per-user). A Save - // from a profile that doesn't see all the targets would silently drop them. - // Recommend uninstalling everywhere except the main profile. - val selfUidCount = - parsePackageUidMap(shellSnapshot["pm_packages"].orEmpty())[selfPkg] - ?.distinct() - ?.size - ?: 0 - if (selfUidCount > 1) { - warn(res.getString(R.string.dashboard_issue_self_multi_profile, selfUidCount)) - } - - // ── Errors: module integrity and backend load problems ── - // Each diagnosis (reason + banner text) was computed once above. Only one - // banner per module fires, and its priority can't drift from the card color. - kmodProblem?.let { err(it.text, it.downloadArtifact) } - kpmProblem?.let { err(it.text) } - zygiskProblem?.let { err(it.text) } - portsProblem?.let { err(it.text) } - - // ── Warnings: modules installed but staged for the next reboot ── - fun rebootWarn(moduleName: String) { - warn(res.getString(R.string.dashboard_issue_module_reboot_to_activate, moduleName)) - } - if (kmodPendingReboot) rebootWarn("kmod") - if (kpmPendingReboot) rebootWarn("KPM") - if (zygiskPendingReboot) rebootWarn("Zygisk") - if (portsPendingReboot) rebootWarn("Ports") - - // ── Protection checks ── - StartupTrace.mark("dashboard_protection_start") - val vpnActive = isVpnActiveFromSnapshot(shellSnapshot["vpn_ifaces"].orEmpty()) - VpnHideLog.i(TAG, "vpnActive=$vpnActive selfNeedsRestart=$selfNeedsRestart") + return EnvironmentFacts( + selinuxPermissive = sections["getenforce"].orEmpty().trim().equals("Permissive", ignoreCase = true), + // Each profile's picker only lists its own apps (getInstalledApplications is + // per-user), so a Save from a profile that cannot see every target would + // silently drop the rest. + selfProfileCount = + parsePackageUidMap(sections["pm_packages"].orEmpty())[context.packageName] + ?.distinct() + ?.size + ?: 0, + debugLoggingOn = targetsSnapshot.canonicalConfig?.debug == true, + agentBridgeOn = appSettings.agentControlEnabled, + suppressVersionWarnings = appSettings.suppressVersionWarnings, + filesystemHiding = + resolveFilesystemHidingState( + desiredEnabled = + OPTIONAL_FEATURE_FILESYSTEM_IFACE_PATHS in + targetsSnapshot.canonicalConfig + ?.settings + ?.optionalFeatures + .orEmpty(), + sections = sections, + ), + portsApply = + detectPortsApplyProblem( + ports, + portsTargetCount, + sections["ports_load_status"].orEmpty(), + currentBootId, + portsDisabled = sections["ports_disabled"].orEmpty().trim() == "1", + ), + ) +} +/** + * Await the check run and fold it into the protection verdict. + * + * The cache does all the gating (VPN off / needs-restart / self-not-routed) in + * one fold, and `awaitTerminal` returns the terminal state itself — so the + * reason for "no results" (blocked gate vs failed run) is carried through + * instead of re-derived from a second VPN sensor or a raced `state.value` read. + */ +private suspend fun resolveProtectionFacts( + context: android.content.Context, + selfNeedsRestart: Boolean, + modules: ModuleFacts, + lsposedActive: Boolean, + sections: Map, +): ProtectionFacts { + VpnHideLog.i( + TAG, + "vpnActive=${isVpnActiveFromSnapshot(sections["vpn_ifaces"].orEmpty())} " + + "selfNeedsRestart=$selfNeedsRestart", + ) + val nativeBackend = modules.nativeBackend val installedOptionalHooks = - installedNativeOptionalHooks(nativeBackend.id, shellSnapshot, currentBootId) - // Held so the partial-hook warning below can ask whether a missing hook is - // actually costing us a vector on this device. - var measuredReport: DiagnosticReport? = null - // Single source of truth: the cache does all the gating (VPN off / needs-restart / - // self-not-routed) through the one fold. awaitTerminal returns the terminal state - // itself, so the reason for "no results" (blocked gate vs a failed run) is carried - // through instead of re-derived from a second VPN sensor or a raced state.value read. - val protection: ProtectionCheck = + installedNativeOptionalHooks(nativeBackend.id, sections, modules.currentBootId) + var report: DiagnosticReport? = null + val check: ProtectionCheck = when (val terminal = DiagnosticsCache.awaitTerminal(context, selfNeedsRestart)) { is DiagnosticsCache.State.Blocked -> { ProtectionCheck.Blocked(terminal.gate) @@ -1780,93 +1414,142 @@ internal suspend fun loadDashboardState( // Derive tiles from the one canonical report (the same object the // debug bundle renders), so the on-screen verdict and the exported // one can never diverge. Tiles judge each backend on the vectors it - // owns; unowned leaks are surfaced via the hero warning below. - val report = + // owns; unowned leaks are left to the issue list. + val built = buildDiagnosticReport( gate = DiagnosticGate.ROUTED, results = terminal.results, backend = nativeBackend, - lsposedActive = lsposed is LsposedState.Active, + lsposedActive = lsposedActive, complete = true, installedOptionalHooks = installedOptionalHooks, ) - measuredReport = report - ProtectionCheck.Checked(report.native.status, report.java.status) + report = built + ProtectionCheck.Checked(built.native.status, built.java.status) } - // State.Failed, and defensively the never-terminal NotRun/Running: - // the run couldn't measure — distinct from a VPN-off gate. + // State.Failed, and defensively the never-terminal NotRun/Running: the + // run couldn't measure — distinct from a VPN-off gate. else -> { ProtectionCheck.Failed } } + return ProtectionFacts( + check = check, + report = report, + partialHookGap = partialHookGap(nativeBackend, installedOptionalHooks), + installedOptionalHooks = installedOptionalHooks, + ) +} - // A kernel backend that loaded but could not resolve every hook target. Warn - // only when a missing hook is costing us something measurable: on kernels that - // never had the symbol at all, the vector is usually closed by SELinux or a - // capability check anyway, and an alarm there is noise. No reinstall fixes a - // kernel that renamed or dropped a function, so this is a warning, not an error. - partialHookGap(nativeBackend, installedOptionalHooks) - ?.takeIf { gap -> measuredReport?.let { gap.costsAnyVector(it) } != false } - ?.let { gap -> - warn( - res.getString( - R.string.dashboard_issue_native_partial_hooks, - gap.installed, - gap.expected, - gap.missing.joinToString(", ") { it.hookName }, - ), - ) - } +/** + * The screen's state object, assembled from the facts it was derived from. + * + * Separate from [loadDashboardState] because it is pure plumbing: no decision is + * made here, every field is either copied out of [DashboardFacts] or is the one + * value the facts deliberately do not carry ([messages], already worded). + */ +private fun DashboardFacts.toDashboardState( + messages: List, + legacyImport: LegacyImportPrompt?, +): DashboardState = + DashboardState( + kmod = modules.kmod.state, + kpm = modules.kpm.state, + zygisk = modules.zygisk.state, + lsposed = lsposed.state, + ports = modules.ports.state, + nativeTargetCount = targets.native, + portsTargetCount = targets.ports, + nativeBackend = modules.nativeBackend, + // Only surface the blue "what to install" card when nothing is installed + // yet. Wrong-variant / broken / unsupported-kernel cases already emit a red + // error with the same call to action — showing both duplicates it. + nativeInstallRecommendation = + kernelRecommendation?.takeIf { modules.backends.noneInstalled && !modules.standaloneKpm }, + kmodLoadStatus = modules.kmodLoadStatus, + protection = protection.check, + messages = messages, + installedOptionalHooks = protection.installedOptionalHooks, + legacyImport = legacyImport, + ) - lsposedVersionMismatch?.let { text -> - if (protectionFullyPassed(protection)) { - info(text) - } else { - warn(text) - } - } +/** + * Everything the Dashboard shows, derived from one root snapshot. + * + * Reads as four steps: derive the facts, await the check run, turn the facts + * into banners, assemble. The banner logic itself is deliberately not here — + * [dashboardIssues] decides and [toMessage] words it, so the ~25 guards are + * reachable from a unit test that needs no `Context`. + */ +internal suspend fun loadDashboardState( + context: android.content.Context, + selfNeedsRestart: Boolean, + rootSnapshot: RootSnapshot, +): DashboardState { + VpnHideLog.i(TAG, "=== Loading dashboard state ===") + StartupTrace.mark("dashboard_derive_start") + val res = context.resources + val selfPkg = context.packageName + val sections = rootSnapshot.sections + val targetsSnapshot = parseTargetsSnapshot(rootSnapshot) - // A hiding layer is active but a vector it OWNS still leaks — the backend - // should hide it and didn't, so the VPN is detectable AND the user can act on - // it (report the device). Surface a warning linking to the per-check breakdown. - // - // Unowned leaks — vectors no active backend covers on this device (e.g. - // RTM_GETRULE with no kernel backend loaded, or a best-effort sysfs path) — are - // deliberately NOT surfaced here: the active backend is already doing everything - // it can, so alarming about a gap the user cannot close just generates noise (and - // support churn). Those still appear, neutrally, in the per-check breakdown. - val checked = protection as? ProtectionCheck.Checked - val nativeLeaks = (checked?.native as? LayerStatus.Active)?.leaks ?: 0 - val javaLeaks = (checked?.java as? LayerStatus.Active)?.leaks ?: 0 - if (nativeLeaks > 0 || javaLeaks > 0) { - messages += - DashboardMessage( - DashboardMessageSeverity.WARNING, - res.getString(R.string.dashboard_issue_checks_failed), - DashboardMessageAction.OpenDiagnostics, - ) - } + fun countPackages(pkgs: Set): Int = pkgs.count { it != selfPkg } + val targets = + TargetCounts( + lsposed = countPackages(targetsSnapshot.lsposedTargets), + native = countPackages(targetsSnapshot.nativeTargets), + ports = countPackages(targetsSnapshot.portsObservers), + ) - StartupTrace.mark("dashboard_messages_done") - VpnHideLog.i(TAG, "protection=$protection messages=$messages") + // Recommendation based purely on the kernel — used by the install card, the + // "kmod-capable kernel, only zygisk installed" nudge, and wrong-variant + // detection inside the kmod diagnosis. + val hasKpatchRuntime = kpatchRuntimeAvailable(sections["kpatch_runtime"].orEmpty()) + val kernelRecommendation = + buildNativeInstallRecommendation( + sections["kernel_release"].orEmpty(), + androidMajorVersionLabel(), + hasKpatchRuntime, + ) + val appVersion = BuildConfig.VERSION_NAME + val modules = deriveModuleFacts(sections, res, kernelRecommendation, hasKpatchRuntime, appVersion) + StartupTrace.mark("dashboard_modules_done") + StartupTrace.mark("dashboard_kernel_done") + + val lsposed = deriveLsposedFacts(context, sections, modules.currentBootId, targets.lsposed) + StartupTrace.mark("dashboard_lsposed_done") + + StartupTrace.mark("dashboard_protection_start") + val protection = + resolveProtectionFacts( + context = context, + selfNeedsRestart = selfNeedsRestart, + modules = modules, + lsposedActive = lsposed.state is LsposedState.Active, + sections = sections, + ) + VpnHideLog.i(TAG, "protection=${protection.check}") StartupTrace.mark("dashboard_protection_done") + + val environment = + deriveEnvironmentFacts( + context = context, + sections = sections, + targetsSnapshot = targetsSnapshot, + ports = modules.ports.state, + portsTargetCount = targets.ports, + currentBootId = modules.currentBootId, + ) + val facts = + DashboardFacts(modules, lsposed, targets, environment, protection, kernelRecommendation, appVersion) + val messages = dashboardIssues(facts).map { it.toMessage(context, res) } + StartupTrace.mark("dashboard_issues_done") + VpnHideLog.i(TAG, "messages=$messages") VpnHideLog.i(TAG, "=== Dashboard state loaded ===") - return DashboardState( - kmod = kmod, - kpm = kpm, - zygisk = zygisk, - lsposed = lsposed, - ports = ports, - nativeTargetCount = nativeTargetCount, - portsTargetCount = portsTargetCount, - nativeBackend = nativeBackend, - nativeInstallRecommendation = nativeInstallRecommendation, - kmodLoadStatus = kmodLoadStatus, - protection = protection, + return facts.toDashboardState( messages = messages, - installedOptionalHooks = installedOptionalHooks, - legacyImport = parseLegacyConfigCandidate(shellSnapshot, targetsSnapshot.uidToPkg)?.toPrompt(), + legacyImport = parseLegacyConfigCandidate(sections, targetsSnapshot.uidToPkg)?.toPrompt(), ) } diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardIssueRender.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardIssueRender.kt new file mode 100644 index 00000000..855f1e8d --- /dev/null +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardIssueRender.kt @@ -0,0 +1,338 @@ +package dev.okhsunrog.vpnhide + +import android.content.Context +import android.content.pm.PackageManager +import android.content.res.Resources + +/** + * Wording for a [DashboardIssue]. The decision of *whether* an issue exists is + * [dashboardIssues]; everything here is presentation, which is why it is the + * only half that needs a [Context]. + * + * Split per issue group so each `when` stays small — one 28-branch dispatch + * would be a complexity hotspot for no benefit. + */ +internal fun DashboardIssue.toMessage( + context: Context, + res: Resources, +): DashboardMessage = + when (this) { + is DashboardIssue.Native -> nativeMessage(res) + is DashboardIssue.Lsposed -> lsposedMessage(context, res) + is DashboardIssue.Module -> moduleMessage(res) + is DashboardIssue.Target -> targetMessage(res) + is DashboardIssue.Environment -> environmentMessage(res) + is DashboardIssue.Protection -> protectionMessage(res) + } + +private fun err( + text: String, + downloadArtifact: String? = null, +) = DashboardMessage(DashboardMessageSeverity.ERROR, text, downloadArtifact = downloadArtifact) + +private fun warn( + text: String, + downloadArtifact: String? = null, +) = DashboardMessage(DashboardMessageSeverity.WARNING, text, downloadArtifact = downloadArtifact) + +private fun info( + text: String, + action: DashboardMessageAction? = null, +) = DashboardMessage(DashboardMessageSeverity.INFO, text, action) + +private fun DashboardIssue.Native.nativeMessage(res: Resources): DashboardMessage = + when (this) { + DashboardIssue.KpmStandaloneInstall -> { + err(res.getString(R.string.dashboard_issue_kpm_standalone_install), "vpnhide-kpm.zip") + } + + DashboardIssue.NoNativeBackend -> { + err(res.getString(R.string.dashboard_issue_no_native)) + } + + is DashboardIssue.BetterBackendAvailable -> { + info( + res.getString( + when (backend) { + SuggestedBackend.Kmod -> R.string.dashboard_issue_kmod_capable_but_zygisk + SuggestedBackend.Kpm -> R.string.dashboard_issue_kpm_capable_but_zygisk + }, + artifact, + ), + ) + } + + DashboardIssue.NativeConflictKernel -> { + err(res.getString(R.string.dashboard_issue_native_conflict_kernel)) + } + + DashboardIssue.MultipleNativeActive -> { + warn(res.getString(R.string.dashboard_issue_multiple_native)) + } + + DashboardIssue.NativeConflictDeferred -> { + warn(res.getString(R.string.dashboard_issue_native_conflict_deferred)) + } + + DashboardIssue.KpmAwaitingSuperkey -> { + warn(res.getString(R.string.dashboard_issue_kpm_awaiting_superkey)) + } + } + +private fun DashboardIssue.Lsposed.lsposedMessage( + context: Context, + res: Resources, +): DashboardMessage = + when (this) { + DashboardIssue.LsposedNotInstalled -> { + err(res.getString(R.string.dashboard_issue_lsposed_not_installed)) + } + + DashboardIssue.LsposedNeedsReboot -> { + err(res.getString(R.string.dashboard_issue_reboot)) + } + + DashboardIssue.LsposedConfigUnreadable -> { + err(res.getString(R.string.dashboard_issue_lsposed_config_unreadable)) + } + + DashboardIssue.LsposedNotEnabled -> { + err(res.getString(R.string.dashboard_issue_lsposed_not_enabled)) + } + + DashboardIssue.LsposedNoSystemScope -> { + err(res.getString(R.string.dashboard_issue_lsposed_no_system_scope)) + } + + is DashboardIssue.LsposedExtraScope -> { + // Extra entries work, they are only cosmetic noise — warn, don't error. + warn( + res.getString( + R.string.dashboard_issue_lsposed_extra_scope, + entries.joinToString(", ") { resolveScopeEntryLabel(context, it) }, + ), + ) + } + + is DashboardIssue.LsposedFieldRename -> { + err(res.getString(R.string.dashboard_issue_lsposed_field_rename, fields, sdkLabel)) + } + + is DashboardIssue.LsposedInstallFailures -> { + err(res.getString(R.string.dashboard_issue_lsposed_install_failures, detail)) + } + } + +private fun DashboardIssue.Module.moduleMessage(res: Resources): DashboardMessage = + when (this) { + is DashboardIssue.ModuleVersionMismatch -> { + // A version gap is a warning: the modules keep working, the user just + // needs to update the lagging side. + warn( + buildModuleVersionIssue( + res = res, + kind = mismatch.kind, + moduleVersion = mismatch.moduleVersion, + appVersion = mismatch.appVersion, + recommendedArtifact = recommendedArtifact, + ), + downloadArtifact = downloadArtifact, + ) + } + + // Text and artifact were resolved with the module's card diagnosis; see + // DashboardIssue.ModuleBroken. + is DashboardIssue.ModuleBroken -> { + err(problem.text, problem.downloadArtifact) + } + + is DashboardIssue.ModuleNeedsReboot -> { + warn(res.getString(R.string.dashboard_issue_module_reboot_to_activate, kind.displayName)) + } + } + +private fun DashboardIssue.Target.targetMessage(res: Resources): DashboardMessage = + when (this) { + DashboardIssue.NoTargets -> { + info(res.getString(R.string.dashboard_issue_no_targets)) + } + + DashboardIssue.PortsNoObservers -> { + info(res.getString(R.string.dashboard_issue_ports_no_observers)) + } + + is DashboardIssue.PortsRulesInactive -> { + warn( + if (failureDetail == null) { + res.getString(R.string.dashboard_issue_ports_rules_inactive) + } else { + res.getString(R.string.dashboard_issue_ports_apply_failed, failureDetail) + }, + ) + } + } + +private fun DashboardIssue.Environment.environmentMessage(res: Resources): DashboardMessage = + when (this) { + is DashboardIssue.FilesystemHidingPending -> { + warn( + res.getString( + when { + enabling && zygisk -> R.string.dashboard_issue_filesystem_hiding_pending_enable_zygisk + enabling -> R.string.dashboard_issue_filesystem_hiding_pending_enable + zygisk -> R.string.dashboard_issue_filesystem_hiding_pending_disable_zygisk + else -> R.string.dashboard_issue_filesystem_hiding_pending_disable + }, + ), + ) + } + + is DashboardIssue.FilesystemHidingBootError -> { + err(res.getString(R.string.dashboard_issue_filesystem_hiding_boot_error, detail)) + } + + DashboardIssue.FilesystemHidingSetupError -> { + err(res.getString(R.string.dashboard_issue_filesystem_hiding_setup_error)) + } + + DashboardIssue.DebugLoggingOn -> { + info(res.getString(R.string.dashboard_issue_debug_logging_on)) + } + + DashboardIssue.AgentBridgeOn -> { + info(res.getString(R.string.dashboard_issue_agent_bridge_on)) + } + + DashboardIssue.SelinuxPermissive -> { + warn(res.getString(R.string.dashboard_issue_selinux_permissive)) + } + + is DashboardIssue.InstalledInMultipleProfiles -> { + warn(res.getString(R.string.dashboard_issue_self_multi_profile, profileCount)) + } + } + +private fun DashboardIssue.Protection.protectionMessage(res: Resources): DashboardMessage = + when (this) { + is DashboardIssue.PartialHooks -> { + // No reinstall fixes a kernel that renamed or dropped a function, so + // this is a warning rather than an error. + warn( + res.getString( + R.string.dashboard_issue_native_partial_hooks, + installed, + expected, + missing.joinToString(", ") { it.hookName }, + ), + ) + } + + is DashboardIssue.LsposedVersionMismatch -> { + val text = res.getString(R.string.dashboard_issue_version_mismatch, runningVersion, appVersion) + if (degraded) warn(text) else info(text) + } + + DashboardIssue.ChecksFailed -> { + DashboardMessage( + DashboardMessageSeverity.WARNING, + res.getString(R.string.dashboard_issue_checks_failed), + DashboardMessageAction.OpenDiagnostics, + ) + } + } + +/** Brand names, not localized — the same spellings the module cards use. */ +private val FlashableModuleKind.displayName: String + get() = + when (this) { + FlashableModuleKind.Kmod -> "kmod" + FlashableModuleKind.Kpm -> "KPM" + FlashableModuleKind.Zygisk -> "Zygisk" + FlashableModuleKind.Ports -> "Ports" + } + +private fun buildModuleVersionIssue( + res: Resources, + kind: FlashableModuleKind, + moduleVersion: String, + appVersion: String, + // Only meaningful for FlashableModuleKind.Kmod: the GKI-specific zip the + // kernel recommends, so an "update the module" nudge can name the exact + // file instead of sending the user back to KernelSU/Magisk to guess + // which variant they originally flashed (issue #225). + recommendedArtifact: String? = null, +): String = + when (compareSemver(normalizeVersion(moduleVersion), normalizeVersion(appVersion))) { + null, 0 -> { + res.getString( + when (kind) { + FlashableModuleKind.Kmod -> R.string.dashboard_issue_kmod_version_mismatch + FlashableModuleKind.Kpm -> R.string.dashboard_issue_kpm_version_mismatch + FlashableModuleKind.Zygisk -> R.string.dashboard_issue_zygisk_version_mismatch + FlashableModuleKind.Ports -> R.string.dashboard_issue_ports_version_mismatch + }, + moduleVersion, + appVersion, + ) + } + + in Int.MIN_VALUE..-1 -> { + if (kind == FlashableModuleKind.Kmod && recommendedArtifact != null) { + res.getString(R.string.dashboard_issue_update_kmod_named, moduleVersion, appVersion, recommendedArtifact) + } else { + res.getString( + when (kind) { + FlashableModuleKind.Kmod -> R.string.dashboard_issue_update_kmod + FlashableModuleKind.Kpm -> R.string.dashboard_issue_update_kpm + FlashableModuleKind.Zygisk -> R.string.dashboard_issue_update_zygisk + FlashableModuleKind.Ports -> R.string.dashboard_issue_update_ports + }, + moduleVersion, + appVersion, + ) + } + } + + else -> { + res.getString( + when (kind) { + FlashableModuleKind.Kmod -> R.string.dashboard_issue_update_app_for_kmod + FlashableModuleKind.Kpm -> R.string.dashboard_issue_update_app_for_kpm + FlashableModuleKind.Zygisk -> R.string.dashboard_issue_update_app_for_zygisk + FlashableModuleKind.Ports -> R.string.dashboard_issue_update_app_for_ports + }, + moduleVersion, + appVersion, + ) + } + } + +/** + * An LSPosed scope entry (`pkg` or `pkg/user`) as the user would recognise it. + * Falls back to the package name when the label is unavailable — a scope entry + * can outlive the app it names. + */ +private fun resolveScopeEntryLabel( + context: Context, + entry: String, +): String { + if (entry == "system" || entry == "system/0") return "System Framework" + + val packageName = entry.substringBefore('/') + val userId = entry.substringAfter('/', "") + return try { + val appInfo = context.packageManager.getApplicationInfo(packageName, 0) + val appLabel = + context.packageManager + .getApplicationLabel(appInfo) + .toString() + .trim() + when { + appLabel.isEmpty() -> packageName + userId.isNotEmpty() && userId != "0" -> "$appLabel ($userId)" + else -> appLabel + } + } catch (_: PackageManager.NameNotFoundException) { + packageName + } +} diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardIssues.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardIssues.kt new file mode 100644 index 00000000..2bc5fca2 --- /dev/null +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardIssues.kt @@ -0,0 +1,585 @@ +package dev.okhsunrog.vpnhide + +import dev.okhsunrog.vpnhide.diagnostics.DiagnosticReport +import dev.okhsunrog.vpnhide.diagnostics.LayerStatus +import dev.okhsunrog.vpnhide.generated.HookIds +import dev.okhsunrog.vpnhide.settings.FilesystemHidingState +import dev.okhsunrog.vpnhide.settings.FilesystemHidingStatus + +/** + * What the dashboard has to say about this device, as data. + * + * The banner list used to be built inline in `loadDashboardState`: ~25 guards + * interleaved with `res.getString` calls, which made the decision logic + * unreachable from a unit test — every branch needed a real `Resources`, and + * this module has no Robolectric. Splitting the decision (here, pure) from the + * wording ([toMessage], thin) follows what the rest of the codebase already + * does: `classifyKmodProblem`/`renderKmodProblem`, + * `classifyKpmProblem`/`renderKpmProblem`. The filesystem-hiding banner used to + * be the odd one out with its own decide-and-word function; it folds into the + * same two halves here. + * + * A case carries exactly the data its wording needs, never a pre-formatted + * string — otherwise the split would be cosmetic. + * + * Grouped into sub-interfaces so both the builder and the renderer stay a set + * of small functions instead of one 28-branch `when` that would just trade a + * `@Suppress("LongMethod")` for a `@Suppress("CyclomaticComplexMethod")`. + */ +internal sealed interface DashboardIssue { + /** The native layer as a whole: nothing installed, the wrong thing installed, or two at once. */ + sealed interface Native : DashboardIssue + + /** LSPosed: the framework, our module's scope, and the hooks' own install health. */ + sealed interface Lsposed : DashboardIssue + + /** A specific flashable module: version drift, integrity, staged-for-reboot. */ + sealed interface Module : DashboardIssue + + /** Targets and the ports backend's applied rules. */ + sealed interface Target : DashboardIssue + + /** The device and the app's own settings, not any one module. */ + sealed interface Environment : DashboardIssue + + /** Derived from a completed check run, so these can only exist after one. */ + sealed interface Protection : DashboardIssue + + // ── Native ── + + /** The KPM zip was flashed as a plain module, so nothing loaded it. */ + data object KpmStandaloneInstall : Native + + data object NoNativeBackend : Native + + /** + * A stealthier kernel backend fits this kernel but only Zygisk is installed. + * [artifact] is the specific zip to flash, so the nudge can name it. + * + * Not [NativeBackendId]: suggesting Zygisk to a Zygisk user is not a state + * this can be in, so it is not one the type allows. + */ + data class BetterBackendAvailable( + val backend: SuggestedBackend, + val artifact: String, + ) : Native + + /** Two kernel backends live at once — the .ko + KPM pair that freezes the device. */ + data object NativeConflictKernel : Native + + data object MultipleNativeActive : Native + + /** KPM stood down at boot because a .ko was already there. */ + data object NativeConflictDeferred : Native + + /** KPM is installed under APatch but dormant: no usable su token or saved SuperKey. */ + data object KpmAwaitingSuperkey : Native + + // ── LSPosed ── + + data object LsposedNotInstalled : Lsposed + + data object LsposedNeedsReboot : Lsposed + + data object LsposedConfigUnreadable : Lsposed + + data object LsposedNotEnabled : Lsposed + + data object LsposedNoSystemScope : Lsposed + + /** Scope entries beyond System Framework. They work; they are just noise. */ + data class LsposedExtraScope( + val entries: List, + ) : Lsposed + + /** + * The running AOSP renamed or retyped a field the hooks reflect on, so the + * matching writeToParcel hook was skipped at install time. + */ + data class LsposedFieldRename( + val fields: String, + val sdkLabel: String, + ) : Lsposed + + data class LsposedInstallFailures( + val detail: String, + ) : Lsposed + + // ── Module ── + + data class ModuleVersionMismatch( + val mismatch: ModuleMismatch, + /** The kernel-specific zip to name for kmod; null for the other kinds. */ + val recommendedArtifact: String?, + /** Offered for one-tap download only when the module is the older side. */ + val downloadArtifact: String?, + ) : Module + + /** + * Integrity or load diagnosis. Already rendered upstream: the same + * [ModuleProblem] drives the module card's colour via + * `ModuleState.withBrokenReason`, so the text exists before the issue list + * is built and re-deriving it here would let card and banner drift apart. + */ + data class ModuleBroken( + val problem: ModuleProblem, + ) : Module + + data class ModuleNeedsReboot( + val kind: FlashableModuleKind, + ) : Module + + // ── Target ── + + data object NoTargets : Target + + data object PortsNoObservers : Target + + /** Ports rules are not in effect. [failureDetail] is set when this boot's apply failed. */ + data class PortsRulesInactive( + val failureDetail: String?, + ) : Target + + // ── Environment ── + + data class FilesystemHidingPending( + val enabling: Boolean, + val zygisk: Boolean, + ) : Environment + + data class FilesystemHidingBootError( + val detail: String, + ) : Environment + + data object FilesystemHidingSetupError : Environment + + data object DebugLoggingOn : Environment + + data object AgentBridgeOn : Environment + + data object SelinuxPermissive : Environment + + /** Installed in more than one user profile; a Save from the wrong one drops targets. */ + data class InstalledInMultipleProfiles( + val profileCount: Int, + ) : Environment + + // ── Protection ── + + data class PartialHooks( + val installed: Int, + val expected: Int, + val missing: List, + ) : Protection + + /** + * The hooks running in system_server are a different build than this APK. + * [degraded] downgrades it to a warning: with everything else passing this + * is informational, but alongside a real failure it is a likely cause. + */ + data class LsposedVersionMismatch( + val runningVersion: String, + val appVersion: String, + val degraded: Boolean, + ) : Protection + + /** A vector an active layer owns is leaking anyway. */ + data object ChecksFailed : Protection +} + +/** The kernel backends worth nudging a Zygisk-only user towards. */ +internal enum class SuggestedBackend { Kmod, Kpm } + +// ── Facts ───────────────────────────────────────────────────────────────── +// +// The inputs the guards read, already derived from the root snapshot. Grouped +// rather than flat: one struct of 28 fields would trip detekt's constructor +// threshold, and the groups are the same ones the guards fall into anyway. + +/** One flashable module, with everything the banners ask about it. */ +internal data class ModuleFact( + val state: ModuleState, + /** Integrity/load diagnosis, or null when healthy or staged for reboot. */ + val problem: ModuleProblem?, + val pendingReboot: Boolean, +) + +internal data class ModuleFacts( + val kmod: ModuleFact, + val kpm: ModuleFact, + val zygisk: ModuleFact, + val ports: ModuleFact, + val backends: NativeBackendStates, + val nativeBackend: DisplayNativeBackend, + /** The KPM zip is present as a plain module with no KernelPatch to load it. */ + val standaloneKpm: Boolean, + val kpmLoadStatus: KpmLoadStatus, + /** Not read by any guard; carried so the orchestrator need not re-derive it. */ + val kmodLoadStatus: KmodLoadStatus?, + val currentBootId: String, + val mismatches: List, +) + +internal data class LsposedFacts( + val state: LsposedState, + val framework: LsposedFramework, + /** Null means the on-disk config could not be read at all. */ + val config: LsposedConfig?, + val brokenFields: String?, + val installFailures: String?, + val aospSdkLabel: String, +) + +internal data class TargetCounts( + val lsposed: Int, + val native: Int, + val ports: Int, +) + +internal data class EnvironmentFacts( + val selinuxPermissive: Boolean, + val selfProfileCount: Int, + val debugLoggingOn: Boolean, + val agentBridgeOn: Boolean, + /** Compare running-vs-installed by base version only; see SettingsRepository. */ + val suppressVersionWarnings: Boolean, + val filesystemHiding: FilesystemHidingState, + val portsApply: PortsApplyProblem?, +) + +internal data class ProtectionFacts( + val check: ProtectionCheck, + /** The completed report, when there was one; null for a blocked or failed run. */ + val report: DiagnosticReport?, + val partialHookGap: PartialHookGap?, + val installedOptionalHooks: Set, +) + +internal data class DashboardFacts( + val modules: ModuleFacts, + val lsposed: LsposedFacts, + val targets: TargetCounts, + val environment: EnvironmentFacts, + val protection: ProtectionFacts, + val kernelRecommendation: NativeInstallRecommendation?, + val appVersion: String, +) + +// ── The guard list ──────────────────────────────────────────────────────── + +/** + * Every issue this device has, in the order the dashboard shows them. + * + * Order is load-bearing: the screen groups by severity but keeps emission + * order inside each group, so the first error a user reads is decided here. + * The eight calls below ARE that order — previously it was implied by the + * physical layout of a 290-line block, where inserting a guard in the wrong + * place silently reordered the banners. + */ +internal fun dashboardIssues(facts: DashboardFacts): List = + buildList { + addAll(nativePresenceIssues(facts)) + addAll(lsposedIssues(facts)) + addAll(moduleVersionIssues(facts)) + addAll(targetIssues(facts)) + addAll(nativeChoiceIssues(facts)) + addAll(environmentIssues(facts)) + addAll(moduleProblemIssues(facts)) + addAll(protectionIssues(facts)) + } + +/** Is there a native layer at all, and was it installed in a way that can work. */ +private fun nativePresenceIssues(facts: DashboardFacts): List = + buildList { + val modules = facts.modules + if (modules.standaloneKpm) { + add(DashboardIssue.KpmStandaloneInstall) + } else if (!modules.backends.anyInstalled) { + add(DashboardIssue.NoNativeBackend) + } + } + +private fun lsposedIssues(facts: DashboardFacts): List = + buildList { + val lsposed = facts.lsposed + val active = lsposed.state is LsposedState.Active + if (lsposed.framework is LsposedFramework.NotInstalled && !active) { + add(DashboardIssue.LsposedNotInstalled) + } + if (lsposed.state is LsposedState.NeedsReboot) { + add(DashboardIssue.LsposedNeedsReboot) + } + // Config problems are only worth reporting when the hooks are not already + // running: a live heartbeat proves the config works, whatever the on-disk + // state looks like. + if (!active) addAll(lsposedConfigIssues(lsposed)) + if (lsposed.brokenFields != null) { + add(DashboardIssue.LsposedFieldRename(lsposed.brokenFields, lsposed.aospSdkLabel)) + } + // A field rename explains the install failures it caused; reporting both + // says the same thing twice. + if (lsposed.installFailures != null && lsposed.brokenFields == null) { + add(DashboardIssue.LsposedInstallFailures(lsposed.installFailures)) + } + } + +private fun lsposedConfigIssues(lsposed: LsposedFacts): List = + buildList { + when (val config = lsposed.config) { + null -> { + add(DashboardIssue.LsposedConfigUnreadable) + } + + LsposedConfig.ModuleNotConfigured -> { + // With no framework installed this is already covered by + // LsposedNotInstalled above. + if (lsposed.framework is LsposedFramework.Installed) { + add(DashboardIssue.LsposedNotEnabled) + } + } + + LsposedConfig.Disabled -> { + add(DashboardIssue.LsposedNotEnabled) + } + + is LsposedConfig.Enabled -> { + if (!config.hasSystemFramework) add(DashboardIssue.LsposedNoSystemScope) + if (config.extraEntries.isNotEmpty()) { + add(DashboardIssue.LsposedExtraScope(config.extraEntries)) + } + } + } + } + +private fun moduleVersionIssues(facts: DashboardFacts): List = + facts.modules.mismatches.map { mismatch -> + val recommendedArtifact = + facts.kernelRecommendation + ?.takeIf { mismatch.kind == FlashableModuleKind.Kmod && it.preferKmod } + ?.recommendedArtifact + // Offer the newer module for download only when the installed module is the + // older side — a module newer than the app means the app is what's behind, + // and re-flashing the module would not fix that. + val moduleOlder = + (compareSemver(baseVersion(mismatch.moduleVersion), baseVersion(mismatch.appVersion)) ?: 0) < 0 + DashboardIssue.ModuleVersionMismatch( + mismatch = mismatch, + recommendedArtifact = recommendedArtifact, + downloadArtifact = if (moduleOlder) downloadArtifactFor(mismatch.kind, recommendedArtifact) else null, + ) + } + +private fun downloadArtifactFor( + kind: FlashableModuleKind, + recommendedArtifact: String?, +): String? = + when (kind) { + FlashableModuleKind.Kmod -> recommendedArtifact + FlashableModuleKind.Kpm -> "vpnhide-kpm.zip" + FlashableModuleKind.Zygisk -> "vpnhide-zygisk.zip" + FlashableModuleKind.Ports -> "vpnhide-ports.zip" + } + +private fun targetIssues(facts: DashboardFacts): List = + buildList { + // A fresh install with nothing selected is not broken — guide, don't alarm. + if (facts.targets.lsposed + facts.targets.native == 0) add(DashboardIssue.NoTargets) + if (facts.modules.ports.state is ModuleState.Installed && facts.targets.ports == 0) { + add(DashboardIssue.PortsNoObservers) + } + facts.environment.portsApply?.let { add(DashboardIssue.PortsRulesInactive(it.failureDetail)) } + } + +/** Working, but not the backend this kernel could be running. */ +private fun nativeChoiceIssues(facts: DashboardFacts): List = + buildList { + addAll(betterBackendIssues(facts)) + addAll(multiNativeIssues(facts)) + if (facts.modules.kpm.state is ModuleState.Installed && + kpmAwaitingSuperkey(facts.modules.kpmLoadStatus, facts.modules.currentBootId) + ) { + add(DashboardIssue.KpmAwaitingSuperkey) + } + } + +/** + * Zygisk works but is detectable by anti-tamper apps when the Native role is on + * for them, whereas kmod/KPM are invisible. Only nudge when the better backend + * is installable right now: kmod always is, KPM only with a KPatch runtime + * already present — otherwise replacing a working setup means installing two + * more things, too pushy for a low-priority hint. + */ +private fun betterBackendIssues(facts: DashboardFacts): List { + val modules = facts.modules + val onlyZygisk = + modules.zygisk.state is ModuleState.Installed && + modules.kmod.state is ModuleState.NotInstalled && + modules.kpm.state is ModuleState.NotInstalled + if (!onlyZygisk) return emptyList() + val recommendation = facts.kernelRecommendation ?: return emptyList() + return when (recommendation.recommended) { + NativeBackendId.Kmod -> { + listOf(DashboardIssue.BetterBackendAvailable(SuggestedBackend.Kmod, recommendation.recommendedArtifact)) + } + + NativeBackendId.Kpm -> { + if (recommendation.kpatchRuntimeAvailable) { + listOf(DashboardIssue.BetterBackendAvailable(SuggestedBackend.Kpm, recommendation.recommendedArtifact)) + } else { + emptyList() + } + } + + NativeBackendId.Zygisk -> { + emptyList() + } + } +} + +private fun multiNativeIssues(facts: DashboardFacts): List { + val modules = facts.modules + // Disabled or inactive modules may still have directories under + // /data/adb/modules; they are not a freeze risk and must not raise the + // .ko + KPM conflict banner. + val severity = + classifyMultiNative( + kmodActive = moduleActive(modules.kmod.state), + kpmActive = moduleActive(modules.kpm.state), + zygiskActive = moduleActive(modules.zygisk.state), + ) + return when (severity) { + MultiNativeSeverity.Error -> { + listOf(DashboardIssue.NativeConflictKernel) + } + + MultiNativeSeverity.Warning -> { + listOf(DashboardIssue.MultipleNativeActive) + } + + MultiNativeSeverity.None -> { + // The active-pair Error above is effectively unobservable — two live + // kernel hookers freeze the device before this screen renders. KPM + // standing down for a co-installed .ko is the state actually seen. + if (kpmDeferredForConflict(modules.kpmLoadStatus, modules.currentBootId)) { + listOf(DashboardIssue.NativeConflictDeferred) + } else { + emptyList() + } + } + } +} + +private fun environmentIssues(facts: DashboardFacts): List = + buildList { + val env = facts.environment + addAll(filesystemHidingIssues(env.filesystemHiding)) + // Only adb/root can read the verbose lines, so this is a neutral note + // rather than a problem. + if (env.debugLoggingOn) add(DashboardIssue.DebugLoggingOn) + // A loopback HTTP server is an on-device fingerprint; note it so it isn't + // left running unnoticed. + if (env.agentBridgeOn) add(DashboardIssue.AgentBridgeOn) + // Permissive exposes the vectors we rely on SELinux to block (RTM_GETROUTE, + // /proc/net/*, /sys/class/net) — see the coverage table in the README. + if (env.selinuxPermissive) add(DashboardIssue.SelinuxPermissive) + if (env.selfProfileCount > 1) { + add(DashboardIssue.InstalledInMultipleProfiles(env.selfProfileCount)) + } + } + +private fun filesystemHidingIssues(state: FilesystemHidingState): List { + val zygisk = state.backend == NativeBackendId.Zygisk + return when (state.status) { + FilesystemHidingStatus.PendingEnable -> { + listOf(DashboardIssue.FilesystemHidingPending(enabling = true, zygisk = zygisk)) + } + + FilesystemHidingStatus.PendingDisable -> { + listOf(DashboardIssue.FilesystemHidingPending(enabling = false, zygisk = zygisk)) + } + + FilesystemHidingStatus.BootConfigError -> { + listOf(DashboardIssue.FilesystemHidingBootError(state.errorDetail.orEmpty())) + } + + FilesystemHidingStatus.HookSetupError -> { + listOf(DashboardIssue.FilesystemHidingSetupError) + } + + FilesystemHidingStatus.Unavailable, + FilesystemHidingStatus.Disabled, + FilesystemHidingStatus.Active, + -> { + emptyList() + } + } +} + +/** + * One banner per module, from the diagnosis already computed for its card, then + * the staged-for-reboot warnings. Keeping both off the same [ModuleFact] is what + * stops a banner's priority from drifting away from the card's colour. + */ +private fun moduleProblemIssues(facts: DashboardFacts): List = + buildList { + val modules = facts.modules + val ordered = + listOf( + FlashableModuleKind.Kmod to modules.kmod, + FlashableModuleKind.Kpm to modules.kpm, + FlashableModuleKind.Zygisk to modules.zygisk, + FlashableModuleKind.Ports to modules.ports, + ) + ordered.forEach { (_, module) -> module.problem?.let { add(DashboardIssue.ModuleBroken(it)) } } + ordered.forEach { (kind, module) -> + if (module.pendingReboot) add(DashboardIssue.ModuleNeedsReboot(kind)) + } + } + +private fun protectionIssues(facts: DashboardFacts): List = + buildList { + val protection = facts.protection + // A kernel backend that loaded but could not resolve every hook target. + // Only worth saying when a missing hook costs a measurable vector: on + // kernels that never had the symbol the surface is usually closed by + // SELinux or a capability check anyway, and alarming there is noise. + protection.partialHookGap + ?.takeIf { gap -> protection.report?.let { gap.costsAnyVector(it) } != false } + ?.let { add(DashboardIssue.PartialHooks(it.installed, it.expected, it.missing)) } + addAll(versionMismatchIssues(facts)) + // A vector an active layer OWNS is leaking: the backend should have hidden + // it and didn't, so the VPN is detectable AND the user can act (report the + // device). Unowned leaks — vectors no active backend covers here — are + // deliberately not surfaced: the backend is already doing all it can, and + // alarming about a gap the user cannot close is noise. They still show, + // neutrally, in the per-check breakdown. + val checked = protection.check as? ProtectionCheck.Checked + val nativeLeaks = (checked?.native as? LayerStatus.Active)?.leaks ?: 0 + val javaLeaks = (checked?.java as? LayerStatus.Active)?.leaks ?: 0 + if (nativeLeaks > 0 || javaLeaks > 0) add(DashboardIssue.ChecksFailed) + } + +/** + * The hook code lives in system_server and only swaps on reboot, so reinstalling + * the APK on the same base leaves the old hooks running until then. Developers + * who reinstall constantly can flip `suppressVersionWarnings` to compare base + * versions only; release users see no difference, release versions carrying no + * dev suffix. + */ +private fun versionMismatchIssues(facts: DashboardFacts): List { + val running = (facts.lsposed.state as? LsposedState.Active)?.version ?: return emptyList() + val mismatch = + if (facts.environment.suppressVersionWarnings) { + versionsMismatch(running, facts.appVersion) + } else { + versionsMismatchFull(running, facts.appVersion) + } + if (!mismatch) return emptyList() + return listOf( + DashboardIssue.LsposedVersionMismatch( + runningVersion = running, + appVersion = facts.appVersion, + degraded = !protectionFullyPassed(facts.protection.check), + ), + ) +} diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/settings/FilesystemHidingMessages.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/settings/FilesystemHidingMessages.kt deleted file mode 100644 index 8cd84705..00000000 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/settings/FilesystemHidingMessages.kt +++ /dev/null @@ -1,66 +0,0 @@ -package dev.okhsunrog.vpnhide.settings - -import android.content.res.Resources -import dev.okhsunrog.vpnhide.DashboardMessage -import dev.okhsunrog.vpnhide.DashboardMessageSeverity -import dev.okhsunrog.vpnhide.NativeBackendId -import dev.okhsunrog.vpnhide.R - -internal fun filesystemHidingDashboardMessage( - desiredEnabled: Boolean, - sections: Map, - res: Resources, -): DashboardMessage? { - val state = resolveFilesystemHidingState(desiredEnabled, sections) - return when (state.status) { - FilesystemHidingStatus.PendingEnable -> { - DashboardMessage( - DashboardMessageSeverity.WARNING, - res.getString( - if (state.backend == NativeBackendId.Zygisk) { - R.string.dashboard_issue_filesystem_hiding_pending_enable_zygisk - } else { - R.string.dashboard_issue_filesystem_hiding_pending_enable - }, - ), - ) - } - - FilesystemHidingStatus.PendingDisable -> { - DashboardMessage( - DashboardMessageSeverity.WARNING, - res.getString( - if (state.backend == NativeBackendId.Zygisk) { - R.string.dashboard_issue_filesystem_hiding_pending_disable_zygisk - } else { - R.string.dashboard_issue_filesystem_hiding_pending_disable - }, - ), - ) - } - - FilesystemHidingStatus.BootConfigError -> { - DashboardMessage( - DashboardMessageSeverity.ERROR, - res.getString( - R.string.dashboard_issue_filesystem_hiding_boot_error, - state.errorDetail.orEmpty(), - ), - ) - } - - FilesystemHidingStatus.HookSetupError -> { - DashboardMessage( - DashboardMessageSeverity.ERROR, - res.getString(R.string.dashboard_issue_filesystem_hiding_setup_error), - ) - } - - FilesystemHidingStatus.Unavailable, - FilesystemHidingStatus.Disabled, - FilesystemHidingStatus.Active, - -> { - null - } - } -} diff --git a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/DashboardIssuesTest.kt b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/DashboardIssuesTest.kt new file mode 100644 index 00000000..b7f7b309 --- /dev/null +++ b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/DashboardIssuesTest.kt @@ -0,0 +1,612 @@ +package dev.okhsunrog.vpnhide + +import dev.okhsunrog.vpnhide.diagnostics.LayerStatus +import dev.okhsunrog.vpnhide.settings.FilesystemHidingState +import dev.okhsunrog.vpnhide.settings.FilesystemHidingStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The dashboard's guard list, which used to be ~290 lines inline in + * `loadDashboardState` and therefore unreachable from a test: every branch went + * through `res.getString`, and this module has no Robolectric. + * + * These assert the decision only. Wording lives in `DashboardIssueRender.kt` and + * is the half that still needs a device. + */ +class DashboardIssuesTest { + // ── Fixtures — a healthy device, so each test states only its own deviation ── + + private val bootId = "boot-1" + + private fun installed( + version: String? = "1.2.5", + active: Boolean = true, + ) = ModuleState.Installed(version = version, active = active) + + private fun moduleFacts( + kmod: ModuleState = installed(), + kpm: ModuleState = ModuleState.NotInstalled, + zygisk: ModuleState = ModuleState.NotInstalled, + ports: ModuleState = ModuleState.NotInstalled, + kmodProblem: ModuleProblem? = null, + pendingReboot: Set = emptySet(), + standaloneKpm: Boolean = false, + kpmLoadStatus: KpmLoadStatus = kpmStatus(), + mismatches: List = emptyList(), + ): ModuleFacts { + val backends = NativeBackendStates(kmod = kmod, kpm = kpm, zygisk = zygisk) + return ModuleFacts( + kmod = ModuleFact(kmod, kmodProblem, FlashableModuleKind.Kmod in pendingReboot), + kpm = ModuleFact(kpm, null, FlashableModuleKind.Kpm in pendingReboot), + zygisk = ModuleFact(zygisk, null, FlashableModuleKind.Zygisk in pendingReboot), + ports = ModuleFact(ports, null, FlashableModuleKind.Ports in pendingReboot), + backends = backends, + nativeBackend = displayNativeBackend(backends), + standaloneKpm = standaloneKpm, + kpmLoadStatus = kpmLoadStatus, + kmodLoadStatus = null, + currentBootId = bootId, + mismatches = mismatches, + ) + } + + private fun kpmStatus( + reason: KpmFailureReason = KpmFailureReason.Ok, + loaded: Boolean? = true, + boot: String? = bootId, + ) = KpmLoadStatus( + timestamp = null, + bootId = boot, + unameR = null, + runtime = KpmRuntime.Apatch, + loaded = loaded, + filesystemHiding = null, + reason = reason, + detail = null, + ) + + private fun facts( + modules: ModuleFacts = moduleFacts(), + lsposed: LsposedState = LsposedState.Active(version = "1.2.5", targetCount = 3), + framework: LsposedFramework = LsposedFramework.Installed(disabled = false), + config: LsposedConfig? = null, + brokenFields: String? = null, + installFailures: String? = null, + targets: TargetCounts = TargetCounts(lsposed = 3, native = 3, ports = 0), + environment: EnvironmentFacts = environment(), + protection: ProtectionFacts = protection(), + kernelRecommendation: NativeInstallRecommendation? = null, + appVersion: String = "1.2.5", + ) = DashboardFacts( + modules = modules, + lsposed = + LsposedFacts( + state = lsposed, + framework = framework, + config = config, + brokenFields = brokenFields, + installFailures = installFailures, + aospSdkLabel = "35", + ), + targets = targets, + environment = environment, + protection = protection, + kernelRecommendation = kernelRecommendation, + appVersion = appVersion, + ) + + private fun environment( + selinuxPermissive: Boolean = false, + selfProfileCount: Int = 1, + debugLoggingOn: Boolean = false, + agentBridgeOn: Boolean = false, + suppressVersionWarnings: Boolean = false, + filesystemHiding: FilesystemHidingState = FilesystemHidingState(FilesystemHidingStatus.Disabled), + portsApply: PortsApplyProblem? = null, + ) = EnvironmentFacts( + selinuxPermissive = selinuxPermissive, + selfProfileCount = selfProfileCount, + debugLoggingOn = debugLoggingOn, + agentBridgeOn = agentBridgeOn, + suppressVersionWarnings = suppressVersionWarnings, + filesystemHiding = filesystemHiding, + portsApply = portsApply, + ) + + private fun protection( + check: ProtectionCheck = ProtectionCheck.Checked(clean, clean), + gap: PartialHookGap? = null, + ) = ProtectionFacts( + check = check, + report = null, + partialHookGap = gap, + installedOptionalHooks = emptySet(), + ) + + private fun recommendation( + backend: NativeBackendId, + kpatchRuntime: Boolean = false, + ) = NativeInstallRecommendation( + androidVersion = "Android 15", + kernelVersion = "6.1.0", + kernelBranch = "android14", + recommended = backend, + recommendedArtifact = "vpnhide-kmod-android14-6.1.zip", + recommendedGkiVariant = "android14-6.1", + kpatchRuntimeAvailable = kpatchRuntime, + ) + + private companion object { + val clean = LayerStatus.Active(hidden = 6, leaks = 0) + val leaking = LayerStatus.Active(hidden = 4, leaks = 2) + } + + private inline fun List.has(): Boolean = any { it is T } + + // ── A healthy device says nothing ── + + @Test + fun `a fully working setup produces no issues at all`() { + assertEquals(emptyList(), dashboardIssues(facts())) + } + + // ── Native presence ── + + @Test + fun `no native backend installed is reported`() { + val issues = dashboardIssues(facts(modules = moduleFacts(kmod = ModuleState.NotInstalled))) + assertTrue(issues.has()) + } + + @Test + fun `a standalone KPM zip replaces the missing-native error rather than joining it`() { + val issues = + dashboardIssues( + facts(modules = moduleFacts(kmod = ModuleState.NotInstalled, standaloneKpm = true)), + ) + + assertTrue(issues.has()) + // Both would be telling the user to install a native backend; the specific + // diagnosis wins. + assertFalse(issues.has()) + } + + // ── LSPosed ── + + @Test + fun `an active hook heartbeat suppresses on-disk config complaints`() { + // The DB says the module is not enabled, but the hooks are demonstrably + // running this boot — believe the runtime, not the file. + val issues = + dashboardIssues( + facts( + lsposed = LsposedState.Active(version = "1.2.5", targetCount = 3), + config = LsposedConfig.Disabled, + ), + ) + + assertFalse(issues.has()) + } + + @Test + fun `an inactive module with a disabled config is reported`() { + val issues = + dashboardIssues( + facts(lsposed = LsposedState.InstalledInactive("1.2.5"), config = LsposedConfig.Disabled), + ) + + assertTrue(issues.has()) + } + + @Test + fun `not-configured is only worth saying when the framework is actually installed`() { + val withFramework = + dashboardIssues( + facts( + lsposed = LsposedState.NotInstalled, + framework = LsposedFramework.Installed(disabled = false), + config = LsposedConfig.ModuleNotConfigured, + ), + ) + val withoutFramework = + dashboardIssues( + facts( + lsposed = LsposedState.NotInstalled, + framework = LsposedFramework.NotInstalled, + config = LsposedConfig.ModuleNotConfigured, + ), + ) + + assertTrue(withFramework.has()) + // Without the framework, "not enabled" is noise on top of "not installed". + assertFalse(withoutFramework.has()) + assertTrue(withoutFramework.has()) + } + + @Test + fun `an unreadable config is distinct from a disabled one`() { + val issues = dashboardIssues(facts(lsposed = LsposedState.InstalledInactive("1.2.5"), config = null)) + assertTrue(issues.has()) + } + + @Test + fun `extra scope entries warn but a missing system scope errors`() { + val issues = + dashboardIssues( + facts( + lsposed = LsposedState.InstalledInactive("1.2.5"), + config = + LsposedConfig.Enabled( + entries = listOf("system", "com.example.app"), + hasSystemFramework = false, + extraEntries = listOf("com.example.app"), + ), + ), + ) + + assertTrue(issues.has()) + assertEquals( + listOf("com.example.app"), + issues.filterIsInstance().single().entries, + ) + } + + @Test + fun `a field rename subsumes the install failures it caused`() { + val both = + dashboardIssues(facts(brokenFields = "mNetworkCapabilities", installFailures = "3")) + val failuresOnly = dashboardIssues(facts(installFailures = "3")) + + assertTrue(both.has()) + assertFalse(both.has()) + assertTrue(failuresOnly.has()) + } + + // ── Module versions ── + + @Test + fun `an older module is offered for download, a newer one is not`() { + val older = + dashboardIssues( + facts( + modules = + moduleFacts( + mismatches = listOf(ModuleMismatch(FlashableModuleKind.Zygisk, "1.2.0", "1.2.5")), + ), + ), + ).filterIsInstance().single() + val newer = + dashboardIssues( + facts( + modules = + moduleFacts( + mismatches = listOf(ModuleMismatch(FlashableModuleKind.Zygisk, "1.3.0", "1.2.5")), + ), + ), + ).filterIsInstance().single() + + assertEquals("vpnhide-zygisk.zip", older.downloadArtifact) + // The app is the lagging side here — re-flashing the module fixes nothing. + assertEquals(null, newer.downloadArtifact) + } + + @Test + fun `an outdated kmod is offered the kernel's own variant`() { + val issue = + dashboardIssues( + facts( + modules = + moduleFacts( + mismatches = listOf(ModuleMismatch(FlashableModuleKind.Kmod, "1.2.0", "1.2.5")), + ), + kernelRecommendation = recommendation(NativeBackendId.Kmod), + ), + ).filterIsInstance().single() + + assertEquals("vpnhide-kmod-android14-6.1.zip", issue.downloadArtifact) + } + + // ── Backend choice ── + + @Test + fun `a zygisk-only install is nudged towards kmod`() { + val issues = + dashboardIssues( + facts( + modules = moduleFacts(kmod = ModuleState.NotInstalled, zygisk = installed()), + kernelRecommendation = recommendation(NativeBackendId.Kmod), + ), + ) + + assertEquals( + SuggestedBackend.Kmod, + issues.filterIsInstance().single().backend, + ) + } + + @Test + fun `KPM is only suggested when a KPatch runtime is already there`() { + fun issuesWith(kpatchRuntime: Boolean) = + dashboardIssues( + facts( + modules = moduleFacts(kmod = ModuleState.NotInstalled, zygisk = installed()), + kernelRecommendation = recommendation(NativeBackendId.Kpm, kpatchRuntime = kpatchRuntime), + ), + ) + + assertTrue(issuesWith(kpatchRuntime = true).has()) + // Otherwise the nudge means "install two more things" — too pushy for a hint. + assertFalse(issuesWith(kpatchRuntime = false).has()) + } + + @Test + fun `two live kernel backends are an error, kernel plus zygisk only a warning`() { + val twoKernel = + dashboardIssues(facts(modules = moduleFacts(kmod = installed(), kpm = installed()))) + val kernelAndZygisk = + dashboardIssues(facts(modules = moduleFacts(kmod = installed(), zygisk = installed()))) + + assertTrue(twoKernel.has()) + assertTrue(kernelAndZygisk.has()) + } + + @Test + fun `an inactive second backend is not a conflict`() { + // Disabled modules keep their /data/adb/modules directory; only live ones + // can freeze the kernel. + val issues = + dashboardIssues( + facts(modules = moduleFacts(kmod = installed(), kpm = installed(active = false))), + ) + + assertFalse(issues.has()) + assertFalse(issues.has()) + } + + @Test + fun `KPM dormant for a missing superkey is surfaced`() { + val issues = + dashboardIssues( + facts( + modules = + moduleFacts( + kpm = installed(active = false), + kpmLoadStatus = kpmStatus(reason = KpmFailureReason.AwaitingSuperkey, loaded = false), + ), + ), + ) + + assertTrue(issues.has()) + } + + // ── Targets and ports ── + + @Test + fun `an unconfigured install is guided, not alarmed`() { + val issues = dashboardIssues(facts(targets = TargetCounts(lsposed = 0, native = 0, ports = 0))) + assertTrue(issues.has()) + } + + @Test + fun `ports installed with no observers is called out`() { + val issues = + dashboardIssues( + facts( + modules = moduleFacts(ports = installed()), + targets = TargetCounts(lsposed = 3, native = 3, ports = 0), + ), + ) + + assertTrue(issues.has()) + } + + @Test + fun `a failed ports apply carries its detail through`() { + val issues = + dashboardIssues(facts(environment = environment(portsApply = PortsApplyProblem("xtables lock")))) + + assertEquals( + "xtables lock", + issues.filterIsInstance().single().failureDetail, + ) + } + + // ── Environment ── + + @Test + fun `permissive selinux, debug logging, the agent bridge and extra profiles are each reported`() { + val issues = + dashboardIssues( + facts( + environment = + environment( + selinuxPermissive = true, + selfProfileCount = 2, + debugLoggingOn = true, + agentBridgeOn = true, + ), + ), + ) + + assertTrue(issues.has()) + assertTrue(issues.has()) + assertTrue(issues.has()) + assertEquals( + 2, + issues.filterIsInstance().single().profileCount, + ) + } + + @Test + fun `a single profile is not worth mentioning`() { + assertFalse( + dashboardIssues(facts(environment = environment(selfProfileCount = 1))) + .has(), + ) + } + + @Test + fun `filesystem hiding reports only its transient and error states`() { + fun issuesFor(status: FilesystemHidingStatus) = + dashboardIssues( + facts(environment = environment(filesystemHiding = FilesystemHidingState(status))), + ) + + assertTrue(issuesFor(FilesystemHidingStatus.PendingEnable).has()) + assertTrue(issuesFor(FilesystemHidingStatus.HookSetupError).has()) + // A settled feature — on, off, or unsupported — has nothing to say. + assertEquals(emptyList(), issuesFor(FilesystemHidingStatus.Active)) + assertEquals(emptyList(), issuesFor(FilesystemHidingStatus.Disabled)) + assertEquals(emptyList(), issuesFor(FilesystemHidingStatus.Unavailable)) + } + + @Test + fun `a pending filesystem-hiding change records which direction it is going`() { + fun pendingFor(status: FilesystemHidingStatus) = + dashboardIssues( + facts(environment = environment(filesystemHiding = FilesystemHidingState(status))), + ).filterIsInstance().single() + + assertTrue(pendingFor(FilesystemHidingStatus.PendingEnable).enabling) + assertFalse(pendingFor(FilesystemHidingStatus.PendingDisable).enabling) + } + + // ── Module problems ── + + @Test + fun `a module problem and a pending reboot are mutually exclusive per module`() { + val problem = ModuleProblem(ModuleBrokenReason.WrongVariant, "broken", downloadArtifact = "x.zip") + val issues = + dashboardIssues( + facts( + modules = + moduleFacts( + kmodProblem = problem, + pendingReboot = setOf(FlashableModuleKind.Ports), + ports = installed(), + ), + ), + ) + + assertEquals(problem, issues.filterIsInstance().single().problem) + assertEquals( + FlashableModuleKind.Ports, + issues.filterIsInstance().single().kind, + ) + } + + // ── Protection ── + + @Test + fun `a leaking owned vector is surfaced`() { + val issues = dashboardIssues(facts(protection = protection(ProtectionCheck.Checked(leaking, clean)))) + assertTrue(issues.has()) + } + + @Test + fun `a blocked or failed run makes no leak claim`() { + assertFalse( + dashboardIssues(facts(protection = protection(ProtectionCheck.Failed))) + .has(), + ) + } + + @Test + fun `a partial hook gap with no measured report is reported anyway`() { + // No report means the run could not measure; err towards telling the user. + val issues = + dashboardIssues( + facts(protection = protection(gap = PartialHookGap(installed = 7, expected = 9, missing = emptyList()))), + ) + + assertTrue(issues.has()) + } + + @Test + fun `a version mismatch is informational while everything passes and a warning once it does not`() { + fun mismatchFor(check: ProtectionCheck) = + dashboardIssues( + facts( + lsposed = LsposedState.Active(version = "1.2.0", targetCount = 3), + appVersion = "1.2.5", + protection = protection(check), + ), + ).filterIsInstance().single() + + assertFalse(mismatchFor(ProtectionCheck.Checked(clean, clean)).degraded) + assertTrue(mismatchFor(ProtectionCheck.Checked(leaking, clean)).degraded) + } + + @Test + fun `inactive hooks cannot produce a version mismatch`() { + // Nothing is running, so there is no running version to disagree with. + assertFalse( + dashboardIssues(facts(lsposed = LsposedState.InstalledInactive("1.2.0"), appVersion = "1.2.5")) + .has(), + ) + } + + @Test + fun `suppressVersionWarnings compares base versions only`() { + fun mismatchesWith(suppress: Boolean) = + dashboardIssues( + facts( + lsposed = LsposedState.Active(version = "1.2.5-3-gabc1234", targetCount = 3), + appVersion = "1.2.5", + environment = environment(suppressVersionWarnings = suppress), + ), + ).has() + + // A dev rebuild off the same base: the full compare notices, the base one does not. + assertTrue(mismatchesWith(suppress = false)) + assertFalse(mismatchesWith(suppress = true)) + } + + // ── Ordering ── + + @Test + fun `issues come out in the order the dashboard shows them`() { + // The screen groups by severity but keeps emission order inside each group, + // so this order decides which error a user reads first. It used to be + // implied by the physical layout of one 290-line block. + val issues = + dashboardIssues( + facts( + modules = + moduleFacts( + kmod = ModuleState.NotInstalled, + zygisk = installed(), + ports = installed(), + mismatches = listOf(ModuleMismatch(FlashableModuleKind.Zygisk, "1.2.0", "1.2.5")), + pendingReboot = setOf(FlashableModuleKind.Ports), + ), + lsposed = LsposedState.InstalledInactive("1.2.5"), + framework = LsposedFramework.NotInstalled, + config = LsposedConfig.ModuleNotConfigured, + targets = TargetCounts(lsposed = 0, native = 0, ports = 0), + environment = environment(selinuxPermissive = true, debugLoggingOn = true), + protection = protection(ProtectionCheck.Checked(leaking, clean)), + kernelRecommendation = recommendation(NativeBackendId.Kmod), + ), + ) + + assertEquals( + listOf( + // No NoNativeBackend: zygisk is installed, so the native layer exists. + "LsposedNotInstalled", + "ModuleVersionMismatch", + "NoTargets", + "PortsNoObservers", + "BetterBackendAvailable", + "DebugLoggingOn", + "SelinuxPermissive", + "ModuleNeedsReboot", + "ChecksFailed", + ), + issues.map { it::class.simpleName }, + ) + } +} diff --git a/scripts/measure-startup.py b/scripts/measure-startup.py index 9de29ebe..f0d3770e 100755 --- a/scripts/measure-startup.py +++ b/scripts/measure-startup.py @@ -238,9 +238,9 @@ def main() -> int: ("kernel", "dashboard_modules_done", "dashboard_kernel_done"), ("lsposed_config", "dashboard_kernel_done", "dashboard_lsposed_config_done"), ("lsposed_state", "dashboard_lsposed_config_done", "dashboard_lsposed_done"), - ("issues", "dashboard_lsposed_done", "dashboard_issues_done"), ("protection", "dashboard_protection_start", "dashboard_protection_done"), - ("compose_frame", "dashboard_protection_done", "dashboard_ready"), + ("issues", "dashboard_protection_done", "dashboard_issues_done"), + ("compose_frame", "dashboard_issues_done", "dashboard_ready"), ] print() print("Startup stage deltas:")