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.
Prep work for rendering apps with "(Work, Second Space)" instead of
"(10, 11)" in the Protection list. Batches `pm list users` into the
same `su` invocation as the package scan via a sentinel-split output,
so the cost is still one root roundtrip per refresh.
Exposes the parsed user_id -> friendly_name map as a StateFlow on
AppListCache. Extends labelWithUsers(label, userIds, userNames =
emptyMap()) to substitute names when present; numeric fallback stays
when root isn't available or parsing fails, so no caller is forced to
pass the map.
The multi-profile merge shipped with a labelWithUsers property that
appended the user-ID list to every row unconditionally — so single-
profile users (the large majority) saw every entry as
"Telegram (0)", "Chrome (0)", ... even though there was nothing
multi-profile to disambiguate.
Extract a top-level labelWithUsers(label, userIds) helper that
suppresses the suffix when the only user is the current one, and apply
it across all three Protection screens (Tun targets, App hiding,
Ports) — previously only the Tun screen rendered the suffix, which was
itself an inconsistency.
When a package exists only in a secondary profile (work profile, MIUI
Second Space, etc.) PM lookup fails for the main-user context and we
fall back to parsing ApplicationInfo from the APK via
getPackageArchiveInfo. The archive-parsed info doesn't carry
FLAG_SYSTEM — that bit is attached by PM at install time, not stored
in the manifest — so every secondary-only app was being classified as
user-installed and the "Show system apps" filter hid none of them.
Fall back to the APK path: /data/app/... is user-installed, everything
else (/system, /product, /vendor, /apex, ...) is baked into the system
image. Flag-based detection still wins whenever PM returns a real
ApplicationInfo.
AppListCache.reload() was spawning three separate `su -c pm list
packages ...` processes to collect package names, UIDs, and APK paths
respectively. Each `su` spin-up is ~50-100ms, so every refresh paid
~150-300ms for no reason — `pm list packages -U -f --user all` returns
all three fields in a single call.
Parser now merges comma-joined UIDs (AOSP `--user all`) and also
merges duplicate lines emitted per-user on ROMs that prefer that
format, so the output is stable across flavors. No behavior change
for callers.
Reported: kmod loads fine (diagnostics pass, /proc/vpnhide_targets
exists) on a custom 5.10 kernel whose uname -r is "5.10.253-Glow-v4.7",
yet the dashboard shows a red "kernel not supported" card and
recommends replacing the kmod with zygisk.
Two orthogonal problems, both fixed here.
1. KMI matching conflated KMI with Android OS release. kernelBranch was
parsed from an androidNN tag inside uname -r (the GKI Kernel Module
Interface identifier), but when the tag was absent the code fell back
to Build.VERSION.RELEASE — a ROM-level value that has nothing to do
with KMI. An Android 15 HyperOS on an android12 GKI kernel is a
common combo; the old fallback matched (Android 15, 5.10), which
isn't in the GKI baseline table, and escalated to zygisk. Now: match
only the parsed KMI. No Build.VERSION.RELEASE fallback for matching.
2. When the KMI tag is genuinely missing (custom kernels strip it), fall
back on kernel series. Unique series (6.1 / 6.6 / 6.12) each ship a
single KMI variant so the recommendation stays deterministic.
Ambiguous series (5.10 / 5.15) each ship two variants — propagate
both via new NativeInstallRecommendation.variantAmbiguous +
alternativeArtifact fields, so the install card surfaces "install X;
if it doesn't load after reboot, install Y instead" and the broken-
kmod banner offers the alternative when one of the two candidates
failed to insmod. kmodVariantMismatch accepts either candidate for
ambiguous series so it doesn't fire on a legitimately-installed
alternative variant.
Defense in depth on top of the smarter heuristic: every heuristic-
driven kmod warning is now gated on !kmodRaw.active (active kmod =
/proc/vpnhide_targets exists = empirical proof of working install), and
kmodOnUnsupportedKernel additionally requires freshForCurrentBoot so a
just-installed-but-not-rebooted module doesn't get prematurely flagged.
Also adds KmodBrokenReason.AmbiguousLoadFailed + matching card subtitle
and issue banner, plus EN/RU strings for all three new surface areas.
isMinifyEnabled was off in release, so Compose + material-icons-extended
+ kotlinx stdlib shipped un-shrunk. com.google.android.material was
pulled in solely for Theme.Material3.DayNight.NoActionBar, but Compose
handles DayNight itself via isSystemInDarkTheme() — swap for
androidx.core:core-splashscreen + a local theme parented from
Theme.DeviceDefault.NoActionBar. proguard-rules.pro already had the
Xposed keep rules, so enabling R8 + isShrinkResources was a flag flip.
While inside the splash path, hold it through the startup chain with
setKeepOnScreenCondition so the Dashboard appears already populated
instead of flashing three transient spinners (root check,
selfNeedsRestart, cache load). DashboardCache prewarm moved up into
MainScreen so it runs while the splash is still held.
Dead wizard-generated drawable/ic_launcher_{foreground,background}.xml
had no referrers — the live launcher icon is the adaptive-icon under
mipmap-*/.
APK: 47.22 MB -> 3.37 MB (-93%). Single classes.dex (multi-dex no
longer needed).
The Diagnostics screen rendered a raw key=value dump of
/data/adb/vpnhide_kmod/load_status + load_dmesg as a monospace blob,
asking users to "share this block" for bug reports. The same data is
already (a) parsed into proper Dashboard issue cards (wrong-variant
kmod, missing kretprobes, etc.) and (b) bundled into the Collect debug
log zip, so the on-screen dump was redundant and ugly.
- Drop KmodLoadTraceCard composable + its loader and state
- Drop diag_kmod_load_trace_{title,description} from EN+RU
- Repoint dashboard_issue_kmod_load_failed at "Collect debug log"
instead of the now-deleted trace section
- Edit changelog.json history[0] for v0.7.0 to drop the trace mention
and regenerate CHANGELOG.md + update-json/changelog.md
Quick uv-shebang script that hits the GitHub Releases API and prints a
per-asset download table for every release, plus a grand total. No new
deps in the repo — script declares its own (httpx + rich) inline.
DiagnosticsCache only triggered runAllChecks the first time the user
opened the Diagnostics tab. That meant a brief spinner on first navigation
even though the same caches we added for Dashboard / Protection make
those tabs instant. The cache itself was already designed to survive tab
switches — we just weren't kicking the load early enough.
MainActivity now fires DiagnosticsCache.run as soon as selfNeedsRestart
is known to be false. By the time the user switches from Dashboard to
Diagnostics, runAllChecks has typically finished and the tab opens with
results already in place — no spinner.
Audit of every "restart" mention surfaced one missing hint: the Tun
help accordion explained how to use the L/K/Z toggles but never said
which of them need the target app to be force-stopped + reopened
after Save. New apps_hint_restart_target paragraph fills the gap:
- L (LSPosed) and K (kmod) — UID lists are live-reloaded by the
hooks; the target sees the new behavior on its next syscall, no
restart.
- Z (Zygisk) — hooks are injected at zygote fork, so flipping Z for
an already-running target only takes effect on its next launch.
Audit pass: every user-facing mention of "restart" or "reboot" now
states explicitly which thing — the device, the VPN Hide app, or the
target apps — needs the action. A few were ambiguous (especially the
banner text + module-card subtitles around `selfNeedsRestart`, which
just said "restart the app" without saying which one).
UI strings (EN + RU):
- banner_added_self / dashboard_needs_restart: "Restart VPN Hide
(force-stop and reopen) … no device reboot needed".
- dashboard_installed_restart_app: "Installed, restart VPN Hide to
activate" (the Zygisk module-card subtitle, shown when VPN Hide
just added itself to its own targets).
- dashboard_reboot_needed: "Device reboot needed" (LSPosed module
card subtitle).
- dashboard_issue_version_mismatch: "Reboot the device to apply…".
- vpn_off_prompt: "Results are cached until VPN Hide is restarted"
(was "for the rest of this app session" — now names the app
explicitly).
README.en.md:
- Step 1.3 / Step 2 final lines say "Reboot the device".
The pkg→UID resolver fix in the previous commit makes targeting
multi-profile apps work transparently — but if the user also installs
the VPN Hide APK itself in more than one profile, there's a footgun:
Each APK instance shares the same target files (those live in
/data/adb/ and /data/system/, system-wide), but each instance's app
picker only sees apps from its own profile (PackageManager
.getInstalledApplications is per-user). A Save from a profile that
doesn't see all targets silently drops them from the saved list.
Detect this from the Dashboard: if `pm list packages -U --user all`
reports more than one UID for our own package, emit a Warning that
recommends uninstalling from secondary profiles and managing targets
only from the main one.
Android user profiles (work profile, MIUI Second Space, Private Space,
secondary users) each give the same package its own UID in the
namespace `<user>*100000 + <app_id>`. Previously every pkg→UID
resolver used plain `pm list packages -U`, which only emits UIDs for
the primary user, so the work-profile copy of Telegram kept seeing
the VPN even though the user had marked Telegram as a target.
Switch every resolver to `pm list packages -U --user all`. The pm
output format for multi-profile apps is comma-separated on one line:
package:com.android.chrome uid:10187,1010187
Each call site now splits on `,` and emits one UID per line so every
profile's copy is individually matched by the hooks. No UI changes —
"mark Telegram as a target" just now means "in every profile it's
installed in".
Resolvers touched (all places found by an audit, no duplicates left):
Shell (boot-time):
kmod/module/service.sh
zygisk/module/service.sh
portshide/module/vpnhide_ports_apply.sh
Kotlin (save-time via suExec):
AppPickerScreen.kt — buildUidResolver
AppHidingScreen.kt — buildHidingUidResolver
ShellUtils.kt — ensureSelfInTargets
TargetsCache.kt — PM_LIST batch script + parser
Verified on a Pixel 4a with a managed profile (user 10):
- Chrome toggled in LZ on primary → both 10187 and 1010187 land in
/data/system/vpnhide_uids.txt.
- Primary-only apps (Ozon, etc.) still resolve to a single UID.
- ensureSelfInTargets correctly adds both UIDs when vpnhide is
installed across profiles.
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().