/* * Copyright (C) 2010 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.sdklib.internal.export; import com.android.io.FileWrapper; import com.android.io.IAbstractFile; import com.android.io.StreamException; import com.android.sdklib.SdkConstants; import com.android.sdklib.xml.AndroidManifestParser; import com.android.sdklib.xml.ManifestData; import com.android.sdklib.xml.ManifestData.SupportsScreens; import org.xml.sax.SAXException; import java.io.BufferedReader; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.io.PrintStream; import java.util.ArrayList; import java.util.Calendar; import java.util.Collections; import java.util.Formatter; import java.util.HashMap; import java.util.List; import java.util.Map; import javax.xml.parsers.ParserConfigurationException; /** * Helper to export multiple APKs from 1 or or more projects. * <strong>This class is not meant to be accessed from multiple threads</strong> */ public class MultiApkExportHelper { private final static String PROP_VERSIONCODE = "versionCode"; private final static String PROP_PACKAGE = "package"; private final String mExportProjectRoot; private final String mAppPackage; private final int mVersionCode; private final Target mTarget; private ArrayList<ProjectConfig> mProjectList; private ArrayList<ApkData> mApkDataList; final static int MAX_MINOR = 100; final static int MAX_BUILDINFO = 100; final static int OFFSET_BUILD_INFO = MAX_MINOR; final static int OFFSET_VERSION_CODE = OFFSET_BUILD_INFO * MAX_BUILDINFO; private final static String FILE_CONFIG = "projects.config"; private final static String FILE_MINOR_CODE = "minor.codes"; private final static String FOLDER_LOG = "logs"; private final PrintStream mStdio; public static final class ExportException extends Exception { private static final long serialVersionUID = 1L; public ExportException(String message) { super(message); } public ExportException(String format, Object... args) { super(String.format(format, args)); } public ExportException(Throwable cause, String format, Object... args) { super(String.format(format, args), cause); } public ExportException(String message, Throwable cause) { super(message, cause); } } public static enum Target { RELEASE("release"), CLEAN("clean"); private final String mName; Target(String name) { mName = name; } public String getTarget() { return mName; } public static Target getTarget(String value) { for (Target t : values()) { if (t.mName.equals(value)) { return t; } } return null; } } public MultiApkExportHelper(String exportProjectRoot, String appPackage, int versionCode, Target target, PrintStream stdio) { mExportProjectRoot = exportProjectRoot; mAppPackage = appPackage; mVersionCode = versionCode; mTarget = target; mStdio = stdio; } public List<ApkData> getApkData(String projectList) throws ExportException { if (mTarget != Target.RELEASE) { throw new IllegalArgumentException("getApkData must only be called for Target.RELEASE"); } // get the list of apk to export and their configuration. List<ProjectConfig> projects = getProjects(projectList); // look to see if there's a config file from a previous export File configProp = new File(mExportProjectRoot, FILE_CONFIG); if (configProp.isFile()) { compareProjectsToConfigFile(projects, configProp); } // look to see if there's a minor properties file File minorCodeProp = new File(mExportProjectRoot, FILE_MINOR_CODE); Map<Integer, Integer> minorCodeMap = null; if (minorCodeProp.isFile()) { minorCodeMap = getMinorCodeMap(minorCodeProp); } // get the apk from the projects. return getApkData(projects, minorCodeMap); } /** * Returns the list of projects defined by the <var>projectList</var> string. * The projects are checked to be valid Android project and to represent a valid set * of projects for multi-apk export. * If a project does not exist or is not valid, the method will throw a {@link BuildException}. * The string must be a list of paths, relative to the export project path (given to * {@link #MultiApkExportHelper(String, String, int, Target)}), separated by the colon (':') * character. The path separator is expected to be forward-slash ('/') on all platforms. * @param projects the string containing all the relative paths to the projects. This is * usually read from export.properties. * @throws ExportException */ public List<ProjectConfig> getProjects(String projectList) throws ExportException { String[] paths = projectList.split("\\:"); mProjectList = new ArrayList<ProjectConfig>(); for (String path : paths) { path = path.replaceAll("\\/", File.separator); processProject(path, mProjectList); } return mProjectList; } /** * Writes post-export logs and other files. * @throws ExportException if writing the files failed. */ public void writeLogs() throws ExportException { writeConfigProperties(); writeMinorVersionProperties(); writeApkLog(); } private void writeConfigProperties() throws ExportException { OutputStreamWriter writer = null; try { writer = new OutputStreamWriter( new FileOutputStream(new File(mExportProjectRoot, FILE_CONFIG))); writer.append("# PROJECT CONFIG -- DO NOT DELETE.\n"); writeValue(writer, PROP_VERSIONCODE, mVersionCode); for (ProjectConfig project : mProjectList) { writeValue(writer,project.getRelativePath(), project.getConfigString(false /*onlyManifestData*/)); } writer.flush(); } catch (Exception e) { throw new ExportException("Failed to write config log", e); } finally { try { if (writer != null) { writer.close(); } } catch (IOException e) { throw new ExportException("Failed to write config log", e); } } } private void writeMinorVersionProperties() throws ExportException { OutputStreamWriter writer = null; try { writer = new OutputStreamWriter( new FileOutputStream(new File(mExportProjectRoot, FILE_MINOR_CODE))); writer.append( "# Minor version codes.\n" + "# To create update to select APKs without updating the main versionCode\n" + "# edit this file and manually increase the minor version for the select\n" + "# build info.\n" + "# Format of the file is <buildinfo>:<minor>\n"); writeValue(writer, PROP_VERSIONCODE, mVersionCode); for (ApkData apk : mApkDataList) { writeValue(writer, Integer.toString(apk.getBuildInfo()), apk.getMinorCode()); } writer.flush(); } catch (Exception e) { throw new ExportException("Failed to write minor log", e); } finally { try { if (writer != null) { writer.close(); } } catch (IOException e) { throw new ExportException("Failed to write minor log", e); } } } private void writeApkLog() throws ExportException { OutputStreamWriter writer = null; try { File logFolder = new File(mExportProjectRoot, FOLDER_LOG); if (logFolder.isFile()) { throw new ExportException("Cannot create folder '%1$s', file is in the way!", FOLDER_LOG); } else if (logFolder.exists() == false) { logFolder.mkdir(); } Formatter formatter = new Formatter(); formatter.format("%1$s.%2$d-%3$tY%3$tm%3$td-%3$tH%3$tM.log", mAppPackage, mVersionCode, Calendar.getInstance().getTime()); writer = new OutputStreamWriter( new FileOutputStream(new File(logFolder, formatter.toString()))); writer.append("# Multi-APK BUILD LOG.\n"); writeValue(writer, PROP_PACKAGE, mAppPackage); writeValue(writer, PROP_VERSIONCODE, mVersionCode); for (ApkData apk : mApkDataList) { // if there are soft variant, do not display the main log line, as it's not actually // exported. Map<String, String> softVariants = apk.getSoftVariantMap(); if (softVariants.size() > 0) { for (String softVariant : softVariants.keySet()) { writer.append(apk.getLogLine(softVariant)); writer.append('\n'); } } else { writer.append(apk.getLogLine(null)); writer.append('\n'); } } writer.flush(); } catch (Exception e) { throw new ExportException("Failed to write build log", e); } finally { try { if (writer != null) { writer.close(); } } catch (IOException e) { throw new ExportException("Failed to write build log", e); } } } private void writeValue(OutputStreamWriter writer, String name, String value) throws IOException { writer.append(name).append(':').append(value).append('\n'); } private void writeValue(OutputStreamWriter writer, String name, int value) throws IOException { writeValue(writer, name, Integer.toString(value)); } private List<ApkData> getApkData(List<ProjectConfig> projects, Map<Integer, Integer> minorCodes) { mApkDataList = new ArrayList<ApkData>(); // get all the apkdata from all the projects for (ProjectConfig config : projects) { mApkDataList.addAll(config.getApkDataList()); } // sort the projects and assign buildInfo Collections.sort(mApkDataList); int buildInfo = 0; for (ApkData data : mApkDataList) { data.setBuildInfo(buildInfo); if (minorCodes != null) { Integer minorCode = minorCodes.get(buildInfo); if (minorCode != null) { data.setMinorCode(minorCode); } } buildInfo++; } return mApkDataList; } /** * Checks a project for inclusion in the list of exported APK. * <p/>This performs a check on the manifest, as well as gathers more information about * mutli-apk from the project's default.properties file. * If the manifest is correct, a list of apk to export is created and returned. * * @param projectFolder the folder of the project to check * @param projects the list of project to file with the project if it passes validation. * @throws ExportException in case of error. */ private void processProject(String relativePath, ArrayList<ProjectConfig> projects) throws ExportException { // resolve the relative path File projectFolder; try { File path = new File(mExportProjectRoot, relativePath); projectFolder = path.getCanonicalFile(); // project folder must exist and be a directory if (projectFolder.isDirectory() == false) { throw new ExportException( "Project folder '%1$s' is not a valid directory.", projectFolder.getAbsolutePath()); } } catch (IOException e) { throw new ExportException( e, "Failed to resolve path %1$s", relativePath); } try { // Check AndroidManifest.xml is present IAbstractFile androidManifest = new FileWrapper(projectFolder, SdkConstants.FN_ANDROID_MANIFEST_XML); if (androidManifest.exists() == false) { throw new ExportException(String.format( "%1$s is not a valid project (%2$s not found).", relativePath, androidManifest.getOsLocation())); } // output the relative path resolution. mStdio.println(String.format("%1$s => %2$s", relativePath, projectFolder.getAbsolutePath())); // parse the manifest of the project. ManifestData manifestData = AndroidManifestParser.parse(androidManifest); // validate the application package name String manifestPackage = manifestData.getPackage(); if (mAppPackage.equals(manifestPackage) == false) { throw new ExportException( "%1$s package value is not valid. Found '%2$s', expected '%3$s'.", androidManifest.getOsLocation(), manifestPackage, mAppPackage); } // validate that the manifest has no versionCode set. if (manifestData.getVersionCode() != null) { throw new ExportException( "%1$s is not valid: versionCode must not be set for multi-apk export.", androidManifest.getOsLocation()); } // validate that the minSdkVersion is not a codename int minSdkVersion = manifestData.getMinSdkVersion(); if (minSdkVersion == ManifestData.MIN_SDK_CODENAME) { throw new ExportException( "Codename in minSdkVersion is not supported by multi-apk export."); } // compare to other projects already processed to make sure that they are not // identical. for (ProjectConfig otherProject : projects) { // Multiple apk export support difference in: // - min SDK Version // - Screen version // - GL version // - ABI (not managed at the Manifest level). // if those values are the same between 2 manifest, then it's an error. // first the minSdkVersion. if (minSdkVersion == otherProject.getMinSdkVersion()) { // if it's the same compare the rest. SupportsScreens currentSS = manifestData.getSupportsScreensValues(); SupportsScreens previousSS = otherProject.getSupportsScreens(); boolean sameSupportsScreens = currentSS.hasSameScreenSupportAs(previousSS); // if it's the same, then it's an error. Can't export 2 projects that have the // same approved (for multi-apk export) hard-properties. if (manifestData.getGlEsVersion() == otherProject.getGlEsVersion() && sameSupportsScreens) { throw new ExportException( "Android manifests must differ in at least one of the following values:\n" + "- minSdkVersion\n" + "- SupportsScreen (screen sizes only)\n" + "- GL ES version.\n" + "%1$s and %2$s are considered identical for multi-apk export.", relativePath, otherProject.getRelativePath()); } // At this point, either supports-screens or GL are different. // Because supports-screens is the highest priority properties to be // (potentially) different, we must do some extra checks on it. // It must either be the same in both projects (difference is only on GL value), // or follow theses rules: // - Property in each projects must be strictly different, ie both projects // cannot support the same screen size(s). // - Property in each projects cannot overlap, ie a projects cannot support // both a lower and a higher screen size than the other project. // (ie APK1 supports small/large and APK2 supports normal). if (sameSupportsScreens == false) { if (currentSS.hasStrictlyDifferentScreenSupportAs(previousSS) == false) { throw new ExportException( "APK differentiation by Supports-Screens cannot support different APKs supporting the same screen size.\n" + "%1$s supports %2$s\n" + "%3$s supports %4$s\n", relativePath, currentSS.toString(), otherProject.getRelativePath(), previousSS.toString()); } if (currentSS.overlapWith(previousSS)) { throw new ExportException( "Unable to compute APK priority due to incompatible difference in Supports-Screens values.\n" + "%1$s supports %2$s\n" + "%3$s supports %4$s\n", relativePath, currentSS.toString(), otherProject.getRelativePath(), previousSS.toString()); } } } } // project passes first validation. Attempt to create a ProjectConfig object. ProjectConfig config = ProjectConfig.create(projectFolder, relativePath, manifestData); projects.add(config); } catch (SAXException e) { throw new ExportException(e, "Failed to validate %1$s", relativePath); } catch (IOException e) { throw new ExportException(e, "Failed to validate %1$s", relativePath); } catch (StreamException e) { throw new ExportException(e, "Failed to validate %1$s", relativePath); } catch (ParserConfigurationException e) { throw new ExportException(e, "Failed to validate %1$s", relativePath); } } /** * Checks an existing list of {@link ProjectConfig} versus a config file. * @param projects the list of projects to check * @param configProp the config file (must have been generated from a previous export) * @return true if the projects and config file match * @throws ExportException in case of error */ private void compareProjectsToConfigFile(List<ProjectConfig> projects, File configProp) throws ExportException { InputStreamReader reader = null; BufferedReader bufferedReader = null; try { reader = new InputStreamReader(new FileInputStream(configProp)); bufferedReader = new BufferedReader(reader); String line; // List of the ProjectConfig that need to be checked. This is to detect // new Projects added to the setup. // removed projects are detected when an entry in the config file doesn't match // any ProjectConfig in the list. ArrayList<ProjectConfig> projectsToCheck = new ArrayList<ProjectConfig>(); projectsToCheck.addAll(projects); // store the project that doesn't match. ProjectConfig badMatch = null; String errorMsg = null; // recorded whether we checked the version code. this is for when we compare // a project config boolean checkedVersion = false; int lineNumber = 0; while ((line = bufferedReader.readLine()) != null) { lineNumber++; line = line.trim(); if (line.length() == 0 || line.startsWith("#")) { continue; } // read the name of the property int colonPos = line.indexOf(':'); if (colonPos == -1) { // looks like there's an invalid line! throw new ExportException( "Failed to read existing build log. Line %d is not a property line.", lineNumber); } String name = line.substring(0, colonPos); String value = line.substring(colonPos + 1); if (PROP_VERSIONCODE.equals(name)) { try { int versionCode = Integer.parseInt(value); if (versionCode < mVersionCode) { // this means this config file is obsolete and we can ignore it. return; } else if (versionCode > mVersionCode) { // we're exporting at a lower versionCode level than the config file? throw new ExportException( "Incompatible versionCode: Exporting at versionCode %1$d but %2$s file indicate previous export with versionCode %3$d.", mVersionCode, FILE_CONFIG, versionCode); } else if (badMatch != null) { // looks like versionCode is a match, but a project // isn't compatible. break; } else { // record that we did check the versionCode checkedVersion = true; } } catch (NumberFormatException e) { throw new ExportException( "Failed to read integer property %1$s at line %2$d.", PROP_VERSIONCODE, lineNumber); } } else { // looks like this is (or should be) a project line. // name of the property is the relative path. // look for a matching project. ProjectConfig found = null; for (int i = 0 ; i < projectsToCheck.size() ; i++) { ProjectConfig p = projectsToCheck.get(i); if (p.getRelativePath().equals(name)) { found = p; projectsToCheck.remove(i); break; } } if (found == null) { // deleted project! throw new ExportException( "Project %1$s has been removed from the list of projects to export.\n" + "Any change in the multi-apk configuration requires an increment of the versionCode in export.properties.", name); } else { // make a map of properties HashMap<String, String> map = new HashMap<String, String>(); String[] properties = value.split(";"); for (String prop : properties) { int equalPos = prop.indexOf('='); map.put(prop.substring(0, equalPos), prop.substring(equalPos + 1)); } errorMsg = found.compareToProperties(map); if (errorMsg != null) { // bad project config, record the project badMatch = found; // if we've already checked that the versionCode didn't already change // we stop right away. if (checkedVersion) { break; } } } } } if (badMatch != null) { throw new ExportException( "Config for project %1$s has changed from previous export with versionCode %2$d:\n" + "\t%3$s\n" + "Any change in the multi-apk configuration requires an increment of the versionCode in export.properties.", badMatch.getRelativePath(), mVersionCode, errorMsg); } else if (projectsToCheck.size() > 0) { throw new ExportException( "Project %1$s was not part of the previous export with versionCode %2$d.\n" + "Any change in the multi-apk configuration requires an increment of the versionCode in export.properties.", projectsToCheck.get(0).getRelativePath(), mVersionCode); } } catch (IOException e) { throw new ExportException(e, "Failed to read existing config log: %s", FILE_CONFIG); } finally { try { if (reader != null) { reader.close(); } } catch (IOException e) { throw new ExportException(e, "Failed to read existing config log: %s", FILE_CONFIG); } } } private Map<Integer, Integer> getMinorCodeMap(File minorProp) throws ExportException { InputStreamReader reader = null; BufferedReader bufferedReader = null; try { reader = new InputStreamReader(new FileInputStream(minorProp)); bufferedReader = new BufferedReader(reader); String line; boolean foundVersionCode = false; Map<Integer, Integer> map = new HashMap<Integer, Integer>(); int lineNumber = 0; while ((line = bufferedReader.readLine()) != null) { lineNumber++; line = line.trim(); if (line.length() == 0 || line.startsWith("#")) { continue; } // read the name of the property int colonPos = line.indexOf(':'); if (colonPos == -1) { // looks like there's an invalid line! throw new ExportException( "Failed to read existing build log. Line %d is not a property line.", lineNumber); } String name = line.substring(0, colonPos); String value = line.substring(colonPos + 1); if (PROP_VERSIONCODE.equals(name)) { try { int versionCode = Integer.parseInt(value); if (versionCode < mVersionCode) { // this means this minor file is obsolete and we can ignore it. return null; } else if (versionCode > mVersionCode) { // we're exporting at a lower versionCode level than the minor file? throw new ExportException( "Incompatible versionCode: Exporting at versionCode %1$d but %2$s file indicate previous export with versionCode %3$d.", mVersionCode, FILE_MINOR_CODE, versionCode); } foundVersionCode = true; } catch (NumberFormatException e) { throw new ExportException( "Failed to read integer property %1$s at line %2$d.", PROP_VERSIONCODE, lineNumber); } } else { try { map.put(Integer.valueOf(name), Integer.valueOf(value)); } catch (NumberFormatException e) { throw new ExportException( "Failed to read buildInfo property '%1$s' at line %2$d.", line, lineNumber); } } } // if there was no versionCode found, we can't garantee that the minor version // found are for this versionCode if (foundVersionCode == false) { throw new ExportException( "%1$s property missing from file %2$s.", PROP_VERSIONCODE, FILE_MINOR_CODE); } return map; } catch (IOException e) { throw new ExportException(e, "Failed to read existing minor log: %s", FILE_MINOR_CODE); } finally { try { if (reader != null) { reader.close(); } } catch (IOException e) { throw new ExportException(e, "Failed to read existing minor log: %s", FILE_MINOR_CODE); } } } }