Skip to content

Fix the work profile the package list drops and the module that cannot reach it - #964

Merged
JingMatrix merged 20 commits into
masterfrom
multiuser-package-list
Nov 5, 2026
Merged

JingMatrix merged 20 commits into
masterfrom
multiuser-package-list

Conversation

@JingMatrix

@JingMatrix JingMatrix commented Sep 7, 2026 •

Copy link
Copy Markdown
Owner

getInstalledPackagesFromAllUsers is a union over users: it asks system_server for 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 declared PackageService.getInstalledPackagesFromAllUsers throws RemoteException and 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 null for a query that did not happen, which the caller refuses instead of flattening into emptyList(). The user list is refused the same way, since getRealUsers turns a null binder or any failure of IUserManager.getUsers into an empty list, and no device has no users — user 0 always exists. ConfigCache.rebuild already 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. daemonAvailable comes 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: 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.

  • The scan becomes a shared job, like the app list. discover() called the daemon directly rather than through the job f93a416 gave AppRepository, and observePackageChanges merges 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 chunked ParceledListSlice, against a binder heap that is a fixed megabyte — 409 binder_alloc_buf ... failed, no address space in one report, 687 buffers outstanding, answered to the manager as DeadObjectException.

  • 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. installExistingPackageAsUser moves 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. getPackageUsers answers which users hold one package, by the same isPackageAvailable probe 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 from getUsers rather 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 apps is keyed by user. It was a column on modules, 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 gained DROP COLUMN in 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 from IUserManager: 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 PartiallyExpanded anchor 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 to Expanded, 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 PanelBar uses 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.getUserIcon opens 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_VERSION goes to 3. Transaction ids follow declaration order, and two changes here moved them: installExistingPackageAsUser, declared in the middle of the interface, and then getPackageUsers and getUserIcon after 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, and softReboot restarts 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 moduleIdOf closes 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_update and 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.

  • enabled is still one flag per package for the whole device. It sits on the same row auto_include has 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: unlike include new apps, which only decides what a future install does, enabled is read on every process start and on every cache rebuild.

One shortcoming of its own, which #953 does not ask about:

  • Between a module update and the next boot, the manager says only "Version mismatch". That is the handshake above refusing a new manager against the daemon still running from the last boot — correct, and unavoidable while the daemon is reused — but two words do not say that a reboot is what finishes the update, nor which of the two ends is the stale one, and the manager knows both: it compares its own PROTOCOL_VERSION against the number the daemon answered.

Closes #953.

@JingMatrix
JingMatrix force-pushed the multiuser-package-list branch from 9aaaf35 to 143fb09 Compare November 5, 2026 15:56
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
JingMatrix force-pushed the multiuser-package-list branch from 634bf0a to 2a8b39c Compare November 5, 2026 20:15
@JingMatrix
JingMatrix merged commit 093ea59 into master Nov 5, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Multi-user / Work Profile support missing after redesign and Auto Include affects other users

1 participant