Skip to content

Fix module scope writes and the daemon notifications the manager dropped - #832

Merged
JingMatrix merged 18 commits into
masterfrom
scope-and-notification-fixes
Aug 1, 2026
Merged

Fix module scope writes and the daemon notifications the manager dropped#832
JingMatrix merged 18 commits into
masterfrom
scope-and-notification-fixes

Conversation

@JingMatrix

@JingMatrix JingMatrix commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Eleven fixes across the daemon and the manager, one per commit. They share a cause: the daemon writes or announces something, and the manager either never hears it or overwrites it.

An empty static scope deleted the module's scope

staticScope=true with no META-INF/xposed/scope.list made readStaticScope answer an empty set. That is "claims nothing" where the caller reads "claims no restriction", so setModuleScope refused every write and ConfigCache.pruneScopeToClaimed dropped every row the module already had on each cache rebuild. The picker locked to an empty list with nothing tickable.

Both sides now ignore the declaration and log it. A scope list of blank lines counts as empty on the daemon side too, matching what the manager already did. A staticScope module that does name packages is enforced exactly as before.

Only one scope request per module could be answered

ModuleService.requestScope posts one prompt per requested package, but the enqueue keyed on the module package alone. Notification identity is (package, tag, id, user) and everything here posts as android, so each prompt replaced the previous one: a module asking for three packages left one answerable, and the other two were neither granted nor answered through IXposedScopeCallback. One module under two users collided the same way, and notifyModuleUpdated shared the tag, so a module update and a scope prompt cancelled each other.

Prompts are now keyed on module, module user and requested package. cancelNotification(channel, pkg, user) ignored both its channel and its user; it is replaced by two functions that cancel what they name.

The prompt regained the delete intent and one-hour timeout dropped in 714e6c8 (both present in 89d255d). Without them the "delete" -> onScopeRequestFailed("Request timeout") branch was unreachable and a dismissed prompt left the module waiting indefinitely. A swipe or timeout can now arrive after a button press, so the first of the four arrivals claims the request and the rest are dropped. "Never ask again" withdraws the module's remaining prompts, which per-package prompts had made independently approvable.

"Never ask again" could not be undone

The blocked set belongs to lspd, not to the module, so the deleteModulePrefs call on uninstall missed it, and nothing else removes an entry — the CLI does not know the key and the manager has no control for it. A module blocked by one tap on an action sitting beside Approve stayed blocked permanently, and reinstalling did not help. Uninstalling now clears it, which matters more given that "never ask again" also withdraws the module's other pending prompts.

A static scope could not be edited at all

Every row of a static-scope module's list was drawn with its checkbox disabled, making the declared scope all or nothing: the selection menu could take the whole list or clear it, but a module naming three apps could never be pointed at one of them, and an app could not be dropped once applied. module.prop claims only that users should not apply the module outside the scope list, the list is already narrowed to that set, and setModuleScope refuses only targets beyond it — a subset has always been accepted. The list stays fixed and the rows still say so; the boxes in it can now be ticked.

Unticking an app removed it from the list

The exemption that keeps a chosen app past the filters read the draft alone, so unticking one took the row out of the list with it — and since the default filters hide system apps, unticking com.android.settings made the row vanish mid-edit, taking the tick that would undo it. A row now counts as in play if it was in force when the screen opened, is in the draft, or has been touched during this visit.

The same defect emptied a static scope outright: those lists are already narrowed to the declared set, but the system, game and module filters still ran over them, so a module fixing its scope on com.android.settings and com.android.systemui showed nothing at all. A static scope now bypasses those three filters, as "recommended only" already did.

The scope editor overwrote concurrent writes

setModuleScope replaces every row of a module, and the editor sent the draft built when the screen opened. load() runs once from init, nothing re-read it, and the view model is scoped to the navigation entry, so it survives leaving the app. Approving a requestScope prompt from the shade — reachable through this screen's own button for opening the module — wrote a row that the next Apply deleted, after the module had been told it was granted.

Apply now sends the difference between the draft and its baseline, applied to a fresh read. The screen also re-reads on resume, additively, so a target already in force is no longer drawn unticked.

module:// launch intents were discarded

The daemon encodes module://<packageName>:<userId> on the intent that opens the manager, and it survives the parasitic redirection intact. MainActivity never read getIntent() and had no onNewIntent, so a module notification opened Home, or whatever screen the back stack had restored. The pre-Compose activity handled this in handleIntent(); it was lost in #796. The destination it names still exists as Route.Scope.

The authority is split by hand: Uri.getHost()/getPort() cannot reject a malformed authority, and answer the whole string with a port of -1, which the old code passed through as a user id.

The intent is offered on every activity creation rather than only when savedInstanceState is null. That test does not hold parasitically — the zygisk hooker saves and restores the activity's state itself, so an activity started by a notification arrives with a bundle and cannot be told from a rotation — and skipping the offer left an earlier tap's destination to be consumed instead, so each tap arrived one launch late: the first module's notification opened Home, the next opened the first module's scope editor. Re-applying is instead prevented by skipping the navigation when the reader is already at that destination.

Package events in other users never arrived

The daemon re-broadcasts package changes for every user to both packages the manager can run as. Nothing in the manager registered that action. The manager used the platform's own broadcasts, which a dynamic receiver hears only for its own user, so an app installed into a work profile never invalidated the all-users list the scope editor filters. Both sources now feed the same invalidation; neither replaces the other.

The launcher prompt fired before it had an answer

ManagerPresence.notificationEnabled seeded false where the daemon's default is true, and was published only after forcedLauncherIcons() forks settings get global. unreachable was therefore true from construction while status.state was still Checking, so the modal prompt was drawn on every parasitic launch. Both toggles now seed their owner's default, the notification read publishes before the fork, and unreachable stays false until the read returns. A failed read writes nothing rather than falling back, and refreshPresence retries.

A framework flash could not survive leaving the screen

The flash ran on the update screen's viewModelScope, scoped to the navigation entry. A back press cancelled the wait for the exit code while the daemon continued flashing, leaving FlashStep.Flashing set on an application-scoped singleton with no control on the bar to clear it; acknowledge() had no callers. The installer now owns the work on its own supervised scope and the screen only observes it.

Testing

Built locally and flashed to an arm64 device on Android 16 under KernelSU, parasitic install, with purpose-built module APKs.

Case Result
staticScope with no scope list write accepted, row survives a cache rebuild, picker editable
staticScope with a scope list target outside it still refused
not-activated notice posted under <pkg>:not-activated, cancelled on enable from the CLI
module-updated notice unaffected by a scope write on an enabled module
module notification tapped opens that module's scope editor (real notification, Android 17)
module://<pkg>:0 launch opens that module's scope editor; no data opens Home
concurrent scope write editor on one app, second added externally, third ticked, all three stored
daemon package broadcast filter registered, delivery dispatched and completed

The scope-request path was exercised with a real API 101 module that binds IXposedService and calls requestScope with three packages:

Case Result
three packages requested three prompts, tagged <module>:<user>:<package>, distinct ids
approve one only that package approved and stored; the other two stay answerable
swipe one away onScopeRequestFailed("Request timeout") — the restored delete intent
"Never Ask" one failure per outstanding request, all prompts withdrawn, block persists
uninstall and reinstall prompts again, no longer blocked

The static-scope changes were then verified on a Pixel 6 running Android 17: a module declaring two system packages with an empty scope lists both rows; ticking one and unticking it again leaves both on screen with no pending changes; and applying one of the two stores only that one.

`staticScope=true` with no `META-INF/xposed/scope.list` is a packaging mistake -- a
module ships the flag and forgets to generate the list -- and reading it literally
fixed the scope at "no apps at all". `FileSystem.readStaticScope` answered an empty
set, which is "claims nothing" rather than "claims no restriction", so
`ModuleDatabase.setModuleScope` refused every write and, worse,
`ConfigCache.pruneScopeToClaimed` deleted every scope row the module already had on
each cache rebuild. The module could never hook anything and nothing said why.

The manager drew the other half of it: the picker locked to an empty list, every box
uncheckable, no explanation.

Both sides now ignore such a declaration and log it. A scope list of blank lines counts
as empty on the daemon side too, which is what the manager already did.

Verified on a Pixel-class arm64 device on Android 16: with a module declaring
staticScope and no scope.list, `scope set` is accepted and the row survives a cache
rebuild, the picker is editable, and the daemon logs "fixes its scope but names
nothing". A module declaring staticScope *with* a scope.list is still enforced -- a
target outside it is refused with the existing message.
`ModuleService.requestScope` loops over the requested packages and posts a prompt for
each, but the enqueue used tag=modulePkg and id=modulePkg.hashCode(). Platform
notification identity is (package, tag, id, user) and everything here is posted as
"android", so each prompt replaced the one before it: a module asking for three
packages left exactly one answerable, and the other two were never granted and never
answered by any callback at all. One module running under two users collided the same
way, and `notifyModuleUpdated` shared the tag, so a module update and a scope prompt
destroyed each other.

A scope prompt is now identified by module, module user and requested package, and
the dishonest `cancelNotification(channel, pkg, user)` -- which ignored both its
channel and its user and cancelled by the module package alone -- is replaced by two
functions that cancel what they name.

The prompt also regained the delete intent and the one-hour timeout that 714e6c8
dropped in the Kotlin rewrite; 89d255d had both. Without a delete intent the
`"delete" -> onScopeRequestFailed("Request timeout")` branch was unreachable, so
swiping the prompt away answered the module's `IXposedScopeCallback` neither way and
it waited forever. Because a swipe or the timeout can now fire after a button has
already been pressed, the first of the four arrivals claims the request and the rest
are dropped -- a request must be answered exactly once.

"Never ask again" now also withdraws the module's other outstanding prompts. The
preference only stops the next request, so with one prompt per package the user would
have said "stop asking" and been left looking at two more questions, both still
approvable.

Separately, the "module is not activated yet" notice is taken down when the module is
activated. It was only marked auto-cancel, which fires on tap, and the sole cancel
path belonged to the scope flow -- so the shade went on saying a module was inactive
after the user had activated it. The cancel sits in `ModuleDatabase.enableModule`
because that is the one point every activation passes through: the manager, the socket
CLI, a backup restore, and `setModuleScope`'s implicit enable. The manager could not
do it -- the AIDL exposes no cancel, and a parasitic manager cannot cancel a
notification posted as "android".

The two things `notifyModuleUpdated` posts are tagged apart, because only one of them
goes stale: "force stop the apps in its scope" is still true after activation, and one
tag for both meant activating a module erased a restart reminder the user had not
acted on.

Verified on Android 16: installing a disabled module posts the notice under
`<pkg>:not-activated`; enabling it from the CLI takes it down; a scope write on an
enabled module leaves the "please force stop" notice alone.
The framework has always encoded which module a notification concerns in the intent
that opens the manager: `NotificationManager` builds `module://<packageName>:<userId>`,
`ManagerService.openManager` copies it onto the launch intent, and it survives the
parasitic redirection intact, because the zygisk hooker rewrites the resolved activity
and never the intent. The manager then threw it away -- `MainActivity` never read
`getIntent()` and had no `onNewIntent` -- so tapping "module X is not activated yet"
opened Home, or, since the back stack is SavedState-backed and `launchMode` is
singleTop, whatever screen the reader had last been on.

A regression from the Compose rewrite in #796: the old MainActivity had
`handleIntent()` on both `onCreate` and `onNewIntent`, with an explicit `module` scheme
case. The destination it names already exists as `Route.Scope(packageName, userId)`.

The authority is split by hand rather than read with `Uri.getHost()`/`getPort()`, which
cannot fail and therefore cannot reject: `module://com.example.foo:bad` answers the
whole authority as the host and -1 as the port, and the old code passed that -1 through
as a user id.

An offer replaces whatever was waiting, including with nothing. This object outlives
the activity, and a destination can be offered and never applied -- SplashGate holds
the shell out of the composition for the best part of a second, and a back press inside
that window finishes the activity first. Left in place it would be applied to the next
launch, dropping someone who opened the app from the launcher into the scope editor of
a module they were last told about.

Verified on Android 16: launching with `module://<pkg>:0` lands on that module's scope
editor with the module list beneath it, and launching with no data lands on Home and is
not diverted by the previous link.
`setModuleScope` replaces every scope row of a module, and the scope editor sent it the
draft it had built when the screen opened. `load()` runs once from `init`, nothing ever
re-read it, and the view model is scoped to the navigation entry, so it survives the
user leaving the app entirely. Anything written meanwhile was deleted.

That is not hypothetical: this screen's own button opens the module, which is exactly
where a libxposed module calls `requestScope`, and approving that from the notification
shade writes a scope row. The next Apply deleted it -- minutes after the module had been
told it was granted -- and nothing anywhere said so.

So the edit travels rather than the picture: the targets ticked and unticked here,
applied to what the daemon holds at the moment of writing. A failed re-read falls back
to the draft, which is exactly the old behaviour, because refusing to apply would leave
an edit that can never be committed. The framework row is compared in the spelling the
daemon stores it in -- user 0 whoever asked -- so the two readings can be subtracted
without it looking added and removed at once.

The screen also re-reads when it comes back to the front, so it stops drawing an empty
box beside a target the module is already being loaded into. That fold is additive:
what the user ticked is theirs and survives, and a row that vanished elsewhere stays in
the draft where it reads honestly as something applying would put back.

Verified on Android 16: with the editor open on a module scoped to one app, adding a
second from outside and then ticking a third and pressing Apply leaves all three. The
pending-changes line counts only the user's own edit.
The daemon has always re-broadcast the package changes it sees, for every user, to both
packages the manager can run as. Nothing in the manager listened: the action string
appeared only at the sender.

The manager listened to the platform's own broadcasts instead, and a dynamic receiver
hears its own user's and nobody else's. That collector is the only caller of
`AppRepository.invalidate()`, and that cache holds the all-users app list the scope
editor filters -- so an app installed into a work profile or a secondary user never
reached the manager at all, and the scope editor for a module in that profile listed
every app except the one just installed, with no refresh that would ever bring it in.

Both sources now feed the same invalidation. Neither can replace the other: the
platform's arrives whether or not the daemon is alive, and the daemon's is the only one
that sees past this user.

Not one of the extras can be read with the accessor its name implies -- the package is a
single String under EXTRA_PACKAGES and the user a plain Int under EXTRA_USER -- which is
worth knowing before someone corrects it to `getStringArrayExtra` and gets null, or to a
UserHandle and gets user 0, the one user this exists to see past.

The receiver has to be exported, so the action can be forged. That is acceptable for one
reason: a delivery makes the manager drop a cache and ask the daemon again, and nothing
is believed on the strength of the payload -- the collector does not read which package
an event names, and `isXposedModule` is deliberately never read.

Verified on Android 16: the manager registers the filter, and installing a package shows
the daemon's broadcast dispatched to it and finished.
`ManagerPresence.notificationEnabled` started false while the daemon's own default,
when nobody has touched the preference, is true. `unreachable` was therefore true from
construction, and `status.state` is `Checking` rather than `Inactive` at that point, so
the HomeScreen guard passed and the modal "you have no way to open this app again"
dialog was drawn -- on a parasitic install with no pinned shortcut, on every launch,
including the launch that came from tapping the very notification it said did not exist.

It lasted long enough to see. The toggle read is published only after
`forcedLauncherIcons()`, which the daemon implements as a `settings get global` fork and
wait, and the whole function runs after `refreshStatus`'s eight sequential round trips.

Three things, because the seed alone is not enough. Both toggles now seed the default
their owner actually uses, the way `_hiddenIcon` already did. `notificationEnabled` is
published as soon as the cheap read answers rather than after the fork. And
`unreachable` is false until the read has actually come back: not knowing and knowing
there is no way in are different, and only the second is worth a modal.

A failed read now writes nothing at all, rather than falling back to a default -- that is
how a switch read correctly on arrival ends up reporting the opposite after one refused
transaction -- and `refreshPresence` re-reads, since `ServiceLocator.service` does not
emit again while one binder stays alive and the single collect in `init` was the only
caller there had ever been.

The two toggle flows move above `init` for the reason `_presence` already sits there:
`viewModelScope` dispatches on `Main.immediate`, so `init` reaches `refreshToggles` on
the thread still constructing the object. It survives today only because the first thing
it does is suspend on a binder call.

The demo host's `enableStatusNotification` answered false where its sibling
`forcedLauncherIcons` correctly mirrors the platform default, so in demo mode the read
*succeeded* with the wrong value and the prompt appeared on every launch regardless.
The flash ran on the update screen's `viewModelScope`, and that view model is scoped to
the navigation entry. A back press during a flash cleared the entry's ViewModelStore,
cancelled the scope, and killed the one line that reads the installer's exit code and
moves off `FlashStep.Flashing`. The daemon finished the install regardless and the exit
code was dropped on the floor.

`FrameworkInstaller` is an application singleton, so the stale `Flashing` outlived the
screen, and the bar's Flashing branch is a bare spinner -- Install exists only in the
Idle branch, Retry in Failed, Reboot in Done. Coming back to the screen showed a
permanent spinner over an install that had long since succeeded, until the process was
force stopped. `acknowledge()`, the only other writer of Idle, had no callers anywhere.

The installer now owns the work: its own supervised, process-lifetime scope, a `start()`
that returns immediately and refuses a second flash rather than queueing it, and
`state`/`lines` as the only answer. Leaving the screen cancels nothing, and coming back
finds the result. `acknowledge()` is wired to a tap on the finished row, so a result
stays up until it is read and can then be put down -- otherwise a build that was
installed and not rebooted straight away would hide the picker behind itself.

The download is no longer cancelled by navigation either, so the reset-to-Idle that
existed because the progress row has no button on it is gone rather than left describing
behaviour the code no longer has.

Refusing to clear a running flash is safe because `Flashing` cannot outlive what it waits
on: the one way the exit code never arrives is the daemon dying mid-install, and
`Constants.setBinder` links to that death and exits the manager process.
@JingMatrix JingMatrix changed the title Nine fixes to module scope, and to what the daemon tells the manager Fix module scope writes and the daemon notifications the manager dropped Jul 31, 2026
"Never ask again" writes the package into a set belonging to "lspd", not to the module,
so `deleteModulePrefs` on uninstall -- which deletes what is filed under the module's
own name -- walked straight past it. Nothing else ever removed it: the socket CLI does
not know the key, and the manager has no control for it. A module blocked by one tap on
a notification action sitting next to Approve was blocked on that device permanently,
and reinstalling it changed nothing.

Uninstalling is deliberate enough to count as taking the decision back, and a module
that is gone has no decision left to honour. The read and both writes now go through one
place, so the two spellings of the preference cannot drift apart.

This matters more since the same change made "never ask again" withdraw the module's
other outstanding prompts: one mis-tap now ends every pending request at once.

Verified on Android 16 with an API 101 module that calls requestScope: pressing "Never
Ask" blocks it and a re-request is refused with "Scope request blocked by user
configuration"; uninstalling logs "may ask for scope again if it returns", and after
reinstalling the same module its next request posts prompts as normal.
Every row of a static-scope module's list was drawn with its checkbox disabled, so the
declared scope was all or nothing: "Use recommended" in the selection menu could take
the whole list, or "Select none" could clear it, but a module naming three apps could
never be pointed at one of them, and an app could not be dropped from the scope once
applied.

Nothing asks for that. module.prop claims only that "users should not apply the module
on apps outside the scope list", the list is already narrowed to exactly that set a few
lines above, and `ModuleDatabase.setModuleScope` refuses only the targets beyond it --
a subset has always been accepted. The manager was the one component enforcing more
than the declaration says.

The list stays fixed and the rows keep saying so; what changes is that the boxes in it
can be ticked. Only the derived row -- a legacy module's own package, which the daemon
adds whether or not the table names it -- stays uncheckable, as before.

Not verified on a device: the test devices went offline before this could be flashed.
The two claims it rests on are checked -- the scope list narrowing at ScopeScreen and
the daemon's `beyond` test in setModuleScope -- but the screen itself has not been
driven with a static-scope module since the change.
The exemption that keeps a chosen app past the filters read the draft and nothing else,
so unticking one took the row out of the list with it. The default filters hide system
apps, and most scope targets worth naming are system apps, so unticking Settings made
the row vanish mid-edit -- taking with it the only tick that could undo the mistake.
Nothing brought it back short of noticing that a filter had to be turned on.

A row now counts as in play if it was in force when the screen opened, is in the draft
now, or has been touched during this visit, and the last of those is what survives
ticking something and then changing your mind about it. The set only grows, so a row
put in play stays until the screen is left.

The same defect emptied a static scope completely. Those lists are already narrowed to
the module's declared set, but the system, game and module filters still ran over them,
so a module fixing its scope on `com.android.settings` and `com.android.systemui` --
which is the ordinary shape of a static scope -- showed nothing at all until something
else put those packages in the scope. A static scope now bypasses those three filters
exactly as the "recommended only" filter already did, and for the reason already
written there.

Verified on a Pixel 6 on Android 17: a static module declaring two system packages with
an empty scope lists both rows; ticking one, then unticking it, leaves both rows on
screen and the pending-changes bar empty; and applying just one of the two stores only
that one, which also confirms on hardware what the previous commit could only argue.
The deep link was offered only when `savedInstanceState` was null, to stop a rotation
re-applying it. That reading does not hold parasitically: the zygisk hooker saves and
restores the manager's activity state itself, so an activity started by a notification
arrives with a bundle and is indistinguishable from a rotation by that test. The offer
was therefore skipped on exactly the launch it existed for, and because nothing replaced
the field, whatever destination an earlier tap had left unapplied was consumed instead.

The visible result was worse than the link simply not working. The first module's
notification opened Home, and the next one opened the *first* module's scope editor --
each tap arriving one launch late.

The intent is now offered whenever the activity is created, so the field always reflects
the launch in hand and a data-less one clears it. Applying it twice is prevented where
it can be judged properly: the shell skips the navigation when the reader is already at
that destination. A rotation is then a no-op, while a second notification for a module
they have since navigated away from still moves them, which keying on the intent could
not have told apart.

Verified on a Pixel 6 on Android 17, through the real notification rather than a
synthetic launch: installing a module posts "is not activated yet", and tapping it opens
that module's scope editor. The earlier check for this used `am start` directly, which
never exercised the restored-state path and so could not have caught this.
Giving every requested package its own prompt removed the collapse that used to hide how
many a module could ask for. `requestScope` takes an unbounded list, the prompts are
enqueued as "android" -- which NotificationManagerService exempts from its per-package
limit -- and they are IMPORTANCE_HIGH, so a module asking for a thousand packages got a
thousand heads-up prompts, each sitting for an hour.

Sixteen unanswered prompts per module. It bounds what is waiting, not what may be asked
over time, so a module that asks and waits never meets it; one that does is told per
package rather than left waiting, because a callback that never fires is the failure this
path exists to avoid. The registry is now per module rather than one shared table, so one
module's burst cannot evict another module's question -- and an entry that is given up is
handed back so its prompt comes off the screen and its callback is failed, instead of
becoming three dead buttons.

An entry also stops counting against the ceiling once it is older than the prompt's own
timeout: an enqueue the platform drops, or a cancellation that never fires the delete
intent, would otherwise hold a place for the rest of the boot.

"Never ask again" now takes effect even when the requested package cannot be resolved for
that user. It is a decision about the module, and the early "Package not found" return was
skipping it, so a module could be blocked and go on asking immediately.

Three comments corrected. An app-initiated cancel does not fire the delete intent --
NotificationManagerService passes sendDelete=false for REASON_APP_CANCEL, so the claim
that ordering guards against re-entry was wrong, though claiming first is still right.
`enableModule` is not the single point every activation passes through: the socket CLI's
`db restore` writes the table directly, leaving a stale "not activated yet" notice. And
the note about which call can throw named the wrong one.
Offering the intent on every creation fixed the notification that opened the wrong
module, and introduced a worse one. `onNewIntent` never called `setIntent`, which the
platform's own documentation says is required, so `getIntent()` answered the intent this
activity was *created* with for the life of the task. Every later recreation -- a
rotation, the dark-mode flip at sunset, a font or locale change, a return from recents --
offered that destination again. The shell's "already there" check only helped while the
reader was still standing on it: once they had moved, `Navigator.switchTo` emptied the
stack, and an unapplied scope draft went with the entry it was scoped to.

What separates a launch from a replay is not the bundle and not where the reader is
standing, so the judgement moves to `DeepLink`, which remembers the destination it last
applied. An offer from `onCreate` naming that same destination is dropped; one from
`onNewIntent` always takes, because the system only delivers it when something started the
manager again. An intent naming nothing understood still clears the field.

`MainActivity.onDestroy` forgets it when the activity is really finishing. The memory
outlives the activity -- a Kotlin object lives as long as the process, and backing out
leaves that process cached -- so without this the next notification about the same module
would be dropped as a replay and open Home.

The remaining miss is stated where it lives rather than papered over: parasitically the
resolved activity is a copy of the host's, so this manifest's `singleTop` need not be the
launch mode the system applies, and a second notice for a module already applied can reach
a fresh `onCreate` beside the living activity and be dropped.

Two comments corrected: `onNewIntent`'s KDoc no longer asserts singleTop as the mechanism,
and the object KDoc no longer says the parasitic redirection never rewrites the intent --
it rewrites the component, and leaves the data Uri and extras alone.

Verified on a Pixel-class arm64 device on Android 15: the deep link opens that module's
scope editor; going back to the module list and forcing a configuration change leaves the
reader there; and finishing the activity while the process stays cached lets the same link
apply again.
Unlocking those checkboxes left the whole presentation saying the opposite. Under a static
scope every row is recommended, so every row is drawn as Locked -- an outline the code's own
comment justified as reading "not yours to change" and deliberately not inviting a tap --
under a caption that says the module fixed it, beside an enum whose KDoc still claimed the
user could not change it. The one visual signal of the feature was styling designed to say
"do not touch".

The rows now carry a colour that reads as tappable, and both comments say what is actually
true: the module fixes which apps may be *listed*, and which of those are in the scope
stays the reader's answer.

`clearAllVisible` also stopped marking every visible row as touched. That set exists so a
row the reader unticks does not vanish under the filters, and a bulk clear was putting rows
that had never been in the scope into it -- permanently, since it never shrinks -- so after
"clear everything shown" the system, game and module filters hid nothing for the rest of
the visit and there was no way back. Only rows whose tick actually changed go in now, and
the KDoc says why `selectAllVisible` deliberately marks nothing.

Verified on Android 15: a static module with an empty scope lists both declared system
apps, ticking one then unticking it leaves both rows on screen with no pending change, and
applying one of the two stores only that one.
`RecommendedScope` still said a static scope means the editor "shows the list read-only
rather than pretending the choice is theirs", which stopped being true when the rows were
unlocked. A maintainer reading it would re-disable them. It now says what staticScope
means: the module fixes the set the scope is drawn from, and the daemon refuses only
targets beyond it, so any subset of it is stored.

The demo host's comment claimed answering the status-notification default wrongly made the
launcher prompt appear on every demo launch. It cannot: that prompt is gated on a parasitic
install, which the demo host never is. The default itself is right -- the daemon's
PreferenceStore reads the preference `?: true`, so the fake has to mirror it -- and the
comment now rests on that alone.
Moving the flash onto the installer's own scope is what stopped a back press from throwing
away an exit code, and it took the only way to abandon a download with it: leaving the
screen no longer cancelled anything, `acknowledge` refuses a step that is not finished, and
the progress row had no control on it. A release zip fetched over mobile data could only be
stopped by force stopping the app -- and a download left to finish goes straight on to
flash the build the reader has just decided against.

The Downloading row gets a button, and it cancels the transfer only. The flash keeps no
equivalent and must not grow one: an installer half way through writing a module tree
cannot be recalled, so stopping that wait would throw away the exit code and change nothing
on the device. The button names the download for the same reason -- a bare "Cancel" on this
bar would read as an offer to call off the install too. The call is built in `start` so a
press that lands before the coroutine is dispatched still has something to cancel, and the
partial file is deleted rather than left in the cache.

Building it there moved a throwing parse onto the main thread, since `HttpUrl` rejects a
url it cannot read; it is guarded, so a release with an unusable asset url reports a failed
download instead of taking the app down on a press of Install.

`acknowledge`'s KDoc no longer argues that a daemon which cannot report its result always
takes the manager with it. `ManagerService` wraps that callback in `runCatching` and stays
up, so the death recipient need never fire; the residual risk is stated instead of
explained away.
The comments added with the launcher-prompt fix described an ordering the code no longer
has. `refreshToggles` is reached from `refreshPresence` in `init`, before the
`ServiceLocator.service` collect that the comments said it queued behind, so the account of
why the seed and the not-yet-asked state matter was arguing from the wrong sequence. They
now describe the order the code runs in.

Checked while there: the `notificationKnown` gate cannot hide the prompt on an install that
is genuinely unreachable. A daemon that never answers leaves it false, and false only
suppresses the claim until the read comes back -- which is the point, since not knowing and
knowing there is no way in are different things.
`Check translations` requires every locale to carry every key, and the new string went in
with only its English text. Each translation is built from the vocabulary that locale
already uses for the two words: its own `github_cancel`, and the verb in its
`update_downloading`.
@JingMatrix
JingMatrix merged commit ebb438b into master Aug 1, 2026
1 check passed
@JingMatrix
JingMatrix deleted the scope-and-notification-fixes branch August 1, 2026 07:21
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.

1 participant