Skip to content

Rewrite the manager in Compose, and retire :app - #796

Open
JingMatrix wants to merge 19 commits into
masterfrom
manager-compose
Open

Rewrite the manager in Compose, and retire :app#796
JingMatrix wants to merge 19 commits into
masterfrom
manager-compose

Conversation

@JingMatrix

@JingMatrix JingMatrix commented Jul 27, 2026

Copy link
Copy Markdown
Owner

The manager is rewritten in Compose and replaces :app, which is removed in this branch — the
zygisk module has been packaging :manager as manager.apk for a while, so the old one was already
shipping nothing.

Everything here runs against the daemon on a real device; the branch builds with zipDebug and the
manager also installs as an ordinary app if you would rather not flash to try it.

What is new

Home shows what the project is doing. Six months of commits on a rail, everyone who wrote them
credited including co-authors, and a marker saying exactly which commits your build is behind — the
version code is git rev-list --count, so no extra endpoint is needed to work that out. Opening the
screen does not talk to GitHub every time; most launches read the snapshot on disk and say so.

Modules reads as what is running before anything is read at all: state is the name's colour,
inactive rows recede, and each row shows the icons of what it hooks rather than a count. Tapping an
icon starts a selection, which can then be enabled, disabled, backed up or uninstalled together.

Scope shows how each app got there — the module fixed it, auto-include keeps it, the module
asked for it, or you ticked it — as a ring around its icon. Anything already in the scope ignores
every filter, so a default can never hide a choice you made.

Logs no longer loads whole files into memory. It indexes byte offsets and pages a window around
the viewport, so a four-megabyte part costs a scan rather than several megabytes of heap in a process
whose memory belongs to com.android.shell. It also reaches the daemon's rotated parts, which were
previously unreachable — chevrons on the line counter move between them.

Store works again. The mirrors it inherited are dead or refusing: modules.lsposed.org answers
modules.json with 403, modules-blogcdn returns 418, modules-cloudflare has no DNS record. It
also stops DNS-over-HTTPS being all-or-nothing, which is the single largest cause of an empty Store —
on a network where Cloudflare itself is blocked, every lookup used to fail.

Canary builds are now downloadable without an account. GitHub gates artifact downloads behind
sign-in even for public repositories, so CI attaches each master build to a canary-<versionCode>
prerelease and the app lists those. Report a problem answers the checklist first — try a canary,
update Zygisk, attach logs — including a root export for when the daemon is the thing that crashed.

Try it

Grab the debug zip from the newest canary release,
or install manager.apk from the module directory as a normal app. Bug reports are very welcome,
particularly on ROMs and Android versions I cannot test here. If something looks wrong, the Logs
panel now saves a zip that contains everything I would ask for.

Notes

Two daemon-side changes come with it: the rotated log parts are exposed over AIDL, and
isVerboseLog() reports the stored preference instead of OR-ing it with BuildConfig.DEBUG, which
made the switch impossible to turn off on a debug build.

manager/README.md covers the parts worth knowing before reading the code: why there is no
dependency injection, how the log reader works, and how a Material colour scheme is generated from a
seed when the platform will not do it.

Compose with Material 3 Expressive, which is not in a stable material3 release, so
material3 is pinned above the Compose BOM rather than taken from it. Navigation 3
for the back stack. Kotlin is declared at the root with `apply false` because AGP 9
otherwise supplies its own, older, version and Coil's metadata will not load
against it.

`githubClientId` is read from a gradle property and defaults to empty; the app hides
sign-in entirely rather than offering something that cannot work.
`getVerboseLog()` only ever handed over the part being written, while the daemon
keeps ten — so most of an hour's logging was unreachable from the manager. Two
calls list the parts and open one by name. The name arrives from an unprivileged
process and is used to build a path inside a directory only root can read, so it is
never trusted: it has to be one of the names the listing just returned, which rules
out traversal by construction rather than by scanning for "..". The listing reads
the directory rather than LogcatMonitor's LRU, so a manager opened after a daemon
restart still sees the history the LRU has forgotten.

`isVerboseLog()` returned the stored preference OR'd with `BuildConfig.DEBUG`, so
against a debug daemon it always read true: the manager could never show "off", its
switch snapped back on every tap, and the only way to stop that looking broken was
to disable the control. The OR was redundant — the preference already defaults to
true — so a debug build still logs verbosely out of the box, and a developer can
now also turn it off.
…wn mark

The status notification opens `:manager` now. Its icons are the Winged Victory
rather than the inherited ones, which is the only place the framework shows a face
outside the app itself.
Navigation 3 models the back stack as an observable list of typed keys, so an
argument like a module's user id is a constructor parameter rather than a string
that can be parsed and silently dropped.

Everything is shaped by where this runs. Parasitically the manager is injected into
`com.android.shell`: its manifest is never installed, so there is no
ContentProvider, no `androidx.startup`, and no guarantee that an Application
subclass exists. Hence hand-rolled service location whose `attach` and `bind` are
idempotent and order-independent, a single activity, and the platform theme.

Every daemon call suspends on `Dispatchers.IO` and returns a `Result`; the binder is
read once per call rather than checked and then used, which was a race.

Messages carry a tone: Material's snackbar said "force stopped" and "could not
uninstall" in identical grey.
Android exposes no public way to turn a colour into a Material scheme —
`dynamicColorScheme` reads the wallpaper and nothing else — so below Android 12,
or with dynamic colour off, the only option was one hand-written palette.

`SeedScheme` generates one in CIE LCh: hold the seed's hue and chroma, walk L*
across the Material tone scale. The part that matters is the gamut search — most
(lightness, hue) pairs cannot hold the seed's full chroma in sRGB, so each tone
binary-searches the most it can keep. Clipping the channels instead, which is the
obvious shortcut, shifts hue as tones darken, and that is why naive generators
drift blue to purple down the ramp. Error stays red whatever the seed: a
destructive action must not become negotiable because someone picked green.

The picker is a hue/chroma wheel rather than an HSV square, because angle and
radius are literally the two numbers the generator consumes. It is painted at the
tone the accent will occupy, so choosing dark mode visibly lightens it.

The sources are one row — wallpaper, presets, wheel — because they are alternatives
to each other, and a switch labelled "dynamic colour" above an unrelated list of
swatches hid that. Underneath, the tone ramp the choice actually produces: a seed
that looks lovely as a dot can still make a muddy container.
The status header needs breathing room above its status line, and space that exists
only because a layout needed it reads as a mistake. Four surfaces fill it, each
answering touch: snow that bursts where pressed and grows where it is not, a maze
with one wanderer solving it, falling code that freezes under a held finger, and
nothing at all for people who want nothing at all.

The maze is carved by a randomised depth-first walk and then braided open, because a
perfect maze is all dead ends and a wanderer in one mostly reverses; the loops are
what make its turns choices. The circuit sits beside it deliberately — they are
opposites, a designed path many signals share against an undesigned one a single
wanderer has to solve.

None of them may compete with the text over them: they draw in the header's own
on-container colour at low alpha, and the frame loop parks entirely when nothing is
moving.
Six months of commits on a rail, with everyone who wrote them credited — co-authors
included, because a co-author is a contributor. Contributors can be ordered by
volume or by recency: by volume the maintainer leads forever and the row never
moves; by recency a first contribution is visible the day it lands.

Two things needed care. GitHub links a commit to an account by email and does not
always manage it, so a contributor can arrive unlinked. A `@users.noreply.github.com`
address *is* the account and needs only parsing; otherwise a handle-shaped name is
worth one lookup, and nothing else is — `GET /users/Qing` answers 200 with an
unrelated person, so probing every display name would eventually credit a stranger.

And `versionCode` is `git rev-list --count`, so a commit's distance from HEAD is
exactly its version number: the feed can say which commits an update would bring,
by name, with no extra endpoint. A build *past* the head of master says so instead —
it was built locally, and then the feed is not the history of what is running.

Opening Home is not a reason to talk to GitHub. Most launches read the snapshot on
disk and say they are resting; only a request that was actually attempted and failed
reports a failure.
… is filed

GitHub gates artifact downloads behind an account even on a public repository —
`actions/artifacts/<id>/zip` answers 401 anonymously where a release asset answers
206 — so "test a canary build" used to be an invitation to a dead end. CI publishes
the same zips as a `canary-<versionCode>` prerelease now, and this lists them with
their sizes. Nobody signs in to anything.

"Report a problem" no longer opens the tracker directly. The maintainer's own first
reply to a bug report is a checklist, and a screen can do most of it: try the latest
canary, update Zygisk, attach logs. The logs matter most, and the two routes are not
equivalent — the zip comes from the daemon, and when the daemon is the thing that
crashed, root is the only way to reach the files. The manager has none of its own,
so it asks for it, and says so when refused.
State is the module name's colour, not a control: accent for running, muted for
off, error for incompatible, and an inactive row at 45% opacity so the list reads as
"what is running" before anything is read at all. Each row shows the *icons* of what
it hooks rather than a count, because recognition beats arithmetic — and the
framework, which is a scope target with no icon, gets a mark of its own instead of
collapsing into a number that matches nothing on screen.

Three targets to a row: the middle column opens the scope, the icon selects, a long
press opens the action sheet. Selection is what makes the list worth having —
enabling eight modules after a restore, or backing up a chosen few, was not possible
at all before. The bar that replaces the header is laid over it at the header's own
size, so nothing below moves when a module is picked up.

In the scope editor, three mechanisms can put an app in a scope and they behave
differently when the world changes — the module fixed it, auto-include keeps it, the
module asked for it, or someone ticked it. Each gets a ring around its icon; all
four used to render as the same checkbox. Anything already in the scope ignores
every filter, so a default can never hide a choice that has been made.

The long-press sheet carries re-optimize, which is not obvious and is the point:
ART inlines small methods into their callers ahead of time, and an inlined method
can no longer be hooked, so a module that works on one device silently does nothing
on another. It is the first thing to try when a hook "just doesn't fire".
The reader used to materialise whole files: `readLines()` into a `List<String>`
held in a StateFlow, for both tabs, on every refresh — several megabytes of String
per tab inside a process whose heap belongs to `com.android.shell`. It indexes and
pages now: one sequential byte scan records where every line starts into a
`LongArray`, and the pane holds a window around the viewport.

One pane, not two. The module log and the verbose log answer the same question at
two levels of detail, so the source is a control inside the search field; two tabs
meant two search boxes and threw away the query at the moment you wanted to carry it
across. The line counter follows the viewport rather than the loaded window, and
carries chevrons for the daemon's rotated parts — of which it could previously reach
only the one being written.

A filter states itself as chips above the list, which is also what lets every row
drop its own tag column when filtered to one tag. Text selects the way text selects
everywhere else, so copying a whole line moved to a double tap.
The endpoints the Store inherited are dead or refusing: modules.lsposed.org answers
modules.json with a 403, modules-blogcdn returns 418, modules-cloudflare has no DNS
record at all. The list comes from the one host that serves it; per-module detail is
served by both, so the public site is a real fallback there and only there. Merging
the two lists back into one would quietly take the Store offline again.

Freshness is declared per request so the disk cache is actually used, and when every
mirror fails the same request is replayed against the cache alone — which is why a
cold start with no network renders the last known catalogue rather than an error.

DNS-over-HTTPS is a fallback, never the only path. It used to be all-or-nothing, and
that is the single largest cause of an empty Store: on a network where Cloudflare is
itself blocked — precisely the network the setting is for — every lookup failed.
The legacy strings are being replaced rather than ported: they name LSPosed
throughout, and a rebranded fork that still says the old name in half its dialogs
has not been rebranded. Only what the new screens actually use is kept.
Modules, Store and Logs are the same kind of screen — title, a few actions, a
search field, a long list — and each had grown its own header: two hand-rolled rows
and a `TopAppBar`, of three different heights. Switching tabs therefore moved the
search field, which is the one control a thumb learns the position of, and moving
it is the sort of thing that is felt long before it is noticed.

`PanelHeader` fixes the height rather than measuring it, so a subtitle that comes
and goes costs nothing below it. What the panels keep is where that subtitle sits:
the module count stays under the backup and restore icons, where it reads as one
idea with them, and the log's line counter stays under the title, because it is a
property of the file rather than of the actions. Titles are bold now, in all three.

Verified on the device: the search field's top edge is at y=448 on Modules, Store
and Logs alike.

The scope editor is deliberately left out. It is a screen you arrive at and leave
again, so it carries a back arrow and a name, and the shape of the panels you
navigate between is the wrong shape for it.

The level rail in the log is a touch wider — enough to read as a colour rather than
a hairline, not enough to become a column.
…e resting

Material's snackbar is inverse-surface: dark on a light theme, light on a dark one.
That inversion is deliberate in the spec and wrong here — a message that is the
opposite colour to everything around it reads as belonging to the system rather
than to the app, which is what the easter egg's message looked like. It sits on the
app's own raised surface now and earns prominence from elevation and shape.

The stars, forks and licence line came from a second request, and a cached read
makes none — so it vanished on every launch that deliberately did not fetch, which
is most of them. Stored beside the commits, for the same reason the commit total is.
Rewritten as a subsystem note in the shape the other modules use — what the module
is, how it is laid out, and the handful of decisions a reader would otherwise have
to reverse-engineer: why there is no dependency injection, why the log reader
indexes instead of loading, how a colour scheme is generated when the platform will
not, and which mirrors are still answering.

What it no longer is: a design document. The rationale for individual screens
belongs in the commits that introduced them, where it stays attached to the code it
explains.
The header now owns the search field rather than sitting above one, and the whole
block is a fixed height: a title row that may carry actions, a line of description,
and the field. Fixing the height rather than measuring it is what makes the layout
predictable — a description that appears only once a catalogue has loaded, or a
line counter that is empty until a log is read, costs nothing below it and shifts
nothing.

The module count moves under the title with the others. Keeping it under the backup
and restore icons made sense on its own and made three panels disagree in a way
that was visible the moment you switched between them.

Verified on the device: the search field's top edge is at the same y on Modules,
Store and Logs.

The README's emphasis is italic rather than bold, which is easier to read at length.
`:manager` replaces it, and the zygisk module has been packaging `:manager` as
manager.apk for some time — so the old module was already shipping nothing. It
remained only as a settings entry and a Crowdin source.

Crowdin now points at the manager's strings instead. The legacy set was
LSPosed-branded throughout and was replaced rather than ported, so leaving it as
the translation source would have kept translators working on text nothing renders.
The release build never ran locally — only debug — and CI caught it:
`:manager:minifyReleaseWithR8` fails with "Compilation failed to complete", preceded
by a list of unresolvable `androidx.window.extensions.*` and
`androidx.window.sidecar.*` references. R8 treats a missing class as an error unless
told otherwise, and those two families are supplied by the device at runtime rather
than by the SDK — the library resolves them reflectively and falls back when they are
absent.

`NavigationSuiteScaffold` pulls `androidx.window` in, so the manager inherits them
whether or not it ever asks about a folding screen. Two `-dontwarn` rules, and the
release APK links.
@JingMatrix

Copy link
Copy Markdown
Owner Author

Screenshots from the current branch, on a Pixel 6 running Android 17.

Home Modules Logs
Canary builds Before you report

Home is reading its saved copy rather than calling GitHub, which is what it does on most launches —
hence the line saying so. The canary list is the real thing: those two zips download without an
account.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant