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 eb53c00..7356c71 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardData.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardData.kt @@ -134,15 +134,9 @@ internal fun classifyMultiNative( * redundant state worth warning about so the user removes one. */ internal fun kpmDeferredForConflict( - loadStatusSection: String, + status: KpmLoadStatus, currentBootId: String, -): Boolean { - val load = parseKeyValueLines(loadStatusSection) - val bootId = load["boot_id"]?.trim() - return load["runtime"]?.trim() == "conflict" && - !bootId.isNullOrEmpty() && - bootId == currentBootId.trim() -} +): Boolean = status.runtime == KpmRuntime.Conflict && status.isFreshFor(currentBootId) /** * True when the KPM boot script stood down this boot because it runs under @@ -156,17 +150,13 @@ internal fun kpmDeferredForConflict( * would just show KPM as inactive with no explanation. */ internal fun kpmAwaitingSuperkey( - loadStatusSection: String, + status: KpmLoadStatus, currentBootId: String, -): Boolean { - val load = parseKeyValueLines(loadStatusSection) - val bootId = load["boot_id"]?.trim() - return load["runtime"]?.trim() == "apatch" && - load["loaded"]?.trim() == "0" && - load["detail"]?.trim() == "awaiting_superkey" && - !bootId.isNullOrEmpty() && - bootId == currentBootId.trim() -} +): Boolean = + status.runtime == KpmRuntime.Apatch && + status.loaded == false && + status.reason == KpmFailureReason.AwaitingSuperkey && + status.isFreshFor(currentBootId) internal fun kpatchRuntimeAvailable(kpatchRuntimeSection: String): Boolean { val props = parseKeyValueLines(kpatchRuntimeSection) @@ -918,6 +908,7 @@ internal fun detectZygiskModule( internal fun detectKpmModule( sections: Map, + loadStatus: KpmLoadStatus, currentBootId: String, ): ModuleState { val prop = parseModuleProp(sections["kpm_prop"].orEmpty()) @@ -926,9 +917,7 @@ internal fun detectKpmModule( // supercall). The boot script writes load_status with loaded=1 and the // boot_id it loaded under, so "active" = loaded for the current boot — // the same freshness check the zygisk heartbeat uses. - val load = parseKeyValueLines(sections["kpm_load_status"].orEmpty()) - val bootId = load["boot_id"]?.trim() - val active = load["loaded"]?.trim() == "1" && bootId != null && bootId == currentBootId.trim() + val active = loadStatus.loaded == true && loadStatus.isFreshFor(currentBootId) return ModuleState.Installed( version = prop.version, active = active, @@ -1191,8 +1180,14 @@ internal suspend fun loadDashboardState( // 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) + val rawNativeBackends = + detectNativeBackendStates( + shellSnapshot, + currentBootId = currentBootId, + kpmLoadStatus = kpmLoadStatus, + ) val kmodRaw = rawNativeBackends.kmod val zygiskStatusRaw = shellSnapshot["zygisk_status"].orEmpty() val zygisk = rawNativeBackends.zygisk @@ -1241,7 +1236,7 @@ internal suspend fun loadDashboardState( // runtime=activator failure cases classifyKpmProblem diagnoses (the // conflict / awaiting-superkey cases stay separate warnings below). val kpmProblem: ModuleProblem? = - classifyKpmProblem(kpmRaw, shellSnapshot["kpm_load_status"].orEmpty(), currentBootId) + classifyKpmProblem(kpmRaw, kpmLoadStatus, currentBootId) ?.let { renderKpmProblem(it, res) } val kpm: ModuleState = if (kpmRaw is ModuleState.Installed && kpmProblem?.reason != null) { @@ -1545,7 +1540,7 @@ internal suspend fun loadDashboardState( // 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(shellSnapshot["kpm_load_status"].orEmpty(), currentBootId)) { + if (kpmDeferredForConflict(kpmLoadStatus, currentBootId)) { warn(res.getString(R.string.dashboard_issue_native_conflict_deferred)) } } @@ -1555,7 +1550,7 @@ internal suspend fun loadDashboardState( // 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(shellSnapshot["kpm_load_status"].orEmpty(), currentBootId) + kpmAwaitingSuperkey(kpmLoadStatus, currentBootId) ) { warn(res.getString(R.string.dashboard_issue_kpm_awaiting_superkey)) } diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/KpmDiagnostics.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/KpmDiagnostics.kt index 06602e9..7494862 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/KpmDiagnostics.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/KpmDiagnostics.kt @@ -30,27 +30,22 @@ internal sealed interface KpmProblemKind { /** Diagnose a complete KPM installation that failed in either boot path. */ internal fun classifyKpmProblem( kpm: ModuleState, - loadStatusSection: String, + status: KpmLoadStatus, currentBootId: String, ): KpmProblemKind? { if (kpm !is ModuleState.Installed || kpm.active) return null - val load = parseKeyValueLines(loadStatusSection) - val bootId = load["boot_id"]?.trim() - val runtime = load["runtime"]?.trim() - if (runtime !in setOf("activator", "kpatch-next") || - load["loaded"]?.trim() != "0" || - bootId.isNullOrEmpty() || - bootId != currentBootId.trim() + if (status.runtime !in setOf(KpmRuntime.Activator, KpmRuntime.KpatchNext) || + status.loaded != false || + !status.isFreshFor(currentBootId) ) { return null } - val reason = load["reason"]?.trim() - if (reason == "unsupported_kernel") { - return KpmProblemKind.UnsupportedKernel(load["uname_r"]?.trim().orEmpty().ifBlank { "?" }) + if (status.reason == KpmFailureReason.UnsupportedKernel) { + return KpmProblemKind.UnsupportedKernel(status.unameR ?: "?") } - val detail = load["detail"]?.trim().orEmpty() + val detail = status.detail.orEmpty() val missingPrefix = "activator missing at " - return if (reason == "missing_activator" || detail.startsWith(missingPrefix)) { + return if (status.reason == KpmFailureReason.MissingActivator || detail.startsWith(missingPrefix)) { KpmProblemKind.ActivatorMissing(detail.removePrefix(missingPrefix)) } else { KpmProblemKind.LoadFailed(detail) diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/KpmLoadStatus.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/KpmLoadStatus.kt new file mode 100644 index 0000000..a43c739 --- /dev/null +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/KpmLoadStatus.kt @@ -0,0 +1,69 @@ +package dev.okhsunrog.vpnhide + +internal enum class KpmRuntime { + Activator, + KpatchNext, + Apatch, + Conflict, + Unknown, +} + +internal enum class KpmFailureReason { + Ok, + ConflictingBackend, + MissingKpm, + MissingActivator, + AwaitingSuperkey, + UnsupportedKernel, + ActivationFailed, + LoadFailed, + Unknown, +} + +internal data class KpmLoadStatus( + val timestamp: Long?, + val bootId: String?, + val unameR: String?, + val runtime: KpmRuntime, + val loaded: Boolean?, + val reason: KpmFailureReason, + val detail: String?, +) { + fun isFreshFor(currentBootId: String): Boolean = !bootId.isNullOrEmpty() && bootId == currentBootId.trim() +} + +internal fun parseKpmLoadStatus(raw: String): KpmLoadStatus { + val values = parseKeyValueLines(raw) + return KpmLoadStatus( + timestamp = values["timestamp"]?.trim()?.toLongOrNull(), + bootId = values["boot_id"]?.trim()?.ifEmpty { null }, + unameR = values["uname_r"]?.trim()?.ifEmpty { null }, + runtime = + when (values["runtime"]?.trim()) { + "activator" -> KpmRuntime.Activator + "kpatch-next" -> KpmRuntime.KpatchNext + "apatch" -> KpmRuntime.Apatch + "conflict" -> KpmRuntime.Conflict + else -> KpmRuntime.Unknown + }, + loaded = + when (values["loaded"]?.trim()) { + "1" -> true + "0" -> false + else -> null + }, + reason = + when (values["reason"]?.trim()) { + "ok" -> KpmFailureReason.Ok + "conflicting_backend" -> KpmFailureReason.ConflictingBackend + "missing_kpm" -> KpmFailureReason.MissingKpm + "missing_activator" -> KpmFailureReason.MissingActivator + "awaiting_superkey" -> KpmFailureReason.AwaitingSuperkey + "unsupported_kernel" -> KpmFailureReason.UnsupportedKernel + "activation_failed" -> KpmFailureReason.ActivationFailed + "load_failed" -> KpmFailureReason.LoadFailed + else -> KpmFailureReason.Unknown + }, + detail = values["detail"]?.trim()?.ifEmpty { null }, + ) +} diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/NativeBackendData.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/NativeBackendData.kt index 6d1dae3..5d82db8 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/NativeBackendData.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/NativeBackendData.kt @@ -43,10 +43,11 @@ private val NATIVE_BACKEND_PRIORITY = internal fun detectNativeBackendStates( sections: Map, currentBootId: String = sections["current_boot_id"].orEmpty(), + kpmLoadStatus: KpmLoadStatus = parseKpmLoadStatus(sections["kpm_load_status"].orEmpty()), ): NativeBackendStates = NativeBackendStates( kmod = detectKmodModule(sections), - kpm = detectKpmModule(sections, currentBootId), + kpm = detectKpmModule(sections, kpmLoadStatus, currentBootId), zygisk = detectZygiskModule( sections = sections, diff --git a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/ClassifyKpmProblemTest.kt b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/ClassifyKpmProblemTest.kt index df8cce7..d3b2df2 100644 --- a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/ClassifyKpmProblemTest.kt +++ b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/ClassifyKpmProblemTest.kt @@ -7,32 +7,38 @@ import org.junit.Test class ClassifyKpmProblemTest { private fun installed(active: Boolean) = ModuleState.Installed(version = "1.0", active = active) + private fun classify( + kpm: ModuleState, + rawStatus: String, + currentBootId: String, + ): KpmProblemKind? = classifyKpmProblem(kpm, parseKpmLoadStatus(rawStatus), currentBootId) + @Test fun `not installed produces no problem`() { - assertNull(classifyKpmProblem(ModuleState.NotInstalled, "runtime=activator\nloaded=0\nboot_id=boot-1\n", "boot-1")) + assertNull(classify(ModuleState.NotInstalled, "runtime=activator\nloaded=0\nboot_id=boot-1\n", "boot-1")) } @Test fun `active kpm is fine regardless of load status`() { - assertNull(classifyKpmProblem(installed(active = true), "runtime=activator\nloaded=1\nboot_id=boot-1\n", "boot-1")) + assertNull(classify(installed(active = true), "runtime=activator\nloaded=1\nboot_id=boot-1\n", "boot-1")) } @Test fun `conflict runtime is not diagnosed here — handled by kpmDeferredForConflict`() { val status = "runtime=conflict\nloaded=0\nboot_id=boot-1\ndetail=vpnhide_kmod present\n" - assertNull(classifyKpmProblem(installed(active = false), status, "boot-1")) + assertNull(classify(installed(active = false), status, "boot-1")) } @Test fun `apatch awaiting-superkey runtime is not diagnosed here — handled by kpmAwaitingSuperkey`() { val status = "runtime=apatch\nloaded=0\nboot_id=boot-1\ndetail=awaiting_superkey\n" - assertNull(classifyKpmProblem(installed(active = false), status, "boot-1")) + assertNull(classify(installed(active = false), status, "boot-1")) } @Test fun `missing activator binary is a named diagnosis with a red card`() { val status = "runtime=activator\nloaded=0\nboot_id=boot-1\ndetail=activator missing at /data/adb/modules/vpnhide_kpm/activator\n" - val kind = classifyKpmProblem(installed(active = false), status, "boot-1") + val kind = classify(installed(active = false), status, "boot-1") assertEquals(KpmProblemKind.ActivatorMissing("/data/adb/modules/vpnhide_kpm/activator"), kind) assertEquals(ModuleBrokenReason.KpmActivatorMissing, kind?.reason) } @@ -40,7 +46,7 @@ class ClassifyKpmProblemTest { @Test fun `generic activator failure surfaces the raw detail with no card color`() { val status = "runtime=activator\nloaded=0\nboot_id=boot-1\ndetail=rc=1 supercall failed: ENOENT\n" - val kind = classifyKpmProblem(installed(active = false), status, "boot-1") + val kind = classify(installed(active = false), status, "boot-1") assertEquals(KpmProblemKind.LoadFailed("rc=1 supercall failed: ENOENT"), kind) assertNull(kind?.reason) } @@ -50,7 +56,7 @@ class ClassifyKpmProblemTest { val status = "runtime=activator\nloaded=0\nboot_id=boot-1\nuname_r=4.4.302-vendor\n" + "reason=unsupported_kernel\ndetail=unsupported kernel 4.4.302-vendor\n" - val kind = classifyKpmProblem(installed(active = false), status, "boot-1") + val kind = classify(installed(active = false), status, "boot-1") assertEquals(KpmProblemKind.UnsupportedKernel("4.4.302-vendor"), kind) assertEquals(ModuleBrokenReason.UnsupportedKernel, kind?.reason) } @@ -60,31 +66,31 @@ class ClassifyKpmProblemTest { val status = "runtime=kpatch-next\nloaded=0\nboot_id=boot-1\nreason=load_failed\n" + "detail=rc=1 kpatch CLI not found\n" - val kind = classifyKpmProblem(installed(active = false), status, "boot-1") + val kind = classify(installed(active = false), status, "boot-1") assertEquals(KpmProblemKind.LoadFailed("rc=1 kpatch CLI not found"), kind) } @Test fun `stale boot id is ignored`() { val status = "runtime=activator\nloaded=0\nboot_id=boot-0\ndetail=rc=1 boom\n" - assertNull(classifyKpmProblem(installed(active = false), status, "boot-1")) + assertNull(classify(installed(active = false), status, "boot-1")) } @Test fun `missing boot id is ignored`() { val status = "runtime=activator\nloaded=0\ndetail=rc=1 boom\n" - assertNull(classifyKpmProblem(installed(active = false), status, "boot-1")) + assertNull(classify(installed(active = false), status, "boot-1")) } @Test fun `empty load status is ignored`() { - assertNull(classifyKpmProblem(installed(active = false), "", "boot-1")) + assertNull(classify(installed(active = false), "", "boot-1")) } @Test fun `inactive with fresh configured status but stale active flag is not diagnosed`() { // loaded=1 on runtime=activator means "configured" per service.sh, not a failure. val status = "runtime=activator\nloaded=1\nboot_id=boot-1\ndetail=configured\n" - assertNull(classifyKpmProblem(installed(active = false), status, "boot-1")) + assertNull(classify(installed(active = false), status, "boot-1")) } } diff --git a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/KpmLoadStatusTest.kt b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/KpmLoadStatusTest.kt new file mode 100644 index 0000000..7cbaad3 --- /dev/null +++ b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/KpmLoadStatusTest.kt @@ -0,0 +1,43 @@ +package dev.okhsunrog.vpnhide + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class KpmLoadStatusTest { + @Test + fun `parser maps known wire values to typed status`() { + val status = + parseKpmLoadStatus( + """ + timestamp=42 + boot_id=boot-1 + uname_r=6.1.0-android + runtime=kpatch-next + loaded=0 + reason=unsupported_kernel + detail=unsupported kernel + """.trimIndent(), + ) + + assertEquals(42L, status.timestamp) + assertEquals("boot-1", status.bootId) + assertEquals("6.1.0-android", status.unameR) + assertEquals(KpmRuntime.KpatchNext, status.runtime) + assertEquals(false, status.loaded) + assertEquals(KpmFailureReason.UnsupportedKernel, status.reason) + assertEquals("unsupported kernel", status.detail) + assertTrue(status.isFreshFor("boot-1")) + } + + @Test + fun `parser keeps unknown and missing wire values non-actionable`() { + val status = parseKpmLoadStatus("runtime=future-runtime\nloaded=maybe\nreason=future-reason\n") + + assertEquals(KpmRuntime.Unknown, status.runtime) + assertEquals(null, status.loaded) + assertEquals(KpmFailureReason.Unknown, status.reason) + assertFalse(status.isFreshFor("boot-1")) + } +} diff --git a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/NativeBackendTest.kt b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/NativeBackendTest.kt index 9c559e5..7ca153c 100644 --- a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/NativeBackendTest.kt +++ b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/NativeBackendTest.kt @@ -9,6 +9,13 @@ import org.junit.Test class NativeBackendTest { private fun installed(active: Boolean) = ModuleState.Installed(version = "1.0", active = active) + private fun parseStatus(raw: String): KpmLoadStatus = parseKpmLoadStatus(raw) + + private fun detectKpm( + sections: Map, + currentBootId: String, + ): ModuleState = detectKpmModule(sections, parseStatus(sections["kpm_load_status"].orEmpty()), currentBootId) + private fun states( kmod: ModuleState, kpm: ModuleState, @@ -158,43 +165,43 @@ class NativeBackendTest { @Test fun `kpm deferred-conflict detected for current boot`() { val status = "runtime=conflict\nloaded=0\nboot_id=boot-1\ndetail=vpnhide_kmod present\n" - assertEquals(true, kpmDeferredForConflict(status, currentBootId = "boot-1")) + assertEquals(true, kpmDeferredForConflict(parseStatus(status), currentBootId = "boot-1")) } @Test fun `kpm deferred-conflict ignored for a stale boot`() { val status = "runtime=conflict\nloaded=0\nboot_id=boot-0\n" - assertEquals(false, kpmDeferredForConflict(status, currentBootId = "boot-1")) + assertEquals(false, kpmDeferredForConflict(parseStatus(status), currentBootId = "boot-1")) } @Test fun `kpm deferred-conflict false for non-conflict runtimes and empty status`() { - assertEquals(false, kpmDeferredForConflict("runtime=activator\nloaded=1\nboot_id=boot-1\n", "boot-1")) - assertEquals(false, kpmDeferredForConflict("runtime=conflict\nloaded=0\n", "boot-1")) - assertEquals(false, kpmDeferredForConflict("", "boot-1")) + assertEquals(false, kpmDeferredForConflict(parseStatus("runtime=activator\nloaded=1\nboot_id=boot-1\n"), "boot-1")) + assertEquals(false, kpmDeferredForConflict(parseStatus("runtime=conflict\nloaded=0\n"), "boot-1")) + assertEquals(false, kpmDeferredForConflict(parseStatus(""), "boot-1")) } // ── kpmAwaitingSuperkey ────────────────────────────────────────────── @Test fun `kpm awaiting-superkey detected for current boot`() { - val status = "runtime=apatch\nloaded=0\nboot_id=boot-1\ndetail=awaiting_superkey\n" - assertEquals(true, kpmAwaitingSuperkey(status, currentBootId = "boot-1")) + val status = "runtime=apatch\nloaded=0\nboot_id=boot-1\nreason=awaiting_superkey\ndetail=awaiting_superkey\n" + assertEquals(true, kpmAwaitingSuperkey(parseStatus(status), currentBootId = "boot-1")) } @Test fun `kpm awaiting-superkey ignored for a stale boot`() { - val status = "runtime=apatch\nloaded=0\nboot_id=boot-0\ndetail=awaiting_superkey\n" - assertEquals(false, kpmAwaitingSuperkey(status, currentBootId = "boot-1")) + val status = "runtime=apatch\nloaded=0\nboot_id=boot-0\nreason=awaiting_superkey\ndetail=awaiting_superkey\n" + assertEquals(false, kpmAwaitingSuperkey(parseStatus(status), currentBootId = "boot-1")) } @Test fun `kpm awaiting-superkey false once loaded or for other states`() { // Superkey saved and module loaded this boot. - assertEquals(false, kpmAwaitingSuperkey("runtime=kpatch-next\nloaded=1\nboot_id=boot-1\n", "boot-1")) + assertEquals(false, kpmAwaitingSuperkey(parseStatus("runtime=kpatch-next\nloaded=1\nboot_id=boot-1\n"), "boot-1")) // Conflict deferral is a different status, not awaiting-superkey. - assertEquals(false, kpmAwaitingSuperkey("runtime=conflict\nloaded=0\nboot_id=boot-1\n", "boot-1")) - assertEquals(false, kpmAwaitingSuperkey("", "boot-1")) + assertEquals(false, kpmAwaitingSuperkey(parseStatus("runtime=conflict\nloaded=0\nboot_id=boot-1\n"), "boot-1")) + assertEquals(false, kpmAwaitingSuperkey(parseStatus(""), "boot-1")) } // ── kpatchRuntimeAvailable ─────────────────────────────────────────── @@ -238,7 +245,7 @@ class NativeBackendTest { @Test fun `kpm not installed when no module prop`() { - val state = detectKpmModule(emptyMap(), currentBootId = "boot-1") + val state = detectKpm(emptyMap(), currentBootId = "boot-1") assertEquals(ModuleState.NotInstalled, state) } @@ -249,7 +256,7 @@ class NativeBackendTest { "kpm_prop" to "id=vpnhide_kpm\nversion=v1.0\n", "kpm_load_status" to "loaded=1\nboot_id=boot-1\n", ) - val state = detectKpmModule(sections, currentBootId = "boot-1") as ModuleState.Installed + val state = detectKpm(sections, currentBootId = "boot-1") as ModuleState.Installed assertEquals(true, state.active) assertEquals("1.0", state.version) } @@ -261,7 +268,7 @@ class NativeBackendTest { "kpm_prop" to "id=vpnhide_kpm\nversion=v1.0\n", "kpm_load_status" to "loaded=1\nboot_id=old-boot\n", ) - val state = detectKpmModule(sections, currentBootId = "boot-1") as ModuleState.Installed + val state = detectKpm(sections, currentBootId = "boot-1") as ModuleState.Installed assertEquals(false, state.active) } @@ -272,7 +279,7 @@ class NativeBackendTest { "kpm_prop" to "id=vpnhide_kpm\nversion=v1.0\n", "kpm_load_status" to "loaded=1\ndetail=configured\n", ) - val state = detectKpmModule(sections, currentBootId = "boot-1") as ModuleState.Installed + val state = detectKpm(sections, currentBootId = "boot-1") as ModuleState.Installed assertEquals(false, state.active) }