blob: ef56149e7b07e51efb0b8a19ac67aacda309cc8f [file] [log] [blame]
/*
* Copyright (C) 2013 The Android Open Source Project
*
* 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.android.ide.common.res2;
import static com.android.SdkConstants.ATTR_NAME;
import static com.android.SdkConstants.ATTR_TYPE;
import static com.android.SdkConstants.DOT_9PNG;
import static com.android.SdkConstants.DOT_PNG;
import static com.android.SdkConstants.DOT_XML;
import static com.android.SdkConstants.RES_QUALIFIER_SEP;
import static com.android.SdkConstants.TAG_EAT_COMMENT;
import static com.android.SdkConstants.TAG_RESOURCES;
import static com.android.utils.SdkUtils.createPathComment;
import static com.google.common.base.Preconditions.checkState;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.ide.common.internal.PngCruncher;
import com.android.ide.common.internal.PngException;
import com.android.resources.ResourceFolderType;
import com.android.resources.ResourceType;
import com.android.utils.SdkUtils;
import com.android.utils.XmlUtils;
import com.google.common.base.Charsets;
import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import com.google.common.io.Files;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import java.io.File;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Callable;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
/**
* A {@link MergeWriter} for assets, using {@link ResourceItem}.
*/
public class MergedResourceWriter extends MergeWriter<ResourceItem> {
@NonNull
private final PngCruncher mCruncher;
@NonNull
private final ResourcePreprocessor mPreprocessor;
/**
* If non-null, points to a File that we should write public.txt to
*/
private final File mPublicFile;
private DocumentBuilderFactory mFactory;
private boolean mInsertSourceMarkers = true;
private final boolean mCrunchPng;
private final boolean mProcess9Patch;
private final int mCruncherKey;
/**
* map of XML values files to write after parsing all the files. the key is the qualifier.
*/
private ListMultimap<String, ResourceItem> mValuesResMap;
/**
* Set of qualifier that had a previously written resource now gone. This is to keep a list of
* values files that must be written out even with no touched or updated resources, in case one
* or more resources were removed.
*/
private Set<String> mQualifierWithDeletedValues;
public MergedResourceWriter(@NonNull File rootFolder,
@NonNull PngCruncher pngRunner,
boolean crunchPng,
boolean process9Patch,
@Nullable File publicFile,
@NonNull ResourcePreprocessor preprocessor) {
super(rootFolder);
mCruncher = pngRunner;
mCruncherKey = mCruncher.start();
mCrunchPng = crunchPng;
mProcess9Patch = process9Patch;
mPublicFile = publicFile;
mPreprocessor = preprocessor;
}
/**
* Sets whether this manifest merger will insert source markers into the merged source
*
* @param insertSourceMarkers if true, insert source markers
*/
public void setInsertSourceMarkers(boolean insertSourceMarkers) {
mInsertSourceMarkers = insertSourceMarkers;
}
/**
* Returns whether this manifest merger will insert source markers into the merged source
*
* @return whether this manifest merger will insert source markers into the merged source
*/
public boolean isInsertSourceMarkers() {
return mInsertSourceMarkers;
}
@Override
public void start(@NonNull DocumentBuilderFactory factory) throws ConsumerException {
super.start(factory);
mValuesResMap = ArrayListMultimap.create();
mQualifierWithDeletedValues = Sets.newHashSet();
mFactory = factory;
}
@Override
public void end() throws ConsumerException {
// Make sure all PNGs are generated first.
super.end();
try {
// Wait for all PNGs to be crunched.
mCruncher.end(mCruncherKey);
} catch (InterruptedException e) {
throw new ConsumerException(e);
}
mValuesResMap = null;
mQualifierWithDeletedValues = null;
mFactory = null;
}
@Override
public boolean ignoreItemInMerge(ResourceItem item) {
return item.getIgnoredFromDiskMerge();
}
@Override
public void addItem(@NonNull final ResourceItem item) throws ConsumerException {
final ResourceFile.FileType type = item.getSourceType();
if (type == ResourceFile.FileType.XML_VALUES) {
// this is a resource for the values files
// just add the node to write to the map based on the qualifier.
// We'll figure out later if the files needs to be written or (not)
mValuesResMap.put(item.getQualifiers(), item);
} else {
checkState(item.getSource() != null);
// This is a single value file or a set of generated files. Only write it if the state
// is TOUCHED.
if (item.isTouched()) {
getExecutor().execute(new Callable<Void>() {
@Override
public Void call() throws Exception {
File file = item.getFile();
String filename = file.getName();
String folderName = getFolderName(item);
File typeFolder = new File(getRootFolder(), folderName);
try {
createDir(typeFolder);
} catch (IOException ioe) {
throw MergingException.wrapException(ioe).withFile(typeFolder).build();
}
File outFile = new File(typeFolder, filename);
if (type == DataFile.FileType.GENERATED_FILES) {
mPreprocessor.generateFile(file, item.getSource().getFile());
}
try {
if (item.getType() == ResourceType.RAW) {
// Don't crunch, don't insert source comments, etc - leave alone.
Files.copy(file, outFile);
} else if (filename.endsWith(DOT_PNG)) {
if (mCrunchPng && mProcess9Patch) {
mCruncher.crunchPng(mCruncherKey, file, outFile);
} else {
// we should not crunch the png files, but we should still
// process the nine patch.
if (mProcess9Patch && filename.endsWith(DOT_9PNG)) {
mCruncher.crunchPng(mCruncherKey, file, outFile);
} else {
Files.copy(file, outFile);
}
}
} else if (mInsertSourceMarkers && filename.endsWith(DOT_XML)) {
SdkUtils.copyXmlWithSourceReference(file, outFile);
} else {
Files.copy(file, outFile);
}
} catch (PngException e) {
throw MergingException.wrapException(e).withFile(file).build();
} catch (IOException ioe) {
throw MergingException.wrapException(ioe).withFile(file).build();
}
return null;
}
});
}
}
}
@Override
public void removeItem(@NonNull ResourceItem removedItem, @Nullable ResourceItem replacedBy)
throws ConsumerException {
ResourceFile.FileType removedType = removedItem.getSourceType();
ResourceFile.FileType replacedType = replacedBy != null
? replacedBy.getSourceType()
: null;
switch (removedType) {
case SINGLE_FILE: // Fall through.
case GENERATED_FILES:
if (replacedType == DataFile.FileType.SINGLE_FILE
|| replacedType == DataFile.FileType.GENERATED_FILES) {
// Save one IO operation and don't delete a file that will be overwritten
// anyway.
break;
}
removeOutFile(removedItem);
break;
case XML_VALUES:
mQualifierWithDeletedValues.add(removedItem.getQualifiers());
break;
default:
throw new IllegalStateException();
}
}
@Override
protected void postWriteAction() throws ConsumerException {
// now write the values files.
for (String key : mValuesResMap.keySet()) {
// the key is the qualifier.
// check if we have to write the file due to deleted values.
// also remove it from that list anyway (to detect empty qualifiers later).
boolean mustWriteFile = mQualifierWithDeletedValues.remove(key);
// get the list of items to write
List<ResourceItem> items = mValuesResMap.get(key);
// now check if we really have to write it
if (!mustWriteFile) {
for (ResourceItem item : items) {
if (item.isTouched()) {
mustWriteFile = true;
break;
}
}
}
if (mustWriteFile) {
String folderName = key.isEmpty() ?
ResourceFolderType.VALUES.getName() :
ResourceFolderType.VALUES.getName() + RES_QUALIFIER_SEP + key;
File valuesFolder = new File(getRootFolder(), folderName);
// Name of the file is the same as the folder as AAPT gets confused with name
// collision when not normalizing folders name.
File outFile = new File(valuesFolder, folderName + DOT_XML);
ResourceFile currentFile = null;
try {
createDir(valuesFolder);
DocumentBuilder builder = mFactory.newDocumentBuilder();
Document document = builder.newDocument();
final String publicTag = ResourceType.PUBLIC.getName();
List<Node> publicNodes = null;
Node rootNode = document.createElement(TAG_RESOURCES);
document.appendChild(rootNode);
Collections.sort(items);
for (ResourceItem item : items) {
Node nodeValue = item.getValue();
if (nodeValue != null && publicTag.equals(nodeValue.getNodeName())) {
if (publicNodes == null) {
publicNodes = Lists.newArrayList();
}
publicNodes.add(nodeValue);
continue;
}
// add a carriage return so that the nodes are not all on the same line.
// also add an indent of 4 spaces.
rootNode.appendChild(document.createTextNode("\n "));
ResourceFile source = item.getSource();
if (source != currentFile && source != null && mInsertSourceMarkers) {
currentFile = source;
File file = source.getFile();
rootNode.appendChild(document.createComment(
createPathComment(file, true)));
rootNode.appendChild(document.createTextNode("\n "));
// Add an <eat-comment> element to ensure that this comment won't
// get merged into a potential comment from the next child (or
// even added as the sole comment in the R class)
rootNode.appendChild(document.createElement(TAG_EAT_COMMENT));
rootNode.appendChild(document.createTextNode("\n "));
}
Node adoptedNode = NodeUtils.adoptNode(document, nodeValue);
rootNode.appendChild(adoptedNode);
}
// finish with a carriage return
rootNode.appendChild(document.createTextNode("\n"));
currentFile = null;
String content = XmlUtils.toXml(document);
Files.write(content, outFile, Charsets.UTF_8);
if (publicNodes != null && mPublicFile != null) {
// Generate public.txt:
int size = publicNodes.size();
StringBuilder sb = new StringBuilder(size * 80);
for (Node node : publicNodes) {
if (node.getNodeType() == Node.ELEMENT_NODE) {
Element element = (Element) node;
String name = element.getAttribute(ATTR_NAME);
String type = element.getAttribute(ATTR_TYPE);
if (!name.isEmpty() && !type.isEmpty()) {
sb.append(type).append(' ').append(name).append('\n');
}
}
}
File parentFile = mPublicFile.getParentFile();
if (!parentFile.exists()) {
boolean mkdirs = parentFile.mkdirs();
if (!mkdirs) {
throw new IOException("Could not create " + parentFile);
}
}
String text = sb.toString();
Files.write(text, mPublicFile, Charsets.UTF_8);
}
} catch (Throwable t) {
ConsumerException exception = new ConsumerException(t,
currentFile != null ? currentFile.getFile() : outFile);
throw exception;
}
}
}
// now remove empty values files.
for (String key : mQualifierWithDeletedValues) {
String folderName = key != null && !key.isEmpty() ?
ResourceFolderType.VALUES.getName() + RES_QUALIFIER_SEP + key :
ResourceFolderType.VALUES.getName();
removeOutFile(folderName, folderName + DOT_XML);
}
}
/**
* Removes a file that already exists in the out res folder. This has to be a non value file.
*
* @param resourceItem the source item that created the file to remove.
* @return true if success.
*/
private boolean removeOutFile(ResourceItem resourceItem) {
return removeOutFile(getFolderName(resourceItem), resourceItem.getFile().getName());
}
/**
* Removes a file from a folder based on a sub folder name and a filename
*
* @param folderName the sub folder name
* @param fileName the file name.
* @return true if success.
*/
private boolean removeOutFile(String folderName, String fileName) {
File valuesFolder = new File(getRootFolder(), folderName);
File outFile = new File(valuesFolder, fileName);
return outFile.delete();
}
private synchronized void createDir(File folder) throws IOException {
if (!folder.isDirectory() && !folder.mkdirs()) {
throw new IOException("Failed to create directory: " + folder);
}
}
/**
* Calculates the right folder name give a resource item.
*
* @param resourceItem the resource item to calculate the folder name from.
* @return a relative folder name
*/
@NonNull
private static String getFolderName(ResourceItem resourceItem) {
ResourceType itemType = resourceItem.getType();
String folderName = itemType.getName();
String qualifiers = resourceItem.getQualifiers();
if (!qualifiers.isEmpty()) {
folderName = folderName + RES_QUALIFIER_SEP + qualifiers;
}
return folderName;
}
}