Skip to content

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

Merged
JingMatrix merged 99 commits into
masterfrom
manager-compose
Jul 29, 2026
Merged

Rewrite the manager in Compose, and retire :app#796
JingMatrix merged 99 commits into
masterfrom
manager-compose

Conversation

@JingMatrix

@JingMatrix JingMatrix commented Jul 27, 2026

Copy link
Copy Markdown
Owner

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

What is new

  • Home — project activity. Six months of commits, everyone who wrote them, and how far behind
    your build is. Most launches read the saved copy rather than calling GitHub.
  • Modules — state is the row's colour, and each row shows the icons of what it hooks. Tap an
    icon to start a selection and act on several at once.
  • Scope — a ring around each icon says how that app got there. Anything already in the scope
    ignores every filter.
  • Logs — indexes byte offsets and pages a window instead of loading whole files, which matters
    in a process whose memory belongs to com.android.shell. Also reaches the daemon's rotated
    parts, which were unreachable before.
  • Store — works again. The inherited mirrors are dead: modules.lsposed.org answers 403,
    modules-blogcdn returns 418, modules-cloudflare has no DNS record. DNS-over-HTTPS is no
    longer all-or-nothing.
  • Framework updates — read the notes for a build and flash it from the app, with the
    installer's output on screen. Older releases stay browsable, so a bad build can be rolled back.
  • 19 languages, right-to-left included. The manager's language is independent of the phone's.
    setApplicationLocales is keyed on an installed package and this one is never installed, so the
    override is applied in composition instead.
  • Speed — deciding whether a package is a module meant opening 363 APKs on every visit to the
    panel: 748 ms. Cached against version code and install time: 16 ms.

Try it

This branch is not on master, so the canary releases do not contain it. Take the build from this
PR's own run: the ✓ beside the latest commit → DetailsSummary, zips at the bottom.
Downloading an Actions artifact needs a GitHub account.

Flash the whole zip rather than installing only manager.apk. The daemon checks the manager's
certificate against one embedded when it was built, so the two only pair when they come from the
same build, and a PR build is signed with the debug key.

Bug reports welcome, particularly on ROMs I cannot test here. The Logs panel saves a zip with
everything I would ask for.

Notes

Daemon-side: rotated log parts are exposed over AIDL, isVerboseLog() reports the stored
preference instead of OR-ing it with BuildConfig.DEBUG, and there is an install API that detects
the root implementation and flashes through it. A system scope row is normalised to user 0 rather
than dropped, so a module in a work profile or private space can hook the framework — that is #136,
and it supersedes #356.

module.prop carries the commit a build came from, marked -dirty when the tree was not clean.
The version code is the commit count on origin/master, so a branch build and a master build were
previously indistinguishable on the device.

Debug builds get a second launcher icon with fourteen scripted device states, for screens that
cannot otherwise be reached without breaking the phone. It lives in src/debug, so a release build
has no such activity.

manager/README.md covers what is worth knowing before reading the code.

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.

The manager can now be set to a language of its own, independently of the
phone, and ships with Simplified Chinese, Traditional Chinese and Russian.

The per-app language API is not available to us: setApplicationLocales is
keyed on an installed package, and parasitically the manager is never
installed — it runs inside com.android.shell, whose language is not ours to
change. So the override is applied in composition instead, which has the
side benefit of taking effect instantly, with no activity restart and no
effect on any other app in the process.

That has one sharp edge, and it cost a crash to find: everything drawn in
its own window — sheets, dialogs, dropdown menus — gets a fresh
AndroidComposeView, which re-provides LocalContext and LocalConfiguration
from the window's own context on the way in. Two consequences, both fixed
here:

  - the context handed down has to remain a wrapper *around the activity*.
    createConfigurationContext returns a detached one, and Compose finds
    LocalActivityResultRegistryOwner by walking the ContextWrapper chain,
    so the Modules panel died on entry with "No ActivityResultRegistryOwner
    was provided" the moment it registered its install launcher.

  - the override has to be re-established inside each popup, or the app
    reads Chinese while its filter menus, sort menus and settings sheets
    stay English. LocalizedOverlay does that; dialogs go through
    VectorAlertDialog so a new one cannot forget.

Dates and counts followed the process locale too, which is the host's:
month separators in the feed rendered "juillet" beside Chinese text. Month
names are now chosen at draw time from the composition's locale, and the
feed model groups by an invariant key instead of a formatted string.

Also: the appearance and language buttons now share a centre line with the
state word rather than floating either side of the whole header block.

Not covered:
  - The scrim and drag-handle content descriptions on bottom sheets are
    Material's own strings, emitted outside our content lambda. They are
    screen-reader only and follow the phone, which is defensible; there is
    no hook to override the scrim's.
  - Three languages, not thirty. Nothing was blocked by a policy — the
    limit is what I can stand behind. Machine-plausible technical strings
    (scope, hook, daemon, module) land in Crowdin marked *translated*,
    which suppresses the native review that would have caught them, and
    that is worse for a reader than falling back to English. crowdin.yml
    now points at the manager's resources, so this is the community's to
    fill in.
German, Spanish, French, Indonesian, Italian, Japanese, Korean, Polish,
Portuguese (Brazil), Turkish, Ukrainian and Vietnamese, joining Simplified
Chinese, Traditional Chinese and Russian. All 329 translatable keys in each,
with each language's own plural categories rather than a copied one/other
pair — Polish and Ukrainian carry few/many, the languages that do not
inflect carry only other.

Indonesian lives in values-in, not values-id: Android still keeps the
obsolete ISO 639 code, and a values-id folder is silently never matched.
Verified on device — the picker offers it and the strings resolve.

Two things that only showed up once a second language existed:

  - compactCount formatted with Locale.getDefault(), which is the host
    process's. A reader on a French phone who had chosen English got
    "11,9k"; it now takes the composition's locale, and Indonesian
    correctly shows "11,9k" while English shows "11.9k".

  - exactTime went through DateUtils, whose formatting reads
    Locale.getDefault() no matter which context it is handed, so the month
    abbreviation stayed in the phone's language. It now asks for the best
    pattern for the chosen locale and formats with it, which keeps the same
    shape — abbreviated month, year only when it is not this one.

manager/tools/check_translations.py is the check that kept these honest:
missing and unknown keys, placeholder parity per string, and Han or kana
appearing in a Latin-script language — the last of which is how a Chinese
sentence inside the Russian file was caught. It runs from anywhere and
exits non-zero, so CI can take it whenever we want it to.

Confidence, since it varies and pretending otherwise would be worse: the
Romance and Germanic languages and the CJK ones I would ship as they are.
Polish and Ukrainian are correct in vocabulary and plural form, but a
native reviewer should read the longer explanatory strings — the log
rotation and verbose-logging paragraphs are where register slips. Arabic,
Hebrew and Persian are deliberately absent: they need RTL, and no screen
here has been laid out or tested in that direction, so shipping the strings
alone would produce a broken app rather than a translated one.

Crowdin still owns all of this. Anything here that reads badly should be
corrected there by someone who speaks it.
…r way

Arabic, Hebrew and Persian, and with them right-to-left. Compose does not
take the layout direction from the configuration we provide — it is read
once from the host view — so choosing Arabic translated every string and
then laid them out left-to-right. Providing LocalLayoutDirection alongside
the configuration mirrors the whole tree at once: rows, start/end padding,
alignment and the auto-mirrored icons all follow it, and no screen needs
RTL handling of its own. Four icons that pointed the wrong way are now
auto-mirrored, and the log part swipe flips its sign, because dragging
forward in a right-to-left language means dragging the other way.

French is what exposed the layout assumptions, all of them the same
assumption — that a label is as short as its English source:

  - The catalogue's sort menu became a sheet. It held three exclusive
    groups, a multi-select group that ranks its choices and a network
    switch, separated only by dividers; the switch was the only row with a
    leading icon, so its label started 24dp right of every other, and it
    was the only label long enough to wrap. Menus are for words. Sheets
    have room for sentences, headings per group, and a Switch with the line
    that explains what it costs.

  - Scope's three menus went the same way, for the same reason: at the
    width a menu gives itself, "Seulement ce que le module demande" wrapped
    to two lines beside single-line neighbours.

  - The search field's placeholder may never wrap. It did in French, which
    grew the field and broke the three-row header every panel shares.

  - The package name under the Scope title now middle-ellipsises rather
    than scrolling. A package is read from both ends — the head says who
    publishes it, the tail says which one it is — and the title above it
    was already marqueeing, so the bar had two things moving at once. That
    marquee is now finite: this is a screen you sit on while working
    through a list, and a title that never stops moving is a distraction.

  - Home's four doors share a height per row. A one-line label beside a
    two-line one left a short card floating in a tall row. Not a fixed two
    lines: English fits on one, and the next language may need three.

Sheets got their half-height stop back. Every one of them passed
skipPartiallyExpanded, which removes the only thing a drag can do other
than dismiss, so the appearance sheet opened at 84% of the screen and could
not be made smaller. The tall ones scroll now, so they are usable at that
stop rather than only when dragged to full.

SheetHeading, ChoiceRow, ToggleRow and SheetAction moved into a shared file
— they were being copied out of the appearance sheet, which is how two
sheets end up looking almost the same.

Marquee was considered for the wrapping menu labels and rejected: it hides
a choice behind a delay in a list whose whole purpose is comparing choices,
and it fights the reduce-motion setting. It is right for a single
persistent title, which is the one place it is still used.
Version numbers, package names and uid/pid triples are identifiers, not
prose. Dropped into an Arabic paragraph the bidi algorithm reorders their
segments, so "2.0 (3049) · API 101" rendered as "API 101 · (3049) 2.0" —
same string, different meaning. Both monospace styles now pin their text
direction, which covers every place the app prints something a machine
produced.
A pinch now scales every ambience, not only the code rain, and it means the
same thing in all four: zooming out gives more of the thing — a finer
drizzle, a finer maze, a busier board — and zooming in gives fewer and
larger. Scale and speed are stored per kind, so the header comes back as it
was left rather than resetting on every launch, and tuning one ambience
does not disturb another.

The code rain's glyphs were a quarter of the header each, which read as a
headline rather than as code; they start at about half that now, with the
pinch there for anyone who wants them bigger. Its second axis is a vertical
drag: dragging down pushes the rain along, dragging up holds it back. That
is the axis the rain already means, and the two questions are genuinely
different — how much can I read at once, and how long do I get to read it.

The drag contract changed to fix a real bug behind all of this. The surface
was reporting the distance from where a drag began, and it had no way to
know when a drag ended: this version of Compose's transform detector
reports no gesture boundary, so the remembered origin leaked from one drag
into the next. The maze rebuilt on a nudge, and the rain hit its speed
ceiling on the first flick because every frame of a drag multiplied by the
running total rather than by that frame's movement. Renderers are now given
the increment and accumulate whatever they need themselves.

Measured on device: a full-height drag roughly doubles or halves the speed,
repeatably and symmetrically, and a 15px nudge does nothing at all.
The selection bar filled the entire header — 150dp of it — for one row of
controls, so a count and four icons floated in a band of colour with a
finger's width of dead space above and below, and the icons were sized for
a bar a third as tall.

It now takes the title and description rows and stops there, which is what
it should always have done: the search field stays visible, stays live, and
above all stays exactly where the thumb left it. PanelHeader grew a
titleOverlay slot to make that expressible, so the two rows' height is
enforced in the one place that already owns the header's geometry rather
than by a bar that measures itself and hopes.

Icons went from 22dp to 26dp with 48dp targets, and the enable action lost
its ▶, which read as "play" rather than "turn these on".
The language sheet ends with a link to the Crowdin project — last rather
than first, because someone who opened this sheet came to change the
language, and an invitation above the list would be in the way of that. It
is there for the reader who has just scrolled past their own language and
not found it.

A language someone put their name to shows a chip crediting them, tappable
to their page while a tap anywhere else on the row still selects the
language. Languages without a named translator show nothing, which is every
language today: all of them were machine-assisted and none has an author
who should be named for it.

The credits are a reviewed Kotlin file, not a translatable string, and that
is deliberate. The obvious design — a `translator` key each translator
fills in — puts a name next to a *link*, and translation rights on Crowdin
are considerably cheaper to obtain than commit rights here. A URL arriving
through the translation pipeline is a URL nobody reviewed, rendered inside
the app under the project's name. Adding yourself is one line, in the same
pull request that brings your translation down, where a maintainer sees it.
It was the last item in the list, reachable only by scrolling past
nineteen languages. A dock at the *foot* of the sheet is not available to
us — the sheet rests at half height, so the bottom of its content is
off-screen exactly when the list is longest — so it sits under the heading,
where it is visible at every height the sheet has.
They were two rows carrying the same Translate glyph forty dp apart, and
the invitation — a two-line list item — outweighed the sheet's own title,
which is backwards for a secondary link. At the half-height rest it also
spent two of the five visible rows before the reader reached a single
language.

It is now a chip on the title row: one glyph, permanently visible at any
sheet height, and the list gets its rows back. The chip's label is short so
it fits beside the title in every language, and the full sentence is what a
screen reader hears — "Translate" alone could as easily mean translating
something *in* the app. The explanatory summary is gone rather than hidden;
the destination explains itself.
The scope editor is a separate screen with its own view model, so the list
behind it had no way to learn that the thing it depicts — a row of app
icons per module — had just been edited. Applying a scope and pressing back
left the old icons in place until a manual pull to refresh, which is asking
the user to correct the app's bookkeeping.

ModuleRepository now carries a scope revision that the editor bumps *after*
the daemon accepts the change, never on an attempt, and the list reloads
its facts when it sees one. A fact, not a guess, so nothing else has to be
re-read.

Not verified on device yet: the working tree does not currently compile —
another change in flight has RepoRepository referencing a model field that
is mid-rename. This commit builds on its own; it will be exercised once the
tree is consistent again.
Three corrections, all found by reading what the previous manager did and by
watching the daemon refuse things on a real device.

**Resolution.** `CATEGORY_INFO` was missing. An app that has a screen worth
opening but deliberately keeps out of the launcher declares exactly that, and
it is the case this whole feature is about — master tried it before
CATEGORY_LAUNCHER, and this manager skipped it. The order is now the Xposed
settings category first for a module, then INFO, then LAUNCHER.

**Multi-user.** The `lsp_no_switch_to_user` extra was not being set. The
daemon reads it, and without it switches the device to the profile parent and
locks the screen before starting the activity — right for an activity that
lives in one profile, startling for someone who pressed "open" on a module
whose window shows for every user. It is taken from the resolved activity's
FLAG_SHOW_FOR_ALL_USERS, as master does.

**Nothing to open.** Most modules have neither a companion nor a launcher
entry, so the control is now absent rather than present-and-apologetic: the
floating button and the sheet row appear only when something resolves.

**The launcher-icon setting** reaches `settings` as a command. Two in-process
routes were tried on a Pixel 6 on Android 17 and both are closed to this
process: the hidden `IContentProvider.call` signature it used has not existed
since Android 12 (NoSuchMethodError, logged and swallowed, switch moved
anyway), and going through the system context's resolver fails with
"Unable to find app for caller … when getting content provider settings",
because the daemon has an ActivityThread but no application record. The
command is stable where that interface demonstrably is not, and the daemon is
root; it already shells out for module installs and dex2oat.
The Information panel reported "not declared" for most modules, and it was
telling the truth: `scope` in the catalogue is optional metadata that most
authors omit. Measured against the live index today — 510 of 814 entries
carry none — so the row was blank far more often than not, which reads as a
broken parser rather than as missing data.

For a module that is installed, the authoritative list is in its own APK, in
`META-INF/xposed/scope.list` or the legacy `xposedscope` metadata, and this
app already reads it — that is how the scope editor knows what a module
asked for. The panel now falls back to that, labelled as coming from the
installed copy so the two are not confused.

The catalogue still wins where it has an answer: it describes the published
module, while the APK describes whichever build happens to be on this device.
**A network fetch inside a compare-and-set loop.** All three feed reads were
`_feed.update { github.load(freshness) }`. `update` is a CAS spin loop that
re-invokes its lambda whenever another writer wins the race, and this lambda
is a fetch, an archive append, a snapshot rewrite and a several-thousand-
commit re-parse — none of it the cheap idempotent body that construct
requires. Three writers touch that flow, so under contention all of it ran
twice for one result. Loaded first, assigned second.

**An empty user list wiped the configuration.** `getRealUsers()` swallows a
null binder and any failure of `IUserManager.getUsers` into an empty list.
The rebuild then found no package info for any module, sent every one of
them down the not-installed path, and deleted its row, its scope and its
preferences — a transient failure to reach the user service costing every
module's configuration on the device. No users now means the question could
not be asked, and the rebuild waits for the next request.

**A guess reported as a diagnosis.** A module that will not load was reported
as "the framework could not read its code", but the loader returns the same
nothing for four different causes: an unparseable zip or DEX, no init files,
no module classes, and a module built against libxposed API 100 — which this
framework refuses outright, which is the same break the API list already
describes. Three times out of four that message named the wrong cause, so it
now says what is actually known.
Opening any module's scope list killed the manager: a null MutableStateFlow
in findCompanion.

The lookup is started from init, and it runs on Main.immediate, which does not
dispatch when it is already on the main thread — so the coroutine body begins
executing inside the constructor. It reached for the field holding the answer,
which is declared below init and therefore still null, parked that null across
the suspend call to findAppUi, and called setValue on it when the answer came
back.

The field moves above init, which is where a property an init block reaches
has to live. Nothing else in the project has the same shape: the only other
init that calls a method defined later is ModulesViewModel.loadModules, and it
touches nothing declared below it.

Also puts openModule's documentation back on openModule, where it drifted off
when the companion lookup was added between them.
The floating button on a module's scope page asked two different questions.
Whether to show it: "does this module have a companion, or failing that a
launcher entry?" What to do when pressed: "does this module have a launcher
entry?" — the companion category was left out of the second call.

So for a module whose only screen is its Xposed settings activity, which is
most of them, the button was always shown and could never work. Pressing it
resolved nothing, started nothing, and said "nothing to open" about a module
whose screen it had already found. Pixelify GPhotos is one; its ActivityMain
now comes up.

The parameter loses its default in both calls, so the two questions cannot
drift apart again without a compile error. All four call sites already state
which one they mean.
The daemon captures the manager's logcat: logcat.cpp routes any tag starting
"Vector" to the verbose stream, so VectorManager lines land in the Verbose tab
beside the daemon's own, and LOG_ID_CRASH is admitted by the same filter, so
manager crashes do too. Proven on the device — the live verbose log carries
"I/VectorManager ] store: 814 modules from ...".

Both only hold while verbose logging is on and a daemon is alive to do the
capturing, and neither is true in the case that matters most: a manager
crashing on a device whose framework is not activated. Nothing keeps that
trace, so a report of "it closes when I open a module" arrives with nothing
attached and the stack trace that explains it in one line is already gone.

So the trace is written to disk before the platform handler runs. The
directory is not a choice: FileSystem.getLogs already collects
cache/crash from both of the manager's homes, as crash_shell and
crash_manager, and the previous manager wrote there for that reason. One file
per crash named for its epoch second, five kept. Writing anywhere else — as
this first did, in filesDir — means a second place to look and a file that
travels with nothing.

Verified end to end against the running, unmodified daemon: an induced crash
appears in the exported zip as crash_manager/1785257410.log, carrying the four
things a stack trace never does — which build including the dirty marker,
standalone or parasitic, the thread, and the platform version.

The System Status page grows a card when the directory is not empty, since
that is the page someone opens when they are about to report something.
The manager had eight log calls across eighty-six files, against the daemon's
hundred and forty-one, and seventy-odd places where a Throwable was turned
into a snackbar and dropped. Pressing the companion button on a scope page
resolved nothing, started nothing, and produced no output at all — the bug was
found by reading the code, because there was nothing else to read.

Sixty-five sites now name the operation, its subject and the consequence. They
were found by fanning out over the source, and every one was then given to a
separate reviewer told to refute it: five were, for logging an expected
empty answer, for firing per row, or for duplicating a line one layer down.
About a hundred more were rejected during the survey.

None of this invents a mechanism. logcat.cpp routes any tag beginning "Vector"
into the daemon's verbose stream, so a line logged under Constants.TAG lands
in the Verbose tab beside the daemon's own and travels in the zip export. A
file-local tag would be correct Android and would land nowhere; VectorDns had
one, and it is gone. The convention that keeps it that way is now written on
the tag itself: area prefix, the Throwable last and never e.message, nothing
secret interpolated, and never a CancellationException, because navigating
away from a screen is not a failure.

The first thing it found, on the first run, was a torn commit archive: eleven
of 6349 lines spliced, a commit message cut mid-word with the next record's
{"sha": welded onto it. append() used appendText, which opens its own stream,
and a hundred commits is many buffers — so prefetch's load() and the home
screen's backfill() interleaved. Writers are serialised now, and a parse that
finds more than one bad line repairs the file rather than skipping the same
lines on every launch forever. Compaction stops reading the file a second time
to count lines the parse just counted.

Proven on the device: the archive went 6349 lines to 2679, the complaint is
gone, and the next launch appended its head window onto a file that parses
whole.
Two calls threw away the daemon's reply and reported success over the top of
it. The logging pass could not have caught either, because there was nothing
to log: both paths were already, as far as the manager knew, working.

setIncludeNewApps is `boolean` in the AIDL and was typed Result<Unit> here.
The daemon answers false when no row was updated — a package it holds no
module row for, or the framework itself — so a refusal and a success were the
same value, and the switch moved either way over a setting that was never
stored.

startActivityAsUserWithFeature is `int` in the AIDL, and openAppUi returned a
hardcoded `true` over it. The daemon returns -1 for a refused user switch or a
missing ActivityManager, and otherwise ActivityManager's own start code, which
is negative for a class not found, an unexported or disabled activity, or a
denied permission. Every one of those was reported to the caller as "opened",
so the screen said nothing and nothing opened — the same symptom as this
morning's companion button, which is exactly how long that class of bug hides.
START_SUCCESS is 0 and @hide, so the comparison is written out with the
constant named beside it.
The badge showed minApiVersion. The framework has never read it.

module.prop carries two numbers: minApiVersion, the author's stated floor, and
targetApiVersion, what the module was built against. FileSystem.readModuleInfo
picks the loading strategy from targetApiVersion alone — >= 101 is modern, 100
is refused outright, anything else falls back to a legacy assets/xposed_init
or does not load. A grep for minApiVersion across the daemon, the xposed
runtime and the services returns nothing at all.

So the WeType module, which declares min 101 and target 102, was shown as
"API 101": a number chosen by its author as a floor, displayed as though it
were the module's generation, next to a framework that had switched on 102 to
decide how to load it.

Three things follow from using the right field. The badge names what the
module is. The "built for N, and 101 changed it" caution is judged on that
same number, so a module declaring min 100 and target 101 is no longer warned
about a break it is on the far side of. And a module that states only a target
stops being reported as declaring no API at all, with a red "?" where its
version belongs.

minApiVersion keeps exactly one job, and it is the honest one: a module whose
floor is above what this framework implements is marked incompatible. The
framework will load it regardless — that mark is the author's warning, not the
loader's, and it is the only place the number is honoured.
The switch read "Force launcher icons — Show icons for apps that hide
themselves from the launcher", which promises something it cannot deliver and
is why it looked broken.

What it governs, from LauncherAppsService.getLauncherActivities in AOSP and
byte-for-byte the same in this device's services.jar: with the setting
non-zero the framework injects a synthetic entry pointing at
android.app.AppDetailsActivity for an app that passes shouldShowSyntheticActivity
— not a system app, requests at least one permission, and declares a
manifest-enabled MAIN/LAUNCHER activity it is currently not showing. At zero
the method returns early and adds nothing. So it cannot give an icon to an app
that never declared one, which is every module whose only activity is its
Xposed settings screen, and both of the ones on this device declare no
launcher activity at all.

Proven on the device rather than argued. With org.matrix.demo's own launcher
activity disabled by itself, ILauncherApps.getLauncherActivities returns the
synthetic AppDetailsActivity at 1 and an empty list at 0, reversibly; with the
activity re-enabled the result is identical at both values, which is the
control that makes the first half mean anything. The Pixel launcher's drawer
shows the same difference — but only after it rebuilds, since nothing
broadcasts this setting, so flipping the switch and watching the drawer not
change is the expected experience and now the summary says so.

The keys are renamed rather than reworded in place. Eighteen translations
carried the old claim, and Crowdin would have kept every one of them asserting
it; falling back to correct English beats staying wrong in nineteen languages.
The manager was already fully translated into its eighteen languages; what was
missing was only today's work — the crash card on the System Status page and
the reworded launcher-icon switch. Five strings, ninety entries.

Each language was written against the file it lands in rather than from the
English alone, so the terms already chosen there for module, launcher, app
info and export are the ones used, and the register matches: German is
informal because that file already says "dein", French keeps its vouvoiement.

Two tokens stay untranslated inside the prose because they are not words.
`crash_manager` names a folder inside the exported zip, and a reader who
translates it will look for something that is not there; "Android 10" is a
version.

Checked mechanically rather than trusted: all five names appear exactly once
in each of the eighteen files, every file still parses, every apostrophe that
needs escaping is escaped — French, Italian and Turkish each needed some — no
value was left identical to the English, and aapt accepted the lot.
Two things put the framework row in front of the user's own work.

It was exempt from the system-apps filter, on the reasoning that a module
needing it would otherwise be stranded. The exemption above already covers
that: an app in the scope is never filtered away, so once the framework is
chosen no filter can hide it. Before it is chosen it is simply the most system
of system apps, and someone who has asked not to see those has asked not to
see it.

And it was pinned to the very top absolutely, which put a target nobody had
chosen above every target they had — the list opened on the framework rather
than on the work. It still needs a pin, because it is not an app and does not
sort like one: by name it lands under S, by install time wherever its borrowed
timestamp puts it. So it now leads what is left, after the chosen.

Grouping the chosen at the top applies to every ordering rather than only to
Relevance. This is a picker: the rows already ticked are the ones you come
back to check, and hunting for them alphabetically among several hundred is
the work the sort was meant to save. Each sort still orders within the groups,
and the grouping no longer depends on whether the framework is visible, which
an earlier draft of this had it doing.

Checked on the device against ChromeXt: with the system filter off the
framework row is gone; with it on the order is Chrome, Spotify, the app ticked
during the test, then System Framework, then everything else. The daemon still
reports the module's original two-app scope, so nothing was applied.
Twenty-five findings, all of them confirmed by a second reviewer when the
audit ran and none of them a blocker. They are grouped here because they were
one piece of work, not because they are one change.

Things that lied to the user. ROOT_NONE was 0, which is also what a binder
proxy returns for a transaction the daemon does not implement — so a daemon
too old to answer reported "no root installed", and the manager told a rooted
user to go and install the root manager they were already running. ROOT_UNKNOWN
takes 0 now and the rest move up; every consumer already used the names, so
nothing else had to change. Force-stop reported success whatever happened. The
Retry button after a failed install only dismissed the error. The modules
empty state blamed the search when a filter was responsible. A module refused
for declaring libxposed API 100 was reported as the generic "could not load
it", which is the one load failure the framework can actually explain.

Things that lost data or work. A corrupt feed snapshot threw away the whole
commit archive instead of falling through to it. The detection cache pruned by
package name, so an updated package kept its old key and its new one alike and
the file gained a line per update and never lost one. ScopeScreen had no
BackHandler, so a back gesture discarded unapplied scope edits without asking.
ModuleUpdateQueue.acknowledge refused to run while the queue was running, which
is exactly when it is needed. A rate-limited contributor lookup was memoised as
"no such user" for the life of the process.

Things that were wasteful or unsafe. prefetch revalidated against GitHub on
every launch instead of honouring the cached window. AppRepository opened every
installed APK itself rather than using the shared detection cache, paying the
scan twice. performCacheUpdate had no mutual exclusion with itself, so two
rebuilds could interleave and publish a state built from a half-read database.
The CLI answered "which modules are enabled" from the async cache while
everything else read the configuration.

Things that were merely wrong. ScopeScreen showed a blank area while loading
and when nothing matched. Switch rows were announced to screen readers as
buttons with no state. The history-exhausted latch never cleared once set.
StatusRibbon.kt was dead except for the enum it declared, which moves to
StatusHeader.kt. A KDoc block documented a function three declarations away.
Dead members in ModulesViewModel and their dead uses in the screen. BUILD_TIME
was still a buildConfigField nothing read. CI skipped signing in silence when
the key was absent, so a release could ship unsigned and look fine. Three
claims in the manager README described behaviour the code does not have,
including a NoSuchMethodError that cannot happen — the proxy returns a default,
which is the very reason ROOT_UNKNOWN was needed.

Checked rather than assumed: manager and daemon both build, ktfmtCheck and
ktfmtCheckScripts pass, the workflow YAML parses, no locale strings file was
touched, every ROOT_* consumer in the repo uses the named constants, and every
symbol deleted as dead has no remaining reference.
Four strings landed with that batch: a force-stop failure, the load failure
for a module built against libxposed API 100, the root implementation a daemon
too old to answer cannot name, and the modules empty state, which gained "or
filter" because a filter can empty that list too.

Each language was written against the file it lands in. The failure line
follows whatever construction that file already uses for its siblings —
"Impossible de…" in French, "%1$s konnte nicht… werden." in German, "No se
pudo…" in Spanish — rather than a literal rendering of the English, and the
load line opens the way modules_load_no_apk and modules_load_unusable already
open in that file. "libxposed" and the number 100 stay as they are.

The fifth string is one the device found. ScopeScreen had no empty state at
all, and the fix reused modules_no_match for it — so a list of *apps*, filtered
to nothing, said "No module matches that search or filter." It has its own
string now, and eighteen translations that take each file's own word for an
app: appli in French, app in German and Italian, застосунок in Ukrainian,
應用程式 in Traditional Chinese.

Checked across all eighteen: every file parses, each of the five names appears
exactly once, the %1$s in the force-stop line survives untranslated, and no
apostrophe is left unescaped.
@3gf8jv4dv

Copy link
Copy Markdown

This UI is beyond my expectations. It is awesome.

I have two questions about the design of the Home page.

  1. Will the planned Home page include only GitHub project activity information, or will other choices be added, such as legacy LSPosed information page?
  2. Will GitHub project activity information be obtained only through GitHub, or are there alternative mirror/CDN solutions? AFAIK, access to GitHub may be restricted in certain regions.

@3gf8jv4dv

Copy link
Copy Markdown

For the “Try a canary”“Open on GitHub” button, I suggest adding a filter event:push. This will display only commits pushed to the master branch, which will help avoid confusion.

Three complaints about the scope screen, one cause between two of them.

"What the module asks for" was still subject to the system, games and other-
modules filters, and it is asking a different question from them. A module that
wants Chrome showed nothing at all: com.android.chrome is a system app, so
unless the reader had separately turned system apps on — which is the opposite
of what asking this question is for — the one entry that mattered was filtered
away by a setting they had no reason to touch. It now answers on its own:
what the module asked for, plus what is already in the scope, and nothing
subtracts from that. The other three grey out while it is on, so the screen
says what the code does rather than leaving three live-looking controls that
have no effect.

The screen also opened part-way down its own list. The saved scope arrives
after the app list, and applying it re-sorts so that what is in the scope
leads; `items` is keyed, so LazyColumn held whatever row sat under the viewport
and let the promoted ones appear above it. The module's own targets were
scrolled off the top, which reads exactly like they are not there. It scrolls
to the top once the load finishes — keyed on the module rather than on the
list, since re-running it on every reorder would yank the view away every time
a row was ticked.

Verified on the device against ChromeXt, disabled, with system apps off: the
list opens on Chrome, and turning the filter on leaves Chrome and Spotify, both
marked as requested, with the other three chips greyed.
A sweep of every comment, KDoc and README line in the module, checking each
claim against the code it describes and against the daemon, the AIDL and the
platform sources where the claim reached beyond this module.

129 statements were false. The one that started this said that applying a
scope makes the daemon force-stop every affected app; it does not, and never
did — setModuleScope validates, rewrites the rows in one transaction and asks
for a cache rebuild, and the only forceStopPackage in the daemon is the
explicit menu action. A scope takes effect when an app's process next starts.
Others were the same shape: that isVerboseLog ORs BuildConfig.DEBUG, which the
daemon stopped doing; that canary zips come from Actions artifacts, when CI
attaches them to a prerelease precisely so no account is needed; that a
restore skips a module the device does not have, when the daemon inserts a row
for it regardless. Numbers were checked by arithmetic rather than trusted —
the icon cache really does hold about 150 entries at 48dp, and #6abfcf really
is the deepest of the wordmark's eight tones.

237 passages narrating the code's own bug history are gone. "It was:", "the
previous implementation", "which cost a crash on the device" — that belongs in
this log, not in a file someone is trying to read. What remains of that shape
is a constraint a reader could otherwise undo: that a field sits above init
because the coroutine starts inline on the constructing thread, or that the
wire format has said `system` since long before this manager existed.

The README is rewritten rather than tidied. It now states the parasitic and
standalone split and what the first forbids, how the binder arrives and why
renaming Constants.setBinder breaks it silently at runtime, why Constants.TAG
is not an arbitrary string, and how to run the thing — including that the
debug build contributes a second launcher activity, so starting the real one
takes an explicit component. It also says plainly that this repository has no
test source set and that CI runs zipAll and nothing else.

Not one character of executable code changed. Checked by stripping every
comment from both versions of all 81 touched Kotlin files and comparing what
was left: identical, including StoreHtml's hand-written GIF header, where a
byte altered under cover of a comment diff would have been invisible.

59 real code defects were found on the way and deliberately left alone, to be
read and judged separately rather than fixed inside a documentation change.
The canary list offered a Download button per zip, which handed a URL to the
browser and left the reader to find the file and flash it by hand — on a screen
whose whole purpose is trying a build. The installer that does the job properly
was already one screen away, listing the same GitHub prereleases through a
different shape and never linked to from here.

A build now has one Install action that opens that installer on it. There is no
new plumbing: CI tags every canary `canary-<versionCode>`, the installer already
selects by version code, so the row hands over the number both sides already
agree on. Pinning it before the release list has loaded is fine — the selection
resolves against the history whenever that arrives.

What the reader gets by going there is what was missing. The release notes are
the commit message: CI writes the body as the subject of `git log -1`, the short
SHA and the run, so the installer shows what the build actually changed instead
of a file name. The flash runs in the app with the installer's output on screen.
The zip choice moves there too, where the sizes and the "you are chasing a bug"
column sit — one action per build here, rather than asking someone to choose
between Release and Debug before anything has told them what either is for.

The external link now uses the stricter of the two filters the README applies:
`event:push`, `branch:master` and `is:completed`, as the build badge does. A run
started by hand, or one still going, is not a build to send anyone to.

canary_download becomes canary_install, and the eighteen translations take each
file's own word for it — the same one it already uses for update_install, since
the two buttons now do the same thing.
The scope list in each cache record is joined and split on a NUL, written into
the source as the byte itself. It works, and it is invisible: an editor shows
nothing between the quotes, grep cannot find it, and any tool that normalises
whitespace would change the file format without anyone seeing a diff worth
reading. It survives as an escape behind a named constant instead, which is the
same character at runtime and a legible one in the file.
@victay44

victay44 commented Jul 29, 2026

Copy link
Copy Markdown

I've installed the manager.apk from Vector-v2.0-3052-Debug.zip but the manager UI is still that of the old Vector. Is there something else I got to do to trigger it to switch over? I'm on Vector-3046-Release.zip

@3gf8jv4dv

Copy link
Copy Markdown

I've installed the manager.apk from Vector-v2.0-3052-Debug.zip but the manager UI is still that of the old Vector. Is there something else I got to do to trigger it to switch over? I'm on Vector-3046-Release.zip

The link in the description is incorrect. It points to a build on the master branch, which does not include the changes in this PR.

Find the ✓ symbol to the right of the latest commit entry on this PR page and click it. Then click “Details” to go to a new page. In the sidebar of the new page, click “Summary”, and then click the file you need below.

@JingMatrix

Copy link
Copy Markdown
Owner Author

Thanks @3gf8jv4dv, you're right. The canary releases are master builds, so that link had none of this in it. Description fixed.

The documentation sweep could not correct a comment without reading the code
under it, and reported 58 defects on the way. 54 are fixed here. Four were
wrong and are not: `loadMoreHistory` already guards re-entry; `getValue` and
`setValue` in HomeScreen look unused but are the operator imports that make
`by collectAsStateWithLifecycle()` compile; AppLocale.kt does not import
fillMaxSize at all; and the four ambience renderers were left answering
isAnimating = true, because AmbientSurface's park branch skips update()
entirely and a renderer that answered false could never answer true again.

The ones a user would have met. Force-stop reported success whatever happened.
`openAppUi` treated any non-negative result as opened, where ActivityManager's
non-fatal band is 100-199 and AOSP's own test is 0..99 — so pressing open just
after a home press claimed success while nothing appeared. PackageBroadcasts
read EXTRA_USER, which carries a UserHandle, with getIntExtra, so the user id
was always 0. Applying a scope did not read the boolean the daemon returns, so
a write refused for reaching past a static scope was reported as applied, and
it did not notice that storing a scope enables the module, so the switch and
the module list both went on claiming it was off. A cancelled flash was
reported as a missing file and threw away the download, because the catch
around it swallowed the CancellationException that cancellation is made of.
Two crashes in the same second overwrote each other, in exactly the crash loop
the recorder exists for. Four dex2oat failure states were hardcoded English —
the states a non-English reader only ever sees when something is already wrong.

`scope_apply_warning` is renamed rather than reworded. It said "Applying
restarts the affected apps", which is not true and never was: setModuleScope
rewrites the rows and asks for a cache rebuild, and the only force-stop in the
daemon is the menu action. Eighteen translations said it too, so the key had to
change for them to fall away rather than keep asserting it.

The rest is smaller: an O(n^2) map copy per module, a byte limit applied as a
character limit that could split a UTF-8 sequence, a snapshot read three times
per call, `onCleared` that never called super, a stacked blockquote per line of
a quote, and around forty dead imports and a dozen dead declarations, each
grepped across main and debug before it was removed.
system_server reads its module list once, when it starts: the zygisk module
asks the daemon during specialization and gets whatever
ConfigCache.getModulesForSystemServer() holds at that moment. Every other
target picks a scope change up when its own process next starts, which happens
by itself soon enough. This one does not. So adding the System Framework to a
module's scope stored the change and left it inert, with nothing on screen
saying so.

A dialog now says it, and offers the soft reboot that fixes it. Restarting the
primary zygote is sufficient on its own: the daemon survives — it holds a death
recipient on the bridge and re-injects into the replacement — and the new
system_server asks for its module list on the way up, by which time the write
that prompted this has already rebuilt the cache.

It fires on removal too, and that half is easy to miss. A module already loaded
into system_server stays loaded until that process goes, so taking the
framework out of a scope is as inert as putting it in. The wording covers both:
the change reaches the framework when the framework restarts.

A dialog rather than the usual snackbar because this one asks for a decision,
and a message that scrolls away on its own would leave the reader believing a
scope is in force when it is stored and doing nothing.

ScopeViewModel and ScopeScreen also carry their share of the previous commit's
fixes, since the same functions are involved.
It sat under the Store's filters, which is the one place it does not belong.
VectorDns applies it to the single OkHttp client, so it already governed the
activity feed and the framework update check as much as the module mirrors —
and someone whose network breaks GitHub had no reason to look for it under
Store. It moves to the settings sheet on Home, beside the other things that are
not appearance, under a Network heading. The Store keeps sort, priority and
channel, and no longer counts a resolver as a filter it has narrowed the list
with.

On by default. The module mirrors are exactly what a network resolves wrongly
or not at all, and an empty Store gives no hint that DNS is the reason. It
costs nothing where ordinary DNS already works: VectorDns only uses it when
nothing is proxying the connection, and drops to the system resolver for the
rest of the session the first time a lookup fails.

The summary says what it does rather than where it used to be — through
Cloudflare instead of the network's resolver, for networks that block or tamper
with DNS. Its first draft explained itself by contrast with the old placement,
which is meaningless to anyone who never saw it there.
Two faults, one of them mine twice over.

The list opened one scope's-worth of rows down, so a module's own targets sat
above the viewport and looked absent. filteredApps is computed on
Dispatchers.Default and lags the loading flag, so the screen draws the previous
emission first — the one built before the saved scope arrived, without those
rows at its head — and when the real list lands it prepends them. `items` is
keyed, so LazyColumn holds the row it had anchored and lets the new ones appear
above it.

Scrolling to the top on arrival does not fix that, which is what the last two
attempts did: on arrival there is nothing yet to scroll past. The list is
pinned instead — every change to the head re-pins it, until a DragInteraction
says the reader has taken over. A drag rather than any scroll, because the pin
is itself a scroll.

And the framework row now leads the group it is being picked from rather than
the whole list. Unticked it heads what is not in the scope; newly ticked it
heads what is about to be; once it is in force it is a member like any other,
with no claim to sit above targets that are already applied. That needed the
saved set alongside the draft in the list flow, since the difference between
them is what "newly ticked" means.

The spinner also waits for the load to finish rather than for the list to be
non-empty. It does not fix the above on its own — the list still lands after
the flag — but showing a list that is known to be in the wrong order is not
better than showing that it is still loading.
434 lines to 100. It had grown into a second copy of the code — the log
reader's indexing strategy, the colour generator, the shape of the store's
mirrors, the demo harness scenario by scenario — all of it readable in the
files themselves, and all of it able to drift out of true without anyone
noticing. A fact-check of the long version was returning wrong claims at around
one in ten.

What is left is what the code cannot tell you: that the manifest is never
registered and what that forbids, that renaming Constants.setBinder breaks the
handshake silently at runtime, that a binder proxy returns a default rather
than throwing for a transaction the daemon does not implement, that a call
which succeeded is not a call that did something, that Constants.TAG is what
gets a line into the daemon's log at all, that changing a string's meaning
needs a new key, and that the debug build has a second launcher activity so
`monkey -c LAUNCHER` is a coin toss.

Every claim in it was checked against the code before it was written: the
proguard keeps, the "Vector" prefix in logcat.cpp, the merged daemon resources,
rev-list on refs/remotes/origin/master, ROOT_UNKNOWN, the eighteen locales that
carry a strings.xml, the CI task, and the absence of any test source set.
The previous cut removed the duplication but took the reasoning with it, and
left an error: it said `:app` was still on disk when git tracks none of it. The
directory here is untracked build output.

What goes back is the why behind each constraint rather than more of the what.
That the manager's memory is `com.android.shell`'s memory, which is the single
fact behind the log reader paging byte offsets and the module scan being cached
— both of which look paranoid until you know it. That `ROOT_UNKNOWN` is 0
because a binder proxy answers a transaction it does not implement with a
default rather than an exception, so an older daemon must not be able to answer
by accident. That a call which succeeded is not a call that did anything, which
is the shape of three bugs fixed this week. That the language override is
applied in composition, which is why it needs no restart. That the demo
activity is a source set so it cannot be reached in a release build and used to
report a healthy framework.

Also drops the bold — a page where a dozen phrases shout is a page where none
of them do.
@JingMatrix
JingMatrix merged commit c147582 into master Jul 29, 2026
1 check passed
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.

The notification bar name should be Vector, not Lsposed. System framework in multiple users modules

3 participants