Commit graph

215 commits

Author SHA1 Message Date
okhsunrog
c5d96546bd perf(lsposed): cache Diagnostics + Protection state for the session
Two new app-scoped caches complete the "nothing re-runs on tab switch"
story started by #53 / #54:

- DiagnosticsCache: state machine (NotRun / Running / VpnOff / Ready)
  around runAllChecks(). Once Ready, hook-probe results are fixed for
  the process lifetime — hooks don't change mid-session, so there's
  nothing to re-run. VpnOff exposes a shared retry UI. Dashboard's
  "Protection status" section and Diagnostics top section both read
  from this cache.
- TargetsCache: every root-shell read each Protection screen used to
  do on tab switch (target files, observer files, pm list packages -U,
  module.prop) is now a single batched suExec collected at startup and
  refreshed on Save / top-bar Refresh. Eight serial root roundtrips
  collapsed to one.

UX tweaks:
- Shared VpnOffPrompt composable (banner + Retry button) used on both
  Dashboard and Diagnostics — tapping Retry refreshes both caches so
  state stays consistent across screens.
- Diagnostics split into two visually independent sections: protection
  checks on top (state-dependent), log-capture tools below (always
  visible). Previously the bottom cards disappeared when VPN was off,
  cutting users off from the very tools they need to report a bug.
- CheckResults / isVpnActive / runAllChecks promoted from `private` to
  `internal` so the cache can call them.

Save handlers in AppPicker / AppHiding / PortsHiding now call
TargetsCache.refresh() alongside DashboardCache.invalidate().
2026-04-20 00:51:05 +03:00
Danila Gornushko
4d81b35c27
Merge pull request #54 from okhsunrog/feat/dashboard-cache
perf(lsposed): cache Dashboard state + update check
2026-04-20 00:24:02 +03:00
okhsunrog
5fbad066dc fix(lsposed): dev builds now see release update prompts
checkForUpdate used compareSemver on the raw versionName, which
returns null for the git-describe dev suffix ("0.6.2-14-gSHA"). The
caller treated null as "no update", so dev APKs silently skipped every
release notification.

Introduce isNewerVersion(remote, current) — pure predicate that runs
both sides through baseVersion() before comparing. Unit-tested:
- 0.6.3 > 0.6.2 → prompt
- 0.6.3 > 0.6.2-14-gSHA → prompt (the bug)
- 0.6.3 > 0.6.2-14-gSHA-dirty → prompt
- 0.6.2 vs 0.6.2-14-gSHA → no prompt (local is "ahead")
- 0.6.2 vs 0.6.3-1-gSHA → no downgrade

checkForUpdate callsite switches to isNewerVersion.
2026-04-19 22:56:34 +03:00
Danila Gornushko
1081c6abb5
Merge pull request #50 from okhsunrog/feat/russian-filter-batch-2
feat(lsposed): expand Russian-apps filter (4pda batch)
2026-04-19 22:53:10 +03:00
okhsunrog
073389d02c feat(lsposed): expand Russian-apps filter (4pda batch)
Six apps reported missing from the Russian-only filter by a 4pda user:
Pribyvalka 63, GoodLine, RosDomofon, Drivee, Сетка, Twinby. Added as
prefix entries in RussianAppFilter plus tests.
2026-04-19 22:31:01 +03:00
okhsunrog
76938adc7e perf(lsposed): cache Dashboard state + update check
Every tab switch into Dashboard re-ran the full loadDashboardState
(multiple suExec calls, kprobes/SELinux/module probes) plus
checkForUpdate (GitHub API call). Toggling tabs a few times was
hitting the 60/h anonymous API rate limit for no good reason.

- DashboardCache: StateFlow-backed cache for DashboardState. Loaded
  once on first Dashboard open; survives tab switches. Invalidated
  after a Save on any Protection screen so target counts refresh
  without manual action.
- UpdateCheckCache: StateFlow + 6h TTL. Re-checks on ON_RESUME if
  stale. No WorkManager, no background alarms, no notifications —
  everything reactive to app lifecycle, no new permissions.
- Refresh IconButton now appears on Dashboard too (was Protection-
  only). Dashboard refresh → DashboardCache.refresh + UpdateCheck
  refresh. Protection refresh → AppListCache refresh (unchanged).
- DashboardScreen: collectAsState from the caches; drop its own
  LaunchedEffect loaders.
- Save handlers in AppPicker/AppHiding/PortsHiding call
  DashboardCache.invalidate() so the next Dashboard open shows the
  fresh counts.
2026-04-19 22:29:18 +03:00
Danila Gornushko
72b8eebb4c
Merge pull request #53 from okhsunrog/feat/app-list-cache
perf(lsposed): cache installed-app list at startup
2026-04-19 22:23:57 +03:00
okhsunrog
1c88be279d perf(lsposed): cache installed-app list at startup
Each of the three Protection screens (AppPicker, AppHiding, Ports)
used to run its own pm.getInstalledApplications + per-app
getApplicationIcon on every tab switch. Icons dominate the wait —
300+ drawable loads for a typical device. Hoist the package list +
icons into an app-scoped AppListCache, load once at MainActivity
composition, subscribe via StateFlow. Tab switches within Protection
are now instant.

Refresh button in the top bar (Protection tab) increments a shared
refreshCounter; each screen's per-screen state (target/observer
files) keys its reload LaunchedEffect on that counter, so refresh
rehydrates both halves — cache and per-screen.

- AppListCache.kt: new. object with apps / loading / refreshCounter
  StateFlows; ensureLoaded / refresh entry points.
- MainActivity.kt: AppListCache.ensureLoaded at composition; refresh
  IconButton in the Protection TopBar actions.
- AppPickerScreen.kt, AppHidingScreen.kt, PortsHidingScreen.kt: drop
  per-screen pm loading; read from AppListCache and merge with own
  per-screen target/observer state keyed on refreshCounter.
- strings.xml (+ RU): action_refresh_apps.
2026-04-19 22:16:19 +03:00
Danila Gornushko
a45fa9a5c3
Merge pull request #52 from okhsunrog/fix/changelog-md-at-release-only
fix(scripts): release-only CHANGELOG.md + Markdown fragments
2026-04-19 22:02:47 +03:00
okhsunrog
85da8f85de fix(scripts): CHANGELOG.md at release time + Markdown fragments
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.
2026-04-19 21:57:34 +03:00
Danila Gornushko
58d22bb025
Merge pull request #38 from okhsunrog/feat/kmod-variant-diagnostics
feat: diagnose wrong-variant kmod installs end-to-end
2026-04-19 21:33:18 +03:00
okhsunrog
edaedf02bd fix(lsposed): red card for broken kmod + drop duplicate recommendation
Previous commit painted wrong-variant kmod as amber "Установлен,
неактивен" — same color as a legitimate pending-reboot state — and
also surfaced the blue install-recommendation card even when the
error banner below already carried the same CTA.

Introduce KmodBrokenReason (WrongVariant / UnsupportedKernel /
MissingKprobes / UnknownVariantInactive) on ModuleState.Installed.
Populate it alongside the existing diagnostic flags. ModuleCard reads
the reason: red palette (same as error banners) + a specific subtitle
per reason instead of the generic "Установлен, неактивен".

Revert nativeInstallRecommendation to its pre-#38 condition — show
only on fresh install. Broken-state cases already emit a red error
with the right zip name; duplicating it in a blue card was noise.
2026-04-19 21:27:26 +03:00
okhsunrog
f3b738a3ed feat(lsposed): recover kmod gkiVariant from updateJson URL as fallback
CI has been injecting updateJson URLs of the form
  .../update-kmod-<kmi>.json
into every kmod zip since commit 3fc7355 — long before this PR added
the gkiVariant= field. That URL already identifies the installed
variant.

Parse the KMI out of updateJson when gkiVariant is missing so
wrong-variant diagnostics work on all currently-installed zips,
not only on reinstalls from the next release. The explicit
gkiVariant= stamping added in this PR remains the authoritative
source — the URL is only a fallback.
2026-04-19 20:41:30 +03:00
okhsunrog
b09f05ce60 feat: diagnose wrong-variant kmod installs end-to-end
Users routinely installed the wrong GKI variant of the kmod zip and
saw no signal beyond "installed, inactive". This adds a full chain
from build to diagnostics so the wrong-variant case is both obvious
to the user and fully captured in bug reports.

Why each piece exists:

- CI stamps `gkiVariant=<kmi>` into each variant's `module.prop`
  so the app can identify what was installed without guessing.
- `post-fs-data.sh` records `/data/adb/vpnhide_kmod/load_status`
  (boot_id, uname -r, gki_variant, insmod exit+stderr, kprobes,
  root manager) and `load_dmesg` at every boot — this survives
  reboots and is the only record of insmod failures by the time
  the user opens the app.
- Dashboard reads both, always computes the kernel-based
  recommendation, and emits targeted issues: wrong-variant,
  unknown-variant (pre-stamp zip that also failed to load),
  kmod-on-unsupported-kernel, kprobes-missing, or generic
  load-failed with the captured stderr.
- Diagnostics screen adds a "Kmod load trace" card so bug
  reports can come in as a screenshot, and the debug zip
  includes load_status + load_dmesg for deeper analysis.

Also aligns `lsposed/native/Cargo.lock` with Cargo.toml (0.6.1
→ 0.6.2) — a real stale-lock fix surfaced by the gradle build.
2026-04-19 20:41:30 +03:00
Danila Gornushko
9570dec03c
Merge pull request #49 from okhsunrog/feat/changelog-fragments
Some checks are pending
CI / kmod (android13-5.10) (push) Waiting to run
CI / kmod (android13-5.15) (push) Waiting to run
CI / lint (push) Waiting to run
CI / kmod (android12-5.10) (push) Waiting to run
CI / kmod (android14-5.15) (push) Waiting to run
CI / kmod (android14-6.1) (push) Waiting to run
CI / kmod (android15-6.6) (push) Waiting to run
CI / kmod (android16-6.12) (push) Waiting to run
CI / zygisk (push) Waiting to run
CI / lsposed (push) Waiting to run
CI / portshide (push) Waiting to run
CI / release (push) Blocked by required conditions
feat(scripts): changelog as per-PR TOML fragments
2026-04-19 20:40:04 +03:00
okhsunrog
40b15dd063 feat(scripts): migrate changelog to per-PR fragments under changelog.d/
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.
2026-04-19 20:05:39 +03:00
Danila Gornushko
da0a35b17f
Merge pull request #47 from okhsunrog/fix/dev-version-mismatch
fix(lsposed): skip version-mismatch warning for dev builds
2026-04-19 19:53:03 +03:00
okhsunrog
56befb828d refactor(lsposed): extract detectModuleMismatches + handle -dirty suffix
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.
2026-04-19 19:45:40 +03:00
okhsunrog
98b05c256f fix(lsposed): skip version-mismatch warning for dev builds
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.
2026-04-19 18:51:32 +03:00
okhsunrog
dcee78e954 feat(lsposed): expand Russian-apps filter
Some checks are pending
CI / lint (push) Waiting to run
CI / kmod (android12-5.10) (push) Waiting to run
CI / kmod (android13-5.10) (push) Waiting to run
CI / kmod (android13-5.15) (push) Waiting to run
CI / kmod (android14-5.15) (push) Waiting to run
CI / kmod (android14-6.1) (push) Waiting to run
CI / kmod (android15-6.6) (push) Waiting to run
CI / kmod (android16-6.12) (push) Waiting to run
CI / zygisk (push) Waiting to run
CI / lsposed (push) Waiting to run
CI / portshide (push) Waiting to run
CI / release (push) Blocked by required conditions
Add 30+ packages to RussianAppFilter: retailers, food chains,
logistics, telecom, aggregators. Prefix matching groups multi-app
companies (platfomni, ertelecom, greenatom) under shared prefixes.
2026-04-19 03:52:56 +03:00
okhsunrog
f8cb33c991 docs: make Russian README primary, English secondary
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.
2026-04-18 23:50:23 +03:00
Danila Gornushko
12f06d06bd
Merge pull request #42 from okhsunrog/feat/ux-and-diagnostics
Some checks are pending
CI / lint (push) Waiting to run
CI / kmod (android12-5.10) (push) Waiting to run
CI / kmod (android13-5.10) (push) Waiting to run
CI / kmod (android13-5.15) (push) Waiting to run
CI / kmod (android14-5.15) (push) Waiting to run
CI / kmod (android14-6.1) (push) Waiting to run
CI / kmod (android15-6.6) (push) Waiting to run
CI / kmod (android16-6.12) (push) Waiting to run
CI / zygisk (push) Waiting to run
CI / lsposed (push) Waiting to run
CI / portshide (push) Waiting to run
CI / release (push) Blocked by required conditions
feat(lsposed): H+O mutex, inline help, issue severity split
2026-04-18 03:47:59 +03:00
okhsunrog
9077378b8f fix(lsposed): pin issue-banner colors; rewrite zygisk hints
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).
2026-04-18 03:41:59 +03:00
okhsunrog
6e6aa6ee5e revert(lsposed): drop target-observer overlap warning
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.
2026-04-18 02:05:10 +03:00
okhsunrog
f733a450fe fix(lsposed): filter-active indicator uses FilledIconButton
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.
2026-04-18 01:44:51 +03:00
okhsunrog
259af21060 style(lsposed): satisfy ktlint 1.8 blank-line-between-when-conditions 2026-04-18 01:43:26 +03:00
okhsunrog
2395db891e feat(lsposed): H+O mutex, inline help, issue severity split
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.
2026-04-18 01:43:26 +03:00
Danila Gornushko
0ad3c5d743
Merge pull request #40 from okhsunrog/feat/debug-logging-toggle
feat: debug logging toggle, off by default
2026-04-18 01:36:57 +03:00
okhsunrog
b21df83d6b feat(lsposed): auto-enable debug logging during diagnostic captures
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.
2026-04-18 01:23:21 +03:00
okhsunrog
3dffddef2f chore: update-json for v0.6.2
Some checks are pending
CI / lint (push) Waiting to run
CI / kmod (android12-5.10) (push) Waiting to run
CI / kmod (android13-5.10) (push) Waiting to run
CI / kmod (android13-5.15) (push) Waiting to run
CI / kmod (android14-5.15) (push) Waiting to run
CI / kmod (android14-6.1) (push) Waiting to run
CI / kmod (android15-6.6) (push) Waiting to run
CI / kmod (android16-6.12) (push) Waiting to run
CI / zygisk (push) Waiting to run
CI / lsposed (push) Waiting to run
CI / portshide (push) Waiting to run
CI / release (push) Blocked by required conditions
2026-04-17 19:45:15 +03:00
okhsunrog
412d78e599 feat: debug logging toggle, off by default
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.
2026-04-17 19:09:07 +03:00
okhsunrog
42407c6e90 fix(lsposed): strip 'v' prefix from module.prop version at parse time
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).
2026-04-17 16:48:13 +03:00
okhsunrog
b85673c81c chore: release v0.6.2 2026-04-17 16:24:53 +03:00
okhsunrog
0a9fcef3c0 ci: rename vpnhide APK artifact + publish as draft release
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).
2026-04-17 15:54:32 +03:00
okhsunrog
637761e678 fix(ci): don't dirty committed module.prop when injecting updateJson
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.
2026-04-17 14:54:33 +03:00
okhsunrog
3fc735572a build: stamp git-describe build version into every artifact
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.
2026-04-17 14:42:40 +03:00
okhsunrog
f86e5e5a4c feat(lsposed): save-to-file for logcat recording
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.
2026-04-17 14:35:16 +03:00
okhsunrog
ad2590a453 refactor(scripts): unreleased section + rename scripts
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.
2026-04-17 14:34:47 +03:00
okhsunrog
876829d9ad docs: add CONTRIBUTING.md and docs/ with build/release/changelog guides
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.
2026-04-17 13:51:33 +03:00
Danila Gornushko
0ac9b09473
Merge pull request #37 from okhsunrog/fix/zygisk-netlink-only-filter
fix(zygisk): only filter netlink sockets in recv/recvmsg hooks
2026-04-17 13:04:11 +03:00
okhsunrog
c74cf32002 fix(zygisk): only filter netlink sockets in recv/recvmsg hooks
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.
2026-04-17 12:56:07 +03:00
Danila Gornushko
dc8df50a34
Merge pull request #31 from okhsunrog/fix/zygisk-close-module-dir-fd
fix(zygisk): close module-dir fd before zygote forks
2026-04-17 12:52:01 +03:00
okhsunrog
131fb16152 fix(zygisk): close module-dir fd before zygote forks
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.
2026-04-16 18:35:12 +03:00
Danila Gornushko
728ebc30c8
Merge pull request #25 from okhsunrog/feat/full-logcat-capture
Some checks are pending
CI / lint (push) Waiting to run
CI / kmod (android12-5.10) (push) Waiting to run
CI / kmod (android13-5.10) (push) Waiting to run
CI / kmod (android13-5.15) (push) Waiting to run
CI / kmod (android14-5.15) (push) Waiting to run
CI / kmod (android14-6.1) (push) Waiting to run
CI / kmod (android15-6.6) (push) Waiting to run
CI / kmod (android16-6.12) (push) Waiting to run
CI / zygisk (push) Waiting to run
CI / lsposed (push) Waiting to run
CI / portshide (push) Waiting to run
CI / release (push) Blocked by required conditions
feat(diagnostics): full system logcat recording
2026-04-16 16:00:10 +03:00
Danila Gornushko
66586309b2
Merge pull request #30 from okhsunrog/fix/service-sh-pm-race
fix(service.sh): wait for PackageManager to index user apps before resolving UIDs
2026-04-16 15:59:53 +03:00
okhsunrog
00ba398f36 fix(service.sh): wait for PackageManager to index user apps before resolving UIDs
`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.
2026-04-16 15:33:46 +03:00
okhsunrog
90d04078e2 feat(diagnostics): full system logcat recording button
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.
2026-04-16 03:37:22 +03:00
okhsunrog
88af2ef2ea docs(changelog): backfill v0.1.0–v0.3.1 history
Some checks are pending
CI / lint (push) Waiting to run
CI / kmod (android12-5.10) (push) Waiting to run
CI / kmod (android13-5.10) (push) Waiting to run
CI / kmod (android13-5.15) (push) Waiting to run
CI / kmod (android14-5.15) (push) Waiting to run
CI / kmod (android14-6.1) (push) Waiting to run
CI / kmod (android15-6.6) (push) Waiting to run
CI / kmod (android16-6.12) (push) Waiting to run
CI / zygisk (push) Waiting to run
CI / lsposed (push) Waiting to run
CI / portshide (push) Waiting to run
CI / release (push) Blocked by required conditions
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).
2026-04-16 00:44:46 +03:00
okhsunrog
a4426ec655 docs(changelog): add full CHANGELOG.md at repo root
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.
2026-04-16 00:42:59 +03:00
okhsunrog
9c13c761a3 ci(release): use changelog.md section as release notes body
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.
2026-04-16 00:34:25 +03:00