feat(lsposed): stop flagging uncoverable detection vectors as errors

An "unowned" leak — a detection vector no active backend can cover on this
device — no longer raises a dashboard warning or the Issues count, and no
longer renders as a red leak in the detailed diagnostics.

When the active backend hides everything it owns, the dashboard reads
Protected / 0 issues. Uncovered vectors are shown neutrally ("not covered")
in a separate group of the per-check breakdown, so the residual surface stays
honest without reading as a module failure the user cannot act on.

- DashboardData: drop the unowned-leak clause from the warning banner
- DiagnosticsScreen: build the canonical DiagnosticReport (per-check `owned`)
  and render uncovered native leaks in a neutral bucket + separate section
- DashboardState carries installedOptionalHooks so the screen can rebuild it
This commit is contained in:
okhsunrog 2026-08-15 19:40:36 +03:00
parent 6bd2fcb6d2
commit e2a66ecd07
6 changed files with 170 additions and 54 deletions

View file

@ -0,0 +1,9 @@
_2026-08-15_
## English
Diagnostics no longer treats detection vectors that no active backend can cover on this device as errors: the dashboard stays clean when the active module hides everything it can, and such vectors are shown neutrally ("not covered") in the detailed breakdown instead of as red leaks.
## Русский
Диагностика больше не считает ошибкой способы обнаружения, которые не может закрыть ни один активный модуль на этом устройстве: на дашборде чисто, когда модуль скрывает всё, что в его силах, а такие векторы показаны нейтрально («Вне зоны») в подробной диагностике, а не как красные утечки.

View file

@ -83,9 +83,17 @@ tile's verdict:
probe — otherwise Partial and Broken are indistinguishable. The native tile is judged
**only on vectors the active backend owns** (has a hook for): a leak on a not-owned
vector (e.g. `/proc/net/dev` under a kernel backend — no kernel hook exists) does not
turn the tile red; it surfaces as a hero warning instead. So the **tile** answers "is
this module doing its job" and the **hero** answers "is the VPN hidden at all". The
Java tile uses the same rollup; LSPosed owns every Java check, so all its leaks count.
turn the tile red. Such an **unowned leak is a surface no active backend can close on
this device**, so it also does **not** raise a dashboard warning or the "Issues" count
— alarming about a gap the user cannot act on is just noise (and support churn).
Instead it is shown neutrally ("not covered") in a separate group of the per-check
breakdown, so the residual surface stays honest without reading as a failure. The
only thing that raises the hero to *attention* is an **owned** leak — a vector the
active backend should hide but didn't (the user can act: report the device / switch
backend) — or a genuine module/version problem. So the **tile** answers "is this
module doing its job", and the dashboard stays clean whenever the active backend
hides everything it *can*. The Java tile uses the same rollup; LSPosed owns every Java
check, so all its leaks count.
## 5. Self-in-tunnel gate

View file

@ -270,6 +270,10 @@ internal data class DashboardState(
val kmodLoadStatus: KmodLoadStatus?,
val protection: ProtectionCheck,
val messages: List<DashboardMessage>,
// Optional native hooks this boot actually installed. Retained so the Detailed
// diagnostics screen can rebuild the canonical DiagnosticReport (which vectors
// the active backend owns) rather than deriving ownership a second way.
val installedOptionalHooks: Set<HookIds.Hook> = emptySet(),
)
internal enum class HeroStatus { Protected, Attention, Unprotected, VpnOff }
@ -1634,12 +1638,6 @@ internal suspend fun loadDashboardState(
val vpnActive = isVpnActiveFromSnapshot(shellSnapshot["vpn_ifaces"].orEmpty())
VpnHideLog.i(TAG, "vpnActive=$vpnActive selfNeedsRestart=$selfNeedsRestart")
// Native leaks the tile doesn't score: vectors the active backend does not own
// (SELinux/zygisk territory), plus the Java-implemented native-level probes
// (NetworkInterface enum, /proc/net/route via ART) which carry no root
// differential and so aren't in nativeOutcomes. Set during the protection
// computation, surfaced via the hero warning so a leak there is never invisible.
var unownedNativeLeakCount = 0
val installedOptionalHooks =
installedNativeOptionalHooks(nativeBackend.id, shellSnapshot, currentBootId)
// Single source of truth: the cache does all the gating (VPN off / needs-restart /
@ -1666,7 +1664,6 @@ internal suspend fun loadDashboardState(
complete = true,
installedOptionalHooks = installedOptionalHooks,
)
unownedNativeLeakCount = report.native.unownedLeaks
ProtectionCheck.Checked(report.native.status, report.java.status)
}
@ -1685,17 +1682,19 @@ internal suspend fun loadDashboardState(
}
}
// A hiding layer is active but some of its runtime probes still leak (native
// partial/full, or a Java probe fails). Without this the state shows only as
// an amber hero/tile with no explanation — surface a warning that links to
// the full diagnostics for the per-check breakdown.
// A leak is a leak: an active layer's owned vector leaks, or a native surface
// the backend doesn't cover leaks (only SELinux would). Either way the VPN is
// detectable — link to the per-check breakdown.
// 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 || unownedNativeLeakCount > 0) {
if (nativeLeaks > 0 || javaLeaks > 0) {
messages +=
DashboardMessage(
DashboardMessageSeverity.WARNING,
@ -1722,5 +1721,6 @@ internal suspend fun loadDashboardState(
kmodLoadStatus = kmodLoadStatus,
protection = protection,
messages = messages,
installedOptionalHooks = installedOptionalHooks,
)
}

View file

@ -67,6 +67,11 @@ fun DiagnosticsScreen(
val scope = rememberCoroutineScope()
val diagState by DiagnosticsCache.state.collectAsState()
// The dashboard state carries which native backend is active + the optional hooks
// it installed — the inputs needed to rebuild the canonical DiagnosticReport here,
// so each check can be shown against the vectors the backend actually OWNS. Null
// until the dashboard has loaded (then we fall back to the raw, ownership-less list).
val dashState by DashboardCache.state.collectAsState()
val tallyFmt = stringResource(R.string.diag_summary_tally)
// Kick off the diagnostics run once per process. The cache parks at
@ -74,6 +79,9 @@ fun DiagnosticsScreen(
// app yet, so a run would be meaningless); run is idempotent otherwise.
LaunchedEffect(selfNeedsRestart) {
DiagnosticsCache.run(scope, context, selfNeedsRestart)
// Ensure the backend/ownership state is available even when the user opens
// Diagnostics without visiting the Dashboard first (cheap no-op if cached).
DashboardCache.ensureLoaded(scope, context, selfNeedsRestart)
}
val results = (diagState as? DiagnosticsCache.State.Ready)?.results
@ -82,13 +90,6 @@ fun DiagnosticsScreen(
// NotMeasured(NoNetworkPermission). Java-level checks never produce that state,
// so this isolates the "app has no network permission" banner from everything else.
val networkBlocked = results?.native?.anyNetworkBlocked() == true
// Honest headline: how many vectors we hide vs still leak (the misleading
// "N/total passed" score was the thing the report redesign retired).
val summary =
results?.let { r ->
val counts = r.all.protectionCounts()
String.format(tallyFmt, counts.hidden, counts.leaks)
}
Column(
modifier =
@ -150,35 +151,23 @@ fun DiagnosticsScreen(
)
}
if (summary != null) {
Spacer(Modifier.height(12.dp))
Text(
text = summary,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
}
results?.let { r ->
Spacer(Modifier.height(16.dp))
SectionHeader(stringResource(R.string.section_native))
Spacer(Modifier.height(6.dp))
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
r.nativeAll.forEachIndexed { i, check ->
CheckCard(check, index = i, count = r.nativeAll.size)
// Build the canonical report when the dashboard has loaded so each
// check knows whether the active backend OWNS its vector; otherwise
// render the raw list (every leak reads as a leak — the pre-report
// behaviour, used only in the brief window before the dashboard loads).
val report =
dashState?.let { ds ->
buildDiagnosticReport(
gate = DiagnosticGate.ROUTED,
results = r,
backend = ds.nativeBackend,
lsposedActive = ds.lsposed is LsposedState.Active,
complete = (diagState as? DiagnosticsCache.State.Ready)?.complete == true,
installedOptionalHooks = ds.installedOptionalHooks,
)
}
}
Spacer(Modifier.height(16.dp))
SectionHeader(stringResource(R.string.section_java))
Spacer(Modifier.height(6.dp))
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
r.java.forEachIndexed { i, check ->
CheckCard(check, index = i, count = r.java.size)
}
}
DiagnosticsResults(report = report, results = r, tallyFmt = tallyFmt)
}
}
}
@ -484,6 +473,97 @@ private fun formatSize(bytes: Long): String {
return "%.1f MB".format(mb)
}
/**
* One row on the Diagnostics list, unified across sources so [CheckCard] renders one
* shape. [uncovered] marks a native leak on a vector the active backend does not own
* a detection surface no active hook covers on this device. It is shown neutrally
* (see [diagStatusUncovered]), never as a red leak, and grouped apart from the
* backend's own vectors.
*/
private data class DiagCard(
val name: String,
val detail: String,
val groundTruthDetail: String?,
val outcome: CheckOutcome,
val uncovered: Boolean,
)
/** From a canonical report check the only source that knows [DiagnosticCheck.owned],
* so it is the only one that can flag an uncovered native leak. */
private fun DiagnosticCheck.toDiagCard(): DiagCard =
DiagCard(
name = label,
detail = appDetail,
groundTruthDetail = groundTruthDetail,
outcome = outcome,
uncovered = layer == CheckLayer.NATIVE && outcome is CheckOutcome.Leak && !owned,
)
/** Raw-list fallback (dashboard not yet loaded): no ownership known, so nothing is
* marked uncovered a leak reads as a leak, the pre-report behaviour. */
private fun CheckResult.toDiagCard(): DiagCard = DiagCard(name, detail, groundTruthDetail, outcome, uncovered = false)
/**
* The results body: the honest headline (hidden vs still-leaking) plus the check
* cards, split into the backend's own vectors, the vectors no active backend covers
* on this device (shown neutrally), and the Java layer. [report] is null only in the
* brief window before the dashboard loads, when we fall back to the raw list.
*/
@Composable
private fun DiagnosticsResults(
report: DiagnosticReport?,
results: CheckResults,
tallyFmt: String,
) {
val nativeCards = report?.native?.checks?.map { it.toDiagCard() } ?: results.nativeAll.map { it.toDiagCard() }
val javaCards = report?.java?.checks?.map { it.toDiagCard() } ?: results.java.map { it.toDiagCard() }
val covered = nativeCards.filterNot { it.uncovered }
val uncovered = nativeCards.filter { it.uncovered }
// Headline counts the backend's job: hidden vectors vs still-leaking OWNED
// vectors. Uncovered vectors are out of the active backend's scope, so they are
// reported below rather than folded into "leaking".
val scored = covered + javaCards
val hidden = scored.count { it.outcome is CheckOutcome.HiddenByBackend || it.outcome is CheckOutcome.HiddenBySelinux }
val leaks = scored.count { it.outcome is CheckOutcome.Leak }
Spacer(Modifier.height(12.dp))
Text(
text = String.format(tallyFmt, hidden, leaks),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(16.dp))
SectionHeader(stringResource(R.string.section_native))
Spacer(Modifier.height(6.dp))
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
covered.forEachIndexed { i, c -> CheckCard(c, index = i, count = covered.size) }
}
if (uncovered.isNotEmpty()) {
Spacer(Modifier.height(16.dp))
SectionHeader(stringResource(R.string.section_native_uncovered))
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.diag_uncovered_caption),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(6.dp))
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
uncovered.forEachIndexed { i, c -> CheckCard(c, index = i, count = uncovered.size) }
}
}
Spacer(Modifier.height(16.dp))
SectionHeader(stringResource(R.string.section_java))
Spacer(Modifier.height(6.dp))
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
javaCards.forEachIndexed { i, c -> CheckCard(c, index = i, count = javaCards.size) }
}
}
/**
* One check's status: a coloured **dot + short word** (the "3-B" treatment the
* app's own status-dot idiom from the module rows). The card colour tracks current
@ -528,13 +608,26 @@ private fun diagStatus(outcome: CheckOutcome): DiagStatus =
}
}
/** Neutral "out of scope" status for a native leak on a vector the active backend
* does not own: no active hook covers it on this device, so it is not the backend
* failing it is reported calmly (grey dot + word, no alarm, collapsed), never as a
* red leak, so a working backend never reads as broken over a gap it cannot close. */
@Composable
private fun diagStatusUncovered(): DiagStatus =
DiagStatus(
stringResource(R.string.diag_status_uncovered),
StatusColors.neutralAccent,
StatusColors.neutralContainer(),
false,
)
@Composable
private fun CheckCard(
r: CheckResult,
r: DiagCard,
index: Int = -1,
count: Int = 1,
) {
val status = diagStatus(r.outcome)
val status = if (r.uncovered) diagStatusUncovered() else diagStatus(r.outcome)
var expanded by remember(r.name) { mutableStateOf(status.expandedByDefault) }
val caretRotation by animateFloatAsState(if (expanded) 90f else 0f, label = "caret")
GroupedCard(

View file

@ -379,10 +379,13 @@
<string name="diag_status_leak">Утечка</string>
<string name="diag_status_nomeasure">Нет данных</string>
<string name="diag_status_nothing">Нечего скрывать</string>
<string name="diag_status_uncovered">Вне зоны</string>
<string name="banner_ready">VPN активен. Для этого приложения включено скрытие. Результаты проверок ниже.</string>
<string name="banner_added_self">Для VPN Hide включено скрытие. Принудительно остановите VPN Hide и откройте снова, чтобы нативные проверки начали работать — перезагрузка устройства не нужна.</string>
<string name="banner_network_blocked">Доступ к сети отключён для этого приложения. Проверки ioctl (SIOCGIFFLAGS, SIOCGIFMTU, SIOCGIFCONF) не могут быть выполнены. Включите в Настройки → Приложения → VPN Hide → Мобильный интернет и Wi-Fi.</string>
<string name="section_native">Нативный уровень (kmod / KPM / Zygisk)</string>
<string name="section_native_uncovered">Вне зоны активного модуля</string>
<string name="diag_uncovered_caption">Способы обнаружения, которые не закрывает ни один активный модуль на этом устройстве. Это не ошибка работающего модуля: их закрыл бы модуль уровня ядра (kmod / KPM), если устройство его поддерживает.</string>
<string name="section_java">Java API уровень (LSPosed)</string>
<!-- Экран настроек -->

View file

@ -107,10 +107,13 @@
<string name="diag_status_leak">Leak</string>
<string name="diag_status_nomeasure">No data</string>
<string name="diag_status_nothing">Nothing to hide</string>
<string name="diag_status_uncovered">Not covered</string>
<string name="banner_ready">VPN is active. Hiding is enabled for this app. Results below.</string>
<string name="banner_added_self">Enabled hiding for VPN Hide. Restart VPN Hide (force-stop and reopen) for native-level checks to take effect — no device reboot needed.</string>
<string name="banner_network_blocked">Network access is disabled for this app. ioctl checks (SIOCGIFFLAGS, SIOCGIFMTU, SIOCGIFCONF) cannot run without it. Enable in Settings → Apps → VPN Hide → Mobile data &amp; Wi-Fi.</string>
<string name="section_native">Native level (kmod / KPM / Zygisk)</string>
<string name="section_native_uncovered">Outside the active backend\'s reach</string>
<string name="diag_uncovered_caption">Detection surfaces no active backend covers on this device — not a failure of the running module. A kernel-level backend (kmod / KPM) would close these where the device supports one.</string>
<string name="section_java">Java API level (LSPosed)</string>
<!-- Dashboard -->