A floating trackpad + pointer, a programmable keys panel, and a display picker / virtual display for Android — built entirely on the phone with a hand-rolled Termux toolchain: no SDK install and no Gradle. The build downloads only the platform jar it compiles against.
Designed for foldables: an unfolded Fold is a small desktop with no pointer.
Built for one workflow: an agent harness in Termux, with a browser beside it to check what the agent wrote. It needs three things a phone does not give you.
- A pointer. A touchscreen has no hover and no precise drag, so a browser's devtools, its
tab strip or its
✕are guesses. The pad draws an arrow and injects it as a realSOURCE_MOUSE, which is why a window drag behaves like a laptop's. The tabs you want are up in Termux's tab strip, away from the keys. - The keys.
Esc,Ctrl+C,Tab,Ctrl+B, the arrows,PageUp/PageDown: the soft keyboard has none of them. The panel's rows follow Termux's extra-keys rows, and the layout is a string you can replace at runtime. - Room for both. The browser and the terminal share one screen, and the split divider is thin enough that a finger misses it.
The display picker covers the rest: the pointer can be aimed at the cover screen, an HDMI or XREAL output, or a virtual display the app creates.
It was built and driven on a Galaxy Z Fold 4 (SM-F936B, Android 16, aarch64), in Termux, with no desktop in the loop — and that is the point: a phone with a package manager is a complete build host for this app. Nothing in the build reads a device model, so any machine with the tools in Prerequisites should do the same.
Three floating dots, and five controls docked inside the pad:
●(right, default) — toggles the trackpad⌨(left, default) — toggles the keys panel▤(left, default) — opens the floating-window list, described below- The dots are draggable and, on release, snap to the nearer vertical edge — they cannot be parked half off the screen, and a short flick no longer counts as a tap.
◐, the lock,▣and the gear — the theme menu, the move/resize lock, the display picker, and the CONTROLS panel. All four live in the pad, just under its title bar:◐and the lock on the left, the gear outermost on the right with▣beside it. They used to float too, but docked they cannot be lost behind another window — and they stay put when you drag the pad around.- CONTROLS (the gear) holds what is a setting rather than a control: whether the keys panel shows the full keyboard or your own favorite shortcuts, whether each optional dot exists at all, and two links — the keys guide below, and the issue tracker. The pad's own dot has no switch, deliberately: it is the only way to show the pad and it carries the gear, so hiding it would strand the way back.
Overlapping floating windows had no way to reach each other: the one at the back is simply
hidden, and nothing on screen names it. ▤ opens a list of them.
- The full-screen app comes first — being behind the floating windows is what makes it hard to reach, so it is the row you open the list for — then every floating window on this display, front-most first.
- Tap a row to bring that window forward. A window that was hidden behind another, or minimized, comes back.
- Tap the full-screen row and the floating windows in the way are minimized first, so the app you asked for is actually visible.
- Rows say what they are:
· full screen,· hidden, or· parkedif the app had to shrink a window out of the way instead of minimizing it.
It works by reading the window list over the same shell bridge the rest of the app uses, which
is why it needs Shizuku. Minimizing uses the pop-up's own minimize button — there is no API for
it — and the result is checked, falling back to shrinking the window into a strip at the bottom
edge if the tap did not take. Both mechanisms are scriptable: op=tasks lists, and
op=taskfocus --es arg <id> does exactly what tapping a row does.
Watch the 0.4.0 walkthrough on YouTube — the list, the CONTROLS panel, and the keys switch, on the unfolded screen.
The keys panel is data, not code: its layout is a spec string you can replace at runtime, with no rebuild.
op=keyswith no argument hands back the layout currently in use, so it can be edited.op=keys --es spec '<spec>'sets a custom one;op=keys-resetdrops it and returns to the built-in keyboard.op=keys-mode full|favorites— what the gear switches — chooses which of the two the panel shows.favoriteswith nothing saved yet falls back to the built-in layout rather than showing an empty panel.
Format: one key per entry as label:keycode, keys separated by ,, rows by |. A key takes
optional ;-separated attributes — m= a modifier (ctrl, alt, shift), n= a repeat
count, which is how the tmux CTRL+b b macro is written. A label may itself contain : or
; if it is escaped with a backslash.
adb shell am broadcast -n app.so7o.ztrackpad/.VDisplayReceiver \
-a app.so7o.ztrackpad.VDISPLAY --es op keys --es spec \
'CTRL:mod;m=ctrl,c:c;m=ctrl,v:v;m=ctrl,a:a;m=ctrl,\u232B:del|ESC:escape,TAB:tab'Keycodes the app knows by name: escape, tab, enter, del, move_home, move_end, plus
any single character.
Stuck, or something broken? Open an issue: https://github.com/jan5o7o/ztrackpad/issues,
and include the line op=status prints.
The shape of the device is why the controls above exist:
- Click-through keeps the big screen usable. The pad can cover the corner of an app and still click the thing underneath it, which is the difference between a trackpad and an obstruction. Measured in split screen, not just designed that way.
- The two screens pair up. The display picker aims the pointer at the cover screen while
the pad and the keys panel stay on the inner one. Retargeting and per-display injection are
verified; what is missing is the panels-on-the-other-screen case — display 1 reports
canHostTasks=false, so the controls stay where your fingers are. - DeX and glasses are what it was aimed at. The same pointer is meant to drive an
external desktop through DeX — it is the reason
SOURCE_MOUSEinjection is used at all instead of accessibility gestures. AGENTS.md keeps the honest split between what is confirmed by hand on that setup and what is only mechanically verified, and this README follows the same rule.
That is the whole app on the inner display: the keys panel across the bottom (a custom
layout, ESC through ⌫ back), the pad down the right edge with its ≡ LOCKED handle, its
◐ / lock / ▣ dots, and the two floating dots on the left and right edges. The drawn
pointer is up in Termux's tab strip.
That arrow is the pointer, injected as a real SOURCE_MOUSE — which is why hover, click and
scroll behave like a desktop's rather than a touchscreen's. What it does
below walks the features with a clip for each one that has footage.
The recordings are in docs/media/: driving a browser (75s),
Termux with the keys panel (57s) and creating a display, and the 0.4.0 walkthrough on YouTube (1:22)
— each one cut where the screen showed something personal, noted in
docs/screenshots. Those are plain links, and
GitHub serves a committed .mp4 as a download rather than playing it, so they fetch the file.
It is not on any app store, and it cannot be. The accessibility service injects taps, drags and key events — that is the whole point of the app — and Play's policy does not allow an accessibility service used for input injection. So it is a sideload: either take the built APK from the latest release, or build it yourself with the quick start below. Either way you end up with the same app.
From the release APK — download it, then let your browser or file manager install it. Android will ask you to allow installing unknown apps for whichever app is doing the installing; that is the normal sideload prompt, and it is per-app. Via a computer instead:
adb install -r ztrackpad.apkThe release APK is signed with the maintainer's key. A copy you build yourself is signed with yours, so Android treats them as different apps: uninstall one before installing the other. Both are the same code, so there is no reason to want both.
Requirements either way — Android 13 or newer, and Shizuku running with this app granted permission. Shizuku is what gives the app the privileges to inject real input; without it the pads appear but keys and drags do nothing. Shizuku itself needs adb (or root) to start after a reboot, which is the one real setup cost of using this app at all.
After installing, enable the accessibility service: open the app and tap Open Accessibility Settings, then turn on So7o Z Trackpad under Installed services. Two small dots appear, and that is it.
The build-from-source path — skip it if you installed the release APK above.
Everything runs in Termux on the device itself — reference environment: Galaxy Z Fold 4 (SM-F936B), Android 16 / One UI, aarch64. An agent can follow this literally; each step is checked in Prerequisites.
# 1. the repo and the toolchain — these are separate Termux packages, not one SDK
pkg install git aapt2 aidl d8 apksigner openjdk-21 zip python android-tools
git clone https://github.com/jan5o7o/ztrackpad.git ~/ztrackpad
# 2. the platform jar the build compiles against (27 MB, deliberately not committed)
mkdir -p ~/ztrackpad/sdk/platforms/android-36
curl -Lo /tmp/platform-36_r02.zip \
https://dl.google.com/android/repository/platform-36_r02.zip
unzip -o /tmp/platform-36_r02.zip 'android-36/android.jar' \
-d ~/ztrackpad/sdk/platforms/
# -> ~/ztrackpad/sdk/platforms/android-36/android.jar
# 3. build + install (needs an adb connection — see Prerequisites)
cd ~/ztrackpad
KSPASS=... ./build.sh
adb install -r out/ztrackpad.apk
# 4. enable the accessibility service (append, do not clobber other services)
SVC=app.so7o.ztrackpad/app.so7o.ztrackpad.TrackpadService
CUR=$(adb shell settings get secure enabled_accessibility_services | tr -d '\r')
case "$CUR" in
*"$SVC"*) : ;;
null|"") adb shell settings put secure enabled_accessibility_services "$SVC" ;;
*) adb shell settings put secure enabled_accessibility_services "$CUR:$SVC" ;;
esac
adb shell settings put secure accessibility_enabled 1
# 5. verify - works any time. Do NOT check logcat here: the app only logs at service
# startup, so a freshly cleared log is empty even when everything is fine.
adb shell am broadcast -n app.so7o.ztrackpad/.VDisplayReceiver \
-a app.so7o.ztrackpad.VDISPLAY --es op status
# Broadcast completed: result=0, data="shizuku=ready id=-1 kind=none window=hidden ..."
# ^ shizuku=ready is the bit that matters. shizuku=no means step 4 is unfinished.At startup the app also logs connected surface=... target=display ..., so
adb logcat -s ZTrackpad:* is useful while troubleshooting — just clear the log and
restart the service (settings put secure accessibility_enabled 0 then 1) before
expecting that line.
Then tap the ● bubble that appears. If Shizuku is not running, the app still
starts but the keys panel and drag do nothing — see step 4 of Prerequisites.
Most of this is agent-doable. Four steps are not, and an agent should stop rather than improvise around them.
| Step | Agent? | Notes |
|---|---|---|
| Install packages, fetch the jar, build, sign, install | yes | plain shell, all above |
| Enable the accessibility service | yes | step 4; append, never clobber |
| Verify state | yes | prefer the broadcast status; logcat only logs at startup |
| Create/show/hide/destroy a virtual display | yes | skills/ztrackpad-vdisplay/ (or the raw broadcast) |
| Enable wireless debugging | no | Developer-options toggle. Must already be on. |
| Start Shizuku | no | needs the Shizuku app, or a human-initiated start. Not persistent without root, so it must be redone after every reboot. |
| Grant the Shizuku permission | no | a runtime dialog; pm grant does not cover this one |
| Drive the UI by tapping | yes, but fragile | adb shell input tap X Y. The panels move and resize, and the picker grows a row per display, so a tap one row off hits the wrong control — prefer the broadcast API |
Five things, each with a check. Nothing here is optional except where stated.
pkg install git aapt2 aidl d8 apksigner openjdk-21 zip python android-tools| tool | Termux package | why |
|---|---|---|
aapt2 |
aapt2 |
compiles and links resources |
aidl |
aidl |
generates the IShellService binder interface |
javac, keytool |
openjdk-21 (17 also works) |
compiles Java; makes the keystore |
d8 |
d8 |
dexes the classes |
apksigner |
apksigner |
signs the APK |
zip, python3 |
zip, python |
repackage the APK; check zip alignment |
adb |
android-tools |
install, and drive the scripts |
git |
git |
to get the repo in the first place |
The package names above are Termux's. Nothing here is Termux-specific: on a desktop the
same binaries come from Android's build-tools (aapt2, aidl, d8, apksigner) plus any
JDK, zip and python3. After that, pkg install is the only Termux-flavoured line left
in the build.
zipalign is not needed — build.sh checks STORED-entry alignment itself with
python3.
for c in aapt2 aidl d8 apksigner javac keytool zip python3 adb; do
command -v $c >/dev/null || echo "MISSING: $c"
doneNot committed (27 MB). Fetch it once:
curl -Lo /tmp/platform-36_r02.zip https://dl.google.com/android/repository/platform-36_r02.zip
unzip -o /tmp/platform-36_r02.zip 'android-36/android.jar' -d ~/ztrackpad/sdk/platforms/Check: ls -l ~/ztrackpad/sdk/platforms/android-36/android.jar → about 27768026 bytes.
The libs/*.jar files are committed, so nothing else needs downloading.
Needed for adb install and for everything in Scripting it.
Wireless debugging must be on (Developer options → Wireless debugging), then:
adb connect <phone-ip>:<port> # port changes on every toggle
adb devices # must list the device as "device", not "offline"The port is not stable, so re-read it from the Wireless debugging screen each time. An mDNS
scan for the _adb-tls-connect._tcp service finds it without looking — but stale
advertisements linger after the toggle goes off, so try every port it reports and not just
the first.
- Install the Shizuku app (
moe.shizuku.privileged.api). - Start it. Without root, Shizuku does not survive a reboot — it has to be started again through wireless debugging after every restart.
- Grant ztrackpad its permission when it asks (the app requests it on first run).
Check: adb shell ps -A | grep shizuku_server shows a line. If it is absent, the app
runs in a degraded state rather than failing: arrow keys still work, but every
keycode is dropped, and drag and DeX input do nothing — the app logs
key ignored - Shizuku not ready.
| value | consequence | |
|---|---|---|
minSdk |
30 (Android 11) | the app installs |
GLOBAL_ACTION_DPAD_* |
added in API 33 (Android 13) | the arrow keys genuinely need Android 13+ |
targetSdk |
36 | compiled against Android 16 |
So the honest floor is Android 13, not the "Android 14+" an earlier version of this file claimed.
Seven things, with a clip where one exists. Anything marked no clip yet is implemented and verified — it simply has not been recorded, which is worth saying rather than padding the list with a picture of something else.
The hero clip at the top of this file shows it. A finger on the pad moves a drawn arrow over
any app, injected as a real SOURCE_MOUSE — so
hover, click, scroll and window drag all behave like a desktop's. Tap to click, hold still to
long-press, two fingers to scroll. The arrow in that clip is the app's, not the system's.
ESC, TAB, CTRL/ALT/SHIFT combinations, PageUp/PageDown, arrows, ⏎, ⌫ and
tmux-style macros such as CTRL+B twice — with sticky modifiers, hold-to-repeat, and a layout
that can be replaced at runtime with a single string. No clip yet; every row is listed under
Trackpad layout.
Five presets — Default, Dark, Light, High contrast and Glass — plus an opacity slider that scales the panel fills without touching text or borders. More in Themes.
Aim the pointer at the cover screen, an HDMI or XREAL output, or a virtual display the app creates itself — floating in the top half of the phone, or headless off-screen. More in Display picker.
The pad's own left and right edges scroll like a laptop's strip; and the lock dot freezes the pad's geometry while leaving input alone. No clip yet — see Split screen and Trackpad layout.
A click aimed under a panel still reaches the app beneath it, measured in split screen rather than merely designed. No clip yet — see Features.
am broadcast drives the virtual display, the keys layout and the pad lock, so the app can be
part of a script. No clip yet — see Scripting it.
- Floating trackpad + drawn pointer (no root; overlay windows)
- Real
SOURCE_MOUSEinjection via Shizuku for hover/click/drag — this is what lets a window actually be moved, whichdispatchGesturecannot do reliably - Shizuku is used when ready, with accessibility
dispatchGesturefallback - Floating-window list (
▤): every window on the display in one place — the full-screen app first, then each floating window, front-most first. Tap a row to raise it, so a window that is hidden behind another (or minimized) comes back; tapping the full-screen row minimizes whatever is in the way - Keys panel mirrors the owner's Termux extra-keys rows:
- Row 1:
ESC TAB CTRL 🅱️ SHIFT 🆎️ ALT HOME END 🅿️ *️⃣🅱️ ⏎ ⌫ - Symbol row:
~ \; : ? ' " - _ / ,` - Number + QWERTY rows,
SHIFT z x c v b n m ⏎, nav row,space+⌫ back - tmux macros fire (e.g.
*️⃣🅱️=CTRL+Btwice)
- Row 1:
- The whole key layout is customizable at runtime — one spec string, no rebuild:
vdisplay keys '<spec>'. Format and examples inskills/ztrackpad-vdisplay/SKILL.md, and the gear dot switches the panel between the built-in keyboard and your own spec. - A CONTROLS panel in the pad (the gear dot, outermost on the right): the keys choice above,
on/off switches for the
⌨and▤dots, and links to the keys guide and the issue tracker. The pad's own dot has no switch — it is the only way to show the pad, and it carries the gear. - Sticky
CTRL/ALT/SHIFTmodifiers, hold-to-auto-repeat on all keys - Haptics on key taps (system CLICK effect, honours
haptic_feedback_enabled) - Press feedback (keys highlight blue while held)
- All panels are draggable (top handle) and resizable from any of the four
corners; geometry persists across restarts. Only the bottom-right grip is drawn —
as the usual diagonal strokes — and the other three corners are invisible touch
targets, because a grip is nothing but a hit area and four handles on screen is just
clutter. (The pad's top-left corner briefly had no grip while the
◐menu button lived in the title bar; the button sits below the handle now, so all four work.) - Click-through: injected clicks briefly drop
FLAG_NOT_TOUCHABLEon the panels so desktop icons under the trackpad are still clickable. The flag needs a moment to be applied —updateViewLayoutonly queues it — so the injection waits ~40ms and the flag stays up for the gesture's whole length. Measured in split screen, where the pad covers most of a pane: a tap with the pointer under the pad lands on the app underneath. - A pad lock (the drawn dot beside
◐): tapped, the pad refuses to move or resize — the handle reads≡ LOCKED— and it stays locked across restarts. The grips and the drag listener stay attached and decline, so an accidental tap cannot be mistaken for a broken control. Edge scrolling still works while locked: the lock is about geometry, not input. - Edge scrolling: a touch that starts within
dp(28)of the pad's left or right edge scrolls instead of moving the pointer — a laptop-style strip for one finger. It runs at a quarter of the two-finger rate in 12px-of-travel steps (see Trackpad layout). - Display picker — aim the pointer at any display (cover screen, HDMI/XREAL, virtual/overlay) while the panels stay on the phone
≡ MOVE ← drag handle (tap to re-centre; reads ≡ LOCKED when locked)
◐ 🔒 ┌─ surface ─────┐ one finger moves · tap = click · hold then move = drag
│ │ the outer dp(28) of each side scrolls instead of moving
└ ← ↑ ↓ → ───┘ arrows (hold to repeat)
⌫ ⏎ ⋮ ◉ backspace · enter · right-click · pointer toggle
Gestures: one-finger drag moves the pointer · tap clicks · hold-still-then-move drags ·
two-finger tap = right-click · two-finger drag = scroll · swipe up/down along either
edge = scroll, which is the same scroll at a quarter of the rate in smaller steps, so a
side swipe is a fine adjustment where the two-finger drag is a coarse one. The left strip
is all scroll now: the ↑/↓ buttons that used to take a slice of it are gone.
Scroll arithmetic, so the feel is predictable: a flush is injected every 12px of finger
travel on the strip (36px for the two-finger drag), each strip flush sends
travel × 0.35 scroll units — it was × 0.7, and still felt fast — and a flush is capped
at those 12px with the remainder carried — so a fast
flick moves in the same small steps rather than one jump. In a list like Settings that is
roughly 23px of content per step; the conversion from scroll units to pixels belongs to
the app being scrolled, so a browser may move a different distance.
Tap ▣ (in the pad, under its title bar) to list every display and tap one to aim the
pointer at it. Two displays
are involved and they are independent:
| meaning | default | |
|---|---|---|
| surface | where the panels are drawn — the display your fingers can reach | the default display |
| target | where the pointer and every injected event go | same as surface |
For an XREAL/DeX screen these must differ: you drag on the phone and the cursor
moves on the glasses. The pad chip reports the target (≡ MOVE · SZ · ▶ 1920×1080).
- Finger deltas are scaled by
outW/screenW, so "drag across the pad" means the same fraction of the screen on any display. Ratio is 1 when they match, so the normal case is byte-for-byte the old behaviour. - Every pointer is drawn by us; the system pointer is always hidden. A window we
own cannot be composited into another display's output, and the system pointer is
not rendered on external/virtual displays at all, so on a target display the arrow
is a second overlay window opened on that display (
createDisplayContext). On the phone screen it is an overlay there; over the floating window it is drawn on top of that window. Before this, pointing at the XREAL showed no cursor whatsoever. - The target is persisted by name + physical mode size, never by display id —
ids get reused as displays come and go (
Overlay #1is id 7 on this device).
dispatchGesture() has no display parameter, so the accessibility fallback can
only ever stroke the surface display. When target ≠ surface and Shizuku is not
ready, sendStroke() refuses rather than silently clicking the wrong screen,
and the pad chip shows ⚠ NO SZ.
DisplayManager.getDisplays() is filtered for apps — on this device it
returns only the default display, and DISPLAY_CATEGORY_PRESENTATION is empty,
so the cover screen and any virtual/overlay display are invisible. But
getDisplay(id) is not filtered. So the picker probes ids 0..63 with
getDisplay(id) (cheap local binder calls, no Shizuku), plus a cached shell sweep
over dumpsys display for anything the probe misses.
The footer of the ▣ display list creates a virtual display owned by ztrackpad, rather
than writing the global overlay_display_devices setting that InnerDesk also
manages (two owners of one global would fight).
It has to be created shell-side (ShellUserService), for two reasons learned
the hard way:
- A PUBLIC, task-hosting display needs
CAPTURE_VIDEO_OUTPUT/ADD_TRUSTED_DISPLAY, which shell holds and an app does not. In-app it fails withSecurityException: ...screen sharing virtual display..., and the suggestedOWN_CONTENT_ONLYalternative can only ever show the owning app's own content. - The Context must be the
com.android.shellpackage context. The system context is packageandroid(uid 1000) while we call as shell (uid 2000), and is rejected withSecurityException: packageName must match the calling uid. Also preferActivityThread.currentActivityThread()oversystemMain()— Shizuku's process already has one, and callingsystemMain()again throws.
There are two controls, and they mean different things:
| Control | Where | surface |
Result |
|---|---|---|---|
| create virtual display | footer, first row | null |
Hosts tasks, but state=OFF, windows mViewVisibility=0x4 (INVISIBLE), and injected clicks do not land. Headless automation only. |
| create floating display | footer, second row | a SurfaceView's Surface |
Fully working: state=ON, renders live into the floating window, and injected input lands. |
| hide / show | on the display's own row | — | Transient: drops the surface but keeps the display and its apps alive, so show restores the same display id. |
| destroy | footer, same row as create | — | Releases the display and anything running on it. |
Hiding is deliberately not destroying, and it is a better answer than a "minimize" would be: the window's surface is the display's output, so collapsing the window would shrink the display's resolution and every app on it would re-lay out.
That single difference is the whole story — the same call with and without a surface
is the difference between state=OFF/invisible and a display you can actually drive.
The floating window defaults to the top half (screenW × screenH/2, so 1812×1025
here) and is then draggable by its title bar and resizable by four corner grips, with
geometry persisted under the v_ prefix. It has to be touchable to be draggable at
all, which means it swallows touches: drag it over the keys panel or the pads and they
stop responding until you move it off. The display is resized to match the surface on
every resize, or the content would be stretched.
Verified end to end. Launch the Calculator on the display, aim with the pad and
click, and the digits appear in the floating window: clicks logged at 954,696 and
1382,696 landed as 4 and 6 — exactly the buttons at those coordinates.
The AIDL carries the Surface over to the shell process. That needs the parcelable
declared for the aidl tool: aidl/android/view/Surface.aidl exists purely so
android.view.Surface resolves as an import, because aidl resolves imports by path
and does not read framework parcelables out of android.jar. It is never compiled.
For a second screen on real hardware, use HDMI / XREAL / DeX — or
overlay_display_devices, which also yields an ON display with a render target.
Two different causes, both now fixed. They look identical, so check which one you have.
- An empty surface-backed display mirrors the default display. The floating window is drawn on that same display, so the window rendered itself, recursively. Creating a floating display now seeds it by starting Samsung's secondary launcher on it, which gives it content of its own — that is why a desktop appears on a new display without you asking. If the mirror comes back, first check whether the display is empty.
- Two virtual displays feeding one surface. A leaked Shizuku user-service process survived an app update and kept hold of its display, so a second one was created alongside it. Fixed by a client-death watchdog: the shell process now releases its display and exits when the app goes away.
If you still hit it — e.g. a process left behind by an older build — kill them and restart the accessibility service so Shizuku rebinds to one fresh process:
adb shell 'for p in $(ps -A | grep "ztrackpad:shell" | awk "{print $2}"); do kill -9 $p; done'Both panes are on one display, so nothing in the injection path has to know a split exists: the pointer's position decides which pane gets the click, and touching a pane also focuses it, so the keys panel then types into that one. Click-through matters more here than anywhere else, because the pad usually covers part of a pane.
The virtual display can be driven without touching the picker. ztrackpad exposes a
broadcast receiver, and skills/ztrackpad-vdisplay/ ships a script, a pi skill and a pi
extension for it:
adb shell am broadcast -n app.so7o.ztrackpad/.VDisplayReceiver \
-a app.so7o.ztrackpad.VDISPLAY --es op statusOps: status | create [headless] | show | hide | destroy | lock on|off|toggle |
keys [<spec>] | keys-reset.
The reply arrives as
Broadcast completed: result=0, data="shizuku=ready id=25 kind=floating window=shown surface=alive vsize=1245x1397 target=0 padlocked=false keys=default".
- The
-ncomponent is required. An implicit broadcast does not reach a manifest-declared receiver on API 26+, so-aalone silently does nothing. - It needs the accessibility service and Shizuku, because the service owns the
overlay windows and the shell binding. Without it the reply is
error: accessibility service not connectedrather than a silent success. show/hideare transient (the display and its apps survive);destroyreleases them.createreturns before the display exists, because it is built from the SurfaceView's surface callback — pollstatus.- The receiver is exported without a permission, so any app on the device can toggle the display. The worst case is a display appearing or disappearing.
lockis the one op that is not about the virtual display: it freezes the pad the same way its lock dot does, and both go through the same code, so they cannot disagree.vdisplay lock on,off, or no argument to toggle;statusthen reportspadlocked=true. Useful for watching something full-screen without the pad drifting.- There is no scriptable way to set the target. Which display the trackpad drives is chosen in the picker only; the broadcast covers the display's lifecycle, not what it points at.
See skills/ztrackpad-vdisplay/SKILL.md for the full reference. The script is
skills/ztrackpad-vdisplay/scripts/vdisplay, and extensions/vdisplay.ts exposes it to pi as
a vdisplay tool. Both are symlinked from ~/.pi/.
The menu applies on tap — five presets and an opacity slider that scales the panel fills without touching text or borders. The clip in What it does shows the preset switching from Default to High contrast.
Open the menu from the ◐ bubble sitting just under the pad's title bar, at its
left. (It began as a separate floating bubble, then briefly a hamburger; it only ever
does one job, so it keeps the theme glyph.) It is deliberately not in the title bar —
there it competed with the drag gesture, and the whole bar should stay draggable.
Five presets:
| Preset | Look |
|---|---|
| Default | the original, byte for byte — translucent dark panels |
| Dark | opaque near-black; stays readable over a bright app |
| Light | dark ink on pale panels, with a black arrow, for a bright room or DeX on a monitor |
| High contrast | solid black, bright borders, squarer corners |
| Glass | the panels all but disappear; the labels carry the layout |
Below the presets is a panel opacity slider (20–100%), which scales only the panel
fills — dimming the text and borders too would not make the pads look more transparent,
it would just make them unreadable. Both the preset and the opacity persist in the pad
prefs (theme, opacity), and the opacity is independent of the preset, so switching
presets keeps your transparency.
The slider applies on release, not on every tick: applying means rebuilding the panels,
and rebuilding inside a SeekBar's own touch callback would delete the slider mid-drag.
The percentage label updates live so the drag still feels responsive.
Presets live in Theme.java rather than in res/values/ deliberately: the
build → install → bounce-the-service loop is about 40 seconds, which is far too slow to
try a colour out. Every colour and corner radius is a named field on Theme
(accent, panelSolid, textDim, cursorHot, radius, …) rather than a positional
lookup, because the same literal means different things in different panels —
0x66FFFFFF was a panel border in one place and dim text in another, and Light has to
move those in opposite directions. Adding a preset is one static block plus one entry
in PRESETS. Resources would also give automatic day/night, and can still be layered
underneath later.
Two things worth knowing:
- The two floating dots are restyled in place, not rebuilt, because their positions are not persisted — a rebuild would scatter them back to their defaults.
- Every control that carries a glyph has its own colour role, so a preset can move them
independently:
bubbleTrackandbubbleKeysfor the floating dots,bubbleThemefor the◐dot,bubbleDisplayfor▣. The lock is deliberately monochrome (textDim) — it is drawn rather than typed, so it takes a colour at all. Defaultreproduces the old hardcoded values exactly, so the refactor is verifiable by eye: switching to it changes nothing.
cd ~/ztrackpad
./build.sh # aapt2 → aidl → javac → d8 → apksigner (all from Termux)
adb install -r out/ztrackpad.apkThe build needs sdk/platforms/android-36/android.jar — see
Prerequisites for the exact fetch — plus the libs/*.jar files,
which are committed for reproducibility.
The keystore password is not in the repo. Signing reads it from KSPASS in the
environment or from ~/.ztrackpad-kspass (outside the repo), and build.sh fails
closed if neither exists:
KSPASS=... ./build.sh # or once:
printf %s 'the-password' > ~/.ztrackpad-kspass && chmod 600 ~/.ztrackpad-kspassbuild.sh generates and reuses keystore.jks (gitignored). Both the original
DroidOS apps share a signature-level permission — irrelevant here; this is a
standalone app.
- The pad is raised above the keys panel so taps on the trackpad in the overlap region don't fall through to keys beneath.
- The drawn pointer is a
FLAG_NOT_TOUCHABLEoverlay, so its z-order is cosmetic only; the system pointer is hidden viaInputManager.setPointerIconType(TYPE_NULL). - Drag (
press-and-hold then move) deliberately does not use the click-through toggle (finger is down on the panel the whole time). - A child view beats the surface under it, which is why the edge strips are dead under the docked dots and in the four corner grips.
- The lock is
LockDot, drawn by hand rather than typed as🔒: an emoji ignoressetTextColorand renders in the font's own colours whatever the theme says. Its one icon serves both states on purpose — a control that changes under the finger that just tapped it reads as a glitch, so the handle's word is the state (≡ MOVE/≡ LOCKED). - The pad's gesture grammar is decided at
ACTION_DOWN, never on first move: the hold timer, the tap-then-drag window and the edge strip all claim a touch there, or a slow press would be misread as a drag. - The icon is the app's own pointer, and nothing else. The artwork it is cut from draws the
whole pad — title bar, dots, key grid, arrow — and at 48 px that is an unreadable smudge, so
the launcher gets the arrow alone and the rest stays in the artwork. It is an adaptive icon
(
mipmap-anydpi-v26), so the launcher supplies the shape, with a monochrome layer for Android 13's themed icons;minSdkis 30, so nothing needs a legacy density-bucket fallback.
MIT — see LICENSE.
The five jars in libs/ are third-party and are dexed into the APK, so their terms travel
with it: Shizuku-API (MIT, Copyright (c) 2021 RikkaW) and androidx.annotation
(Apache-2.0). Texts and attribution: THIRD_PARTY_LICENSES.md.
None of the Shizuku manager is bundled or redistributed here — this is a Shizuku client.
It declares moe.shizuku.manager.permission.API_V23, which is how a client asks to be
allowed to use the API; it claims no permission of its own. Not affiliated with, or
endorsed by, the Shizuku project.






