Repository navigation
Fix the work profile the package list drops and the module that cannot reach it - #964
Merged
Merged
Conversation
JingMatrix
force-pushed
the
multiuser-package-list
branch
from
November 5, 2026 15:56
9aaaf35 to
143fb09
Compare
getInstalledPackagesFromAllUsers asks system_server for every package,
once per user, and accumulates the answers. getInstalledPackagesReflect
turned any failure of that query into an empty list, and the loop reads
an empty list as a user with nothing installed and moves on -- so a
transaction that died came back as a device where that profile holds no
apps at all, which the manager then draws as fact.
The kernel says what actually fails, and it is not one oversized reply.
A report on a Samsung A52 with a Shelter work profile has 409 of
binder_alloc: 1519: binder_alloc_buf size 197464 failed, no address space
binder_alloc: allocated: 463248 (num: 532 largest: 235880),
free: 577136 (num: 114 largest: 118688)
against pid 1519, the daemon: 577 KB free but no block larger than
118 KB for a 197 KB reply, with 532 buffers outstanding and peaks at
687. system_server then fails the reply with -28, ENOSPC, and the
DeadObjectException the daemon sees says "remote process probably died,
but this could also be caused by running out of binder buffer" -- there
is no tombstone for system_server anywhere near it, so it is the second
clause. The daemon's binder heap is a fixed megabyte and roughly sixty
manager threads were enumerating packages into it at once; one
enumeration on its own is fine, and the same device answers a correct
831 when it is not racing itself.
The manager side of that is a separate commit. What is wrong here is
that the daemon translates ENOSPC into "this user has no packages", and
the caller has no way to tell the difference. v2.0 did not:
PackageService.getInstalledPackagesFromAllUsers was declared throws
RemoteException and called getInstalledPackages straight, so a dead
transaction failed the whole call. The Kotlin rewrite wrapped it in
runCatching and answered emptyList(), and the flags are unchanged either
side -- v2.0's MATCH_ALL_FLAGS already carried MATCH_ANY_USER -- so the
swallow is the whole of the regression.
So the reflection answers null for a query that did not happen, which is
not the same answer as a user holding nothing, and the caller refuses to
build a list it knows is short.
A module reaches a profile's apps only from inside that profile: the scope list is filtered to the module's own user, because that is the boundary the daemon enforces. So a module the owner holds and the work profile does not can never be scoped there, and the manager -- which runs in one user -- had nothing that could put it in the other. The module list then hides a profile with no modules in it, which is right on its own but closes the circle: the profile is invisible because it has no module, and no module can be sent to it. Android keeps one APK per package name for the whole device and varies only who has it installed, so this is not a copy. It is the platform's own installExistingPackageAsUser, the same call pm install-existing makes, which flips the install state for that user and nothing else -- the version, the signature and the scope rows are all untouched. Offered only where it can do something: on a module, and only for the users that do not already hold it. Those are read off the module list's own per-user tabs rather than asked of the daemon, so the row cannot disagree with the list behind it, and a user with no tab holds nothing and is exactly the user this exists to reach. A device with one user never sees the row. The users are listed rather than confirmed, because the question is which user, and on a device with both a work profile and a private space there is no sensible one to preselect.
f93a416 gave AppRepository a job that concurrent readers join rather than each starting their own, because enumerating packages through the daemon is expensive in a way its call site does not look. The Modules panel was left out of it: discover() called daemonClient.getInstalledPackagesFromAllUsers directly, with no cache and nothing to coalesce two of them. It is the worst place to have left out. The scan is driven by packageRevision, which observePackageChanges bumps from two merged sources -- the platform's broadcast and the daemon's re-broadcast -- so one install starts two enumerations by design, and a profile whose packages churn keeps many more than two alive. A report on a Samsung A52 with a Shelter work profile has 148 scans in eight minutes, five inside one second, across 59 manager threads. The daemon's binder heap is a fixed megabyte, and that many chunked ParceledListSlices in flight together do not fit in it: binder_alloc: 1519: binder_alloc_buf size 197464 failed, no address space binder_alloc: allocated: 463248 (num: 532 largest: 235880), free: 577136 (num: 114 largest: 118688) 409 of those, against the daemon, with 687 buffers outstanding at the peak. Note the shape of it: 577 KB free and not one block big enough for a 197 KB reply. It is saturation and fragmentation, not a reply that was ever too large -- one enumeration answers a correct 831 packages on the same device, and the failures only start once the scans pile up. What that costs is not time. A per-user query answered ENOSPC used to take that user out of the list, so the same device reported 27 different totals between 0 and 831, and 697 of them -- 831 less the work profile -- is a reader being told they have no work profile. The commit before this one stops the daemon lying about that; this one stops the manager asking sixty times at once. A second enumeration rather than a filter over getInstalledApps: this one needs MATCH_ANY_USER and uninstalled packages so a module held only by a profile is still seen, and must not set filterNoProcess, because a module with no components of its own is still a module. It answers a Result, so a failed read stays distinguishable from a device with no packages, and only a success is cached.
`getInstalledPackagesFromAllUsers` unions one `getInstalledPackages` per user, and the per-user term is refused now that the platform may decline it. The term above it is still swallowed: `getRealUsers` turns a null binder and any failure of `IUserManager.getUsers` into an empty list, so a union over no users is a list of no packages, and no device has none -- user 0 always exists. `ConfigCache.rebuild` already refuses an empty user list, where answering it would delete the configuration of every module; this is the call the manager draws every list from, and answering it drew "No modules installed." over a device full of them. The flag conversion moves out of the loop while here, chosen as it is by a version test that cannot differ between users. Sharing the module scan made its cache the only answer any reader gets, and nothing but a package broadcast drops it. Pull-to-refresh then handed back the list it was pulled to replace -- on a device whose daemon was down when the module arrived, the one gesture that could have found it did nothing -- and the reload after a batch uninstall redrew the modules the batch had just removed. Both ask for a re-read now. A re-read still joins a live enumeration rather than racing a second one against it: one in flight is milliseconds old, and never running two at once is what the sharing is for. An enumeration that failed still drew "No modules installed.", the same claim about an absent answer, in the one place a person reads it. The user list cannot speak for the enumeration, which is the expensive half and the half that exhausts the binder heap, so the scan reports its own failure and the empty state says which of the two it is. On that path the detection cache is left alone: `flush` keeps only the keys whose package is in the set it is handed, so flushing the empty set would write an empty cache and make the next scan that does succeed reopen every APK on the device. Also here: the module list builds its holders once per tab set. The lambda ran for every visible row on every recomposition and handed each one a freshly built set, equal to the last but not the same object, which is enough to stop the row skipping.
`auto_include` was a column on `modules`, a table keyed by package name alone, so one module held one flag for the whole device. Turning the switch on from a work profile's scope screen turned it on for the owner as well, and the install handler then read it without a user at all: the next app installed anywhere was handed to every auto-including module and written under whichever user it arrived in, so an app installed in one profile entered the scope of a module configured in another. A module the owner and a profile both hold is two copies with two scopes, and this was the one thing about it that could not differ between them. It is a table now, `auto_include (mid, user_id)`, where a row's existence is the whole value and there is no second column to disagree with it. Keyed by mid, so uninstalling a module takes these rows with it exactly as it takes its scope. The column stays where it is, dead and selected by nothing: SQLite gained DROP COLUMN in 3.35 and this supports API 27. What the old flag meant in effect was every user, so the migration writes one row per user to preserve it, and takes the users from `scope` -- the only table that names any -- unioned with user 0. Not from `IUserManager`: a migration runs wherever the database is first opened, which is early, and one that silently found no users would read as a setting nobody had ever made. A user that neither holds scope nor is 0 loses it, and a user created afterwards never had it; both are what the switch now means rather than a gap in it, since nothing can say a person wanted this in a profile they have never configured. The scope screen passes the user whose copy it is already showing, and says so in the switch's subtitle where there is more than one user to distinguish -- the arrangement the framework row above it already uses. Also here: `PROTOCOL_VERSION`, which `installExistingPackageAsUser` should have carried. It was declared in the middle of the interface, and ids follow declaration order, so every method below it moved, and this file's own rule is to bump the number in the same commit. The daemon reuse policy is what makes that rule bite on the ordinary upgrade path rather than in some corner. One daemon is started per boot by service.sh; a module update replaces files on disk and starts nothing, a replayed service.sh finds the lockfile held and exits, and `softReboot` restarts zygote rather than the daemon. So from the moment a module is installed until the next real boot, the manager APK on disk is the new build and the daemon answering it is the old one -- which is every update that touches this file, on every device. The descriptor still matches across that pair, so without the number they bind and then call a different method for each shifted id, which is the failure the handshake replaced hand-written ids to prevent. Two signature changes here need the same bump, and one covers both.
The action was offered for a module only, and only for the users the module list draws. Those are not the same set as the device's: a profile holding no modules has no tab, which is exactly the profile that needs this, and the tabs cannot speak for an app at all. So it is asked rather than derived. `getPackageUsers` answers which users hold one package, by the same `isPackageAvailable` probe that the package enumeration filters each user's rows by -- so a user it reports and a user whose apps the manager draws mean the same thing. One transaction per user: the question is about a single package, and enumerating every package of every user to answer it is what exhausts the daemon's binder heap. The sheet subtracts that from `getUsers` rather than inverting it, because a daemon that cannot reach the user service answers empty to both, and an empty holder set inverted against a user list of our own would offer every user on the device. That drops the holder map the module list was passing down, and with it the parameter and the two rows of plumbing that carried it. An app earns the row for the opposite reason a module does -- a module is put into a profile so it can be scoped there, an app so a module already in that profile can reach it -- so the prompt and the subtitle each have a second wording rather than calling a hook target a module.
Every root provider's install stages into /data/adb/modules_update and leaves the next boot to promote the directory, while the daemon resolves the manager APK beside its own -- in /data/adb/modules, the live one. So a one-line change to a Compose file cost a reboot, and the manager that came back was the one from before it. `restartManager<Variant>` puts the APK where the daemon will read it and restarts the process holding it: three seconds, against a boot. The APK is written *through* the live file rather than moved over it, so the owner and the SELinux label the flash gave it survive -- a fresh file carries the label of whatever made it, and the daemon only reasserts that label at startup, which is the reboot being avoided. The relaunch is the line module/action.sh uses, because the redirection in system_server fires for an intent carrying the manager's LAUNCH_MANAGER category and for nothing else; without it the host's own activity opens instead. The redirection is also quoted for `su` rather than left to `adb shell`, which joins its arguments and hands them to a shell running as `shell` -- which cannot create that file, and says so. The manager only. A daemon, AIDL or native change still needs the full install and a reboot, and nothing here checks for one: the handshake does that at runtime and names the side that is behind.
It opened at full height and jumped down a third of a second later. Material offers the PartiallyExpanded anchor only to a sheet taller than half its container and places it at exactly half. Three rows here are decided by a binder call -- the companion row by `findAppUi`, the install row by `getUsers` and `getPackageUsers` -- so the sheet is first measured without them and lands on the short side of that line. `show()` picks PartiallyExpanded, loses the anchor a frame later and retargets to Expanded; the rows then arrive mid-animation, the sheet crosses the line, the anchor returns, and the correction waits for the animation to end and arrives as a snap. On a 2400px screen: 1131px measured, 1483px once the rows land, against a stop at 1200. A floor at that stop takes the question away rather than racing it. The anchor exists at the first measure whatever has answered by then, so the target chosen when the sheet is shown is the one it keeps -- on any screen, in any locale, however slow the daemon is that day. Dragging up to Expanded is untouched. A sheet with little in it now opens at the same height as every other rather than at its own, which is the point: where it opens stops depending on what happens to be inside it.
The dialog listed users as text buttons, so a device with two profiles called something close to one another offered two rows that differed by a word. A profile is recognised by its picture first, and the platform already holds one: `IUserManager.getUserIcon` opens the photo a user was given, for a caller that can reach /data/system/users -- which the daemon can and the manager cannot. Reflectively, because the IUserManager this compiles against is a stub in a plain Java library with no Android SDK on its classpath, so it can name only the classes it declares itself and ParcelFileDescriptor is not one. The descriptor rather than the bytes, since opening the file is the whole of what the daemon has to do; the manager decodes it and drops it, rather than holding a descriptor a composable would have to remember to close. Most users have never been given a photo and answer null, so the fallback is the ordinary case rather than the error case: a circle carrying the first letter of the name, tinted from the user id so that two profiles differ at a glance and one profile looks the same every time.
The rows were emitted where they were written, so the order was the order the file happened to declare them in. They are collected into a list now and drawn in whichever order the reader has dragged them into, each keeping its own condition: a row nobody can use is still absent rather than present and inert. Long press then drag, rather than handles behind an edit mode. A plain drag anywhere on this sheet already means "move the sheet" and has to keep working; long press is the one gesture these rows do not use, and `detectDragGesturesAfterLongPress` consumes nothing until the press has been held, so the two never contend for the same movement. Heights are measured rather than assumed, because the rows are not the same height -- one with a subtitle is half again as tall as one without, and a swap decided on an average would jump two places one way and none the other. The order is written on the swap rather than when the finger lifts: the sheet takes the pointer back if the drag strays, which ends the gesture as a cancel, and an order the reader watched move and then lost is worse than one that never moved. Stored as ids, per kind of package, so an action added or retired in a later build leaves the rest where they are: one the stored order does not name keeps its place in declaration order, and a name this build no longer has is ignored.
The comments across this branch had grown to explain a mechanism twice and then restate the conclusion: 442 of the lines it adds were comment, some blocks running fifteen lines for a point made in three. Every one that carried a reason nobody could recover from the code is still here -- why an empty user list is refused, why the sheet needs a floor, why the order is written on the swap -- but said once. No behaviour changes; the diff is comments and KDoc alone.
The drag was not practical. It had to be discovered before it could be used, since nothing on the sheet says a long press does anything; it is invisible to a screen reader, which leaves the order unchangeable for anyone using one; and it contended with the sheet's own drag for the same movement, which it lost whenever the finger strayed sideways -- the sheet took the pointer back and ended the gesture as a cancel. That last one is why the order had to be written on every swap rather than when the finger lifted. A toggle in the header turns the rows into a list with a pair of arrows each. Two buttons say what they do, move exactly one place, and work the same for everyone. The rows keep their ordinary appearance while being moved, so what is reordered is what the reader sees; they are narrowed by the width of the buttons so a long subtitle wraps clear of them, and made inert, since in this mode a tap belongs to the arrows. The toggle is offered on every sheet but the framework's, which has one row. That test is on `isSystemFramework` rather than on how many rows were collected, because the list is filled below the header: reading its size there sees the previous composition, and a plain list cannot provoke the next one. Each entry is drawn in a Column rather than straight into the Box the arrows are aligned in -- uninstall brings its own divider, and a Box would stack the two on top of one another.
`getPackageUsers` and `getUserIcon` were both declared after the commit that took PROTOCOL_VERSION to 2, and both sit in the middle of the interface, so the ids below them moved while the number stayed put. Two builds of this branch therefore both answer 2 and disagree about what each id means. That is not theoretical here. The daemon is reused until a real boot and `restartManagerDebug` replaces the manager alone, so pairing a manager from one commit of this branch with a daemon from another is the normal way of working on it -- and the handshake would wave that pair through. The file's rule is per change, not per branch: bump it in the same commit as the change, however many have gone before.
Ten imports the drag needed and the arrows do not -- the gesture detector, the offset and zIndex modifiers, the height map, both maths helpers. The compiler does not mind an unused import and neither does ktfmt, so they would have sat there. The row list is built afresh each composition rather than remembered and refilled: its entries close over values that pass computed, so one kept across passes would hand the next one stale captures. The box that swallows a tap in reorder mode carries no semantics now. It exists to make the row inert, and an unlabelled button announced over every row would be noise to a screen reader -- which is the reader the arrows were chosen for.
The stored order is reached with arrows now, so "until someone drags something" and "one nobody has dragged" name an interaction this branch removed. The revision flow also moves above the two functions that write and read it, where every other backing field in this file sits.
Every action appeared twice. The list was made in the function body and filled inside the sheet's own lambda, and that lambda is its own recomposition scope: it runs again without the body around it, so the list it found was the one the last pass had already filled, and each row was added to it a second time. Made where it is filled now, so one pass fills one list. The earlier shape -- remembered, and cleared at the top of the lambda -- worked for the same reason and was lost when the clear went. Found on the device. It is invisible to the compiler and to a reading that takes "fresh each composition" to mean what it says.
"The list of installed packages could not be read ... pull down to try again" was drawn by a branch that returns before the PullToRefreshBox, so there was nothing to pull and no other way to retry. The empty state is wrapped in one now. In a lazy list holding one full-height item rather than a plain Box: the pull is seen through nested scroll, and something that does not scroll dispatches nothing to see. Over a Box the indicator never appeared however far the gesture went, which is what the device showed before and after. Also here: the statement `moduleIdOf` compiles is closed. It is a native statement, and the two callers around it leak theirs, which is not a reason for a third.
The daemon launcher prefers /data/local/tmp/daemon.apk when one is there, and a daemon started that way resolves the manager beside its own class path -- so it reads /data/local/tmp/manager.apk, not the module's. The task deleted the pushed copy after writing the module's, which left that daemon serving the APK from before the task ran, and the change under test simply did not appear. It is kept now whenever a tmp daemon.apk is present, and still removed otherwise, so the ordinary case leaves nothing behind.
The reorder toggle kept one description across both states, so a screen reader announced "Reorder these actions" over the check that ends the mode -- the one moment the control means the opposite. It reads `panels_done` there now: the same word the navigation bar's own reorder ends on, already translated, and paired with the same check icon in PanelBar.
It read "arrows, not a drag", and gave three reasons: a drag has to be discovered, it is invisible to a screen reader, and it contends with the sheet. Two of those three are just as true of the navigation panels, which this project reorders by dragging on purpose -- so the comment argued against a pattern it sits twenty lines from, without saying why here is different. Only the third reason is about this sheet, so it leads now, and the precedent is named rather than contradicted: a drag works on the bar because the bar does not move under it, and it fails here because the sheet does. The toggle is that screen's edit mode by another name.
JingMatrix
force-pushed
the
multiuser-package-list
branch
from
November 5, 2026 20:15
634bf0a to
2a8b39c
Compare
1 task done
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.
getInstalledPackagesFromAllUsersis a union over users: it askssystem_serverfor each user's packages and adds them to a running list. A union cannot distinguish a term that is genuinely empty from one that failed — both arrive as no rows, both shrink the total. v2.0 declaredPackageService.getInstalledPackagesFromAllUsersthrows RemoteExceptionand propagated; the Kotlin refactor's swallow is the regression, and on a device with a work profile one lost term is one profile the reader is told does not exist.What changes:
The enumeration fails rather than lose a term. The reflection answers
nullfor a query that did not happen, which the caller refuses instead of flattening intoemptyList(). The user list is refused the same way, sincegetRealUsersturns a null binder or any failure ofIUserManager.getUsersinto an empty list, and no device has no users — user 0 always exists.ConfigCache.rebuildalready refuses an empty user list for the same reason; this is the call the manager builds every list from.A failed enumeration says so. It drew "No modules installed." before, which is the same claim about an absent answer in the one place a person reads it.
daemonAvailablecomes from the user list and cannot speak for the enumeration, so the scan reports its own failure and the empty state names which of the two happened. The module-detection cache is left alone on that path:flushkeeps only the keys whose package is in the set it is handed, so flushing the empty set would write an empty cache and make the next scan that does succeed reopen every APK on the device.The scan becomes a shared job, like the app list.
discover()called the daemon directly rather than through the job f93a416 gaveAppRepository, andobservePackageChangesmerges the platform's broadcast with the daemon's re-broadcast, so one install started two full enumerations and a device whose packages churn kept dozens alive at once. Each pulls every package with its metadata, per user, as a chunkedParceledListSlice, against a binder heap that is a fixed megabyte — 409binder_alloc_buf ... failed, no address spacein one report, 687 buffers outstanding, answered to the manager asDeadObjectException.Pull-to-refresh and the reload after an uninstall ask past that cache. It is dropped by nothing but a package broadcast, so a pull would otherwise hand back the list it was pulled to replace — on a device whose daemon was down when the module arrived, the one gesture that could have found it — and a post-uninstall reload would redraw what it had just removed. A forced read still joins a live enumeration rather than racing a second one against it.
A package can be put into a user that does not hold it, from the long-press sheet. Scope is filtered to the module's own user, so a module a profile lacks could never be scoped there, and the manager runs in one user — nothing in the product could put a module into another, which left a profile with no modules unable to get its first. An app earns the row for the opposite reason: a module goes into a profile so it can be scoped there, an app so a module already in that profile can reach it.
installExistingPackageAsUsermoves no APK; it flips the install state for that user, and the package keeps its version, its signature and its uid derivation.The users offered are the device's, not the ones on screen.
getPackageUsersanswers which users hold one package, by the sameisPackageAvailableprobe the enumeration filters each user's rows by, so a user it reports and a user whose apps the manager draws mean the same thing by "holds" — and one transaction per user, since enumerating every package of every user to answer a question about one package is what exhausts the heap above. The module list's tabs would have answered for a module, but only for the users it draws: a profile holding no modules has no tab, and that is exactly the profile worth offering. The sheet subtracts the holders fromgetUsersrather than inverting them, because a daemon that cannot reach the user service answers empty to both, and an empty holder set inverted against a user list of the manager's own would offer every user on the device.include new appsis keyed by user. It was a column onmodules, a table keyed by package name alone, so one module carried one flag for the whole device: turning it on from a work profile's scope screen turned it on for the owner too, and the install handler read it without a user at all, handing each new app to every auto-including module and writing it under whichever user it arrived in. It is a table now,auto_include (mid, user_id), where a row's existence is the whole value; keyed by mid so that uninstalling a module takes these rows with it as it takes its scope. The old column stays, dead and selected by nothing, because SQLite gainedDROP COLUMNin 3.35 and this supports API 27.Database v5 migrates that flag. Every user is what it meant in effect, so one row per user preserves it, and the users come from
scope— the only table that names any — unioned with user 0. Not fromIUserManager: a migration runs wherever the database is first opened, which is early and is not a place to need a system service, and one that silently found no users would read as a setting nobody had ever made. A user that neither holds scope nor is 0 loses it, and a user created afterwards never had it; both are what the switch now means rather than a gap in it.The long-press sheet opens at the stop it settles on. Material offers the
PartiallyExpandedanchor only to a sheet taller than half its container and places it at exactly half; three rows here wait on a binder call, so the sheet was first measured without them, landed short of that line, retargeted toExpanded, and snapped back a third of a second later once the rows arrived. Measured at 1131px without them and 1483 with, against a stop at 1200. A floor at that stop makes the first target the one it keeps.The sheet's actions can be put in the reader's order. A toggle in the header turns the rows into a list with a pair of arrows each, stored by id per kind of package so an action added or retired later leaves the rest where they are. Arrows rather than the drag
PanelBaruses for the navigation panels: a drag works there because the bar does not move under it, and fails here because the sheet does — it takes the pointer back and ends the reorder as a cancel.The user picker shows each user's own photo.
IUserManager.getUserIconopens the file the daemon can reach and the manager cannot; most users have never been given one, so the fallback — a circle carrying the first letter, tinted from the user id — is the ordinary case rather than the error case.PROTOCOL_VERSIONgoes to 3. Transaction ids follow declaration order, and two changes here moved them:installExistingPackageAsUser, declared in the middle of the interface, and thengetPackageUsersandgetUserIconafter it. Nothing else makes such a shift visible to a peer — the descriptor still matches across it, so two builds that disagree about what each id means still bind, and then each call lands on the wrong method. That is the failure the handshake replaced hand-written ids to prevent. The daemon reuse policy makes it the ordinary upgrade path rather than a corner: one daemon is started per boot by service.sh, a module update replaces files on disk and starts nothing, a replayed service.sh finds the lockfile held and exits, andsoftRebootrestarts zygote rather than the daemon — so from installing a module until the next real boot, the manager APK on disk is the new build and the daemon answering it is the old one.The empty state can be pulled. It said "pull down to try again" from a branch that returns before the
PullToRefreshBox, so there was nothing to pull and no other way to retry. It is wrapped in one now, holding a single full-height item in a lazy list — a pull is seen through nested scroll, and a static box dispatches nothing to see.Smaller, in passing: the flag conversion moves out of the per-user loop it could not vary inside, and
moduleIdOfcloses the statement it compiles.For working on it:
restartManager<Variant>installs the manager alone and restarts it in about three seconds. Every root provider's install stages into/data/adb/modules_updateand leaves the next boot to promote it, while the daemon reads the manager APK beside its own in/data/adb/modules— so a one-line Compose change used to cost a reboot.Two things #953 asks for that this does not do:
A profile holding no modules still has no tab of its own.
discover()drops a user with nothing in it on purpose — a private space that has never had a module would otherwise add a second tab to a screen with no choice on it — so "show every user" holds only for users that hold modules. Such a profile is offered as an install target whether or not it has a tab, and gets one as soon as it holds a module, which is what the action above exists for.enabledis still one flag per package for the whole device. It sits on the same rowauto_includehas just left, so switching a module off in one user's tab switches it off in every user. Keying it by user is a larger change than this: unlikeinclude new apps, which only decides what a future install does,enabledis read on every process start and on every cache rebuild.One shortcoming of its own, which #953 does not ask about:
PROTOCOL_VERSIONagainst the number the daemon answered.Closes #953.