feat(lsposed): H+O mutex, inline help, issue severity split

Three UX-and-diagnostics fixes for issues surfaced by user reports.

H+O mutual exclusion in App Hiding:
Users marking the same app as both Hidden and Observer caused it to
crash on startup — the app would query its own PackageInfo during init,
system_server hook matched it as an observer and stripped its own
package from the result, and the framework bailed on NameNotFound.
Roles are now mutually exclusive: toggling one clears the other. On
first launch, any pre-existing H+O config is migrated to O-only and
dirty=true so the Save button prompts the user to persist the fix.

Help is always-visible, not behind a ? icon:
Real-world usage showed nobody taps the HelpOutline in the top bar —
users configure observers, flip toggles blind, then open issues when
things don't work. Help dialogs on Tun / Apps / Ports screens are
replaced with collapsible cards at the top of each list, expanded by
default and remembered-per-screen in SharedPreferences so experienced
users can collapse them without losing the affordance.

Issues → Errors + Warnings:
The single red-banner "Issues" list mixed "your setup is broken"
(LSPosed missing, no targets) with "your setup works but is suboptimal"
(version skew, extra scope entries). Split into two sections with
theme colors (error / tertiary). Five new warnings cover misconfigs
that went unreported before:

 * kernel supports kmod but only Zygisk is installed (zygisk is
   theoretically detectable; kmod isn't)
 * kmod and Zygisk both active (redundant hooks, larger fingerprint)
 * package marked as both Tun target and Ports observer (traps users
   with transparent-proxy clients — vpnhide_out REJECTs redirected
   loopback traffic and the app loses internet)
 * debug logging left enabled after a bug report (leaks tag-matched
   lines to any root-reading forensic scan — reads the flag file
   written by the Diagnostics toggle in the sibling PR, so fires
   only once that merges)
 * SELinux in Permissive mode (exposes six detection vectors we rely
   on the kernel to block — see the coverage table in README.md)

Also aligns lsposed/native/Cargo.lock with Cargo.toml (0.6.1 → 0.6.2)
— same stale-lock fix as in the other pending PRs.
This commit is contained in:
okhsunrog 2026-04-18 00:23:33 +03:00
parent 0ad3c5d743
commit 2395db891e
12 changed files with 406 additions and 163 deletions

View file

@ -8,10 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Dashboard now splits issues into Errors (red, block protection) and Warnings (amber, setup is suboptimal but working). Five new warnings: kernel supports kmod but only Zygisk is installed; kmod and Zygisk both active simultaneously; app configured as both Tun target and Ports observer (breaks connectivity with transparent-proxy clients); debug logging left on; SELinux in Permissive mode (exposes six detection vectors that VPN Hide relies on the kernel to block).
- Debug logging toggle in Diagnostics: off by default — VPN Hide, LSPosed hooks (VpnHide-NC/NI/LP and the package-visibility filter), and zygisk keep logcat near-silent. Start recording and Collect debug log automatically enable verbose logging for the duration of the capture and restore it afterwards, so the toggle is only needed if you want logs emitted continuously outside a capture. Errors always pass through so hook-install failures remain visible.
### Changed
- Help text on Protection screens (Tun / Apps / Ports) moved from a hard-to-discover ? icon in the top bar to always-visible collapsible cards at the top of each list. Users who read and understood the hints can collapse them — the state is remembered across app restarts.
### Fixed
- Dashboard now shows a consistent version string for all modules. Kernel-module, Zygisk and Ports module cards used to display the Magisk-style 'vX.Y.Z' from their module.prop, while the LSPosed hook module card showed the Android-style 'X.Y.Z' from the APK's versionName — on the same screen, for the same version number. The 'v' prefix is now stripped at parse time so every card reads 'X.Y.Z' (or 'X.Y.Z-N-gSHA' for dev builds).
- App Hiding: marking the same app as both H (Hidden) and O (Observer) caused it to crash on startup — the app would query its own PackageInfo, our system_server hook matched it as an observer and stripped its own package from the result, and the framework bailed. Roles are now mutually exclusive: toggling one clears the other, and existing H+O configs are migrated to O-only on first launch.
## v0.6.2

View file

@ -1,21 +1,38 @@
{
"unreleased": {
"sections": [
{
"type": "added",
"items": [
{
"en": "Dashboard now splits issues into Errors (red, block protection) and Warnings (amber, setup is suboptimal but working). Five new warnings: kernel supports kmod but only Zygisk is installed; kmod and Zygisk both active simultaneously; app configured as both Tun target and Ports observer (breaks connectivity with transparent-proxy clients); debug logging left on; SELinux in Permissive mode (exposes six detection vectors that VPN Hide relies on the kernel to block).",
"ru": "Обзор теперь делит проблемы на Ошибки (красные, ломают защиту) и Предупреждения (жёлтые, защита работает, но конфигурация не оптимальная). Пять новых предупреждений: ядро поддерживает kmod, но установлен только Zygisk; kmod и Zygisk активны одновременно; приложение добавлено и в Туннели, и как Observer портов (ломает сеть при прозрачном прокси); оставлены включёнными отладочные логи; SELinux в режиме Permissive (открывает шесть векторов детекции, которые должен блокировать)."
},
{
"en": "Debug logging toggle in Diagnostics: off by default — VPN Hide, LSPosed hooks (VpnHide-NC/NI/LP and the package-visibility filter), and zygisk keep logcat near-silent. Start recording and Collect debug log automatically enable verbose logging for the duration of the capture and restore it afterwards, so the toggle is only needed if you want logs emitted continuously outside a capture. Errors always pass through so hook-install failures remain visible.",
"ru": "Переключатель отладочных логов в Диагностике: по умолчанию выключен — приложение, хуки LSPosed (VpnHide-NC/NI/LP, фильтр видимости пакетов) и zygisk почти ничего не пишут в logcat. Кнопки «Начать запись» и «Собрать отладочный лог» автоматически включают логирование на время захвата и возвращают состояние обратно, так что переключатель нужен только если вы хотите, чтобы логи писались постоянно, вне сессии захвата. Ошибки всегда видны, чтобы проблемы установки хуков не терялись."
}
]
},
{
"type": "changed",
"items": [
{
"en": "Help text on Protection screens (Tun / Apps / Ports) moved from a hard-to-discover ? icon in the top bar to always-visible collapsible cards at the top of each list. Users who read and understood the hints can collapse them — the state is remembered across app restarts.",
"ru": "Подсказки на вкладке Защита (Туннели / Приложения / Порты) переехали из неприметной иконки ? в заголовке в постоянно видимые сворачиваемые карточки над списком. Прочитавшие и понявшие могут свернуть — состояние запоминается между запусками приложения."
}
]
},
{
"type": "fixed",
"items": [
{
"en": "Dashboard now shows a consistent version string for all modules. Kernel-module, Zygisk and Ports module cards used to display the Magisk-style 'vX.Y.Z' from their module.prop, while the LSPosed hook module card showed the Android-style 'X.Y.Z' from the APK's versionName — on the same screen, for the same version number. The 'v' prefix is now stripped at parse time so every card reads 'X.Y.Z' (or 'X.Y.Z-N-gSHA' for dev builds).",
"ru": "На панели теперь одинаковый формат версий для всех модулей. Раньше карточки модулей ядра, Zygisk и скрытия портов показывали Magisk-стиль 'vX.Y.Z' из module.prop, а карточка LSPosed-хука — Android-стиль 'X.Y.Z' из versionName APK — на одном экране, для одной и той же версии. Префикс 'v' теперь снимается при парсинге, так что везде показывается 'X.Y.Z' (или 'X.Y.Z-N-gSHA' для dev-сборок)."
}
]
},
{
"type": "added",
"items": [
},
{
"en": "Debug logging toggle in Diagnostics: off by default — VPN Hide, LSPosed hooks (VpnHide-NC/NI/LP and the package-visibility filter), and zygisk keep logcat near-silent. Start recording and Collect debug log automatically enable verbose logging for the duration of the capture and restore it afterwards, so the toggle is only needed if you want logs emitted continuously outside a capture. Errors always pass through so hook-install failures remain visible.",
"ru": "Переключатель отладочных логов в Диагностике: по умолчанию выключен — приложение, хуки LSPosed (VpnHide-NC/NI/LP, фильтр видимости пакетов) и zygisk почти ничего не пишут в logcat. Кнопки «Начать запись» и «Собрать отладочный лог» автоматически включают логирование на время захвата и возвращают состояние обратно, так что переключатель нужен только если вы хотите, чтобы логи писались постоянно, вне сессии захвата. Ошибки всегда видны, чтобы проблемы установки хуков не терялись."
"en": "App Hiding: marking the same app as both H (Hidden) and O (Observer) caused it to crash on startup — the app would query its own PackageInfo, our system_server hook matched it as an observer and stripped its own package from the result, and the framework bailed. Roles are now mutually exclusive: toggling one clears the other, and existing H+O configs are migrated to O-only on first launch.",
"ru": "Скрытие приложений: если приложение было отмечено одновременно как H (скрытое) и O (observer), оно падало при запуске — запрашивало свою собственную PackageInfo, наш хук в system_server вырезал его из ответа как наблюдателю, и фреймворк ломался. Роли теперь взаимоисключающие: включение одной сбрасывает другую, а уже сохранённая комбинация H+O автоматически превращается в O при первом запуске."
}
]
}

View file

@ -25,10 +25,7 @@ import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
@ -36,7 +33,6 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -128,6 +124,12 @@ fun AppHidingScreen(
val selfPkg = context.packageName
val installedApps = pm.getInstalledApplications(PackageManager.GET_META_DATA)
// Packages with both roles crash on startup: the app queries its own
// PackageInfo/ResolveInfo during init, we detect the observer caller
// (itself) and strip its own package from the result, so frameworks
// see a self-lookup NameNotFoundException and bail. Collapse to
// observer-only on load so the next Save persists the fix.
var autoFixedConflict = false
val entries =
installedApps
.filter { it.packageName != selfPkg } // self is always hidden; managed invisibly
@ -146,17 +148,27 @@ fun AppHidingScreen(
}
val isSystem = (info.flags and ApplicationInfo.FLAG_SYSTEM) != 0
val pkg = info.packageName
val rawHidden = pkg in hiddenNames
val rawObserver = pkg in observerNames
val (hidden, observer) =
if (rawHidden && rawObserver) {
autoFixedConflict = true
false to true
} else {
rawHidden to rawObserver
}
HidingEntry(
packageName = pkg,
label = label,
icon = icon,
isSystem = isSystem,
hidden = pkg in hiddenNames,
observer = pkg in observerNames,
hidden = hidden,
observer = observer,
)
}.sortedBy { it.label.lowercase() }
allApps = entries
if (autoFixedConflict) dirty = true
loading = false
}
}
@ -189,6 +201,29 @@ fun AppHidingScreen(
state = listState,
modifier = Modifier.fillMaxSize(),
) {
item {
Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
HelpAccordion(
prefKey = "apps_hiding",
title = stringResource(R.string.hiding_help_title),
) {
Text(
text = stringResource(R.string.hiding_hint_roles),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.hiding_hint_system),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(R.string.hiding_hint_reboot),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
items(filteredApps, key = { it.packageName }) { app ->
HidingAppRow(
app = app,
@ -198,9 +233,24 @@ fun AppHidingScreen(
if (it.packageName != app.packageName) {
it
} else {
// Roles are mutually exclusive: turning one on
// forces the other off. Avoids the H+O self-hide
// crash (app can't resolve its own package info).
when (role) {
HidingRole.HIDDEN -> it.copy(hidden = !it.hidden)
HidingRole.OBSERVER -> it.copy(observer = !it.observer)
HidingRole.HIDDEN -> {
val newHidden = !it.hidden
it.copy(
hidden = newHidden,
observer = if (newHidden) false else it.observer,
)
}
HidingRole.OBSERVER -> {
val newObserver = !it.observer
it.copy(
observer = newObserver,
hidden = if (newObserver) false else it.hidden,
)
}
}
}
}
@ -382,38 +432,6 @@ private fun buildHidingUidResolver(
append("; else echo > $outputFile 2>/dev/null; fi")
}
@Composable
fun AppHidingHelpDialog(onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.hiding_help_title)) },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = stringResource(R.string.hiding_hint_roles),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.hiding_hint_system),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(R.string.hiding_hint_reboot),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("OK") }
},
)
}
@Composable
private fun HidingAppRow(
app: HidingEntry,

View file

@ -12,9 +12,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@ -181,6 +179,24 @@ fun AppPickerScreen(
state = listState,
modifier = Modifier.fillMaxSize(),
) {
item {
Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
HelpAccordion(
prefKey = "apps_tun",
title = stringResource(R.string.apps_help_title),
) {
Text(
text = stringResource(R.string.apps_hint_toggles),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.apps_hint_zygisk),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
items(filteredApps, key = { it.packageName }) { app ->
AppRow(
app = app,
@ -347,33 +363,6 @@ fun AppPickerScreen(
}
}
@Composable
fun AppsHelpDialog(onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.apps_help_title)) },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = stringResource(R.string.apps_hint_toggles),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.apps_hint_zygisk),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("OK") }
},
)
}
private fun buildSaveCommand(
header: String,
kmodPkgs: List<String>,

View file

@ -99,6 +99,13 @@ private sealed interface LsposedConfig {
) : LsposedConfig
}
internal enum class IssueSeverity { ERROR, WARNING }
internal data class Issue(
val severity: IssueSeverity,
val text: String,
)
internal data class DashboardState(
val kmod: ModuleState,
val zygisk: ModuleState,
@ -106,7 +113,7 @@ internal data class DashboardState(
val ports: ModuleState,
val nativeInstallRecommendation: NativeInstallRecommendation?,
val protection: ProtectionCheck,
val issues: List<String>,
val issues: List<Issue>,
)
internal data class NativeInstallRecommendation(
@ -124,10 +131,18 @@ internal fun loadDashboardState(
context: android.content.Context,
selfNeedsRestart: Boolean,
): DashboardState {
val issues = mutableListOf<String>()
val issues = mutableListOf<Issue>()
val res = context.resources
val selfPkg = context.packageName
fun err(text: String) {
issues += Issue(IssueSeverity.ERROR, text)
}
fun warn(text: String) {
issues += Issue(IssueSeverity.WARNING, text)
}
VpnHideLog.i(TAG, "=== Loading dashboard state ===")
// ── Module detection ──
@ -156,6 +171,15 @@ internal fun loadDashboardState(
}
}
fun readTargetPackageSet(path: String): Set<String> {
val (_, out) = suExec("cat $path 2>/dev/null || true")
return out
.lines()
.map { it.trim() }
.filter { it.isNotEmpty() && !it.startsWith("#") }
.toSet()
}
fun parseProps(raw: String): Map<String, String> =
raw
.lines()
@ -451,13 +475,17 @@ internal fun loadDashboardState(
}
VpnHideLog.i(TAG, "ports: $ports")
// Recommendation based purely on the kernel — used both by the install
// card (only shown when nothing's installed) and by the "kmod-capable
// kernel, only zygisk installed" warning (fires even after install).
val kernelRecommendation = buildNativeInstallRecommendation()
val nativeInstallRecommendation =
if (kmod is ModuleState.NotInstalled && zygisk is ModuleState.NotInstalled) {
buildNativeInstallRecommendation()
kernelRecommendation
} else {
null
}
VpnHideLog.i(TAG, "nativeInstallRecommendation=$nativeInstallRecommendation")
VpnHideLog.i(TAG, "nativeInstallRecommendation=$nativeInstallRecommendation kernelRec=$kernelRecommendation")
// lsposed hook status
val (_, hookStatusRaw) = suExec("cat ${HookEntry.HOOK_STATUS_FILE} 2>/dev/null || true")
@ -529,71 +557,138 @@ internal fun loadDashboardState(
// ── Issues ──
val hasNative = kmod is ModuleState.Installed || zygisk is ModuleState.Installed
if (!hasNative) {
issues += res.getString(R.string.dashboard_issue_no_native)
err(res.getString(R.string.dashboard_issue_no_native))
}
if (lsposedFramework is LsposedFramework.NotInstalled && lsposed !is LsposedState.Active) {
issues += res.getString(R.string.dashboard_issue_lsposed_not_installed)
err(res.getString(R.string.dashboard_issue_lsposed_not_installed))
}
if (lsposed is LsposedState.NeedsReboot) {
issues += res.getString(R.string.dashboard_issue_reboot)
err(res.getString(R.string.dashboard_issue_reboot))
}
// Only report LSPosed config issues when hooks are not already active at runtime —
// if hooks are active, the config is clearly working regardless of what we detect on disk
if (lsposed !is LsposedState.Active) {
when (lsposedConfig) {
null -> {
issues += res.getString(R.string.dashboard_issue_lsposed_config_unreadable)
err(res.getString(R.string.dashboard_issue_lsposed_config_unreadable))
}
LsposedConfig.ModuleNotConfigured -> {
if (lsposedFramework is LsposedFramework.Installed) {
issues += res.getString(R.string.dashboard_issue_lsposed_not_enabled)
err(res.getString(R.string.dashboard_issue_lsposed_not_enabled))
}
}
LsposedConfig.Disabled -> {
issues += res.getString(R.string.dashboard_issue_lsposed_not_enabled)
err(res.getString(R.string.dashboard_issue_lsposed_not_enabled))
}
is LsposedConfig.Enabled -> {
if (!lsposedConfig.hasSystemFramework) {
issues += res.getString(R.string.dashboard_issue_lsposed_no_system_scope)
err(res.getString(R.string.dashboard_issue_lsposed_no_system_scope))
}
if (lsposedConfig.extraEntries.isNotEmpty()) {
issues +=
// Extra entries work, they're just cosmetic noise — warn.
warn(
res.getString(
R.string.dashboard_issue_lsposed_extra_scope,
lsposedConfig.extraEntries.map(::resolveScopeEntryLabel).joinToString(", "),
)
),
)
}
}
}
}
val appVersion = BuildConfig.VERSION_NAME
// Version mismatches are warnings — modules keep working, user just needs to
// update the lagging side. Full coverage is not affected by a patch-level gap.
if (kmod is ModuleState.Installed && kmod.version != null && normalizeVersion(kmod.version) != normalizeVersion(appVersion)) {
issues += buildModuleVersionIssue(NativeModuleKind.Kmod, kmod.version, appVersion)
warn(buildModuleVersionIssue(NativeModuleKind.Kmod, kmod.version, appVersion))
}
if (zygisk is ModuleState.Installed && zygisk.version != null && normalizeVersion(zygisk.version) != normalizeVersion(appVersion)) {
issues += buildModuleVersionIssue(NativeModuleKind.Zygisk, zygisk.version, appVersion)
warn(buildModuleVersionIssue(NativeModuleKind.Zygisk, zygisk.version, appVersion))
}
if (ports is ModuleState.Installed && ports.version != null && normalizeVersion(ports.version) != normalizeVersion(appVersion)) {
issues += buildModuleVersionIssue(NativeModuleKind.Ports, ports.version, appVersion)
warn(buildModuleVersionIssue(NativeModuleKind.Ports, ports.version, appVersion))
}
val totalTargets = lsposedTargetCount + kmodTargetCount + zygiskTargetCount
if (totalTargets == 0) {
issues += res.getString(R.string.dashboard_issue_no_targets)
err(res.getString(R.string.dashboard_issue_no_targets))
}
if (ports is ModuleState.Installed && ports.targetCount == 0) {
issues += res.getString(R.string.dashboard_issue_ports_no_observers)
warn(res.getString(R.string.dashboard_issue_ports_no_observers))
}
if (lsposed is LsposedState.Active) {
val runningVersion = lsposed.version
if (runningVersion != null && runningVersion != appVersion) {
VpnHideLog.w(TAG, "version mismatch: running=$runningVersion app=$appVersion")
issues += res.getString(R.string.dashboard_issue_version_mismatch, runningVersion, appVersion)
warn(res.getString(R.string.dashboard_issue_version_mismatch, runningVersion, appVersion))
}
}
// ── Warnings: suboptimal-but-working setups ──
// W1: kernel supports kmod, but user only installed zygisk. Zygisk is
// in-process and theoretically detectable by anti-tamper; kmod is strictly
// less fingerprinted when available.
if (kernelRecommendation?.preferKmod == true &&
zygisk is ModuleState.Installed &&
kmod is ModuleState.NotInstalled
) {
warn(
res.getString(
R.string.dashboard_issue_kmod_capable_but_zygisk,
kernelRecommendation.recommendedArtifact,
),
)
}
// W2: kmod and zygisk both active simultaneously — redundant native hooks,
// larger fingerprint surface for anti-tamper SDKs (more hooked libc
// entrypoints in /proc/self/maps). Zygisk is meant as a fallback only.
if (kmod is ModuleState.Installed &&
kmod.active &&
zygisk is ModuleState.Installed &&
zygisk.active
) {
warn(res.getString(R.string.dashboard_issue_both_native_active))
}
// W3: same package configured as a Tun target AND a Ports observer.
// Common trap for users with transparent-proxy setups (Clash/sing-box): the
// proxy client redirects the target app's traffic to 127.0.0.1:<proxy>, and
// our vpnhide_out chain REJECTs because destination is loopback with a
// matching observer UID — the target app "has no internet".
val targetsUnion =
(
readTargetPackageSet(KMOD_TARGETS) +
readTargetPackageSet(ZYGISK_TARGETS) +
readTargetPackageSet(LSPOSED_TARGETS)
) - selfPkg
val observersSet = readTargetPackageSet(PORTS_OBSERVERS_FILE) - selfPkg
val targetObserverOverlap = (targetsUnion intersect observersSet).sorted()
if (targetObserverOverlap.isNotEmpty() && ports is ModuleState.Installed) {
val labels = targetObserverOverlap.map { resolveScopeEntryLabel(it) }.joinToString(", ")
warn(res.getString(R.string.dashboard_issue_target_observer_conflict, labels))
}
// W4: user has debug logging turned on — VPN Hide is writing verbose lines
// to logcat that a forensic reader with root can see. The flag file is
// written by the Diagnostics → Debug logging toggle (separate PR); absent
// file ⇒ default off ⇒ no warning.
val (debugEnabledExit, debugEnabledRaw) = suExec("cat /data/system/vpnhide_debug_logging 2>/dev/null")
if (debugEnabledExit == 0 && debugEnabledRaw.trim() == "1") {
warn(res.getString(R.string.dashboard_issue_debug_logging_on))
}
// W5: SELinux Permissive exposes six detection vectors we rely on SELinux
// to block (RTM_GETROUTE, /proc/net/{tcp,tcp6,udp,udp6,dev,fib_trie},
// /sys/class/net). See the coverage table in the top-level README.
val (_, getenforce) = suExec("getenforce 2>/dev/null")
if (getenforce.trim().equals("Permissive", ignoreCase = true)) {
warn(res.getString(R.string.dashboard_issue_selinux_permissive))
}
// ── Protection checks ──
val vpnActive = isVpnActiveSync()
VpnHideLog.i(TAG, "vpnActive=$vpnActive selfNeedsRestart=$selfNeedsRestart")

View file

@ -134,19 +134,24 @@ fun DashboardScreen(
}
}
// Issues
if (s.issues.isNotEmpty()) {
// Issues — split by severity. Errors first (user attention), then
// warnings (working-but-suboptimal). Sections hide themselves when
// empty so the Dashboard stays short on a healthy setup.
val errors = s.issues.filter { it.severity == IssueSeverity.ERROR }
val warnings = s.issues.filter { it.severity == IssueSeverity.WARNING }
if (errors.isNotEmpty()) {
Spacer(Modifier.height(20.dp))
Text(
text = stringResource(R.string.dashboard_issues, s.issues.size),
text = stringResource(R.string.dashboard_issues, errors.size),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(8.dp))
for (issue in s.issues) {
for (issue in errors) {
StatusBanner(
text = issue,
text = issue.text,
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
)
@ -154,6 +159,25 @@ fun DashboardScreen(
}
}
if (warnings.isNotEmpty()) {
Spacer(Modifier.height(20.dp))
Text(
text = stringResource(R.string.dashboard_warnings, warnings.size),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.tertiary,
)
Spacer(Modifier.height(8.dp))
for (issue in warnings) {
StatusBanner(
text = issue.text,
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
)
Spacer(Modifier.height(6.dp))
}
}
Spacer(Modifier.height(16.dp))
}
}

View file

@ -0,0 +1,120 @@
package dev.okhsunrog.vpnhide
import android.content.Context
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
/**
* Collapsible help card. Always visible at the top of a screen, starts
* expanded for new users, remembers the collapsed state in
* SharedPreferences keyed by [prefKey] so it stays collapsed across
* launches once the user has read it.
*
* Replaces the old `?`-icon AlertDialog pattern that users in practice
* don't discover the first real UI telemetry we had was bug reports
* from people who'd never opened the help because they had no reason to
* suspect it existed.
*/
private const val ACCORDION_PREFS = "vpnhide_prefs"
private const val ACCORDION_KEY_PREFIX = "help_collapsed_"
@Composable
fun HelpAccordion(
prefKey: String,
title: String,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
val context = LocalContext.current
val prefs =
remember { context.getSharedPreferences(ACCORDION_PREFS, Context.MODE_PRIVATE) }
val fullKey = "$ACCORDION_KEY_PREFIX$prefKey"
var collapsed by rememberSaveable { mutableStateOf(prefs.getBoolean(fullKey, false)) }
val chevronRotation by animateFloatAsState(
targetValue = if (collapsed) 180f else 0f,
label = "accordionChevron",
)
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
modifier = modifier.fillMaxWidth(),
) {
Column {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable {
collapsed = !collapsed
prefs.edit().putBoolean(fullKey, collapsed).apply()
}.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Outlined.Info,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(10.dp))
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
Icon(
Icons.Default.KeyboardArrowUp,
contentDescription = null,
modifier = Modifier.rotate(chevronRotation),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
AnimatedVisibility(visible = !collapsed) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
content = content,
)
}
}
}
}

View file

@ -8,7 +8,6 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.HelpOutline
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Clear
@ -94,7 +93,6 @@ private fun MainScreen() {
var showSystem by remember { mutableStateOf(false) }
var showRussianOnly by remember { mutableStateOf(false) }
var showFilterMenu by remember { mutableStateOf(false) }
var showHelp by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
selfNeedsRestart =
@ -195,13 +193,6 @@ private fun MainScreen() {
)
}
}
IconButton(onClick = { showHelp = true }) {
Icon(
Icons.AutoMirrored.Filled.HelpOutline,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
},
)
@ -252,8 +243,6 @@ private fun MainScreen() {
searchQuery = searchQuery,
showSystem = showSystem,
showRussianOnly = showRussianOnly,
showHelp = showHelp,
onDismissHelp = { showHelp = false },
modifier = Modifier.padding(innerPadding),
)
}

View file

@ -25,10 +25,7 @@ import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@ -38,7 +35,6 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -189,6 +185,29 @@ fun PortsHidingScreen(
state = listState,
modifier = Modifier.fillMaxSize(),
) {
item {
Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
HelpAccordion(
prefKey = "apps_ports",
title = stringResource(R.string.ports_help_title),
) {
Text(
text = stringResource(R.string.ports_hint_role),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.ports_hint_safe),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(R.string.ports_hint_reboot),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
items(filteredApps, key = { it.packageName }) { app ->
PortsAppRow(
app = app,
@ -341,38 +360,6 @@ private fun buildPortsSaveCommand(
).joinToString(" && ")
}
@Composable
fun PortsHidingHelpDialog(onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.ports_help_title)) },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = stringResource(R.string.ports_hint_role),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.ports_hint_safe),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(R.string.ports_hint_reboot),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("OK") }
},
)
}
@Composable
private fun NotInstalledCard(modifier: Modifier = Modifier) {
Box(

View file

@ -1,11 +1,9 @@
package dev.okhsunrog.vpnhide
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
@ -15,7 +13,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@ -27,20 +24,10 @@ fun ProtectionScreen(
searchQuery: String,
showSystem: Boolean,
showRussianOnly: Boolean,
showHelp: Boolean,
onDismissHelp: () -> Unit,
modifier: Modifier = Modifier,
) {
var mode by rememberSaveable { mutableStateOf(ProtectionMode.VpnTargets) }
if (showHelp) {
when (mode) {
ProtectionMode.VpnTargets -> AppsHelpDialog(onDismiss = onDismissHelp)
ProtectionMode.AppHiding -> AppHidingHelpDialog(onDismiss = onDismissHelp)
ProtectionMode.PortHiding -> PortsHidingHelpDialog(onDismiss = onDismissHelp)
}
}
Column(modifier = modifier.fillMaxSize()) {
ProtectionModeSwitcher(
mode = mode,

View file

@ -78,7 +78,13 @@
<string name="dashboard_install_recommendation_kmod">Рекомендуется модуль ядра. Скачайте %1$s.</string>
<string name="dashboard_install_recommendation_zygisk">Для вашего устройства модуль ядра не поддерживается. Установите %1$s.</string>
<string name="dashboard_install_recommendation_zygisk_warning">Некоторые приложения определяют Zygisk-хуки. Для таких приложений отключите Zygisk-защиту и используйте другие методы защиты.</string>
<string name="dashboard_issues">Проблемы (%d)</string>
<string name="dashboard_issues">Ошибки (%d)</string>
<string name="dashboard_warnings">Предупреждения (%d)</string>
<string name="dashboard_issue_kmod_capable_but_zygisk">Ваше ядро поддерживает модуль ядра — он незаметнее чем Zygisk. Установите %1$s и удалите Zygisk-модуль.</string>
<string name="dashboard_issue_both_native_active">Модуль ядра и Zygisk активны одновременно. Zygisk — запасной вариант для ядер без поддержки kmod; одновременная работа обоих дублирует хуки и увеличивает fingerprint. Удалите Zygisk-модуль.</string>
<string name="dashboard_issue_target_observer_conflict">Эти приложения отмечены и в Туннелях, и как Observers портов: %1$s. Если вы используете прозрачный прокси (Clash, sing-box, NekoBox), который заворачивает трафик на 127.0.0.1, эти приложения потеряют интернет. Уберите их из Observers или из Туннелей.</string>
<string name="dashboard_issue_debug_logging_on">Отладочные логи включены. VPN Hide пишет подробные строки в logcat, которые может прочитать любой с root-доступом. Выключите переключатель в Диагностике после сбора багрепорта.</string>
<string name="dashboard_issue_selinux_permissive">SELinux в режиме Permissive. Шесть векторов детекции, которые VPN Hide рассчитывает на блокировку ядром (RTM_GETROUTE, /proc/net/{tcp,udp,dev,fib_trie}, /sys/class/net), стали доступны целевым приложениям. Выполните `setenforce 1` и разберитесь, что его переключило.</string>
<string name="dashboard_issue_no_native">Нативный модуль не установлен. Установите kmod или zygisk для полной защиты.</string>
<string name="dashboard_issue_lsposed_not_installed">LSPosed/Vector не установлен. Установите его и включите модуль VPN Hide для System Framework.</string>
<string name="dashboard_issue_reboot">LSPosed настроен правильно, но его хуки ещё не активны. Перезагрузите устройство, чтобы активировать их.</string>

View file

@ -111,7 +111,13 @@
<string name="dashboard_install_recommendation_kmod">Kernel module is recommended. Download %1$s.</string>
<string name="dashboard_install_recommendation_zygisk">Kernel module is not supported for your device. Install %1$s.</string>
<string name="dashboard_install_recommendation_zygisk_warning">Some apps can detect Zygisk hooks. For those apps, disable Zygisk protection and use other protection methods instead.</string>
<string name="dashboard_issues">Issues (%d)</string>
<string name="dashboard_issues">Errors (%d)</string>
<string name="dashboard_warnings">Warnings (%d)</string>
<string name="dashboard_issue_kmod_capable_but_zygisk">Your kernel supports the kernel module, which is stealthier than Zygisk. Install %1$s and uninstall the Zygisk module.</string>
<string name="dashboard_issue_both_native_active">Kernel module and Zygisk are both active. Zygisk is the fallback for kernels without kmod support — running both duplicates hooks and adds fingerprint. Uninstall the Zygisk module.</string>
<string name="dashboard_issue_target_observer_conflict">These apps are both Tun targets and Ports observers: %1$s. If you use a transparent-proxy client (Clash, sing-box, NekoBox) that redirects traffic to 127.0.0.1, these apps will lose their internet. Remove them from Observers or from Tun.</string>
<string name="dashboard_issue_debug_logging_on">Debug logging is enabled. VPN Hide is writing verbose lines to logcat that anyone with root can read. Turn it off in Diagnostics after you\'ve finished collecting a bug report.</string>
<string name="dashboard_issue_selinux_permissive">SELinux is Permissive. Six detection vectors VPN Hide relies on the kernel to block (RTM_GETROUTE, /proc/net/{tcp,udp,dev,fib_trie}, /sys/class/net) are reachable to target apps. Run `setenforce 1` and investigate what disabled it.</string>
<string name="dashboard_issue_no_native">No native module installed. Install kmod or zygisk for full protection.</string>
<string name="dashboard_issue_lsposed_not_installed">LSPosed/Vector is not installed. Install it and enable the VPN Hide module for System Framework.</string>
<string name="dashboard_issue_reboot">LSPosed is configured correctly, but its hooks are not active yet. Reboot the device to activate them.</string>