mirror of
https://github.com/okhsunrog/vpnhide.git
synced 2026-08-17 12:33:46 +00:00
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:
parent
6bd2fcb6d2
commit
e2a66ecd07
6 changed files with 170 additions and 54 deletions
|
|
@ -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.
|
||||
|
||||
## Русский
|
||||
|
||||
Диагностика больше не считает ошибкой способы обнаружения, которые не может закрыть ни один активный модуль на этом устройстве: на дашборде чисто, когда модуль скрывает всё, что в его силах, а такие векторы показаны нейтрально («Вне зоны») в подробной диагностике, а не как красные утечки.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
<!-- Экран настроек -->
|
||||
|
|
|
|||
|
|
@ -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 & 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 -->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue