LSPosed / Vector (Xposed framework)¶
LSPosed is a Zygisk-based implementation of the
Xposed framework — it injects into Zygote so Xposed/LSPosed modules can hook any app's
Java/Kotlin (and, via their bundled native libs, ART) at runtime. Vector is JingMatrix's
actively-maintained successor (the project rebranded LSPosed → Vector at v2.0); it ships the
same org.lsposed.lspd daemon and is what this guide pins.
Because Beetroot already flashes Magisk/Zygisk modules declaratively and activates Zygisk on
boot (see Magisk Modules and Boot Flow), Vector
is "just another module" — there's no manual Magisk-app tap-through to install the framework.
What this guide adds is the other half of a real Xposed test: installing an Xposed module as
a normal app (so the package manager populates its nativeLibraryDir — the thing
LSPatch-embedded patching can't do, see rosetta-xposed#25),
and enabling it in LSPosed's scope for a target app — non-interactively, so it's automatable.
Verified on the binder: vm TCG VM
The flash → reboot → activate flow below was verified end-to-end on the QEMU micro-VM
backend (beetroot modes → binder: vm, TCG): the lspd daemon comes up, Zygisk is
injected into zygote64 and system_server, and the scope DB schema below is the live
one (modules_config.db v4).
1. Flash the framework¶
Add Vector to your instance's modules: (or copy examples/lsposed.yaml
over a fresh beetroot.yaml):
api_version: 6
android:
version: 14
modules:
- url: https://github.com/JingMatrix/Vector/releases/download/v2.0/Vector-v2.0-3021-Release.zip
sha256: d5e39669c02c2c699ab948eb8f3639b348eefb7749553224a9c62fa4a2f2dc18
magisk:
denylist:
- com.google.android.gms
- com.google.android.gms.unstable
When a newer Vector is cut, look it up at
https://github.com/JingMatrix/Vector/releases, swap the URL, and recompute the digest with
curl -sL <url> | sha256sum.
2. Activate Zygisk (the second-boot rule)¶
Zygisk injects Zygote at Zygote start, which happens early in boot — before the framework
can be flashed/enabled on the very first boot. So a freshly-flashed Zygisk module (LSPosed
included) goes live on the next boot, when magiskd reads the persisted zygisk=1 at
post-fs-data and injects Zygote at its first start. In practice:
Confirm the framework is up:
beetroot shell <name> -c 'logcat -d | grep -iE "lspd|Vector daemon"'
# I LSPosedService: Vector daemon started: lateInject: false
# I LSPosedService: version 2.0 (3021)
beetroot shell <name> -c 'ps -A | grep lspd' # the daemon process
binder: vm needs persistent /data
The second-boot activation requires /data to survive a reboot (so zygisk=1 and the
flashed module persist). The redroid binder: host/auto backend bind-mounts the
instance's data/ to /data, so this is automatic. The binder: vm micro-VM persists
redroid's /data on the guest rootfs (a directory bind-mounted into the redroid
container; override with BEETROOT_GUEST_DATA_DIR) — also automatic, but note the
vm.boot_cache warm-start reverts to its checkpoint each resume (discarding the flashed
module, zygisk=1, and modules_config.db written after that checkpoint), so leave
boot_cache: false while iterating on a persistent LSPosed setup. Beetroot prints a runtime
warning on every warm resume to flag the /data reset.
3. Install an Xposed module as an app¶
This is the step that makes the module's native libraries loadable: installing the module's
APK through the package manager populates its nativeLibraryDir, so System.loadLibrary(...)
inside the module resolves on ART (exactly what an LSPatch-embedded module cannot do).
adb connect localhost:5555 # or: beetroot shell <name>
adb -s localhost:5555 install /path/to/your-xposed-module.apk
4. Enable the module in scope (non-interactive)¶
LSPosed stores enabled modules and their per-app scope in a SQLite database at
/data/adb/lspd/config/modules_config.db. Two tables matter:
-- which modules are enabled
modules(mid INTEGER PK, module_pkg_name TEXT UNIQUE, apk_path TEXT, enabled 0|1, auto_include 0|1)
-- which target apps each enabled module hooks
scope(mid INTEGER, app_pkg_name TEXT, user_id INTEGER) -- PK(mid, app_pkg_name, user_id)
To enable module com.example.module for target app com.example.target (user 0),
non-interactively:
beetroot shell <name> -c 'su 0 sh -c "
DB=/data/adb/lspd/config/modules_config.db
APK=\$(pm path com.example.module | sed s/package://)
sqlite3 \$DB \"INSERT OR REPLACE INTO modules(module_pkg_name, apk_path, enabled, auto_include)
VALUES (\\\"com.example.module\\\", \\\"\$APK\\\", 1, 0);\"
MID=\$(sqlite3 \$DB \"SELECT mid FROM modules WHERE module_pkg_name=\\\"com.example.module\\\";\")
sqlite3 \$DB \"INSERT OR IGNORE INTO scope(mid, app_pkg_name, user_id)
VALUES (\$MID, \\\"com.example.target\\\", 0);\"
"'
Magisk ships its own sqlite
If sqlite3 isn't on the device, use magisk --sqlite "<statement>" against the same
file path, or pre-seed modules_config.db on the host and push it into place. LSPosed reads
its config at daemon start, so apply scope changes before the second boot, or
beetroot restart <name> after writing them.
5. Launch the target and observe hooks¶
beetroot shell <name> -c 'monkey -p com.example.target -c android.intent.category.LAUNCHER 1'
beetroot shell <name> -c 'logcat -d | grep -iE "LSPosed|your-module-tag"'
When the module's hooks fire you'll see its log lines and the LSPosed "loaded module" entries
under /data/adb/lspd/log/modules_*.log. Note that a module's XposedBridge.log(...) output
lands in that LSPosed module log (not adb logcat), which is the reliable channel to assert
on headlessly.
Automated end-to-end test¶
Beetroot ships a self-contained module-hook e2e so you can prove the whole pipeline works on a fresh instance:
tests/fixtures/xposed-hook-module/— a minimal prebuilt Xposed module (party.beetroot.hooktest) that hooksandroid.app.ActivityonCreate/onResumein any app it's scoped to and logsBEETROOT_HOOK_FIRED. Sources +build.share included so you can rebuild it.scripts/lsposed-hook-e2e.sh— drives the flow against a booted, Vector-active instance:
# 1) flash Vector (examples/lsposed.yaml), `beetroot up`, `beetroot restart` (2nd boot → active)
scripts/lsposed-hook-e2e.sh setup localhost:5555 \
tests/fixtures/xposed-hook-module/beetroot-hook-test.apk com.android.settings
beetroot restart <name> # lspd loads the newly-scoped module
scripts/lsposed-hook-e2e.sh check localhost:5555 com.android.settings
# [+] PASS — module hook fired: BEETROOT_HOOK_FIRED onResume pkg=com.android.settings
This is verified on the binder: vm TCG VM: launching Settings produced
BEETROOT_HOOK_FIRED onCreate and onResume, proving a real Xposed method hook fired on a
Beetroot instance end-to-end. The e2e.yml tier-lsposed-hook job runs the same flow in CI.
Building a module against the Vector/LSPosed API
Vector obfuscates the de.robv.android.xposed.* API and remaps your module's references to
its internal classes at load. Compile against the API as compileOnly (don't package it),
and match the exact signatures — e.g. XposedHelpers.findAndHookMethod returns
XC_MethodHook.Unhook. A wrong return type compiles fine but throws NoSuchMethodError at
runtime because the full (remapped) method descriptor no longer matches. See the fixture's
stubs/.
Why a Beetroot rooted phone, not LSPatch on a vanilla AVD¶
System.loadLibrary("dexkit") (and any module that ships a native .so) needs the library
extracted into a real nativeLibraryDir. LSPatch-embedded patching doesn't populate that, so
the native load fails (see rosetta-xposed#25).
A Beetroot instance installs the module as a normal app, so the package manager populates
nativeLibraryDir and the native side loads on ART — turning that issue's hardest approach
("real LSPosed install") into the easy default.