/*
* Copyright 2012-present Facebook, Inc.
*
* 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.facebook.buck.android;
import com.android.sdklib.build.ApkBuilder;
import com.android.sdklib.build.ApkCreationException;
import com.android.sdklib.build.DuplicateFileException;
import com.android.sdklib.build.SealedApkException;
import com.facebook.buck.io.ProjectFilesystem;
import com.facebook.buck.jvm.java.JavaRuntimeLauncher;
import com.facebook.buck.step.ExecutionContext;
import com.facebook.buck.step.Step;
import com.facebook.buck.step.StepExecutionResult;
import com.facebook.buck.util.HumanReadableException;
import com.google.common.base.Joiner;
import com.google.common.base.Supplier;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMultimap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Multimap;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintStream;
import java.nio.file.Path;
import java.security.Key;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.UnrecoverableKeyException;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.Collection;
import java.util.Map;
/**
* Merges resources into a final APK. This code is based off of the now deprecated apkbuilder tool:
* https://android.googlesource.com/platform/sdk/+/fd30096196e3747986bdf8a95cc7713dd6e0b239%5E/sdkmanager/libs/sdklib/src/main/java/com/android/sdklib/build/ApkBuilderMain.java
*/
public class ApkBuilderStep implements Step {
/**
* The type of a keystore created via the {@code jarsigner} command in Sun/Oracle Java. See
* http://docs.oracle.com/javase/7/docs/technotes/guides/security/StandardNames.html#KeyStore.
*/
private static final String JARSIGNER_KEY_STORE_TYPE = "jks";
private final ProjectFilesystem filesystem;
private final Path resourceApk;
private final Path dexFile;
private final Path pathToOutputApkFile;
private final ImmutableSet<Path> assetDirectories;
private final ImmutableSet<Path> nativeLibraryDirectories;
private final ImmutableSet<Path> zipFiles;
private final ImmutableSet<Path> jarFilesThatMayContainResources;
private final Path pathToKeystore;
private final Supplier<KeystoreProperties> keystorePropertiesSupplier;
private final boolean debugMode;
private final JavaRuntimeLauncher javaRuntimeLauncher;
/**
* @param resourceApk Path to the Apk which only contains resources, no dex files.
* @param pathToOutputApkFile Path to output our APK to.
* @param dexFile Path to the classes.dex file.
* @param assetDirectories List of paths to assets to be included in the apk.
* @param nativeLibraryDirectories List of paths to native directories.
* @param zipFiles List of paths to zipfiles to be included into the apk.
* @param debugMode Whether or not to run ApkBuilder with debug mode turned on.
*/
public ApkBuilderStep(
ProjectFilesystem filesystem,
Path resourceApk,
Path pathToOutputApkFile,
Path dexFile,
ImmutableSet<Path> assetDirectories,
ImmutableSet<Path> nativeLibraryDirectories,
ImmutableSet<Path> zipFiles,
ImmutableSet<Path> jarFilesThatMayContainResources,
Path pathToKeystore,
Supplier<KeystoreProperties> keystorePropertiesSupplier,
boolean debugMode,
JavaRuntimeLauncher javaRuntimeLauncher) {
this.filesystem = filesystem;
this.resourceApk = resourceApk;
this.pathToOutputApkFile = pathToOutputApkFile;
this.dexFile = dexFile;
this.assetDirectories = assetDirectories;
this.nativeLibraryDirectories = nativeLibraryDirectories;
this.jarFilesThatMayContainResources = jarFilesThatMayContainResources;
this.zipFiles = zipFiles;
this.pathToKeystore = pathToKeystore;
this.keystorePropertiesSupplier = keystorePropertiesSupplier;
this.debugMode = debugMode;
this.javaRuntimeLauncher = javaRuntimeLauncher;
}
@Override
public StepExecutionResult execute(ExecutionContext context) throws IOException {
PrintStream output = null;
if (context.getVerbosity().shouldUseVerbosityFlagIfAvailable()) {
output = context.getStdOut();
}
try {
PrivateKeyAndCertificate privateKeyAndCertificate = createKeystoreProperties();
ApkBuilder builder =
new ApkBuilder(
filesystem.getPathForRelativePath(pathToOutputApkFile).toFile(),
filesystem.getPathForRelativePath(resourceApk).toFile(),
filesystem.getPathForRelativePath(dexFile).toFile(),
privateKeyAndCertificate.privateKey,
privateKeyAndCertificate.certificate,
output);
builder.setDebugMode(debugMode);
for (Path nativeLibraryDirectory : nativeLibraryDirectories) {
builder.addNativeLibraries(
filesystem.getPathForRelativePath(nativeLibraryDirectory).toFile());
}
for (Path assetDirectory : assetDirectories) {
builder.addSourceFolder(filesystem.getPathForRelativePath(assetDirectory).toFile());
}
for (Path zipFile : zipFiles) {
// TODO(natthu): Skipping silently is bad. These should really be assertions.
if (filesystem.exists(zipFile) && filesystem.isFile(zipFile)) {
builder.addZipFile(filesystem.getPathForRelativePath(zipFile).toFile());
}
}
for (Path jarFileThatMayContainResources : jarFilesThatMayContainResources) {
Path jarFile = filesystem.getPathForRelativePath(jarFileThatMayContainResources);
builder.addResourcesFromJar(jarFile.toFile());
}
// Build the APK
builder.sealApk();
} catch (ApkCreationException
| IOException
| KeyStoreException
| NoSuchAlgorithmException
| SealedApkException
| UnrecoverableKeyException e) {
context.logError(e, "Error when creating APK at: %s.", pathToOutputApkFile);
Throwables.throwIfInstanceOf(e, IOException.class);
return StepExecutionResult.ERROR;
} catch (DuplicateFileException e) {
throw new HumanReadableException(
String.format(
"Found duplicate file for APK: %1$s\nOrigin 1: %2$s\nOrigin 2: %3$s",
e.getArchivePath(), e.getFile1(), e.getFile2()));
}
return StepExecutionResult.SUCCESS;
}
private PrivateKeyAndCertificate createKeystoreProperties()
throws IOException, KeyStoreException, NoSuchAlgorithmException, UnrecoverableKeyException {
KeyStore keystore = KeyStore.getInstance(JARSIGNER_KEY_STORE_TYPE);
KeystoreProperties keystoreProperties = keystorePropertiesSupplier.get();
InputStream inputStream = filesystem.getInputStreamForRelativePath(pathToKeystore);
char[] keystorePassword = keystoreProperties.getStorepass().toCharArray();
try {
keystore.load(inputStream, keystorePassword);
} catch (IOException | NoSuchAlgorithmException | CertificateException e) {
throw new HumanReadableException(e, "%s is an invalid keystore.", pathToKeystore);
}
String alias = keystoreProperties.getAlias();
char[] keyPassword = keystoreProperties.getKeypass().toCharArray();
Key key = keystore.getKey(alias, keyPassword);
// key can be null if alias/password is incorrect.
if (key == null) {
throw new HumanReadableException(
"The keystore [%s] key.alias [%s] does not exist or does not identify a key-related "
+ "entry",
pathToKeystore, alias);
}
Certificate certificate = keystore.getCertificate(alias);
return new PrivateKeyAndCertificate((PrivateKey) key, (X509Certificate) certificate);
}
@Override
public String getShortName() {
return "apk_builder";
}
@Override
public String getDescription(ExecutionContext context) {
ImmutableList.Builder<String> args = ImmutableList.builder();
args.add(
javaRuntimeLauncher.getCommand(),
"-classpath",
// TODO(mbolin): Make the directory that corresponds to $ANDROID_HOME a field that is
// accessible via an AndroidPlatformTarget and insert that here in place of "$ANDROID_HOME".
"$ANDROID_HOME/tools/lib/sdklib.jar",
"com.android.sdklib.build.ApkBuilderMain");
args.add(String.valueOf(pathToOutputApkFile));
args.add("-v" /* verbose */);
if (debugMode) {
args.add("-d");
}
// Unfortunately, ApkBuilderMain does not have CLI args to set the keystore,
// so these member variables are left out of the command:
// pathToKeystore, pathToKeystorePropertiesFile
Multimap<String, Collection<Path>> groups =
ImmutableMultimap.<String, Collection<Path>>builder()
.put("-z", ImmutableList.of(resourceApk))
.put("-f", ImmutableList.of(dexFile))
.put("-rf", assetDirectories)
.put("-nf", nativeLibraryDirectories)
.put("-z", zipFiles)
.put("-rj", jarFilesThatMayContainResources)
.build();
for (Map.Entry<String, Collection<Path>> group : groups.entries()) {
String prefix = group.getKey();
for (Path path : group.getValue()) {
args.add(prefix, String.valueOf(path));
}
}
return Joiner.on(' ').join(args.build());
}
private static class PrivateKeyAndCertificate {
private final PrivateKey privateKey;
private final X509Certificate certificate;
PrivateKeyAndCertificate(PrivateKey privateKey, X509Certificate certificate) {
this.privateKey = privateKey;
this.certificate = certificate;
}
}
}