/*
* 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);
}
}
}
}