Fix module scope writes and the daemon notifications the manager dropped - #832
Merged
Conversation
`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.
"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`.
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.
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=truewith noMETA-INF/xposed/scope.listmadereadStaticScopeanswer an empty set. That is "claims nothing" where the caller reads "claims no restriction", sosetModuleScoperefused every write andConfigCache.pruneScopeToClaimeddropped 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
staticScopemodule that does name packages is enforced exactly as before.Only one scope request per module could be answered
ModuleService.requestScopeposts 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 asandroid, 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 throughIXposedScopeCallback. One module under two users collided the same way, andnotifyModuleUpdatedshared 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 thedeleteModulePrefscall 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.propclaims only that users should not apply the module outside the scope list, the list is already narrowed to that set, andsetModuleScoperefuses 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.settingsmade 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.settingsandcom.android.systemuishowed nothing at all. A static scope now bypasses those three filters, as "recommended only" already did.The scope editor overwrote concurrent writes
setModuleScopereplaces every row of a module, and the editor sent the draft built when the screen opened.load()runs once frominit, nothing re-read it, and the view model is scoped to the navigation entry, so it survives leaving the app. Approving arequestScopeprompt 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 discardedThe daemon encodes
module://<packageName>:<userId>on the intent that opens the manager, and it survives the parasitic redirection intact.MainActivitynever readgetIntent()and had noonNewIntent, so a module notification opened Home, or whatever screen the back stack had restored. The pre-Compose activity handled this inhandleIntent(); it was lost in #796. The destination it names still exists asRoute.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
savedInstanceStateis 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.notificationEnabledseeded false where the daemon's default is true, and was published only afterforcedLauncherIcons()forkssettings get global.unreachablewas therefore true from construction whilestatus.statewas stillChecking, 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, andunreachablestays false until the read returns. A failed read writes nothing rather than falling back, andrefreshPresenceretries.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, leavingFlashStep.Flashingset 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.
staticScopewith no scope liststaticScopewith a scope list<pkg>:not-activated, cancelled on enable from the CLImodule://<pkg>:0launchThe scope-request path was exercised with a real API 101 module that binds
IXposedServiceand callsrequestScopewith three packages:<module>:<user>:<package>, distinct idsonScopeRequestFailed("Request timeout")— the restored delete intentThe 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.