Rewrite the manager in Compose, and retire :app - #796
Open
JingMatrix wants to merge 90 commits into
Open
Conversation
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.
Owner
Author
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.
|
This UI is beyond my expectations. It is awesome. I have two questions about the design of the Home page.
|
|
For the “Try a canary” → “Open on GitHub” button, I suggest adding a filter |
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.





The manager is rewritten in Compose and replaces
:app, which is removed in this branch — thezygisk module has been packaging
:managerasmanager.apkfor a while, so the old one was alreadyshipping nothing.
Everything here runs against the daemon on a real device; the branch builds with
zipDebugand themanager 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 thescreen 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 werepreviously unreachable — chevrons on the line counter move between them.
Store works again. The mirrors it inherited are dead or refusing:
modules.lsposed.organswersmodules.jsonwith 403,modules-blogcdnreturns 418,modules-cloudflarehas no DNS record. Italso 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 —
setApplicationLocalesis 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 101was rendering asAPI 101 · (3052) 2.0inside 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.apkfrom 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 aninstall API that detects the root implementation the way NeoZygisk does and flashes through it. A
systemscope row is normalised to user 0 rather than dropped, so the system framework can behooked 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.javaand no longer applies.module.propnow carries the commit a build came from, marked-dirtywhen the tree was not clean.The version code is the commit count on
origin/master, so a branch build and a master build werepreviously 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 nosuch 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.mdcovers the parts worth knowing before reading the code: why there is nodependency injection, how the log reader works, and how a Material colour scheme is generated from a
seed when the platform will not do it.