Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds support for resources.arsc parsing #22

Merged
merged 21 commits into from
Jul 24, 2024
Merged
Show file tree
Hide file tree
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
3 changes: 1 addition & 2 deletions play-validations/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,4 @@ android.builder.sdkDownload=false
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=false
android.nonFinalResIds=false
android.suppressUnsupportedCompileSdk=34
android.enableResourceOptimizations=false
android.suppressUnsupportedCompileSdk=34
1 change: 1 addition & 0 deletions play-validations/memory-footprint/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ dependencies {
implementation 'com.google.guava:guava:32.0.1-jre'
implementation 'com.google.mug:mug-guava:6.6'
implementation 'commons-cli:commons-cli:1.5.0'
implementation 'com.android.tools.apkparser:binary-resources:31.4.0'
implementation project(':validator')

testImplementation 'junit:junit:4.13.2'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package com.google.wear.watchface.dfx.memory;

import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
* Represents a resource, from an AAB or APK.
*/
public class AndroidResource {
private static final Pattern VALID_RESOURCE_PATH = Pattern.compile(".*res/([^-/]+).*/([^.]+)(\\.|)(.*|)$");
private static final int VALID_RESOURCE_GROUPS = 4;

// Resource type, for example "raw", "asset", "drawable" etc.
private final String resourceType;

// Resource name, for example "watchface" for res/raw/watchface.xml.
private final String resourceName;

// File extension of the resource, for example "xml" for res/raw/watchface.xml
private final String extension;

// Path in the package. This is the obfuscated path to the actual data, where obfuscation has
// been used, for example "res/raw/watchface.xml" may point to something like "res/li.xml".
private final Path filePath;

// The resource data itself.
private final byte[] data;

public AndroidResource(
String resourceType,
String resourceName,
String extension,
Path filePath,
byte[] data
) {
this.resourceType = resourceType;
this.resourceName = resourceName;
this.extension = extension;
this.filePath = filePath;
this.data = data;
}

public String getResourceName() {
return resourceName;
}

public Path getFilePath() {
return filePath;
}

public byte[] getData() {
return data;
}

// TODO: This should be improved to parse res/xml/watch_face_info.xml where present, so as not
// to assume all XML files in the res/raw directory are watch face XML files.
public Boolean isWatchFaceXml() {
return "xml".equals(extension) && "raw".equals(resourceType);
}

public Boolean isDrawable() { return "drawable".equals(resourceType); }

public Boolean isFont() { return "font".equals(resourceType); }

public Boolean isAsset() { return "asset".equals(resourceType); }

public Boolean isRaw() { return "raw".equals(resourceType); }

static AndroidResource fromPath(Path filePath, byte[] data) {
String pathWithFwdSlashes = filePath.toString().replace('\\', '/');
Matcher m = VALID_RESOURCE_PATH.matcher(pathWithFwdSlashes);
if (m.matches() && m.groupCount() == VALID_RESOURCE_GROUPS) {
String resType = m.group(1);
String resName = m.group(2);
String ext = m.group(4);
return new AndroidResource(
resType,
resName,
ext,
filePath,
data
);
}
throw new RuntimeException("Not a valid resource file: " + m.matches());
}

static AndroidResource fromPath(String filePath, byte[] data) {
return fromPath(Paths.get(filePath), data);
}

static Boolean isValidResourcePath(Path filePath) {
Matcher m = VALID_RESOURCE_PATH.matcher(filePath.toString());
return m.matches() && m.groupCount() == VALID_RESOURCE_GROUPS;
}

static Boolean isValidResourcePath(String filePath) {
return isValidResourcePath(Paths.get(filePath));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
package com.google.wear.watchface.dfx.memory;

import com.google.common.io.Files;
import com.google.devrel.gmscore.tools.apk.arsc.BinaryResourceFile;
import com.google.devrel.gmscore.tools.apk.arsc.BinaryResourceValue;
import com.google.devrel.gmscore.tools.apk.arsc.Chunk;
import com.google.devrel.gmscore.tools.apk.arsc.ResourceTableChunk;
import com.google.devrel.gmscore.tools.apk.arsc.StringPoolChunk;
import com.google.devrel.gmscore.tools.apk.arsc.TypeChunk;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Iterator;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.Spliterator;
import java.util.Spliterators;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipInputStream;

/**
* Represents all the resources of interest in the Android package.
* <p>
* Where obfuscation has been applied, the mapping is derived, for the creation of the
* AndroidResource objects. For example, for a logical resource res/raw/watchface.xml, an APK
* may in fact store this as res/aB.xml. The resources.arsc file contains this mapping, and the
* AndroidResourceLoader provides a stream of resources with their logical types, names and data.
* <p>
* Note that more than one AndroidResource object can exist for the given dimensions. For example,
* if there is a drawable and a drawable-fr folder, then there may be multiple AndroidResource
* entries for drawables with the same type, name and extension. The configuration detail, e.g.
* "fr" or "default", is not currently exposed in the AndroidResource objects as it isn't used.
*/
public class AndroidResourceLoader {
// Only certain resource types are of interest to the evaluator, notably, not string resources.
private static final Set<String> RESOURCE_TYPES = Set.of("raw", "xml", "drawable", "font", "asset");
private static final String RESOURCES_FILE_NAME = "resources.arsc";

private AndroidResourceLoader() { }

/**
* Creates a resource stream from a path to an AAB structure on the file system.
*
* @param aabPath The path to the root of the AAB directory.
* @return A stream of Android resource objects.
* @throws IOException when the resources file cannot be found, or other IO errors occur.
*/
static Stream<AndroidResource> streamFromAabDirectory(Path aabPath) throws IOException {
Stream<Path> childrenFilesStream = java.nio.file.Files.walk(aabPath);
int relativePathOffset = aabPath.toString().length() + 1;

return childrenFilesStream
.map(
filePath -> {
AndroidResource resource = null;
if (AndroidResource.isValidResourcePath(filePath)) {
try {
resource = AndroidResource.fromPath(
Paths.get(filePath.toString().substring(relativePathOffset)),
java.nio.file.Files.readAllBytes(filePath)
);
} catch (IOException e) {
throw new RuntimeException(e);
}
} else if (filePath.endsWith("manifest/AndroidManifest.xml")) {
try {
resource = new AndroidResource(
"xml",
"AndroidManifest",
"xml",
Paths.get(filePath.toString().substring(relativePathOffset)),
java.nio.file.Files.readAllBytes(filePath)
);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
return resource;
}
).filter(Objects::nonNull);
}

/**
* Creates a stream of resource objects from the AAB file.
*
* @param aabZipFile The zip file object representing the AAB.
* @return A stream of resource objects.
*/
static Stream<AndroidResource> streamFromAabFile(ZipFile aabZipFile) {
return aabZipFile.stream()
.map(
zipEntry -> {
Path zipEntryPath = Paths.get(zipEntry.getName());
AndroidResource resource = null;
if (AndroidResource.isValidResourcePath(zipEntryPath)) {
try {
resource = AndroidResource.fromPath(
zipEntryPath,
aabZipFile.getInputStream(zipEntry).readAllBytes()
);
} catch (IOException e) {
throw new RuntimeException(e);
}
} else if (zipEntry.getName().endsWith("manifest/AndroidManifest.xml")) {
try {
resource = new AndroidResource(
"xml",
"AndroidManifest",
"xml",
Paths.get(zipEntry.getName()),
aabZipFile.getInputStream(zipEntry).readAllBytes()
);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
return resource;
}
).filter(Objects::nonNull);
}

/**
* Creates a resource stream from a base split entry within an archive
*
* @param baseSplitZipStream The zip entry for the base split.
* @return A stream of resource objects.
* @throws IOException when the resources file cannot be found, or other IO errors occur.
*/
static Stream<AndroidResource> streamFromMokkaZip(ZipInputStream baseSplitZipStream) throws IOException {

Iterator<AndroidResource> iterator =
new Iterator<AndroidResource>() {
private ZipEntry zipEntry;

@Override
public boolean hasNext() {
try {
zipEntry = baseSplitZipStream.getNextEntry();
// Advance over entries in the zip that aren't relevant.
while (zipEntry != null && !AndroidResource.isValidResourcePath(zipEntry.getName())) {
zipEntry = baseSplitZipStream.getNextEntry();
}
} catch (IOException e) {
throw new RuntimeException(e);
}
return zipEntry != null;
}

@Override
public AndroidResource next() {
System.out.println(zipEntry);
byte[] entryData;
try {
entryData = readAllBytes(baseSplitZipStream);
} catch (IOException e) {
throw new RuntimeException(e);
}
return AndroidResource.fromPath(zipEntry.getName(), entryData);
}
};

return StreamSupport.stream(
Spliterators.spliteratorUnknownSize(iterator, Spliterator.ORDERED),
false);
}

/**
* Creates a resource stream from an APK zip file.
* <p>
* APK files can have their resources obfuscated, so it is necessary to extract the mapping
* between the original path and the path in the obfuscated zip.
*
* @param zipFile The APK zip file
* @return A stream of resource objects.
* @throws IOException when errors loading resources occur.
*/
static Stream<AndroidResource> streamFromApkFile(ZipFile zipFile) throws IOException {
ZipEntry arscEntry = new ZipEntry(RESOURCES_FILE_NAME);
InputStream is = zipFile.getInputStream(arscEntry);

BinaryResourceFile resources = BinaryResourceFile.fromInputStream(is);
List<Chunk> chunks = resources.getChunks();
if (chunks.isEmpty()) {
throw new IOException("no chunks");
}
if (!(chunks.get(0) instanceof ResourceTableChunk)) {
throw new IOException("no res table chunk");
}
ResourceTableChunk table = (ResourceTableChunk) chunks.get(0);
StringPoolChunk stringPool = table.getStringPool();

List<TypeChunk> typeChunks = table.getPackages()
.stream()
.flatMap(p -> p.getTypeChunks().stream())
.toList();

return typeChunks.stream()
.flatMap(c -> c.getEntries().values().stream())
.filter(t -> RESOURCE_TYPES.contains(t.typeName()))
.filter(t -> t.value().type() == BinaryResourceValue.Type.STRING)
.map(entry -> {
Path path = Path.of(stringPool.getString(entry.value().data()));
byte[] data = null;
try {
data = zipFile.getInputStream(new ZipEntry(path.toString())).readAllBytes();
} catch (IOException e) {
throw new RuntimeException(e);
}

return new AndroidResource(
entry.parent().getTypeName(),
entry.key(),
Files.getFileExtension(path.toString()),
path,
data
);
});
}

/** Read all bytes from an input stream to a new byte array. */
static byte[] readAllBytes(InputStream inputStream) throws IOException {
int len;
byte[] buffer = new byte[1024];
ByteArrayOutputStream bos = new ByteArrayOutputStream();
while ((len = inputStream.read(buffer)) > 0) {
bos.write(buffer, 0, len);
}
return bos.toByteArray();
}
}
Loading
Loading