From 92c7d49e5f0e2794bd0f7c138a8b3f49fccc35be Mon Sep 17 00:00:00 2001 From: Danila Gornushko Date: Fri, 9 Oct 2026 21:23:38 +0000 Subject: [PATCH] Fix v1 policy caching and diagnostic reporting --- changelog.d/fixed-v1-policy-cache-30f8.md | 9 + docs/debug-bundle.md | 23 +- docs/help/en/configure-hiding.md | 6 + docs/help/ru/configure-hiding.md | 6 + docs/help/zh/configure-hiding.md | 5 + docs/notes/v1-improvements-status.md | 36 +++ docs/state.md | 25 +- lsposed/AGENTS.md | 6 +- .../okhsunrog/vpnhide/AgentControlBridge.kt | 69 +---- .../dev/okhsunrog/vpnhide/AgentHttpData.kt | 89 ++++++ .../okhsunrog/vpnhide/AtomicTelemetryData.kt | 19 ++ .../okhsunrog/vpnhide/CanonicalConfigData.kt | 288 ++++++++++++++++++ .../dev/okhsunrog/vpnhide/DashboardData.kt | 12 +- .../okhsunrog/vpnhide/DashboardIssueData.kt | 185 +++++++++++ .../dev/okhsunrog/vpnhide/DashboardScreen.kt | 9 +- .../dev/okhsunrog/vpnhide/HookMaskData.kt | 44 +++ .../dev/okhsunrog/vpnhide/HookRegistry.kt | 41 --- .../kotlin/dev/okhsunrog/vpnhide/LogTags.kt | 3 +- .../dev/okhsunrog/vpnhide/LsposedState.kt | 19 +- .../dev/okhsunrog/vpnhide/StorageConfig.kt | 286 ----------------- .../okhsunrog/vpnhide/debug/DebugExport.kt | 39 ++- .../okhsunrog/vpnhide/debug/VpnHideState.kt | 24 +- .../vpnhide/diagnostics/DiagnosticsScreen.kt | 66 +++- .../dev/okhsunrog/vpnhide/hook/HookEntry.kt | 1 - .../dev/okhsunrog/vpnhide/hook/HookLog.kt | 3 +- .../vpnhide/hook/SystemServerConfigCache.kt | 160 ++++------ .../vpnhide/hook/SystemServerConfigData.kt | 24 ++ .../vpnhide/hook/SystemServerPolicyCache.kt | 105 +++++++ .../okhsunrog/vpnhide/picker/AppPickerData.kt | 15 + .../vpnhide/picker/AppPickerScreen.kt | 4 + .../vpnhide/picker/TargetPickerScaffold.kt | 16 +- .../settings/FilesystemHidingSettings.kt | 6 +- .../settings/HiddenAppsSettingsScreen.kt | 8 +- .../vpnhide/settings/SettingsScreen.kt | 16 +- .../okhsunrog/vpnhide/startup/MainActivity.kt | 19 +- .../vpnhide/statistics/StatisticsScreen.kt | 8 +- .../vpnhide/ui/components/AppSearchTopBar.kt | 4 +- .../app/src/main/res/values-ru/strings.xml | 6 + .../src/main/res/values-zh-rCN/strings.xml | 6 + lsposed/app/src/main/res/values/strings.xml | 6 + .../dev/okhsunrog/vpnhide/AgentHttpTest.kt | 47 +++ .../okhsunrog/vpnhide/AtomicTelemetryTest.kt | 48 +++ .../vpnhide/BundleSchemaGoldenTest.kt | 26 ++ .../vpnhide/DashboardIssueReasonTest.kt | 28 ++ .../vpnhide/HookPackageBoundaryTest.kt | 98 +++++- .../hook/SystemServerConfigDataTest.kt | 70 +++++ .../hook/SystemServerPolicyCacheTest.kt | 274 +++++++++++++++++ .../vpnhide/picker/SharedAppIdTest.kt | 24 ++ .../test/resources/bundle/state_golden.json | 66 ++++ 49 files changed, 1814 insertions(+), 583 deletions(-) create mode 100644 changelog.d/fixed-v1-policy-cache-30f8.md create mode 100644 docs/notes/v1-improvements-status.md create mode 100644 lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AgentHttpData.kt create mode 100644 lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AtomicTelemetryData.kt create mode 100644 lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/CanonicalConfigData.kt create mode 100644 lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardIssueData.kt create mode 100644 lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/HookMaskData.kt create mode 100644 lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/hook/SystemServerConfigData.kt create mode 100644 lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/hook/SystemServerPolicyCache.kt create mode 100644 lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/AgentHttpTest.kt create mode 100644 lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/AtomicTelemetryTest.kt create mode 100644 lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/DashboardIssueReasonTest.kt create mode 100644 lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/hook/SystemServerConfigDataTest.kt create mode 100644 lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/hook/SystemServerPolicyCacheTest.kt create mode 100644 lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/picker/SharedAppIdTest.kt diff --git a/changelog.d/fixed-v1-policy-cache-30f8.md b/changelog.d/fixed-v1-policy-cache-30f8.md new file mode 100644 index 00000000..aeb4af38 --- /dev/null +++ b/changelog.d/fixed-v1-policy-cache-30f8.md @@ -0,0 +1,9 @@ +_2026-10-10_ + +## English + +Fixed shared-app-ID Java hook selection and policy-cache races. Temporary policy read failures retain the last successful policy and report an error; LSPosed telemetry keeps complete snapshots on write failure. Debug reports include the capture Dashboard, state before capture and stable issue reasons, and the export screen explains partial and failed results. Added accessible search labels, lifecycle-aware screen subscriptions and bounded agent HTTP requests. + +## Русский + +Исправлены выбор Java-хуков при общем app ID и гонки кеша политики. Временная ошибка чтения сохраняет последнюю успешную политику и отражается в диагностике; телеметрия LSPosed сохраняет полный снимок при ошибке записи. Отчёт включает Дашборд из данных захвата, состояние до сбора и стабильные причины предупреждений; экран экспорта объясняет частичный и неудачный результат. Добавлены доступные подписи поиска, подписки экранов с учётом lifecycle и лимиты HTTP-запросов агента. diff --git a/docs/debug-bundle.md b/docs/debug-bundle.md index 62cae89d..d3281043 100644 --- a/docs/debug-bundle.md +++ b/docs/debug-bundle.md @@ -107,7 +107,8 @@ renders*, so the bundle can't disagree with what the user saw on screen. | `activeBackend` | The **one** native backend in charge (`id` + `state`). Priority kmod > builtin > KPM > zygisk; a live `/proc/vpnhide_ctl` decides between the two kernel backends by the `backend` id it reports. | | `ports` | The portshide module `ModuleState` (localhost port blocker). | | `kmodLoadStatus` | Boot-time `.ko` load result (uname, kprobes/kretprobes ok, insmod exit, dmesg tail, `filesystemHiding`). The richest single "did the kernel module come up" field. | -| `dashboard` | Full live dashboard model. **`null` in file exports** (only the agent-bridge `getState` fills it). | +| `dashboard` | Dashboard derived from the capture root snapshot and one saved diagnostic presentation, shared with `diagnostics`. File exports and agent bridge use the same builder. A failed dashboard assembly leaves this null, appends a reason to `errors`, and preserves the rest of the archive. | +| `beforeCapture` | Optional available process state before enabling capture logging or starting checks: observation time, diagnostic summary, retained Dashboard protection/messages, pending operation IDs, recovery flag and last operation phases/failure. No fresh root read or diagnostic run is requested. It may be absent or use retained screen values; it is the state available at collection start, not proof of what the user previously saw. | | `config` | The canonical desired-state config as structured JSON (`/data/system/vpnhide_config.json`) — answers "is app X even a target, with which roles". | | `statistics` | Point-in-time hook-counter totals (per-uid/per-method hit counts). Always present in a debug export, even without forensics. | | `rootShell` | Snapshot-shell self-diagnosis (§6). The "not verified vs inactive" discriminator. | @@ -428,3 +429,23 @@ Rule of thumb: a **typed** top-level field → find where it's *derived* | Was the capture even measurable? | `gate` (§4) | | Is anything missing/cut off? | `errors`, `sections.debug_snapshot_truncated` | | What was included in this capture? | `captureOptions`, `captureKind` | + +### Export feedback and stable issue reasons + +The Collect sheet shows complete, partial and failed outcomes. Partial archives +remain available for Save/Share; the section failures are shown alongside the +result. A failed export retains its reason and can be retried. + +Each `dashboard.messages` entry keeps its existing severity, localized text, +action, download artifact and help article, and adds `reason` and +`presentationLanguage`. `reason.kind` is an explicit stable snake_case identifier; +`reason.parameters` is a map of named string values (versions, components, counts, +details; list-valued parameters use comma-separated stable package/hook names). +The same `DashboardIssue` that produces the UI message produces its reason. No +export-specific detection rules exist. For example `module_version_mismatch` +carries `component`, `moduleVersion` and `appVersion` regardless of RU/EN wording. + +New reason identifiers and optional parameters are additive. Do not rename a +published identifier, repurpose a parameter or infer it from localized text; +changes of meaning follow the existing schema-bump rule. These fields and +`beforeCapture` are additive within schema 2 and are covered by the bundle golden. diff --git a/docs/help/en/configure-hiding.md b/docs/help/en/configure-hiding.md index e0608f0b..05e57b4b 100644 --- a/docs/help/en/configure-hiding.md +++ b/docs/help/en/configure-hiding.md @@ -95,6 +95,12 @@ custom per-hook settings. Ready? [Open the Hiding tab](vpnhide://hiding) and pick the app to configure. +Packages sharing an Android app ID also share the effective Java settings in +all profiles: enabled Java hooks from each package are combined. Turning Java +off for one package does not disable hooks selected by another package in the +group. To disable a hook for the group, deselect it in every package. The Java +hook dialog shows a notice when the app inventory identifies a shared app ID. + ## Row, role chips and custom hooks Tapping an unselected app row enables Java and Apps, plus Native and Ports when diff --git a/docs/help/ru/configure-hiding.md b/docs/help/ru/configure-hiding.md index eeceae92..c8f367ca 100644 --- a/docs/help/ru/configure-hiding.md +++ b/docs/help/ru/configure-hiding.md @@ -92,6 +92,12 @@ split tunneling. Готовы? [Откройте вкладку «Скрытие»](vpnhide://hiding) и выберите приложение. +Пакеты с общим Android app ID используют общие действующие настройки Java во +всех профилях: включённые хуки каждого пакета объединяются. Отключение Java у +одного пакета не отключает хуки, выбранные у другого пакета группы. Чтобы +отключить хук для всей группы, снимите его у каждого пакета. Диалог Java-хуков +показывает пояснение, когда список приложений выявляет общий app ID. + ## Строка приложения, плашки ролей и отдельные хуки Нажатие на невыбранную строку включает Java и Apps, а также Native и Ports, если diff --git a/docs/help/zh/configure-hiding.md b/docs/help/zh/configure-hiding.md index 5a638b18..d2be721a 100644 --- a/docs/help/zh/configure-hiding.md +++ b/docs/help/zh/configure-hiding.md @@ -79,6 +79,11 @@ VPN Hide 会自动识别;你也可以手动隐藏。 准备好了?[打开「隐藏」标签页](vpnhide://hiding)并选择要配置的应用。 +共享 Android 应用 ID 的软件包在所有用户配置中使用相同的有效 Java 设置:每个软件包启用的钩子会合并。 +关闭其中一个软件包的 Java 角色不会禁用其他软件包选择的钩子。 +要为整个组禁用某个钩子,请在每个软件包中取消选择它。 +应用列表识别到共享应用 ID 时,Java 钩子对话框会显示提示。 + ## 应用行、角色按钮与单独钩子 点击未选择的应用行会开启 Java 和 Apps,并在对应组件已安装时开启 Native 和 Ports。 diff --git a/docs/notes/v1-improvements-status.md b/docs/notes/v1-improvements-status.md new file mode 100644 index 00000000..2cbb6da2 --- /dev/null +++ b/docs/notes/v1-improvements-status.md @@ -0,0 +1,36 @@ +# V1 improvement checklist + +Work branch: `fix/v1-policy-cache`, based on `02bc7379ffd9240cc96a7b2b80b6c39e2170c3dd`. +The updated task is implemented in one branch. Only v1 is in scope. + +## Implementation and review + +- [x] **P1-01** — Enabled Java hook masks are unioned by appId at the Binder boundary. Disabled roles add no bits; every profile of a shared appId gets the same union. Package intent remains in canonical JSON. Inventory-detected sharing is explained in the Java dialog and all three offline-guide languages. Tests cover order, equal/different masks, disabled/empty/default roles, missing packages and profiles. +- [x] **P1-02** — Both canonical JSON and `packages.list` are fingerprinted before/after reads (device, inode, size, nanosecond mtime/ctime). Cache entries carry an atomic invalidation generation. Two attempts maximum; unstable reads retain a coherent prior value and retry on the next call. Tests drive file replacement, missed watcher events, invalidation during I/O and immediately before publication, and continuous changes. A watcher-thread test verifies invalidation does not wait for the reader lock. +- [x] **P2-03** — Typed read results distinguish errors from a successful empty policy. Errors retain last-good (empty at startup), publish an LSPosed metadata error and retry after one second without requiring a metadata change. Valid empty JSON or a removed canonical file clears policy. Recovery clears the error, including stat recovery without changed metadata. Tests cover these cache transitions; actual Android file-permission/SELinux scenarios remain device checks. +- [x] **P2-04** — Close-search and clear-query buttons have EN/RU/ZH accessible names. Resource compilation and lint pass; TalkBack focus/navigation remain device checks. +- [x] **P2-05** — Telemetry uses atomic same-directory rename only. Failure preserves the old complete file and in-memory counters, then schedules another flush. Tests verify a failed replacement/recovery and an open reader retaining the complete old inode across replacement. Actual target permissions/SELinux remain device checks. +- [x] **P3-06** — Screen subscription audit completed. Screen state collection uses lifecycle-aware APIs; the existing RESUMED-owned app-VPN poller and process-owned observation/config/diagnostic coordinators remain intact. Operation feedback subscriptions remain active to handle results of accepted work. Statistics-session polling intentionally continues in the background while the user exercises another app. Activity recreation/tab/background scenarios remain device checks. +- [x] **P3-07** — Import scans recurse through hook and app packages. Reviewed shared sources are checked for UI imports, unreviewed project imports and references to app-only declarations. Nested-source fixtures cover the recursive scan. Shared config parsing/model and hook-mask vocabulary are separated from app storage/report helpers. This is a source-level guard, not a separate Kotlin compile module or proof of reflective/third-party dependency isolation. +- [x] **P3-08** — Responsibilities reviewed. The boundary-driven extraction into `CanonicalConfigData` and `HookMaskData` has a concrete benefit; large screens and existing classifier tables were not split solely for line counts. Architecture guidance was updated in `lsposed/AGENTS.md`. +- [ ] **P3-09** — Device visual check pending: small display, landscape, large font, keyboard/bottom-sheet actions and reduced animations. No Android device was attached (`adb devices -l` returned an empty list). No density defect is claimed from source inspection. +- [x] **P3-10** — HTTP receives at most 32 KiB of request-head bytes, 64 headers, 8 KiB per line and 256 KiB of body, with one five-second monotonic deadline shared by head/body. Socket timeout follows the remaining budget. Receiving ends before operation dispatch, so this does not cancel an accepted root operation. Tests cover repeated/oversized headers, carriage-return inflation, slow reads, body deadline and a subsequent normal request. +- [x] **P2-11** — File exports derive Dashboard from the capture root snapshot and one saved diagnostic presentation, also used by the summary. Capture-owned gate, evidence and run ID remain unchanged. Dashboard assembly failure appends a section error and leaves the rest of the bundle available. Compilation, existing run/outcome tests and bundle golden validate contracts; full root capture remains a device check. +- [x] **P2-12** — The export sheet retains the full outcome and explains complete, partial and failed results in EN/RU/ZH. Partial archives keep Save/Share; failures retain their reason and retry path. Resource compilation/lint pass; interaction checks remain pending on device. +- [x] **P3-13** — Best-effort `beforeCapture` records available process state before logging/probes: timestamp, diagnostic summary, retained protection/messages, pending operation IDs and last phase/failure state. It performs no fresh root read and does not claim to be a previously viewed screen. It never blocks collection. Bundle golden covers the structure. +- [x] **P3-14** — Dashboard messages carry an explicit stable reason/parameter DTO and presentation language alongside existing text/severity/actions/help. Both file export and agent bridge consume the same Dashboard builder/classifier. Tests pin version/component parameters independent of translated text. Contract and golden are updated; additions remain within schema 2 under the existing compatibility policy. + +## Verification + +Run from the repository root with the configured Android/JDK/Rust environment: + +```sh +ktlint 'lsposed/**/*.kt' +./lsposed/gradlew -p lsposed :app:testDebugUnitTest :app:detekt cpdCheck \ + :app:lintDebug :app:assembleDebug -PvpnhideWarningsAsErrors=true +``` + +All checks above passed; 844 JVM tests completed with zero failures or skips. + +Runtime claims are limited to JVM tests and successful APK compilation/packaging. +No APK, TalkBack, LSPosed or root/SELinux scenario was executed on a device. diff --git a/docs/state.md b/docs/state.md index d8707deb..8d268215 100644 --- a/docs/state.md +++ b/docs/state.md @@ -307,8 +307,29 @@ sanitizer for that app; `java: ["hook_name", ...]` enables only the named LSPosed Java hooks for the app. App-hiding package visibility is controlled by `appHiding`, not by the Java VPN hook list. -Inotify watches `/data/system/vpnhide_config.json`; package reinstall UID/appId -changes are picked up by a periodic fingerprint of `/data/system/packages.list`. +Packages sharing an appId use the **union** of their enabled Java hook lists. +A disabled Java role (or empty list) contributes no hooks; it does not veto +another package's selection. Binder cannot distinguish those packages at the +UID boundary, so the union applies to the whole group in every profile. Canonical +JSON still stores each package's intent. The Java hook dialog explains this when +the package inventory identifies a shared appId; the offline guide describes it +regardless of inventory availability. + +Inotify invalidates the cache on canonical-config changes. Independently, a +one-second poll fingerprints **both** canonical JSON and `packages.list`, including +device/inode, size and nanosecond modification/change times. A load compares both +fingerprints before and after reading, and publishes only under the same +invalidation generation. A late invalidation cannot be overwritten by a reader. +Each call attempts at most two reads. If the sources keep changing (or cannot be +statted), it returns the previous coherent snapshot, or an empty fail-open policy +at startup; it leaves the cache expired so the next call retries. A stable missing +canonical file or explicit empty config replaces the previous policy. Read/parse errors retain the last successful policy (or fail open at startup) +and trigger another read after one second even if metadata did not change. A +blank, malformed or unsupported JSON file is an error; intentional reset is a +valid empty canonical object or removal of the canonical file. LSPosed telemetry +exports `policy_read_status=error` and `policy_read_error` until reading succeeds. +Telemetry uses same-directory atomic rename only; replacement failure preserves +the previous complete file and schedules another write without clearing counters. --- diff --git a/lsposed/AGENTS.md b/lsposed/AGENTS.md index 31922ff9..3f724aae 100644 --- a/lsposed/AGENTS.md +++ b/lsposed/AGENTS.md @@ -23,7 +23,7 @@ model together: | `ui/`, `checks/`, `generated/` | design system, the JNI probe binding, codegen | What stays in the root package is the shared vocabulary both sides use — -`StorageConfig`, `ShellUtils`, `RootSnapshotCache`, `HookRegistry`, +`CanonicalConfigData`, `StorageConfig`, `ShellUtils`, `RootSnapshotCache`, `HookMaskData`, `HookRegistry`, `DashboardData`, the agent bridge. Moving those buys import churn and nothing else; they belong to no single feature. @@ -54,7 +54,7 @@ The single most important thing about this module: `hook/` is loaded by LSPosed **into `system_server`**; everything else runs in the app process. So `hook/` carries no Compose, no Activity, no app resources — and nothing outside it may touch `de.robv.android.xposed` (absent in the app process). The shared vocabulary -they both use (canonical-config parsing, `HookRegistry`, `LsposedStats`, +they both use (canonical-config parsing in `CanonicalConfigData`, mask vocabulary in `HookMaskData`, `LsposedStats`, `LogTags`) stays in the root package. `HookPackageBoundaryTest` enforces both directions, since `internal` is module-wide and the compiler will not. @@ -132,7 +132,7 @@ directions, since `internal` is module-wide and the compiler will not. or a pure transform of the fresh config; never save a cached full snapshot or call a config/activation shell through `suExec`. Root outcome can be unknown: preserve phase evidence and let the coordinator reconcile it. -- **`StorageConfig` / `ShellCommandBuilders`** — canonical JSON schema, +- **`CanonicalConfigData` / `StorageConfig` / `ShellCommandBuilders`** — shared canonical JSON models/parsing, app-side storage builders, migration helpers, and root-safe file writes (`buildCanonicalConfigWriteCommand` in `StorageConfig` wraps the generic `buildAtomicSystemDataRawWriteCommand` in `ShellCommandBuilders`). The canonical JSON is the persistent diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AgentControlBridge.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AgentControlBridge.kt index 3f79c815..ecd0cadc 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AgentControlBridge.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AgentControlBridge.kt @@ -8,10 +8,8 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.serialization.SerializationException import kotlinx.serialization.encodeToString -import java.io.ByteArrayOutputStream import java.io.File import java.io.IOException -import java.io.InputStream import java.net.InetAddress import java.net.InetSocketAddress import java.net.ServerSocket @@ -29,14 +27,6 @@ private const val LOCALHOST = "127.0.0.1" // every accepted socket gets this read deadline before any byte is read. private const val SOCKET_TIMEOUT_MS = 5_000 -// Upper bound on a request body. The /call payload is a small JSON document; -// anything larger is rejected (after auth) rather than allocated. -private const val MAX_BODY_BYTES = 256 * 1024 - -// Pre-auth guard: cap a single request/header line so an unauthenticated peer -// cannot grow the line buffer without bound before we ever check the token. -private const val MAX_LINE_BYTES = 8 * 1024 - internal object AgentControlBridge { private val lock = Any() @@ -148,8 +138,8 @@ private class BridgeServer( private fun handleClient(client: Socket) { try { - val input = client.getInputStream() - val head = readRequestHead(input) + val request = AgentRequestReader(client.getInputStream(), { client.soTimeout = it }) + val head = request.readHead() if (head == null) { writeError(client, 400, "Bad Request", "Invalid HTTP request") return @@ -162,7 +152,7 @@ private class BridgeServer( writeError(client, 401, "Unauthorized", "Missing or invalid bearer token") return } - val body = readBody(input, head.headers) + val body = request.readBody(head.headers) when { head.method == "GET" && head.path == "/functions" -> { writeJson(client, 200, "OK", AgentControlDispatcher.functionsJson()) @@ -198,59 +188,6 @@ private class BridgeServer( } } -private data class RequestHead( - val method: String, - val path: String, - val headers: Map, -) - -private fun readRequestHead(input: InputStream): RequestHead? { - val requestLine = readAsciiLine(input)?.takeIf { it.isNotBlank() } ?: return null - val requestParts = requestLine.split(' ', limit = 3) - if (requestParts.size < 2) return null - - val headers = linkedMapOf() - while (true) { - val line = readAsciiLine(input) ?: return null - if (line.isEmpty()) break - val separator = line.indexOf(':') - if (separator <= 0) continue - headers[line.substring(0, separator).trim().lowercase()] = line.substring(separator + 1).trim() - } - return RequestHead(method = requestParts[0], path = requestParts[1], headers = headers) -} - -private fun readBody( - input: InputStream, - headers: Map, -): String { - val contentLength = headers["content-length"]?.toIntOrNull() ?: 0 - if (contentLength <= 0) return "" - require(contentLength <= MAX_BODY_BYTES) { "Request body too large" } - val body = ByteArray(contentLength) - var offset = 0 - while (offset < contentLength) { - val read = input.read(body, offset, contentLength - offset) - if (read == -1) throw IOException("Unexpected end of request body") - offset += read - } - return body.decodeToString() -} - -private fun readAsciiLine(input: InputStream): String? { - val out = ByteArrayOutputStream() - while (true) { - val next = input.read() - if (next == -1) return if (out.size() == 0) null else out.toString(Charsets.US_ASCII.name()) - if (next == '\n'.code) break - if (next != '\r'.code) { - require(out.size() < MAX_LINE_BYTES) { "Request header line too long" } - out.write(next) - } - } - return out.toString(Charsets.US_ASCII.name()) -} - private fun writeError( client: Socket, status: Int, diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AgentHttpData.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AgentHttpData.kt new file mode 100644 index 00000000..82ec23c2 --- /dev/null +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AgentHttpData.kt @@ -0,0 +1,89 @@ +package dev.okhsunrog.vpnhide + +import java.io.BufferedInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import java.net.SocketTimeoutException + +internal data class AgentRequestHead( + val method: String, + val path: String, + val headers: Map, +) + +/** Bounds receiving one request. The deadline ends before dispatching an accepted operation. */ +internal class AgentRequestReader( + input: InputStream, + private val setTimeout: (Int) -> Unit, + private val nanoTime: () -> Long = System::nanoTime, +) { + private val input = BufferedInputStream(input) + private val startedAt = nanoTime() + private var headBytes = 0 + + fun readHead(): AgentRequestHead? { + val request = readLine()?.takeIf { it.isNotBlank() } ?: return null + val parts = request.split(' ', limit = 3) + if (parts.size < 2) return null + val headers = linkedMapOf() + var count = 0 + while (true) { + val line = readLine() ?: return null + if (line.isEmpty()) break + require(++count <= MAX_HEADERS) { "Too many request headers" } + val separator = line.indexOf(':') + if (separator <= 0) continue + headers[line.substring(0, separator).trim().lowercase()] = line.substring(separator + 1).trim() + } + checkDeadline() + return AgentRequestHead(parts[0], parts[1], headers) + } + + fun readBody(headers: Map): String { + val length = headers["content-length"]?.toIntOrNull() ?: 0 + if (length <= 0) return "" + require(length <= MAX_BODY_BYTES) { "Request body too large" } + val body = ByteArray(length) + var offset = 0 + while (offset < length) { + checkDeadline() + val count = input.read(body, offset, length - offset) + if (count == -1) throw IOException("Unexpected end of request body") + offset += count + } + checkDeadline() + return body.decodeToString() + } + + private fun readLine(): String? { + val output = ByteArrayOutputStream() + while (true) { + checkDeadline() + val next = input.read() + if (next == -1) return null + require(++headBytes <= MAX_HEAD_BYTES) { "Request headers too large" } + if (next == '\n'.code) break + if (next != '\r'.code) { + require(output.size() < MAX_LINE_BYTES) { "Request header line too long" } + output.write(next) + } + } + return output.toString(Charsets.US_ASCII.name()) + } + + private fun checkDeadline() { + val remaining = REQUEST_TIMEOUT_NANOS - (nanoTime() - startedAt) + if (remaining <= 0) throw SocketTimeoutException("Request receive deadline exceeded") + setTimeout(((remaining + NANOS_PER_MILLI - 1) / NANOS_PER_MILLI).toInt()) + } + + private companion object { + const val NANOS_PER_MILLI = 1_000_000L + const val REQUEST_TIMEOUT_NANOS = 5_000 * NANOS_PER_MILLI + const val MAX_HEAD_BYTES = 32 * 1024 + const val MAX_HEADERS = 64 + const val MAX_LINE_BYTES = 8 * 1024 + const val MAX_BODY_BYTES = 256 * 1024 + } +} diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AtomicTelemetryData.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AtomicTelemetryData.kt new file mode 100644 index 00000000..3bcbfb7c --- /dev/null +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AtomicTelemetryData.kt @@ -0,0 +1,19 @@ +package dev.okhsunrog.vpnhide + +import java.io.File +import java.io.IOException + +/** Same-directory rename only: a failed replacement leaves the old snapshot intact. */ +internal fun writeAtomicTelemetry( + destination: File, + temporary: File, + text: String, + replace: (File, File) -> Boolean = { source, target -> source.renameTo(target) }, +) { + try { + temporary.writeText(text) + if (!replace(temporary, destination)) throw IOException("atomic telemetry replacement failed") + } finally { + temporary.delete() + } +} diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/CanonicalConfigData.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/CanonicalConfigData.kt new file mode 100644 index 00000000..fa45d9d4 --- /dev/null +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/CanonicalConfigData.kt @@ -0,0 +1,288 @@ +package dev.okhsunrog.vpnhide + +import dev.okhsunrog.vpnhide.generated.HookIds +import org.json.JSONArray +import org.json.JSONObject + +internal const val CANONICAL_CONFIG_FILE = "/data/system/vpnhide_config.json" +internal const val SUPERKEY_FILE = "/data/adb/vpnhide/superkey" +internal val OPTIONAL_FEATURE_FILESYSTEM_IFACE_PATHS = HookIds.Hook.FILESYSTEM_IFACE_PATHS.hookName + +internal data class CanonicalConfig( + val version: Int = 1, + val debug: Boolean = false, + val debugSwitch: Boolean = false, + val apps: Map = emptyMap(), + val settings: CanonicalSettings = CanonicalSettings(), +) + +internal data class CanonicalSettings( + val rememberSuperkey: Boolean = false, + val optionalFeatures: Set = emptySet(), + val autoHideVpnServices: Boolean = true, + val autoHideVpnName: Boolean = false, + val autoHideExcludedPackages: Set = emptySet(), + val autoHiddenPackages: Set = emptySet(), +) + +internal data class CanonicalApp( + val java: Boolean = false, + val javaHooks: List? = null, + val native: NativeRole = NativeRole.Disabled, + val appHiding: Boolean = false, + val ports: Boolean = false, + val portPolicy: PortPolicy? = null, + val hidden: Boolean = false, +) { + val hasAnyRole: Boolean + get() = java || native.enabled || appHiding || ports || hidden +} + +internal data class NativeRole( + val enabled: Boolean, + val overrides: NativeHookOverrides = NativeHookOverrides(), +) { + companion object { + val Disabled = NativeRole(enabled = false) + val All = NativeRole(enabled = true) + } +} + +internal enum class NativeHookFamily { + Kernel, + Kmod, + Kpm, + Zygisk, +} + +internal data class NativeHookOverrides( + val kernel: List? = null, + val zygisk: List? = null, +) { + fun hooksFor(family: NativeHookFamily): List? = + when (family) { + NativeHookFamily.Kernel, NativeHookFamily.Kmod, NativeHookFamily.Kpm -> kernel + NativeHookFamily.Zygisk -> zygisk + } + + fun withHooksFor( + family: NativeHookFamily, + hooks: List?, + ): NativeHookOverrides = + when (family) { + NativeHookFamily.Kernel, NativeHookFamily.Kmod, NativeHookFamily.Kpm -> copy(kernel = hooks) + NativeHookFamily.Zygisk -> copy(zygisk = hooks) + } + + val hasAnyOverride: Boolean + get() = kernel != null || zygisk != null +} + +private data class ParsedHookRole( + val enabled: Boolean, + val hooks: List? = null, +) + +internal val NativeKernelHookEntries: List = KERNEL_HOOKS.toList() + +internal val NativeKmodHookEntries: List = (KERNEL_HOOKS + KMOD_HOOKS).toList() + +internal val ZygiskNativeHookEntries: List = ZYGISK_HOOKS.toList() + +internal val LsposedJavaHookEntries: List = + LSPOSED_HOOKS.filter { it != HookIds.Hook.LSPOSED_PACKAGE_VISIBILITY } + +internal fun nativeHookEntriesFor(family: NativeHookFamily): List = + when (family) { + NativeHookFamily.Kernel -> NativeKernelHookEntries + NativeHookFamily.Kmod -> NativeKmodHookEntries + NativeHookFamily.Kpm -> NativeKernelHookEntries + NativeHookFamily.Zygisk -> ZygiskNativeHookEntries + } + +internal fun hookSelectionMask( + enabled: Boolean, + hooks: List?, + entries: List, +): Long { + if (!enabled) return 0L + val allMask = entries.toHookMask() + if (hooks == null) return allMask + val allowedByName = entries.associateBy { it.hookName } + return hooks.fold(0L) { acc, name -> acc or (allowedByName[name]?.bit ?: 0L) } +} + +internal fun parseCanonicalConfig(raw: String): CanonicalConfig? { + if (raw.isBlank()) return null + val root = JSONObject(raw) + val version = root.optInt("version", 1) + if (version > 1) return null + val debug = root.optBoolean("debug", false) + val debugSwitch = root.optBoolean("debugSwitch", debug) + val appsJson = root.optJSONObject("apps") + val apps = + appsJson + ?.keys() + ?.asSequence() + ?.sorted() + ?.associateWith { pkg -> parseCanonicalApp(appsJson.optJSONObject(pkg)) } + .orEmpty() + .filterValues { it.hasAnyRole } + val settingsJson = root.optJSONObject("settings") + val defaultSettings = CanonicalSettings() + return CanonicalConfig( + version = version, + debug = debug, + debugSwitch = debugSwitch, + apps = apps, + settings = + CanonicalSettings( + rememberSuperkey = settingsJson?.optBoolean("rememberSuperkey", defaultSettings.rememberSuperkey) == true, + optionalFeatures = parseStringSet(settingsJson?.optJSONArray("optionalFeatures")), + autoHideVpnServices = + settingsJson?.optBoolean("autoHideVpnServices", defaultSettings.autoHideVpnServices) + ?: defaultSettings.autoHideVpnServices, + autoHideVpnName = + settingsJson?.optBoolean("autoHideVpnName", defaultSettings.autoHideVpnName) + ?: defaultSettings.autoHideVpnName, + autoHideExcludedPackages = parseStringSet(settingsJson?.optJSONArray("autoHideExcludedPackages")), + autoHiddenPackages = parseStringSet(settingsJson?.optJSONArray("autoHiddenPackages")), + ), + ) +} + +private fun parseCanonicalApp(obj: JSONObject?): CanonicalApp { + if (obj == null) return CanonicalApp() + val java = parseHookRole(obj.opt("java")) + val portsRequested = obj.optBoolean("ports", false) + val portPolicyPresent = obj.has("portPolicy") && !obj.isNull("portPolicy") + val portPolicy = + if (portsRequested && portPolicyPresent) { + obj.optJSONObject("portPolicy")?.let(::parsePortPolicy) + } else { + null + } + // A missing policy intentionally retains the legacy "all ports" meaning. + // A present but malformed policy must instead disable the ports role: if it + // were serialized as `ports=true` without a policy, the activator would + // broaden the requested ranges into an all-ports block. + val ports = portsRequested && (!portPolicyPresent || portPolicy != null) + return CanonicalApp( + java = java.enabled, + javaHooks = java.hooks, + native = parseNativeRole(obj.opt("native")), + appHiding = obj.optBoolean("appHiding", false), + ports = ports, + portPolicy = portPolicy, + hidden = obj.optBoolean("hidden", false), + ) +} + +private fun parsePortPolicy(obj: JSONObject?): PortPolicy? { + if (obj == null) return null + // Best-effort: a single malformed rule (e.g. an out-of-range port that trips + // PortRule's require()) must not throw and unwind the WHOLE canonical config, + // which would silently disable every hook. Drop the offending rule/policy and + // treat the app as ports-disabled instead. + return runCatching { + val rulesJson = obj.optJSONArray("rules") ?: return@runCatching null + val rules = + (0 until rulesJson.length()).map { idx -> + val ruleObj = requireNotNull(rulesJson.optJSONObject(idx)) { "Port rule must be an object" } + parsePortRule(ruleObj) + } + normalizePortPolicy( + PortPolicy( + mode = PortPolicyMode.fromJson(obj.optString("mode", PortPolicyMode.Custom.jsonName)), + preset = + if (obj.has("preset") && !obj.isNull("preset")) { + obj.optString("preset").takeIf { it.isNotBlank() } + } else { + null + }, + rules = rules, + ), + ) + }.getOrNull() +} + +private fun parsePortRule(obj: JSONObject): PortRule { + val start = obj.optInt("start", -1) + val end = if (obj.has("end")) obj.optInt("end", start) else start + return PortRule( + protocol = PortProtocol.fromJson(obj.optString("protocol", PortProtocol.Both.jsonName)), + start = start, + end = end, + ) +} + +private fun parseNativeRole(value: Any?): NativeRole = + when (value) { + is JSONObject -> { + parseNativeRoleObject(value) + } + + else -> { + parseHookRole(value).let { role -> + if (role.enabled) { + NativeRole( + enabled = true, + overrides = NativeHookOverrides(kernel = role.hooks), + ) + } else { + NativeRole.Disabled + } + } + } + } + +private fun parseNativeRoleObject(obj: JSONObject): NativeRole { + // A native object with no explicit `enabled` defaults to enabled, matching + // the Rust activator's serde default (`NativeSelectionDetail.enabled = true`), + // which is the authoritative projector. The old `has("kernel")||has("zygisk")` + // default disagreed only for a bare `{}` object — display-only drift, aligned + // here so the app shows what the activator would apply. + val enabled = obj.optBoolean("enabled", true) + if (!enabled) return NativeRole.Disabled + return NativeRole( + enabled = true, + overrides = + NativeHookOverrides( + kernel = parseOptionalStringList(obj, "kernel"), + zygisk = parseOptionalStringList(obj, "zygisk"), + ), + ) +} + +private fun parseHookRole(value: Any?): ParsedHookRole = + when (value) { + is Boolean -> { + if (value) ParsedHookRole(enabled = true) else ParsedHookRole(enabled = false) + } + + is JSONArray -> { + val hooks = + (0 until value.length()) + .mapNotNull { idx -> value.optString(idx).takeIf { it.isNotBlank() } } + if (hooks.isEmpty()) ParsedHookRole(enabled = false) else ParsedHookRole(enabled = true, hooks = hooks) + } + + else -> { + ParsedHookRole(enabled = false) + } + } + +private fun parseStringSet(value: JSONArray?): Set = + (0 until (value?.length() ?: 0)) + .mapNotNull { idx -> value?.optString(idx)?.takeIf { it.isNotBlank() } } + .toSortedSet() + +private fun parseOptionalStringList( + obj: JSONObject, + key: String, +): List? { + if (!obj.has(key) || obj.isNull(key)) return null + val array = obj.optJSONArray(key) ?: return null + return (0 until array.length()) + .mapNotNull { idx -> array.optString(idx).takeIf { it.isNotBlank() } } +} 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 f8c1d13c..f45b4d19 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardData.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardData.kt @@ -327,6 +327,8 @@ internal data class DashboardMessage( // "Learn more" button that opens this help article — the contextual entry // from an issue to the guide section that explains and fixes it. val helpArticle: String? = null, + val reason: DashboardIssueReason? = null, + val presentationLanguage: String? = null, ) @Serializable @@ -1591,7 +1593,15 @@ internal fun assembleDashboardState( facts.appVersion, developer, ) - val messages = dashboardIssues(dashboardFacts).map { it.toMessage(context, context.resources) } + val messages = + dashboardIssues(dashboardFacts).map { issue -> + issue.toMessage(context, context.resources).copy( + reason = issue.toReason(), + presentationLanguage = + context.resources.configuration.locales[0] + .toLanguageTag(), + ) + } VpnHideLog.i(TAG, "protection=${protection.check} messages=$messages") return dashboardFacts.toDashboardState(messages = messages, legacyImport = facts.legacyImport) } diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardIssueData.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardIssueData.kt new file mode 100644 index 00000000..8f57432a --- /dev/null +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardIssueData.kt @@ -0,0 +1,185 @@ +package dev.okhsunrog.vpnhide + +import kotlinx.serialization.Serializable + +/** Stable, locale-independent reason alongside the existing rendered message. */ +@Serializable +internal data class DashboardIssueReason( + val kind: String, + val parameters: Map = emptyMap(), +) + +// Exhaustive vocabulary mapping, not another issue classifier. Keep identifiers +// explicit so moving/renaming a Kotlin type does not change exported reasons. +@Suppress("CyclomaticComplexMethod", "LongMethod") +internal fun DashboardIssue.toReason(): DashboardIssueReason = + when (this) { + DashboardIssue.KpmStandaloneInstall -> { + DashboardIssueReason("kpm_standalone_install") + } + + DashboardIssue.NoNativeBackend -> { + DashboardIssueReason("no_native_backend") + } + + is DashboardIssue.BetterBackendAvailable -> { + DashboardIssueReason("better_backend_available", mapOf("artifact" to artifact)) + } + + DashboardIssue.NativeConflictKernel -> { + DashboardIssueReason("native_conflict_kernel") + } + + DashboardIssue.BuiltinCompanionMissing -> { + DashboardIssueReason("builtin_companion_missing") + } + + DashboardIssue.RedundantKmodWithBuiltin -> { + DashboardIssueReason("redundant_kmod_with_builtin") + } + + DashboardIssue.MultipleNativeActive -> { + DashboardIssueReason("multiple_native_active") + } + + DashboardIssue.NativeConflictDeferred -> { + DashboardIssueReason("native_conflict_deferred") + } + + DashboardIssue.KpmAwaitingSuperkey -> { + DashboardIssueReason("kpm_awaiting_superkey") + } + + DashboardIssue.LsposedNotInstalled -> { + DashboardIssueReason("lsposed_not_installed") + } + + DashboardIssue.LsposedNeedsReboot -> { + DashboardIssueReason("lsposed_needs_reboot") + } + + DashboardIssue.LsposedConfigUnreadable -> { + DashboardIssueReason("lsposed_config_unreadable") + } + + DashboardIssue.LsposedNotEnabled -> { + DashboardIssueReason("lsposed_not_enabled") + } + + DashboardIssue.LsposedNoSystemScope -> { + DashboardIssueReason("lsposed_no_system_scope") + } + + is DashboardIssue.LsposedExtraScope -> { + DashboardIssueReason("lsposed_extra_scope", mapOf("entries" to entries.joinToString(","))) + } + + is DashboardIssue.LsposedFieldRename -> { + DashboardIssueReason("lsposed_field_rename", mapOf("fields" to fields, "sdk" to sdkLabel)) + } + + is DashboardIssue.LsposedInstallFailures -> { + DashboardIssueReason("lsposed_install_failures", mapOf("detail" to detail)) + } + + is DashboardIssue.ModuleVersionMismatch -> { + DashboardIssueReason( + "module_version_mismatch", + mapOf( + "component" to mismatch.kind.reasonId(), + "moduleVersion" to mismatch.moduleVersion, + "appVersion" to mismatch.appVersion, + ), + ) + } + + is DashboardIssue.ModuleBroken -> { + DashboardIssueReason("module_broken", mapOf("artifact" to problem.downloadArtifact.orEmpty())) + } + + is DashboardIssue.ModuleNeedsReboot -> { + DashboardIssueReason("module_needs_reboot", mapOf("component" to kind.reasonId())) + } + + DashboardIssue.NoTargets -> { + DashboardIssueReason("no_targets") + } + + DashboardIssue.PortsNoObservers -> { + DashboardIssueReason("ports_no_observers") + } + + is DashboardIssue.PortsRulesInactive -> { + DashboardIssueReason("ports_rules_inactive", mapOf("detail" to failureDetail.orEmpty())) + } + + is DashboardIssue.FilesystemHidingPending -> { + DashboardIssueReason( + "filesystem_hiding_pending", + mapOf("enabling" to enabling.toString(), "zygisk" to zygisk.toString()), + ) + } + + is DashboardIssue.FilesystemHidingBootError -> { + DashboardIssueReason("filesystem_hiding_boot_error", mapOf("detail" to detail)) + } + + DashboardIssue.FilesystemHidingSetupError -> { + DashboardIssueReason("filesystem_hiding_setup_error") + } + + DashboardIssue.DebugLoggingOn -> { + DashboardIssueReason("debug_logging_on") + } + + DashboardIssue.AgentBridgeOn -> { + DashboardIssueReason("agent_bridge_on") + } + + DashboardIssue.SelinuxPermissive -> { + DashboardIssueReason("selinux_permissive") + } + + is DashboardIssue.InstalledInMultipleProfiles -> { + DashboardIssueReason( + "installed_in_multiple_profiles", + mapOf("count" to profileCount.toString()), + ) + } + + is DashboardIssue.PartialHooks -> { + DashboardIssueReason( + "partial_hooks", + mapOf( + "installed" to installed.toString(), + "expected" to expected.toString(), + "missing" to missing.joinToString(",") { it.hookName }, + ), + ) + } + + is DashboardIssue.LsposedVersionMismatch -> { + DashboardIssueReason( + "lsposed_version_mismatch", + mapOf( + "component" to "lsposed", + "runningVersion" to runningVersion, + "appVersion" to appVersion, + "degraded" to degraded.toString(), + ), + ) + } + + DashboardIssue.ChecksFailed -> { + DashboardIssueReason("checks_failed") + } + } + +private fun FlashableModuleKind.reasonId(): String = + when (this) { + FlashableModuleKind.Kmod -> "kmod" + FlashableModuleKind.Builtin -> "builtin" + FlashableModuleKind.Kpm -> "kpm" + FlashableModuleKind.Zygisk -> "zygisk" + FlashableModuleKind.Ports -> "ports" + } 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 9a1ef306..ce046b01 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardScreen.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.okhsunrog.vpnhide.diagnostics.DiagnosticsCache import dev.okhsunrog.vpnhide.diagnostics.LayerStatus import dev.okhsunrog.vpnhide.diagnostics.Verdict @@ -72,14 +73,14 @@ fun DashboardScreen( val context = LocalContext.current val scope = rememberCoroutineScope() - val state by DashboardCache.state.collectAsState() - val loadError by DashboardCache.error.collectAsState() - val updateInfo by UpdateCheckCache.info.collectAsState() + val state by DashboardCache.state.collectAsStateWithLifecycle() + val loadError by DashboardCache.error.collectAsStateWithLifecycle() + val updateInfo by UpdateCheckCache.info.collectAsStateWithLifecycle() // What the user is looking at, classified once from the shared diagnostic // projection (routing knowledge, the run in flight, the latest attempt, the // measurement and its staleness). The hero and the prompt under it are two // wordings of it (heroVisual), never an overlay of flows of different vintages. - val situation by DiagnosticsCache.situation.collectAsState() + val situation by DiagnosticsCache.situation.collectAsStateWithLifecycle() var showChangelog by remember { mutableStateOf(false) } var changelogData by remember { mutableStateOf(null) } var showContact by remember { mutableStateOf(false) } diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/HookMaskData.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/HookMaskData.kt new file mode 100644 index 00000000..de5215f4 --- /dev/null +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/HookMaskData.kt @@ -0,0 +1,44 @@ +package dev.okhsunrog.vpnhide + +import dev.okhsunrog.vpnhide.generated.HookIds + +/** The bit this hook occupies in a wire mask (protocol §5). The single shift. */ +internal val HookIds.Hook.bit: Long get() = 1L shl id + +/** Whether this wire mask (a packed Long) contains [hook]. */ +internal fun Long.hasHook(hook: HookIds.Hook): Boolean = this and hook.bit != 0L + +/** Fold a set of hooks back into a packed wire mask (protocol §5). */ +internal fun Iterable.toHookMask(): Long = fold(0L) { acc, hook -> acc or hook.bit } + +/** The hooks a raw wire mask selects, in registry order. */ +internal fun hooksInMask(mask: Long): Set = HookIds.Hook.entries.filterTo(linkedSetOf()) { mask.hasHook(it) } + +/** + * Hooks each backend owns, derived **once** from the generated per-backend masks. + * A backend acts only on the hooks it owns and ignores foreign bits (protocol §5). + */ +internal val KERNEL_HOOKS: Set = hooksInMask(HookIds.KERNEL_HOOK_MASK.toLong()) +internal val KMOD_HOOKS: Set = hooksInMask(HookIds.KMOD_HOOK_MASK.toLong()) +internal val BUILTIN_HOOKS: Set = hooksInMask(HookIds.BUILTIN_HOOK_MASK.toLong()) +internal val KPM_HOOKS: Set = hooksInMask(HookIds.KPM_HOOK_MASK.toLong()) +internal val ZYGISK_HOOKS: Set = hooksInMask(HookIds.ZYGISK_HOOK_MASK.toLong()) +internal val LSPOSED_HOOKS: Set = hooksInMask(HookIds.LSPOSED_HOOK_MASK.toLong()) + +// ── wire status codes → registry enums ────────────────────────────────────── +// Protocol.Status keeps its fields as raw Longs so the wire stays decoupled from +// the registry; these are the one place those codes resolve back to the enums. + +private val STATUS_ERROR_BY_CODE: Map = HookIds.StatusError.entries.associateBy { it.code.toLong() } +private val BACKEND_BY_ID: Map = HookIds.Backend.entries.associateBy { it.id.toLong() } + +/** Resolve the wire error code (protocol §5.1) to its [HookIds.StatusError], or null if unknown. */ +internal val Protocol.Status.statusError: HookIds.StatusError? get() = STATUS_ERROR_BY_CODE[error] + +/** Resolve the wire backend id (protocol §4.3) to its [HookIds.Backend], or null if unknown. */ +internal val Protocol.Status.backendId: HookIds.Backend? get() = BACKEND_BY_ID[backend] + +/** OK and PARTIAL_HOOKS both mean the backend loaded and is serving at least some + * owned hooks — the states that count as a live/active backend. */ +internal val HookIds.StatusError.indicatesActive: Boolean + get() = this == HookIds.StatusError.OK || this == HookIds.StatusError.PARTIAL_HOOKS diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/HookRegistry.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/HookRegistry.kt index 252d2792..4f1e3087 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/HookRegistry.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/HookRegistry.kt @@ -15,29 +15,6 @@ import dev.okhsunrog.vpnhide.statistics.parseProtocolStatusBlock // Sets, so no call site reimplements `1 shl id` or `mask and bit` (they used to, // six times over, in both Int and Long flavours). -/** The bit this hook occupies in a wire mask (protocol §5). The single shift. */ -internal val HookIds.Hook.bit: Long get() = 1L shl id - -/** Whether this wire mask (a packed Long) contains [hook]. */ -internal fun Long.hasHook(hook: HookIds.Hook): Boolean = this and hook.bit != 0L - -/** Fold a set of hooks back into a packed wire mask (protocol §5). */ -internal fun Iterable.toHookMask(): Long = fold(0L) { acc, hook -> acc or hook.bit } - -/** The hooks a raw wire mask selects, in registry order. */ -internal fun hooksInMask(mask: Long): Set = HookIds.Hook.entries.filterTo(linkedSetOf()) { mask.hasHook(it) } - -/** - * Hooks each backend owns, derived **once** from the generated per-backend masks. - * A backend acts only on the hooks it owns and ignores foreign bits (protocol §5). - */ -internal val KERNEL_HOOKS: Set = hooksInMask(HookIds.KERNEL_HOOK_MASK.toLong()) -internal val KMOD_HOOKS: Set = hooksInMask(HookIds.KMOD_HOOK_MASK.toLong()) -internal val BUILTIN_HOOKS: Set = hooksInMask(HookIds.BUILTIN_HOOK_MASK.toLong()) -internal val KPM_HOOKS: Set = hooksInMask(HookIds.KPM_HOOK_MASK.toLong()) -internal val ZYGISK_HOOKS: Set = hooksInMask(HookIds.ZYGISK_HOOK_MASK.toLong()) -internal val LSPOSED_HOOKS: Set = hooksInMask(HookIds.LSPOSED_HOOK_MASK.toLong()) - /** The hooks owned by [backend] (its generated mask), or an empty set for an unknown id. */ internal fun ownedHooks(backend: HookIds.Backend): Set = when (backend) { @@ -129,21 +106,3 @@ internal fun expectedInstalledHooks( /** Whether any hook that should cover this vector is in [hooks]. */ internal fun NativeCheckSpec.coveredBy(hooks: Set): Boolean = expectedHooks.any { it in hooks } - -// ── wire status codes → registry enums ────────────────────────────────────── -// Protocol.Status keeps its fields as raw Longs so the wire stays decoupled from -// the registry; these are the one place those codes resolve back to the enums. - -private val STATUS_ERROR_BY_CODE: Map = HookIds.StatusError.entries.associateBy { it.code.toLong() } -private val BACKEND_BY_ID: Map = HookIds.Backend.entries.associateBy { it.id.toLong() } - -/** Resolve the wire error code (protocol §5.1) to its [HookIds.StatusError], or null if unknown. */ -internal val Protocol.Status.statusError: HookIds.StatusError? get() = STATUS_ERROR_BY_CODE[error] - -/** Resolve the wire backend id (protocol §4.3) to its [HookIds.Backend], or null if unknown. */ -internal val Protocol.Status.backendId: HookIds.Backend? get() = BACKEND_BY_ID[backend] - -/** OK and PARTIAL_HOOKS both mean the backend loaded and is serving at least some - * owned hooks — the states that count as a live/active backend. */ -internal val HookIds.StatusError.indicatesActive: Boolean - get() = this == HookIds.StatusError.OK || this == HookIds.StatusError.PARTIAL_HOOKS diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/LogTags.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/LogTags.kt index 19720cdf..e8d0705d 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/LogTags.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/LogTags.kt @@ -1,12 +1,11 @@ package dev.okhsunrog.vpnhide -import dev.okhsunrog.vpnhide.debug.LogcatRecorder import dev.okhsunrog.vpnhide.hook.HookLog /** * Single registry of the logcat tags this project emits, so call sites, the * [HookLog] system_server sink and the debug-capture logcat filter - * ([LogcatRecorder]) all agree on one spelling instead of re-typing string + * (debug.LogcatRecorder) all agree on one spelling instead of re-typing string * literals. Native-backend tags (kmod / ports / zygisk / shadowhook) are listed * too — the app never logs under them, but the bundle filter needs them. */ diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/LsposedState.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/LsposedState.kt index 51cbb265..6e401ebd 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/LsposedState.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/LsposedState.kt @@ -95,6 +95,15 @@ internal object LsposedStats { @Volatile private var installFailures: List = emptyList() + @Volatile private var policyReadError: String? = null + + fun setPolicyReadError(reason: String?) { + val sanitized = reason?.let(::sanitizeMetadataValue) + if (policyReadError == sanitized) return + policyReadError = sanitized + scheduleWrite() + } + // ConnectivityService hooks attach asynchronously (the reliable path only // fires when "connectivity" registers, after handleLoadPackage returns), so // their bits and diagnostics can't be known at setStatus() time. The attach @@ -156,13 +165,10 @@ internal object LsposedStats { try { val text = buildStateText() val tmp = File("$LSPOSED_STATE_FILE.tmp.${Process.myPid()}") - tmp.writeText(text) - if (!tmp.renameTo(File(LSPOSED_STATE_FILE))) { - tmp.delete() - File(LSPOSED_STATE_FILE).writeText(text) - } + writeAtomicTelemetry(File(LSPOSED_STATE_FILE), tmp, text) } catch (t: Throwable) { HookLog.e("VpnHide: failed to write LSPosed state: ${t.message}") + scheduleWrite() // Counters stay in memory; retry even without another hook call. } } @@ -201,6 +207,9 @@ internal object LsposedStats { LsposedStateMetadata.TIMESTAMP to (System.currentTimeMillis() / 1000).toString(), LsposedStateMetadata.AOSP_SDK to Build.VERSION.SDK_INT.toString(), ) + val readError = policyReadError + meta["policy_read_status"] = if (readError == null) "ok" else "error" + readError?.let { meta["policy_read_error"] = it } val broken = brokenFields if (broken.isNotEmpty()) { meta[LsposedStateMetadata.BROKEN_FIELDS] = broken.joinToString(",") diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/StorageConfig.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/StorageConfig.kt index 9bdfb7b6..428ed4ed 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/StorageConfig.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/StorageConfig.kt @@ -1,105 +1,6 @@ package dev.okhsunrog.vpnhide -import dev.okhsunrog.vpnhide.generated.HookIds import dev.okhsunrog.vpnhide.picker.TargetsSnapshot -import org.json.JSONArray -import org.json.JSONObject - -internal const val CANONICAL_CONFIG_FILE = "/data/system/vpnhide_config.json" -internal const val SUPERKEY_FILE = "/data/adb/vpnhide/superkey" -internal val OPTIONAL_FEATURE_FILESYSTEM_IFACE_PATHS = HookIds.Hook.FILESYSTEM_IFACE_PATHS.hookName - -internal data class CanonicalConfig( - val version: Int = 1, - val debug: Boolean = false, - val debugSwitch: Boolean = false, - val apps: Map = emptyMap(), - val settings: CanonicalSettings = CanonicalSettings(), -) - -internal data class CanonicalSettings( - val rememberSuperkey: Boolean = false, - val optionalFeatures: Set = emptySet(), - val autoHideVpnServices: Boolean = true, - val autoHideVpnName: Boolean = false, - val autoHideExcludedPackages: Set = emptySet(), - val autoHiddenPackages: Set = emptySet(), -) - -internal data class CanonicalApp( - val java: Boolean = false, - val javaHooks: List? = null, - val native: NativeRole = NativeRole.Disabled, - val appHiding: Boolean = false, - val ports: Boolean = false, - val portPolicy: PortPolicy? = null, - val hidden: Boolean = false, -) { - val hasAnyRole: Boolean - get() = java || native.enabled || appHiding || ports || hidden -} - -internal data class NativeRole( - val enabled: Boolean, - val overrides: NativeHookOverrides = NativeHookOverrides(), -) { - companion object { - val Disabled = NativeRole(enabled = false) - val All = NativeRole(enabled = true) - } -} - -internal enum class NativeHookFamily { - Kernel, - Kmod, - Kpm, - Zygisk, -} - -internal data class NativeHookOverrides( - val kernel: List? = null, - val zygisk: List? = null, -) { - fun hooksFor(family: NativeHookFamily): List? = - when (family) { - NativeHookFamily.Kernel, NativeHookFamily.Kmod, NativeHookFamily.Kpm -> kernel - NativeHookFamily.Zygisk -> zygisk - } - - fun withHooksFor( - family: NativeHookFamily, - hooks: List?, - ): NativeHookOverrides = - when (family) { - NativeHookFamily.Kernel, NativeHookFamily.Kmod, NativeHookFamily.Kpm -> copy(kernel = hooks) - NativeHookFamily.Zygisk -> copy(zygisk = hooks) - } - - val hasAnyOverride: Boolean - get() = kernel != null || zygisk != null -} - -private data class ParsedHookRole( - val enabled: Boolean, - val hooks: List? = null, -) - -internal val NativeKernelHookEntries: List = KERNEL_HOOKS.toList() - -internal val NativeKmodHookEntries: List = (KERNEL_HOOKS + KMOD_HOOKS).toList() - -internal val ZygiskNativeHookEntries: List = ZYGISK_HOOKS.toList() - -internal val LsposedJavaHookEntries: List = - LSPOSED_HOOKS.filter { it != HookIds.Hook.LSPOSED_PACKAGE_VISIBILITY } - -internal fun nativeHookEntriesFor(family: NativeHookFamily): List = - when (family) { - NativeHookFamily.Kernel -> NativeKernelHookEntries - NativeHookFamily.Kmod -> NativeKmodHookEntries - NativeHookFamily.Kpm -> NativeKernelHookEntries - NativeHookFamily.Zygisk -> ZygiskNativeHookEntries - } internal fun nativeHookFamilyFor(backend: NativeBackendId?): NativeHookFamily = when (backend) { @@ -116,193 +17,6 @@ internal fun nativeHookFamilyFor(backend: NativeBackendId?): NativeHookFamily = null -> NativeHookFamily.Kernel } -internal fun hookSelectionMask( - enabled: Boolean, - hooks: List?, - entries: List, -): Long { - if (!enabled) return 0L - val allMask = entries.toHookMask() - if (hooks == null) return allMask - val allowedByName = entries.associateBy { it.hookName } - return hooks.fold(0L) { acc, name -> acc or (allowedByName[name]?.bit ?: 0L) } -} - -internal fun parseCanonicalConfig(raw: String): CanonicalConfig? { - if (raw.isBlank()) return null - val root = JSONObject(raw) - val version = root.optInt("version", 1) - if (version > 1) return null - val debug = root.optBoolean("debug", false) - val debugSwitch = root.optBoolean("debugSwitch", debug) - val appsJson = root.optJSONObject("apps") - val apps = - appsJson - ?.keys() - ?.asSequence() - ?.sorted() - ?.associateWith { pkg -> parseCanonicalApp(appsJson.optJSONObject(pkg)) } - .orEmpty() - .filterValues { it.hasAnyRole } - val settingsJson = root.optJSONObject("settings") - val defaultSettings = CanonicalSettings() - return CanonicalConfig( - version = version, - debug = debug, - debugSwitch = debugSwitch, - apps = apps, - settings = - CanonicalSettings( - rememberSuperkey = settingsJson?.optBoolean("rememberSuperkey", defaultSettings.rememberSuperkey) == true, - optionalFeatures = parseStringSet(settingsJson?.optJSONArray("optionalFeatures")), - autoHideVpnServices = - settingsJson?.optBoolean("autoHideVpnServices", defaultSettings.autoHideVpnServices) - ?: defaultSettings.autoHideVpnServices, - autoHideVpnName = - settingsJson?.optBoolean("autoHideVpnName", defaultSettings.autoHideVpnName) - ?: defaultSettings.autoHideVpnName, - autoHideExcludedPackages = parseStringSet(settingsJson?.optJSONArray("autoHideExcludedPackages")), - autoHiddenPackages = parseStringSet(settingsJson?.optJSONArray("autoHiddenPackages")), - ), - ) -} - -private fun parseCanonicalApp(obj: JSONObject?): CanonicalApp { - if (obj == null) return CanonicalApp() - val java = parseHookRole(obj.opt("java")) - val portsRequested = obj.optBoolean("ports", false) - val portPolicyPresent = obj.has("portPolicy") && !obj.isNull("portPolicy") - val portPolicy = - if (portsRequested && portPolicyPresent) { - obj.optJSONObject("portPolicy")?.let(::parsePortPolicy) - } else { - null - } - // A missing policy intentionally retains the legacy "all ports" meaning. - // A present but malformed policy must instead disable the ports role: if it - // were serialized as `ports=true` without a policy, the activator would - // broaden the requested ranges into an all-ports block. - val ports = portsRequested && (!portPolicyPresent || portPolicy != null) - return CanonicalApp( - java = java.enabled, - javaHooks = java.hooks, - native = parseNativeRole(obj.opt("native")), - appHiding = obj.optBoolean("appHiding", false), - ports = ports, - portPolicy = portPolicy, - hidden = obj.optBoolean("hidden", false), - ) -} - -private fun parsePortPolicy(obj: JSONObject?): PortPolicy? { - if (obj == null) return null - // Best-effort: a single malformed rule (e.g. an out-of-range port that trips - // PortRule's require()) must not throw and unwind the WHOLE canonical config, - // which would silently disable every hook. Drop the offending rule/policy and - // treat the app as ports-disabled instead. - return runCatching { - val rulesJson = obj.optJSONArray("rules") ?: return@runCatching null - val rules = - (0 until rulesJson.length()).map { idx -> - val ruleObj = requireNotNull(rulesJson.optJSONObject(idx)) { "Port rule must be an object" } - parsePortRule(ruleObj) - } - normalizePortPolicy( - PortPolicy( - mode = PortPolicyMode.fromJson(obj.optString("mode", PortPolicyMode.Custom.jsonName)), - preset = - if (obj.has("preset") && !obj.isNull("preset")) { - obj.optString("preset").takeIf { it.isNotBlank() } - } else { - null - }, - rules = rules, - ), - ) - }.getOrNull() -} - -private fun parsePortRule(obj: JSONObject): PortRule { - val start = obj.optInt("start", -1) - val end = if (obj.has("end")) obj.optInt("end", start) else start - return PortRule( - protocol = PortProtocol.fromJson(obj.optString("protocol", PortProtocol.Both.jsonName)), - start = start, - end = end, - ) -} - -private fun parseNativeRole(value: Any?): NativeRole = - when (value) { - is JSONObject -> { - parseNativeRoleObject(value) - } - - else -> { - parseHookRole(value).let { role -> - if (role.enabled) { - NativeRole( - enabled = true, - overrides = NativeHookOverrides(kernel = role.hooks), - ) - } else { - NativeRole.Disabled - } - } - } - } - -private fun parseNativeRoleObject(obj: JSONObject): NativeRole { - // A native object with no explicit `enabled` defaults to enabled, matching - // the Rust activator's serde default (`NativeSelectionDetail.enabled = true`), - // which is the authoritative projector. The old `has("kernel")||has("zygisk")` - // default disagreed only for a bare `{}` object — display-only drift, aligned - // here so the app shows what the activator would apply. - val enabled = obj.optBoolean("enabled", true) - if (!enabled) return NativeRole.Disabled - return NativeRole( - enabled = true, - overrides = - NativeHookOverrides( - kernel = parseOptionalStringList(obj, "kernel"), - zygisk = parseOptionalStringList(obj, "zygisk"), - ), - ) -} - -private fun parseHookRole(value: Any?): ParsedHookRole = - when (value) { - is Boolean -> { - if (value) ParsedHookRole(enabled = true) else ParsedHookRole(enabled = false) - } - - is JSONArray -> { - val hooks = - (0 until value.length()) - .mapNotNull { idx -> value.optString(idx).takeIf { it.isNotBlank() } } - if (hooks.isEmpty()) ParsedHookRole(enabled = false) else ParsedHookRole(enabled = true, hooks = hooks) - } - - else -> { - ParsedHookRole(enabled = false) - } - } - -private fun parseStringSet(value: JSONArray?): Set = - (0 until (value?.length() ?: 0)) - .mapNotNull { idx -> value?.optString(idx)?.takeIf { it.isNotBlank() } } - .toSortedSet() - -private fun parseOptionalStringList( - obj: JSONObject, - key: String, -): List? { - if (!obj.has(key) || obj.isNull(key)) return null - val array = obj.optJSONArray(key) ?: return null - return (0 until array.length()) - .mapNotNull { idx -> array.optString(idx).takeIf { it.isNotBlank() } } -} - internal fun buildCanonicalConfig( debug: Boolean, // null = inherit the user's toggle intent from [existing] (or fall back to diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/debug/DebugExport.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/debug/DebugExport.kt index 9a76fe15..a85d1211 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/debug/DebugExport.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/debug/DebugExport.kt @@ -3,6 +3,8 @@ package dev.okhsunrog.vpnhide.debug import android.content.Context import android.net.ConnectivityManager import android.os.Build +import dev.okhsunrog.vpnhide.CanonicalConfigRepository +import dev.okhsunrog.vpnhide.DashboardCache import dev.okhsunrog.vpnhide.LogTags import dev.okhsunrog.vpnhide.LsposedConfig import dev.okhsunrog.vpnhide.RootSnapshot @@ -15,6 +17,7 @@ import dev.okhsunrog.vpnhide.diagnostics.DiagnosticsCache import dev.okhsunrog.vpnhide.diagnostics.buildHookDiagnosticsText import dev.okhsunrog.vpnhide.diagnostics.diagnosticSummary import dev.okhsunrog.vpnhide.diagnostics.verdict +import dev.okhsunrog.vpnhide.loadDashboardState import dev.okhsunrog.vpnhide.next import dev.okhsunrog.vpnhide.readLsposedConfig import dev.okhsunrog.vpnhide.statistics.buildStatisticsState @@ -98,6 +101,27 @@ private suspend fun buildDebugState( ): VpnHideState { // Forensics forces debug logging + a fresh dmesg window while the capture runs; // a lean (no-forensics) export skips all of that and just serializes the state. + val beforeCapture = + runCatching { + val dashboard = DashboardCache.state.value + val operation = CanonicalConfigRepository.state.value + BeforeCaptureInfo( + observedAt = isoNow(), + diagnostics = diagnosticSummary(DiagnosticsCache.presentation.value), + protection = dashboard?.protection, + messages = dashboard?.messages.orEmpty(), + pendingOperationIds = operation.operations.sorted(), + operationRechecking = operation.rechecking, + lastOperationId = operation.lastResult?.id, + lastOperationPhases = + operation.lastResult + ?.phases + .orEmpty() + .mapKeys { it.key.name } + .mapValues { it.value.name }, + lastOperationFailure = operation.lastResult?.failure?.name, + ) + }.getOrNull() val loggingSession = if (options.forensics) beginDebugCaptureLogging() else null var restoreAttempted = false val errors = mutableListOf() @@ -114,6 +138,7 @@ private suspend fun buildDebugState( DiagnosticsCache.captureRun(context, selfNeedsRestart, nextDebugCaptureId.getAndIncrement()), ) errors += selfTest.errors + val presentation = DiagnosticsCache.presentation.value // Authoritative module/liveness state — the SAME snapshot the dashboard // derives from. This is what fixes the old export path silently reading @@ -125,6 +150,16 @@ private suspend fun buildDebugState( RootSnapshot(emptyMap()) } + val dashboard = + try { + loadDashboardState(context, selfNeedsRestart, rootSnapshot, presentation) + } catch (c: CancellationException) { + throw c + } catch (e: Exception) { + errors += "dashboard assembly failed: ${e.message}" + null + } + val shellSnapshot = if (options.forensics) { collectDebugShellSnapshot().also { @@ -163,7 +198,9 @@ private suspend fun buildDebugState( checkResults = selfTest.checkResults, selfTestRunId = selfTest.runId, // The projection after the capture's own run settled: what the screens show now. - diagnostics = diagnosticSummary(DiagnosticsCache.presentation.value), + diagnostics = diagnosticSummary(presentation), + dashboard = dashboard, + beforeCapture = beforeCapture, dmesg = dmesg, logcat = logcat, bootLsposedLogcat = if (options.forensics) captureBootLsposedLogcat() else "", diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/debug/VpnHideState.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/debug/VpnHideState.kt index b868999e..2f723d65 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/debug/VpnHideState.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/debug/VpnHideState.kt @@ -3,6 +3,7 @@ package dev.okhsunrog.vpnhide.debug import android.content.Context import android.os.Build import dev.okhsunrog.vpnhide.AgentStatisticsState +import dev.okhsunrog.vpnhide.DashboardMessage import dev.okhsunrog.vpnhide.DashboardState import dev.okhsunrog.vpnhide.DebugShellSnapshot import dev.okhsunrog.vpnhide.DisplayNativeBackend @@ -116,11 +117,10 @@ internal data class VpnHideState( val activeBackend: DisplayNativeBackend, val ports: ModuleState, val kmodLoadStatus: KmodLoadStatus?, - // The full live dashboard model (hero/messages/recommendations on top of the - // module states above). Populated by the agent-bridge getState (which has a - // live DashboardState); null in the file export, which carries only the cheap - // detector-derived fields above. + // Dashboard derived from the same root snapshot and diagnostic presentation. + // Null if assembly failed (errors explains why), or for captures that omit it. val dashboard: DashboardState? = null, + val beforeCapture: BeforeCaptureInfo? = null, // Desired-state config embedded as structured JSON (the canonical // /data/system/vpnhide_config.json). Lets one read answer "is app X even a // target" alongside the runtime state. Null when not requested. @@ -156,6 +156,20 @@ internal data class VpnHideState( val errors: List = emptyList(), ) +/** Available process state before logging or probes; never initiates a read. */ +@Serializable +internal data class BeforeCaptureInfo( + val observedAt: String, + val diagnostics: DiagnosticSummaryInfo?, + val protection: ProtectionCheck?, + val messages: List, + val pendingOperationIds: List, + val operationRechecking: Boolean, + val lastOperationId: Long? = null, + val lastOperationPhases: Map = emptyMap(), + val lastOperationFailure: String? = null, +) + @Serializable internal data class AppInfo( val packageName: String, @@ -275,6 +289,7 @@ internal fun buildVpnHideState( errors: List, networkView: NetworkViewSnapshot? = null, dashboard: DashboardState? = null, + beforeCapture: BeforeCaptureInfo? = null, config: JsonElement? = null, statistics: AgentStatisticsState? = null, options: StateContentOptions = StateContentOptions(), @@ -331,6 +346,7 @@ internal fun buildVpnHideState( ports = ports, kmodLoadStatus = kmodLoadStatus, dashboard = dashboard, + beforeCapture = beforeCapture, // Explicit config (the bridge lean call) wins; otherwise derive it from the // raw canonical-config section so the file export carries it structured too. config = config ?: parseCanonicalConfigSection(rootSections["canonical_config"]), diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/diagnostics/DiagnosticsScreen.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/diagnostics/DiagnosticsScreen.kt index acccae44..f180b4d2 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/diagnostics/DiagnosticsScreen.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/diagnostics/DiagnosticsScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.okhsunrog.vpnhide.DashboardCache import dev.okhsunrog.vpnhide.DiagnosticsFailedPrompt import dev.okhsunrog.vpnhide.DiagnosticsRetryPrompt @@ -88,14 +89,14 @@ fun DiagnosticsScreen( // supplies only this screen's side channels (which results to list, whether they // are complete, their coverage, the notice about the latest attempt), from the // same instant. The screen words the pure decision; it never combines flows. - val presentation by DiagnosticsCache.presentation.collectAsState() - val situation by DiagnosticsCache.situation.collectAsState() + val presentation by DiagnosticsCache.presentation.collectAsStateWithLifecycle() + val situation by DiagnosticsCache.situation.collectAsStateWithLifecycle() val decision = diagnosticScreenDecision(situation, presentation) // 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 dashState by DashboardCache.state.collectAsStateWithLifecycle() val tallyFmt = stringResource(R.string.diag_summary_tally) // Kick off the diagnostics run once per process. The cache parks at @@ -386,7 +387,8 @@ private fun DebugExportSheet( val context = LocalContext.current val scope = rememberCoroutineScope() var exporting by remember { mutableStateOf(false) } - var resultFile by remember { mutableStateOf(null) } + var outcome by remember { mutableStateOf(null) } + val resultFile = (outcome as? DebugExportOutcome.Written)?.file // One export recipe, shared with the agent bridge's getState. var optForensics by remember { mutableStateOf(true) } @@ -397,7 +399,7 @@ private fun DebugExportSheet( val saveLauncher = rememberZipSaveLauncher("debug-export") { resultFile } // Changing any toggle invalidates a result produced with the old recipe. - val clearResult = { resultFile = null } + val clearResult = { outcome = null } // Recreated with this composable, so a reopened sheet never flashes the previous // verdict while a fresh probe runs (e.g. user closed the blocked sheet, turned the // VPN on, reopened). rememberCaptureGate re-measures on first composition = on open. @@ -458,18 +460,19 @@ private fun DebugExportSheet( val kernel = optKernelImage exporting = true scope.launch { - // A Failed export produced no file: the sheet returns to its - // configure phase exactly as it did for the old null result. - // Everything else — including a self-test that could not - // measure — is a written bundle carrying its own reasons. - resultFile = (exportDebug(context, restartState, options, kernel) as? DebugExportOutcome.Written)?.file - exporting = false + outcome = null + try { + outcome = exportDebug(context, restartState, options, kernel) + } finally { + exporting = false + } } } Unit } val file = resultFile + if (!exporting) outcome?.let { ExportOutcomeBanner(it) } // Configure phase only: warn that a VPN-off / not-in-tunnel capture // will be incomplete. The encouraged action (Re-check) rides on the @@ -582,7 +585,7 @@ private fun ExportToggle( private fun LogcatRecordCard(selfNeedsRestart: Boolean?) { val context = LocalContext.current val scope = rememberCoroutineScope() - val state by LogcatRecorder.state.collectAsState() + val state by LogcatRecorder.state.collectAsStateWithLifecycle() // Own gate: the card is always visible, so it holds its VPN-off/not-in-tunnel // verdict until re-checked (re-measured on entry and on the banner's re-check). @@ -754,8 +757,8 @@ private data class CaptureGate( private fun rememberCaptureGate(selfNeedsRestart: Boolean?): CaptureGate { val context = LocalContext.current val scope = rememberCoroutineScope() - val sharedGate by RoutingGateCache.gate.collectAsState() - val sharedLoading by RoutingGateCache.loading.collectAsState() + val sharedGate by RoutingGateCache.gate.collectAsStateWithLifecycle() + val sharedLoading by RoutingGateCache.loading.collectAsStateWithLifecycle() var awaitingFreshSinceOpen by remember { mutableStateOf(true) } val recheck: () -> Unit = { @@ -1059,3 +1062,38 @@ private fun CheckCard( } } } + +@Composable +private fun ExportOutcomeBanner(outcome: DebugExportOutcome) { + val text = + when (outcome) { + is DebugExportOutcome.Failed -> { + stringResource(R.string.debug_export_failed, outcome.reason) + } + + is DebugExportOutcome.Written -> { + if (outcome.errors.isEmpty()) { + stringResource(R.string.debug_export_complete) + } else { + stringResource(R.string.debug_export_partial, outcome.errors.joinToString("\n")) + } + } + } + val color = + when (outcome) { + is DebugExportOutcome.Failed -> { + StatusColors.errorContainer() + } + + is DebugExportOutcome.Written -> { + if (outcome.errors.isEmpty()) { + StatusColors.successContainer() + } else { + StatusColors + .warningContainer() + } + } + } + StatusBanner(text = text, containerColor = color, contentColor = MaterialTheme.colorScheme.onSurface) + Spacer(Modifier.height(12.dp)) +} diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/hook/HookEntry.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/hook/HookEntry.kt index fa8c7325..b45444d7 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/hook/HookEntry.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/hook/HookEntry.kt @@ -17,7 +17,6 @@ import de.robv.android.xposed.XposedHelpers import de.robv.android.xposed.callbacks.XC_LoadPackage import dev.okhsunrog.vpnhide.LsposedStats import dev.okhsunrog.vpnhide.bit -import dev.okhsunrog.vpnhide.diagnostics.token import dev.okhsunrog.vpnhide.generated.HookIds import dev.okhsunrog.vpnhide.generated.IfaceLists import java.util.concurrent.atomic.AtomicBoolean diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/hook/HookLog.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/hook/HookLog.kt index 195ac4c4..06c5136e 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/hook/HookLog.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/hook/HookLog.kt @@ -5,11 +5,10 @@ import android.util.Log import de.robv.android.xposed.XposedBridge import dev.okhsunrog.vpnhide.GatedLogger import dev.okhsunrog.vpnhide.LogTags -import dev.okhsunrog.vpnhide.VpnHideLog /** * system_server logcat facade for LSPosed hooks, which can't reach the app's - * canonical-snapshot cache. Same gate-and-level policy as [VpnHideLog] (see + * canonical-snapshot cache. Same gate-and-level policy as the app logger (see * [GatedLogger]) — info is per-request/hot-path and gated, error always prints * so "hooks didn't attach" reports stay diagnosable — but the flag is owned by * [SystemServerConfigCache], which re-reads the canonical JSON on the same 1s diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/hook/SystemServerConfigCache.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/hook/SystemServerConfigCache.kt index 993e3b45..ac185d9b 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/hook/SystemServerConfigCache.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/hook/SystemServerConfigCache.kt @@ -1,11 +1,13 @@ package dev.okhsunrog.vpnhide.hook import android.os.SystemClock +import android.system.ErrnoException +import android.system.Os +import android.system.OsConstants import dev.okhsunrog.vpnhide.CANONICAL_CONFIG_FILE -import dev.okhsunrog.vpnhide.LsposedJavaHookEntries +import dev.okhsunrog.vpnhide.LsposedStats import dev.okhsunrog.vpnhide.generated.HookIds import dev.okhsunrog.vpnhide.hasHook -import dev.okhsunrog.vpnhide.hookSelectionMask import dev.okhsunrog.vpnhide.parseCanonicalConfig import java.io.File @@ -37,74 +39,30 @@ internal data class SystemServerConfig( * for the same appId is covered. */ internal object SystemServerConfigCache { - private const val STAT_CHECK_INTERVAL_MS = 1_000L private const val USER_ID_MODULO = 100_000 private val configFile = File(CANONICAL_CONFIG_FILE) private val packagesListFile = File("/data/system/packages.list") - private data class FileFingerprint( - val exists: Boolean, - val lastModified: Long, - val length: Long, - ) - - private data class Fingerprint( - val config: FileFingerprint, - val packagesList: FileFingerprint, - ) - - private data class Cache( - val fingerprint: Fingerprint, - val config: SystemServerConfig, - val nextStatCheckUptimeMs: Long, - ) - - @Volatile private var cache: Cache? = null - private val lock = Any() - - fun load(): SystemServerConfig { - val now = SystemClock.uptimeMillis() - cache?.let { cached -> - if (now < cached.nextStatCheckUptimeMs) return cached.config - } + private val policyCache = + SystemServerPolicyCache( + fingerprint = ::fingerprint, + readConfig = ::readConfig, + uptimeMillis = SystemClock::uptimeMillis, + onReadError = LsposedStats::setPolicyReadError, + onLoaded = { result -> + // Poll both sources even if the FileObserver misses atomic renames. + HookLog.enabled = result.debug + HookLog.i( + "VpnHide: system_server config loaded " + + "java=${result.javaTargetAppIds.size} observer=${result.observerAppIds.size} " + + "hidden=${result.hiddenPackages.size} debug=${result.debug}", + ) + }, + ) - synchronized(lock) { - val lockedNow = SystemClock.uptimeMillis() - cache?.let { cached -> - if (lockedNow < cached.nextStatCheckUptimeMs) return cached.config - val fingerprint = fingerprint() - if (cached.fingerprint == fingerprint) { - val refreshed = cached.withNextCheck(lockedNow) - cache = refreshed - return refreshed.config - } - } + fun load(): SystemServerConfig = policyCache.load() - val result = readConfig() - val loadedFingerprint = fingerprint() - // The debug-logging flag rides this same 1s stat poll as everything - // else the hooks read. It used to be refreshed only by HookLog's - // FileObserver, and on a Pixel 8 Pro that observer silently stopped - // delivering: an atomic rename over the config produced no callback - // at all, so the flag stayed frozen at its boot value for five days - // while this cache — reading the very same file — was current the - // whole time. Turning Debug logging off in the app simply never - // reached system_server, and the hooks kept naming target UIDs in - // logcat. Correctness must not depend on inotify. - HookLog.enabled = result.debug - HookLog.i( - "VpnHide: system_server config loaded " + - "java=${result.javaTargetAppIds.size} observer=${result.observerAppIds.size} " + - "hidden=${result.hiddenPackages.size} debug=${result.debug}", - ) - cache = Cache(loadedFingerprint, result, nextStatCheck(lockedNow)) - return result - } - } - - fun invalidate() { - cache = null - } + fun invalidate() = policyCache.invalidate() fun isTargetUid(uid: Int): Boolean { if (uid < android.os.Process.FIRST_APPLICATION_UID) return false @@ -121,64 +79,58 @@ internal object SystemServerConfigCache { fun appId(uid: Int): Int = uid % USER_ID_MODULO - private fun Cache.withNextCheck(now: Long): Cache = copy(nextStatCheckUptimeMs = nextStatCheck(now)) - - private fun nextStatCheck(now: Long): Long = now + STAT_CHECK_INTERVAL_MS - - private fun readConfig(): SystemServerConfig = + private fun readConfig(): PolicyReadResult = try { + if (!configFile.exists()) return PolicyReadResult.Loaded(SystemServerConfig()) val canonical = - parseCanonicalConfig(configFile.takeIf(File::isFile)?.readText().orEmpty()) - ?: return SystemServerConfig() + parseCanonicalConfig(configFile.readText()) + ?: return PolicyReadResult.Failed("empty or unsupported canonical config") val packageAppIds = parsePackagesListAppIds(packagesListFile.takeIf(File::isFile)?.readText().orEmpty()) - val javaTargetHookMasks = - canonical.apps - .mapNotNull { (pkg, app) -> - val appId = packageAppIds[pkg] ?: return@mapNotNull null - val mask = - hookSelectionMask( - enabled = app.java, - hooks = app.javaHooks, - entries = LsposedJavaHookEntries, - ) - if (mask == 0L) null else appId to mask - }.toMap() val observers = canonical.apps .filterValues { it.appHiding } .keys .resolveAppIds(packageAppIds) val hidden = canonical.apps.filterValues { it.hidden }.keys - SystemServerConfig( - javaTargetHookMasksByAppId = javaTargetHookMasks, - observerAppIds = observers, - hiddenPackages = hidden, - packageAppIds = packageAppIds, - debug = canonical.debug, + PolicyReadResult.Loaded( + SystemServerConfig( + javaTargetHookMasksByAppId = javaHookMasksByAppId(canonical, packageAppIds), + observerAppIds = observers, + hiddenPackages = hidden, + packageAppIds = packageAppIds, + debug = canonical.debug, + ), ) } catch (t: Throwable) { HookLog.e("VpnHide: failed to read canonical config: ${t.message}") - SystemServerConfig() + PolicyReadResult.Failed(t.message ?: t.javaClass.simpleName) } private fun Set.resolveAppIds(packageAppIds: Map): Set = mapNotNull(packageAppIds::get).toSet() - private fun fingerprint(): Fingerprint = - Fingerprint( - config = configFile.fingerprint(), - packagesList = packagesListFile.fingerprint(), - ) + private fun fingerprint(): SystemServerFingerprint? = + try { + SystemServerFingerprint(configFile.fingerprint(), packagesListFile.fingerprint()) + } catch (e: ErrnoException) { + HookLog.e("VpnHide: failed to stat config sources: ${e.message}") + null // An unreadable source must never look like a stable missing file. + } - private fun File.fingerprint(): FileFingerprint = + private fun File.fingerprint(): PolicyFileFingerprint? = try { - if (!exists()) { - FileFingerprint(exists = false, lastModified = 0L, length = 0L) - } else { - FileFingerprint(exists = true, lastModified = lastModified(), length = length()) - } - } catch (t: Throwable) { - HookLog.e("VpnHide: failed to stat ${this.path}: ${t.message}") - FileFingerprint(exists = false, lastModified = 0L, length = 0L) + val stat = Os.stat(path) + PolicyFileFingerprint( + device = stat.st_dev, + inode = stat.st_ino, + length = stat.st_size, + modifiedSeconds = stat.st_mtim.tv_sec, + modifiedNanos = stat.st_mtim.tv_nsec, + changedSeconds = stat.st_ctim.tv_sec, + changedNanos = stat.st_ctim.tv_nsec, + ) + } catch (e: ErrnoException) { + if (e.errno != OsConstants.ENOENT) throw e + null } } diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/hook/SystemServerConfigData.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/hook/SystemServerConfigData.kt new file mode 100644 index 00000000..9e6e4ed6 --- /dev/null +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/hook/SystemServerConfigData.kt @@ -0,0 +1,24 @@ +package dev.okhsunrog.vpnhide.hook + +import dev.okhsunrog.vpnhide.CanonicalConfig +import dev.okhsunrog.vpnhide.LsposedJavaHookEntries +import dev.okhsunrog.vpnhide.hookSelectionMask + +/** + * Binder identifies the caller by UID, not package. Each enabled package adds + * hooks for the whole shared appId, across profiles; a disabled role adds none + * and cannot veto another package's selection. Preserve the per-package intent + * in canonical JSON and combine it only at this UID execution boundary. + */ +internal fun javaHookMasksByAppId( + canonical: CanonicalConfig, + packageAppIds: Map, +): Map { + val masks = mutableMapOf() + for ((pkg, app) in canonical.apps) { + val appId = packageAppIds[pkg] ?: continue + val mask = hookSelectionMask(enabled = app.java, hooks = app.javaHooks, entries = LsposedJavaHookEntries) + if (mask != 0L) masks[appId] = (masks[appId] ?: 0L) or mask + } + return masks +} diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/hook/SystemServerPolicyCache.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/hook/SystemServerPolicyCache.kt new file mode 100644 index 00000000..ea5abe63 --- /dev/null +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/hook/SystemServerPolicyCache.kt @@ -0,0 +1,105 @@ +package dev.okhsunrog.vpnhide.hook + +import java.util.concurrent.atomic.AtomicLong + +internal data class PolicyFileFingerprint( + val device: Long, + val inode: Long, + val length: Long, + val modifiedSeconds: Long, + val modifiedNanos: Long, + val changedSeconds: Long, + val changedNanos: Long, +) + +internal data class SystemServerFingerprint( + val config: PolicyFileFingerprint?, + val packagesList: PolicyFileFingerprint?, +) + +internal sealed interface PolicyReadResult { + data class Loaded( + val config: SystemServerConfig, + ) : PolicyReadResult + + data class Failed( + val reason: String, + ) : PolicyReadResult +} + +/** Testable reader protocol; all Android I/O stays in SystemServerConfigCache. */ +internal class SystemServerPolicyCache( + private val fingerprint: () -> SystemServerFingerprint?, + private val readConfig: () -> PolicyReadResult, + private val uptimeMillis: () -> Long, + private val onLoaded: (SystemServerConfig) -> Unit = {}, + private val onReadError: (String?) -> Unit = {}, +) { + private data class Cache( + val generation: Long, + val fingerprint: SystemServerFingerprint, + val config: SystemServerConfig, + val nextStatCheckUptimeMs: Long, + val retryRead: Boolean = false, + ) + + private val generation = AtomicLong() + private val lock = Any() + + @Volatile private var cache: Cache? = null + + fun invalidate() { + // Never wait for a reader's I/O. Even a publication after this increment + // carries the old generation and cannot satisfy another load. + generation.incrementAndGet() + } + + fun load(): SystemServerConfig { + cache?.takeIf { it.isFresh(uptimeMillis()) }?.let { return it.config } + return synchronized(lock) { loadLocked() } + } + + private fun loadLocked(): SystemServerConfig { + cache?.takeIf { it.isFresh(uptimeMillis()) }?.let { return it.config } + repeat(MAX_READ_ATTEMPTS) { + val readingGeneration = generation.get() + val before = fingerprint() ?: return@repeat + val previous = cache + if (previous?.generation == readingGeneration && previous.fingerprint == before && !previous.retryRead) { + if (generation.get() == readingGeneration) { + cache = previous.copy(nextStatCheckUptimeMs = nextStatCheck()) + return previous.config + } + return@repeat + } + val result = readConfig() + val after = fingerprint() + if (before == after && generation.get() == readingGeneration) { + val config = + when (result) { + is PolicyReadResult.Loaded -> result.config + is PolicyReadResult.Failed -> previous?.config ?: SystemServerConfig() + } + cache = Cache(readingGeneration, before, config, nextStatCheck(), retryRead = result is PolicyReadResult.Failed) + onReadError((result as? PolicyReadResult.Failed)?.reason) + onLoaded(config) + return config + } + } + // Do not stamp an unstable read as current. The expired/invalidated + // entry stays expired, so the next call retries without an inotify event. + cache = cache?.copy(retryRead = true) + onReadError("config sources unstable or unreadable; retry pending") + return cache?.config ?: SystemServerConfig() + } + + private fun Cache.isFresh(now: Long): Boolean = + generation == this@SystemServerPolicyCache.generation.get() && now < nextStatCheckUptimeMs + + private fun nextStatCheck(): Long = uptimeMillis() + STAT_CHECK_INTERVAL_MS + + private companion object { + const val STAT_CHECK_INTERVAL_MS = 1_000L + const val MAX_READ_ATTEMPTS = 2 + } +} diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/picker/AppPickerData.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/picker/AppPickerData.kt index 0b4fbad2..544b51cd 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/picker/AppPickerData.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/picker/AppPickerData.kt @@ -66,6 +66,21 @@ internal const val NATIVE_USER_UID_CAPACITY = NATIVE_TARGET_UID_CAPACITY - NATIV private const val ANDROID_UIDS_PER_USER = 100_000 private const val FIRST_APPLICATION_UID = 10_000 +/** Distinct packages sharing an appId; copies of one package in profiles do not count. */ +internal fun packagesSharingAppIds(packageUids: Map>): Set = + packageUids.entries + .flatMap { (pkg, uids) -> + uids + .filter { it >= FIRST_APPLICATION_UID } + .map { it % ANDROID_UIDS_PER_USER } + .distinct() + .map { it to pkg } + }.groupBy({ it.first }, { it.second }) + .values + .filter { it.size > 1 } + .flatten() + .toSet() + internal data class NativeTargetCapacityUsage( val used: Int, val capacity: Int = NATIVE_TARGET_UID_CAPACITY, diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/picker/AppPickerScreen.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/picker/AppPickerScreen.kt index f62418fc..c3e7cec2 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/picker/AppPickerScreen.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/picker/AppPickerScreen.kt @@ -91,6 +91,7 @@ internal data class AppEntry( override val isSystem: Boolean, override val userIds: List = emptyList(), val uids: List = emptyList(), + val sharesJavaAppId: Boolean = false, val java: Boolean = false, val javaHooks: List? = null, val native: Boolean = false, @@ -136,6 +137,7 @@ internal fun AppPickerScreen( merge = { apps, t, selfPkg -> val nativeTargets = t.nativeTargets val observers = t.observerNames + val sharedAppIdPackages = packagesSharingAppIds(t.packageUids) val autoHideSignals = apps.map(AppSummary::toAutoHideSignal) val baseCanonical = t.canonicalConfig ?: buildCanonicalConfigFromTargetsSnapshot(t) val autoApplied = @@ -157,6 +159,7 @@ internal fun AppPickerScreen( isSystem = app.isSystem, userIds = app.userIds, uids = t.packageUids[app.packageName].orEmpty(), + sharesJavaAppId = app.packageName in sharedAppIdPackages, java = canonicalApp?.java ?: (app.packageName in t.lsposedTargets), javaHooks = canonicalApp?.takeIf { it.java }?.javaHooks?.takeIf { it.isNotEmpty() }, native = app.packageName in nativeTargets, @@ -426,6 +429,7 @@ private fun AppRow( hookEntries = LsposedJavaHookEntries, selectedHooks = app.javaHooks, roleEnabled = app.java, + notice = if (app.sharesJavaAppId) stringResource(R.string.java_hooks_shared_app_id) else null, onDismiss = { javaHookDialogOpen = false }, onSave = { hooks -> onJavaHooksChange(hooks) diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/picker/TargetPickerScaffold.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/picker/TargetPickerScaffold.kt index d373853c..02bf549f 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/picker/TargetPickerScaffold.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/picker/TargetPickerScaffold.kt @@ -40,7 +40,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -60,6 +59,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.graphics.drawable.toBitmap +import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.okhsunrog.vpnhide.CanonicalActivation import dev.okhsunrog.vpnhide.CanonicalConfig import dev.okhsunrog.vpnhide.CanonicalConfigRepository @@ -156,18 +156,18 @@ internal fun TargetPickerScreen( val context = LocalContext.current val resources = LocalResources.current - val cachedApps by AppListCache.apps.collectAsState() - val appListError by AppListCache.error.collectAsState() - val userNames by AppListCache.userNames.collectAsState() - val scanWarning by AppListCache.scanWarning.collectAsState() - val cachedTargets by TargetsCache.snapshot.collectAsState() - val repository by CanonicalConfigRepository.state.collectAsState() + val cachedApps by AppListCache.apps.collectAsStateWithLifecycle() + val appListError by AppListCache.error.collectAsStateWithLifecycle() + val userNames by AppListCache.userNames.collectAsStateWithLifecycle() + val scanWarning by AppListCache.scanWarning.collectAsStateWithLifecycle() + val cachedTargets by TargetsCache.snapshot.collectAsStateWithLifecycle() + val repository by CanonicalConfigRepository.state.collectAsStateWithLifecycle() val editor = rememberCanonicalEditor(helpPrefKey) val targets = cachedTargets?.copy(canonicalConfig = editor.state.current ?: repository.confirmed ?: cachedTargets?.canonicalConfig) val saving = editor.saving var resaveNeeded by remember { mutableStateOf(false) } val dirty = editor.state.dirty || resaveNeeded - val targetsError by TargetsCache.error.collectAsState() + val targetsError by TargetsCache.error.collectAsStateWithLifecycle() var allApps by remember { mutableStateOf>(emptyList()) } var snackMessage by remember { mutableStateOf(null) } diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/settings/FilesystemHidingSettings.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/settings/FilesystemHidingSettings.kt index d2ae0d50..f15a571b 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/settings/FilesystemHidingSettings.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/settings/FilesystemHidingSettings.kt @@ -11,7 +11,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -20,6 +19,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.okhsunrog.vpnhide.CanonicalActivation import dev.okhsunrog.vpnhide.CanonicalConfigRepository import dev.okhsunrog.vpnhide.CanonicalEdit @@ -44,11 +44,11 @@ import kotlinx.coroutines.withContext internal fun FilesystemHidingSettingsSection() { val scope = rememberCoroutineScope() val checkWrite = LocalConfigWriteAccess.current - val rootSnapshot by RootSnapshotCache.snapshot.collectAsState() + val rootSnapshot by RootSnapshotCache.snapshot.collectAsStateWithLifecycle() var saving by remember { mutableStateOf(false) } var confirmationOpen by remember { mutableStateOf(false) } var saveStatus by remember { mutableStateOf(null) } - val repository by CanonicalConfigRepository.state.collectAsState() + val repository by CanonicalConfigRepository.state.collectAsStateWithLifecycle() var requested by remember { mutableStateOf(null) } val pending = repository.pending[CanonicalToggle.Filesystem] val enabled = diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/settings/HiddenAppsSettingsScreen.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/settings/HiddenAppsSettingsScreen.kt index ec608cbf..30c5915e 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/settings/HiddenAppsSettingsScreen.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/settings/HiddenAppsSettingsScreen.kt @@ -39,7 +39,6 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -53,6 +52,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.okhsunrog.vpnhide.CanonicalConfigRepository import dev.okhsunrog.vpnhide.ConfigCoordinatorMode import dev.okhsunrog.vpnhide.HelpAccordion @@ -77,8 +77,8 @@ import dev.okhsunrog.vpnhide.ui.theme.AppColors @Composable internal fun HiddenAppsSettingsScreen(onBack: () -> Unit) { val context = LocalContext.current - val apps by AppListCache.apps.collectAsState() - val userNames by AppListCache.userNames.collectAsState() + val apps by AppListCache.apps.collectAsStateWithLifecycle() + val userNames by AppListCache.userNames.collectAsStateWithLifecycle() val snackbarHostState = LocalConfigSnackbar.current val checkWrite = LocalConfigWriteAccess.current var filter by rememberSaveable { mutableStateOf(HiddenAppsFilter.All) } @@ -86,7 +86,7 @@ internal fun HiddenAppsSettingsScreen(onBack: () -> Unit) { var searchActive by rememberSaveable { mutableStateOf(false) } val editor = rememberCanonicalEditor("hidden_apps") val saving = editor.saving - val repository by CanonicalConfigRepository.state.collectAsState() + val repository by CanonicalConfigRepository.state.collectAsStateWithLifecycle() var snackMessage by remember { mutableStateOf(null) } val savedMessage = stringResource(R.string.settings_auto_hide_saved) diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/settings/SettingsScreen.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/settings/SettingsScreen.kt index f88da354..ce594ba2 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/settings/SettingsScreen.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/settings/SettingsScreen.kt @@ -64,7 +64,6 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -78,6 +77,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.okhsunrog.vpnhide.CanonicalActivation import dev.okhsunrog.vpnhide.CanonicalConfig import dev.okhsunrog.vpnhide.CanonicalConfigRepository @@ -524,14 +524,14 @@ private fun ConfigBackupSection() { val context = LocalContext.current val scope = rememberCoroutineScope() val checkWrite = LocalConfigWriteAccess.current - val targets by TargetsCache.snapshot.collectAsState() + val targets by TargetsCache.snapshot.collectAsStateWithLifecycle() var operation by remember { mutableStateOf(ConfigOperation.Idle) } var pendingExport by remember { mutableStateOf(null) } var pendingPackageListExport by remember { mutableStateOf(null) } var packageListDialogOpen by remember { mutableStateOf(false) } var legacyImportDialog by remember { mutableStateOf(null) } var status by remember { mutableStateOf(null) } - val rootSnapshot by RootSnapshotCache.snapshot.collectAsState() + val rootSnapshot by RootSnapshotCache.snapshot.collectAsStateWithLifecycle() // Pre-1.0 config still on disk (see LegacyConfigImport). Derived from the // shared root snapshot TargetsCache already loads — no extra su round-trip. val legacyPrompt = @@ -857,8 +857,8 @@ private fun packageListFormatLabel(format: PackageListFormat): String = @Composable private fun SuperkeySettingsSection() { val scope = rememberCoroutineScope() - val targets by TargetsCache.snapshot.collectAsState() - val repository by CanonicalConfigRepository.state.collectAsState() + val targets by TargetsCache.snapshot.collectAsStateWithLifecycle() + val repository by CanonicalConfigRepository.state.collectAsStateWithLifecycle() val pending = CanonicalToggle.RememberSuperkey in repository.pending val checkWrite = LocalConfigWriteAccess.current var superkey by remember { mutableStateOf("") } @@ -1022,14 +1022,14 @@ private suspend fun importConfigFromUri( private fun AutoHideSettingsSection(onOpenHiddenApps: () -> Unit) { val context = LocalContext.current val scope = rememberCoroutineScope() - val targets by TargetsCache.snapshot.collectAsState() - val apps by AppListCache.apps.collectAsState() + val targets by TargetsCache.snapshot.collectAsStateWithLifecycle() + val apps by AppListCache.apps.collectAsStateWithLifecycle() var saving by remember { mutableStateOf(null) } var status by remember { mutableStateOf(null) } var unavailableDialogOpen by remember { mutableStateOf(false) } val unavailableRemovedMessage = stringResource(R.string.settings_unavailable_configured_removed) val unavailableFailedMessage = stringResource(R.string.settings_unavailable_configured_failed) - val repository by CanonicalConfigRepository.state.collectAsState() + val repository by CanonicalConfigRepository.state.collectAsStateWithLifecycle() val canonical = repository.confirmed val hiddenSummary = remember(canonical, apps, context.packageName) { diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/startup/MainActivity.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/startup/MainActivity.kt index 63d6b066..74c1ce29 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/startup/MainActivity.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/startup/MainActivity.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.unit.lerp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.repeatOnLifecycle import dev.okhsunrog.vpnhide.AgentControlBridge import dev.okhsunrog.vpnhide.BackgroundUpdateChecks @@ -151,7 +152,7 @@ fun VpnHideApp(mainProfile: Boolean = isMainAppProfile(Process.myUid())) { val context = LocalContext.current val settingsRepository = remember(context) { SettingsRepository(context.applicationContext) } - val loadedSettings by settingsRepository.settings.collectAsState(initial = null) + val loadedSettings by settingsRepository.settings.collectAsStateWithLifecycle(initialValue = null) val settingsLoaded = loadedSettings != null val settings = loadedSettings ?: AppSettings() val settingsScope = rememberCoroutineScope() @@ -372,16 +373,16 @@ private fun MainScreen() { var showSystem by rememberSaveable { mutableStateOf(false) } var showRussianOnly by rememberSaveable { mutableStateOf(false) } var targetSortMode by rememberSaveable { mutableStateOf(TargetListSortMode.ConfiguredFirst) } - val appListLoading by AppListCache.loading.collectAsState() - val targetsLoading by TargetsCache.loading.collectAsState() + val appListLoading by AppListCache.loading.collectAsStateWithLifecycle() + val targetsLoading by TargetsCache.loading.collectAsStateWithLifecycle() // The top-bar indicator follows only reads the user asked for: a background // re-derivation (the startup reconcile, a root dependency) is not news to spin for. - val dashboardLoading by DashboardCache.refreshing.collectAsState() - val statisticsLoading by StatisticsCache.loading.collectAsState() - val dashboardState by DashboardCache.state.collectAsState() - val dashboardError by DashboardCache.error.collectAsState() - val rootSnapshot by RootSnapshotCache.snapshot.collectAsState() - val selfTargetState by startupCoordinator.selfTargetState.collectAsState() + val dashboardLoading by DashboardCache.refreshing.collectAsStateWithLifecycle() + val statisticsLoading by StatisticsCache.loading.collectAsStateWithLifecycle() + val dashboardState by DashboardCache.state.collectAsStateWithLifecycle() + val dashboardError by DashboardCache.error.collectAsStateWithLifecycle() + val rootSnapshot by RootSnapshotCache.snapshot.collectAsStateWithLifecycle() + val selfTargetState by startupCoordinator.selfTargetState.collectAsStateWithLifecycle() val selfNeedsRestart = (selfTargetState as? StartupSelfTargetState.Ready)?.selfNeedsRestart val selfTargetFailure = selfTargetState as? StartupSelfTargetState.Failed diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/statistics/StatisticsScreen.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/statistics/StatisticsScreen.kt index 01de3e96..bfcadbd1 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/statistics/StatisticsScreen.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/statistics/StatisticsScreen.kt @@ -21,7 +21,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf @@ -44,6 +43,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.core.graphics.drawable.toBitmap +import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.okhsunrog.vpnhide.R import dev.okhsunrog.vpnhide.StatusBanner import dev.okhsunrog.vpnhide.StatusColors @@ -94,9 +94,9 @@ private object CaptureSession { @Composable fun StatisticsScreen(modifier: Modifier = Modifier) { val context = LocalContext.current - val state by StatisticsCache.state.collectAsState() - val loadError by StatisticsCache.error.collectAsState() - val installedApps by AppListCache.apps.collectAsState() + val state by StatisticsCache.state.collectAsStateWithLifecycle() + val loadError by StatisticsCache.error.collectAsStateWithLifecycle() + val installedApps by AppListCache.apps.collectAsStateWithLifecycle() var detailApp by remember { mutableStateOf(null) } // Backed by the process-scoped CaptureSession so the session survives an // Activity recreation (rotation / day-night / resize). frozenCapture being diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/ui/components/AppSearchTopBar.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/ui/components/AppSearchTopBar.kt index bd21c809..4e2948e8 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/ui/components/AppSearchTopBar.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/ui/components/AppSearchTopBar.kt @@ -54,13 +54,13 @@ internal fun AppSearchTopBar( placeholder = { Text(placeholder) }, leadingIcon = { IconButton(onClick = onClose) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.close_search)) } }, trailingIcon = { if (query.isNotEmpty()) { IconButton(onClick = { onQueryChange("") }) { - Icon(Icons.Default.Clear, contentDescription = null) + Icon(Icons.Default.Clear, contentDescription = stringResource(R.string.clear_search_query)) } } }, diff --git a/lsposed/app/src/main/res/values-ru/strings.xml b/lsposed/app/src/main/res/values-ru/strings.xml index 033d4dbf..f4fe872c 100644 --- a/lsposed/app/src/main/res/values-ru/strings.xml +++ b/lsposed/app/src/main/res/values-ru/strings.xml @@ -12,6 +12,8 @@ Сохранить Отмена Обновить + Закрыть поиск + Очистить запрос Поиск приложений… Системные Российские @@ -32,6 +34,7 @@ Не удалось прочитать: %1$s. Показаны все приложения, которые удалось найти; настройки для %1$s сохранены. Как это работает + У этого пакета общий Android app ID с другими пакетами. Включённые Java-хуки всех пакетов с этим ID объединяются и действуют на всю группу во всех профилях. Чтобы отключить хук для группы, снимите его у каждого пакета. Java-хуки Нативные хуки Нативные хуки · %1$s @@ -630,4 +633,7 @@ Companion-модуль встроенного бэкенда ещё установлен VPN Hide встроен в работающее ядро. Удалите лишний модуль kmod и оставьте companion-модуль встроенного бэкенда. VPN Hide встроен в это ядро, но companion-модуль отсутствует. Установите companion-модуль встроенного бэкенда VPN Hide для настройки защиты. + Отчёт собран полностью. + Собран частичный отчёт. Его можно сохранить или отправить.\n%1$s + Не удалось собрать отчёт: %1$s diff --git a/lsposed/app/src/main/res/values-zh-rCN/strings.xml b/lsposed/app/src/main/res/values-zh-rCN/strings.xml index 3dad7d19..379918df 100644 --- a/lsposed/app/src/main/res/values-zh-rCN/strings.xml +++ b/lsposed/app/src/main/res/values-zh-rCN/strings.xml @@ -13,6 +13,8 @@ 保存 取消 刷新 + 关闭搜索 + 清除搜索内容 搜索应用… 系统 俄区 @@ -32,6 +34,7 @@ 未能刷新全部 Android 用户资料。正在显示上一次的完整应用列表;没有删除任何设置。 未能扫描:%1$s。已显示 VPN Hide 能读取到的全部应用;%1$s 的设置予以保留。 工作原理 + 此软件包与其他软件包共享 Android 应用 ID。共享该 ID 的所有软件包启用的 Java 钩子会合并,并在所有用户配置中作用于整个组。要为整个组禁用某个钩子,请在每个软件包中取消选择它。 Java 钩子 原生钩子 原生钩子 · %1$s @@ -618,4 +621,7 @@ 内置后端配套模块仍已安装 VPN Hide 已内置于当前内核。请移除多余的 kmod 模块,并保留内置后端配套模块。 VPN Hide 已内置于此内核,但缺少配套模块。请安装 VPN Hide 内置后端配套模块以配置保护。 + 报告已完整收集。 + 已收集部分报告,仍可保存或分享。\n%1$s + 报告收集失败:%1$s diff --git a/lsposed/app/src/main/res/values/strings.xml b/lsposed/app/src/main/res/values/strings.xml index 75510e96..9a2988a1 100644 --- a/lsposed/app/src/main/res/values/strings.xml +++ b/lsposed/app/src/main/res/values/strings.xml @@ -12,6 +12,8 @@ Save Cancel Refresh + Close search + Clear query Search apps… System Russian @@ -39,6 +41,7 @@ Native Apps Ports + This package shares an Android app ID. Enabled Java hooks from all packages with that ID are combined and apply to the whole group in every profile. To disable a hook for the group, deselect it in every package. Java hooks Native hooks Native hooks · %1$s @@ -661,4 +664,7 @@ Built-in companion module is still installed VPN Hide is built into the running kernel. Remove the unnecessary kmod module and keep the built-in companion module. VPN Hide is built into this kernel, but its companion module is missing. Install the VPN Hide built-in companion module to configure protection. + Report collected successfully. + A partial report was collected. You can save or share it.\n%1$s + Report collection failed: %1$s diff --git a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/AgentHttpTest.kt b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/AgentHttpTest.kt new file mode 100644 index 00000000..43d091f1 --- /dev/null +++ b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/AgentHttpTest.kt @@ -0,0 +1,47 @@ +package dev.okhsunrog.vpnhide + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import java.io.ByteArrayInputStream +import java.net.SocketTimeoutException + +class AgentHttpTest { + private fun reader(text: String): AgentRequestReader = AgentRequestReader(ByteArrayInputStream(text.toByteArray()), {}) + + @Test + fun `normal request remains usable after rejection`() { + assertThrows( + IllegalArgumentException::class.java, + ) { reader("GET /functions HTTP/1.1\r\n" + "x: a\r\n".repeat(65) + "\r\n").readHead() } + val request = reader("POST /call HTTP/1.1\r\nAuthorization: Bearer test\r\nContent-Length: 2\r\n\r\n{}") + val head = requireNotNull(request.readHead()) + assertEquals("Bearer test", head.headers["authorization"]) + assertEquals("{}", request.readBody(head.headers)) + } + + @Test + fun `total size includes ignored carriage returns and repeated headers`() { + assertThrows(IllegalArgumentException::class.java) { + reader("GET / HTTP/1.1\r\n" + "x: ${"a".repeat(700)}\r\n".repeat(60) + "\r\n").readHead() + } + assertThrows(IllegalArgumentException::class.java) { reader("GET / HTTP/1.1" + "\r".repeat(33 * 1024) + "\n\n").readHead() } + } + + @Test + fun `slow headers exhaust total deadline despite individual reads making progress`() { + var now = 0L + val request = + AgentRequestReader(ByteArrayInputStream("GET / HTTP/1.1\r\n\r\n".toByteArray()), {}, { now.also { now += 400_000_000 } }) + assertThrows(SocketTimeoutException::class.java) { request.readHead() } + } + + @Test + fun `body shares deadline with headers`() { + var now = 0L + val request = AgentRequestReader(ByteArrayInputStream("POST / HTTP/1.1\r\nContent-Length: 2\r\n\r\n{}".toByteArray()), {}, { now }) + val head = requireNotNull(request.readHead()) + now = 5_000_000_000 + assertThrows(SocketTimeoutException::class.java) { request.readBody(head.headers) } + } +} diff --git a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/AtomicTelemetryTest.kt b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/AtomicTelemetryTest.kt new file mode 100644 index 00000000..642ef23d --- /dev/null +++ b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/AtomicTelemetryTest.kt @@ -0,0 +1,48 @@ +package dev.okhsunrog.vpnhide + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test +import java.io.IOException +import java.nio.file.Files + +class AtomicTelemetryTest { + @Test + fun `reader opened before replacement retains complete old snapshot`() { + val directory = Files.createTempDirectory("telemetry-reader").toFile() + try { + val target = directory.resolve("state").apply { writeText("old complete snapshot") } + target.inputStream().bufferedReader().use { reader -> + writeAtomicTelemetry(target, directory.resolve("state.tmp"), "new complete snapshot") + assertEquals("old complete snapshot", reader.readText()) + assertEquals("new complete snapshot", target.readText()) + } + } finally { + directory.deleteRecursively() + } + } + + @Test + fun `failed rename preserves old snapshot and following write recovers`() { + val directory = Files.createTempDirectory("telemetry").toFile() + try { + val target = directory.resolve("state").apply { writeText("old complete snapshot") } + val temporary = directory.resolve("state.tmp") + try { + writeAtomicTelemetry(target, temporary, "new complete snapshot") { _, destination -> + assertEquals("old complete snapshot", destination.readText()) + false + } + throw AssertionError("replacement failure must be reported") + } catch (_: IOException) { + assertEquals("old complete snapshot", target.readText()) + assertFalse(temporary.exists()) + } + writeAtomicTelemetry(target, temporary, "new complete snapshot") + assertEquals("new complete snapshot", target.readText()) + assertFalse(temporary.exists()) + } finally { + directory.deleteRecursively() + } + } +} diff --git a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/BundleSchemaGoldenTest.kt b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/BundleSchemaGoldenTest.kt index 0107bbf2..8e16e9f0 100644 --- a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/BundleSchemaGoldenTest.kt +++ b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/BundleSchemaGoldenTest.kt @@ -1,6 +1,7 @@ package dev.okhsunrog.vpnhide import dev.okhsunrog.vpnhide.debug.AppInfo +import dev.okhsunrog.vpnhide.debug.BeforeCaptureInfo import dev.okhsunrog.vpnhide.debug.CallbackEvent import dev.okhsunrog.vpnhide.debug.CapabilityFacts import dev.okhsunrog.vpnhide.debug.DeviceInfo @@ -138,6 +139,7 @@ internal fun sampleBundleState(): VpnHideState { ports = ModuleState.NotInstalled, kmodLoadStatus = null, dashboard = sampleDashboard(kmod, backends), + beforeCapture = sampleBeforeCapture(), rootShell = RootShellDiag.from( mapOf("snapshot_shell_uid" to "uid=0\nid=uid=0(root)\ncontext=u:r:ksu:s0\nerrno_ctl=ok"), @@ -251,3 +253,27 @@ private fun sampleDiagnosticSummary(): DiagnosticSummaryInfo = lastKnownRouting = SelfRouting.VpnOff, routingRead = RoutingReadInfo(ReadReason.Transition, pendingMs = 820), ) + +private fun sampleBeforeCapture(): BeforeCaptureInfo = + BeforeCaptureInfo( + observedAt = "2026-08-22T11:59:59+0300", + diagnostics = sampleDiagnosticSummary(), + protection = null, + messages = + listOf( + DashboardMessage( + DashboardMessageSeverity.WARNING, + "Update module", + reason = + DashboardIssue + .ModuleVersionMismatch( + ModuleMismatch(FlashableModuleKind.Kmod, "1.2.0", "1.3.0"), + null, + null, + ).toReason(), + presentationLanguage = "en", + ), + ), + pendingOperationIds = listOf(7), + operationRechecking = true, + ) diff --git a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/DashboardIssueReasonTest.kt b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/DashboardIssueReasonTest.kt new file mode 100644 index 00000000..f6d52840 --- /dev/null +++ b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/DashboardIssueReasonTest.kt @@ -0,0 +1,28 @@ +package dev.okhsunrog.vpnhide + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class DashboardIssueReasonTest { + @Test + fun `version reason and parameters survive localized wording changes`() { + val issue = DashboardIssue.ModuleVersionMismatch(ModuleMismatch(FlashableModuleKind.Kmod, "1.2.0", "1.3.0"), null, null) + val reason = issue.toReason() + assertEquals("module_version_mismatch", reason.kind) + assertEquals(mapOf("component" to "kmod", "moduleVersion" to "1.2.0", "appVersion" to "1.3.0"), reason.parameters) + val english = DashboardMessage(DashboardMessageSeverity.WARNING, "Update module", reason = reason, presentationLanguage = "en") + val russian = english.copy(text = "Обновите модуль", presentationLanguage = "ru") + assertEquals(english.reason, russian.reason) + assertTrue(Json.encodeToString(russian).contains("module_version_mismatch")) + } + + @Test + fun `hook and profile reasons retain structured counts`() { + assertEquals(mapOf("count" to "3"), DashboardIssue.InstalledInMultipleProfiles(3).toReason().parameters) + assertEquals("lsposed_config_unreadable", DashboardIssue.LsposedConfigUnreadable.toReason().kind) + assertEquals("checks_failed", DashboardIssue.ChecksFailed.toReason().kind) + } +} diff --git a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/HookPackageBoundaryTest.kt b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/HookPackageBoundaryTest.kt index 654a118f..aa7aa125 100644 --- a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/HookPackageBoundaryTest.kt +++ b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/HookPackageBoundaryTest.kt @@ -1,10 +1,10 @@ package dev.okhsunrog.vpnhide -import dev.okhsunrog.vpnhide.hook.HookEntry import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import java.io.File +import java.nio.file.Files /** * Guards the one boundary in this module that has a real failure mode: code in @@ -19,11 +19,52 @@ class HookPackageBoundaryTest { private val mainSources = File("src/main/kotlin/dev/okhsunrog/vpnhide") private val hookSources = File(mainSources, "hook") - private fun kotlinFiles(dir: File): List = dir.listFiles().orEmpty().filter { it.isFile && it.extension == "kt" } + private val sharedFiles = + setOf( + "CanonicalConfigData.kt", + "HookMaskData.kt", + "GatedLogger.kt", + "LogTags.kt", + "LsposedState.kt", + "AtomicTelemetryData.kt", + "PortPolicyData.kt", + "Protocol.kt", + ) + + private fun kotlinFiles(dir: File): List = dir.walkTopDown().filter { it.isFile && it.extension == "kt" }.toList() private fun imports(file: File): List = file.readLines().filter { it.startsWith("import ") }.map { it.removePrefix("import ").trim() } + private fun uiOffenders(files: List): List { + val forbidden = listOf("androidx.", "android.app.", "android.content.Context", "dev.okhsunrog.vpnhide.ui.") + return files.flatMap { file -> + imports(file).filter { imp -> forbidden.any(imp::startsWith) }.map { "${file.name}: $it" } + } + } + + @Test + fun `nested hook and app package imports remain covered`() { + val fixture = Files.createTempDirectory("hook-boundary").toFile() + try { + val hook = + fixture.resolve("hook/nested/Ui.kt").apply { + parentFile?.mkdirs() + writeText("import androidx.compose.runtime.Composable") + } + val app = + fixture.resolve("app/nested/Xposed.kt").apply { + parentFile?.mkdirs() + writeText("import de.robv.android.xposed.XposedBridge") + } + assertEquals(listOf("Ui.kt: androidx.compose.runtime.Composable"), uiOffenders(kotlinFiles(fixture.resolve("hook")))) + assertTrue(kotlinFiles(fixture).containsAll(listOf(hook, app))) + assertTrue(imports(app).any { it.startsWith("de.robv.android.xposed") }) + } finally { + fixture.deleteRecursively() + } + } + @Test fun `the hook package exists and is where the Xposed entry lives`() { assertTrue("hook sources missing at ${hookSources.path}", hookSources.isDirectory) @@ -38,11 +79,7 @@ class HookPackageBoundaryTest { fun `hook code pulls in no UI`() { // system_server has no Compose, no Activity, no app resources. An import // that drags any of it in is a crash waiting for the next ROM. - val forbidden = listOf("androidx.compose", "androidx.activity", "dev.okhsunrog.vpnhide.ui.") - val offenders = - kotlinFiles(hookSources).flatMap { file -> - imports(file).filter { imp -> forbidden.any(imp::startsWith) }.map { "${file.name}: $it" } - } + val offenders = uiOffenders(kotlinFiles(hookSources) + sharedFiles.map { File(mainSources, it) }) assertEquals("hook/ must stay free of app-process UI", emptyList(), offenders) } @@ -52,8 +89,53 @@ class HookPackageBoundaryTest { // process, so touching it from app code throws NoClassDefFoundError. val offenders = kotlinFiles(mainSources).flatMap { file -> - imports(file).filter { it.startsWith("de.robv.android.xposed") }.map { "${file.name}: $it" } + if (file.toPath().startsWith(hookSources.toPath())) return@flatMap emptyList() + imports(file).filter { it.startsWith("de.robv.android.xposed") }.map { "${file.relativeTo(mainSources)}: $it" } } assertEquals("Xposed imports belong in hook/", emptyList(), offenders) } + + @Test + fun `shared dependencies cannot reach app process declarations`() { + val files = kotlinFiles(mainSources) + val shared = files.filter { it.name in sharedFiles && it.parentFile == mainSources } + assertEquals("reviewed shared sources must exist", sharedFiles.size, shared.size) + val allowed = shared + kotlinFiles(hookSources) + kotlinFiles(File(mainSources, "generated")) + val allowedNames = allowed.flatMap { declarations(code(it), includeMembers = true) }.toSet() + "BuildConfig" + val appOnlyNames = (files - allowed.toSet()).flatMap { declarations(code(it)) }.toSet() - allowedNames + val offenders = + allowed.filter { it.parentFile?.name != "generated" }.flatMap { file -> + val forbiddenImports = + imports(file).filter { imported -> + imported.startsWith("dev.okhsunrog.vpnhide.") && + !imported.startsWith("dev.okhsunrog.vpnhide.hook.") && + !imported.startsWith("dev.okhsunrog.vpnhide.generated.") && + imported.removePrefix("dev.okhsunrog.vpnhide.") !in allowedNames + } + assertEquals("${file.name}: unreviewed shared imports", emptyList(), forbiddenImports) + val content = code(file) + Regex("(?(), offenders) + } + + private fun code(file: File): String = + file.readText().replace(Regex("/\\*.*?\\*/|//[^\\n]*|\"\"\".*?\"\"\"|\"(?:\\\\.|[^\"\\\\])*\"", RegexOption.DOT_MATCHES_ALL), " ") + + private fun declarations( + source: String, + includeMembers: Boolean = false, + ): List = + Regex( + "(?m)^(?:(?:internal|private|public|protected|data|sealed|suspend|inline|abstract|open|override|const)\\s+)*(?:(?:class|interface|object|typealias)\\s+(\\w+)|fun\\s+[^\\n(]*?\\b(\\w+)\\s*\\(|(?:val|var)\\s+[^\\n:=]*?\\b(\\w+)\\s*[:=])", + ).findAll(if (includeMembers) source.lines().joinToString("\n") { it.trimStart() } else source) + .map { match -> match.groupValues.drop(1).first { it.isNotEmpty() } } + .toList() } diff --git a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/hook/SystemServerConfigDataTest.kt b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/hook/SystemServerConfigDataTest.kt new file mode 100644 index 00000000..cc5c521d --- /dev/null +++ b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/hook/SystemServerConfigDataTest.kt @@ -0,0 +1,70 @@ +package dev.okhsunrog.vpnhide.hook + +import dev.okhsunrog.vpnhide.CanonicalApp +import dev.okhsunrog.vpnhide.CanonicalConfig +import dev.okhsunrog.vpnhide.LsposedJavaHookEntries +import dev.okhsunrog.vpnhide.bit +import org.junit.Assert.assertEquals +import org.junit.Test + +class SystemServerConfigDataTest { + private val first = LsposedJavaHookEntries[0] + private val second = LsposedJavaHookEntries[1] + private val ids = mapOf("first" to 10_501, "second" to 10_501) + + @Test + fun `shared app id unions enabled masks independent of package order`() { + val apps = linkedMapOf("first" to selection(first.hookName), "second" to selection(second.hookName)) + val expected = mapOf(10_501 to (first.bit or second.bit)) + assertEquals(expected, javaHookMasksByAppId(CanonicalConfig(apps = apps), ids)) + assertEquals(expected, javaHookMasksByAppId(CanonicalConfig(apps = apps.entries.reversed().associate { it.toPair() }), ids)) + } + + @Test + fun `disabled and empty roles cannot veto enabled shared package`() { + for (disabled in listOf( + CanonicalApp(), + CanonicalApp(javaHooks = listOf(second.hookName)), + CanonicalApp(java = true, javaHooks = emptyList()), + )) { + val config = CanonicalConfig(apps = mapOf("first" to selection(first.hookName), "second" to disabled)) + assertEquals(mapOf(10_501 to first.bit), javaHookMasksByAppId(config, ids)) + } + } + + @Test + fun `identical shared selections do not add bits`() { + val config = CanonicalConfig(apps = mapOf("first" to selection(first.hookName), "second" to selection(first.hookName))) + assertEquals(mapOf(10_501 to first.bit), javaHookMasksByAppId(config, ids)) + } + + @Test + fun `disabled unresolved and unknown hooks create no target`() { + val config = + CanonicalConfig( + apps = + mapOf( + "first" to CanonicalApp(), + "second" to selection("unknown"), + "missing" to CanonicalApp(java = true), + ), + ) + assertEquals(emptyMap(), javaHookMasksByAppId(config, ids)) + } + + @Test + fun `different app ids stay separate and same app id matches across profiles`() { + val config = CanonicalConfig(apps = mapOf("first" to selection(first.hookName), "second" to selection(second.hookName))) + val masks = javaHookMasksByAppId(config, ids + ("second" to 10_502)) + assertEquals(mapOf(10_501 to first.bit, 10_502 to second.bit), masks) + assertEquals(masks[SystemServerConfigCache.appId(10_501)], masks[SystemServerConfigCache.appId(210_501)]) + } + + @Test + fun `default enabled role contributes every Java hook`() { + val config = CanonicalConfig(apps = mapOf("first" to selection(first.hookName), "second" to CanonicalApp(java = true))) + assertEquals(mapOf(10_501 to LsposedJavaHookEntries.fold(0L) { mask, hook -> mask or hook.bit }), javaHookMasksByAppId(config, ids)) + } + + private fun selection(name: String): CanonicalApp = CanonicalApp(java = true, javaHooks = listOf(name)) +} diff --git a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/hook/SystemServerPolicyCacheTest.kt b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/hook/SystemServerPolicyCacheTest.kt new file mode 100644 index 00000000..ea833425 --- /dev/null +++ b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/hook/SystemServerPolicyCacheTest.kt @@ -0,0 +1,274 @@ +package dev.okhsunrog.vpnhide.hook + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class SystemServerPolicyCacheTest { + private val first = SystemServerConfig(javaTargetHookMasksByAppId = mapOf(10_501 to 1L)) + private val second = SystemServerConfig(javaTargetHookMasksByAppId = mapOf(10_502 to 2L)) + + private class Source { + var now = 0L + var fingerprint = SystemServerFingerprint(file(1), file(1)) + var config = SystemServerConfig() + var reads = 0 + val publications = mutableListOf() + val errors = mutableListOf() + var afterRead: () -> Unit = {} + var afterStat: () -> Unit = {} + var failure: String? = null + var statReadable = true + var afterClock: () -> Unit = {} + val cache = + SystemServerPolicyCache( + fingerprint = { + val result = fingerprint.takeIf { statReadable } + afterStat() + result + }, + readConfig = { + reads++ + val result = failure?.let { PolicyReadResult.Failed(it) } ?: PolicyReadResult.Loaded(config) + afterRead() + result + }, + uptimeMillis = { + afterClock() + now + }, + onLoaded = { publications += it }, + onReadError = { errors += it }, + ) + } + + @Test + fun `unchanged sources reuse snapshot after polling interval`() { + val source = Source().apply { config = first } + assertEquals(first, source.cache.load()) + source.now = 1_000 + assertEquals(first, source.cache.load()) + assertEquals(1, source.reads) + } + + @Test + fun `canonical replacement during read never publishes mixed fingerprint`() { + assertReplacementDuringRead { it.copy(config = file(2)) } + } + + @Test + fun `package replacement during read never publishes mixed fingerprint`() { + assertReplacementDuringRead { it.copy(packagesList = file(2)) } + } + + private fun assertReplacementDuringRead(replace: (SystemServerFingerprint) -> SystemServerFingerprint) { + val source = Source().apply { config = first } + source.afterRead = { + source.config = second + source.fingerprint = replace(source.fingerprint) + } + assertEquals(second, source.cache.load()) + assertEquals(listOf(second), source.publications) + assertEquals(2, source.reads) + assertEquals(second, source.cache.load()) + assertEquals(2, source.reads) + } + + @Test + fun `invalidation during reading forces another read with unchanged metadata`() { + val source = Source().apply { config = first } + source.afterRead = { + if (source.reads == 1) { + source.config = second + source.cache.invalidate() + } + } + assertEquals(second, source.cache.load()) + assertEquals(listOf(second), source.publications) + assertEquals(2, source.reads) + } + + @Test + fun `invalidation just after publication is retained`() { + var value = first + var reads = 0 + lateinit var cache: SystemServerPolicyCache + cache = + SystemServerPolicyCache( + fingerprint = { SystemServerFingerprint(file(1), file(1)) }, + readConfig = { + reads++ + PolicyReadResult.Loaded(value) + }, + uptimeMillis = { 0 }, + onLoaded = { + if (reads == 1) { + value = second + cache.invalidate() + } + }, + ) + assertEquals(first, cache.load()) + assertEquals(second, cache.load()) + assertEquals(2, reads) + } + + @Test + fun `invalidation between stability check and publication cannot make stale entry fresh`() { + val source = Source().apply { config = first } + source.afterRead = { + source.afterRead = {} + source.afterClock = { + source.afterClock = {} + source.config = second + source.cache.invalidate() + } + } + assertEquals(first, source.cache.load()) + assertEquals(second, source.cache.load()) + assertEquals(2, source.reads) + } + + @Test(timeout = 5_000) + fun `watcher invalidation does not wait for reader lock`() { + val source = Source().apply { config = first } + source.afterRead = { + source.afterRead = {} + val watcher = Thread { source.cache.invalidate() }.apply { isDaemon = true } + watcher.start() + watcher.join(1_000) + assertFalse("watcher must invalidate while the reader owns the lock", watcher.isAlive) + source.config = second + } + assertEquals(second, source.cache.load()) + assertEquals(listOf(second), source.publications) + } + + @Test + fun `invalidation during unchanged stat prevents renewing old generation`() { + val source = Source().apply { config = first } + source.cache.load() + source.now = 1_000 + source.afterStat = { + source.afterStat = {} + source.config = second + source.cache.invalidate() + } + assertEquals(second, source.cache.load()) + assertEquals(2, source.reads) + } + + @Test + fun `missed watcher events for either source are repaired by polling`() { + val source = Source().apply { config = first } + source.cache.load() + source.config = second + source.fingerprint = source.fingerprint.copy(config = file(2)) + assertEquals(first, source.cache.load()) + source.now = 1_000 + assertEquals(second, source.cache.load()) + source.config = first + source.fingerprint = source.fingerprint.copy(packagesList = file(2)) + source.now = 2_000 + assertEquals(first, source.cache.load()) + assertEquals(3, source.reads) + } + + @Test + fun `continuous changes return previous snapshot after two attempts then retry`() { + val source = Source().apply { config = first } + source.cache.load() + source.config = second + source.cache.invalidate() + source.afterRead = { + source.fingerprint = source.fingerprint.copy(config = file(source.reads.toLong())) + } + assertEquals(first, source.cache.load()) + assertEquals(3, source.reads) + assertEquals(listOf(first), source.publications) + source.afterRead = {} + assertEquals(second, source.cache.load()) + assertEquals(4, source.reads) + } + + @Test + fun `continuous invalidations without initial snapshot fail open without caching`() { + val source = Source().apply { config = first } + source.afterRead = { source.cache.invalidate() } + assertEquals(SystemServerConfig(), source.cache.load()) + assertEquals(2, source.reads) + assertEquals(emptyList(), source.publications) + source.afterRead = {} + assertEquals(first, source.cache.load()) + assertEquals(3, source.reads) + } + + @Test + fun `stat failure is not cached as missing and recovers without watcher`() { + val source = Source().apply { config = first } + source.cache.load() + source.now = 1_000 + source.statReadable = false + assertEquals(first, source.cache.load()) + source.config = second + source.fingerprint = source.fingerprint.copy(config = file(2)) + source.statReadable = true + assertEquals(second, source.cache.load()) + } + + @Test + fun `stat recovery with unchanged metadata rereads policy and clears error`() { + val source = Source().apply { config = first } + source.cache.load() + source.now = 1_000 + source.statReadable = false + assertEquals(first, source.cache.load()) + source.statReadable = true + assertEquals(first, source.cache.load()) + assertEquals(2, source.reads) + assertEquals(null, source.errors.last()) + } + + @Test + fun `explicit empty policy and removed canonical source replace previous snapshot`() { + val source = Source().apply { config = first } + source.cache.load() + source.config = SystemServerConfig() + source.fingerprint = source.fingerprint.copy(config = null) + source.cache.invalidate() + assertEquals(SystemServerConfig(), source.cache.load()) + assertEquals(2, source.reads) + } + + @Test + fun `temporary read failure retains last good and retries unchanged source`() { + val source = Source().apply { config = first } + source.cache.load() + source.failure = "malformed JSON" + source.config = second + source.cache.invalidate() + assertEquals(first, source.cache.load()) + source.failure = null + source.now = 1_000 + assertEquals(second, source.cache.load()) + assertEquals(3, source.reads) + assertEquals(listOf(null, "malformed JSON", null), source.errors) + } + + @Test + fun `initial failure fails open then recovers`() { + val source = + Source().apply { + config = first + failure = "permission denied" + } + assertEquals(SystemServerConfig(), source.cache.load()) + source.failure = null + source.now = 1_000 + assertEquals(first, source.cache.load()) + } + + private companion object { + fun file(inode: Long): PolicyFileFingerprint = PolicyFileFingerprint(1, inode, 128, 1, 0, 1, 0) + } +} diff --git a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/picker/SharedAppIdTest.kt b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/picker/SharedAppIdTest.kt new file mode 100644 index 00000000..c5975831 --- /dev/null +++ b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/picker/SharedAppIdTest.kt @@ -0,0 +1,24 @@ +package dev.okhsunrog.vpnhide.picker + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SharedAppIdTest { + @Test + fun `distinct packages sharing app id across profiles are marked`() { + assertEquals( + setOf("first", "second"), + packagesSharingAppIds(mapOf("first" to listOf(10_501, 110_501), "second" to listOf(210_501), "other" to listOf(10_502))), + ) + } + + @Test + fun `duplicate uids and multiple profiles of one package are not sharing`() { + assertEquals(emptySet(), packagesSharingAppIds(mapOf("first" to listOf(10_501, 10_501, 110_501), "missing" to emptyList()))) + } + + @Test + fun `system uid entries do not produce app sharing notice`() { + assertEquals(emptySet(), packagesSharingAppIds(mapOf("first" to listOf(1_000), "second" to listOf(1_000)))) + } +} diff --git a/lsposed/app/src/test/resources/bundle/state_golden.json b/lsposed/app/src/test/resources/bundle/state_golden.json index 1e7a0f84..172fbb91 100644 --- a/lsposed/app/src/test/resources/bundle/state_golden.json +++ b/lsposed/app/src/test/resources/bundle/state_golden.json @@ -180,6 +180,72 @@ "installedOptionalHooks": [], "legacyImport": null }, + "beforeCapture": { + "observedAt": "2026-08-22T11:59:59+0300", + "diagnostics": { + "eligibility": "Eligible", + "activeRunId": 5, + "activeStage": "Core", + "lastAttempt": { + "runId": 4, + "outcome": "Failed", + "failure": "ExecutionFailed", + "eligibility": null + }, + "measurement": { + "runId": 3, + "startedAt": 1755856800000, + "endedAt": 1755856802000, + "completed": true, + "interrupted": false, + "observationId": 7 + }, + "applicability": "Changed", + "evidence": { + "hidden": 9, + "systemBlocked": 1, + "nothingToLeak": 2, + "leaks": 0, + "notMeasured": 0, + "notRun": 0, + "uncoveredLeaks": 0, + "conclusion": "NoObservedLeak" + }, + "currentSuccess": false, + "probeUnavailable": true, + "lastKnownRouting": "VpnOff", + "routingRead": { + "reason": "Transition", + "pendingMs": 820 + } + }, + "protection": null, + "messages": [ + { + "severity": "WARNING", + "text": "Update module", + "action": null, + "downloadArtifact": null, + "helpArticle": null, + "reason": { + "kind": "module_version_mismatch", + "parameters": { + "component": "kmod", + "moduleVersion": "1.2.0", + "appVersion": "1.3.0" + } + }, + "presentationLanguage": "en" + } + ], + "pendingOperationIds": [ + 7 + ], + "operationRechecking": true, + "lastOperationId": null, + "lastOperationPhases": {}, + "lastOperationFailure": null + }, "config": null, "statistics": null, "rootShell": {