From e2a66ecd07ca7bbdf8ecb5da2284a7bf59fe23c0 Mon Sep 17 00:00:00 2001 From: okhsunrog Date: Sat, 15 Aug 2026 19:40:36 +0300 Subject: [PATCH] feat(lsposed): stop flagging uncoverable detection vectors as errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...no-longer-treats-detection-vectors-c93c.md | 9 + docs/diagnostics.md | 14 +- .../dev/okhsunrog/vpnhide/DashboardData.kt | 30 ++-- .../okhsunrog/vpnhide/DiagnosticsScreen.kt | 165 ++++++++++++++---- .../app/src/main/res/values-ru/strings.xml | 3 + lsposed/app/src/main/res/values/strings.xml | 3 + 6 files changed, 170 insertions(+), 54 deletions(-) create mode 100644 changelog.d/changed-diagnostics-no-longer-treats-detection-vectors-c93c.md diff --git a/changelog.d/changed-diagnostics-no-longer-treats-detection-vectors-c93c.md b/changelog.d/changed-diagnostics-no-longer-treats-detection-vectors-c93c.md new file mode 100644 index 0000000..62bde09 --- /dev/null +++ b/changelog.d/changed-diagnostics-no-longer-treats-detection-vectors-c93c.md @@ -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. + +## Русский + +Диагностика больше не считает ошибкой способы обнаружения, которые не может закрыть ни один активный модуль на этом устройстве: на дашборде чисто, когда модуль скрывает всё, что в его силах, а такие векторы показаны нейтрально («Вне зоны») в подробной диагностике, а не как красные утечки. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 97db5eb..8f2627b 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -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 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 2499aba..2913b52 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardData.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardData.kt @@ -270,6 +270,10 @@ internal data class DashboardState( val kmodLoadStatus: KmodLoadStatus?, val protection: ProtectionCheck, val messages: List, + // 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 = 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, ) } diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DiagnosticsScreen.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DiagnosticsScreen.kt index 4bcef54..a835772 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DiagnosticsScreen.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DiagnosticsScreen.kt @@ -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( diff --git a/lsposed/app/src/main/res/values-ru/strings.xml b/lsposed/app/src/main/res/values-ru/strings.xml index 18f942d..3cf4481 100644 --- a/lsposed/app/src/main/res/values-ru/strings.xml +++ b/lsposed/app/src/main/res/values-ru/strings.xml @@ -379,10 +379,13 @@ Утечка Нет данных Нечего скрывать + Вне зоны VPN активен. Для этого приложения включено скрытие. Результаты проверок ниже. Для VPN Hide включено скрытие. Принудительно остановите VPN Hide и откройте снова, чтобы нативные проверки начали работать — перезагрузка устройства не нужна. Доступ к сети отключён для этого приложения. Проверки ioctl (SIOCGIFFLAGS, SIOCGIFMTU, SIOCGIFCONF) не могут быть выполнены. Включите в Настройки → Приложения → VPN Hide → Мобильный интернет и Wi-Fi. Нативный уровень (kmod / KPM / Zygisk) + Вне зоны активного модуля + Способы обнаружения, которые не закрывает ни один активный модуль на этом устройстве. Это не ошибка работающего модуля: их закрыл бы модуль уровня ядра (kmod / KPM), если устройство его поддерживает. Java API уровень (LSPosed) diff --git a/lsposed/app/src/main/res/values/strings.xml b/lsposed/app/src/main/res/values/strings.xml index 360d87b..b8c2347 100644 --- a/lsposed/app/src/main/res/values/strings.xml +++ b/lsposed/app/src/main/res/values/strings.xml @@ -107,10 +107,13 @@ Leak No data Nothing to hide + Not covered VPN is active. Hiding is enabled for this app. Results below. Enabled hiding for VPN Hide. Restart VPN Hide (force-stop and reopen) for native-level checks to take effect — no device reboot needed. Network access is disabled for this app. ioctl checks (SIOCGIFFLAGS, SIOCGIFMTU, SIOCGIFCONF) cannot run without it. Enable in Settings → Apps → VPN Hide → Mobile data & Wi-Fi. Native level (kmod / KPM / Zygisk) + Outside the active backend\'s reach + 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. Java API level (LSPosed)