// Copyright 2015 The Bazel Authors. All rights reserved. // // 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.google.devtools.build.android; import com.google.common.collect.ImmutableBiMap; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.FactoryConfigurationError; import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.TransformerException; import javax.xml.transform.TransformerFactory; import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import org.xml.sax.SAXException; /** * Modifies a {@link MergedAndroidData} manifest for the specified densities. */ public class DensitySpecificManifestProcessor { static final ImmutableList<String> SCREEN_SIZES = ImmutableList.of( "small", "normal", "large", "xlarge"); static final ImmutableBiMap<String, String> PLAY_STORE_SUPPORTED_DENSITIES = ImmutableBiMap.<String, String>builder() .put("ldpi", "ldpi") .put("mdpi", "mdpi") .put("tvdpi", "213") .put("hdpi", "hdpi") .put("280dpi", "280") .put("xhdpi", "xhdpi") .put("400dpi", "400") .put("420dpi", "420") .put("xxhdpi", "480") .put("560dpi", "560") .put("xxxhdpi", "640").build(); private static final ImmutableMap<String, Boolean> SECURE_XML_FEATURES = ImmutableMap.of( XMLConstants.FEATURE_SECURE_PROCESSING, true, "http://xml.org/sax/features/external-general-entities", false, "http://xml.org/sax/features/external-parameter-entities", false, "http://apache.org/xml/features/nonvalidating/load-external-dtd", false); private static DocumentBuilder getSecureDocumentBuilder() throws ParserConfigurationException { DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance( "com.sun.org.apache.xerces.internal.jaxp.DocumentBuilderFactoryImpl", null); factory.setValidating(false); factory.setXIncludeAware(false); for (Map.Entry<String, Boolean> featureAndValue : SECURE_XML_FEATURES.entrySet()) { try { factory.setFeature(featureAndValue.getKey(), featureAndValue.getValue()); } catch (ParserConfigurationException e) { throw new FactoryConfigurationError(e, "Xerces DocumentBuilderFactory doesn't support the required security features: " + e.getMessage()); } } return factory.newDocumentBuilder(); } private final List<String> densities; private final Path out; /** * @param densities An array of string densities to use for filtering resources. * @param out The path to use for the generated manifest. */ public DensitySpecificManifestProcessor(List<String> densities, Path out) { this.densities = densities; this.out = out; } /** * Modifies the manifest to contain a <compatible-screens> section corresponding to the * specified densities. If the manifest already contains a superset of the * <compatible-screens> section to be created, it is left unchanged. * * @throws ManifestProcessingException when the manifest cannot be properly modified. */ public Path process(Path manifest) throws ManifestProcessingException { if (densities.isEmpty()) { return manifest; } try { DocumentBuilder db = getSecureDocumentBuilder(); Document doc = db.parse(Files.newInputStream(manifest)); NodeList manifestElements = doc.getElementsByTagName("manifest"); if (manifestElements.getLength() != 1) { throw new ManifestProcessingException( String.format("Manifest %s does not contain exactly one <manifest> tag. " + "It contains %d.", manifest, manifestElements.getLength())); } Node manifestElement = manifestElements.item(0); Set<String> existingDensities = new HashSet<>(); NodeList screenElements = doc.getElementsByTagName("screen"); for (int i = 0; i < screenElements.getLength(); i++) { Node screen = screenElements.item(i); existingDensities.add(PLAY_STORE_SUPPORTED_DENSITIES.inverse().get( screen.getAttributes().getNamedItem("android:screenDensity").getNodeValue())); } if (existingDensities.containsAll(densities)) { return manifest; } NodeList compatibleScreensElements = doc.getElementsByTagName("compatible-screens"); for (int i = 0; i < compatibleScreensElements.getLength(); i++) { Node compatibleScreensElement = compatibleScreensElements.item(i); compatibleScreensElement.getParentNode().removeChild(compatibleScreensElement); } // If the list of densities provided in the android_binary build rule contains a density not // supported by the Play Store, omit the <compatible-screens> declaration from the manifest to // indicate that this APK supports all densities. This is a temporary fix to support new // density buckets until the Play Store introduces a new density targeting mechanism. boolean omitCompatibleScreens = false; for (String density : densities) { if (!PLAY_STORE_SUPPORTED_DENSITIES.containsKey(density)) { omitCompatibleScreens = true; System.out.println(density + " is not an accepted Play Store density."); System.out.println("Omitting <compatible-screens> declaration from output manifest."); break; } } if (!omitCompatibleScreens) { Node compatibleScreens = doc.createElement("compatible-screens"); manifestElement.appendChild(compatibleScreens); for (String density : densities) { for (String screenSize : SCREEN_SIZES) { Element screen = doc.createElement("screen"); screen.setAttribute("android:screenSize", screenSize); screen.setAttribute("android:screenDensity", PLAY_STORE_SUPPORTED_DENSITIES.get(density)); compatibleScreens.appendChild(screen); } } } Files.createDirectories(out.getParent()); TransformerFactory transformerFactory = TransformerFactory.newInstance(); transformerFactory.newTransformer().transform( new DOMSource(doc), new StreamResult(Files.newOutputStream(out))); return out; } catch (ParserConfigurationException | SAXException | IOException | TransformerException e) { throw new ManifestProcessingException(e.getMessage()); } } }