Both call sites duplicated the IN_GETIFADDRS guard wiring around
the real_getifaddrs lookup + ifaddrs walk. Extract the wiring so
a guard fix lands in one place — the kind of drift that hit the
closed PR #92.
It was added with comment "Temporary check to verify the recvfrom
hook works." The hook is verified in production; the probe carries
no diagnostic value beyond what check_netlink_getlink already gives.
Both forms came in with the codegen split (#91) but no [[vpn]]
rule has ever used them — the only `suffix=` rules are `digits`
(`wlan` test vector + `if` from #93). The grammar surface paid
for itself in ~150 lines of dead C/Rust helpers + their tests.
Drop them from VALID_KINDS, the parser, the C/Rust/Kotlin
emitters, and the helper test cases. If a future rule needs
either form, reintroduce alongside the rule that needs it.
Re-ran the codegen; tests pass for all four targets.
The zygisk job has had this for a while; lsposed/native was rebuilding
the uniffi/serde/quinn deps from scratch every run. Same shape as the
zygisk cache, separate cache key so the two jobs don't fight over a
shared `target/` (different crate, different artifacts).
Dashboard's `isVpnActiveSync` and `runJavaProtectionCheck` both
maintained their own `listOf("tun", "wg", "ppp", "tap", "ipsec",
"xfrm")` + `startsWith` checks, which missed names the kmod/zygisk
filter actually hides — `if<N>` from issue #86, `MyVPN`, `wg-client`,
substring catch-all. The dashboard would say "VPN not active" while
the filter was happily suppressing the renamed tunnel.
Move the `/sys/class/net + operstate` walk into a single
`isVpnActiveBlocking()` in ShellUtils.kt that uses
`IfaceLists.isVpnIface` (the same matcher fed to all three modules
from data/interfaces.toml). DiagnosticsScreen.isVpnActive becomes a
thin `withContext(IO)` wrapper around it. The link-properties
ifname check in `runJavaProtectionCheck` switches to the same
matcher.
The date line in changelog.d/fixed-notification-time-increased-to-make-it-fc9a.md
read `gi_2026-04-25_` instead of `_2026-04-25_`. _DATE_LINE in changelog_lib.py
anchors with `^_…_$` under re.MULTILINE, so parse_fragment would have raised
ValueError and blocked the next release. No effect on rendered output — date
isn't emitted into CHANGELOG.md, only used for fragment sort order.
Unauthenticated GitHub API hits rate-limit the moment you re-run; the
script then iterated the {"message": ...} error dict as a list of releases
and crashed on release["assets"]. Now uses GITHUB_TOKEN or `gh auth token`
when available and raise_for_status surfaces the real HTTP error.
Bar length was `count // 2`, which overflowed the terminal for any
non-trivial release and rich silently truncated with an ellipsis. Now
normalized: `count * bar_w // max_count` where bar_w is computed from
console.width minus the name and count columns plus padding.
- SIOCGIF{FLAGS,MTU,CONF} casts use `as _` so the host build
picks the right Ioctl type (matches PR #94).
- check_proc_file uses the codegen-backed `is_vpn_iface` over
whitespace tokens; the local VPN_PREFIXES array is gone now
that lib.rs imports `matches_vpn` from `generated::iface_lists`.
Real cause of the lsposed/lint NPE on CI: Gobley's
RustAndroidTarget.ndkToolchainDir resolves the NDK by checking, in
order, the explicit `ndkRoot` parameter, `<sdkRoot>/ndk/<latestVersion>`,
then `$ANDROID_NDK_ROOT`. The CI image installs the NDK as a separate
tree at /opt/android-ndk and exports `ANDROID_NDK_HOME`, not
`ANDROID_NDK_ROOT` — so all three lookups return null and Gobley's `!!`
produces a bare `NullPointerException` during `:app` configuration.
Locally my shell exports `ANDROID_NDK_ROOT` (Android Studio convention),
which is why the issue only surfaces in CI.
Bake `ANDROID_NDK_ROOT` into the CI Dockerfile and export it inline in
the lint / lsposed gradle steps so this PR's CI passes before the image
rebuilds. Revert the prior `rustup target add x86_64-unknown-linux-gnu`
and `--stacktrace` debug additions — that was a wrong-hypothesis
workaround (the host target is already installed by `rustup-init`).
Gobley's cargo plugin enumerates Kotlin targets at gradle configure
time and queries rustup for each one — including the JVM host target,
even though we never build for it (`androidUnitTest = false` skips
wiring the JVM cargo build into Android unit tests, but the build
entry is still created at configure time).
Without `x86_64-unknown-linux-gnu` installed, that lookup returns
null and `:app:lint` / `assembleRelease` die with a bare
`NullPointerException` during project configuration.
Add the target as a workflow step in the lint and lsposed jobs so
this PR's CI passes immediately, and bake it into the CI Dockerfile
so subsequent image rebuilds carry it.
Follow-up cleanup on top of the Gobley/UniFFI FFI rewrite:
- Drop the dead `log` crate and `logi()` helper. `android_logger`
was never `init()`'d so all `log::info!` calls were silently
no-op — removing them makes that explicit.
- Drop `staticlib` from `crate-type`; uniffi for Android-only
needs only `cdylib`.
- Skip the JVM/host Rust build via
`builds.withType<CargoJvmBuild>().configureEach { androidUnitTest = false }`.
Without this `:app:testDebugUnitTest` triggers a Linux x64
cargo build that fails — the source uses Android-shaped `i32`
ioctl request types incompatible with glibc's `c_ulong`. Unit
tests don't load the native lib, so the JVM build is dead weight.
- Drop the cosmetic `"exception: "` prefix in `nativeCheck`'s catch
block to match the other Kotlin probes; the UI badge already
conveys status. Fall back to the exception class name when
`e.message` is null.
- Document `open_netlink`'s `Result<i32, CheckOutput>` signature:
`Err` is short-circuit control flow, not necessarily a failure
(SELinux denials map to `CheckStatus::Pass`).
- Add blank lines between `when` arms in
`runNativeProtectionCheck` to satisfy ktlint
`blank-line-between-when-conditions` (a multiline arm forced the rule).
Before: every check_* function returned String with a "PASS: …" /
"FAIL: …" / "NETWORK_BLOCKED: …" prefix, and the Kotlin side
reconstructed the pass/fail/skipped verdict via startsWith() on the
prefix. The same format was mirrored inside Kotlin-only probes
(hasTransport, LinkProperties, etc.) just to stay consistent with the
Rust shape — the string protocol leaked beyond the FFI boundary.
Now: Rust exports CheckStatus { Pass, Fail, NetworkBlocked } and
CheckOutput { status, detail } as uniffi types. nativeCheck() maps
CheckStatus to the existing local CheckResult.passed: Boolean? (Pass →
true, Fail → false, NetworkBlocked → null). Adding a new variant later
will make the Kotlin when-expression non-exhaustive, catching every
call-site that has to change instead of hoping a grep of startsWith()
surfaced them all.
Cleanup that falls out of the type change:
- DashboardData.runNativeProtectionCheck no longer contains three
substring branches (SELinux / EACCES / Permission denied) — those
were defensive duplicates of Rust's is_selinux_denial, which already
classifies SELinux-blocked reads as Pass. The resulting loop is a
three-arm when on status.
- DiagnosticsScreen's "app has no network permission" banner switches
from detail.startsWith("NETWORK_BLOCKED:") to any { it.passed == null }
on native results only. Java-level checks never produce null, so the
scope is unambiguous.
- Kotlin-only probes (Java-level hasTransport, LinkProperties, proc-via-
java, etc.) lose the cosmetic "PASS: " / "FAIL: " prefix from detail
strings. The UI already shows a typed badge from passed: Boolean?;
the prefix was redundant and only existed to look like the Rust
format.
Cost: APK 3.83 MB -> 3.92 MB (+90 KB, generated FfiConverter glue for
the enum+record + R8-keep rules for them). Per-call FFI path now
serializes a RustBuffer instead of a single String — invisible for 17
button-driven probes, worth knowing if chatty checks are ever added.
Verified on-device: 27/27 diagnostics still pass; NETWORK_BLOCKED
branch untested on this device but the type-level change means its
semantics match the old implementation exactly.
Step 1 of the uniffi migration: FFI surface and return types stay
identical ([17 `() -> String` functions with "PASS:"/"FAIL:" payloads),
only the plumbing underneath is replaced. A follow-up commit swaps
String for a structured CheckResult record.
- lsposed/native: drop the jni crate + hand-rolled `jni_fn!` macro +
17 mangled `Java_…` exports. Each check_* is now `#[uniffi::export]`,
plus nine thin wrappers over `check_proc_file` because the Kotlin
side calls `checkProcNetRoute()` / `checkProcNetIfInet6()` / etc.
with no arguments — the path stays encoded at the Rust boundary.
Drop the unused `android_logger` dep (nothing initialized it, the
`log::info!` calls were going to the null logger anyway).
- lsposed/app: add `dev.gobley.cargo` + `dev.gobley.uniffi` plugins
(version 0.3.7) plus `kotlin("plugin.atomicfu")` required by the
generated bindings. Cargo plugin points at `../native` and now
drives the Rust build + .so packaging, replacing the hand-written
`buildRustNative` gradle task and the manual `src/main/jniLibs/`
copy. UniFFI plugin generates bindings into `dev.okhsunrog.vpnhide
.checks` at compile time; call-sites in DashboardData and
DiagnosticsScreen drop the `NativeChecks.` prefix and import the
generated top-level functions directly. NativeChecks.kt (hand-
written `object` with 17 `external fun` declarations and the
`System.loadLibrary` call) is deleted — all of that is now emitted
by the plugin.
- proguard-rules.pro: add the three JNA keep rules from its upstream
FAQ. Gobley's UniFfi plugin generates the same rules into
build/generated/uniffi/androidMain/generated-proguard-rules.txt,
but its auto-wiring into the app's R8 configuration doesn't fire
for pure-Android (non-KMP) applications — the generated file is
produced but never reaches minifyReleaseWithR8. Without these
rules R8 renames com.sun.jna.Pointer and related classes, and
`Native.initIDs` fails at runtime with
`UnsatisfiedLinkError: Can't obtain peer field ID for class
com.sun.jna.Pointer` on the first uniffi call.
APK: 3.37 MB -> 3.83 MB (+305 KB, ~9%). Breakdown: +172 KB
libjnidispatch.so (JNA native dispatcher), +106 KB classes.dex
(generated bindings + JNA classes kept unobfuscated), +9 KB uniffi
scaffolding inside libvpnhide_checks.so. Verified on-device: all 27
diagnostics pass; LSPosed hook still loads; single classes.dex still
fits (no multi-dex regression).
Add a single TOML rule `prefix = "if", suffix = "digits"` to the shared
matcher. Renames using the kernel's default anonymous-netdev naming
(`ip link set tun0 name if33`) — the exact attack from issue #86 — now
get hidden by every component (kmod, zygisk, lsposed, lsposed-native).
The shape is intentionally narrow: `if` + 1+ ASCII digits only. `ifb<N>`
(intermediate-functional-block traffic shaping) has a letter after `if`
and is not matched.
Reinstalling a native module via KSU/Magisk replaces
/data/adb/modules/<id>/ wholesale with the contents of the new zip, so
the runtime flag file `debug_logging` (which is not part of the zip) is
wiped — even though the user's persisted preference in SharedPrefs
still says ON. After such a reinstall, zygisk silently dropped its log
filter to error-only and the user had to toggle the setting off-then-on
to get logs back.
Re-apply the persisted preference to disk in MainActivity.onCreate via
applyDebugLoggingRuntime, on a background dispatcher. Two `su`
roundtrips per app start, only when the user actually had logging
enabled — cheap.
`libc::ioctl`'s second arg is `Ioctl`, which is `c_int` on android-arm64
but `c_ulong` on linux x86_64. Hardcoding `as i32` made the host build
of the lsposed/native test harness fail with a type mismatch, so
`cargo test --lib` couldn't compile and the generated `iface_lists`
unit tests in this crate were silently dead.
Use `as _` so the cast picks the right width per target. Then add the
matching `cargo test (lsposed native)` step in CI for symmetry with the
zygisk crate, so the codegen tests actually run.
Two follow-ups to #90 in one PR:
1. Two new match forms in data/interfaces.toml grammar:
suffix = "digits_optional" prefix + 0+ ASCII digits
suffix = "any" prefix + 1+ any chars
Needed by the upcoming whitelist (PR-B) for patterns like
`seth_lte\d*` and `v4-.+`. Not used by any current [[vpn]] rule, but
the helper functions are exercised by direct unit tests in the
generated test modules so a bug would surface before whitelist lands.
2. [[test]] vectors in data/interfaces.toml that the codegen renders
into per-language unit tests:
- zygisk + lsposed/native: #[cfg(test)] mod tests inside the
generated iface_lists.rs (run via `cargo test`)
- lsposed/app: a separate IfaceListsGeneratedTest under
src/test/kotlin (run via `:app:testDebugUnitTest`)
- kmod: a userspace test driver test_iface_lists.c — the
generated header now has __KERNEL__-guarded includes so the
same matcher compiles against libc, and a new lint step builds
and runs it via gcc.
36 fixed vectors today; trivial to grow as new rules / corner cases
come up. CI catches drift on the next push: any single matcher that
disagrees with the toml fails its job.
No production behavior change — generated matches_vpn / vpnhide_iface_is_vpn
/ IfaceLists.isVpnIface bodies are byte-identical to before; only the
helper functions and test modules grew.
The kernel module, zygisk, lsposed-native, and the LSPosed Kotlin module
each had their own hand-written list of VPN interface name prefixes,
and the four had drifted: kmod/zygisk/HookEntry knew utun/l2tp/gre
while lsposed-native and DiagnosticsScreen only knew tun/wg/ppp/tap/
ipsec/xfrm. So the self-test could PASS while the hooks were actually
hiding more interfaces.
Move the rules to data/interfaces.toml and render four matchers from it
via scripts/codegen-interfaces.py — one per language target. A new lint
job re-runs the codegen and fails if anything drifts.
The match grammar is intentionally tiny so each codegen target
implements it without depending on regex (kernel C can't):
exact / prefix / prefix+digits / contains.
Side effect: native diagnostics now agree with the hooks, so the
self-test in DiagnosticsScreen will recognize utun*, l2tp*, gre* and
*vpn* substrings as VPN tunnels (previously it would silently PASS on
those). The /proc/net/route check also moved from raw substring to
whitespace-tokenized matching, which avoids matching VPN-prefix
substrings that show up by chance inside hex-encoded IP addresses.
Existing zygisk filter unit tests still pass unchanged — public API of
is_vpn_iface_bytes / is_vpn_iface_cstr is preserved, only the body now
delegates to the generated matches_vpn().
Cargo.lock files updated incidentally (synced with Cargo.toml versions
that were already 0.7.1 in the manifests).
The post-save snackbar was reading hiddenPkgs.size, but hiddenPkgs has
selfPkg appended unconditionally (so the kernel-side list always
contains vpnhide itself), so the displayed count was always one higher
than what the user actually selected.
Switch to the same hiddenCount/observerCount values the screen header
already uses (allApps.count { it.hidden / it.observer }) so both
counters stay in sync and don't drift if the hiddenPkgs assembly logic
changes again.
Closes#79.
Follow-up to #83. Five small fixes I caught while reviewing:
- build_lib: spell out the stdlib-only invariant in the module docstring.
build-version.py is called from app/build.gradle.kts on every Gradle
build, so adding pip/uv deps here would break the APK build.
- build_lib: drop unused get_python_exe() (no callers anywhere).
- build_lib: add version_sort_key() and use it in zygisk and kmod for
NDK / clang auto-detection. The previous lexicographic sorted() picked
the wrong directory when major versions span different digit widths
(e.g. 100.0.0 < 25.0.1, clang-r9 sorting after clang-r498344b).
zygisk/build-zip.sh used `sort -V` before the python port, so this is
a regression fix; kmod is a new safety net (DDK containers ship one
clang today, but auto-detect should still be correct).
- kmod/build-zip.py: drop the manual mtime check before `make strip`.
The check only watched vpnhide_kmod.c, so edits to the Makefile,
kernel headers, or .config wouldn't trigger a rebuild. Let make's own
dependency tracking decide.
- build_lib: minor cleanup — hoist `import subprocess` to the top of
the module instead of importing it inside get_build_version().
GitHub Actions does not expose secrets to workflows triggered by PRs from
forks, so the lsposed job's `assembleRelease` was failing with a corrupt
release.jks for every external contributor. Generate a throwaway keystore
on the fly in that case so fork PRs get a green CI; signed-for-release
artifacts (push/tag runs) keep using the real secrets unchanged.
17 tests exercising every branch of the KMI × kernel-series matcher:
- every row of the exact-match shipping matrix (android12-5.10
through android16-6.12),
- the reporter's regression scenario (5.10-android12 kernel on an
Android 15 ROM — used to land in zygisk-recommended, now correctly
resolves to android12-5.10 with androidVersion "Android 15"),
- ambiguous series fallbacks (5.10 / 5.15 without a KMI tag return
both candidates via variantAmbiguous),
- deterministic series fallbacks (6.1 / 6.6 / 6.12 without KMI
still unambiguous),
- pre-GKI / unsupported series (4.14, 5.4, 6.3) all fall back to
zygisk,
- unparseable / blank / empty kernel strings (don't crash, return
zygisk or null respectively),
- parseKernelSeries + parseKernelAndroidBranch edge cases.
Zero new test infrastructure — plain JUnit 4 @Test like the existing
RussianAppFilterTest.
The KMI × kernel-series matching logic was buried as a local function
inside loadDashboardState and called suExec / Build.VERSION.RELEASE
inline, which made it impossible to exercise from JVM unit tests.
Extract buildNativeInstallRecommendation(kernelRaw, deviceAndroidLabel)
as a top-level internal pure function taking both inputs as strings,
plus the parseKernelSeries and parseKernelAndroidBranch helpers it
uses. The su and Build.VERSION probes now happen at the call site in
loadDashboardState, so the core matching logic is a pure String ->
NativeInstallRecommendation? mapping that tests can hit directly.
No behaviour change.
The freshForCurrentBoot gate was asymmetric: kmodVariantMismatch
fires the moment you install a wrong-variant zip (valid, since it's
decided from the module.prop-stamped gkiVariant vs the kernel-series
table — no boot required), but kmodOnUnsupportedKernel waited for
post-fs-data even though its predicate is identical in nature
(kernel-series ∉ GKI table ⇒ zygisk recommended). Same philosophy,
same gate.
Keep the boot gate on kmodAmbiguousLoadFailed — that one is literally
"the kmod you installed tried to load this boot and failed, pick the
other candidate", so it genuinely requires current-boot insmod
status.
On a device where the Android OS release differs from the kernel's
GKI KMI tag (common on Pixel 4a/6a etc. — Android 14/15 ROM over an
android12-5.10 kernel) the kernel-version line shows "android12"
right next to "Your device: Android 15", which is genuinely
confusing if you don't know what a KMI is.
Adds a small italic note below the device/kernel line that spells
out what the "androidNN" fragment actually means. Only shown when
the KMI parses out of uname -r AND it differs from the OS release —
when they match the note would just be noise, and when no KMI is in
uname -r there's nothing to clarify.
The existing dashboard_issue_self_multi_profile warning still said the
picker "only sees apps from its own profile" and that a Save would
"silently drop targets it can't see" — both of which stopped being
true after #66 switched every picker and resolver to `pm list packages
--user all`. Running VPN Hide in more than one profile is still not
recommended, but for different reasons: the main-profile copy is the
only one that matters (LSPosed hooks live in system_server, which is
main-profile only), extras in secondary profiles are redundant, and
they race each other's Saves to the shared target files.
Also spells out why "install only in Second Space" is not an option,
which was coming up in support threads.
Chrome installed in three profiles used to read "Chrome (0, 10, 11)"
— cryptic unless you know Android's user-ID scheme. Every Protection
screen now threads AppListCache.userNames through the row renderer
and hands it to labelWithUsers, so the same row reads
"Chrome (Owner, Work, Test User 1)".
Applied to all three Protection screens (Tun targets, App hiding,
Ports). When the name map is missing (no root, parse fail) the helper
still falls back to numeric IDs, so nothing regresses.