On MediaTek A11 the live ConnectivityService is defined by a child classloader;
resolving the class by name through that loader follows delegation to a parent
copy — a different Class object with the same name. Hooks attached to it cleanly
(every method matched, mask full) yet never fired, because the live binder
dispatches to the child copy. Tell: NetworkInfo.writeToParcel still fired on the
result of getNetworkInfo(TYPE_VPN) while our getNetworkInfo method hook did not —
the served method was a different object than the hooked one.
Hook binder.javaClass directly (the live instance's actual class) instead of
re-resolving by name. Add a nameResolvesSame flag to cs_attempts to confirm the
delegation mismatch from a report.
Path A (resolve + hook the class on the system_server classloader at install
time) reported success on MediaTek A11 — every method matched, mask full — yet
none of the connectivity hooks fired and all five checks still leaked. The
class is hooked before ConnectivityService is constructed, and ART replaces the
method entries when it later initialises/compiles the class, dropping the
hooks. It also grabbed the once-only latch, so path C — which had already seen
the addService registration — was skipped ("C:skipped(already-hooked)").
Drop path A entirely. Only ever attach from the live service binder (B/C/D),
which happens after the instance exists so the hooks stick. Confirmed on a
Pixel 4a (A13): C attaches cleanly, no A entry in the trail, all checks pass.
On Android 13+ ConnectivityService lives in the Connectivity APEX, so its
method hooks can only be attached from the service binder's classloader. That
was done solely by catching ServiceManager.addService("connectivity", ...) —
which never fires on many MediaTek/OEM ROMs (they publish the service off that
path). Paths A (system_server classloader) and B (immediate getService) can't
cover A13+, so the ConnectivityService hooks never attached and
getNetworkForType, the active/all-networks handles, getNetworkInfo(TYPE_VPN)
and pushed NetworkCallbacks leaked the VPN, while the writeToParcel hooks kept
passing — 20/25 with five Java-API checks red.
Add path D: a deferred getService("connectivity") poll on a short-lived
background thread that retries until the live binder appears and attaches from
its classloader, independent of how the service was registered. In-process
getService returns the local ConnectivityService instance (not a proxy), so
its APEX classloader resolves the class. The thread is torn down as soon as
the hooks attach (any path) or the retry budget is exhausted.
Also make the published hook mask honest: the connectivity bits are set only
once those hooks actually attach, so a device where they don't shows
PARTIAL_HOOKS and lists them under missing owned hooks instead of a false
"8/8 installed". And record per-attempt install telemetry (resolved class,
classloader chain, attach path, per-method match counts, full attempt trail)
as cs_* keys in the LSPosed state file, so this class of attach failure is
diagnosable from a debug bundle without depending on logcat or the
debug-logging toggle.
release.py rewrites each crate's `version = "..."` in Cargo.toml but never
touched Cargo.lock, so the lock kept the previous version. The first
`cargo build` in CI then rewrote the lock to match, dirtying the working tree —
and get_build_version()'s `git describe --dirty` stamped every artifact
"X.Y.Z-dirty" (that's why the v1.1.0 KPM/Ports/etc. modules report 1.1.0-dirty).
Two layers so it cannot recur:
- release.py now runs `cargo update --offline --workspace` for both lockfiles
after the Cargo.toml bump, so the release commit carries a matching lock.
- The Rust build steps (activator, zygisk cdylib, lsposed probe) pass --locked,
so a drifted lock fails the build loudly instead of silently rewriting it.
Also commits the now-synced 1.1.0 lockfiles.
* feat(lsposed): single-row app bar with adaptive brand, filters as list chips
Replace the LargeTopAppBar with a compact single-line TopAppBar so the logo,
tab title and actions sit on one row, reclaiming the vertical space the
two-tier large bar wasted (worst on short / high-density screens). Wrap the
brand in BoxWithConstraints and scale the logo and text down proportionally
when the title slot is narrow, so "VPN Hide" never wraps or clips.
Move the Apps-list filters (show-system, RU-only, sort) out of the top-bar
dropdown into a single horizontally scrollable row of chips above the list;
"Configured first" is selected by default. Only Search stays as a bar action,
which is what frees the room for the brand + three actions on one line.
* feat(lsposed): airy Dashboard header that morphs to compact on tab switch
On tall screens the Dashboard header expands: the logo grows and drops below
the (fixed) action buttons, with a larger wordmark. Every other tab — and any
short / high-density screen — keeps the compact single-row bar shipped in the
previous commit. It's one adaptive header, not a per-tab swap: an animated
`headerProgress` (0 = compact, 1 = airy) drives the logo size, wordmark scale
and vertical drop, so switching tabs smoothly grows/shrinks the bar and the
content below follows. Replaces the fixed-height TopAppBar with a Surface+Box
so the header can take the extra height; the brand keeps its narrow-screen
down-scaling so nothing wraps.
On a device with no backend the /proc/net/route check read a green "OK" even
though the app's read was SELinux-blocked — because it classifies as NothingToLeak
(root also sees nothing: a split-tunnel VPN isn't in the main routing table), and
NothingToLeak was collapsed into the backend "OK". Two checks were also dead
weight.
- NothingToLeak gets its own neutral "nothing to hide" pill, distinct from the
green "OK" a backend earns — so a surface that's empty for everyone no longer
reads as active protection where there is none.
- Native check detail now shows the root ground-truth read (`root: …`) next to the
app's own read (`app: …`). That is the basis for the verdict, so adjacent cards
with the same "access denied by SELinux" app read now explain why one is
"nothing to hide" (root: no VPN) and another is "SELinux" (root: VPN present).
Plumbed through CheckResult.groundTruthDetail and the agent bridge.
- Drop the /proc/net/route (Java) probe: a duplicate of the native Rust probe with
no root differential, so it could only report a misleading "OK" on the SELinux
denial. The Rust probe covers the vector correctly.
- Drop getActiveNetworkInfo(): its .type reports the underlying transport
(WIFI/mobile) for an active VPN, not TYPE_VPN, so it never surfaced the leak.
getNetworkInfo(TYPE_VPN) covers the same LSPOSED_NETWORK_INFO hook and works.
NetworkInterface enum is kept — the canonical Java iface-enum a detector uses.
The install-recommendation card told the user to "download vpnhide-kmod-….zip"
but gave no link, and the KPM-alternative note said the kmod zip "won't load"
which reads in Russian as "won't download". This wires up actual downloads and
clarifies the wording.
- The recommendation card gets two buttons: a bright one that downloads the exact
recommended zip via GitHub's /releases/latest/download/<asset> redirect (always
the latest release, no hard-coded tag), and a plain "All releases" link for the
ambiguous-variant case or picking anything else.
- Module-problem and version-mismatch banners that name a zip (wrong/unknown/
unsupported variant, ambiguous load, outdated module) get a download button too,
via a new DashboardMessage.downloadArtifact. The outdated-version download is
offered only when the installed module is older than the app.
- Reword the kmod-load strings: "won't load" -> "won't load into your kernel
(wrong variant, or the kernel enforces module signatures)", so it no longer
reads as a download failure.
* Replace gobley/UniFFI with a cargo-ndk + JNI/JSON native transport
The native check probes reached the app through UniFFI, built by the gobley
Gradle plugin — which needs an AGP-9 fork on a private maven repo to build at
all. Now that diagnostics also want a root-exec ground-truth probe (a second,
JSON-over-stdout transport), the whole native surface is just "run the checks,
return a blob", so a typed-FFI codegen framework no longer earns its keep. Both
paths now share one JSON schema from one crate: a cdylib with a single JNI
function for the in-process app-view run, and a vhprobe bin for the root-exec
run. Check logic and diagnostics behaviour are unchanged.
- native: drop uniffi; add serde + jni 0.22.4; crate-type cdylib+rlib plus a
vhprobe bin; run_all_json() and one JNI export
- build: remove the gobley plugins/version and the maven fork repo; add a
config-cache-safe cargo-ndk Exec task (cdylib -> jniLibs, bin -> assets)
wired via preBuild
- app: NativeProbe (loadLibrary + JNI + JSON parse) replaces the generated
bindings; native specs join probe results by id
* Add the root-differential CheckOutcome (who hid the VPN)
Each native check now gets an honest outcome — hidden by the backend, hidden by
SELinux, nothing to leak, a real leak, or not measured — instead of one green
"pass" that conflated all of them. The app runs the probes in-process (its own
filtered view) and execs the same vhprobe binary as root (uid 0 is not a hook
target, so the unfiltered ground truth), then diffs per check: root sees the VPN
and the app doesn't = backend hid it; the app was EACCES-blocked = SELinux hid
it; root also sees nothing = nothing to leak. The legacy passed: Boolean? path
and the dashboard are untouched for now; the outcome is exposed on the agent
bridge for validation.
- native: CheckStatus gains SelinuxBlocked; the four EACCES sites report it
instead of masquerading as pass
- app: CheckOutcome + classifier, and GroundTruthProbe (extract the asset, stage
to /data/local/tmp, su-exec, parse), computed per run and carried on CheckResults
- bridge: AgentCheckResult gains an outcome token for the native checks
* Rewire the dashboard onto LayerStatus (backend health, not pass-count)
The native/Java tiles were NativeResult/JavaResult rolled up from the raw
pass-count, so an unloaded backend read "Partial" (from SELinux-only passes) and
a couple of failing Java probes read a blanket "Not working". Replace both with
one LayerStatus { Absent, Inactive, Active(hidden, leaks) } and a verdict
Ok/Partial/Broken. Presence is gated before the checks, so an inactive backend
can only render "Not active", never Partial; and each backend is judged only on
the vectors it owns — a leak on a not-owned vector (e.g. /proc/net/dev under a
kernel backend) goes to the hero, not the tile. hidden/leaks come from the
root-differential CheckOutcome, so the counts are measured, not inferred.
- DashboardData: ProtectionCheck.Checked carries LayerStatus; drop
NativeResult/JavaResult and toNativeResult/toJavaResult; hero, fully-passed,
and the checks-failed warning read the verdict
- summarizeNativeLayer / summarizeJavaLayer / verdict in LayerStatus.kt
- DashboardScreen: native and Java tiles share one layerSummaryText/Accent
- bridge: AgentProtectionSummary reports the verdict + hidden/leaks
- strings: add "Not active"; unit tests updated (a few Java fails = Partial)
* feat(lsposed): add RTM_GETRULE policy-rule probe, fix if_inet6 attribution
Policy routing is where Android expresses per-app VPN membership: an
`ip rule ... uidrange N-N lookup tun0` steers a UID into the tunnel's own
table, invisible to /proc/net/route (main table only). The new native
probe mirrors the fib_nl_fill_rule kernel filter (VPN iface name, or this
UID steered into a non-standard table), so a working backend reads as
hidden_backend, SELinux-blocked as hidden_selinux, and root ground-truth
as a leak. Validated on the Pixel 4a across enforcing/permissive/root.
Also drop INET6_FILL_IFADDR from the proc_if_inet6 coverage set — no
kernel seq_show hook exists for /proc/net/if_inet6; only the zygisk
openat filter (or SELinux) owns that vector.
* feat(lsposed): rewire diagnostics screen onto CheckOutcome attribution
Each native check now renders its root-differential outcome instead of a
bare pass/fail: a green card with a 'hidden by backend' pill when a hook
did the work, a green card with a blue 'hidden by SELinux' pill when the
app was EACCES-blocked, 'nothing to hide' when nothing was there, red on
a real leak, grey when not measured. The who-hid-it split is what the
whole redesign is for, and it now reads at a glance on the screen.
CheckOutcome rides along on each native CheckResult so the card is a pure
function of the list; Java checks (no root ground truth) keep the
passed tri-state, shown as clean/leak/not-measured in the same vocabulary.
* feat(lsposed): probe legacy NetworkInfo APIs, drop system-proxy check
The NetworkInfo hook (lsposed_network_info) was installed but exercised by
no check — getActiveNetworkInfo() / getNetworkInfo(TYPE_VPN) were untested,
so a green diagnostics run did not actually verify that hook. Add both
probes: getActiveNetworkInfo() marshals a NetworkInfo across Binder (hits
the parcel hook), getNetworkInfo(TYPE_VPN) is the legacy type query the
result sanitizer nulls (issue #85). Together they close the coverage gap so
the Java layer verifies every VPN-hiding hook.
Drop the system-proxy check: no backend hooks the proxy properties, a
consumer VPN sets no system proxy, so it never fired and only added a
green row that proved nothing.
Note: device was in sideload mode at commit time; on-device verification
(both checks pass with LSPosed active) still pending.
* test(lsposed): cover the root-differential classifier and layer rollup
Pins the truth table verified on-device this cycle: classifyNativeOutcome
(leak / hidden-by-backend / hidden-by-SELinux / nothing-to-leak / not-measured,
incl. empty-ground-truth-wins), CheckStatus.toPassed's SELinux mapping, the
Ok/Partial/Broken verdict, and that a native tile is judged only on the vectors
its backend family owns (kernel vs zygisk masks) — an unowned leak surfaces via
unownedNativeLeaks, not the tile. No device needed.
* feat(lsposed): gate diagnostics on VPN Hide being in the tunnel
Diagnostics measure VPN Hide's own process. If VPN Hide is split-tunnelled
out of the VPN, its own traffic never touches the tunnel, so the checks have
nothing to hide from us and a clean run is meaningless. Gate on it: read the
policy routing rules as root (hook-inert ground truth) and ask whether this
app's uid is steered into the VPN's own routing table.
- vhprobe --uid <n>: report {uid, routed, detail}. Identifies the VPN egress
table via an 'oif <vpn-iface>' rule (is_vpn_iface, not a hardcoded tun0),
then checks uidrange membership against exactly that table — so a wlan/rmnet
per-network uidrange rule no longer counts as 'routed through the VPN'.
- New DiagnosticsCache.State.SelfNotRouted + ProtectionCheck.SelfNotRouted;
a SelfNotRoutedPrompt ('add VPN Hide to your tunnel') on both the diagnostics
screen and the dashboard, distinct from 'VPN off'. A null (no-root) answer
does not block. Agent bridge reports state 'self_not_routed'.
Verified on device: uid 10572 (full tunnel) -> routed, 999999 -> not; the
happy path runs the checks (no self_not_routed transition). The blocking path's
prompt still needs a split-tunnel config to see live.
* feat(lsposed): classify Java checks by outcome (hidden/leak)
With the self-in-tunnel gate guaranteeing a VPN artifact is present, a clean
Java-API check means the LSPosed hook removed it and a dirty one is a leak —
so the Java layer becomes binary Leak / HiddenByBackend (NotMeasured only as a
defensive edge for a probe that could not run). classifyJavaOutcome rides onto
each Java CheckResult via withJavaOutcomes; summarizeJavaLayer now reads
hidden/leaks off the outcome so Partial vs Broken is a measurement, mirroring
the native tile. The diagnostics screen renders the same outcome pills for
Java, and the agent bridge threads the Java outcome token.
On-device pill screenshot still pending (GUI nav flaky on this ROM); logic is
unit-tested and the gate round-trip is verified.
* fix(lsposed): probe IPv6 policy rules in RTM_GETRULE and the self-in-tunnel gate
The kernel's RTM_GETRULE dump is per-family, so requesting AF_INET only
returned IPv4 policy rules. Android installs the per-app VPN uidrange/oif
rules for both families, so an IPv6-only rule set was invisible to both the
netlink_getrule diagnostic and the self-in-tunnel gate.
Add a shared dump_fib_rules helper and dump AF_INET + AF_INET6 on the same
socket (distinct seq), merging the results. Verified on-device: the gate
reports routed=true/false correctly and the rule set now covers both families.
* fix(lsposed): tighten diagnostics attribution honesty
Three refinements so the who-hid-it view can't overclaim:
- Java checks that couldn't observe (no active network / capabilities / link
properties, or getNetworkForType unreachable via reflection) now report
passed=null so they classify as NotMeasured instead of a false "hidden by
backend". The self-in-tunnel gate makes these edges near-impossible, but they
must not paint a backend success.
- Fold the Java-implemented native-level probes (NetworkInterface enum,
/proc/net/route via ART) into the unowned-native-leak count. They carry no
root differential, so a leak there was previously invisible to the dashboard.
- Scope the native tile's hidden count to owned vectors like its leak count, so
the two describe the same vector set and a cross-backend hidden can't mask an
owned Broken verdict.
* feat(lsposed): compact the diagnostics screen to a status dot + word, detail on tap
The detailed diagnostics screen was a wall of monospace detail — on an enforcing
device 6-7 cards repeated the same "access denied by SELinux" string, and the long
"Hidden by backend / Hidden by SELinux" pills wrapped the check titles.
Each check now reads as one compact card: name + a coloured status dot + a short
word (OK / SELinux / Leak / No data), matching the status-dot idiom used on the
module rows. The card colour tracks current reality only (green when hidden by the
backend, by SELinux, or nothing to leak; red on a leak; neutral when not measured),
so a normal enforcing device stays all-green and the backend-vs-SELinux attribution
rides on the dot colour, not an alarm. The raw detail is collapsed by default and
revealed on tap; a leak is expanded up front. Drops the misleading "Clean" pill that
showed on access-denied fallback rows.
* ci: resolve the lsposed NDK from ANDROID_NDK_HOME, and rustfmt the probe
Two CI fixes for the branch:
- buildRustProbe hard-coded the NDK path as $SDK/ndk/28.2.13676358, but the CI
image installs the NDK standalone at $ANDROID_NDK_HOME (r28c == that build
number), not under the SDK. Prefer ANDROID_NDK_HOME/ANDROID_NDK_ROOT like the
zygisk build already does, falling back to the SDK-managed path for local dev.
- Run rustfmt over the RTM_GETRULE/gate changes in the probe so `cargo fmt
--check` passes.
* ci: drop orphaned string resources the diagnostics rewire left unused
Android lint (lintDebug, previously skipped because rustfmt failed first) flags
badge_pass/fail/info and dashboard_protection_hooks_inactive as UnusedResources —
they were left behind when the diagnostics screen moved to the outcome model and
the tiles unified onto LayerStatus. Remove them from both locales.
* docs: add docs/diagnostics.md — the self-diagnosis / attribution model
Curated reference for how the app self-tests hiding and attributes who hid the
VPN: the root-differential classifier, the CheckOutcome taxonomy, the
LayerStatus/verdict rollup, the self-in-tunnel gate, the empirical facts that
shape the checks (SELinux carries part of protection; the VPN lives in policy
tables not the main route table), and the native check→hook map. Indexed from
AGENTS.md; complements detection-vectors.md (the hiding side).
* fix(lsposed): show "not checked" on both tiles for every unmeasured state
VPN-off, self-not-routed and needs-restart all mean the layer's hiding checks
did not run, so the Native / Java API tiles now read a plain "not checked" (neutral
accent) in all three — instead of cramming the hero's "VPN is off" title into the
tile for the VPN-off case only. The hero and banner remain the single place that
says why (turn on VPN / add to tunnel / restart); the tiles just report that the
layer was not measured.
* refactor(lsposed): canonical config is the single source of truth for debug logging
The debug flag lived in two stores — the app's SharedPreferences and the
canonical JSON — kept in sync by a startup reconcile plus a drift safety-net,
which was the source of a class of edge cases. Collapse it to one store: the
canonical JSON, with two distinct fields — `debug` (the effective flag every
sink gates on: app logger, system_server HookLog, native backends) and
`debugSwitch` (the user's toggle intent). The Settings toggle writes both; a
capture temporarily forces only `debug` and restores it to `debugSwitch` in a
finally; an interrupted capture self-heals on the next launch via a
`debug <- debugSwitch` reconcile within the same store — no cross-process drift,
no SharedPreferences.
- Add `debugSwitch` to the canonical config (migrates from `debug` when absent);
serialize both. buildCanonicalConfig inherits `debugSwitch` from the existing
config, so rebuild paths (Save / auto-hide / manual-hidden) never clobber the
user's intent when it diverges from the effective flag during a capture.
- The app reads the effective `debug` from the canonical snapshot; the Settings
toggle reflects `debugSwitch`.
- Captures force only `debug` via a targeted copy of the parsed canonical config
(never a rebuild from the legacy targets snapshot), so settings/autoHiddenPackages
are preserved, and restore to `debugSwitch`.
- Replace the prefs<->canonical reconcile and the drift safety-net with a single
startup `debug <- debugSwitch` reconcile; delete SharedPreferences,
runtimeReconcileCanonicalConfig, and reapplyDebugLoggingIfDrifted.
- VpnHideLog gains an always-on e() for errors; stray Log.e routed through it.
- Tests: debugSwitch migration/round-trip, capture session, and that the
auto-hide rebuild preserves debugSwitch.
* refactor(lsposed): centralize log tags and gating, drop double-activator
- add LogTags registry so call sites, HookLog and the debug-capture
logcat filter share one spelling instead of scattered literals
- extract the shared gate-and-level policy into GatedLogger; VpnHideLog
and HookLog keep their own sinks and per-process flag sources
- startup debug reconcile no longer runs the native activator twice
(the reconciled write already runs it; the no-op path runs it once)
* fix(lsposed): re-read canonical before writing the debug toggle
setDebugLoggingEnabled read the cached RootSnapshotCache StateFlow and
then invalidated it on success, with nothing on the Settings screen
repopulating it. A second toggle in the same visit read a null snapshot
and silently no-opped (no write; the switch snapped back). Read via
getOrLoad() so the write always sees fresh canonical state, matching the
capture and startup-reconcile sites.
* feat: upgrade diagnostic captures
Collect richer debug and full-logcat bundles with device, backend, hook, portshide, and kernel partition evidence.
Add an explicit kernel image export for maintainer-requested kernel analysis.
* fix: harden diagnostic capture sessions
* fix(lsposed): harden diagnostic export error paths from review
Address correctness findings from a review of the diagnostic-capture upgrade,
all validated on a Pixel 4a via the agent bridge (kernel/debug/logcat exports).
- Kernel image export: wrap exportKernelImagesZip so a zip/IO failure returns
null (and cleans the parts dir) instead of escaping and stranding the export
button disabled; add try/finally at the DiagnosticsScreen call site. Cap the
per-partition dd (skip unknown/oversized partitions, bound with count=) so a
mislabelled or huge partition can't fill /data.
- HookDiagnostics: drive counter deltas off an explicit "baseline captured"
flag instead of baselineCounters.isEmpty(), so a fresh boot with an empty
baseline no longer reports every delta as n/a. Add HookDiagnosticsTest.
- DebugShellSnapshot: raise the 20s snapshot timeout to 60s and preserve+flag a
timeout-truncated section instead of silently dropping it and every later one.
- LogcatRecorder: delete the intermediate raw logcat file after zipping and on
failed start, so repeated captures stop leaking multi-MB files into cache.
- DebugExport: rethrow CancellationException before the generic catch so
navigating away mid-capture cancels cleanly instead of logging a false error.
- DebugCaptureLogging: when the on-disk canonical config is present but
unparseable, refuse the targets-snapshot rebuild (which would reset settings
and clear autoHiddenPackages); fall back to an app-process-only toggle.
- Dashboard: don't warn "ports rules not active" for a module the user disabled
via their manager (new ports_disabled probe, mirrors the activator's guard).
- activator write_atomic: per-process temp name so a boot-time and app-triggered
activation can't corrupt each other's load_status.
- Drop the wasted multi-user pm enumeration from the counter baseline command.
- classifyKpmProblem: new red-banner diagnosis for a KPM install whose
activator failed to load (missing binary, or a generic activator
exit failure), reusing the boot script's `detail` status field that
was previously parsed only for the conflict/awaiting-superkey cases
- NativeBackendStates.anyInstalled/noneInstalled: single source of
truth for the three call sites that used to hand-roll their own
kmod/kpm/zygisk boolean combination
- kmod's stamped GKI variant is now shown on its dashboard card, and
an outdated-kmod warning names the exact recommended zip — updating
an old kmod install no longer means guessing which variant to grab
Fixes#225
* fix(lsposed): reconcile auto-hidden VPN apps on startup and Refresh
The auto-hidden VPN-app set was only re-materialized on an explicit Save,
so a VPN app installed after AppListCache had loaded its snapshot stayed
visible to detector apps until the user opened the Hiding tab and saved
again. Toggling auto-hide off/on didn't help — it re-materializes from the
same stale cached app list.
Watch the installed-app list and re-derive settings.autoHiddenPackages
whenever it (re)loads — at cold start and on Hiding-tab Refresh — persisting
only when the set actually changed. The write is idempotent and preserves
every manual role, since it reuses applyAutoHiddenPackages, which touches
only the auto-hidden set and the hidden flags derived from it.
* fix(lsposed): skip startup debug-flag re-propagation when already in sync
The cold-start safety-net re-propagated the persisted debug flag to the on-disk
canonical config and the native activator on every launch, rewriting the config
with byte-identical content each time — an extra su invocation, a config write,
an activator run, and a system_server FileObserver re-read per cold start.
Guard it with reapplyDebugLoggingIfDrifted: only re-propagate when the on-disk
flag has actually drifted from the persisted preference. Capture paths
(LogcatRecorder / debug export) are unaffected — they gate their own
applyDebugLoggingRuntime calls on VpnHideLog.enabled and must still write both
directions, so the guard lives at the startup call site, not in
writeDebugFlagFiles.
* docs: refresh installation and backend docs
* docs: fix review findings and add KPM to update-json
Cross-checked the docs refresh against the code and corrected:
- protocol.md: config parser is vpnhide_parse_config (not the legacy
vpnhide_parse_target_uids); path is kmod/shared/vpnhide_logic.h; drop the
unverified 0x2026 KPatch-Next marker (activator drives it via the CLI).
- zygisk/README: module declares Zygisk API v2 (not v5); add the recv hook
row and the if<N> renamed-netdev match.
- README (en/ru): live-check counts are 13 native / 12 Java; Diagnostics is
under Settings, not a tab; mention the Statistics tab; note KPM beta parity.
- detection-vectors: KPM host-route helper is kpm_is_public_host_route{4,6};
restore CONFIG_KPROBES and the Zygisk qualifiers; codegen renders four targets.
- storage.md: legacy files are no longer user-managed (migration inputs), not
'gone'. CLAUDE.md: activator has four bins (kmod/kpm/zygisk/ports).
- development/ROADMAP/kmod/lsposed/portshide READMEs + AGENTS.md: assorted
accuracy fixes (KPM needs a KernelPatch runtime, per-KMI kmod zip name, etc.).
- CONTRIBUTING.md: clang-format uses scripts/clang-format-c.sh (covers KPM src).
- portshide customize.sh: 'Hiding -> Ports' (renamed tab).
- update-json.sh: emit update-kpm.json so KPM gets Magisk/KSU update checks.
* docs: correct diagnostics check count (25, not 26 vectors)
* docs: refresh README screenshots for the 1.0.0 UI
Replace the v0.5.3-era screenshots (old per-tab Protection UI) with a
new 1.0.0 set: VPN-hidden dashboard, the unified Hiding list with
J/N/A/P, the Statistics tab + per-hook breakdown, per-app hook selection,
diagnostics, settings, and the community screen. Extra shots
(statistics-apps, settings-advanced, diagnostics-java, settings-developer)
are included for the release notes.
* docs: fix the how-it-works layer breakdown
The old 'Layer 3 — additional app-level controls' bucket was wrong:
interface hiding is what Layers 1+2 already do, app-hiding is done by the
LSPosed/Java layer (system_server PackageManager hooks), and ports is a
separate module. Fold app-hiding into Layer 1 (LSPosed) and make Layer 3
the standalone portshide module.
* docs: conceptual-coherence pass on the 5 core docs
Second review pass, this time for architectural coherence rather than
code-facts. Fixes:
- README (en/ru): 'What vpnhide hides' stopped reifying interface hiding
as a standalone 'layer'/'role' and now ties the three hiding goals to
the four J/N/A/P roles (interface = Java + Native, toggled independently)
- README (ru): add the KPM-beta hook-parity caveat (parity with the EN side)
- protocol.md: app-hiding is a normal lsposed mask bit, not a separate
'package/observer records' entry in the §6 profile table
- detection-vectors.md: a complete install is two components (native + lsposed),
not 'two roles'; name both lsposed jobs
- storage.md: scope the text protocol to the config wire (LSPosed reuses the
format for its state file); 'by function' not 'by role'; note the Apps role
shares the same resolver; heading no longer files ports under 'Native layer'
release.py bumps the crate versions in Cargo.toml but not the lockfiles;
cargo rewrites them on first build, which left the tree dirty and stamped
artifacts as 1.0.0-dirty. Commit the synced locks so builds are clean 1.0.0.
* feat(lsposed): localize Removed/Deprecated/Security changelog headers
The in-app What's-new dialog only mapped added/changed/fixed/notes to
localized labels and fell back to the raw type string for anything else.
1.0.0 introduces a Security section, so add localized headers for
removed/deprecated/security too.
* chore(changelog): consolidate dev-cycle fragments into 1.0.0 release notes
Collapse 105 changelog.d fragments accumulated since v0.7.1 into 28
curated, flagship-first bullets. Features born this cycle (KPM backend,
Statistics tab, per-app hook selection, M3 redesign, the unified Hiding
list, ...) are each stated once in their final state instead of the
add/change/fix iteration users never saw; the VPN-detection work is
grouped by surface with example apps; pure internal refactors are dropped.
Rendered/verified with preview-changelog.py; release.py rotates these at
release time.
* feat(lsposed): add background update checks
* refactor(lsposed): show the update-checks prompt only after root is granted
The first-run prompt rendered above the root gate, so it could pop over
the loading and no-root screens where the app is inert. Move it into the
root-granted branch so it only appears once the app is actually usable.
* fix(lsposed): persist the update-notification dedup marker in DataStore
The 'last notified version' marker was written to a separate
SharedPreferences via async apply(). A WorkManager worker's process can
be torn down the moment doWork() returns, before that fire-and-forget
write flushes — verified on-device: the notification posted but the
marker never hit disk, so the daily check would re-notify for the same
release every run. Store it in the app's existing DataStore (like every
other setting) with a synchronous suspend write that completes before
the worker finishes.
* feat(lsposed): drop the duplicate per-level protection status on the Dashboard
The «Статус скрытия» section restated the hero card's «Нативный уровень» /
«Java API уровень» tiles verbatim (same label, same status text, same colour —
both derive from nativeSummaryText/javaSummaryText), adding no information in
any state. It's removed. The critical branches that lived in that section —
the VPN-off prompt and the needs-restart banner — move to a slot right under
the hero, so they're more prominent, not buried mid-screen. In the all-good
state the dashboard is now just hero + Модули. Removes the now-dead
NativeProtectionCard / JavaProtectionCard / ProtectionCardShell and the unused
dashboard_protection string.
* feat(lsposed): drop the duplicate status pill in the Dashboard hero
The hero rendered its verdict twice — the big «VPN скрыт» title and a
coloured StatusPill on the right, both from visual.titleRes. The pill only
added colour, which the status-tinted shield icon already carries. Removed
the pill (and the now-dead private StatusPill composable); the verdict text
appears once.
* feat(lsposed): warn + link to diagnostics when hiding checks fail; reword debug-logging copy
- New Dashboard warning: when a layer is active but some runtime probes still
leak (NativeResult.Fail / JavaResult.Fail) the state used to show only as an
amber hero/tile with no explanation. Now it adds a WARNING message with a
'Details' button that opens the full diagnostics screen.
- Centralised the message action→button mapping into one messageActionSlot()
used by all three message loops, so a new action is an enum case + one branch
(not an edit per loop). Added DashboardMessageAction.OpenDiagnostics + a
top-level diagnostics overlay in MainActivity (reuses DiagnosticsSettingsScreen).
- Reworded settings_debug_logging_sub: off by default to keep logcat quiet and
save resources — not 'for stealth'. Third-party apps can't read another app's
logcat, so the stealth framing was a wrong threat model.
* fix(lsposed): drop the confusing cache note from the VPN-off prompt
The VPN-off prompt ended with 'Results are cached until VPN Hide is
restarted — no need to re-run later in the same session' — an internal
caching detail that answers a concern the user doesn't have, and on the
VPN-off screen it forward-references results that don't exist yet. Cut it;
the prompt is now just the actionable core (turn the VPN on, tap Retry).
Reframes the app's vocabulary around hiding the VPN and cleans up the
Russian and English UI copy. The Dashboard status now reads
'VPN hidden / VPN visible', the Protection tab is renamed to Hiding, and a
lot of anglicisms and awkward Russian phrasings are tightened. The
'targets/цели' concept and the Пресет / ИНФО / ПРОВАЛ / Squircle-углы
labels are kept. No logic changes — only strings and doc comments.
* feat(lsposed): app icons + names in Statistics, live capture session
The per-app probe list now resolves each UID to its app icon and
friendly label (with the package name in monospace below), the same way
the Protection tab does, instead of showing the bare package name. UIDs
with no installed match fall back to a neutral placeholder avatar.
The capture session is now live: while a session is active the screen
re-reads the backend counters every couple of seconds, so probes show up
on their own as the user exercises a target app — the manual Refresh step
is gone (the top-bar refresh stays as an optional manual reload). The
card now leads with a numbered three-step how-to, and the session list is
relabelled 'Probes this session' to set it apart from the all-time list.
* feat(lsposed): keep capture results after Stop until cleared
Stopping a capture session used to discard everything the user had just
gathered and snap back to the all-time list. Now Stop freezes the
session's per-app results and keeps them on screen: the card shows
'Captured · M:SS · N apps' with New capture / Clear actions, and the
probe list stays put until the user explicitly clears it or starts a
fresh capture. Introduces an explicit three-state model (Idle / Live /
Stopped) for the capture section.
* fix(lsposed): more natural capture copy (drop 'Live'/'Вживую')
The live-status label read 'Live'/'Вживую' — a literal calque that no
Russian app uses for an in-progress capture; it now reads 'Capturing' /
'Идёт захват', matching the section's 'захват' vocabulary (with the
finished label moving from 'Поймано' to 'Захвачено'). The third how-to
step answered a question users don't have ('do I need to come back and
refresh?'); it now states the useful, positive outcome instead — the
checks show up in the list below. Also drops the '— live' tail from the
intro line.
* feat(lsposed): fold the Backends section into the Statistics top card
The standalone 'Backends' section sat awkwardly between the capture card
and the app list. It's now folded into the hero: a big total-events
headline, an 'N apps · M methods' caption, and a compact per-backend
strip (health dot + name + 'OK · hooks: N' + event count) inside the same
card. The redundant 'active X/Y' metric is dropped — the strip already
lists the active backends. Tab flow is now [top card] -> [capture] ->
[apps] with no foreign mid-screen section. Removes BackendSummaryCard /
BackendBadge and the now-unused metric strings.
* docs: changelog for Statistics top-card consolidation
* fix(lsposed): lay the Statistics total on one baseline, fill the row
The total read as a big number with 'events' stranded on its own line and
a lot of empty space to the right. It's now one baseline-aligned line —
'1,337 events' together — with the 'N apps · M methods' summary pushed to
the right edge so the headline fills the card width.
* fix(lsposed): flow the Statistics headline so RU doesn't clip on narrow screens
The single baseline row clipped on a small screen in Russian, where
'1,337 события' + the longer 'приложений: N · способов: M' overran the
card width and collided. The headline is now a FlowRow: the number and
its unit stay together as one block, and the apps/methods summary flows
onto the same line when it fits (English) or wraps below when it doesn't
(Russian on a narrow screen). No clipping in any locale.
* feat(lsposed): editorial summary sentence for the Statistics headline
Replaces the number + stranded unit + apps/methods caption with a
centred editorial line: the grand total as a big blue figure, then
'<events> recorded across <apps> via <methods>' with the two scope
figures highlighted in green. Built as one wrapping AnnotatedString so it
never clips in any locale, and the words are resolved through plurals so
the Russian case/number forms agree with each count (событие/события/
событий, среди N приложений, N способом/способами).
* feat(lsposed): make the Statistics headline one inline sentence
The total and the rest of the summary were two separate Text elements, so
they always sat on different lines. They're now a single AnnotatedString
paragraph: the big blue total is inline at the start of the sentence (one
element, baseline-aligned by the paragraph itself), and the green scope
figures are enlarged (headlineSmall). Adds a second sentinel (TOTAL_MARK)
so the total and the scope figures can carry different styles in the one
string; the summary template gains the total as %1.
* refactor(lsposed): move resolveAppSummary to ProbeStats.kt with a unit test
Per lsposed/AGENTS.md, pure logic belongs in a top-level *Data function with
a unit test, not in a composable file. resolveAppSummary (a pure UID→package
lookup) moves from StatisticsScreen.kt to ProbeStats.kt next to its analogues
(buildAppProbeStats/diffCapture), and gains ProbeStatsTest coverage for the
installed-match, shared-uid first-match, and uninstalled/empty fallback.
* fix(lsposed): address review nits on the Statistics redesign
Six low/nit items from the branch review:
- EN capture chips read 'apps: N' instead of an ungrammatical '1 apps' in
the common single-app case (mirrors the RU colon form).
- AppStatAvatar rasterises each icon once per Drawable via remember() instead
of on every recomposition — the live-capture 1 Hz tick was re-rasterising
every visible row's icon every second.
- The capture session (baseline / start / frozen result) lives in a
process-scoped holder, so it survives an Activity recreation (rotation,
day/night auto-switch, resize) and a tab switch instead of silently
resetting. Verified on device: session + elapsed clock + probes persist
across a day/night flip.
- Dropped the dead write-only HealthVisual.container field left after the
BackendBadge removal.
- Capture stopwatch uses SystemClock.elapsedRealtime() (monotonic) instead of
wall-clock System.currentTimeMillis(), so an NTP/manual time step can't skew
the shown duration.
- Plural-word selection derives the category from the last two digits
(totalCount % 100) rather than clamping to Int.MAX_VALUE, so the Russian
word agrees with the figure above 2^31 events.
The 'Root access required' gate (RootDeniedScreen) was the barest of the app's
'can't proceed' cards: no icon, no button, and an all-red top bar. Worst of all,
the root probe ran once in LaunchedEffect(Unit), so after granting root the user
had to fully kill and reopen the app.
- Extract a shared BlockingErrorCard (ui/components) so every full-screen
blocking state renders through one composable — no-root and the startup-prep
error now use it, so they stay consistent and a redesign can't miss one again.
- Modernize RootDeniedScreen: lock icon, neutral app-chrome top bar + screen
background (only the card carries the error colour, like the rest of the app),
and a 'Check again' button that re-probes root without relaunching.
- Migrate RootPreparationErrorScreen onto the same card.
Navigation stays blocked: the no-root branch shows only this screen; the tabs
live in MainScreen, reachable only when root is granted.
Verified on a real Pixel 4a in BOTH light and dark themes (forced the no-root
state): errorContainer/onErrorContainer adapt per theme, top bar and background
match the app chrome, icon + title + body + button render correctly. ktlint,
detekt, lintDebug, and unit tests pass.
The 'Startup preparation failed' card always rendered a static message telling
the user to 'check your root manager if permission was interrupted', even though
the real reason was carried all the way to the composable and then dropped at the
render call. For the common incomplete-snapshot case (a backend's section missing,
e.g. a garbled KPM reply -> 'missing sections: kpm_state') that message actively
misleads: root WAS granted, nothing was interrupted.
Model the failure cause (SelfTargetFailureKind: RootUnavailable / IncompleteData /
ConfigWriteFailed / Unknown) where it's actually known, thread it through
SelfTargetPreparation -> StartupSelfTargetState.Failed, and render a
cause-appropriate body plus the underlying detail string (monospace) in the card.
It's the app's own screen, so showing the technical detail leaks nothing.
Verified on-device (forced the IncompleteData path): the card shows the accurate
body and 'root snapshot incomplete, missing sections: kpm_state' verbatim.
ktlint + detekt + lintDebug + unit tests pass.
The Protection tab's app filters used to be able to hide apps the user had
already configured. The system-apps filter already exempted configured apps
(showSystem || !isSystem || anySelected), but the 'Russian apps only' filter
did not — turning it on hid a configured non-Russian app. Make the Russian
filter consistent: it (like the system filter) narrows the discovery set for
new apps but never hides an already-configured one.
Also remove the now-redundant 'Configured only' filter. The default
ConfiguredFirst sort already groups configured apps into a 'Configured' section
at the top (alphabetised within the group), so 'Configured only' only collapsed
the 'Other apps' section below — staying on ConfiguredFirst gives the same
alphabetical configured list. This drops showConfiguredOnly threading through
ProtectionScreen -> AppPickerScreen -> TargetPickerScreen -> visibleTargetEntries,
the filter menu item, and both string resources.
Updated the unit test that asserted the old configured-only behaviour to instead
assert configured apps survive the Russian filter. detekt + ktlint + lintDebug +
unit tests pass.
The per-app hook-config dialog could silently turn an app into a full target.
`app.javaHooks`/`nativeHooks` is null for BOTH "role on with all hooks" and
"role off", so opening the dialog on a disabled role showed every hook
pre-checked, and Save resolved all-checked to null -> role enabled. Thread the
role's enabled state into HooksDialog and start with nothing checked when the
role is off, so inspecting (or Save) no longer creates an unintended target.
Also from the audit, three UI cleanups with no behavior change:
- Memoize the Statistics capture diff with remember(baseline, stats, self) so the
once-per-second elapsed-clock tick no longer rebuilds the per-app rollup.
- Promote one shared SectionHeader to ui/components and delete the four
near-identical per-screen copies (two were byte-identical).
- Promote shared MetricTile + IconBubble for the Dashboard/Statistics hero cards,
replacing the copy-pasted pair (unifying a 3dp/4dp label-gap drift to 4dp).
* fix(native): drop dead proc diagnostics, fix misaligned reads, parser parity
Three native-correctness fixes from the codebase audit.
Diagnostics: /proc/net/{tcp,tcp6,udp,udp6,fib_trie} expose the VPN only as a
hex local address, never as an interface name, so the name-matching probe could
only ever report a green "no VPN entries" regardless of an actual leak — and
from inside the targeted (self-hidden) process there is no reference tunnel
address to decode and compare. Remove those five checks rather than keep a
meaningless always-pass; the address-leak vector on these files is covered by
zygisk's socket-table filters. /proc/net/{route,ipv6_route,if_inet6,dev}, which
do carry interface names, are unchanged.
UB: SIOCGIFCONF and the netlink dumps reinterpret the kernel's bytes as ifreq /
nlmsghdr / rtattr over plain [u8; N] buffers (align 1), so forming those refs is
undefined behaviour. Back the three buffers with an 8-byte-aligned wrapper so
every such reference is well-formed.
Protocol parity: the Rust parse_header skipped a non-ASCII first significant
line and searched on, while C (kmod/KPM) and Kotlin (LSPosed) reject the whole
payload — same wire bytes, different accept decision across the mutually
exclusive backends. Distinguish "blank/comment" (skip) from "significant but
non-ASCII" (reject) so all three agree. Clarify the §4.2 corner in the spec and
add header-position golden vectors (cfg + kind) that all three parsers now pass.
* style: rustfmt protocol parse_header
The debug HTTP bridge runs a single accept/serve thread with no read deadline,
so any local peer can connect and send nothing (or a partial request) to wedge
it forever before auth is ever checked.
- Set a 5s read timeout on every accepted socket, and track the active client so
stop() can close it — thread interrupt and ServerSocket.close() cannot abort a
blocking read on an accepted client socket, so without this the worker leaks.
- Authenticate on the request headers before reading the body, reject a
Content-Length over 256 KiB, and cap request/header lines at 8 KiB, so an
unauthenticated peer cannot force a large allocation pre-auth.
The bearer token is still logged on purpose: tools/agent-mcp/server.py reads it
from logcat as the fallback for retrieving it on release builds, where
`run-as cat files/agent_bridge_token` is unavailable. Documented at the call
site so it is not mistaken for a leak again.
* fix(lsposed): config resilience, update-check, diagnostics race, off-thread saves
- StorageConfig: parse portPolicy best-effort — a single out-of-range port rule
used to throw and unwind the whole canonical-config parse, silently disabling
every hook. Drop the bad rule/policy (ports-disabled for that app) instead.
- UpdateChecker: return a sealed UpdateCheckResult (Available/UpToDate/Failed)
so UpdateCheckCache can tell a transient failure from 'no update' — on failure
it now keeps any found update and doesn't advance the staleness timestamp, so
the next check retries instead of caching the failure for 6 hours.
- DiagnosticsCache: synchronize run() and relaunch when the Running state has a
dead inflight job (scope cancelled mid-run), so Diagnostics/Dashboard can't
wedge on a stale Running forever; reset Running->NotRun on cancellation.
- DiagnosticsScreen: run the debug-zip and logcat file copies on Dispatchers.IO
with runCatching, so a large file can't block the UI and a write failure can't
crash the app.
* fix(lsposed): guard diagnostics cancellation-reset on job identity
The DiagnosticsCache CancellationException handler ran its
`if (_state is Running) _state = NotRun` reset outside the @Synchronized
monitor (doRun's body runs async on a dispatcher after run() returned). A
cancelled run could therefore land its catch after a later run() had already
fallen through the dead-job guard and relaunched — clobbering the new run's
Running state with a spurious NotRun (a transient diagnostics flicker, not a
wedge, since the live run still completes to Ready).
Move the reset into a @Synchronized helper that only fires when the cancelling
job is still the current inflight job, so the inflight/_state read-modify-write
is atomic against a concurrent relaunch and only our own cancellation resets.
detekt + ktlint + compileDebugKotlin + testDebugUnitTest all pass.
* fix(lsposed): close LinkProperties and UID-name stealth gaps
- PackageVisibilityHooks: hook getNameForUid and getNamesForUids so a hidden
package can't be discovered by resolving a uid back to its name (the dual of
the already-hooked getPackagesForUid).
- sanitizeLinkProperties: also clear mLinkAddresses (the tunnel's assigned IP)
and mDnses (the VPN's DNS servers) for a VPN LinkProperties — they carry no
interface tag and were read straight back via getLinkAddresses()/
getDnsServers(). Extract the routes/stacked-links blocks into helpers to keep
complexity in check.
* style: document NestedBlockDepth suppress reason
* refactor(lsposed): drop the card-shadows setting and make Debug logging a consistent row
Remove the card-shadows appearance toggle and its plumbing
(AppSettings/Repository/Interactor, the DataStore key, strings): the
shadow was a fixed 1dp that's barely visible, and cards already separate
via their hairline border. Modifier.container now always applies the
default elevation.
Also reshape the Debug logging control: it was a standalone EnhancedCard
that looked foreign next to the Developer section's grouped preference
rows. Make it a PreferenceRowSwitch like the version and agent-control
toggles (icon + concise subtitle), and delete the now-orphaned
DebugLoggingCard.
* docs(changelog): note Debug logging is now a settings row
Tighten the existing (unreleased) move fragment so the single changelog
line reflects the row presentation, rather than adding a second entry for
the same user-facing change.
* refactor(lsposed): split DiagnosticsScreen into UI, checks, and export
DiagnosticsScreen.kt mixed three concerns in ~1240 lines. Extract the
~20 Java/native probe functions into JavaChecks.kt and the debug-zip /
device-info gathering into DebugExport.kt, leaving DiagnosticsScreen.kt
as the screen UI. No behaviour change; exportDebugZip becomes internal
so the UI section can still call it across the file boundary.
* feat(lsposed): move Debug logging into the Developer settings section
Debug logging is a developer toggle, so it now lives in the Developer
section alongside the version-warning and agent-control toggles instead
of the Debugging tools section. The capture tools (logcat recording,
Collect debug log) stay under Debugging; reword the description so it no
longer says "below" now that the toggle sits elsewhere.
A run that threw (root dropped, shell exec failure) set DiagnosticsCache
to VpnOff, so a user with the VPN on saw a misleading "VPN is off, retry"
banner. Add a distinct State.Failed with its own "checks failed, retry"
prompt; awaitFullResults now also treats Failed as terminal so callers
don't hang.
Add a dashboard warning when KPM is installed under APatch but dormant
because no superkey is saved (runtime=apatch, detail=awaiting_superkey for
the current boot) — it previously read as inactive with no reason. Demote
the no-targets message from error to info, since a fresh, not-yet-configured
install isn't broken. Drop the drifted I1/I2/W2/W4/W5 comment prefixes (the
err/warn/info call already conveys severity) and note the open permissive-
SELinux severity/coverage questions in the roadmap.
* feat(lsposed): add debug agent bridge
* feat(tools): add agent bridge MCP server
* fix(lsposed): address agent bridge review feedback
* fix(tools): format agent bridge MCP server
* fix(tools): sort MCP server imports
* fix(lsposed): centralize agent bridge lifecycle
* fix(tools): detach adb stdin in agent bridge MCP server
adb subprocesses inherited the server's stdin, which carries the MCP
JSON-RPC frames, so the adb child consumed handshake bytes and wedged
every MCP session. Run adb with stdin=DEVNULL. Switch the server shebang
to the project's uv run --script convention and add smoke-test.py, an
end-to-end MCP handshake check.
* feat(lsposed): move agent control toggle into the Developer section
The agent bridge is debug-only, so its toggle belongs with the other
developer knobs rather than the Debugging tools section. Gate it behind
BuildConfig.DEBUG so release builds (whose AgentControlBridge is a no-op
stub) don't show a dead switch.
* feat(lsposed): ship agent bridge in release, off by default
The bridge was debug-only (real impl in src/debug, no-op stub in
src/release, toggle gated by BuildConfig.DEBUG), which hid it from the
release builds used for development. Move AgentControlBridge into the
main source set so it builds everywhere, drop the BuildConfig.DEBUG
guards, and keep the toggle off by default. Because an enabled bridge
opens a loopback port other apps can use to detect VPN Hide, surface a
dashboard info note while it is on. The host token path falls back to
logcat on release builds, where run-as is unavailable.
* style(lsposed): expression body for startServer
ktlint's function-expression-body rule fires now that the early
BuildConfig.DEBUG return is gone and startServer is a single expression.
Broaden the Developer toggle to additionally skip the post-rebuild changelog
dialog, so repeated dev reinstalls stop popping it. Reads the persisted flag
directly to avoid racing the cold-start DataStore load; when suppressed it
also skips markChangelogSeen so turning the toggle off still shows the
changelog for the current version.
Compare the running LSPosed hook against the installed app by full version
(dev suffix included) by default, since the hook lives in system_server and
only swaps on reboot. Add a Developer settings section with a toggle to fall
back to base-version compare for developers who reinstall without rebooting.
Release versions carry no dev suffix, so end users see no change.
The per-app Java / Native hook selection sheets now group hooks under a method
header (friendly name + an explanation of what the probe is and how an app uses
it to detect a VPN), reusing the DetectionMethod taxonomy and descriptions from
the Statistics screen. Individual hook checkboxes keep their technical note to
disambiguate the exact API; the raw hook id is no longer surfaced.
The per-hook detail dialog now describes each detection method — what the probe
is and how an app uses it to infer a VPN is up (routing table, interface
enumeration, NetworkCapabilities transport flag, etc.) — grouped surface →
method (label + count + explanation) → the exact hooks behind it when a method
folds several. Adds method_desc_* strings (EN + RU) on the DetectionMethod
taxonomy.
- Capture-session intro in plainer language: drop the 'snapshot the counters'
jargon, describe the flow directly.
- The per-hook detail dialog now groups hooks under coloured Java API / Native /
Packages headers, so it's clear which detection techniques are framework-level
vs native syscall/libc.
Start a capture: the app snapshots the cumulative counters as a baseline, you
open and exercise a target app, then Refresh — the list switches to deltas
(current − baseline) showing exactly what that app probed in the window, on any
active backend. Stop ends the session and returns to the cumulative view.
Pure baseline-diff, no backend reset command and no per-event timestamps. A
counter dropping below its baseline (reboot / system_server restart / Zygisk
re-inject) is detected and the session re-baselines automatically. v1 is
manual-Refresh; live polling can follow if the manual flow feels clunky.
Also: the apps caption now hints that tapping an app opens the per-hook detail.
- ProbeStats: snapshotCounters + diffCapture (pure, unit-tested incl. reset).
- StatisticsScreen: CaptureControlCard (start / elapsed + count / refresh + stop),
capture-aware apps section. EN + RU strings.