Skip to content

Fill a TypedValue of our own, not the one Resources lends out - #1048

Merged
JingMatrix merged 4 commits into
masterfrom
layout-hook-own-typed-value
Oct 30, 2026
Merged

JingMatrix merged 4 commits into
masterfrom
layout-hook-own-typed-value

Conversation

@JingMatrix

@JingMatrix JingMatrix commented Oct 30, 2026 •

Copy link
Copy Markdown
Owner

Four defects in the legacy resource hooks, found chasing one of them.

The borrowed TypedValue. XResources.getLayout needs the variant segment of a layout's resource path to label the XC_LayoutInflated callbacks registered for that id, and read it through the TypedValue sitting in Resources.mTmpValue, a field reached by reflection rather than one of ours. That field is a pool of one, in AOSP's own wording: declared beside the lock that guards it, handed out by obtainTempTypedValue, which leaves it null until releaseTempTypedValue "returns a TypedValue to the pool", and taken that way by fourteen methods of Resources — getLayout by way of loadXmlResourceParser, the one this override calls through to, among them. android10-release holds the same pool, so there is no API change to adapt to: the borrowed value has always been null whenever another lookup on the same Resources was in flight, and AssetManager.getResourceValue opens with Objects.requireNonNull(outValue, "outValue"). What changed is how often it overlaps: on the Android 17 QPR3 beta a module hooking SystemUI's shade layout meets it through an <include> — scene_window_root, absent from android14-release, wraps super_notification_shade and is inflated while SystemUIService and KeyguardService are created — and SystemUI crash-loops at boot until an inflation finds the slot full. That a later attempt on the same build and device comes up is the race showing itself.

Names a system-wide hook never received. A layout hook is filed under the resource directory it was registered for and a system-wide one under null. getLayout honoured both keys for the callbacks and only the first for the names, so a framework layout hooked through hookSystemWideLayout and inflated by an app — what that API is for — reached the callback with resNames null. Their registration also moves ahead of the callbacks', and a first callback now joins its set before the set is published, because an inflation reads the two in the other order and one that finds callbacks with no names records nothing against the document it returns.

ResourceNames built the other way round. The constructor takes (id, pkg, name, type) and spells a full name as pkg:type/name, the form getResourceName returns; both factories passed the type name as the name and the entry name as the type. Every layout callback has been receiving android:simple_list_item_1/layout for what is android:layout/simple_list_item_1, and ResourceNames.equals could match neither field. The swap is inherited — LSPosed's XResources has it too — so a module that worked around it will stop matching, which is the price of the fields meaning what they document.

A cache bit that could be missed or lost. A replacement's id is noted in a bitmap first and in sReplacements second, so a resource read whose bit is clear returns the original without taking a lock. The bitmap was a byte[] written under a monitor and read under none, which the memory model lets a reader miss for as long as it likes, and it was set outside the lock isFirstLoad clears it under, so a replacement registered as a module reloaded could keep its row and lose its bit — unreplaceable for the life of the process. It is now an AtomicIntegerArray, read with a volatile load, set with a compare-and-swap beside the row under the same lock, and held in a final field. The bits are exactly the ones the old key-and-offset arithmetic reached, 0x000700ff of a framework id and 0x0007007f of an app's, each named once as the bit an id stands for.

Three things in the same code were looked at and left alone: XTypedArray.peekValue keeps the field it borrows, since TypedArray.mValue is assigned only where it is declared and returning that instance is its contract; mParseState and mBlock are still spelled as XResources reads them in android10-release and android16-release alike, and the native side returns early on a null parse state; and every id-taking getter of Resources that Xposed documents as replaceable is still overridden, getQuantityString arriving through the overridden getQuantityText.

XResources.getLayout labels a layout's callbacks with the variant from its
resource path, and took the TypedValue for that read out of Resources'
mTmpValue. The field is a one-slot pool rather than scratch space:
obtainTempTypedValue hands the instance over and leaves it null under
mTmpValueLock until releaseTempTypedValue puts it back, which fourteen
methods of Resources do, unchanged from android10-release to
android16-release. The borrowed value is therefore null whenever another
lookup on the same Resources is in flight, and AssetManager.getResourceValue
opens with Objects.requireNonNull(outValue, "outValue"):

  InflateException: Binary XML file line #31 in
      com.android.systemui:layout/scene_window_root: outValue
  Caused by: NullPointerException: outValue

seen on the Android 17 QPR3 beta, where a module hooking the shade layout
meets it through an <include> while SystemUIService is created, and SystemUI
crash-looped at boot until an inflation found the slot full.

Allocate one instead, on a path already building a parser and a callback
list; nothing then depends on which lookup held the slot. XTypedArray.peekValue
keeps the field it borrows, TypedArray.mValue being assigned only where it is
declared and returning that instance being peekValue's contract.
@JingMatrix
JingMatrix force-pushed the layout-hook-own-typed-value branch from e6f4227 to 3a3aadc Compare October 30, 2026 13:00
@JingMatrix

Copy link
Copy Markdown
Owner Author

@jiggs82 Thanks for reporting this issue, it should be fixed now in the CI build: https://github.com/JingMatrix/Vector/actions/runs/41059640616

Please confirm it so that I can merge.

@jiggs82

jiggs82 commented Oct 30, 2026

Copy link
Copy Markdown

Yup it seems to be working fine for me thanks for the quick fix 👍🏻

A layout hook is filed under the resource directory it was registered for, and
a system-wide one under null. getLayout honours both keys when it looks for the
callbacks and only the first when it looks for the names, so a framework layout
hooked through hookSystemWideLayout and inflated by an app — what that API is
for — reaches the callback with resNames null, and a module reading a name off
it gets a NullPointerException instead. Look the names up under both keys.

Their registration also moves ahead of the callbacks'. An inflation takes the
callbacks first and the names second, and one that finds callbacks with no
names records nothing against the document it returns, so a callback added a
moment later misses that document as well: the names have to be there before
anything can find a callback. A first callback is likewise added to its set
before the set is published, a set being findable while still empty having the
same effect for the inflation that finds it.

Also here: the id of a layout whose file name cannot be read is put inside the
logged exception rather than concatenated to it, which turned the whole
NotFoundException into its own message and dropped the stack.
… them

The constructor takes (id, pkg, name, type) and spells a full name as
pkg:type/name, which is the form getResourceName returns. Both factories passed
getResourceTypeName as the name and getResourceEntryName as the type, so every
layout callback received android:simple_list_item_1/layout for what is
android:layout/simple_list_item_1, with name and type the same way round.
ResourceNames.equals, whose whole purpose is to let a module match on the type
or the entry it asked for, could not match either.

Pass them as the parameters are named. The swap is inherited: LSPosed's own
XResources has it too, so a module that worked around it by naming the type
where a name belongs will stop matching, which is the cost of the fields
meaning what they are documented to mean.
…are read

A replacement's id is noted in a bitmap first and in sReplacements second, so a
resource read whose bit is clear returns the original without taking a lock.
Two things made that bit unreliable.

It was a byte[] written under a monitor and read under none, which the memory
model lets a reader miss for as long as it likes: a module registering
replacements from handleInitPackageResources while the app reads resources on
its own threads is the ordinary case, not an exotic one. An AtomicIntegerArray
element is read with a volatile load and set with a compare-and-swap, which is
the ordering this wants and still no lock on the read. A loop rather than
getAndUpdate, whose lambda may not name this class.

And it was set outside the lock that isFirstLoad clears it under, so a
replacement registered just as a module was reloaded could keep its row in
sReplacements and lose its bit, leaving the resource unreplaceable for the life
of the process. The bit now goes in beside the row, under that same lock.

The bitmaps carry exactly the bits the old key-and-offset arithmetic reached,
0x000700ff of a framework id and 0x0007007f of an app's, each now named once as
the bit an id stands for. The array reference is final, so no thread can reach
a resource lookup through an object whose cache it cannot see yet.
@JingMatrix
JingMatrix merged commit 4209420 into master Oct 30, 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.

2 participants