Skip to content

Upstream - #18

Open
superturtlee wants to merge 64 commits into
superturtlee:upstreamfrom
JingMatrix:master
Open

Upstream #18
superturtlee wants to merge 64 commits into
superturtlee:upstreamfrom
JingMatrix:master

Conversation

@superturtlee

Copy link
Copy Markdown
Owner

No description provided.

JingMatrixand others added 27 commits July 25, 2026 06:32
Gradle 9.6 removed the internal API that AGP 8.13 relied on, forcing the
AGP 9 upgrade and the surrounding build-script changes.
Toolchain:
- Gradle wrapper 9.3.1 -> 9.6.1; AGP 8.13.1 -> 9.3.1; apksig follows AGP.
- Adopt AGP 9 built-in Kotlin: remove org.jetbrains.kotlin.android from all
Android modules; Kotlin stdlib 2.3.10 -> 2.4.10.
- compileSdk/targetSdk 36 -> 37, build-tools 37.0.0.
Build-script migration:
- Root build configures the shared CommonExtension through its getters, since
AGP 9 dropped the action-DSL methods on that type.
- daemon generates SignInfo through androidComponents.onVariants and a typed
task; android.applicationVariants was removed.
Resource generators (replace the rikka autoResConfig / materialthemebuilder
plugins, whose entry points use removed AGP variant APIs):
- buildSrc/GenerateLangListTask scans the translated locales.
- buildSrc/GenerateMaterialThemeTask computes the accent-color theme overlays,
reusing the materialthemebuilder color library without applying its plugin.
Drop android.nonFinalResIds=false:
AGP 9 enables optimized resource shrinking by default, and that shrinker
requires non-final resource IDs. With isShrinkResources=true the manager's
release build now fails :app:minifyReleaseWithR8 with "Optimized resource
shrinking requires non-final IDs". AGP offers two remedies: make the IDs
non-final, or opt out of optimized shrinking (r8.optimizedResourceShrinking=
false). We take the former, because the false setting turns out to be dead
weight:
- It was added in 348f049 (Aug 2023), an unrelated "show packagename" feature
commit, as a one-line drop-in beside the now-removed experimental flags
enableAppCompileTimeRClass / enableNewResourceShrinker.preciseShrinking. Those
siblings were cleaned up later; this line was simply missed.
- Final IDs are only actually required to use R.* as Java switch/case labels.
There are zero `case R.*` occurrences in the tree at 348f049 and at every
commit since, so the flag never protected anything here.
- Non-final IDs are the modern AGP default, so removing the line (rather than
writing =true) expresses the intent with no config at all.
It also builds smaller: the optimized shrinker trims the release APK from
~3.45 MB to ~3.13 MB (~9%). Verified end to end -- assembleRelease plus a
zygisk installKsuAndReboot run that loads the module and starts lspd on device.
Formatting task:
- Add buildSrc/src/main/kotlin to the format task so the generator sources are
formatted with the rest of the Kotlin build logic.
- Exclude daemon/**, which is intentionally kept on ktfmt's default (Meta)
style; formatting it here fought :daemon:ktfmtFormat and flipped the style
back and forth.
Dependencies:
- AGP/apksig 9.3.1, Kotlin 2.4.10, androidx.core 1.19.0 (dependabot maven group).
- coroutines 1.11.0, okhttp 5.4.0, gson 2.14.0, nav 2.9.8, glide 5.0.9,
androidx activity/browser/annotation, ktfmt 0.26.0.
- Material kept at 1.12.0; 1.13+ removes the colorPrimary/colorError attrs the
manager references.
- Submodules fmt and commons-lang bumped; CI action versions bumped
(actions/checkout 6 -> 7, actions/cache 5 -> 6).
Android 17 (API 37) reshaped the IServiceConnection callback and dropped the
old overload instead of keeping both:
void connected(in ComponentName name, IBinder service,
in @nullable IBinderSession session, boolean dead);
ManagerGuard overrode only the three-argument form, so as soon as system_server
dispatched the new transaction the Stub landed on an abstract method and the
daemon died with AbstractMethodError, taking the manager session down with it.
Only the Xiaomi XSpace workaround binds this connection, which is why the crash
was reported on HyperOS first; the interface change itself ships in stock
Android 17 and is not vendor specific.
Declare both overloads in the IServiceConnection stub and override both in
ManagerGuard, so every supported release finds the method system_server
dispatches. IBinderSession is stubbed as an empty interface, since the type is
only referenced by the descriptor of the new overload.
Brings the framework in line with the API version master vendors, checked
throughout with test modules that assert the documented behaviour and log
pass/fail, on a Pixel 6 running Android 17.
hookClassInitializer never worked: it aborted the process, and once that was
fixed the hook still could not fire, because resolving <clinit> through JNI
runs the initialiser during the lookup. It finds the method from ART's layout
now — the gap the reflected members leave behind — and the hooker runs ahead of
the class's own initialiser.
Also fixed: the interceptor chain could resurrect an exception it had already
suppressed; ExceptionMode.DEFAULT ignored module.prop, so passthrough was
unreachable; getInvoker() threw NPE on an unhooked method; Constructor
.newInstance was hookable; an unhooked constructor dispatched Method.invoke's
id against itself; getArgs() was mutable; a late-injected system server
dispatched onSystemServerStarting into an empty module set; android:ui was
reported to modules as the system package; edit().clear() did nothing, and
preference updates reached every Android user's hooked processes; empty scope
requests never called back; openRemoteFile threw RemoteException where
FileNotFoundException is documented; getScope() repeated a package once per
user; module.prop was not parsed as Properties.
In the manager, one malformed module.prop could blank the entire module list.
The list now shows which API each module targets, and staticScope is enforced
rather than parsed and ignored — in the picker, in the daemon, and by dropping
stale rows at startup.
Two changes modules will notice:
- Invoker.invoke reports the target's exception wrapped in
InvocationTargetException on every path, as Method#invoke does. A module
catching the raw exception will stop catching it.
- A module declaring staticScope loses scope entries outside its scope.list.
The <clinit> lookup is measured on one device, one Android version and one
architecture. Its assumptions are re-checked at runtime and it declines rather
than guesses, so an unfamiliar layout degrades to "no static initializer"
rather than misbehaving.
Since #648 the `ServiceManager.addService` call that claims the `serial` name
sat inside the `SDK_INT >= R` branch that exists only for
`registerForNotifications`, so on pre-R nothing claimed it and the Zygisk module
aborted the injection. Keep only `registerForNotifications` behind the check.
`startActivityAsUserWithFeature` is also R-only, and both call sites used it
unconditionally; the `NoSuchMethodError` escaped `onTransact` and killed the
daemon. Route them through `startActivityAsUserCompat`, like
`registerReceiverCompat`.
Closes#773.
An Actions artifact cannot be downloaded without a GitHub account: measured
against this repository, `GET /actions/artifacts/<id>/zip` answers 401 to an
anonymous caller while a release asset answers 206. Testing a canary is the
lowest-friction way for an ordinary user to help, and asking each of them to grant
an OAuth app something first — to work around where the zips happen to live — is a
real cost for a project whose users are careful about what they install. It also
excludes the users who cannot reach GitHub's login page at all, who are exactly
the ones a canary programme loses first.
Each push to master, and each manual run, now attaches the same two zips to a
`canary-<versionCode>` prerelease. Prerelease, so `releases/latest` — which is what
update checks read — keeps pointing at the last stable tag. Artifacts stay: they
also carry mappings and symbols, which are for us rather than for testers.
Five are kept. They are pruned by version code rather than by date, because the
version code is the commit count and therefore monotonic, while dates can be
disordered by a rerun or a revert. The release is deleted and recreated rather
than edited, so re-running the workflow for a commit replaces that build instead
of appending a second copy of every asset to it.
The job gains `contents: write`, which it did not have; everything else in it only
reads.
The parasitic manager lives inside com.android.shell, which has no INTERNET
permission before Android 12, so preAppSpecialize appends the INET group to its
gid array. That satisfies setgroups() and nothing else: once
nativeForkAndSpecialize returns, Zygote#forkAndSpecialize runs
setAllowNetworkingForProcess(containsInetGid(gids)) against the array it passed
in, not the one we substituted, and turns networking off in libnetd_client.
socket() and dns_open_proxy() then return EPERM whatever groups we belong to,
which libcore reports as "Permission denied (missing INTERNET permission?)" —
an unchecked SecurityException that killed the OkHttp dispatcher and the manager
with it, and left the Repository tab empty.
So overwrite the first entry of the caller's array too; it has no further use
once specialization is done. LSPosed did this from the commit that introduced
the parasitic manager, and the line was dropped in the rewrite for the new
Zygisk architecture. Android 12 and later were never affected: com.android.shell
declares INTERNET there, so the array already carries the group.
Fixes#636
`:app` is deleted, not deprecated — 218 files, and no longer in
settings.gradle.kts. `:manager` takes its place: 86 Kotlin files, Material 3,
one activity, because parasitically every activity has to be tracked by hand by
the zygisk hooker. Launching it has not changed: still injected into
com.android.shell, still reached through Constants.setBinder.
Most of the branch is screens the old manager did not have. The framework
updates from inside the app, canaries included. Home shows the project's commit
history rather than a status light. The log screen indexes byte offsets and
pages a window instead of loading four megabytes of strings into a process
whose heap belongs to com.android.shell, and reaches the daemon's rotated
parts. The Store runs off the mirrors that still answer. The scope editor
collects edits into a draft and writes them once, where the old one rewrote a
module's whole scope on every checkbox tap. System status states what is
running and copies itself in English whatever the phone's language.
Daemon-side, less: ModuleDatabase owns every configuration read and ConfigCache
holds no SQL, so "enable this module" and "what is enabled" agree rather than
race a rebuild. The AIDL gained the transactions the new screens need, and
ROOT_UNKNOWN takes 0 — a binder proxy answers an unimplemented transaction with
a default, so ROOT_NONE at 0 meant an old daemon told a rooted user to install
the root manager they were already running. The two ship in one zip.
Eighteen languages, crowdin.yml repointed from app/ to manager/, right-to-left
included.
No automated tests, because the repository has none, and CI runs `zipAll`. It
was verified on a device, screen by screen, checking each claim against what
the daemon had actually stored. That found real bugs late — a comment asserting
the daemon force-stops apps on a scope write, which it has never done; a filter
that hid the rows a reader had chosen. Expect a few more.
OkHttp 5's Android artifact keeps the public suffix list in assets/, reached
through a process-static Context set from androidx.startup. Parasitically the
manager's manifest is never installed, so that provider never runs and
DnsOverHttps -- which asks the list whether a host is private before opening
any socket -- died with "Unable to load PublicSuffixDatabase.list" on the first
lookup. Coil was already hand-initialised for this reason; OkHttp was not.
Two things made that fatal rather than degrading: VectorDns caught only
UnknownHostException, so an IllegalStateException escaped it and the fallback
never engaged; and canaryBuilds and frameworkReleases called get() unguarded,
with no CoroutineExceptionHandler anywhere in the manager, so the throw landed
on the main thread.
Coil's own initialisation then moves out of MainActivity to
ServiceLocator.attach, for the same reason OkHttp's did: the debug demo host
never opens MainActivity, so it had no image loader at all.
Separately, for diagnosis: off, bypassed by a proxy, and latched onto the
system resolver all looked identical from the sheet, so VectorDns now records
what each lookup did and the sheet renders it, with a way to clear the latch.
Two fixes fell out:
- `direct` was a lazy val, so the proxy check ran once per process and joining
a VPN mid-session was invisible. Read per lookup now.
- The failure line named the host being resolved, which UnknownHostException
already carries as its whole message -- so it printed twice while which way
it failed went missing.
Finally, a crash record is discarded when another build wrote it. A record
outlives the build that made it, so the status card kept showing a crash the
running build had already fixed -- which is what #799 was answered with: five
traces from the build before the one that fixed them.
Fixes#799
Every row on PackageActionSheet dismissed the sheet and then launched its
work on rememberCoroutineScope(), so the composition left on the next frame
and took the scope with it — the coroutine died at the first withContext hop
inside DaemonClient, usually before the binder transaction was made. Nothing
logged, onResult never ran, and whether the work beat the frame was a race,
which is what made it look flaky. The actions now run on
ServiceLocator.appScope with Dispatchers.Main. App info, force stop,
re-optimize, uninstall and soft reboot were all affected; the Scope screen's
companion button was not, since it goes through viewModelScope.
The sheet also did not read as one list. A Material list item paints its
container surface while a sheet is drawn on surfaceContainerLow, so every
list item on a sheet now takes the transparent sheetRowColors — the mute
switch here, and the log settings, batch update, asset picker and framework
versions sheets. The mute switch was the only row built from the generic
ToggleRow, so it gains the sheet's own shape through ActionRowLayout and
keeps announcing itself as a switch; uninstall drops DeleteOutline for
Delete to match the filled glyphs around it; and "not in the store" no
longer ripples under a thumb.
Fixes#810
Four unrelated fixes.
The mapping and symbols artifacts have pointed at paths that stopped existing
in #796, so both uploaded empty.
check_translations.py had no caller; it runs in CI now and catches bare %s and
%d, which fixes three defects it found.
zygisk/update.json named a zip the release does not attach, so the v2.0 update
has always failed to install. Old repository paths go with it.
And the daemon translations: 18 machine-translated product names, a handful of
real defects, and 39 locales saying "Xposed module" where we say "module".
On a Huawei MatePad 11 the manager never opened. A LoadedApk for the host package
is already cached by the time bindApplication arrives, so
ActivityThread#getPackageInfo returns that instance and never looks at the
ApplicationInfo we just swapped in. Its one repair path, updateApplicationInfo, is
gated by isLoadedApkResourceDirsUpToDate, which compares nothing but the resource
and overlay directories -- and getManagerPkgInfo copies both from the host, so
sourceDir is never picked up. The process then runs with mResDir on the stock
Shell.apk, and because its mApplicationInfo is a different object than ours, the
identity check in the getClassLoader hook skips the DEX injection and MainActivity
cannot be found.
Dropping the entry from mPackages and mResourcePackages forces a fresh LoadedApk,
built from our own ApplicationInfo, which the identity check then accepts. That
check is left as it is: matching on the package name instead, as this patch first
did, would also match the stale LoadedApk and the resource-only one out of
mResourcePackages. A freshly forked process has an empty cache, so the eviction is
a no-op everywhere else, and the warning it logs when it does remove something is
the only evidence that the pre-warming is real.
Verified by the reporter on the affected tablet, and on a device without it.
A legacy module reports being active by hooking a method in its own app, so it
has to be in its own scope before it can say anything at all. The View-era
manager added that row on every save and hid it again on read; #796 dropped
both halves, and every legacy module has reported itself inactive since.
ConfigCache derives that scope during its rebuild rather than storing it, so
configurations written by those builds need no repair and nothing that replaces
the scope table can drop it again. Legacy is the loader's own verdict, so a
module built against API 101 keeps its own process to itself.
The scope screen shows the derived row: ticked, exempt from every filter,
grouped with what is in force, and closed to the toggle and to both bulk
actions. It never enters the draft, so an apply can neither write nor delete a
row the scope table does not own.
Fixes#816.
Android 17 refuses every reflective write to a static final field: `Field_set`
calls `ThrowIAEIfFieldIsNotOverwritable` before it looks at the accessible flag,
and `ArtField::IsUnmodifiable` lets one through only for a process targeting SDK
36 or lower. Clearing the reflective copy's ACC_FINAL does not help, an
unreflected VarHandle is read-only, and Android's `Unsafe` has no static field
accessors -- which left `XposedHelpers.setStatic*Field` dead for every legacy
module on 17, `android.os.Build` spoofing included.
`HookBridge.makeFieldWritable` clears ACC_FINAL where the check reads it and the
setters retry through reflection, so the value, the conversions and the
exceptions stay reflection's. ART's own JNI `SetStatic*Field` is deliberately not
taken: `EnsureModifiable` is `LOG(FATAL)` for a field it holds unmodifiable, so a
write outside the `android.os.Build` carve-out aborts the process. The ArtField's
access flags are checked against `Field.getModifiers()` before anything is
written, so a runtime that lays them out differently is left alone and the caller
keeps the `IllegalAccessError` it already had.
…om (#809)
Flash Vector, open it from the root manager, close it, and there is no way back
in. The dialer code registered a filter with no action, which matches nothing, so
that branch has been dead since #597; it works again and is rebound to 832867,
VECTOR on the keypad. Parasitically the manager is not installed, so the launcher
has nothing to show either: the pinned shortcut and the standalone install that
#796 dropped return, in an "Opening Vector" section and a first-launch prompt,
with `getManagerApk()` handing over the APK the host cannot read. Sixteen new
strings, translated into all eighteen languages.
The version string now names where a build came from rather than calling every
canary "dirty", which it did only because the workflow writes signing credentials
into the tracked `gradle.properties`. Repository and commit both come from the
pull request's head, since GitHub's defaults describe the run and not the code.
Closes#815.
#809 gave the stamp a second half and left `divergesFrom` comparing the whole
string. A canary reports `JingMatrix-Vector-93d66473`, no release SHA starts
with that, so every canary was called "same number, other build" against the
release it had just been flashed from, and no row was ever marked installed.
The `-dirty` test it still carried had become unreachable in the same commit.
The stamp now leads with the commit, `93d66473-JingMatrix-Vector`, so reading it
back is a prefix and nothing more. A modified tree is marked `+` rather than
`-`, in semver's sense of build metadata: after a `-` is a repository holding
this exact commit, after a `+` are changes no repository holds. `buildStamp`
takes one apart and `isCommit` compares as a prefix in either direction, since a
stamp carries git's short form and a release the full SHA. Where a build was
made is not compared — a fork at the same commit builds the same code. A stamp
naming no commit, including the shape published between #809 and here, reads as
"I cannot tell" rather than as divergence.
On the status page the commit keeps the size it is read at and the rest is set
smaller and muted; copying the page still yields the whole stamp. In the
versions sheet the status has a width of its own, so the clause on a divergent
row no longer takes the room the build's name needs and wraps that row alone.
Refuse to hook Object.getClass
R8 compiles Kotlin's parameter null checks into `obj.getClass()`, and one of them is
the first instruction of `VectorNativeHooker.callback`. The dispatch therefore calls
`Object.getClass` entering every hooked method, and a module that hooks it re-enters
the dispatch from its own prologue until the stack is gone, before any hooker runs.
There is no recovery: lsplant marks a hooked method non-compilable, the framework dex
never gets an oat file, and `Throwable.toString` calls `getClass`, so reporting the
StackOverflowError raises another one.
It is refused at both registration paths, alongside `Method.invoke` and
`Constructor.newInstance`, which are refused for the same reason. The
`IllegalArgumentException` costs the module that one hook and nothing else.
AGP 9 made this certain rather than possible: from the same source the Release
framework dex holds 44 `Object.getClass` call sites at 3043 and 246 at 3048, and the
one in `callback` moved from a cold branch to instruction zero. The hook was already
fatal -- KiminonawaResa/HyperLight#193 is the same launcher loop on 3044. What
changed on the module side is libxposed API 101, which offers no `hookAll*` helper;
the module that hit this walks `Class#getMethods()`, which always lists the
`getClass` inherited from `Object`. The legacy helpers walk `getDeclaredMethods()`
and cannot reach it.
Verified on a Pixel 7a with a module transcribed from the decompiled one: on master
the target dies and restarts with `failed to complete startup`; here the refusal is
logged and the process stays up.
The messages for the other blocked hooks -- abstract methods, framework-internal
methods, `Method.invoke`, `Constructor.newInstance` -- now say why rather than only
that it failed.
Fixes#798.
Three of these are what borrowing `com.android.shell`'s uid costs the manager.
Below API 33 `ContextCompat.registerReceiver` demands
`<package>.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION`, which under the host is
nobody's, so every store install threw before `commit()`; both installers
register by hand now, under a UUID-named action. `createSessionInternal` skips
`INSTALL_REPLACE_EXISTING` for `SHELL_UID`, so a module already on the device
failed with `ALREADY_EXISTS` -- that branch is unchanged from API 27 to AOSP
main, so a store update has never worked parasitically. And `AwSettings` reads
`checkSelfPermission(INTERNET)` in its constructor, which AOSP's Shell did not
request until android-12, so every in-app page failed as `ERR_CACHE_MISS` on a
device whose networking is fine; the context a WebView is built with now answers
that one question, and only where the platform says no.
The fourth is that an offer is decided by numbers a git tag states, which nothing
obliges to be the ones in the APK's manifest, and no rule over `(code, name)` can
bridge that: a module that never changes its tag code is only ever seen to update
through the name clause, and one that reuses a versionName across several codes
only through the code clause. So the Store stops inferring and records instead --
the release it installed, and the version the device reported once it was on --
read in `StoreEntry.upgradable` so every count, filter and badge agrees. Where an
offer names the version already installed it is worded as a reinstall rather than
as an update from a version to itself, which is true whether the tag disagrees
with the manifest or the release is a genuine rebuild. All eighteen locales are
filled in.
Fixes#823.
)
A module loaded into system_server cannot dlopen a library out of its own
APK. Everything under /data/app is apk_data_file, and while system_server
may read and map such a file it may not execute it, which AOSP states
outright and forbids granting: "Executable files in /data are a
persistence vector". Every app domain does hold that permission, so the
same module loads the same library without trouble in an ordinary process
and fails only here, with the linker refusing the first PROT_EXEC mapping:
dlopen failed: couldn't map ".../base.apk!/lib/arm64-v8a/libhmahook.so"
segment 2: Permission denied
The way past it is not a new rule but the one this module already ships.
xposed_data is a type we declare ourselves, outside the data_file_type
attribute that neverallow is written against, and the existing
`allow * xposed_data {file dir} *` already reaches every domain,
system_server included. So the daemon now copies a system_server-bound
module's libraries into the misc directory it already owns and labels,
and hands the loader that directory to search first. Nothing is staged
for any other process: they can execute straight out of /data/app, and
staging for them would buy nothing but disk.
The copy is keyed to the APK's size, mtime and the framework version, so
an updated module is re-extracted rather than left running superseded
native code, and directories belonging to modules that are no longer
bound for system_server are dropped.
Staging deliberately ignores moduleLibraryNames: that list only names the
libraries whose native_init we are asked to call, and a module may load
its own libraries without declaring any -- the module that prompted this
does exactly that.
While here, register the declared native entrypoints before the entry
classes run instead of after. A module may load its libraries from its
constructor or from onModuleLoaded, and an entrypoint recorded afterwards
is one the dlopen hook has already missed. The legacy loader has always
done it in this order.
…ped (#832)
Defects across the daemon and the manager sharing one cause: the daemon writes or
announces something, and the manager either never hears it or overwrites it.
Scope
- An empty static scope no longer fixes the scope at "no apps at all". readStaticScope
answered an empty set, so setModuleScope refused every write and pruneScopeToClaimed
deleted the module's rows on each cache rebuild.
- A static scope fixes which apps may be listed, not which of them are chosen. The rows
are editable and any subset is stored, which the daemon already accepted.
- The editor sends the user's diff applied to a fresh read rather than a whole-set
replace, and re-reads on resume, so a scope approved elsewhere is no longer deleted.
- A row unticked in the list stays in the list.
Notifications
- One scope prompt per requested package, each independently answerable, bounded at
sixteen unanswered per module. Previously they shared a tag and replaced one another,
so only the last was ever answerable and the rest were never answered at all.
- The delete intent and one-hour timeout dropped in 714e6c8 are restored, so a
dismissed prompt answers the module instead of leaving it waiting indefinitely.
- "Never ask again" withdraws the module's other prompts, and uninstalling undoes it.
- The "not activated yet" notice is cancelled once the module is activated.
Manager
- module:// launch intents open that module's scope editor again, lost in #796, and a
rebuilt activity no longer replays them.
- Package events in other users reach the manager, via the daemon's own broadcast.
- The "no way back in" prompt waits until the answer is known.
- A framework flash survives leaving the screen; a download can still be abandoned.
Source patterns. Within a path node the CLI compiles `*` to `.+` and applies the
result as an anchored match, so a `*` must consume at least one character. A
pattern of the form `strings*.xml` therefore selects only names that carry
something between the stem and the extension, never the bare `strings.xml`. A
file that no pattern selects never enters the candidate set, so the upload
neither includes it nor reports it as missing: the failure is silent by
construction. Neither `**` nor `?` closes the gap — `**` is special only as a
whole path node, and `?` adds a further required character. Naming each source
file is the only formulation without such a hole.
Locale placeholders. %android_code% is region-qualified for every language,
whereas an Android resource folder carries a region only where one is needed to
disambiguate a variant. Aligning folders with it would require one mapping entry
per language to strip the region back off — an obligation that grows with every
language added and degrades silently when forgotten, since translation upload
derives one expected path per target language and skips the ones it cannot find.
%two_letters_code% inverts the default: it already agrees with the unqualified
folders, so only the genuinely region-qualified locales, plus the codes Android
retained after the ISO renames, need an entry. Those are now written down in the
repository rather than left implicit in project state that no checkout can see.
The mapping is repeated per file because it is a file-level key; a YAML anchor
keeps the four entries identical by construction.
The `translation` value doubles as the file's server-side export pattern and is
expanded there, where the local mapping does not apply, so the same overrides
must exist in the project's language settings for downloads to resolve. Nothing
in CI observes this, as it does not download translations.
The workflow trigger now names the same files instead of globbing them. Its
matching rules and the CLI's are defined independently, and a trigger that
quietly never fires is the worse failure of the two.
Two unrelated fixes.
A process no module has in scope produced three log lines about it, and one
called the daemon's ordinary refusal a failure. The duplicate in
`ipc_bridge.cpp` goes; the two in `module.cpp` now say what happened. The
wording stops short of "out of scope" because the zygisk side cannot tell:
`BridgeService.onTransact` also returns false when the daemon binder has not
arrived yet, or when the process is already registered. The daemon logs the
reason itself.
`sendToBridge` leaves the main thread at euid 1000, and the statement after it
read the verbose-log preference out of the config database, which sits under a
directory only root can enter. That normally works because a binder thread opens
and caches the handle during specialization first, but only when the injection
succeeded. When it failed nothing had, and the daemon died on the preference
read instead of carrying on — the second half of the crash in #744 and #773.
Reading it before `sendToBridge` leaves the check where it was and does the open
while we still have root.
android.util.Log drops a throwable's trace whenever an
UnknownHostException is anywhere in its cause chain, so our DoH, store
and GitHub failures logged a message and nothing else. Traces are
formatted here now and appended to the message, on the unfiltered
Log.println, for the manager, the daemon and the legacy bridge.
The log panel then lost half of each one by guessing continuations from
indentation. It goes by the writer instead: logcat.cpp puts the prefix
in the first iovec, so an unprefixed line under an entry continues it,
bar the four the daemon writes raw. The one exception rejoins a message
printlns cut in two, on tag, process, thread, level and adjacency.
One renderer draws every trace -- our frames marked, the platform's
dimmed, Caused by as a divider, each frame tappable to copy. The crash
card states what threw, what it said, the nearest frame that is ours and
when, with the trace a tap away.
Sixteen new strings across eighteen locales; fifteen dead imports gone.
GrapheneOS ships a "Restrict dynamic code loading" exploit-protection setting
that is immutable and enabled for system apps. The parasitic manager runs
inside com.android.shell, a system app, so GrapheneOS forbids it from loading
the manager's DEX and the manager never starts.
GrapheneOS enforces DCL through two channels, both fed by the same per-app
verdict, and each has to be cleared at that shared source rather than at the
symptom:
- The ART DexFile checks (DynCodeLoading.getAppBindFlags) reject the
manager's transplanted /proc/self/fd DEX as "DCL via storage".
- The kernel grapheneos_flags written at zygote specialize keep
DENY_EXECMEM/DENY_EXECMOD set, so LSPlant/Dobby cannot make its inline-hook
trampoline executable and the process takes a SIGSEGV
(TSEC_FLAG_DENY_EXECMEM: op denied). The manager's isolated WebView process
likewise keeps DENY_EXECMEM, disabling Chromium's JIT.
getAppBindFlags and SELinuxFlags.{get,getForWebViewProcess} all read
AswRestrict{Memory,Storage,WebView}DynCodeLoading.get(), which honours a
non-null getImmutableValue. So the hook is on getImmutableValue for all three
switches in system_server, returning false (allowed) for the single host
package and deferring to GrapheneOS's original verdict for every other app. A
non-null result takes precedence over both the user toggle and the default, so
this forces the setting to allowed regardless of the user's configuration.
The manager host ends up an ordinary DCL-allowed app: the DexFile checks are
off and the exec* SELinux flags cleared, while ptrace denial, hardened_malloc
and MTE stay intact. GrapheneOS is detected by the classes being present, so
the hook is a no-op on every other system; verified against the GrapheneOS 14,
16 and 17 branches.
The scope is intentionally narrow: only com.android.shell is affected, and
only in system_server, where the value is computed. Modules are excluded --
a normal app already exposes a user-configurable DCL toggle on GrapheneOS and
can be allowed without a patch.
Supersedes #711 with a smaller footprint: the GRAPHENE_SETTINGS_PACKAGE_NAME
build variable is dropped and the hook resides in its own class. The technique
was originally developed by @Enovale in #711 and in discussion #340.
Co-authored-by: Enovale <17408285+Enovale@users.noreply.github.com>
Long-press the navigation container to enter edit mode: drag panels to
reorder, tap a badge to hide or restore one. Hidden panels stay
registered as destinations, since a saved back stack may still name
them; only the container stops drawing them. One panel always remains
visible. Works on both the bottom bar and the rail.
Add an appearance-sheet option, off by default, that replaces the bar
with a draggable ball. The navigation suite type becomes None, so the
container is never laid out and the strip it costs every screen returns
as content. Long-press the ball to fan the panels into an arc and
release on one; a plain tap latches the arc open so each panel is an
ordinary target, which is the path a screen reader can use. Drawn inside
the app window, never a system overlay -- the parasitic manager runs as
com.android.shell and must not request SYSTEM_ALERT_WINDOW.
The arrangement persists as one ordered string of route keys, hidden
ones flagged, tolerant of unknown or duplicate entries. The ball's edge
and height persist likewise; ball and arc are positioned in absolute
window coordinates so both render correctly under RTL.
Sixteen strings across eighteen locales.
The GitHub sign-in came from a `githubClientId` Gradle property set nowhere
-- not in gradle.properties, not in CI -- so every build compiled
GITHUB_CLIENT_ID as "". isConfigured was therefore always false: the card
returned before drawing anything, signIn() short-circuited to Unavailable,
and tokenProvider() always returned null, so no request ever carried an
Authorization header. It bought only the rate limit, 60 requests an hour
anonymous against 5000 signed in, and nothing depends on having it: the
canary screen sources its zips from release assets precisely so that testing
a build needs no account.
Deleted rather than given a client id, because the flow also has nowhere
good to keep a token. Parasitically the manager's SharedPreferences land in
com.android.shell's data directory, whose shared_prefs is world-executable,
so a token would sit outside our own sandbox. That is worth solving before
offering sign-in again, not alongside it.
The rest is what #796 left unreferenced. buildSrc held two Gradle task
classes no build script registers and whose outputs no longer exist -- the
locale list comes from BuildConfig.TRANSLATIONS now. The version catalog
still pinned the whole View stack: 26 aliases, the safeargs plugin, and the
nav, glide and appcenter versions. hiddenapi/stubs carried 20 files nothing
compiles against, with four HiddenApiBridge wrappers and the stub members
that existed only to type them. In :manager the four @serializable models
for the Actions runs and artifacts API became unreachable when eb07955
moved canary sourcing to release prereleases, and nine symbols --
relativeTime, levelLabel, sectionHeaderStyle, SeedScheme.parseHex,
VectorLightColors, VectorDarkColors, ModulesViewModel.selectAll,
LogPaneState.atOldest and GOOD_FIRST_ISSUE_URL -- have no call site at all.
Seventeen strings go with them, translations deleted alongside their source
so the next Crowdin pull does not add them back. Daemon-side,
CliHandler.isPackageInstalled, Utils.getZoneId, Utils.isLENOVO and :xposed's
trackedApks have no caller; the live tracker is LoadedApkTracker.activeApks.
One deletion ships behaviour. Before #550 the loader baked the raw
githubusercontent URL of magisk-loader/update/zygisk.json into every
module.prop, so Magisk on a pre-v2.0 install polls it and will now get a
404. Effectively everyone is on v2.0 by now, and zygisk/update.json -- what
module.prop has pointed at since -- is untouched.
…841)
The fmt and commons-lang submodules move to the tip of their upstream
branches, core-splashscreen to 1.2.0, navigation3 to 1.1.5 and material3 to
1.5.0-alpha25, which is everything in the catalog that was behind. Both
libxposed submodules stay where they are, since their only new commit is the
API 102 RFC and that belongs with the API work. In CI, ninja moves to 1.13.2
and crowdin.yml's floating action refs are pinned to majors like everywhere
else.
The NDK pin moves from 29.0.13113456, which is r29 beta 1, to r29 stable at
29.0.14206865. Its clang 21 rejects the lsplant x86 build over duplicate
<emmintrin.h> definitions arriving through two module fragments, so phmap's
SSE2 group scan is switched off -- for every native module, because the flag
changes phmap's layout and hook_bridge.cpp instantiates the same templates.
The rest is the seventy or so lines a build printed when nothing was wrong.
:hiddenapi:stubs no longer overrides its Java version to 8. Thirty-three
Kotlin deprecations in the manager are migrated: ListItem's headline becomes
a trailing content lambda, TabRow becomes PrimaryTabRow,
rememberModalBottomSheetState becomes rememberBottomSheetState, and eight
icons move to their auto-mirrored versions; the two reads with no
replacement are suppressed in place. Vendored and generated Java, along with
:legacy, compile with javac's notes off, and lsplant's string literal
operator template warning is silenced at its target. The `by extra` delegate
and AGP's srcDirs are rewritten, and the apksign plugin is replaced by the
signing configuration it was applying, since its last release still calls
the deprecated Project.getProperties.
Underneath that, lint was reporting calls the minimum cannot make. The
manager and the daemon both set API 27 and both reached for later methods,
which on 8.1 is a NoSuchMethodError rather than an opinion:
getLongVersionCode is API 28, FileObserver's multi-file constructor is 29,
and LocalServerSocket only implements Closeable from 28. Each is now gated
on the release that introduced it, checked against the platform's own
api-versions.xml, and two constants newer than the minimum are spelled out
rather than referenced. NewApi and InlinedApi findings go from twelve to
none, on both variants.
Signing deserved a second look with the plugin gone, and was verified both
ways: with a keystore the manager APK and the certificate compiled into
SignInfo.kt are the same one, and without, both fall back to the debug key
as before. A clean zipAll over debug and release now prints only its task
list, and the release zip runs on a Pixel 6 on Android 17.
Modules loaded but none of their hooks landed in release builds, while debug
builds were fine (#847, #848).
Some framework types extend super classes that no dex contains: they are
generated on the device, so they can inherit from whichever platform classes it
provides. Such a type cannot be resolved until that has happened, and a failed
resolution is permanent, so the fragility is transitive — every class naming one
acquires it.
R8 spread it beyond what the source shows. Each lambda becomes a class, and
classes of the same shape are merged afterwards, so `XResources`' two lambdas
put it inside the shared `Function` synthetic — the one commons-lang's
`ClassUtilsX` instantiates from its static initialiser, which
`XposedHelpers.findClass` calls. Every `findClass` in system_server failed for
the rest of the boot.
Both lambdas are now written out long-hand. Keep rules cannot state this
invariant, since they govern the classes you write rather than the ones an
optimiser invents, so `checkXResourcesIsolationRelease` reads the optimised dex,
resolves names through the mapping file, and fails the build if any class
outside resource hooking comes to name one of these types.
Saved bug reports also get one name, built in one place instead of three. Two
archives attached to the same report used to be indistinguishable until opened,
so the name now says the build type, and each format records the commit where it
can — the log zip in its comment, the module backup in a field of its own. The
version code is the commit count on master, so branch builds wear numbers they
were never built from.
JNI has a rule that is easy to forget: when a Java method throws, the exception
does not become a C++ exception. It stays pending on the thread, and the runtime
is entitled to abort at the next transition rather than let you carry on. So
every call into Java, and every lookup that can fail, owes an answer to "did
that throw" — and six files never asked.
The worst of them is `FindAndCall`, which hands a whole process to the
framework's Java entry and then inspected nothing. A throwing entry left that
process without Xposed, while the line printed immediately afterwards said the
framework had been injected. It now reports the failure and returns whether the
call arrived, and both callers say which happened. The rest are smaller versions
of the same thing: two lookups in `resources_hook` returned `JNI_FALSE` to Java
with `NoSuchMethodError` still pending, so a caller that asked for a boolean got
a throw instead; `RegisterNatives`, `LogcatMonitor`'s `refreshFd` lookup and
`dex2oat`'s string read did the same on their failure paths; and the obfuscation
map builder returned null on a failed `FindClass` without clearing, then fed two
unchecked method ids to `NewObject`.
Most of this is not new code but the lsplant wrappers we already have. They
clear the exception, log the Java stack behind it, and hand back scoped
references — which incidentally disposes of a local reference the obfuscation
map leaked per entry. An explicit check survives only where the caller has to
know what happened, because a wrapper clears the exception before anyone can
ask.
Two decisions worth recording. The stack is rendered with
`Log.getStackTraceString` rather than `ExceptionDescribe`, because the latter
writes to stderr and a process forked from the zygote has nowhere for stderr to
go — the trace would simply vanish. And `SetAllowUnload(false)` stays
unconditional: the ART and JNI hooks are installed before the entry runs and
their trampolines point into this library, so a failed entry is no reason to let
it be unloaded.
`hook_bridge` is deliberately untouched. It implements `Method.invoke` semantics
and has to leave a target's exception pending so it can wrap it in
`InvocationTargetException`.
Scaffold places its bottom slot against the bottom of the window and reserves
nothing for it -- the documentation is explicit that topBar and bottomBar are
expected to handle insets themselves, which is why NavigationBar and
BottomAppBar carry windowInsets of their own. Three of this app's four docked
bars are plain Surfaces and reserved nothing, so their contents were drawn
under the navigation bar.
It bites on every detail screen whatever the navigation style, because the
navigation container is hidden away from a panel root and
NavigationSuiteScaffold consumes NoWindowInsets while hidden: nothing above the
screen has taken the system bars. With three-button navigation that is 48dp, and
on the scope editor it left a few pixels of Apply and Discard to aim at, which
is #884. Gesture navigation is 24dp and was covered too, the handle drawn across
the supporting line.
The padding goes inside each bar's Surface rather than on it, so the fill still
reaches the bottom edge and the bar reads as one surface. Insets already
consumed count for nothing, so the same call adds nothing in the arrangements
where a container below has taken them.
Home is the same fault from the other direction. It sets contentWindowInsets to
zero so the header can run under the status bar, which gave away the bottom as
well; with the panels floating there is no container to have taken it, and both
the last row of the feed and the scroll controls ended up behind the navigation
bar. Take the bottom edge alone, from the Scaffold's own default so a bottom
display cutout counts.
Four faults on the daemon side of a module's scope request.
The framework could never be granted. "system" names system_server and belongs
to no package, but the receiver resolved the requested package before acting on
the button, so every framework prompt was answered "Package not found", closed
and cancelled with no row written. Accept the framework name without asking the
package manager, and do the lookup only under Approve, the one answer that has
to name something real; deny and the one-hour timeout no longer report a lookup
failure for a package uninstalled while the prompt was up.
One request is now one prompt. The interface takes a list and a single
IXposedScopeCallback for it, but the daemon put one prompt per package on screen
and answered each in its own right, so a module asking for three packages made
the user answer three questions and fired that one listener three times. The
whole list goes up as one prompt whose Approve answers for all of it,
deduplicated and sorted so the same set asked twice replaces its own prompt, and
the per-module ceiling bounds calls rather than packages. The notification reuses
the string it always did with the packages joined into it, so no translation
changes.
An approval survives a dead module. A prompt sits for an hour and the app a
module runs inside can be killed in it, and the receiver returned on a dead
callback binder before claiming the answer, leaving nothing written and the
prompt on screen with buttons that did nothing. The decision is recorded whether
or not the module is still there to be told, and the only call that can fail
against it is caught where it is made.
The refusal path is logged. It had no log line at all, so a request that could
never be granted left no trace anywhere, which is why the framework case went
unnoticed for as long as it did. Name the packages that did not resolve, and the
ones that were approved.
JingMatrixand others added 13 commits August 7, 2026 23:48
Handing a module app its `IXposedService` means starting it: the daemon acquires the module's own `XposedService` provider, which brings the process up, and passes the binder in the reply. That reference was never released, and the platform reads an outstanding external reference as a live client — so `OomAdjuster` held every module app at `FOREGROUND_APP_ADJ` with adj type `ext-provider`, never cached, and a host dying while its provider was still launching was restarted for it. The platform bounds those restarts at three per provider record; acquiring again rebuilds the record with the count at zero, which is how #889 reached fourteen process starts in seventy-six seconds, six of them ours. The release has to be unconditional, because the two returns that matter come back null with the reference already registered.
Three consequences of the same path. The delivery no longer runs on the uid observer, where one module delayed every other module's binder by eight seconds. A failed send is no longer recorded as a delivered one, and repeated failures are throttled to one attempt a minute rather than one a second. And a delivery is recorded per process rather than per uid — a uid outlives any one of its processes, so the record could otherwise never clear.
Separately, `getContentProviderExternal` gained its `tag` argument in Q by replacing the three-argument form, so the unconditional four-argument call has meant no module received its app-side service on 8.1 or 9 since #597. And the `IUidObserver` stub declared four of the interface's eight methods; the rest stayed abstract at runtime, which `oneway` makes fatal rather than reportable.
Module apps are no longer immortal, so a scope answer arriving an hour later may find the app gone. The grant itself is written before the callback.
…895)
The status badge is the only route to the System status page, where the
settings for opening Vector live, and a tick does not read as a button
(#856). While the framework is active the tick now morphs into a gear for
ten seconds every thirty; across those ten seconds the gear tosses a coin
every two seconds and either turns once or stands still, because a wheel
that starts and stops reads as something being operated while one that
simply rotates becomes decoration. Every degree it moves was tossed for,
including the one hint in thirty-two that does not move at all. Only the
tick does this; the other states are reports, two of them urgent. The
hint retires after five badge taps in a day and returns the next.
A pinned shortcut does not follow the user to a launcher installed later,
yet getPinnedShortcuts keeps reporting it, because the pin flag belongs
to the shortcut rather than to the pair and only the active launcher may
read the per-launcher sets (#883). The launchers that have pinned it are
now recorded on this side, and a device running a launcher that is not
among them is offered the shortcut again. Where nothing is recorded the
current launcher is adopted, so no existing shortcut is declared missing,
and a home screen resolving to the chooser or to nothing counts as
unknown rather than as a mismatch.
Opening Home tossed a coin, and four times in five it showed whatever was
on disk — right for returning to Home, wrong for the first Home of a
process, when the archive has had longest to go stale. The first now
always revalidates; the toss governs only the visits after it.
XposedInterface promises that invocations through an invoker bypass access checks, and ours only did once the executable carried a hook: the unhooked path went out through `Method.invoke` on the module's own reflected object, where ART runs its usual check, while the hooked one escaped only because LSPlant makes its backup accessible. Beside it, `invokeSpecial` and `newInstanceSpecial` never read `Invoker.Type`, so the full chain ran even under `Type.Origin`, and the argument handling bore little relation to the `Method#invoke` the invoker is documented against - any `Number` taken for any numeric parameter and narrowed silently, reference arguments and receivers unchecked, and a static executable or a `Character` unboxed through a `Number` method id taking the process down. Both paths are now one primitive dispatching through JNI, which performs no access control, after applying the rules reflection applies: the widening conversions of JLS 5.1.2 and nothing else, checked before the chain is entered so a refusal of ours arrives unwrapped. The chain boundary also stopped collapsing a level, so a target throwing an `InvocationTargetException` surfaces as `ITE(ITE(x))`.
Hooking a method had also turned `invoke` into a non-virtual call, because the backup `ArtMethod::BackupTo` leaves behind is private and ART dispatches it directly, so a `Method` taken from a superclass ran the superclass body where `Method#invoke` runs the override. The override is resolved before dispatch now, top-down so overriding stays transitive, on return type as well as parameters so a covariant bridge is not skipped, and package-private only within the same runtime package. `Type.Origin` deliberately does not resolve, since skipping all hooks cannot mean entering one.
Last, `legacyApiPrefixes` refused four prefixes where the interface names one, and API 102 carries no resource API of its own, so a module targeting it could not touch resources at all. Narrowing the guard exposed three defects behind it: the two `ResStringPool::stringAt` overloads bound to each other's signatures, `ResXMLTree` read at Android 9 field offsets, and `XResources.isXmlCached` comparing an asset cookie against a resource id. The conformance module this was checked against is in commit 8768186, and `external/lsplant` is rebased onto upstream.
…or (#902)
`uidGone()` drops the in-flight marker rather than wait for a send that may never return, since `provider.call` runs the module's own `onServiceBind` with no deadline, so from that moment a replacement process starts a second send while the first is still blocked in `getContentProviderExternal`. That is the ordinary case for a module app that dies on every launch, which is the one this machinery exists for. Two sends for one uid was survivable; two that both believe they speak for it was not - both are woken by the same publish, both deliver, and `onServiceBind` runs twice. The first one out then removes the marker the second is holding, letting a third start behind it, and a third `getContentProviderExternal` builds a fresh provider record with the platform's restart count back at zero. Whichever finishes last also decides the retry throttle, so a send that spoke to a process the uid has already outlived can clear the failure run that was about to slow the restart loop down.
The death recipient had the same problem from the other side: it knew its uid but not its delivery. A death notification is queued when the process dies rather than when we get to it, so the one belonging to the process we served can arrive after a replacement has taken a binder of its own, forgetting a uid that is being served and costing the module a second copy of a service it already holds.
A send now carries a token, and only the send whose token is still the one in the map may commit; a recipient checks that the entry it is about to remove is the one it was linked for. The two fields those steps read and write are individually atomic, which is what makes the gap between them easy to miss - a send could pass its ownership test, be overtaken by `uidGone()`, and still add the uid, leaving a replacement started in that window refused at the top of `uidStarts` until another uid edge arrives. The three places that hand a uid over therefore share one lock, and nothing that blocks runs under it. Failures are still counted whether or not the send that saw one still owns the uid, since a module app dying while the platform waits for its provider is both what the throttle is for and what takes the ownership away.
Co-authored-by: JingMatrix <jingmatrix@gmail.com>
The fd constructor mmaps the DEX and the destructor unmaps it, which is what a process served its
framework over a shared-memory fd needs. An in-process embedder that already holds the framework
DEX in a buffer it owns - a JVM byte[] pinned for the load - has nothing to map and nothing to
unmap. Give PreloadedDex a non-owning (void*, size_t) view for that case, and track ownership so the
move operations and the destructor only unmap what the fd constructor mapped.
buildDummyClassLoader's body becomes BuildDummySuperClassLoader(env, parent, res_super, ta_super),
a plain entry the JNI method now delegates to. An injector that builds the framework class loader
itself can install this loader as the framework loader's parent at construction, so XResources'
synthetic super resolves the first time XResources is defined -- there is no zygote window to
generate that super first when the framework is loaded into an already-running app.
makeFieldWritable reads the ArtField from the jfieldID FromReflectedField returns, which is the
ArtField pointer only under JniIdType kPointer. A debuggable process runs kIndices, where the
jfieldID is a table index and the flag write would dereference a small integer.
Take the pointer directly when FromReflectedField yields one (kPointer -- unchanged path), and only
when it yields a small index decode the reflected Field to its mirror::Field and read its ArtField
(Thread::DecodeJObject + mirror::Field::GetArtField, resolved from libart). The low-address and
access-flags checks stay as backstops, and a runtime missing those symbols keeps the old behavior.
getModuleClassLoader(pkg) and loadedModulePackages() expose the generation map read-only. An injector
that hands each module its own service -- a libxposed IXposedService delivered without the daemon --
can then find the class loader a module runs in and enumerate what is loaded.
The API 101 onPackageLoaded/onPackageReady callbacks dispatch from the LoadedApk.createAppFactory and
createOrUpdateClassLoaderLocked hooks, gated on LoadedApkTracker. A host that builds a LoadedApk by
hand, before those hooks exist, never registers with the tracker, so expose
VectorStartup.trackLoadedApk (with a Startup facade) to enrol its instance and widen LoadedApkTracker
to internal. Once tracked, onPackageLoaded dispatches before the app's AppComponentFactory
initializer, as the API requires.
Everything both this manager and a second host would draw -- the module list and rows, the store with
its detail and install flow, the logs screen and reader, the hideable nav shell, the ambience header,
and the appearance and language sheets -- moves into a new manager-ui library under
org.matrix.vector.ui. The app keeps only what is its own and reaches the library through seam
interfaces (LogSource, StoreInstallHost, AppearanceSettings, AmbienceSettings, LocaleController,
FloatingNavSettings), so another manager can supply its own transport without forking the UI. The
manager's split string files move to manager-ui/res, and crowdin.yml and the sync workflow follow.
The live-log tail follows the reader's viewport rather than the loaded window, so scrolling up to read
history pauses it; and ModuleRow gains an optional reachStart slot, a badge left of the reach band and
off by default, for a host that wants a status on that line.
The Japanese and Ukrainian translations from #911 are adapted to this layout, since that sync was
raised against the old one.
Every submodule that was behind moves to the tip of the branch it tracks:
commons-lang by thirty-two commits, fmt by nine, lsplt by two, and
ManifestEditor to c238e28. In the catalog okhttp goes to 5.5.0, ktfmt to
0.27.0, webkit to 1.17.0, the Compose BOM to 2026.08.00, material3 to
1.5.0-alpha26 and navigation3 to 1.1.6; everything else has only a
prerelease above it. The wrapper takes Gradle 9.7.0 from #920, its jar
checked against the published SHA-256. The NDK stays at r29, since
sdkmanager's newer offer is r30 beta 2.
Which process wrote a line is not in the line's words, so no search can ask it, but it is the question
a reader most often has of a log several applications share. It becomes a filter like the others: the
scan counts uids as it already counts tags and levels, the sheet offers them as chips between the
levels and the tags, and a chosen one shows as a chip that undoes itself -- with its own avatar, since
it sits beside the tag's and says something else.
The three read as one question. Within a category the choices are alternatives -- two applications,
two levels, and now two tags, which was a single choice before and had no reason to be -- while across
categories they are conditions on the same line. The counts follow: each category is counted under the
others and never under itself, so choosing an application leaves the sheet listing the tags that
application writes, with the counts it wrote them, while the applications beside it stay listed and
switchable. Anything the rest of the filter has left with nothing goes, since a chip that selects no
line is a choice not worth offering; a chosen one stays regardless, or a filter could not be undone.
A uid is not a name, so the host puts one to it through writerLabel, asked once per scan and off the
main thread. Both hosts read logs written by installed packages and would answer alike, so the rule is
written once, in WriterLabeler: one package holding the uid is named by its label, a uid several share
by the platform's name for it. The platform names only what it has a package setting for, and the
writers that dominate a privileged daemon's log have none -- root has no setting at all -- so the few
fixed assignments a reader actually meets are spelled out. What remains unnamed keeps its number,
which still separates one process from another, and a writer outside the first user carries the user
it belongs to, since the same application in a second profile is a different writer with the same
label.
hasVerboseStream, from the same seam: a host reduced to its own process's log has no second stream to
unfold into, where both would be the same lines under two names, and says so instead of offering a
control that changes nothing.
Asking the daemon for the installed applications is not the cheap lookup its call site reads as. It
goes out as getInstalledPackagesFromAllUsers with filterNoProcess set, and the daemon answers that by
querying the package manager for the full component list of every package on the device -- activities,
services, receivers and providers, several hundred times over, with a four-call fallback each time a
binder buffer overflows on the way back. AppRepository caches the answer for exactly that reason, and
the splash prefetch pays for it once where nobody is waiting.
What the cache did not have was a way to say that a read was already running. It checked a volatile
field and, finding it empty, went to the daemon; two callers arriving together both found it empty and
both went. The scope editor is two such callers by construction -- its load reads the list, and the
module-package set it filters by reads it again -- so on a cold cache it ran two of those enumerations
against each other, one racing the other for the same threads, while the screen it was opening waited
on the first. Cold is not the rare case either: every install, update and uninstall drops the cache,
and a package event arrives twice, once from the platform and once from the daemon's re-broadcast.
So the fetch becomes a job the repository holds rather than work each caller starts. A caller that
finds one running joins it, and the mutex guarding that field is never held across the fetch itself,
only across the decision. The job runs on the application scope, not the caller's: leaving a screen
part-way through a read now leaves the answer behind for the next visit instead of throwing away the
several hundred queries it had already paid for. A finished job is retired by the next reader rather
than by itself, which is what lets a failed read be retried instead of joined for the life of the
process -- a successful one has filled the cache and is never consulted again.
A generation counter goes in beside it. A read already in flight when a package event lands has by
definition missed what that event carried, and the old code cached its answer regardless, holding a
list known to be wrong until the next event -- on a device where nothing else changes, forever. The
counter is sampled when a read starts and again before it publishes: the caller that asked still gets
the answer, because it is the best that read can offer, but nobody else inherits it.
forceRefresh goes. It had no callers, and sharing a running job leaves nothing for it to mean.
ScopeUiState starts with loading set, and the state built on the last line of load was the only other
thing that ever wrote it. Every exit that skipped that line therefore left the flag standing, and the
screen is drawn entirely behind it: a throw from one of the reads, or the reader leaving while they
were still running, and the spinner was there for as long as the view model was. There is no second
load to correct it -- load runs once, from init.
A try around the body and the flag cleared in the finally, only when it is still set, so the success
path stays the single writer of the state it builds. What a failure now shows is the empty list, which
is wrong but says so, rather than a wait that never ends.
The one read in that body that could throw is the manifest inspection, which opens the module's APK.
The package info fetched immediately above it is already guarded for the same hazard -- a package
removed or replaced between one call and the next -- and this one was not, so an APK that went away
mid-load took the whole manager down from a viewModelScope coroutine. It recovers the way its
neighbour does, as no recommended scope, which costs the reader a few rows that would have arrived
pre-ticked.
The one OkHttp client, the DoH resolver and its status section were the manager app's alone. They
move to manager-ui -- a new org.matrix.vector.ui.net package, a NetworkSettings interface in place
of the concrete SettingsRepository, and a shared DohSettingSection composing the switch and status
the sheet used to build inline -- so a downstream consumer resolves names the same way rather than
reimplementing it. The strings move with them, translations and all. Nothing about how Vector
resolves or renders changes.
The shared Store screen builds its own header, so a consumer had no way to put anything in its title
row. Thread an optional actions slot through to the PanelHeader the header already uses; Vector
passes none, so its Store is unchanged. LSPatch hangs a menu button there.
ART resolves the superclass of every type a method mentions when it
verifies that method, not when the branch mentioning it runs. The
`Build.VERSION.SDK_INT >= R` gate around `registerForNotifications`
therefore never got a chance to help: verifying registerProxyService
resolved the anonymous callback, that resolved
`android.os.IServiceCallback$Stub`, and on Android 9 the daemon logged a
NoClassDefFoundError for its very first call even though the guarded code
was never executed.
Move the callback into a holder object that the gate only touches on R
and newer, so the resolution is deferred to that object's class
initialization, and pin the holder in the R8 rules so a release build
cannot inline it back into the caller and reintroduce the reference.
Fixes#925
FRAMEWORK_NAME was baked from this repository's own root project name, so every consumer of the
xposed library reported "Vector" to the modules it loaded -- including LSPatch, which ships this
code inside a patched app and is a different framework from the Zygisk one. A module that branches
on getFrameworkName() was told the name of the library rather than of its loader.
It now comes from the root build when there is one, which is the build that decides what the
framework is. Vector building itself has no parent, so it stamps its own name as before; the
version already resolved this way, being read from the repository the build was invoked in.
The shared library exists to be drawn by two managers, and half of it was drawn by one. The panel
bar, the floating ball and the arrangement behind them existed twice over -- this app's own files
under ui/navigation, and a copy in the library that only LSPatch consumed -- and so did the language
override, its picker, the localised dialog, the clipboard helper and the monospaced identifier
styles. The copies were the same code; each was a place for the two to drift; and the language
override is where drift costs most, being the mechanism that decides whether a sheet speaks the
reader's language or the phone's.
The container's fork had a reason once. The library's copy could not name a destination, because the
manager that used it navigated by generated route strings and had no route type to hand over, so it
took a key, handed a key back, and every surface that drew it mapped keys to routes on its own. That
manager now holds its back stack the way this one does, as a list of typed keys. What the library
keeps is this app's implementation, typed on NavKey: a panel carries the destination it opens, the
bar and the ball are told the current one and hand one back, and the navigator -- the operations
over the stack, and the arrangement it owns -- moves there with them. Each host answers for the two
things that are its own, where the arrangement is stored and where the ball rests, through ports
beside the ones the language override and the ambient header already use.
The language mechanism is the library's too, and what stays here is the binding: which languages
this app is translated into, where the choice is kept, and who translated what. The picker goes with
it. The library's copy had no credits, because the manager that used it had none to show, so the
chip moves into the shared row and the map behind it becomes a port with an empty default, and
language_translated_by moves into the library's resources with the translations it already had. The
words on the row that opens the container's edit mode move the same way: the title was already
there, read from a second copy here, while the summary beside it had no shared copy at all, which is
what stopped the other manager from offering the row.
The matching behind those credits did not work. It promised to tolerate the two spellings of
Indonesian and Hebrew -- values-in and values-iw against the tags id and he -- by trying the
locale's language beside its tag. Which of the two a Locale answers with is not fixed: Android keeps
the retired codes, a desktop JVM normalises them away, and toLanguageTag gives the modern one on
both, so an entry keyed the way the folder is named was never found. A credit that fails to match
looks exactly like a credit nobody wrote, which is why this went unnoticed. The pairs are written
out rather than assumed.
The dialog is the library's, which localises through the hook a host installs instead of calling the
override itself; the hook is installed once around everything rather than around the one screen that
first needed it, because a dialog can open anywhere. The clipboard helper is told the label to put
on the clip, since a manager running inside com.android.shell must not name its clips after the
process it borrowed. Two things move the other way, having only ever existed here though both
managers want them: the snackbar that says by its colour and its icon whether something worked, and
the icon cache that rasterises an app icon when it is drawn and bounds what it keeps.
Nothing drawn changes.
A pointer asks a row for its menu with the secondary button -- a right
click, or a two-finger tap -- and nothing answered it.
`combinedClickable` reads any button's press as an ordinary tap and
finds a long press only by timing a held finger, so a right click did
what a left click does.
A `View` answers it by itself -- badly: the menu came from the row under
the pointer but acted on the field only the long press wrote, opening
against whichever row was held last. Compose changed the symptom, not
the cause.
So the press is handed to the long press's lambda, watched on the
initial pass and consumed so the click never lands -- module rows,
apps in a scope, contributors on the home screen. Nothing changes for a
finger.
Fixes#644
A scope row names a package; the map the injector reads is keyed by uid.
Uninstalling a target and installing it again crosses that gap. The app
returns under a new uid, the entry under the old one went with
ACTION_UID_REMOVED, and the install handler looked a target up by uid in
that same cache — so it matched nothing, asked for no rebuild, and the
row it is still configured under sat unread.
That leaves the configuration correct and inert. The manager reads the
table, so it goes on showing the target ticked; draft and saved agree, so
no apply bar appears and nothing on that screen can put it right. It
takes a scope edit elsewhere, or the next boot.
Asking the scope table by name answers it. The uid test stays behind the
new one for the rows no table holds: a module in its own scope, and the
self-scope derived for a legacy one. Removal asks for its own rebuild
too, that cleanup having only ever been incidental to a uid being
retired.
The lifetime this restores was never written down. The README, the
schema, ConfigCache and the scope editor now say it.
The manager APK leaves the module directory as a descriptor, not as
bytes: the daemon passes it over binder to the parasitic host, and to
an installed manager updating itself. The kernel judges that transfer
by the receiver -- selinux_binder_transfer_file checks the file against
cred_sid(to) -- so the read is asked of u:r:shell:s0, never of the
daemon's root context.
The installer leaves the module tree system_file, which every appdomain
and coredomain may read. Nothing holds it there: /data/adb is
adb_data_file by default, and init's restorecon --recursive --skip-ce
/data rewalks the tree whenever the stored digest stops matching -- a
system image flashed over a kept /data. Every launch then fails the
transfer, and the host dies on an intent it was never given.
So the daemon reasserts the label at startup, beside /data/adb/lspd and
the dex2oat wrappers, and only when it has drifted.
A repository returns whatever version name the publisher wrote, and a long one took the whole store row with it, pushing the date beside it into a column one character wide. The row's fixed parts are measured first now, and the version takes what is left as a single scrolling line — in the list badge, on the release card, and in the install button, through one shared ScrollingLabel rather than three arrangements of the same idea.
That label then replaces the three inline spellings of itself that were already in the tree: the module name in the list, the module name again in the scope screen's title, and the version inside UpdatableVersion. Each had picked its own pause and number of passes, two of them on the same row of the modules list. The motion is one behaviour now, named once, in one file.
In the version-history sheet the filled dot marked the installed build, so tapping an older one left the fill against a row the reader had just left. It follows the selection now, the way a column of dots is read everywhere else. Which build is installed is a separate fact, and goes on saying itself in the dot's colour and in the status column.
The monochrome icon was a solid silhouette, which at icon size is a blob: the wings and drapery the statue is recognised by were lost inside it. It is line work now, stroked heavily and thinned by region — the outline kept whole, the wing's feathers kept, the skirt's folds mostly dropped — because the status bar draws the whole figure at 24 by 38 pixels.
makeFieldWritable took FromReflectedField's value as the ArtField address
and read the access flags four bytes in. That holds only while a jfieldID
is the pointer itself, which is all public AOSP has ever handed out:
DecodeArtField in runtime/jni/jni_internal.h reinterpret_casts anything
IsIndexId says is not an index.
The ART on the Android 17 canary train tags it -- DecodeArtFieldInternal
tests bit 55 and clears bits 54-55 before using the rest as the address --
so the read lands in unmapped memory. Seen on a Pixel 6, where
PixelifyGooglePhotos spoofing Build through XposedHelpers put
com.google.android.apps.photos into a crash loop:
signal 11 (SIGSEGV), fault addr 0x0080000070d257e4
#3 de.robv.android.xposed.XposedHelpers.setStaticFinalField
#7 balti.xposed.pixelifygooglephotos.DeviceSpoofer.handleLoadPackage
0x0080000070d257e0 is the tagged id, and +4 is access_flags_.
That tagging is in no published source -- it was read out of the runtime's
own disassembly -- so nothing here is built on knowing it. Nothing is
decoded. Each reading of the id is offered as a candidate, and a candidate
earns the write by answering three questions, ordered so that each is safe
to ask before the next: is it above the first page, which is arithmetic on
a number; is it mapped, which msync answers from the page tables without
touching the address; do the flags at +4 equal what Field.getModifiers()
reports, which is the first dereference and only of a page already known
to exist.
The tag constant therefore shapes which candidate is offered and is never
trusted. A wrong constant, or a runtime that shapes ids in some further
way, fails one of the three questions, and the caller keeps the
IllegalAccessError it had before this existed. A wrong theory about ART
costs a module a refusal rather than the app its process, which is what
lets this be five lines of guessing.
An odd id is not an address under any encoding: JniIdManager::IsIndexId
calls a null or odd id an index and encodes one as (index << 1) + 1. It is
left to the mirror::Field path that was already there for kIndices, which
now returns false where it used to crash, neither ART symbol it needs
being exported on this release.
The tag constant is built in 64 bits and narrowed to uintptr_t, so it is
simply zero on armeabi-v7a instead of a shift wider than the type, which
if constexpr would not have excused outside a template.
Sign up for freeto 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.

7 participants

@superturtlee@kn007@JingMatrix@Leaf-lsgtky@yunsmall@HSSkyBoy@LIghtJUNction