blob: 092d169b3b0feccef595196d34cb2f29bbf6cb20 [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.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 com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.ide.common.internal.AaptRunner;
import com.android.ide.common.xml.XmlPrettyPrinter;
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.Sets;
import com.google.common.io.Files;
import org.w3c.dom.Document;
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> {
/** Filename to save the merged file as */
public static final String FN_VALUES_XML = "values.xml";
/** Prefix in comments which mark the source locations for merge results */
public static final String FILENAME_PREFIX = "From: ";
@Nullable
private final AaptRunner mAaptRunner;
private boolean mInsertSourceMarkers = true;
/**
* 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, @Nullable AaptRunner aaptRunner) {
super(rootFolder);
mAaptRunner = aaptRunner;
}
/**
* 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() throws ConsumerException {
super.start();
mValuesResMap = ArrayListMultimap.create();
mQualifierWithDeletedValues = Sets.newHashSet();
}
@Override
public void end() throws ConsumerException {
super.end();
mValuesResMap = null;
mQualifierWithDeletedValues = null;
}
@Override
public boolean ignoreItemInMerge(ResourceItem item) {
return item.getIgnoredFromDiskMerge();
}
@Override
public void addItem(@NonNull final ResourceItem item) throws ConsumerException {
ResourceFile.FileType type = item.getSource().getType();
if (type == ResourceFile.FileType.MULTI) {
// 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.getSource().getQualifiers(), item);
} else {
// This is a single value file.
// Only write it if the state is TOUCHED.
if (item.isTouched()) {
getExecutor().execute(new Callable<Void>() {
@Override
public Void call() throws Exception {
ResourceFile resourceFile = item.getSource();
File file = resourceFile.getFile();
String filename = file.getName();
// Validate the filename here. Waiting for aapt isn't good
// because the error messages don't point back to the original
// file (if it's not an XML file) and besides, aapt prints
// the wrong path (it hard-codes "res" into the path for example,
// even if the file is not in a folder named res.
for (int i = 0, n = filename.length(); i < n; i++) {
// This is a direct port of the aapt file check in aapt's
// Resource.cpp#makeFileResources validation
char c = filename.charAt(i);
if (!((c >= 'a' && c <= 'z')
|| (c >= '0' && c <= '9')
|| c == '_' || c == '.')) {
String message =
"Invalid file name: must contain only lowercase "
+ "letters and digits ([a-z0-9_.])";
throw new MergingException(message).setFile(file);
}
}
ResourceType itemType = item.getType();
String folderName = itemType.getName();
String qualifiers = resourceFile.getQualifiers();
if (!qualifiers.isEmpty()) {
folderName = folderName + RES_QUALIFIER_SEP + qualifiers;
}
File typeFolder = new File(getRootFolder(), folderName);
try {
createDir(typeFolder);
} catch (IOException ioe) {
throw new MergingException(ioe).setFile(typeFolder);
}
File outFile = new File(typeFolder, filename);
try {
if (itemType == ResourceType.RAW) {
// Don't crunch, don't insert source comments, etc - leave alone.
Files.copy(file, outFile);
} else if (mAaptRunner != null && filename.endsWith(DOT_PNG)) {
// run aapt in single crunch mode on the original file to write the
// destination file.
mAaptRunner.crunchPng(file, outFile);
} else if (mInsertSourceMarkers && filename.endsWith(DOT_XML)) {
SdkUtils.copyXmlWithSourceReference(file, outFile);
} else {
Files.copy(file, outFile);
}
} catch (IOException ioe) {
throw new MergingException(ioe).setFile(file);
}
return null;
}
});
}
}
}
@Override
public void removeItem(@NonNull ResourceItem removedItem, @Nullable ResourceItem replacedBy)
throws ConsumerException {
ResourceFile.FileType removedType = removedItem.getSource().getType();
ResourceFile.FileType replacedType = replacedBy != null ?
replacedBy.getSource().getType() : null;
if (removedType == replacedType) {
// if the type is multi, then we make sure to flag the qualifier as deleted.
if (removedType == ResourceFile.FileType.MULTI) {
mQualifierWithDeletedValues.add(
removedItem.getSource().getQualifiers());
} else {
// both are single type resources, so we actually don't delete the previous
// file as the new one will replace it instead.
}
} else if (removedType == ResourceFile.FileType.SINGLE) {
// removed type is single.
// The case of both single type is above, so here either, there is no replacement
// or the replacement is multi. We always need to remove the old file.
// if replacedType is non-null, then it was values, if not,
removeOutFile(removedItem.getSource());
} else {
// removed type is multi.
// whether the new type is single or doesn't exist, we always need to mark the qualifier
// for rewrite.
mQualifierWithDeletedValues.add(removedItem.getSource().getQualifiers());
}
}
@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);
File outFile = new File(valuesFolder, FN_VALUES_XML);
ResourceFile currentFile = null;
try {
createDir(valuesFolder);
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(true);
factory.setValidating(false);
factory.setIgnoringComments(true);
DocumentBuilder builder;
builder = factory.newDocumentBuilder();
Document document = builder.newDocument();
Node rootNode = document.createElement(TAG_RESOURCES);
document.appendChild(rootNode);
Collections.sort(items);
for (ResourceItem item : items) {
ResourceFile source = item.getSource();
if (source != currentFile && source != null && mInsertSourceMarkers) {
currentFile = source;
rootNode.appendChild(document.createTextNode("\n"));
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, item.getValue());
rootNode.appendChild(adoptedNode);
}
currentFile = null;
String content;
try {
content = XmlPrettyPrinter.prettyPrint(document, true);
} catch (Throwable t) {
content = XmlUtils.toXml(document, false);
}
Files.write(content, outFile, Charsets.UTF_8);
} catch (Throwable t) {
ConsumerException exception = new ConsumerException(t);
exception.setFile(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, FN_VALUES_XML);
}
}
/**
* Removes a file that already exists in the out res folder. This has to be a non value file.
*
* @param resourceFile the source file that created the file to remove.
* @return true if success.
*/
private boolean removeOutFile(ResourceFile resourceFile) {
if (resourceFile.getType() == ResourceFile.FileType.MULTI) {
throw new IllegalArgumentException("SourceFile cannot be a FileType.MULTI");
}
File file = resourceFile.getFile();
String fileName = file.getName();
String folderName = file.getParentFile().getName();
return removeOutFile(folderName, fileName);
}
/**
* 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);
}
}
}