From 2395db891e9dfd74f05bbd7370ea8d4e17469388 Mon Sep 17 00:00:00 2001 From: okhsunrog Date: Sat, 18 Apr 2026 00:23:33 +0300 Subject: [PATCH] feat(lsposed): H+O mutex, inline help, issue severity split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 5 + lsposed/app/src/main/assets/changelog.json | 33 +++-- .../dev/okhsunrog/vpnhide/AppHidingScreen.kt | 98 +++++++------ .../dev/okhsunrog/vpnhide/AppPickerScreen.kt | 47 +++---- .../dev/okhsunrog/vpnhide/DashboardData.kt | 133 +++++++++++++++--- .../dev/okhsunrog/vpnhide/DashboardScreen.kt | 34 ++++- .../dev/okhsunrog/vpnhide/HelpAccordion.kt | 120 ++++++++++++++++ .../dev/okhsunrog/vpnhide/MainActivity.kt | 11 -- .../okhsunrog/vpnhide/PortsHidingScreen.kt | 59 +++----- .../dev/okhsunrog/vpnhide/ProtectionScreen.kt | 13 -- .../app/src/main/res/values-ru/strings.xml | 8 +- lsposed/app/src/main/res/values/strings.xml | 8 +- 12 files changed, 406 insertions(+), 163 deletions(-) create mode 100644 lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/HelpAccordion.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index c9c80ed..b2fdec2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lsposed/app/src/main/assets/changelog.json b/lsposed/app/src/main/assets/changelog.json index c6bf4bd..f881a21 100644 --- a/lsposed/app/src/main/assets/changelog.json +++ b/lsposed/app/src/main/assets/changelog.json @@ -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 при первом запуске." } ] } diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AppHidingScreen.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AppHidingScreen.kt index 89137d9..a16a9ce 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AppHidingScreen.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AppHidingScreen.kt @@ -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, diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AppPickerScreen.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AppPickerScreen.kt index c94f754..00c57f5 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AppPickerScreen.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AppPickerScreen.kt @@ -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, 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 c973aa1..f37de05 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardData.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardData.kt @@ -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, + val issues: List, ) internal data class NativeInstallRecommendation( @@ -124,10 +131,18 @@ internal fun loadDashboardState( context: android.content.Context, selfNeedsRestart: Boolean, ): DashboardState { - val issues = mutableListOf() + val issues = mutableListOf() 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 { + 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 = 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:, 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") diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardScreen.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardScreen.kt index 694459d..be1895f 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardScreen.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardScreen.kt @@ -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)) } } diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/HelpAccordion.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/HelpAccordion.kt new file mode 100644 index 0000000..3db5715 --- /dev/null +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/HelpAccordion.kt @@ -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, + ) + } + } + } +} diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/MainActivity.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/MainActivity.kt index 3f1937c..dbf8f36 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/MainActivity.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/MainActivity.kt @@ -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), ) } diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/PortsHidingScreen.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/PortsHidingScreen.kt index f70a6f7..fc68a76 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/PortsHidingScreen.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/PortsHidingScreen.kt @@ -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( diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/ProtectionScreen.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/ProtectionScreen.kt index 8939ba6..bacdeb9 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/ProtectionScreen.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/ProtectionScreen.kt @@ -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, diff --git a/lsposed/app/src/main/res/values-ru/strings.xml b/lsposed/app/src/main/res/values-ru/strings.xml index 2b8b790..4008f52 100644 --- a/lsposed/app/src/main/res/values-ru/strings.xml +++ b/lsposed/app/src/main/res/values-ru/strings.xml @@ -78,7 +78,13 @@ Рекомендуется модуль ядра. Скачайте %1$s. Для вашего устройства модуль ядра не поддерживается. Установите %1$s. Некоторые приложения определяют Zygisk-хуки. Для таких приложений отключите Zygisk-защиту и используйте другие методы защиты. - Проблемы (%d) + Ошибки (%d) + Предупреждения (%d) + Ваше ядро поддерживает модуль ядра — он незаметнее чем Zygisk. Установите %1$s и удалите Zygisk-модуль. + Модуль ядра и Zygisk активны одновременно. Zygisk — запасной вариант для ядер без поддержки kmod; одновременная работа обоих дублирует хуки и увеличивает fingerprint. Удалите Zygisk-модуль. + Эти приложения отмечены и в Туннелях, и как Observers портов: %1$s. Если вы используете прозрачный прокси (Clash, sing-box, NekoBox), который заворачивает трафик на 127.0.0.1, эти приложения потеряют интернет. Уберите их из Observers или из Туннелей. + Отладочные логи включены. VPN Hide пишет подробные строки в logcat, которые может прочитать любой с root-доступом. Выключите переключатель в Диагностике после сбора багрепорта. + SELinux в режиме Permissive. Шесть векторов детекции, которые VPN Hide рассчитывает на блокировку ядром (RTM_GETROUTE, /proc/net/{tcp,udp,dev,fib_trie}, /sys/class/net), стали доступны целевым приложениям. Выполните `setenforce 1` и разберитесь, что его переключило. Нативный модуль не установлен. Установите kmod или zygisk для полной защиты. LSPosed/Vector не установлен. Установите его и включите модуль VPN Hide для System Framework. LSPosed настроен правильно, но его хуки ещё не активны. Перезагрузите устройство, чтобы активировать их. diff --git a/lsposed/app/src/main/res/values/strings.xml b/lsposed/app/src/main/res/values/strings.xml index a02f367..5ed9e88 100644 --- a/lsposed/app/src/main/res/values/strings.xml +++ b/lsposed/app/src/main/res/values/strings.xml @@ -111,7 +111,13 @@ Kernel module is recommended. Download %1$s. Kernel module is not supported for your device. Install %1$s. Some apps can detect Zygisk hooks. For those apps, disable Zygisk protection and use other protection methods instead. - Issues (%d) + Errors (%d) + Warnings (%d) + Your kernel supports the kernel module, which is stealthier than Zygisk. Install %1$s and uninstall the Zygisk module. + 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. + 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. + 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. + 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. No native module installed. Install kmod or zygisk for full protection. LSPosed/Vector is not installed. Install it and enable the VPN Hide module for System Framework. LSPosed is configured correctly, but its hooks are not active yet. Reboot the device to activate them.