refactor(lsposed): parse KPM status into typed model

This commit is contained in:
okhsunrog 2026-08-11 22:17:42 +03:00
parent 1b3f3d8f02
commit f0dcf80ef2
7 changed files with 183 additions and 67 deletions

View file

@ -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<String, String>,
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))
}

View file

@ -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)

View file

@ -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 },
)
}

View file

@ -43,10 +43,11 @@ private val NATIVE_BACKEND_PRIORITY =
internal fun detectNativeBackendStates(
sections: Map<String, String>,
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,

View file

@ -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"))
}
}

View file

@ -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"))
}
}

View file

@ -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<String, String>,
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)
}