Skip to content

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

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

Rewrite the manager in Compose, and retire :app#796
JingMatrix wants to merge 90 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.

Nineteen languages, and right to left. The manager can be set to a language of its own,
independently of the phone. The per-app language API is not available to us — setApplicationLocales
is keyed on an installed package, and parasitically this one is never installed — so the override is
applied in composition instead, which takes effect immediately with no restart and no effect on any
other app in the process. Arabic, Hebrew and Persian come with right-to-left: Compose reads the
layout direction from the host view rather than from the configuration we provide, so that is
provided too and the whole tree mirrors at once. Identifiers stay left to right, because
2.0 (3052) · API 101 was rendering as API 101 · (3052) 2.0 inside an Arabic paragraph.

Updating the framework, from the app. The manager can see that an update exists, read its
release notes, choose between the Release and Debug zips — 8 MB against 20 MB, and the app asks
people for a debug build when they report a problem, so it had better be able to install one — and
flash it, streaming the installer's output to the screen and to the daemon log. Canary builds are
only offered to someone already running one, derived from the running build rather than from a
preference. "No update available" is not a dead end either: every release on the channel is
browsable, so a build that broke something can be rolled back. Rolling back says what it costs,
because the daemon wipes the module database when the schema moved.

Speed. Deciding whether an installed package is a module meant opening its APK, and its splits,
as a zip — 363 of them on my device, 748 ms, on every single visit to the panel. The answer only
changes when the APK does, so it is cached against version code and install time and the daemon's
package events drive the rescan. Same device, after the first run: 16 ms, nothing opened. The
panels people open first also start loading while the splash is still playing.

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

Daemon-side changes come with it: the rotated log parts are exposed over AIDL, isVerboseLog()
reports the stored preference instead of OR-ing it with BuildConfig.DEBUG, and there is now an
install API that detects the root implementation the way NeoZygisk does and flashes through it. A
system scope row is normalised to user 0 rather than dropped, so the system framework can be
hooked by a module in a work profile or a private space — that is #136, and it supersedes #356,
which fixes the same thing in app/ScopeAdapter.java and no longer applies.

module.prop now 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 — each degraded cause
separately, no root, two roots fighting, a flash that dies halfway — for the screens that cannot
otherwise be reached without breaking the phone. It lives in src/debug, so a release build has no
such class and no such activity; that matters here, because a demo mode reachable in a release build
would be a way to make the manager report the framework as healthy when it is not.

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.

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.
Reaching the end of the feed with a range chosen offered "load earlier
commits", and taking it up fetched commits the range then threw away. The
test asked whether the oldest commit *on screen* was older than the start of
the range — and the rendered list is cut to the range, so its oldest entry
is at or after that start by construction. It could never fail, so the
invitation never went away.

It now asks the archive instead: if what is held already reaches past the
start of the range, everything the range can show is in hand. The foot then
says so — "that is everything in this range" — which is a different sentence
from "this is where the project began", and both are different from "there
is more to fetch". All three now exist.
The list of all versions decided "Installed" on the version code alone,
while the panel two lines above it was already comparing commits. That
number is `git rev-list --count`, so a build from a branch and one from
master at the same depth wear it identically, and a locally built one may
come from no published commit at all — so the sheet put a confident
"Installed" against a row the screen behind it was calling divergent. One
screen contradicting itself is worse than either answer alone.

It now asks the same question the rest of the screen asks, including the
dirty case, and says "same number, other build" where the number matches and
the build does not. The row is no longer filled or accented either: that is
where the reader appears to be and is not.

Caught on the device it was written on — the 3052 canary is published from
29dd7f7, the running framework is a different build at the same count, and
the row that used to read "Installed" now says so.
Enabling from the selection bar left the row where it was, in the wrong
section, with the header count unchanged, until the app was restarted — at
which point everything was correct. So the write had worked and only the
screen was wrong.

The daemon answered "which modules are enabled" from its config cache, which
is rebuilt asynchronously: a write only *requests* an update through a
conflated channel. Anyone asking straight after enabling a module therefore
got the state from before their own write. The manager does exactly that —
it writes, then re-reads to confirm — and the stale answer overwrote what it
had correctly recorded itself.

The daemon now answers that question from the database, which is the thing
the write changed. The cache stays where it belongs, on the injection path
it exists for, carrying the resolved apk paths and scopes this query does
not need.

The manager stops asking, too. Each toggle already returned the daemon's own
answer and the repository recorded it, so the re-read could only replace
something confirmed with something less fresh — and a toggle changes nothing
about which packages are installed, so the rediscovery that followed it was
work for its own sake. The single-module path never did either, which is
exactly why that one moved the row and this one did not.

Verified on the device with the manager fix alone, against the daemon
already flashed there: the row moves and the count follows, immediately.
The daemon holds two notions of a module and stored them in one place. The
configuration — what the user asked for — is cheap, transactional, and must
answer a reader with the reader's own writes applied. The realisation — the
resolved APK and parsed DEX handed to a forking process — costs package
manager calls, zip reads and DEX parsing, measured at 39 ms for five
modules, so it is built off the binder thread and is eventually consistent.

Confusing the two is what made a module enabled from the selection bar stay
in the wrong section. Every configuration query already read the database
except one, which is why it was so hard to place.

So the line is drawn where it belongs. The database handle moves to the
configuration store, and the cache borrows it — the realisation is derived
*from* the configuration, and the dependency now says so. The three
configuration queries that lived in the cache move across with it, and the
rebuild asks for rows rather than issuing SQL, so the cache holds no
statements at all and a config query cannot be written there without
visibly reaching for someone else's database.

The two stores can also disagree honestly, and that difference was being
thrown away. A module whose APK will not resolve, or whose DEX will not
parse, was deleted from the configuration outright — the user enabled it,
the switch went off by itself, and nothing said why. Only a package that is
no longer installed is cleaned up now. One that is installed and unloadable
keeps its configuration and is reported: the panel says "enabled, but the
framework could not read its code. It is not running."

Old daemons answer the two new transactions with nothing, so a newer
manager against an older framework simply shows no such rows.
The toggle is fully implemented on both sides — the daemon adds every newly
installed package to the scope of each module that asks for it — and it was
sitting in the scope sheet labelled "include the module itself", which is a
different feature entirely. The one control here that changes the *future*
of a scope looked like a niche toggle about its present, so nobody found it.

It now says it adds new apps automatically, with a line saying what that
means, and carries an icon about adding to a list rather than one about
modules.
"Auto include" says that something is included automatically and leaves out
the only part that matters — what. It is `includeNewApps` throughout now:
the AIDL, the daemon, the client, the view model and the strings. The
transaction ids are untouched, so a manager and a daemon of different
vintages still understand each other.

The storage column keeps the old name deliberately. SQLite gained ALTER
TABLE RENAME COLUMN in 3.25, this project supports API 27 whose SQLite is
older, and renaming therefore means rebuilding the table behind a version
bump — while onDowngrade wipes the module configuration, so anyone who then
flashed an older build would lose theirs. That is a real cost for a name no
user ever sees, so the schema now carries a comment tying the column to the
concept instead.

The scope list also loses its "auto-included" badge, which was a guess. That
setting reacts to packages installed *from now on*, and nothing records how
an app already in scope got there — so the badge was labelling recommended
apps with a mechanism that had never touched them. The setting explains
itself in the sheet, where it is a property of the module rather than a
claim about a row.
A scope is edited one module at a time, so the same filters were being set
again a dozen times in one sitting. They are ways of reading a list of
several hundred apps rather than anything about a particular module, which
is the test this app already applies to word wrap, the header surface and
the activity window — and it applies it because the host process is killed
constantly, so anything held in a ViewModel is gone by the next visit.

Sort, its direction, system apps, games and other modules are remembered.
"Recommended only" is not: it narrows the list to what one module asked for,
and a module that asks for nothing would open to an empty screen, which
reads as breakage rather than as a filter.

The mark on the filter button also starts meaning something. It asked
whether other modules were hidden — which is the default — so it was lit on
a device nobody had touched, and a mark that is always on is a mark that
says nothing. It compares against the defaults now, which is worth having
precisely because these choices survive the visit: coming back to a list
filtered the way you left it last week is when you need telling.
Two hazards, both found by reading the rebuild rather than by hitting them.

A throw inside the rebuild ended the `for` loop that consumes the request
channel. The coroutine completed, and every later request was dropped into a
channel nobody was reading — configuration silently stopped taking effect
for the life of the daemon, with one stack trace hours earlier as the only
evidence. One module with an APK the parser dislikes is enough. The loop
guards each pass now and says so.

The rebuild also wrote back a snapshot it had taken some hundred and fifty
lines earlier, while making binder calls throughout. The other writers touch
the same field from other threads, so whichever of them landed during a
rebuild was silently reverted: a manager reinstalled mid-rebuild would stop
being recognised as the manager, having been recognised a moment before. The
swap is against the current state now, under a lock the other writers take
for their assignments — the expensive part of each, the package query and
the signature check, stays outside it.

The static scopes are swapped with the modules rather than sixty lines
earlier, so a reader can no longer see one module set against another's
claims.
`ktfmtCheckScripts` has been failing on this branch: the root script picked
up two over-long lines from a comment added here, and the daemon's script
has been at the wrong indentation since the AGP 9 migration. Nothing in CI
runs that task, so it went unnoticed for a while — running it is a
prerequisite for merging, not an afterthought.

No behaviour changes; `ktfmtCheck` and `ktfmtCheckScripts` are both clean
now, and both modules still assemble.
"Enabled 5 modules" was reported whether five changed or none did. A module
already in the asked-for state was still written and still counted as a
success, so the number said how many were selected, not how many changed —
and the report is the only evidence a user has of what happened.

Three outcomes now, and the ones already in the right state are not even
written: what changed, what was already so, and what refused. Enabling five
modules of which two were already on says "Enabled 3 modules · 2 were
already on"; a batch where nothing needed doing says so instead of claiming
work it did not do. Uninstall reports through the same shape, and the counts
are plurals rather than a number wedged into one sentence.

Backup and restore already read this way — "restored 3, skipped 2 not
installed here" — which is where the shape came from.
Reported as "a 'no interface available to open' message appeared" for four
modules that do not show on the desktop. They are not broken: a module is
not something anyone wants in their app drawer, so a great many hide their
launcher entry and expose their settings through the convention the original
Xposed framework established — an activity carrying
`de.robv.android.xposed.category.MODULE_SETTINGS`.

This manager only ever looked for a launcher entry, and the category appears
nowhere in the tree. So it told the owners of those modules there was
nothing to open, when it was this app that did not know where to knock. Two
of the five modules on the device this was written against declare that
category; one of them, Pixelify GPhotos, has no launcher entry at all and
was unreachable.

Both ways in are tried now, launcher first, in one place that the long-press
sheet and the scope screen share.

The scope screen gains that button as well, next to the switch that runs the
module: a scope is half of a module's configuration and the other half lives
inside the module, so having to go back to the list to reach it was a detour
through a place you had just come from. A module with neither entry says so
when pressed rather than hiding the control — its absence is a fact about
that module, and a button that comes and goes between modules is harder to
learn than one that answers.
**The version was not a version.** The status page named "2.0 (3052)", which
is a commit count on master — the same number for a branch build and the
official build of the same depth. The framework and the manager are flashed
separately and are routinely different builds of that number. Both are named
exactly now, hash and all, `-dirty` when the tree was not clean. On the
device this was written against: 878c537-dirty and fd11f35-dirty. One
number, two builds, previously indistinguishable.

The manager grew a VERSION_HASH for that, replacing a BUILD_TIME field that
nothing read — and which, being `Instant.now()` at configuration time,
changed every invocation, invalidating the Kotlin compile and the entire R8
run on every build and making the release APK irreproducible for a commit.

**"Force launcher icons" was three things wrong at once.** It wrote through
the pre-Android-12 signature of a hidden `IContentProvider.call`, which has
not existed since Android 12: every press threw NoSuchMethodError inside the
daemon, was logged at warning level and swallowed, and the switch moved
anyway. It wrote the *inverse* of what its label says. And it had no getter,
so it started at a hardcoded value regardless of the system's actual state.
The daemon has run ActivityThread.systemMain() since start-up, so it now
uses Settings.Global directly — public API, no signature to rot — the
boolean means what the label says, and the switch reads the real value on
arrival and again after writing.

**One sheet served three subjects and offered all of them the same actions.**
A module is not an app you open; what it may have is a companion, the screen
its author wrote to configure it, which is what the Xposed settings category
marks — so that is what the item says, and it looks there first. Re-optimize
is gone from a module: recompiling so ART stops inlining hooked methods is
about the app being hooked, not the module doing the hooking.

The system framework is not an app at all — no launcher entry, no page in
Settings, nothing to re-optimize — so it is offered none of them. What it
has is a way to be stopped and started, which for the framework takes every
running app with it: called a soft reboot, explained in those words, and
confirmed first. The daemon already knew how, by restarting the primary
zygote that system_server is forked from. It is also shown as
`system_server`, which is what it is; the stored name stays `system`, since
the scope table and every backup on every device say so, and the rename
lives at the point of display.

The scope screen's way into the companion is a floating button rather than a
bar item — the bar holds what the screen is, and this leaves for somewhere
else — and "Open in store" wears the Store tab's own glyph, because it is
the same place.
`refreshToggles()` was written and never called. Both switches on the status
page therefore showed their initial values for the life of the process: a
status notification the user had turned on months ago read as off on every
launch, and turning it "on" again wrote a value that was already there.

Proved rather than guessed. The switch flipped on, survived until the app was
restarted, and came back off — so the write looked broken. Pulling
`/data/adb/lspd/config/modules_config.db` off the device and decoding the
serialized Boolean showed `enable_status_notification` was `true` throughout.
The write had always worked; nothing ever read it back.

It is read when the binder arrives, which is the moment there is a daemon to
ask.

Also: the framework version a *module* sees now carries the whole thing —
"2.0 (3052) 878c537-dirty" instead of "2.0". The interface promises a
version string and its content is this implementation's to choose; the
number that identifies a build is the commit count, and even that is shared
by every branch built at the same depth. A module author reading a bug
report could not tell which framework produced it. The parenthesised group
stays purely numeric and getFrameworkVersionCode() still answers with the
number alone, so nothing that compares versions needs to parse it.
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.
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

2 participants