The off-by-default toggle silenced VpnHide logs everywhere — which
silently broke the two diagnostic capture paths that bug reports
actually depend on: Start recording would share a logcat with zero
VpnHide-tagged lines, and Collect debug log would zip up a useless
`logcat.txt` stub.
Extract applyDebugLoggingRuntime() that pushes the flag to runtime
sinks (VpnHideLog.enabled + the two flag files) without writing
SharedPreferences. exportDebugZip and LogcatRecorder.start call it to
force-enable on entry when the persisted preference is OFF; on exit
they reconcile against the current SharedPreferences state so a user
who flipped the UI toggle mid-capture wins over the rollback.
Rewrites the toggle's description string to reflect that users don't
need to touch it for one-off bug reports — the capture buttons handle
it — and keep the toggle as an escape hatch for "emit logs
continuously" cases.
Users installing VPN Hide for stealth didn't want the app, LSPosed
hooks, and zygisk writing per-request lines to logcat that a forensic
analysis with root could read back. Add a single toggle in Diagnostics
that silences all three layers; errors still pass through so hook-
install failures stay diagnosable.
Why each piece:
- VpnHideLog + HookLog wrap Log.i/d/w and XposedBridge.log respectively,
gated by @Volatile flags. i/d/w only; e always prints.
- SharedPreferences is the source of truth for the app process;
system_server hooks read /data/system/vpnhide_debug_logging (inotify-
watched, same pattern as vpnhide_uids.txt — flip is live without
rebooting); zygisk reads debug_logging from its module dir via the
existing dir fd in on_load, then calls log::set_max_level.
- Default Off matches the project's stealth-first stance. When disabled,
zygisk stays at LevelFilter::Error so the rare hook-install failure
is still visible.
- Zygisk-hooked apps need a restart for the change to take effect
(flag is read at on_load). App and system_server hooks pick it up
immediately. Documented in the toggle's description.
Also aligns lsposed/native/Cargo.lock with Cargo.toml (0.6.1 → 0.6.2)
— same stale-lock fix surfaced by the gradle build as in the previous PR.
Kernel-module, Zygisk and Ports module cards were showing
'vX.Y.Z' (from module.prop, Magisk convention), while the LSPosed
hook card showed 'X.Y.Z' (from APK versionName, Android convention) —
the same information rendered two different ways on one screen.
Normalize at parse: parseModuleProp now strips the 'v' prefix via
normalizeVersion, so every downstream consumer — dashboard cards,
mismatch-issue text, update checks — sees a plain semver string.
module.prop files themselves keep the 'v' prefix (Magisk ecosystem
still expects it, and Magisk's update flow reads versionCode anyway).
Two tweaks driven by the same goal — make the artifact list on the CI
run page less ambiguous and give the release step a review gate.
- The APK artifact was named `vpnhide`, which blends in with the other
module-zip artifacts (`vpnhide-kmod-*`, `vpnhide-zygisk`,
`vpnhide-ports`). Rename to `vpnhide-apk` so every entry in the
Artifacts list names the thing you actually get when you download it.
- Release-on-tag job now creates a DRAFT GitHub release instead of
publishing directly. Gives a chance to eyeball the release notes and
attached binaries before they go public, and avoids racing
update-json.sh against the assets becoming reachable.
docs/releasing.md and the release.py post-run hints updated to reflect
the manual Publish step and the fact that update-json still has to
wait for the release to be *published*, not just drafted (draft
release assets sit behind auth).
Yesterday's Phase 2 commit left the zygisk and portshide CI artifacts
carrying a "-dirty" suffix in their module.prop version: CI appended
`updateJson=...` to the committed module.prop *before* calling
build-zip.sh, so when build-version.sh ran inside the script it saw
the dirtied working tree and `git describe --dirty` appended "-dirty".
Move the updateJson injection into build-zip.sh itself, gated on an
UPDATE_JSON_URL env var. CI sets the env var via the job step `env:`
block; committed module.prop files are no longer touched. Local dev
builds leave the var unset and ship without updateJson, matching the
previous behaviour.
kmod CI already did things in the right order (version computed
before any module.prop edits); left that step as-is.
Add scripts/build-version.sh — a single source of truth for the
effective version string:
* HEAD on tag vX.Y.Z -> "X.Y.Z"
* N commits past tag -> "X.Y.Z-N-gSHA"
* working tree dirty -> additional "-dirty" suffix
* no git / no matching tag -> VERSION file fallback
Wired into every packaging path:
* zygisk/build-zip.sh and portshide/build-zip.sh now stage a copy of
module/ and sed-patch `version=` in the staging copy, so committed
module.prop files stay at the last-released version.
* kmod/build-zip.sh now builds into a staging copy too.
* The kmod CI step runs build-version.sh and sed-patches module.prop
before zipping (git installed in the DDK container).
* lsposed/app/build.gradle.kts exec's build-version.sh at configure
time and assigns the result to `versionName` (versionCode stays
static, still bumped by release.py).
All actions/checkout@v6 gained `fetch-depth: 0` so git describe sees
the full tag history inside CI containers.
Result: a locally built or CI-from-main APK shows up in Android
Settings as e.g. `0.6.1-16-gf86e5e5`, and the zip inside carries the
same string in module.prop; the Magisk/KSU manager displays it in the
update list. Release tag builds are indistinguishable from before —
clean `X.Y.Z`. Diagnostic bug reports now carry the exact commit in
the App version line of device_info.txt.
Mirror the debug-zip collection UI: after a logcat capture is stopped,
show a small "Last recording · 0:23 · 3.1 MB" caption above a row of
Save + Share buttons, with Start for a new recording underneath.
Save opens the SAF picker (CreateDocument, text/plain) and writes the
capture to the chosen destination via ContentResolver, same pattern as
the debug-zip Save button — addresses the prior flow where users who
picked "Save to Downloads" from the Share sheet ended up with no file
actually persisted.
The size/duration moved from the Share button label into a shared
caption above both buttons, so it's clear the stats describe the file
both buttons act on. Reused R.string.btn_save and R.string.btn_share_debug
for the labels.
LogcatRecorder.State.Stopped gains lastDurationMs (computed from
startMs on stop) so the caption can show elapsed time even after the
Recording state is gone.
Rework the changelog and release flow to remove the aspirational
top-level version that made it unclear whether new entries were
landing in an already-released section.
Schema change: `changelog.json` now has an explicit `unreleased`
object instead of hoisting the upcoming version to the top level. The
old `{version, sections, history}` layout becomes
`{unreleased, history}`, with the previously-released version moved
into `history[0]`.
New entries always go into `unreleased` via `changelog.py`. Releasing
is a single atomic operation (`release.py X.Y.Z`) that promotes
`unreleased` into `history[0]` with the target version number,
propagates the version to every source file, and regenerates the
markdown artifacts.
Script renames:
- `_changelog.py` → `changelog_lib.py` (no more underscore-prefixed
module that's imported by two siblings)
- `changelog-add.py` → `changelog.py`
- `update-version.py` → `release.py` (does more than just version
propagation — the name now reflects the full release action)
CHANGELOG.md rendering follows Keep a Changelog: a `## [Unreleased]`
block appears on top only when there are unreleased entries; the
update-json/changelog.md shown in Magisk/KSU popups still skips
Unreleased (only released versions make sense there).
Docs (docs/changelog.md, docs/releasing.md, CONTRIBUTING.md, CLAUDE.md)
updated with the new commands and the clarified model.
CLAUDE.md additionally gains a "read these before doing any work"
section that lists the contributor docs — so future sessions load the
workflow rules into context instead of skipping them as optional.
Split the contributor-facing knowledge that used to live in the local
CLAUDE.md into versioned, public docs:
- CONTRIBUTING.md — PR process, commit conventions, required changelog
entry for user-visible changes, code-style checks.
- docs/development.md — prereqs, keystore setup, per-module build
commands, device install, CI lints.
- docs/releasing.md — VERSION bump → update-version.py → tag → CI →
update-json.sh flow, with the rationale for why update-json is a
separate post-release commit.
- docs/changelog.md — changelog.json as source of truth, how the two
generated markdowns are regenerated, when to add an entry.
Extended kmod/BUILDING.md with a Podman variant of the DDK command,
covering rootless + SELinux (Fedora) where --userns=keep-id and :Z are
required. Kept the kmod build docs next to the code since the GKI /
DDK complexity is kmod-specific.
Component READMEs untouched — they document each module's architecture
and belong next to the code.
hooked_recv and hooked_recvmsg used to run the netlink dump filter on
EVERY recv call regardless of socket type. The filter looked at bytes
[4..6] of the buffer for an RTM_NEWLINK (0x10) / RTM_NEWADDR (0x14)
magic; on a TCP/UDP/Unix socket those bytes are arbitrary user data
(TLS ciphertext, HTTP body, whatever). A random 2-byte coincidence was
enough to trip filter_netlink_dump, which then mutated the buffer
in-place via copy_within while trying to parse it as a linked list of
nlmsghdrs — corrupting the TLS stream and stalling the SSL socket on
top. Per-recv probability is low (~2/65536) but HTTPS-heavy cold-start
paths issue hundreds of recvs, so collision is near-certain.
Symptom reported in #16: target apps (Ozon, Home Assistant, Megafon,
Chrome) hang on infinite load with the zygisk module installed, and
work fine once the module is disabled. The ANR trace at reproduction
on Pixel 4a points at RuStorePushService.onStartCommand stuck on
conscrypt recv() reading from ozone.ru endpoints; the TLS stream never
completes because our hook intermittently mangled arriving bytes.
Gate both hooks on `getsockopt(SOL_SOCKET, SO_DOMAIN) == AF_NETLINK`.
TCP/UDP/Unix sockets now pass through untouched; netlink sockets still
get filtered as before. One extra syscall per recv, negligible vs the
network round-trip already in progress.
Also add the IN_GETIFADDRS thread-local guard to both hooks — bionic's
getifaddrs internally uses recvmsg on netlink, so without the guard we
would recurse through collect_vpn_iface_indices -> real_getifaddrs ->
recvmsg -> hooked_recvmsg -> collect_vpn_iface_indices and so on.
The recursion used to terminate on the netlink type check; now that
we might actually reach the netlink path on a netlink fd, the guard
becomes load-bearing.
Locally reproduced and fixed on Pixel 4a with the exact module stack
reported — Kitsune 27001 debug + ZN 1.3.4 + Vector 2.0.
Zygisksu's get_module_dir() returns a raw fd to /data/adb/modules/<id>
that the module owns. Previously the fd was stored in a local RawFd
variable and leaked — every app forked from zygote after us inherited
it via normal fd inheritance.
Apps that scan /proc/self/fd for root-managed paths (Ozon anti-tamper,
likely others) detected a descriptor pointing inside /data/adb/ and
silently hung — even when our .so was dlclosed for non-target apps.
Wrap the raw fd in OwnedFd so Drop closes it at end of on_load, before
zygote forks any app process.
Bisect-verified on Pixel 4a with Magisk Kitsune 27001 + Zygisk Next
1.3.4 + Vector 2.0: Ozon 19.12.1 hung at loading spinner with any
vpnhide-zygisk build before this fix, loads to full catalog after.
`pm list packages` starts responding to IPC very early in boot but
returns only system packages for several more seconds. service.sh's
previous `pm list packages >/dev/null && break` loop exited as soon as
PM was alive — before user-installed packages (including the vpnhide
app itself and any chosen targets) were indexed. The subsequent
`pm list packages -U | grep "^package:$pkg "` returned nothing, so
/data/system/vpnhide_uids.txt was written empty, and the LSPosed hook
in system_server cached an empty target set for the session. Result:
all Java-level filtering silently disabled until the next reboot where
we got lucky on timing.
Gate the boot wait on our own package being visible in the list (with
a 60s budget instead of 30s). That guarantees PM has moved past the
system-only snapshot before we read target names.
Also add per-call diagnostic logs to the three writeToParcel hooks
(NC, NI, LP) — `VpnHide-NC`, `VpnHide-NI`, `VpnHide-LP` tags — so the
next "Java check fails with zygisk on / passes with zygisk off" style
report can be diagnosed from logs alone instead of a live instrumented
build. The per-call volume is modest compared to system_server's own
logging and the logs live inside the LSPosed bridge log.
Add a start/stop capture control on the Diagnostics screen that spawns
`logcat -b all -v threadtime` via root and pipes it to a file in the
app cache dir. Users can press Start, reproduce a bug (cold-start an
app, trigger a hang, etc.), then Stop; the captured file is then
shareable via the standard Android share sheet.
Motivation: the existing debug-zip collector only grabs log lines
tagged VpnHide*, which is useless when we need to see what other apps
(Ozon, Home Assistant, Chrome, system_server) are doing around a
zygisk-injected hang. This gives us the unfiltered system view.
Uses su because READ_LOGS is not granted to third-party apps. The
subprocess is spawned as `exec logcat ...` under su so destroy() kills
logcat directly rather than the wrapping shell.
Reconstructed the four earliest release sections from git log (their
GitHub release bodies are empty). Appended to changelog.json history
and regenerated CHANGELOG.md so the full project history is now in the
canonical source-of-truth JSON.
No behavior change; the short update-json/changelog.md is unaffected
(still last 5 versions).
Split the generated markdown into two files:
- CHANGELOG.md at repo root — full history with the Keep a Changelog
header. Human-facing, discoverable from the GitHub repo page.
- update-json/changelog.md — still truncated to the last 5 versions,
for the Magisk/KSU update popup.
Both are regenerated from changelog.json on every changelog-add.py
and update-version.py run.
Also switch the CI release-notes extraction to read CHANGELOG.md so
the body is future-proof once a tag ages out of the short popup file.
Extract the current tag's section from update-json/changelog.md with
awk and pass it as body_path to softprops/action-gh-release. Keeping
generate_release_notes=true so GitHub still appends the auto PR list
and "Full Changelog" link below our handwritten summary.
Bumps actions/checkout v4→v6, actions/cache v4→v5,
actions/upload-artifact v4→v7, actions/download-artifact v4→v8
to silence the Node.js 20 deprecation warnings GitHub is emitting
ahead of the June 2026 cutoff.
softprops/action-gh-release stays on v2 (third-party, wasn't in the
deprecation list and v3 would need a separate compatibility review).
Magisk before v28 requires META-INF/com/google/android/update-binary
+ updater-script in module zips to extract them; without these the
manager fails with an unpack error (issue #23). Magisk v28+ removed
this requirement, which is why the bug only shows up on older managers.
Added the standard Magisk template (same one already used by the
zygisk module) to portshide and kmod. CI's `(cd module && zip -qr)`
step picks up the new files automatically.
Replace the bash update-version.sh with a python equivalent that also
rotates the changelog (previous top-level → history[0], new empty
top-level for the new VERSION) and regenerates update-json/changelog.md
from the JSON source of truth.
Add changelog-add.py for appending a bilingual entry (EN + RU) to the
upcoming version's section.
Both scripts are uv inline scripts; shared helpers live in _changelog.py.
Address second-pass review of PR #19:
apply.sh was feeding package names to grep as a regex. Package names
contain dots, which regex treats as "any char" — so resolving e.g.
\"com.x.y\" could land on a neighbouring \"comXxXy\" package and
silently block the wrong UID. Switch to awk with literal field
comparison, eliminating the class entirely.
While there, log explicitly when the boot-time pm readiness loop times
out after 30s, so apply.sh's \"0 observer(s)\" message is no longer
ambiguous between "user selected nothing" and "pm never came up".
Minor polish: drop the empty-body null-branch comment in PortsHidingScreen
and simplify body-string construction with joinToString(postfix).
Move UID resolution from Save-time to apply-time so app reinstalls
(which rotate UIDs) get picked up automatically without manual Save.
observers.txt now stores one package name per line — vpnhide_ports_apply.sh
reads it, queries \`pm list packages -U\`, and resolves to UIDs inline
when building the iptables ruleset. Same pattern kmod's service.sh
already uses for its targets.
Other review points:
- uninstall.sh loops \`-D OUTPUT -j …\` so duplicate jumps are fully
cleaned up, and redirects all probes to /dev/null
- PortsHidingScreen detects the module via \`cat module.prop\` rather
than \`[ -d \$dir ]\` — a KSU-Next remove-pending dir stays on disk
until reboot, so bare dir-check misreports install state
- Dashboard observer count now uses the existing countTargets helper,
consistent with kmod/zygisk line counting
- Dropped the awkward \`when (moduleInstalled) { null, true -> Unit;
false -> … return }\` in a Composable — plain if/else reads cleaner
- Comment on apply.sh explaining v4/v6 restores are per-family, not
transactional — if v6 fails, v4 rules are already live
- Simplified buildPortsSaveCommand: no more per-package UID resolver
shell-script construction, just a base64-encoded newline list
Mirror the kmod/zygisk plumbing so KernelSU-Next / Magisk pick up
portshide updates automatically:
- scripts/update-version.sh bumps portshide/module/module.prop along
with the other modules when VERSION changes
- scripts/update-json.sh writes update-json/update-ports.json pointing
at the current release zip
- CI appends updateJson=.../update-ports.json to the portshide
module.prop before zipping, matching kmod/zygisk
- Dashboard reports portshide version mismatches as issues, with the
same up/down/different wording the other modules use
Adds a portshide job mirroring the simple kmod zip packaging (no build
needed, just zip the module directory). Artifact lands next to the
other release zips so the gh-release step picks it up for tagged
builds.
Add a fourth module card under Modules showing ports module install
state, iptables chain active-ness, and configured observer count.
Detected via /data/adb/modules/vpnhide_ports presence plus a quick
`iptables -L vpnhide_out` probe to confirm service.sh has applied the
chain.
Add an issue card when the module is installed but observers.txt is
empty, pointing the user to Protection → Ports.
Replaces the ComingSoonPlaceholder in Protection → Ports with a real
picker. Lists installed apps with a single P chip per row — tapping it
toggles the app as a localhost-proxy-hide observer. On Save, resolves
package names to UIDs, writes /data/adb/vpnhide_ports/observers.txt,
then invokes the portshide module's apply script via su so iptables
rules update immediately (no reboot needed).
If the portshide module isn't installed, shows a hint card explaining
how to install vpnhide-ports.zip via KernelSU-Next or Magisk.
Help dialog becomes mode-aware for Ports too: explains the P role,
what connect() behavior observers see (ECONNREFUSED), and which app
categories are safe to mark as observers (banks / госуслуги /
marketplaces yes; Chromium-based browsers no).
New standalone Magisk / KernelSU-Next module that rejects TCP/UDP
connections from selected UIDs to 127.0.0.1 / ::1 via iptables
owner-match rules. Covers the VPN/proxy detection vector where an app
probes well-known localhost ports (7890, 1080, etc.) via
connect() — the observer gets ECONNREFUSED, indistinguishable from
a real closed port.
Rules live in a dedicated chain `vpnhide_out` / `vpnhide_out6` with a
jump from OUTPUT, applied atomically via iptables-restore. Configured
by /data/adb/vpnhide_ports/observers.txt (one UID per line, UID < 10000
filtered out for safety). service.sh re-applies at boot after netd
finishes its own chain setup. uninstall.sh flushes on module removal.
No C code, no per-kernel builds, no Rust FFI — just a shell script
leveraging the iptables binary that ships with every Android ≥ 4.
Verified on Pixel 8 Pro (Android 16) with iptables 1.8.11 legacy:
observer UID gets REJECT, non-observer UIDs are unaffected.
On GKI 5.10 kernels built with Clang LTO, dev_ifconf() gets inlined
into sock_do_ioctl(). The symbol remains in kallsyms as a dead stub,
so kretprobe registration succeeds but the probe never fires.
Confirmed by disassembly on Xiaomi 13 Lite (5.10.136) and Lenovo
Legion 2 Pro (5.10.101).
On 6.1+, SIOCGIFCONF was moved from sock_do_ioctl() into sock_ioctl()
directly, so hooking sock_do_ioctl would miss it on newer kernels.
sock_ioctl is the file_operations->unlocked_ioctl callback — used as
a function pointer, so LTO cannot inline it. SIOCGIFCONF passes through
it on every kernel version. After it returns, ifconf data is already in
userspace, so we filter uniformly via copy_from_user/copy_to_user with
no version-specific code paths.
Rename the Apps tab to Protection and put a segmented button on top
that selects between three sub-modes: Tun (existing VPN target picker),
Apps (new package-visibility picker with H/O chips per app), and Ports
(placeholder — coming soon).
App Hiding reads/writes vpnhide_hidden_pkgs.txt and vpnhide_observer_uids.txt,
resolves observer package names to UIDs at save time. Self-package is always
added to the hidden list both at app startup (ensureSelfInTargets) and on
Save — managed invisibly, not shown in the app list.
Help dialog is mode-aware: Tun shows the existing L/K/Z hints, Apps shows
the new H/O hints with Russian labels spelling out Hidden / Observer
explicitly, Ports has no help.
Hides selected packages from selected caller UIDs at the PackageManagerService
Binder stub. Filters getInstalled{Packages,Applications}, queryIntent*,
resolve{Intent,Service}, get{Package,Application}Info, getPackageUid,
getPackagesForUid, getInstaller{PackageName,SourceInfo}. Hooks
IPackageManagerBase with PackageManagerService fallback.
Config via /data/system/vpnhide_hidden_pkgs.txt and
/data/system/vpnhide_observer_uids.txt with inotify live-reload. Callers
with UID < 10000 are exempt to avoid breaking installd / LauncherApps.
logcat via su runs as root and can't see app's own log entries on some
devices. Use Runtime.exec("logcat") directly instead, which reads the
app's own log buffer without needing READ_LOGS permission.