Extract the three copy-pasted version-mismatch if-blocks (kmod / zygisk
/ ports) in loadDashboardState into a pure list-driven helper:
detectModuleMismatches(
listOf(kmod to Kmod, zygisk to Zygisk, ports to Ports),
appVersion,
)
Returns List<ModuleMismatch>; the callsite just iterates. Closes the
typo-in-one-of-three-branches gap and makes the predicate unit-testable
without Android context.
Also broaden baseVersion() to strip the -dirty marker that
`git describe --dirty` appends when the working tree has uncommitted
changes, so 0.6.2-14-gSHA-dirty and 0.6.2-dirty both normalize to
0.6.2. Pre-release tags (-rc1, -beta) remain untouched.
Dashboard compared module.prop versions (e.g. 0.6.2) directly against
BuildConfig.VERSION_NAME (which carries the git-describe suffix
0.6.2-14-g1f2205e on dev builds), always flagging mismatch. Now uses
baseVersion() to strip -N-gSHA before comparison; pre-release tags
(-rc1, -beta) are preserved.
Adds unit tests for normalizeVersion, compareSemver, baseVersion and
versionsMismatch, and wires :app:testDebugUnitTest into CI so they
actually run.
Rename README.ru.md -> README.md and the previous README.md -> README.en.md.
Target audience is primarily Russian-speaking. Update language-switcher
links in both files.
The issue banners used colorScheme.error / tertiaryContainer — both
get remixed by Material You to whatever tone the wallpaper suggests,
and in practice landed on lavender / pink on user devices, reading
as "note" instead of "problem". Pinned the Errors and Warnings
sections to the same hardcoded red and amber pairs the module-status
cards already use (consistent with the rest of the Dashboard). The
NeedsRestart protection banner got the same warning palette for
visual consistency with the Warnings section below it.
Rewrote the Zygisk advice in four places now that we know the failure
mode is concrete (banking and payment apps crash on detecting Zygisk
hooks, not "some apps can theoretically detect"):
- apps_hint_zygisk (Protection / Tun help card)
- dashboard_install_recommendation_zygisk_warning (install card)
- dashboard_issue_kmod_capable_but_zygisk (Warning W1)
- dashboard_issue_both_native_active (Warning W2)
W1 and W2 still Warnings, not Errors: just having Zygisk installed
and active doesn't break anything — our .so is only injected into
target apps, and DlCloseModuleLibrary unloads it for the rest. The
actual hit happens only when the user enables Z for a specific
banking/payment app. The warnings reframed around that per-app
footgun.
Diagnostics screen banner "VPN активен ... готово к запуску проверок"
was leftover from when there was a manual "Run" button — the checks
auto-run now and the result shows immediately below. Trimmed the
sentence to just state the current state ("Results below").
Dropped btn_run_all and summary_running strings (no remaining code
references).
The warning fired on any package present in both the Tun target list
and Ports observers list — too broad. Being both is fine by design:
a target app that gets its VPN-indicators stripped can also block
connect(127.0.0.1, <proxy-control-port>) probes from anti-tamper SDKs
without any conflict. The original bug it tried to catch (Clash/sing-box
TProxy redirect to loopback crashing banks on the same device) is a
system-level condition we can't reliably detect from the app. Will
revisit properly later; for now this warning false-positives more than
it helps.
The active-filter tint (primary) lost contrast against the topbar's
primaryContainer on Material You palettes — screenshots from users
showed the FilterList icon disappearing into the background while
Search and Help (on onPrimaryContainer) stayed crisp. Swap the
IconButton for a FilledIconButton when a filter is active so the
indicator uses the primary / onPrimary pair, which M3 guarantees
to contrast regardless of dynamic-color source.
Three UX-and-diagnostics fixes for issues surfaced by user reports.
H+O mutual exclusion in App Hiding:
Users marking the same app as both Hidden and Observer caused it to
crash on startup — the app would query its own PackageInfo during init,
system_server hook matched it as an observer and stripped its own
package from the result, and the framework bailed on NameNotFound.
Roles are now mutually exclusive: toggling one clears the other. On
first launch, any pre-existing H+O config is migrated to O-only and
dirty=true so the Save button prompts the user to persist the fix.
Help is always-visible, not behind a ? icon:
Real-world usage showed nobody taps the HelpOutline in the top bar —
users configure observers, flip toggles blind, then open issues when
things don't work. Help dialogs on Tun / Apps / Ports screens are
replaced with collapsible cards at the top of each list, expanded by
default and remembered-per-screen in SharedPreferences so experienced
users can collapse them without losing the affordance.
Issues → Errors + Warnings:
The single red-banner "Issues" list mixed "your setup is broken"
(LSPosed missing, no targets) with "your setup works but is suboptimal"
(version skew, extra scope entries). Split into two sections with
theme colors (error / tertiary). Five new warnings cover misconfigs
that went unreported before:
* kernel supports kmod but only Zygisk is installed (zygisk is
theoretically detectable; kmod isn't)
* kmod and Zygisk both active (redundant hooks, larger fingerprint)
* package marked as both Tun target and Ports observer (traps users
with transparent-proxy clients — vpnhide_out REJECTs redirected
loopback traffic and the app loses internet)
* debug logging left enabled after a bug report (leaks tag-matched
lines to any root-reading forensic scan — reads the flag file
written by the Diagnostics toggle in the sibling PR, so fires
only once that merges)
* SELinux in Permissive mode (exposes six detection vectors we rely
on the kernel to block — see the coverage table in README.md)
Also aligns lsposed/native/Cargo.lock with Cargo.toml (0.6.1 → 0.6.2)
— same stale-lock fix as in the other pending PRs.
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.