* 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.
* fix(kpm): build with -mcmodel=large so .rodata addressing survives the loader
The KPM ctl0 status/stats replies came back corrupted on a real APatch
(KernelPatch) device — garbled/duplicated string literals ("vpnhide vpnhide",
"backendr", a stray "1 stats" prefix). That made the app's root snapshot
fail to parse the kpm_state section and show "Startup preparation failed",
with no working KPM control channel.
Root cause: the .kpm was built with -mcmodel=small, which emits ADRP
(ADR_PREL_PG_HI21, PC-relative ±4 GB) relocations for .rodata string literals.
KernelPatch loads the module in a vmalloc region that can sit more than 4 GB
from where those literals were assumed, so the relocations overflow and resolve
to garbage addresses. The string-formatting code (vpnhide_format_status/stats,
shared with the .ko) is correct — it's only the .kpm's address materialisation
that breaks. The .ko is unaffected because Kbuild handles module addressing.
Switch the KPM build to -mcmodel=large, which materialises absolute addresses
via MOVW_UABS (no ±4 GB range limit). Verified on a real Pixel 4a (APatch,
4.14): ctl0 status/stats now decode cleanly and the app starts. QEMU KPM
harness still passes 18/18 with no panic on android16-6.12 (the larger .kpm
still boots KernelPatch) and android14-6.1.
* fix(activator): trust kpatch ctl0-read stdout, not its byte-count exit status
The kpatch CLI prints a ctl0 reply to stdout and exits with the supercall's
return value, which for a READ is the reply byte count (e.g. 64), not 0.
run_kpatch_kpm_ctl0_read treated any non-zero exit as failure, so every KPM
status/stats read failed on KernelSU / KPatch-Next and the dashboard showed no
KPM counters. (On APatch the supercall is read directly, so that path was
unaffected — its bug was the .rodata corruption fixed by the -mcmodel change.)
Capture stdout regardless of exit code: the reply text is authoritative, and on
a real error the CLI leaves its buffer empty so stdout is empty. The config path
already tolerates the byte-count exit status (kpatch_ctl0_config_status_ok); this
brings reads in line. Verified on a real Pixel 8 Pro (KSU-Next + KPatch-Next):
'activator state' now returns the clean status/stats reply instead of erroring.
* fix(ports): block the whole loopback range; warn on native-target overflow
Two activator findings from the codebase audit.
The localhost port blocker only rejected 127.0.0.1, so a proxy/VPN daemon bound
to the wildcard 0.0.0.0 (the common allow-lan / TUN setup for Clash, sing-box,
V2Ray) stayed reachable on any other 127.x.x.x alias — an observer could
connect(127.0.0.2:port) and get a handshake a clean device would refuse, a
positive fingerprint. Reject the whole 127.0.0.0/8 block instead (::1 already is
the entire IPv6 loopback).
The native-target cap (64) was restated as a bare literal in three places (the
activator and both C backends) and projection truncated the overflow with a
silent .take(), dropping the highest-UID apps from protection with no diagnostic.
Export the cap once from the shared protocol crate, alias it in the activator,
cross-reference it from the C #defines, and warn on stderr when the target set
exceeds it.
* style: rustfmt activator loopback comment
* docs(changelog): note the native-target overflow warning
* 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
Verified on a Pixel 4a (APatch, kernel 4.14) and a Pixel 8 Pro (KSU-Next +
KPatch-Next-Module, GKI 6.1):
- KPM runtime probe: drop the dead /data/adb/ksu/bin/kpatch guess (KSU-Next
ships kpatch via the KPatch-Next-Module, not ksu/bin) and the unused PATH
check. The real signals are /data/adb/ap (APatch — loads KPMs via supercall,
no CLI on disk) and /data/adb/modules/KPatch-Next/bin/kpatch (the module, same
on Magisk/KSU/KSU-Next). Mirror the same trim in the activator's find_kpatch
and the KPM post-fs-data, documenting that APatch goes through the supercall.
- root_manager: APatch and KSU both ship a /data/adb/magisk compat layer (the
APatch 4a has /data/adb/magisk + magisk.db), so the old order reported APatch
as 'magisk'. Check magisk first and let the exclusive ksu/ap dirs override it.
- reword the KPM needs-runtime string: drop the misplaced 'APatch already
includes it' note (an APatch user never sees this string — the runtime flag is
already true via /data/adb/ap).
The app writes a per-app Java hook selection as a string array ("java":
["lsposed_network", ...]), the same shape as "native". The Rust activator
typed "java" as a plain bool, so serde rejected the whole canonical with
'invalid type: sequence, expected a boolean'. Because the native activator
parses the entire config, a partial Java selection for a single app silently
disabled the kmod/KPM/Zygisk backend for every app.
The activator never acts on "java" (LSPosed self-reads and owns Java hook
selection), it only needs to parse it. Accept both the bool and the array
form, collapsing to enabled-ness.
- activator: de_bool_or_hook_list deserializer for the java field; regression
test covering the array form (and an empty array = disabled), asserting
native projection is unaffected
- testdata/storage_config_v1.json: give org.example.proxy a per-hook java
array so the shared Rust+Kotlin parity fixture exercises the shape that
previously slipped through; update both fixture assertions
When the KPM stands down for a co-installed .ko backend (§1.5), the boot
flow previously let the activator's silent skip be recorded by service.sh
as 'activator 1 configured' — load_status falsely claimed the KPM was
loaded. The .ko+KPM conflict banner also keyed on both backends being
active, a state that can never be observed (two live kernel hookers freeze
the device), so users were never warned about the redundant co-install.
- activator: activate_kpm_boot returns KpmBootOutcome; a kmod conflict is
DeferredConflict (not silent Ok), mapped to exit code 3 in the kpm bin.
- kpm service.sh: conflict dir-check before the APatch superkey branch
(so a co-installed .ko isn't masked as awaiting_superkey), and exit 3
is recorded as a truthful 'conflict' status instead of 'configured'.
- app: surface the deferred-conflict (load_status runtime=conflict for the
current boot) as a warning; new dashboard_issue_native_conflict_deferred
string (EN+RU) telling the user to remove one kernel backend.
- post-fs-data.sh: replace the comment claiming a non-existent kernel-half
guard with the actual layered, fail-safe userspace design.