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:")