/* * 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.builder.internal.packaging; import static com.android.SdkConstants.FN_APK_CLASSES_N_DEX; import com.android.SdkConstants; import com.android.annotations.NonNull; import com.android.annotations.Nullable; import com.android.builder.internal.packaging.JavaResourceProcessor.IArchiveBuilder; import com.android.builder.model.PackagingOptions; import com.android.builder.packaging.DuplicateFileException; import com.android.builder.packaging.PackagerException; import com.android.builder.packaging.SealedPackageException; import com.android.builder.signing.SignedJarBuilder; import com.android.builder.signing.SignedJarBuilder.IZipEntryFilter; import com.android.ide.common.signing.CertificateInfo; import com.android.utils.FileUtils; import com.android.utils.ILogger; import com.google.common.io.Closeables; import com.google.common.io.Files; import java.io.BufferedInputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.FilenameFilter; import java.io.IOException; import java.io.InputStream; import java.net.MalformedURLException; import java.net.URL; import java.net.URLConnection; import java.util.Collection; import java.util.HashMap; import java.util.Map; import java.util.Set; import java.util.jar.Attributes; import java.util.jar.Manifest; import java.util.regex.Pattern; /** * Class making the final app package. * The inputs are: * - packaged resources (output of aapt) * - code file (ouput of dx) * - Java resources coming from the project, its libraries, and its jar files * - Native libraries from the project or its library. * */ public final class Packager implements IArchiveBuilder { private static final Pattern PATTERN_NATIVELIB_EXT = Pattern.compile("^.+\\.so$", Pattern.CASE_INSENSITIVE); /** * Filter to detect duplicate entries * */ private final class DuplicateZipFilter implements IZipEntryFilter { private File mInputFile; void reset(File inputFile) { mInputFile = inputFile; } @Override public boolean checkEntry(String archivePath) throws ZipAbortException { mLogger.verbose("=> %s", archivePath); File duplicate = checkFileForDuplicate(archivePath); if (duplicate != null) { // we have a duplicate but it might be the same source file, in this case, // we just ignore the duplicate, and of course, we don't add it again. File potentialDuplicate = new File(mInputFile, archivePath); if (!duplicate.getAbsolutePath().equals(potentialDuplicate.getAbsolutePath())) { throw new DuplicateFileException(archivePath, duplicate, mInputFile); } return false; } else { mAddedFiles.put(archivePath, mInputFile); } return true; } } /** * A filter to filter out binary files like .class */ private static final class NoBinaryZipFilter implements IZipEntryFilter { @NonNull private final IZipEntryFilter parentFilter; private NoBinaryZipFilter(@NonNull IZipEntryFilter parentFilter) { this.parentFilter = parentFilter; } @Override public boolean checkEntry(String archivePath) throws ZipAbortException { return parentFilter.checkEntry(archivePath) && !archivePath.endsWith(".class"); } } private SignedJarBuilder mBuilder = null; private final ILogger mLogger; private boolean mJniDebugMode = false; private boolean mIsSealed = false; private final DuplicateZipFilter mNoDuplicateFilter = new DuplicateZipFilter(); private final NoBinaryZipFilter mNoBinaryZipFilter = new NoBinaryZipFilter(mNoDuplicateFilter); @Nullable private final SignedJarBuilder.IZipEntryFilter mPackagingOptionsFilter; private final HashMap<String, File> mAddedFiles = new HashMap<String, File>(); private final HashMap<String, File> mMergeFiles = new HashMap<String, File>(); /** * Creates a new instance. * * This creates a new builder that will create the specified output file, using the two * mandatory given input files. * * An optional debug keystore can be provided. If set, it is expected that the store password * is 'android' and the key alias and password are 'androiddebugkey' and 'android'. * * An optional {@link ILogger} can also be provided for verbose output. If null, there will * be no output. * * @param apkLocation the file to create * @param resLocation the file representing the packaged resource file. * @param mergingFolder the folder to store files that are being merged. * @param certificateInfo the signing information used to sign the package. Optional the OS path to the debug keystore, if needed or null. * @param logger the logger. * @throws com.android.builder.packaging.PackagerException */ public Packager( @NonNull String apkLocation, @NonNull String resLocation, @NonNull File mergingFolder, CertificateInfo certificateInfo, @Nullable String createdBy, @Nullable PackagingOptions packagingOptions, @Nullable SignedJarBuilder.IZipEntryFilter packagingOptionsFilter, ILogger logger) throws PackagerException { try { File apkFile = new File(apkLocation); checkOutputFile(apkFile); File resFile = new File(resLocation); checkInputFile(resFile); checkMergingFolder(mergingFolder); if (packagingOptions != null) { for (String merge : packagingOptions.getMerges()) { mMergeFiles.put(merge, new File(mergingFolder, merge)); } } mPackagingOptionsFilter = packagingOptionsFilter; mLogger = logger; mBuilder = new SignedJarBuilder( new FileOutputStream(apkFile, false /* append */), certificateInfo != null ? certificateInfo.getKey() : null, certificateInfo != null ? certificateInfo.getCertificate() : null, getLocalVersion(), createdBy); mLogger.verbose("Packaging %s", apkFile.getName()); // add the resources addZipFile(resFile); } catch (PackagerException e) { if (mBuilder != null) { mBuilder.cleanUp(); } throw e; } catch (Exception e) { if (mBuilder != null) { mBuilder.cleanUp(); } throw new PackagerException(e); } } public void addDexFiles(@NonNull File mainDexFolder, @NonNull Collection<File> extraDexFiles) throws DuplicateFileException, SealedPackageException, PackagerException { File[] mainDexFiles = mainDexFolder.listFiles(new FilenameFilter() { @Override public boolean accept(File file, String name) { return name.endsWith(SdkConstants.DOT_DEX); } }); if (mainDexFiles != null && mainDexFiles.length > 0) { // Never rename the dex files in the main dex folder, in case we are in legacy mode // we requires the main dex files to not be renamed. for (File dexFile : mainDexFiles) { addFile(dexFile, dexFile.getName()); } // prepare the index for the next files. int dexIndex = mainDexFiles.length + 1; for (File dexFile : extraDexFiles) { addFile(dexFile, String.format(FN_APK_CLASSES_N_DEX, dexIndex++)); } } } /** * Sets the JNI debug mode. In debug mode, when native libraries are present, the packaging * will also include one or more copies of gdbserver in the final APK file. * * These are used for debugging native code, to ensure that gdbserver is accessible to the * application. * * There will be one version of gdbserver for each ABI supported by the application. * * the gbdserver files are placed in the libs/abi/ folders automatically by the NDK. * * @param jniDebugMode the jni-debug mode flag. */ public void setJniDebugMode(boolean jniDebugMode) { mJniDebugMode = jniDebugMode; } /** * Adds a file to the APK at a given path * @param file the file to add * @param archivePath the path of the file inside the APK archive. * @throws PackagerException if an error occurred * @throws com.android.builder.packaging.SealedPackageException if the APK is already sealed. * @throws DuplicateFileException if a file conflicts with another already added to the APK * at the same location inside the APK archive. */ @Override public void addFile(File file, String archivePath) throws PackagerException, SealedPackageException, DuplicateFileException { if (mIsSealed) { throw new SealedPackageException("APK is already sealed"); } try { doAddFile(file, archivePath); } catch (DuplicateFileException e) { mBuilder.cleanUp(); throw e; } catch (Exception e) { mBuilder.cleanUp(); throw new PackagerException(e, "Failed to add %s", file); } } /** * Adds the content from a zip file. * All file keep the same path inside the archive. * @param zipFile the zip File. * @throws PackagerException if an error occurred * @throws SealedPackageException if the APK is already sealed. * @throws DuplicateFileException if a file conflicts with another already added to the APK * at the same location inside the APK archive. */ void addZipFile(File zipFile) throws PackagerException, SealedPackageException, DuplicateFileException { if (mIsSealed) { throw new SealedPackageException("APK is already sealed"); } FileInputStream fis = null; try { mLogger.verbose("%s:", zipFile); // reset the filter with this input. mNoDuplicateFilter.reset(zipFile); // ask the builder to add the content of the file. fis = new FileInputStream(zipFile); mBuilder.writeZip(fis, mNoDuplicateFilter, null /* ZipEntryExtractor */); } catch (DuplicateFileException e) { mBuilder.cleanUp(); throw e; } catch (Exception e) { mBuilder.cleanUp(); throw new PackagerException(e, "Failed to add %s", zipFile); } finally { try { Closeables.close(fis, true /* swallowIOException */); } catch (IOException e) { // ignore } } } /** * Adds all resources from a merged folder or jar file. There cannot be any duplicates and all * files present must be added unless it is a "binary" file like a .class or .dex (jack * produces the classes.dex in the same location as the obfuscated resources). * @param jarFileOrDirectory a jar file or directory reference. * @throws PackagerException could not add an entry to the package. * @throws DuplicateFileException if an entry with the same name was already present in the * package being built while adding the jarFileOrDirectory content. */ public void addResources(@NonNull File jarFileOrDirectory) throws PackagerException, DuplicateFileException { mNoDuplicateFilter.reset(jarFileOrDirectory); InputStream fis = null; try { if (jarFileOrDirectory.isDirectory()) { addResourcesFromDirectory(jarFileOrDirectory, ""); } else { fis = new BufferedInputStream(new FileInputStream(jarFileOrDirectory)); mBuilder.writeZip(fis, mNoBinaryZipFilter, null /* ZipEntryExtractor */); } } catch (DuplicateFileException e) { mBuilder.cleanUp(); throw e; } catch (Exception e) { mBuilder.cleanUp(); throw new PackagerException(e, "Failed to add %s", jarFileOrDirectory); } finally { try { if (fis != null) { Closeables.close(fis, true /* swallowIOException */); } } catch (IOException e) { // ignore. } } } private void addResourcesFromDirectory(@NonNull File directory, String path) throws IOException, IZipEntryFilter.ZipAbortException { File[] directoryFiles = directory.listFiles(); if (directoryFiles == null) { return; } for (File file : directoryFiles) { String entryName = path.isEmpty() ? file.getName() : path + "/" + file.getName(); if (file.isDirectory()) { addResourcesFromDirectory(file, entryName); } else { doAddFile(file, entryName); } } } /** * Adds the native libraries from the top native folder. * The content of this folder must be the various ABI folders. * * This may or may not copy gdbserver into the apk based on whether the debug mode is set. * * @param nativeFolder the root folder containing the abi folders which contain the .so * @param abiFilters a list of abi filters to include. If null or empty, all abis are included. * * @throws PackagerException if an error occurred * @throws SealedPackageException if the APK is already sealed. * @throws DuplicateFileException if a file conflicts with another already added to the APK * at the same location inside the APK archive. * * @see #setJniDebugMode(boolean) */ public void addNativeLibraries(@NonNull File nativeFolder, @Nullable Set<String> abiFilters) throws PackagerException, SealedPackageException, DuplicateFileException { if (mIsSealed) { throw new SealedPackageException("APK is already sealed"); } if (!nativeFolder.isDirectory()) { // not a directory? check if it's a file or doesn't exist if (nativeFolder.exists()) { throw new PackagerException("%s is not a folder", nativeFolder); } else { throw new PackagerException("%s does not exist", nativeFolder); } } File[] abiList = nativeFolder.listFiles(); mLogger.verbose("Native folder: %s", nativeFolder); if (abiList != null) { for (File abi : abiList) { if (abiFilters != null && !abiFilters.isEmpty() && !abiFilters.contains(abi.getName())) { continue; } if (abi.isDirectory()) { // ignore files File[] libs = abi.listFiles(); if (libs != null) { for (File lib : libs) { // only consider files that are .so or, if in debug mode, that // are gdbserver executables String libName = lib.getName(); if (lib.isFile() && (PATTERN_NATIVELIB_EXT.matcher(lib.getName()).matches() || (mJniDebugMode && (SdkConstants.FN_GDBSERVER.equals(libName) || SdkConstants.FN_GDB_SETUP.equals(libName))))) { String path = SdkConstants.FD_APK_NATIVE_LIBS + "/" + abi.getName() + "/" + libName; try { if (mPackagingOptionsFilter == null || mPackagingOptionsFilter.checkEntry(path)) { doAddFile(lib, path); } } catch (Exception e) { mBuilder.cleanUp(); throw new PackagerException(e, "Failed to add %s", lib); } } } } } } } } /** * Seals the APK, and signs it if necessary. * * @throws PackagerException if an error occurred * @throws SealedPackageException if the APK is already sealed. */ public void sealApk() throws PackagerException, SealedPackageException { if (mIsSealed) { throw new SealedPackageException("APK is already sealed"); } // Add all the merged files that are pending to be packaged. for (Map.Entry<String, File>entry : mMergeFiles.entrySet()) { File inputFile = entry.getValue(); String archivePath = entry.getKey(); try { if (inputFile.exists()) { mBuilder.writeFile(inputFile, archivePath); } } catch (IOException e) { mBuilder.cleanUp(); throw new PackagerException(e, "Failed to add merged file %s", inputFile); } } // close and sign the application package. try { mBuilder.close(); mIsSealed = true; } catch (Exception e) { throw new PackagerException(e, "Failed to seal APK"); } finally { mBuilder.cleanUp(); } } private void doAddFile(File file, String archivePath) throws IZipEntryFilter.ZipAbortException, IOException { // If a file has to be merged, write it to a file in the merging folder and add it later. if (mMergeFiles.keySet().contains(archivePath)) { File mergingFile = mMergeFiles.get(archivePath); Files.createParentDirs(mergingFile); FileOutputStream fos = new FileOutputStream(mMergeFiles.get(archivePath), true); try { fos.write(Files.toByteArray(file)); } finally { fos.close(); } } else { if (!mNoBinaryZipFilter.checkEntry(archivePath)) { return; } mAddedFiles.put(archivePath, file); mBuilder.writeFile(file, archivePath); } } /** * Checks if the given path in the APK archive has not already been used and if it has been, * then returns a {@link File} object for the source of the duplicate * @param archivePath the archive path to test. * @return A File object of either a file at the same location or an archive that contains a * file that was put at the same location. */ private File checkFileForDuplicate(String archivePath) { return mAddedFiles.get(archivePath); } /** * Checks an output {@link File} object. * This checks the following: * - the file is not an existing directory. * - if the file exists, that it can be modified. * - if it doesn't exists, that a new file can be created. * @param file the File to check * @throws PackagerException If the check fails */ private static void checkOutputFile(File file) throws PackagerException { if (file.isDirectory()) { throw new PackagerException("%s is a directory!", file); } if (file.exists()) { // will be a file in this case. if (!file.canWrite()) { throw new PackagerException("Cannot write %s", file); } } else { try { if (!file.createNewFile()) { throw new PackagerException("Failed to create %s", file); } } catch (IOException e) { throw new PackagerException( "Failed to create '%1$ss': %2$s", file, e.getMessage()); } } } /** * Checks the merger folder is: * - a directory. * - if the folder exists, that it can be modified. * - if it doesn't exists, that a new folder can be created. * @param file the File to check * @throws PackagerException If the check fails */ private static void checkMergingFolder(File file) throws PackagerException { if (file.isFile()) { throw new PackagerException("%s is a file!", file); } if (file.exists()) { // will be a directory in this case. if (!file.canWrite()) { throw new PackagerException("Cannot write %s", file); } } try { FileUtils.emptyFolder(file); } catch (IOException e) { throw new PackagerException(e); } } /** * Checks an input {@link File} object. * This checks the following: * - the file is not an existing directory. * - that the file exists (if <var>throwIfDoesntExist</var> is <code>false</code>) and can * be read. * @param file the File to check * @throws FileNotFoundException if the file is not here. * @throws PackagerException If the file is a folder or a file that cannot be read. */ private static void checkInputFile(File file) throws FileNotFoundException, PackagerException { if (file.isDirectory()) { throw new PackagerException("%s is a directory!", file); } if (file.exists()) { if (!file.canRead()) { throw new PackagerException("Cannot read %s", file); } } else { throw new FileNotFoundException(String.format("%s does not exist", file)); } } public static String getLocalVersion() { Class clazz = Packager.class; String className = clazz.getSimpleName() + ".class"; String classPath = clazz.getResource(className).toString(); if (!classPath.startsWith("jar")) { // Class not from JAR, unlikely return null; } try { String manifestPath = classPath.substring(0, classPath.lastIndexOf('!') + 1) + "/META-INF/MANIFEST.MF"; URLConnection jarConnection = new URL(manifestPath).openConnection(); jarConnection.setUseCaches(false); InputStream jarInputStream = jarConnection.getInputStream(); Attributes attr = new Manifest(jarInputStream).getMainAttributes(); jarInputStream.close(); return attr.getValue("Builder-Version"); } catch (MalformedURLException ignored) { } catch (IOException ignored) { } return null; } }