vpnhide/docs/development.md
Danila Gornushko 5902c0f86c
Some checks are pending
CI / setup (push) Waiting to run
CI / lint (push) Blocked by required conditions
CI / kmod-activator (push) Blocked by required conditions
CI / kmod (android12-5.10) (push) Blocked by required conditions
CI / kmod (android13-5.10) (push) Blocked by required conditions
CI / kmod (android13-5.15) (push) Blocked by required conditions
CI / kmod (android14-5.15) (push) Blocked by required conditions
CI / kmod (android14-6.1) (push) Blocked by required conditions
CI / kmod (android15-6.6) (push) Blocked by required conditions
CI / kmod (android16-6.12) (push) Blocked by required conditions
CI / kmod-qemu (android12-5.10) (push) Blocked by required conditions
CI / kmod-qemu (android13-5.10) (push) Blocked by required conditions
CI / kmod-qemu (android13-5.15) (push) Blocked by required conditions
CI / kmod-qemu (android14-5.15) (push) Blocked by required conditions
CI / kmod-qemu (android14-6.1) (push) Blocked by required conditions
CI / kmod-qemu (android15-6.6) (push) Blocked by required conditions
CI / kmod-qemu (android16-6.12) (push) Blocked by required conditions
CI / kpm-qemu (android12-5.10) (push) Blocked by required conditions
CI / kpm-qemu (android13-5.10) (push) Blocked by required conditions
CI / kpm-qemu (android13-5.15) (push) Blocked by required conditions
CI / kpm-qemu (android14-5.15) (push) Blocked by required conditions
CI / kpm-qemu (android14-6.1) (push) Blocked by required conditions
CI / kpm-qemu (android15-6.6) (push) Blocked by required conditions
CI / kpm-qemu (android16-6.12) (push) Blocked by required conditions
CI / kpm-qemu-legacy (4.14) (push) Blocked by required conditions
CI / kpm-qemu-legacy (4.19) (push) Blocked by required conditions
CI / kpm-qemu-legacy (5.4) (push) Blocked by required conditions
CI / kpm (push) Blocked by required conditions
CI / zygisk (push) Blocked by required conditions
CI / lsposed (push) Blocked by required conditions
CI / portshide (push) Blocked by required conditions
CI / release (push) Blocked by required conditions
Upgrade diagnostic captures (#227)
* 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.
2026-07-02 16:25:53 +03:00

7.8 KiB

Development setup

How to build vpnhide from source.

Prerequisites

  • JDK 17 or later — what the CI image installs (openjdk-17-jdk-headless); local builds with JDK 21 also work. The lsposed/app Gradle build sets sourceCompatibility = 17 and jvmTarget = "17".
  • Android SDK — install platforms;android-35, build-tools;35.0.0, platform-tools (via Android Studio or cmdline-tools). Export ANDROID_HOME.
  • Android NDK r28 or later — export ANDROID_NDK_HOME (or drop it in $ANDROID_HOME/ndk/<version>/, the scripts auto-detect). The Gobley Gradle plugin used by lsposed/app reads ANDROID_NDK_ROOT, not ANDROID_NDK_HOME, so export both (or alias one to the other) when invoking Gradle directly. r27c builds compile, but the resulting cdylibs trigger an Android 16 KiB-page-size compatibility warning at app start on Pixel 8 Pro / future hardware (сегмент LOAD не выровнен); r28+ aligns LOAD segments on 16 KiB by default. (zygisk/build.rs and lsposed/native/build.rs also pass -Wl,-z,max-page-size=16384 explicitly so older NDKs stay compatible — defence in depth.)
  • Rust (latest stable) with the Android target:
    rustup target add aarch64-linux-android
    cargo install cargo-ndk
    
  • clang-format 18.x — kmod C formatting is pinned because clang-format versions disagree on kernel-style C casts. The repo checks this via scripts/clang-format-c.sh. On Arch:
    sudo pacman -S clang18
    mkdir -p ~/.local/bin
    ln -sfn /usr/lib/llvm18/bin/clang-format ~/.local/bin/clang-format-18
    clang-format-18 --version
    
  • docker or podman — only for building the kernel module via DDK images. Docker is preferred when both are installed, matching CI and device-test workflows. See kmod/BUILDING.md.
  • zip — packaging module zips.
  • adb — installing builds on a device.

Gobley (Gradle plugins dev.gobley.cargo + dev.gobley.uniffi) is what builds the Rust crate at lsposed/native/ via cargo-ndk and bundles the resulting libvpnhide_checks.so plus its UniFFI-generated Kotlin bindings (package dev.okhsunrog.vpnhide.checks) into the APK. The plugins are auto-resolved by Gradle from Maven Central — no manual install. Version is pinned in lsposed/gradle/libs.versions.toml.

Repository layout

Path Component
zygisk/ Zygisk native module (Rust, inline libc hooks)
lsposed/ LSPosed module + target-picker Android app (Kotlin, Compose)
kmod/ Kernel-level native backends: GKI .ko (C, kretprobes) and KPM beta (KernelPatch inline hooks)
portshide/ Localhost port blocker (shell + iptables)
scripts/ Release & changelog tooling
update-json/ Magisk/KSU update metadata
docs/ Contributor documentation (this directory)

Each module has its own README with architecture and design notes.

Signing keystore (required for lsposed)

lsposed/app/build.gradle.kts routes all APK build types (debug, rawDebug, and release) through a single signing config that reads lsposed/keystore.properties. Without that file, ./gradlew assembleDebug, :app:assembleRawDebug, and :app:assembleRelease fail with:

SigningConfig 'release' is missing required property 'storeFile'

Create lsposed/keystore.properties (git-ignored):

storeFile=/absolute/path/to/your.jks
keyAlias=yourAlias
password=yourPassword

Generate a keystore if you don't have one:

keytool -genkey -v -keystore ~/vpnhide.jks \
    -keyalg RSA -keysize 4096 -validity 36500 -alias vpnhide

Build each module

zygisk module

cd zygisk && ./build.py
# → zygisk/target/vpnhide-zygisk.zip

The script auto-detects the NDK from $ANDROID_NDK_HOME or ~/Android/Sdk/ndk/*.

lsposed APK

cd lsposed && ./gradlew :app:assembleDebug
# → lsposed/app/build/outputs/apk/debug/app-debug.apk

The default debug APK is still debuggable and uses the same package id/signing as release, but it is R8/resource-shrunk so LSPosed/Vector has less dex to prepare after an APK update. This is the recommended local and agent-control build.

For a distribution build:

cd lsposed && ./gradlew :app:assembleRelease
# → lsposed/app/build/outputs/apk/release/app-release.apk

For the old unminified Studio/debugger path, build :app:assembleRawDebug instead. Use it only when you specifically need to debug code before R8 has shrunk it.

kernel module

./kmod/build.py --kmi android14-6.1   # one variant
./kmod/build.py --all                  # every supported GKI
# → vpnhide-kmod-<kmi>.zip at the repo root

The script auto-spawns the ghcr.io/ylarod/ddk-min:<kmi>-<TAG> container via Docker or podman (same image CI uses; Docker is preferred when both are installed). For local kernel-source builds via direnv and the GKI matrix details, see kmod/BUILDING.md.

KPM backend

./kmod/kpm/build.py
# → vpnhide-kpm.zip at the repo root

The script builds the Android kpm activator, builds vpnhide.kpm against the pinned KernelPatch submodule, stages kmod/kpm/module/, and packages the flashable zip. For runtime requirements and safety notes, see kmod/kpm/README.md.

Install on device

# APK
adb install -r lsposed/app/build/outputs/apk/release/app-release.apk

# Native backend: push exactly one of these, then install via the Magisk,
# KernelSU, KernelSU-Next, or APatch manager path appropriate for the backend.
adb push zygisk/target/vpnhide-zygisk.zip /sdcard/Download/
adb push vpnhide-kmod-<kmi>.zip /sdcard/Download/
# The KPM zip additionally needs a KernelPatch runtime (built into APatch;
# KernelSU-Next/Magisk add it via KPatch-Next) — see kmod/kpm/README.md.
adb push vpnhide-kpm.zip /sdcard/Download/

After flashing a native backend, reboot the device. Do not keep multiple native backends installed unless you are explicitly testing conflict handling; the app uses priority kmod > KPM > Zygisk, and .ko + KPM together is unsafe.

CI lints (run before pushing)

CI runs the same checks. See .github/workflows/ci.yml for the authoritative list.

# Codegen drift — run after editing data/interfaces.toml; CI fails on diff
python3 scripts/codegen-interfaces.py
git diff --quiet  # must be clean

# Python (ruff, config in pyproject.toml). uvx runs without installing anything global.
uvx ruff format --check
uvx ruff check

# Rust
cd zygisk && cargo fmt --check && cargo ndk -t arm64-v8a clippy -- -D warnings
cd ../lsposed/native && cargo fmt --check && cargo ndk -t arm64-v8a clippy -- -D warnings
cd ../zygisk && cargo test
cd ../lsposed/native && cargo test

# C (kernel module)
scripts/clang-format-c.sh --check
# Host-side test of the generated VPN-iface matcher used by the kernel module
gcc -O2 -Wall -Werror -o /tmp/test_iface_lists kmod/test_iface_lists.c && /tmp/test_iface_lists

# Kotlin
ktlint "lsposed/app/src/**/*.kt"
cd lsposed && ./gradlew :app:lintDebug :app:testDebugUnitTest

Build versions

Every module zip and the APK carry a version string derived from git at build time:

  • on a release tag vX.Y.ZX.Y.Z
  • otherwise → X.Y.Z-N-gSHA (commits since the nearest tag + short hash, plus -dirty if the working tree has uncommitted changes)

So a locally-built dev APK shows up in Android Settings as e.g. 0.6.1-5-gabc1234-dirty, and the same string lands in module.prop inside the zip. The committed module.prop files themselves stay at the last release number — the version is stamped into a staging copy per build.

See releasing.md for details.

More docs