/*
* Copyright 2017-present Facebook, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
package com.facebook.buck.android.resources;
import com.facebook.buck.util.MoreCollectors;
import com.facebook.buck.util.RichStream;
import com.google.common.base.Supplier;
import com.google.common.base.Suppliers;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Maps;
import com.google.common.io.ByteStreams;
import java.io.ByteArrayInputStream;
import java.io.Closeable;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.SortedMap;
import java.util.zip.CRC32;
import java.util.zip.Deflater;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
/**
* ExoResourceRewriter is the core of constructing build outputs for exo-for-resources.
*
* <p>Some background: Android's resources are packaged into the APK primarily in the resources.arsc
* file with some references out to other files in the APK's res/ directory (.png/.xml mostly). The
* resources.arsc file is a binary file in a format that isn't really well documented, but you can
* get a good idea of its structure by looking at ResourceTable and other classes in this package.
* At runtime, Android constructs an AssetManager from the APK and resource lookups go through that.
* While this is primarily done for an app's own resources, the framework may construct one to
* access an app's resources directly (e.g. for driving animations, for intent pickers, etc) and
* apps can access resources of other apps (e.g. a launcher app will access names/icons).
*
* <p>For exo-for-resources, we determine a minimal set of resources (including referenced files)
* that need to be in the main APK. This set includes all resources referenced from the
* AndroidManifest.xml or from any animation. We construct a resources.arsc for these resources and
* then the primary apk includes those resources. To avoid odd issues, we rewrite the full
* resources.arsc and all compiled .xml files such that references match between the primary apk and
* the exo resources (and without this, we have run into issues).
*
* <p>For assets, we don't package any into the main apk. For exo resources, assets are put into a
* separate zip from the exo .arsc (and resource files).
*
* <p>TODO(cjhopman): The underlying c++ resource handling supports some things that we should take
* advantage of. First, we are able to easily have multiple resource apks (including multiple .arsc
* with the same package id) as long as we don't have the same package-id/type-id pair in different
* .arsc files. Second, there's no restriction that all resources of the same type actually have the
* same type id (e.g. we could have strings with type id 0x01, 0x02, and 0x03). I'm pretty sure that
* aapt's --feature-of/--feature-after work by using different type ids for the same type.
*
* <p>Using these, we should be able to split resources across some larger number of .zips in such a
* way that users will typically only need to install a small number of resources (for example, in a
* particular large app that I've looked at, a vast majority of resource size is spent on just the
* 'strings' type). It's probably also possible for us to construct multiple top-level aapt targets
* that handle smaller subsets of the resources (e.g. construct a separate 'strings' top-level aapt
* rule). While it would be hard to construct multiple aapt rules for a particular type (adding
* restrictions on resource overriding would make it easier) we could still easily split a
* particular type into multiple exo resources zips (using different type ids).
*
* <p>It might be possible to get even more ids to work by using a different package id (other than
* 0x7f). I believe the package id space is partitioned like so:
*
* <ul>
* <li>0x01- framework
* <li>0x02 - 0x7f - potentially any of these are used by OEM overlays. OEM overlays, just like
* the framework, will be loaded into the zygote.
* <li>0x7f - the app
* <li>0x80 and above - (KK+) dynamically loaded resource libraries (like GMS and WebView
* resources), some of these get loaded after your process starts by the framework code that
* processes your apps AndroidManifest, the WebView resources get loaded at runtime, the first
* time you new up a WebView instance.
* </ul>
*
* <p>As the normal Android build system doesn't use anything other than 0x7f, and that's always
* been the only package id used by applications, and that we have some leeway in the type id space,
* I didn't think it was worth it now to further investigate the feasibility/difficulty of using
* different package ids.
*/
public class ExoResourcesRewriter {
private ExoResourcesRewriter() {}
public static void rewrite(Path inputPath, Path primaryResources, Path exoResources)
throws IOException {
try (ApkZip apkZip = new ApkZip(inputPath)) {
UsedResourcesFinder.ResourceClosure closure =
UsedResourcesFinder.computePrimaryApkClosure(apkZip);
ReferenceMapper resMapping =
BringToFrontMapper.construct(ResTablePackage.APP_PACKAGE_ID, closure.idsByType);
// Rewrite the arsc.
apkZip.getResourceTable().reassignIds(resMapping);
// Update the references in xml files.
for (ResourcesXml xml : apkZip.getResourcesXmls()) {
xml.transformReferences(resMapping::map);
}
// Write the full (rearranged) resources to the exo resources.
try (ResourcesZipBuilder zipBuilder = new ResourcesZipBuilder(exoResources)) {
for (ZipEntry entry : apkZip.getEntries()) {
addEntry(
zipBuilder,
entry.getName(),
apkZip.getContent(entry.getName()),
entry.getMethod() == ZipEntry.STORED ? 0 : Deflater.BEST_COMPRESSION,
false);
}
}
// Then, slice out the resources needed for the primary apk.
try (ResourcesZipBuilder zipBuilder = new ResourcesZipBuilder(primaryResources)) {
ResourceTable primaryResourceTable =
ResourceTable.slice(
apkZip.getResourceTable(),
ImmutableMap.copyOf(Maps.transformValues(closure.idsByType, Set::size)));
addEntry(
zipBuilder,
"resources.arsc",
primaryResourceTable.serialize(),
apkZip.getEntry("resources.arsc").getMethod() == ZipEntry.STORED
? 0
: Deflater.BEST_COMPRESSION,
false);
for (String path : RichStream.from(closure.files).sorted().toOnceIterable()) {
ZipEntry entry = apkZip.getEntry(path);
addEntry(
zipBuilder,
entry.getName(),
apkZip.getContent(entry.getName()),
entry.getMethod() == ZipEntry.STORED ? 0 : Deflater.BEST_COMPRESSION,
false);
}
}
}
}
private static void addEntry(
ResourcesZipBuilder zipBuilder,
String name,
byte[] content,
int compressionLevel,
boolean isDirectory)
throws IOException {
// TODO(cjhopman): for files that we don't already have in memory, we should use the builder's
// stream api.
CRC32 crc32 = new CRC32();
crc32.update(content);
zipBuilder.addEntry(
new ByteArrayInputStream(content),
content.length,
crc32.getValue(),
name,
compressionLevel,
isDirectory);
}
private static class ApkZip implements Closeable, UsedResourcesFinder.ApkContentProvider {
private final ZipFile zipFile;
private final SortedMap<String, ZipEntry> entries;
private final Map<String, byte[]> entryContents;
private final Map<String, ResourcesXml> xmlEntries;
private final Supplier<ResourceTable> resourceTable;
public ApkZip(Path inputPath) throws IOException {
this.zipFile = new ZipFile(inputPath.toFile());
this.entries =
Collections.list(zipFile.entries())
.stream()
.collect(MoreCollectors.toImmutableSortedMap(ZipEntry::getName, e -> e));
this.entryContents = new HashMap<>();
this.xmlEntries = new HashMap<>();
this.resourceTable =
Suppliers.memoize(() -> ResourceTable.get(ResChunk.wrap(getContent("resources.arsc"))));
}
@Override
public ResourceTable getResourceTable() {
return resourceTable.get();
}
@Override
public ResourcesXml getXml(String path) {
return xmlEntries.computeIfAbsent(path, this::extractXml);
}
@Override
public boolean hasFile(String path) {
return entries.containsKey(path);
}
@Override
public void close() throws IOException {
zipFile.close();
}
public Iterable<ZipEntry> getEntries() {
return entries.values();
}
public ZipEntry getEntry(String path) {
return entries.get(path);
}
Iterable<ResourcesXml> getResourcesXmls() {
return entries
.keySet()
.stream()
.filter(
name ->
name.equals("AndroidManifest.xml")
|| ((name.startsWith("res")
&& !name.startsWith("res/raw")
&& name.endsWith(".xml"))))
.map(this::getXml)
.collect(MoreCollectors.toImmutableList());
}
byte[] getContent(String path) {
return entryContents.computeIfAbsent(path, this::extractContent);
}
private byte[] extractContent(String path) {
try {
return ByteStreams.toByteArray(zipFile.getInputStream(entries.get(path)));
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private ResourcesXml extractXml(String path) {
return ResourcesXml.get(ResChunk.wrap(getContent(path)));
}
}
}