From 3a3aadc2bd163e359e16d49143bb09b7566d84ce Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Fri, 30 Oct 2026 13:46:36 +0100 Subject: [PATCH 1/4] Fill a TypedValue of our own, not the one Resources lends out 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 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. --- legacy/src/main/java/android/content/res/XResources.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/legacy/src/main/java/android/content/res/XResources.java b/legacy/src/main/java/android/content/res/XResources.java index 2a5850ebe4..eb8252870d 100644 --- a/legacy/src/main/java/android/content/res/XResources.java +++ b/legacy/src/main/java/android/content/res/XResources.java @@ -974,7 +974,10 @@ public XmlResourceParser getLayout(int id) throws NotFoundException { } if (callbacks != null) { String variant = "layout"; - TypedValue value = (TypedValue) getObjectField(this, "mTmpValue"); + // Resources' mTmpValue is a one-slot pool, emptied by obtainTempTypedValue + // until its holder releases it, so a lookup in flight on these Resources + // leaves nothing there to read this through. + TypedValue value = new TypedValue(); getValue(id, value, true); if (value.type == TypedValue.TYPE_STRING) { String[] components = value.string.toString().split("/", 3); From c3e0b845b1581afd2f80491f3ecc9294d92c8db5 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Fri, 30 Oct 2026 15:23:13 +0100 Subject: [PATCH 2/4] Hand a system-wide layout hook the names it registered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../java/android/content/res/XResources.java | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/legacy/src/main/java/android/content/res/XResources.java b/legacy/src/main/java/android/content/res/XResources.java index eb8252870d..9b3990231c 100644 --- a/legacy/src/main/java/android/content/res/XResources.java +++ b/legacy/src/main/java/android/content/res/XResources.java @@ -987,7 +987,7 @@ public XmlResourceParser getLayout(int id) throws NotFoundException { XposedBridge.log("Unexpected resource path \"" + value.string.toString() + "\" for resource id 0x" + Integer.toHexString(id)); } else { - XposedBridge.log(new NotFoundException("Could not find file name for resource id 0x") + Integer.toHexString(id)); + XposedBridge.log(new NotFoundException("Could not find file name for resource id 0x" + Integer.toHexString(id))); } synchronized (sXmlInstanceDetails) { @@ -995,7 +995,13 @@ public XmlResourceParser getLayout(int id) throws NotFoundException { HashMap resNamesInner = sResourceNames.get(id); if (resNamesInner != null) { synchronized (resNamesInner) { - XMLInstanceDetails details = new XMLInstanceDetails(resNamesInner.get(mResDir), variant, callbacks); + // Both keys, as the callbacks above were looked up under: a + // system-wide hook files its names under null, and the layouts it + // hooks are inflated by apps, whose resource directory is not null. + ResourceNames resNames = resNamesInner.get(mResDir); + if (resNames == null && mResDir != null) + resNames = resNamesInner.get(null); + XMLInstanceDetails details = new XMLInstanceDetails(resNames, variant, callbacks); sXmlInstanceDetails.put(result, details); // if we were called inside LayoutInflater.parseInclude, store the details for it @@ -1806,6 +1812,11 @@ private static XC_LayoutInflated.Unhook hookLayoutInternal(String resDir, int id } } + // Ahead of the callbacks, because an inflation reads the two in the other order: it takes + // the callbacks and then the names, and one that finds callbacks with no names records + // nothing for the document it is about to return, so later callbacks miss it too. + putResourceNames(resDir, resNames); + HashMap> inner; synchronized (sLayoutCallbacks) { inner = sLayoutCallbacks.get(id); @@ -1815,19 +1826,19 @@ private static XC_LayoutInflated.Unhook hookLayoutInternal(String resDir, int id } } - CopyOnWriteSortedSet callbacks; synchronized (inner) { - callbacks = inner.get(resDir); + CopyOnWriteSortedSet callbacks = inner.get(resDir); if (callbacks == null) { + // Filled before it is published: a set that can be found while it is still empty + // is a hook that is registered and does not run. callbacks = new CopyOnWriteSortedSet<>(); + callbacks.add(callback); inner.put(resDir, callbacks); + } else { + callbacks.add(callback); } } - callbacks.add(callback); - - putResourceNames(resDir, resNames); - return callback.new Unhook(resDir, id); } From 8cbdc6c500776a181f92aec9c2c4d546dfd90d21 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Fri, 30 Oct 2026 15:23:28 +0100 Subject: [PATCH 3/4] Give ResourceNames the entry name and the type the way round it reads 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. --- legacy/src/main/java/android/content/res/XResources.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/legacy/src/main/java/android/content/res/XResources.java b/legacy/src/main/java/android/content/res/XResources.java index 9b3990231c..ef3ff8b0e0 100644 --- a/legacy/src/main/java/android/content/res/XResources.java +++ b/legacy/src/main/java/android/content/res/XResources.java @@ -330,8 +330,8 @@ private ResourceNames getResourceNames(int id) { return new ResourceNames( id, getResourcePackageName(id), - getResourceTypeName(id), - getResourceEntryName(id)); + getResourceEntryName(id), + getResourceTypeName(id)); } private static ResourceNames getSystemResourceNames(int id) { @@ -339,8 +339,8 @@ private static ResourceNames getSystemResourceNames(int id) { return new ResourceNames( id, sysRes.getResourcePackageName(id), - sysRes.getResourceTypeName(id), - sysRes.getResourceEntryName(id)); + sysRes.getResourceEntryName(id), + sysRes.getResourceTypeName(id)); } private static void putResourceNames(String resDir, ResourceNames resNames) { From 2df89d7b2d57313109a4e7121f7e33b6b4d619da Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Fri, 30 Oct 2026 15:24:18 +0100 Subject: [PATCH 4/4] Keep the replacement cache's bits visible, and clearable, where they 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. --- .../java/android/content/res/XResources.java | 87 +++++++++++++------ 1 file changed, 62 insertions(+), 25 deletions(-) diff --git a/legacy/src/main/java/android/content/res/XResources.java b/legacy/src/main/java/android/content/res/XResources.java index ef3ff8b0e0..71852fbcc1 100644 --- a/legacy/src/main/java/android/content/res/XResources.java +++ b/legacy/src/main/java/android/content/res/XResources.java @@ -33,10 +33,10 @@ import org.xmlpull.v1.XmlPullParser; import java.io.File; -import java.util.Arrays; import java.util.HashMap; import java.util.LinkedList; import java.util.WeakHashMap; +import java.util.concurrent.atomic.AtomicIntegerArray; import de.robv.android.xposed.IXposedHookZygoteInit; import de.robv.android.xposed.XC_MethodHook; @@ -66,9 +66,16 @@ public class XResources extends XResourcesSuperClass { // TT is the type of the resource; // NNNN is the name of the resource in that type. // For applications resources, PP is always 0x7f. - private static final byte[] sSystemReplacementsCache = new byte[256]; // bitmask: 0x000700ff => 2048 bit => 256 bytes - private byte[] mReplacementsCache; // bitmask: 0x0007007f => 1024 bit => 128 bytes - private static final HashMap sReplacementsCacheMap = new HashMap<>(); + // + // Which ids are worth a lookup, one bit each, a bit standing for several ids. Ints rather than + // bytes because an AtomicIntegerArray element is read with a volatile load: the thread that + // registers a replacement is rarely the thread that reads the resource, and a plain array read + // is free to miss the other thread's write for as long as it likes, which here means serving + // the original resource for as long as it likes. The per-directory one is final so that the + // array is in place for every thread that can see the object at all. + private static final AtomicIntegerArray sSystemReplacementsCache = new AtomicIntegerArray(64); // 0x000700ff => 2048 bits + private final AtomicIntegerArray mReplacementsCache; // 0x0007007f => 1024 bits + private static final HashMap sReplacementsCacheMap = new HashMap<>(); private static final SparseArray sColorStateListCache = new SparseArray<>(0); private static final SparseArray>> sLayoutCallbacks = new SparseArray<>(); @@ -93,19 +100,20 @@ public XResources(ClassLoader classLoader, String resDir) { this.mResDir = resDir; this.mPackageName = getPackageName(resDir); + AtomicIntegerArray cache = null; if (resDir != null) { synchronized (sReplacementsCacheMap) { // Not computeIfAbsent: its mapping function would be a lambda, which this class may // not create. See the note above [includedLayouts]. Under this lock the two spellings // describe the same operation. - byte[] cache = sReplacementsCacheMap.get(resDir); + cache = sReplacementsCacheMap.get(resDir); if (cache == null) { - cache = new byte[128]; + cache = new AtomicIntegerArray(32); sReplacementsCacheMap.put(resDir, cache); } - mReplacementsCache = cache; } } + mReplacementsCache = cache; } /** @@ -169,7 +177,7 @@ public boolean isFirstLoad() { for (int i = 0; i < sReplacements.size(); i++) { sReplacements.valueAt(i).remove(mResDir); } - Arrays.fill(mReplacementsCache, (byte) 0); + clearCacheBits(mReplacementsCache); return true; } } @@ -605,19 +613,6 @@ else if (resDir == null && id >= 0x7f000000) if (replacement instanceof Drawable) throw new IllegalArgumentException("Drawable replacements are deprecated since Xposed 2.1. Use DrawableLoader instead."); - // Cache that we have a replacement for this ID, false positives are accepted to save memory. - if (id < 0x7f000000) { - int cacheKey = (id & 0x00070000) >> 11 | (id & 0xf8) >> 3; - synchronized (sSystemReplacementsCache) { - sSystemReplacementsCache[cacheKey] |= 1 << (id & 7); - } - } else { - int cacheKey = (id & 0x00070000) >> 12 | (id & 0x78) >> 3; - synchronized (res.mReplacementsCache) { - res.mReplacementsCache[cacheKey] |= 1 << (id & 7); - } - } - synchronized (sReplacements) { HashMap inner = sReplacements.get(id); if (inner == null) { @@ -625,9 +620,53 @@ else if (resDir == null && id >= 0x7f000000) sReplacements.put(id, inner); } inner.put(resDir, replacement); + + // Under the same lock as the row, because isFirstLoad drops a directory's rows and + // clears its bits while holding it: a bit set outside the lock can be cleared while + // the row it stands for is written, and the replacement then answers nothing, for good. + // False positives are accepted to save memory, false negatives are not survivable. + if (id < 0x7f000000) + setCacheBit(sSystemReplacementsCache, systemCacheBit(id)); + else + setCacheBit(res.mReplacementsCache, appCacheBit(id)); + } + } + + /** The cache bit standing for a framework id, that is {@code 0x000700ff} of it. */ + private static int systemCacheBit(int id) { + return (id & 0x00070000) >> 8 | (id & 0xff); + } + + /** The cache bit standing for an app id, that is {@code 0x0007007f} of it. */ + private static int appCacheBit(int id) { + return (id & 0x00070000) >> 9 | (id & 0x7f); + } + + private static boolean hasCacheBit(AtomicIntegerArray cache, int bit) { + return (cache.get(bit >> 5) & (1 << (bit & 31))) != 0; + } + + /** + * Sets [bit], retrying until the word carries it. + * + * A loop rather than getAndUpdate, whose lambda would name this class. See the note above + * [includedLayouts]. + */ + private static void setCacheBit(AtomicIntegerArray cache, int bit) { + int index = bit >> 5; + int mask = 1 << (bit & 31); + for (;;) { + int word = cache.get(index); + if ((word & mask) != 0 || cache.compareAndSet(index, word, word | mask)) + return; } } + private static void clearCacheBits(AtomicIntegerArray cache) { + for (int i = 0; i < cache.length(); i++) + cache.set(i, 0); + } + // ======================================================= // RETURNING REPLACEMENTS // ======================================================= @@ -638,12 +677,10 @@ private Object getReplacement(int id) { // Check the cache whether it's worth looking for replacements if (id < 0x7f000000) { - int cacheKey = (id & 0x00070000) >> 11 | (id & 0xf8) >> 3; - if ((sSystemReplacementsCache[cacheKey] & (1 << (id & 7))) == 0) + if (!hasCacheBit(sSystemReplacementsCache, systemCacheBit(id))) return null; } else if (mResDir != null) { - int cacheKey = (id & 0x00070000) >> 12 | (id & 0x78) >> 3; - if ((mReplacementsCache[cacheKey] & (1 << (id & 7))) == 0) + if (!hasCacheBit(mReplacementsCache, appCacheBit(id))) return null; }