Skip to content

Move the manager AIDL out of org.lsposed.lspd - #861

Merged
JingMatrix merged 13 commits into
masterfrom
manager-aidl-namespace
Aug 4, 2026
Merged

Move the manager AIDL out of org.lsposed.lspd#861
JingMatrix merged 13 commits into
masterfrom
manager-aidl-namespace

Conversation

@JingMatrix

@JingMatrix JingMatrix commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Finishes the namespace move #757 started. That PR moved the daemon's AIDL and listed three things it left behind: the manager's interface, the Utils logger, and the daemon's implementation class names. This does all three. /data/adb/lspd is deliberately untouched — it is on-disk state and needs a migration, not a rename.

ILSPManagerService and its parcelables become IManagerService, IFrameworkInstallReceiver, ScopeEntry and DeviceUser under org.matrix.vector.ipc. org.lsposed.lspd.util.Utils becomes org.matrix.vector.util, with the inner Log promoted to top level. ApplicationService and ModuleService take the names of the interfaces they implement.

The interface goes from 45 methods to 39. Three had no callers, restartFor among them, which the daemon implemented as a no-op. getUnloadableModules and getModuleLoadState collapse into one call returning a map: the old pair sent a list of package names and then spent a transaction per name asking why each had failed, so the caller pre-filled every entry with MODULE_LOAD_NO_APK, and a dropped transaction told the user the framework could not find their module's APK.

The interface name is the binder descriptor, so this breaks a manager installed as a separate app. Such a copy survives a flash today; after this it binds successfully, then fails every transaction while the binder stays alive, and every screen goes blank with nothing said. The manager now compares descriptors before binding and reports the mismatch. getProtocolVersion goes in alongside it, because a new descriptor is the only point at which it needs no fallback of its own. Recovery is the parasitic manager, which ships with the daemon, and that is worth a release note.

Seven defects surfaced while documenting the implementation, each with its own commit: enableModule discarded the result of its insert() and reported a row that was never written as a success; systemServerRequested latched before the registration it reports had succeeded; uninstallPackage waited on a binder thread with no timeout; the dex2oat row read "Supported" on releases that have no wrapper; Log.muted was ignored by three overloads; a failed log rotation blamed the daemon for refusing; and getRootImplementationVersion was fetched on every refresh and never rendered.

…t does

The daemon-side AIDL was cleaned in 34144a8, which left this for later: the
manager's interface, the parcelables it carries, and the -keep rule naming them.

The names had drifted from the code by more than a namespace. getXposedVersionCode
returned this framework's version, not Xposed's; getFrameworkCommit had to open its
own documentation by saying it was not a commit; enableStatusNotification read as a
command and the socket CLI called it as one, from the branch that *reads* settings;
dex2oatFlagsLoaded asked whether a property carried a flag; startActivityAsUserWithFeature
was named after an AOSP method whose distinguishing feature this signature does not
have. Application and UserInfo both collided with platform types, so every caller
handling a list of either wrote the type out fully qualified and then kept a local
mirror under a name that did say what it was.

Three methods had no callers at all - restartFor was a daemon-side no-op with a manager
wrapper documenting it as vestigial - and two pairs asked one question twice.
getUnloadableModules plus getModuleLoadState is the pair that mattered: the daemon
holds a Map<String, Int> and the AIDL sent its keys, then took one transaction per key
for the values. That forced the caller to seed every entry with MODULE_LOAD_NO_APK
before the second round could overwrite it, so a dropped transaction told the user the
framework could not find their module's APK - a claim nothing had established. It is
one call returning the map.

The lsp_no_switch_to_user intent extra is gone, and it is worth naming because it was a
string agreed between two APKs that can ship apart. Getting it wrong switched the
device's user and locked the screen when somebody opened a module. It is a parameter.

The hand-written transaction ids go with it. They existed to keep a method's number
stable across revisions, and they did that badly: id 33 of the old interface carried
setHiddenIcon(boolean hide) and later setForcedLauncherIcons(boolean force), the same
number with the argument's sense inverted, so every peer built against the earlier file
went on calling it and asking for the opposite of what it meant. A number beside each
method cannot catch that; a version can. getProtocolVersion is declared first, which
makes it transaction zero in every revision, and the manager asks it before anything
else. The rule is no longer "append only" but "change what the design wants, and bump
PROTOCOL_VERSION in the same commit".

That descriptor change is the cost. The manager can be installed as an ordinary app -
getManagerApk exists for that - and an installed copy survives a later flash, so a
manager and a daemon of different builds is a supported configuration rather than an
accident. It used to degrade quietly, one method at a time, which is why ROOT_UNKNOWN
has to be 0 and why getLogParts is defended with orEmpty(). A changed descriptor does
not degrade: every transaction throws SecurityException while the binder stays alive, so
a manager older than the framework it is flashed beside answers nothing on every screen.

getProtocolVersion is added at id 1 for the same reason, and can only be added now:
every daemon answering to this descriptor was built from a file that already carries it,
so it needs no bootstrap sentinel. That is the usual reason a version handshake cannot
be retrofitted to a live interface.

Every method, constant, field and parcelable now carries what it is for, who may call
it, what a false or a null means, and the failure symptom for constraints that cannot be
inferred from the signature. Nothing asserts behaviour that was not read out of the
implementation first.
The last of this project's own code under org.lsposed.lspd, and the third item
34144a8 left for later. Twenty-two files reach it, across the legacy API, the
injected framework and the zygisk bridge, all compiling into one artifact — and
nothing in the manager, which wrote its own logger rather than depend on this one.

org.matrix.vector.util, deliberately not one of the three prefixes
daemon/src/main/jni/obfuscation.cpp rewrites. Nothing here is obfuscated today and
this keeps it that way; landing it under .core, .nativebridge or .service would have
started rewriting a class the manager does not compile, for no gain.

Log becomes a top-level class rather than a member of Utils. It held nothing but
static members while being a *non-static inner class*, which Java has accepted only
since release 16, and which meant every call site outside an import wrote Utils.Log
for a type never scoped to an instance of anything. Splitting it also lets the two say
what they are for: Log is the muting-aware stand-in for android.util.Log, and Utils is
the convenience layer that logs under the framework's own tag.

Which methods honour `muted` is uneven — the message-only forms and
w(String, String, Throwable) do, the other Throwable forms do not — and that is
carried over exactly. It looks like an oversight, but changing it changes what a muted
device records, which is a decision about the log rather than about this move.

The tag is the one thing here that must not change: the daemon's log reader routes any
tag beginning "Vector" into its verbose stream, so what these helpers write reaches the
manager's Verbose tab and travels in an exported bug report. It is now documented where
it is declared.

XposedBridge keeps its call fully qualified. That file imports android.util.Log too,
and it is the framework's own that is wanted there — the platform's getStackTraceString
returns an empty string when anything in the cause chain is an UnknownHostException, so
a module logging a failed request landed an empty line.
The fourth item 34144a8 left for later. Renaming the AIDL moved the interfaces to
honest names and left the classes implementing them behind.

ApplicationService implemented IFrameworkService and named neither: nothing in it is
about an Application. It is FrameworkService.

The two module services were worse than mismatched, they were swapped in the reader's
head. A module gets two services — libxposed's IXposedService, which is the module as
its own *app* sees it, and this project's IModuleService, which is the module as an
injected *process* sees it — and they deliberately differ in what they permit: an app
may write its remote files, a hooked process may only read them, because a hooked
process runs as the app it was injected into rather than as the module. The class
implementing IXposedService was called ModuleService, the vaguer of the two names, and
the class implementing IModuleService was called InjectedModuleService. The one name
that said which of the two it was sat on the wrong one, and the natural name for the
other was already taken.

ModuleAppService and InjectedModuleService now both carry the distinguishing fact, and
each points at the other. That beats dropping the I from each interface, which would
give ModuleService and XposedService — two names told apart only by knowing which
project each interface belongs to.

The daemon AIDL gets the three fixes that reading it for this turned up:

  - IVectorDaemon.attachProcess returns null in four cases and documented one of them.
    The others are a caller that is not uid 1000, a (uid, pid) that has already
    attached, and a registration that failed. @nullable is documentation-only in the
    Java backend — no annotation is emitted and the signature is unchanged — so this is
    the doc catching up with the code, not a wire change.
  - LoadedModule.applicationInfo was the one field in that file with no comment. It is
    the *module's* ApplicationInfo, not the host process's, which is why it has to be
    carried rather than looked up.
  - IVectorDaemon and IModuleService said nothing about transaction numbering while
    IFrameworkService explains it at length. Both are implicit and append-only for the
    same reason — they only ever cross between a daemon and a framework dex from one
    zip — and both now say so, and why IManagerService is explicit instead.
Eight files carried the GPL boilerplate at the top — the "this program is free
software, distributed without warranty, you should have received a copy" paragraph —
inherited from upstream and by then naming a project this is not.

The licence is conveyed where it counts and is not weakened by this: LICENSE holds the
full GPL-3.0 text at the root, and README's own License section says the project is
under it. Per-file headers are the FSF's recommendation for making the terms travel
with a stray copy of a single file, not a condition of the licence, and twenty lines of
boilerplate above a four-line hidden-API stub was never buying that.

What the headers held that nothing else did is EdXposed, named in a copyright line on
the logger and nowhere else in the repository. It goes to README's Credits beside
LSPosed, which already stood there as upstream source — the lineage is a fact about
where this code came from and belongs somewhere a reader will find it.
Not part of the rename; it is what the rename makes necessary, and it can be dropped on
its own if it is not wanted.

The interface's fully qualified name is its binder descriptor, so moving the AIDL out
of org.lsposed.lspd changes it. Two of the three consumers ship in the same zip as the
daemon and are always in step. The third is not: getManagerApk exists so the manager
can be installed as an ordinary app, and an installed copy survives every later flash.

Across that skew nothing is loud. Stub.asInterface wraps any binder in a proxy without
checking, the binder stays alive so isBinderAlive() keeps answering true, and every
transaction throws SecurityException out of the daemon's enforceInterface — which
DaemonClient turns into a failed Result and every screen draws as empty. The header
would then read "Not activated", which is the one thing that is certainly false: a
framework is plainly running, it pushed us the binder, and the reader is being sent to
install something they already have.

So Constants.setBinder asks the binder what it is before binding. That question is
exempt by construction — INTERFACE_TRANSACTION sits outside the range the generated
dispatcher checks the interface token for — so it is answered across any mismatch. It
can still throw, since the daemon may have died in between, and a throw is not evidence
of a mismatch, so it falls through to binding and lets linkToDeath report the death.

FrameworkState gains Mismatched, rendered as "Version mismatch". It keeps the error
colours — this is a broken install — but takes the priority glyph rather than the
cross, because the cross means absence. The label does not blame either side: the
descriptor says which build is which, and that goes to the log, but a user cannot act
on the difference and the manager is as likely to be the newer one.

The two places asking `state != Inactive` to mean "there is a daemon to talk to" would
both have answered wrongly, so the question moved onto FrameworkStatus as daemonUsable.
HomeViewModel collects the binder and the mismatch together, because a refusal leaves
service exactly as it was — null — so a StateFlow has nothing to emit and the header
would sit on whatever it already said.

There is no route out from inside a stale manager: the call that would fetch a
replacement APK is a transaction on the interface that does not match. The parasitic
manager ships with the daemon, is always in step, and is reachable from the status
notification and the dialer code. A manager built before this check exists cannot show
it at all, and every released build is one — on the flash that lands this, such a copy
shows empty screens, as it does today for any other daemon failure.
`enableModule` has two paths. Updating an existing row checks what it did —
`changed = db.update(...) > 0` — and inserting a newly discovered module did not: it
called `db.insert(...)`, threw the result away, and set `changed = true`.

`SQLiteDatabase.insert` does not throw on failure. It catches the SQLException itself,
logs one line, and answers -1. A disk that is full, a database locked by a concurrent
write, or a constraint that rejects the row therefore all looked exactly like a
successful insert.

Everything downstream then acted on that. `setModuleEnabled` answered true, so the
manager left its switch on. `ConfigCache.requestCacheUpdate()` ran for a module that has
no row. Worst of the three, the shade's "this module is not activated yet" notice was
cancelled — the one thing that would have told the user something was wrong. The module
then simply never loaded, with the manager showing it enabled and nothing anywhere
reporting a failure.
`systemServerRequested` was set immediately after the uid/process-name/life-token gate,
before `FrameworkService.registerHeartBeat` — the call that decides whether
system_server actually gets its framework service. Registration answers false when the
life token cannot be linked to death, which is rare rather than impossible, and the flag
is never cleared.

So a failed registration read as attached, and the manager's status page reported the
framework as present in system_server while no module hooking the system loaded. That is
the expensive kind of wrong: a green health row sends the reader to look at their
module, which is the one place the fault is not.

The assignment moves below the registration, so the flag now means what
`isSystemServerAttached()` says it means. The observable change is that the health row
flips a moment later during boot — after the registration rather than at the request —
and stays false on the failure it used to hide.
`uninstallPackage` runs on a binder thread, asks the package installer to remove a
package, and then waited on a CountDownLatch with no timeout for the status broadcast.

The status is not guaranteed to arrive. A device-policy refusal, a user removed while
the uninstall is in flight, or a wedged package installer all end with nobody counting
the latch down. That thread is then held for the life of the daemon.

The cost is not confined to the caller. The daemon's binder thread pool is small, and
threads lost this way never come back, so enough of them starve everything else the
daemon answers — the manager's module list, the log reads, and the calls injected
processes make while they start. The symptom is a manager that hangs on every screen and
a device that needs the daemon restarted, none of which points at an uninstall somebody
tried a while ago.

A minute, which is generous for work that normally takes seconds, and false on expiry: a
timeout is a failure of our knowledge rather than of the uninstall, and the caller must
not be told a package is gone on the strength of a status that never came.
`muted` gated the message-only forms of d/v/i and both forms of w, but not
d/v/i(String, String, Throwable). The setting therefore only half worked: a user who
turned verbose logging off still got every debug, verbose and info line that happened to
carry an exception.

Those are not the rare ones. `muted` is set in injected processes, from
`IFrameworkService.isLogMuted`, and the lines that carry a throwable are the ones on the
failure paths of hooking — the paths that run in every process the framework touches. A
muted device kept paying for them, and kept writing them to a log the user had asked to
be quiet.

ERROR and above stay ungated, deliberately and as before: muting asks for less noise,
not for a failure to go unrecorded.
The daemon only starts the dex2oat wrapper machinery from Android 10 and answers a
literal 0 below it — and 0 is DEX2OAT_OK. The status page rendered that as "Supported",
in green, with a healthy tick.

On Android 8.1 and 9 that is a positive claim about a feature the device does not have.
The platform's dex2oat inlines across methods there exactly as it always did, so a hook
on an inlined method does not take, and a reader chasing that is told by the status page
that this part is fine.

The row is dropped below Android 10 rather than given a fifth state. A new constant was
the other option and it renders worse: every value the screen does not recognise falls to
"Unsupported", so a device that today reads a wrong green would have read a wrong red,
which is further from the truth rather than closer. Saying nothing about a wrapper that
does not exist is the honest answer, and the section still carries SELinux and
system_server, which do apply there.

No health issue changes: `Dex2oatWrapperBroken` is raised only when the state is not
DEX2OAT_OK, so it was never raised below Android 10 either.
`FrameworkUpdateViewModel` fetched it into `RootState.version` on every refresh of the
framework update screen, and no composable read that field. It was one binder
transaction per refresh buying nothing, and it was the only consumer the AIDL method
had — so the method had no readers at all.

Removed rather than rendered because rendering it is a design decision about that screen,
not a defect to repair. The daemon still derives the string and still logs it
(`Root implementation: Magisk 27.0 via …`, and the comma-joined list for ROOT_MULTIPLE),
so nothing is lost from a bug report; only the unread IPC is gone.

Removing a method shifts the transaction id of every one below it, which is exactly what
PROTOCOL_VERSION is there to make safe: a peer that has not been rebuilt is refused at
the handshake rather than left calling a number that now means something else.
…was refused

`logs_rotate_failed` read "The daemon refused to start a new log", and there is no path
by which the daemon refuses. `startNewLogPart` answers nothing — it writes a sentinel
into the log for the native reader to act on afterwards — so the only failure the manager
can observe is that the transaction never arrived: a dead binder, a descriptor that does
not match, or no daemon at all.

The inaccuracy predates this branch. `clearLogs` returned a boolean that was the constant
`true`, so its false branch already meant "the transaction did not arrive" while the
string said the daemon had refused. It sent a reader looking for a permission or a
configuration problem when the framework was simply not reachable.

A new key rather than a reworded one, because the meaning changes and eighteen
translations would otherwise go on asserting the old one. Each translation keeps the word
for the daemon that its own file already used — service, background process, 守护进程 —
so only the claim changes.
@JingMatrix
JingMatrix force-pushed the manager-aidl-namespace branch from 3833ad0 to 6057399 Compare August 4, 2026 03:25
The daemon carried NeoZygisk's version floors — 26402 for Magisk, 10762 for APatch — and
answered ROOT_TOO_OLD below them, which the update screen rendered as "too old to flash
through".

That was never the daemon's decision to make. Whether the zygisk loader runs on this
device is settled before the daemon exists; a daemon that is answering at all has already
passed whatever check the loader applies. Duplicating the floor here only created a second
opinion that could disagree with the first, and it had to be kept in step with a number
living in another project. The KernelSU branch already said as much: it applies no floor,
because `ksud -V` prints a build hash and there is nothing to compare — and that was
sound, not a gap.

So detection reports what it found and quotes the version back for the user to read.
ROOT_TOO_OLD goes, with the string and the demo scenario that rendered it, and the
remaining ROOT_* values close up behind it. ROOT_UNKNOWN keeps 0, which it holds because
0 is what a binder proxy returns for a transaction the daemon does not implement.

The prose loses its running commentary on NeoZygisk's internals along the way. What is
worth saying is why detection goes by binary — the binary has to exist to do the flashing
anyway — not how a different project reaches the same answer.
@JingMatrix
JingMatrix merged commit de6e250 into master Aug 4, 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.

1 participant