Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
259 changes: 258 additions & 1 deletion src/main/java/com/reandroid/apk/ApkBundle.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,20 @@
*/
package com.reandroid.apk;

import com.reandroid.archive.ByteInputSource;
import com.reandroid.archive.InputSource;
import com.reandroid.archive.ZipEntryMap;
import com.reandroid.archive.block.ApkSignatureBlock;
import com.reandroid.arsc.chunk.PackageBlock;
import com.reandroid.arsc.chunk.TableBlock;
import com.reandroid.arsc.chunk.xml.ResXmlAttribute;
import com.reandroid.arsc.chunk.xml.ResXmlDocument;
import com.reandroid.arsc.chunk.xml.ResXmlElement;
import com.reandroid.arsc.model.ResourceEntry;
import com.reandroid.arsc.value.ValueType;
import com.reandroid.utils.collection.ArrayCollection;

import java.io.ByteArrayOutputStream;
import java.io.Closeable;
import java.io.File;
import java.io.FileNotFoundException;
Expand Down Expand Up @@ -67,6 +76,9 @@ public ApkModule mergeModules(boolean force) throws IOException {

result.setApkSignatureBlock(signatureBlock);

// Resolve resource overlays (@null references) from split modules
resolveResourceOverlays(result, moduleList);

if(result.hasTableBlock()){
TableBlock tableBlock=result.getTableBlock();
tableBlock.sortPackages();
Expand Down Expand Up @@ -190,9 +202,254 @@ private void logError(String msg, Throwable tr) {
apkLogger.logError(msg, tr);
}
}
private void logVerbose(String msg) {
private void logVerbose(String msg){
if(apkLogger!=null){
apkLogger.logVerbose(msg);
}
}

/**
* Resolves @null drawable references in merged APK by updating binary XML attributes
* to point to actual PNG resources from density split modules.
*
* Modern App Bundles use resource overlays where base APK contains XML with @null
* references and density splits contain the actual PNG files. After merging, the PNGs
* are present but XMLs still reference @null, causing Resources$NotFoundException at runtime.
*
* This method:
* 1. Scans drawable XMLs for @null references (type=REFERENCE, data=0)
* 2. Finds corresponding PNG resources using fuzzy name matching
* 3. Looks up resource IDs from the resource table
* 4. Updates XML binary attributes with correct resource IDs
*/
private void resolveResourceOverlays(ApkModule merged, List<ApkModule> splits) {
try {
if (!merged.hasTableBlock()) {
return;
}

TableBlock tableBlock = merged.getTableBlock();
PackageBlock packageBlock = null;
Iterator<PackageBlock> packages = tableBlock.getPackages();
while (packages.hasNext()) {
PackageBlock pkg = packages.next();
if (pkg.getName() != null) {
packageBlock = pkg;
break;
}
}
if (packageBlock == null) {
return;
}

ZipEntryMap mergedZip = merged.getZipEntryMap();
int resolvedCount = 0;
int totalNullCount = 0;
List<String> unresolved = new ArrayList<>();

// Scan drawable XMLs for @null references
for (ResFile resFile : merged.listResFiles()) {
String path = resFile.getFilePath();
if (!path.startsWith("res/drawable/") || !path.endsWith(".xml")) {
continue;
}

InputSource source = resFile.getInputSource();
if (source == null) continue;

try {
ResXmlDocument xmlDoc = new ResXmlDocument();
xmlDoc.readBytes(source.openStream());

if (hasNullDrawableReferences(xmlDoc)) {
String baseName = getBaseName(path);
totalNullCount++;

// Find corresponding PNG resources
List<String> pngPaths = findOverlayPngs(baseName, mergedZip);

if (!pngPaths.isEmpty()) {
// Extract drawable names and look up resource IDs
Map<String, Integer> drawableIds = new HashMap<>();
for (String pngPath : pngPaths) {
String drawableName = extractDrawableName(pngPath);
ResourceEntry entry = packageBlock.getResource("drawable", drawableName);
if (entry != null) {
drawableIds.put(drawableName, entry.getResourceId());
}
}

if (!drawableIds.isEmpty()) {
// Update XML with resource IDs
if (updateNullReferences(xmlDoc, drawableIds, baseName)) {
// Write updated XML back to merged APK
ByteArrayOutputStream baos = new ByteArrayOutputStream();
xmlDoc.writeBytes(baos);
byte[] newBytes = baos.toByteArray();

mergedZip.remove(path);
mergedZip.add(new ByteInputSource(newBytes, path));
resolvedCount++;
}
} else {
unresolved.add(baseName);
}
} else {
unresolved.add(baseName);
}
}
} catch (Exception e) {
// Skip files that can't be parsed
}
}

if (totalNullCount > 0) {
logMessage("Resolved " + resolvedCount + "/" + totalNullCount + " resource overlays");
if (!unresolved.isEmpty() && unresolved.size() <= 5) {
logMessage("Unresolved: " + String.join(", ", unresolved));
}
}
} catch (Exception e) {
logError("Error resolving resource overlays", e);
}
}

/**
* Checks if XML document contains @null drawable references.
* In binary XML, null references are represented as type=REFERENCE with data=0.
*/
private boolean hasNullDrawableReferences(ResXmlDocument xmlDoc) {
ResXmlElement root = xmlDoc.getDocumentElement();
if (root == null) return false;

Iterator<ResXmlElement> items = root.recursiveElements();
while (items.hasNext()) {
ResXmlElement item = items.next();
if (!"item".equals(item.getName())) continue;

ResXmlAttribute drawable = item.searchAttributeByName("drawable");
if (drawable != null) {
ValueType vtype = drawable.getValueType();
int data = drawable.getData();
if (vtype == ValueType.REFERENCE && data == 0) {
return true;
}
}
}
return false;
}

/**
* Extracts base filename without extension.
* Example: "res/drawable/abc_switch.xml" -> "abc_switch"
*/
private String getBaseName(String path) {
String name = path.substring(path.lastIndexOf('/') + 1);
return name.substring(0, name.lastIndexOf('.'));
}

/**
* Extracts drawable name from PNG path.
* Example: "res/drawable-xxhdpi/abc_btn_switch.9.png" -> "abc_btn_switch"
*/
private String extractDrawableName(String pngPath) {
String fileName = pngPath.substring(pngPath.lastIndexOf('/') + 1);
return fileName.replaceAll("\\.9?\\.png$", "");
}

/**
* Finds PNG resources that match the XML base name using fuzzy keyword matching.
* Material Design resources often have different naming patterns between XML and PNG.
* Example: "abc_switch_thumb_material.xml" matches "abc_btn_switch_to_on_mtrl_*.png"
*/
private List<String> findOverlayPngs(String baseName, ZipEntryMap zip) {
List<String> pngs = new ArrayList<>();

// Extract keywords for fuzzy matching (skip short words)
String[] keywords = baseName.toLowerCase().split("_");

for (InputSource inputSource : zip.listInputSources()) {
String path = inputSource.getAlias();
if (!path.endsWith(".png") || !path.startsWith("res/drawable-")) {
continue;
}

String fileName = path.substring(path.lastIndexOf('/') + 1).toLowerCase();

// Count keyword matches
int matches = 0;
for (String keyword : keywords) {
if (keyword.length() > 3 && fileName.contains(keyword)) {
matches++;
}
}

// Require at least 1 keyword match for fuzzy matching
if (matches >= 1 && keywords.length > 2) {
pngs.add(path);
}
}

return pngs;
}

/**
* Updates @null drawable references with actual resource IDs.
* Uses heuristics to match states (checked/unchecked) based on drawable names.
*
* @return true if any references were updated, false otherwise
*/
private boolean updateNullReferences(ResXmlDocument xmlDoc, Map<String, Integer> drawableIds, String baseName) {
ResXmlElement root = xmlDoc.getDocumentElement();
if (root == null) return false;

boolean updated = false;
Map<String, Integer> stateToId = new HashMap<>();

// Map drawable names to states based on naming patterns
for (Map.Entry<String, Integer> entry : drawableIds.entrySet()) {
String name = entry.getKey();
Integer id = entry.getValue();

// Heuristic: "on" or "00012" in name = checked state
if (name.contains("_on_") || name.contains("00012")) {
stateToId.put("checked", id);
} else if (!stateToId.containsKey("default")) {
stateToId.put("default", id);
}
}

// Update @null references
Iterator<ResXmlElement> items = root.recursiveElements();
while (items.hasNext()) {
ResXmlElement item = items.next();
if (!"item".equals(item.getName())) continue;

ResXmlAttribute drawable = item.searchAttributeByName("drawable");
if (drawable != null && drawable.getValueType() == ValueType.REFERENCE && drawable.getData() == 0) {
// Determine which state this item represents
boolean isChecked = false;
Iterator<ResXmlAttribute> attrs = item.getAttributes();
while (attrs.hasNext()) {
ResXmlAttribute attr = attrs.next();
if ("state_checked".equals(attr.getName()) && attr.getValueAsBoolean()) {
isChecked = true;
break;
}
}

// Get appropriate resource ID
Integer newResId = isChecked ?
stateToId.getOrDefault("checked", stateToId.get("default")) :
stateToId.get("default");

if (newResId != null) {
drawable.setData(newResId);
updated = true;
}
}
}

return updated;
}
}