Profiling the warm-cache run on PR #105 showed three remaining hot spots
in the Gradle phase:
installUniffiBindgen 52s ← cargo install on every CI build
cargoBuildAndroidArm64Debug 30s ← Rust crate compile
lintAnalyze* (3 variants) 43s ← AGP Lint × main + unit + androidTest
This PR cuts the first one entirely and trims the third.
- Dockerfile: pre-install uniffi-bindgen 0.29.x in the CI image so
Gobley's :app:installUniffiBindgen task finds it ready instead of
rebuilding it from sources on every run. Triggers a ci-image
rebuild on merge — wait for that workflow to finish before merging
consumers (or the first lint/lsposed run will still hit the old
image and behave as before).
- lsposed/gradle.properties: enable build cache + configuration
cache. Verified locally: `./gradlew :app:assembleDebug
--configuration-cache` reports "Configuration cache entry stored"
cleanly with Gobley 0.3.7 + AGP 8.9.3 + Kotlin 2.1.20.
- lsposed/app/build.gradle.kts: `lint { checkTestSources = false }`.
Skips lintAnalyzeDebugUnitTest / lintAnalyzeDebugAndroidTest. Test
sources here are pure JVM unit-test logic — functional bugs caught
by :app:testDebugUnitTest, no Android-lifecycle code to lint.
Deliberately leave `checkReleaseBuilds` at its default so ad-hoc
`./gradlew :app:lint` still catches R8/ProGuard issues.
- .github/workflows/ci.yml: `:app:lint` -> `:app:lintDebug`. Lints
the debug variant only on PRs; release-variant Lint stays
available locally / for future tag-time CI.
- docs/development.md: refresh local-lint snippet.
Expected effect on warm cache (cumulative on top of PR #105):
lint 286s -> ~190s (3m10s, -32%)
lsposed 227s -> ~130s (2m10s, -42%)
Repo had ~1800 lines of Python (kmod/build.py, scripts/*, zygisk/build.py,
portshide/build-zip.py) with no formatter or linter. Long-lived scripts
like scripts/release.py and scripts/codegen-interfaces.py benefit from
catching unused-import / undefined-name / outdated-syntax issues early.
pyproject.toml — ruff config, target-py312, line-length 100,
rules E F W I B UP SIM. Excludes zygisk/third_party,
target/, .claude/.
ci.yml — astral-sh/ruff-action@v4 for `format --check` and `check`,
ahead of the slow Rust/Gradle steps so it fails fast.
docs/development.md — add `uvx ruff …` to the local-lint snippet.
Cleanup applied (`ruff format` + `ruff check --fix`):
- reformat: kmod/build.py, scripts/{changelog_lib,codegen-interfaces,
release,stats}.py, zygisk/build.py
- I001: split multi-name imports onto separate lines after the
sys.path.insert prelude (kmod/build.py, zygisk/build.py)
- E501 manual: wrap one console.print line in scripts/release.py
Stdlib-only invariant from scripts/build_lib.py is preserved — ruff is
a dev/CI tool, not imported at runtime.
Two related changes that ship together because they touch the same
build-script + docs surface and were verified together on-device.
16 KiB alignment
- zygisk/build.rs: pass `-Wl,-z,max-page-size=16384` to lld so the
cdylib's LOAD segments line up on 16 KiB pages. NDK r28+ already
does this by default, but the flag keeps r27 builds compatible.
- lsposed/native/build.rs: new file, same flag, for libvpnhide_checks.so.
- docs/development.md: bumped the NDK requirement to r28+ and noted
the 16 KiB rationale.
Verified via `llvm-readelf -l`: both libvpnhide_zygisk.so and
libvpnhide_checks.so now show `Align 0x4000` on every LOAD segment.
Unified build entry points
- kmod/build.py replaces kmod/build-zip.py. Single script that
auto-detects whether to build natively (we're inside the DDK image
or `--kdir` was passed) or to spawn `ghcr.io/ylarod/ddk-min` via
podman/docker. CI uses the same script with `--inside-container`.
- zygisk/build-zip.py renamed to zygisk/build.py for symmetry; logic
unchanged.
- kmod/BUILDING.md rewritten — local build is now one command:
`./kmod/build.py --kmi android14-6.1` (or `--all`). The old
hand-rolled podman/docker recipes are gone.
- .github/workflows/ci.yml updated to call the new entry points.
The DDK image tag in CI now has a comment pointing at
`DDK_IMAGE_TAG` in kmod/build.py as the source of truth.
- README.{md,en.md}, kmod/README.md, zygisk/README.md, docs/releasing.md,
scripts/build_lib.py: reference updates.
- README.en.md: also fixes a "bacame" typo and tightens the Windows
zygisk-build note (the aux.rs / libgit2 issue is still real).
Verified end-to-end on Pixel 8 Pro (husky, android14-6.1, Android 16):
APK installs, kmod + zygisk modules load, all 26 self-checks PASS in
Enforcing, 22/26 PASS in Permissive (the same 4 by-design FAILs as
before — kmod doesn't cover those paths in Permissive).
Six unrelated drift fixes that accumulated since they were last
synced. Each is independent of the rest:
* README{.en,}.md — kmod claim "filters /proc/net/*" trimmed to
/proc/net/route. The other /proc/net files are SELinux-blocked
for untrusted apps and the coverage table already says so.
* kmod/README.md — hook table and architecture note updated from
dev_ifconf to sock_ioctl. dev_ifconf gets inlined by Clang LTO
on GKI 5.10 so the kretprobe silently never fires; sock_ioctl
has been the actual hook target since the vpnhide_kmod.c fix.
* zygisk/README.md — five inline hooks now, not four (recv was
added separately because bionic's recv tail-calls recvfrom).
Also clarified pre_app_specialize runs in the forked child, not
zygote, matching the lifecycle block in lib.rs.
* docs/development.md — JDK requirement matches CI image (17, not
21); document ANDROID_NDK_ROOT quirk for Gobley; CI lint list
expanded to match what ci.yml actually runs.
* docs/development.md + lsposed/README.md — explain Gobley (the
Gradle plugin pair that builds lsposed/native/ and bundles the
.so + UniFFI Kotlin bindings into the APK). Previously absent
from all *.md.
Two changes that together eliminate changelog merge conflicts from
concurrent PRs:
1. **CHANGELOG.md is regenerated only by release.py.** The previous cut
still had every changelog.py invocation rewrite CHANGELOG.md with a
different [Unreleased] block, so two PRs producing different
unreleased content collided on the MD file. Checked-in CHANGELOG.md
now contains released versions only. Unreleased is rendered on
demand from changelog.d/ via scripts/preview-changelog.py — prints
to stdout, writes nothing.
2. **Fragment format: Markdown instead of TOML.** Filenames now look
like `<type>-<slug>-<hex4>.md` (e.g.
`fixed-dev-version-mismatch-a1b2.md`). Type is readable at-a-glance
in the directory listing; 4-char random hex prevents collision when
two PRs pick the same slug. Body is plain Markdown with `## English`
/ `## Русский` sections — renders directly on GitHub, no YAML/TOML
parser dependency.
- scripts/changelog_lib.py: MD parser replaces tomllib. render_full_md
drops the [Unreleased] block; write_md(data) signature simplified;
render_unreleased_md(fragments) for on-demand preview.
- scripts/changelog.py: writes <type>-<slug>-<hex4>.md, no MD regen.
- scripts/release.py: updated to the new write_md signature.
- scripts/preview-changelog.py: new.
- changelog.d/*.md: 10 existing TOML fragments migrated to MD. One
fragment (changelog-entries-now-live-as-per) updated to say Markdown
instead of TOML since that's the final state by the time this ships.
- CHANGELOG.md: regenerated — Unreleased block gone.
- .gitattributes: merge=union moved from *.toml to *.md.
- docs/changelog.md, docs/releasing.md, CONTRIBUTING.md,
changelog.d/README.md, CLAUDE.md: describe the new format + flow.
The JSON `unreleased` section was a conflict magnet — every PR touching
it collided with every other PR touching it. Fragments sidestep that:
each entry is its own TOML file, two PRs get two files, merges don't
touch the same bytes.
- changelog_lib.py: add FRAGMENTS_DIR, load_fragments(),
fragments_as_sections(), rotate_fragments_into_history();
render_full_md() now takes fragments explicitly.
- changelog.py: write a TOML fragment to changelog.d/<ts>-<slug>.toml
instead of appending to JSON. Auto-derive slug from EN text; accept
--slug override.
- release.py: load fragments, rotate into history[0], delete them.
- lsposed/app/src/main/assets/changelog.json: drop `unreleased` key;
only `history` remains (the 8 unreleased entries migrated to
changelog.d/*.toml). CHANGELOG.md regenerates byte-identical.
- UpdateChecker.kt: fix long-standing loadChangelog bug — was calling
parseChangelogEntry on the root object, which has no `version` field,
so the parse always threw and the function silently returned null.
Simplify ChangelogData to {history} and parse history[]; drop the
unused `current` field that DashboardScreen just concatenated back in.
- .gitattributes: mark changelog.d/*.toml as merge=union, belt-and-
suspenders fallback for same-filename collisions (rare — filenames
carry a timestamp).
- docs/changelog.md, docs/releasing.md, CONTRIBUTING.md,
changelog.d/README.md: describe the new flow.
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).
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.
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.