/*
* Copyright 2013-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.facebook.buck.model.Pair;
import com.facebook.buck.util.Escaper;
import com.facebook.buck.util.HumanReadableException;
import com.facebook.buck.util.VersionStringComparator;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Charsets;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Properties;
import java.util.stream.Collectors;
/** Utility class used for resolving the location of Android specific directories. */
public class DefaultAndroidDirectoryResolver implements AndroidDirectoryResolver {
// Pre r11 NDKs store the version at RELEASE.txt.
@VisibleForTesting static final String NDK_PRE_R11_VERSION_FILENAME = "RELEASE.TXT";
// Post r11 NDKs store the version at source.properties.
@VisibleForTesting static final String NDK_POST_R11_VERSION_FILENAME = "source.properties";
@VisibleForTesting
static final String SDK_NOT_FOUND_MESSAGE =
"Android SDK could not be found. Make sure to set "
+ "one of these environment variables: ANDROID_SDK, ANDROID_HOME";
@VisibleForTesting
static final String TOOLS_NEED_SDK_MESSAGE =
"Android SDK Build tools require Android SDK. "
+ "Make sure one of these environment variables was set: ANDROID_SDK, ANDROID_HOME";
@VisibleForTesting
static final String NDK_NOT_FOUND_MESSAGE =
"Android NDK could not be found. Make sure to set "
+ "one of these environment variables: ANDROID_NDK_REPOSITORY, ANDROID_NDK or NDK_HOME]";
@VisibleForTesting
static final String NDK_TARGET_VERSION_IS_EMPTY_MESSAGE =
"buckconfig entry [ndk] ndk_version is an empty string.";
public static final ImmutableSet<String> BUILD_TOOL_PREFIXES =
ImmutableSet.of("android-", "build-tools-");
private final FileSystem fileSystem;
private final ImmutableMap<String, String> environment;
private final Optional<String> targetBuildToolsVersion;
private final Optional<String> targetNdkVersion;
private Optional<String> sdkErrorMessage;
private Optional<String> buildToolsErrorMessage;
private Optional<String> ndkErrorMessage;
private final Optional<Path> sdk;
private final Optional<Path> buildTools;
private final Optional<Path> ndk;
public DefaultAndroidDirectoryResolver(
FileSystem fileSystem,
ImmutableMap<String, String> environment,
Optional<String> targetBuildToolsVersion,
Optional<String> targetNdkVersion) {
this.fileSystem = fileSystem;
this.environment = environment;
this.targetBuildToolsVersion = targetBuildToolsVersion;
this.targetNdkVersion = targetNdkVersion;
this.sdkErrorMessage = Optional.empty();
this.buildToolsErrorMessage = Optional.empty();
this.ndkErrorMessage = Optional.empty();
this.sdk = findSdk();
this.buildTools = findBuildTools();
this.ndk = findNdk();
}
@Override
public Path getSdkOrThrow() {
if (!sdk.isPresent() && sdkErrorMessage.isPresent()) {
throw new HumanReadableException(sdkErrorMessage.get());
}
return sdk.get();
}
@Override
public Path getBuildToolsOrThrow() {
if (!buildTools.isPresent() && buildToolsErrorMessage.isPresent()) {
throw new HumanReadableException(buildToolsErrorMessage.get());
}
return buildTools.get();
}
@Override
public Path getNdkOrThrow() {
if (!ndk.isPresent() && ndkErrorMessage.isPresent()) {
throw new HumanReadableException(ndkErrorMessage.get());
}
return ndk.get();
}
@Override
public Optional<Path> getSdkOrAbsent() {
return sdk;
}
@Override
public Optional<Path> getNdkOrAbsent() {
return ndk;
}
@Override
public Optional<String> getNdkVersion() {
Optional<Path> ndkPath = getNdkOrAbsent();
if (!ndkPath.isPresent()) {
return Optional.empty();
}
return findNdkVersion(ndkPath.get());
}
private Optional<Path> findSdk() {
Optional<Path> sdkPath;
try {
sdkPath = findDirectoryByEnvironmentVariables("ANDROID_SDK", "ANDROID_HOME");
} catch (RuntimeException e) {
sdkErrorMessage = Optional.of(e.getMessage());
return Optional.empty();
}
if (!sdkPath.isPresent()) {
sdkErrorMessage = Optional.of(SDK_NOT_FOUND_MESSAGE);
}
return sdkPath;
}
private Optional<Path> findDirectoryByEnvironmentVariables(String... environmentVariables) {
Path dirPath = null;
String dirPathEnvironmentVariable = null;
// First, try to find a value in each of the environment variables, in order.
for (String environmentVariable : environmentVariables) {
String environmentVariableValue = environment.get(environmentVariable);
if (environmentVariableValue != null) {
dirPath = fileSystem.getPath(environmentVariableValue);
dirPathEnvironmentVariable = environmentVariable;
break;
}
}
// If a dirPath was found, verify that it maps to a directory before returning it.
if (dirPath == null) {
return Optional.empty();
}
if (!Files.isDirectory(dirPath)) {
throw new RuntimeException(
String.format(
"Environment variable '%s' points to a path that is not a directory: '%s'.",
dirPathEnvironmentVariable, dirPath));
}
return Optional.of(dirPath);
}
private Optional<Path> findBuildTools() {
if (!sdk.isPresent()) {
buildToolsErrorMessage = Optional.of(TOOLS_NEED_SDK_MESSAGE);
return Optional.empty();
}
final Path sdkDir = sdk.get();
final Path toolsDir = sdkDir.resolve("build-tools");
if (toolsDir.toFile().isDirectory()) {
// In older versions of the ADT that have been upgraded via the SDK manager, the build-tools
// directory appears to contain subfolders of the form "17.0.0". However, newer versions of
// the ADT that are downloaded directly from http://developer.android.com/ appear to have
// subfolders of the form android-4.2.2. There also appear to be cases where subfolders
// are named build-tools-18.0.0. We need to support all of these scenarios.
File[] directories;
try {
directories =
toolsDir
.toFile()
.listFiles(
pathname -> {
if (!pathname.isDirectory()) {
return false;
}
String version = stripBuildToolsPrefix(pathname.getName());
if (!VersionStringComparator.isValidVersionString(version)) {
throw new HumanReadableException(
"%s in %s is not a valid build tools directory.%n"
+ "Build tools directories should be follow the naming scheme: "
+ "android-<VERSION>, build-tools-<VERSION>, or <VERSION>. Please remove "
+ "directory %s.",
pathname.getName(), buildTools, pathname.getName());
}
if (targetBuildToolsVersion.isPresent()) {
return targetBuildToolsVersion.get().equals(pathname.getName());
}
return true;
});
} catch (HumanReadableException e) {
buildToolsErrorMessage = Optional.of(e.getHumanReadableErrorMessage());
return Optional.empty();
}
if (targetBuildToolsVersion.isPresent()) {
if (directories.length == 0) {
buildToolsErrorMessage = unableToFindTargetBuildTools();
return Optional.empty();
} else {
return Optional.of(directories[0].toPath());
}
}
// We aren't looking for a specific version, so we pick the newest version
final VersionStringComparator comparator = new VersionStringComparator();
File newestBuildDir = null;
String newestBuildDirVersion = null;
for (File directory : directories) {
String currentDirVersion = stripBuildToolsPrefix(directory.getName());
if (newestBuildDir == null
|| newestBuildDirVersion == null
|| comparator.compare(newestBuildDirVersion, currentDirVersion) < 0) {
newestBuildDir = directory;
newestBuildDirVersion = currentDirVersion;
}
}
if (newestBuildDir == null) {
buildToolsErrorMessage =
Optional.of(
buildTools
+ " was empty, but should have "
+ "contained a subdirectory with build tools. Install them using the Android "
+ "SDK Manager ("
+ toolsDir.getParent().resolve("tools").resolve("android")
+ ").");
return Optional.empty();
}
return Optional.of(newestBuildDir.toPath());
}
if (targetBuildToolsVersion.isPresent()) {
// We were looking for a specific version, but we aren't going to find it at this point since
// nothing under platform-tools was versioned.
buildToolsErrorMessage = unableToFindTargetBuildTools();
return Optional.empty();
}
// Build tools used to exist inside of platform-tools, so fallback to that.
return Optional.of(sdkDir.resolve("platform-tools"));
}
private Optional<Path> findNdk() {
Optional<Path> repository = Optional.empty();
try {
repository = findDirectoryByEnvironmentVariables("ANDROID_NDK_REPOSITORY");
} catch (RuntimeException e) {
ndkErrorMessage = Optional.of(e.getMessage());
}
if (repository.isPresent()) {
return findNdkFromRepository(repository.get());
}
Optional<Path> directory = Optional.empty();
try {
directory = findDirectoryByEnvironmentVariables("ANDROID_NDK", "NDK_HOME");
} catch (RuntimeException e) {
ndkErrorMessage = Optional.of(e.getMessage());
}
if (directory.isPresent()) {
return findNdkFromDirectory(directory.get());
}
if (!ndkErrorMessage.isPresent()) {
ndkErrorMessage = Optional.of(NDK_NOT_FOUND_MESSAGE);
}
return Optional.empty();
}
private Optional<Path> findNdkFromDirectory(Path directory) {
Optional<String> version = findNdkVersion(directory);
if (!version.isPresent() && ndkErrorMessage.isPresent()) {
return Optional.empty();
} else if (version.isPresent()) {
if (targetNdkVersion.isPresent() && !versionsMatch(targetNdkVersion.get(), version.get())) {
ndkErrorMessage =
Optional.of(
"Buck is configured to use Android NDK version "
+ targetNdkVersion.get()
+ " at ndk.dir or ANDROID_NDK or NDK_HOME. The found version "
+ "is "
+ version.get()
+ " located at "
+ directory);
return Optional.empty();
}
} else {
ndkErrorMessage = Optional.of("Failed to read NDK version from " + directory + ".");
return Optional.empty();
}
return Optional.of(directory);
}
private Optional<Path> findNdkFromRepository(Path repository) {
ImmutableSet<Path> repositoryContents;
try (DirectoryStream<Path> stream = Files.newDirectoryStream(repository)) {
repositoryContents = ImmutableSet.copyOf(stream);
} catch (IOException e) {
ndkErrorMessage =
Optional.of(
"Unable to read contents of Android ndk.repository or "
+ "ANDROID_NDK_REPOSITORY at "
+ repository);
return Optional.empty();
}
VersionStringComparator versionComparator = new VersionStringComparator();
List<Pair<Path, Optional<String>>> availableNdks =
repositoryContents
.stream()
.filter(Files::isDirectory)
// Pair of path to version number
.map(p -> new Pair<>(p, findNdkVersion(p)))
.filter(pair -> pair.getSecond().isPresent())
.sorted(
(o1, o2) -> versionComparator.compare(o2.getSecond().get(), o1.getSecond().get()))
.collect(Collectors.toList());
if (availableNdks.isEmpty()) {
ndkErrorMessage =
Optional.of(
repository
+ " does not contain any valid Android NDK. Make"
+ " sure to specify ANDROID_NDK_REPOSITORY or ndk.repository.");
return Optional.empty();
}
if (targetNdkVersion.isPresent()) {
if (targetNdkVersion.get().isEmpty()) {
ndkErrorMessage = Optional.of(NDK_TARGET_VERSION_IS_EMPTY_MESSAGE);
return Optional.empty();
}
Optional<Path> targetNdkPath =
availableNdks
.stream()
.filter(p -> versionsMatch(targetNdkVersion.get(), p.getSecond().get()))
.map(Pair::getFirst)
.findFirst();
if (targetNdkPath.isPresent()) {
return targetNdkPath;
}
ndkErrorMessage =
Optional.of(
"Target NDK version "
+ targetNdkVersion.get()
+ " is not "
+ "available. The following versions are available: "
+ availableNdks
.stream()
.map(Pair::getSecond)
.map(Optional::get)
.collect(Collectors.joining(", ")));
return Optional.empty();
}
return Optional.of(availableNdks.get(0).getFirst());
}
/**
* The method returns the NDK version of a path.
*
* @param ndkDirectory Path to the folder that contains the NDK.
* @return A string containing the NDK version or absent.
*/
public static Optional<String> findNdkVersionFromDirectory(Path ndkDirectory) {
Path newNdk = ndkDirectory.resolve(NDK_POST_R11_VERSION_FILENAME);
Path oldNdk = ndkDirectory.resolve(NDK_PRE_R11_VERSION_FILENAME);
boolean newNdkPathFound = Files.exists(newNdk);
boolean oldNdkPathFound = Files.exists(oldNdk);
if (newNdkPathFound && oldNdkPathFound) {
throw new HumanReadableException(
"Android NDK directory "
+ ndkDirectory
+ " can not "
+ "contain both properties files. Remove source.properties or RELEASE.TXT.");
} else if (newNdkPathFound) {
Properties sourceProperties = new Properties();
try (FileInputStream fileStream = new FileInputStream(newNdk.toFile())) {
sourceProperties.load(fileStream);
return Optional.ofNullable(sourceProperties.getProperty("Pkg.Revision"));
} catch (IOException e) {
throw new HumanReadableException("Failed to read NDK version from " + newNdk + ".");
}
} else if (oldNdkPathFound) {
try (BufferedReader reader = Files.newBufferedReader(oldNdk, Charsets.UTF_8)) {
// Android NDK r10e for Linux is mislabeled as r10e-rc4 instead of r10e. This is a work
// around since we should consider them equivalent.
return Optional.ofNullable(reader.readLine().split("\\s+")[0].replace("r10e-rc4", "r10e"));
} catch (IOException e) {
throw new HumanReadableException("Failed to read NDK version from " + oldNdk + ".");
}
} else {
throw new HumanReadableException(
ndkDirectory + " does not contain a valid properties " + "file for Android NDK.");
}
}
public Optional<String> findNdkVersion(Path ndkDirectory) {
try {
return findNdkVersionFromDirectory(ndkDirectory);
} catch (HumanReadableException e) {
ndkErrorMessage = Optional.of(e.getHumanReadableErrorMessage());
return Optional.empty();
}
}
private static String stripBuildToolsPrefix(String name) {
for (String prefix : BUILD_TOOL_PREFIXES) {
if (name.startsWith(prefix)) {
return name.substring(prefix.length());
}
}
return name;
}
private Optional<String> unableToFindTargetBuildTools() {
return Optional.of(
"Unable to find build-tools version "
+ targetBuildToolsVersion.get()
+ ", which is specified by your config. Please see "
+ "https://buckbuild.com/concept/buckconfig.html#android.build_tools_version for more "
+ "details about the setting. To install the correct version of the tools, run `"
+ Escaper.escapeAsShellString(sdk.get().resolve("tools/android").toString())
+ " update "
+ "sdk --force --no-ui --all --filter build-tools-"
+ targetBuildToolsVersion.get()
+ "`");
}
private boolean versionsMatch(String expected, String candidate) {
return !(Strings.isNullOrEmpty(expected) || Strings.isNullOrEmpty(candidate))
&& candidate.startsWith(expected);
}
@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
if (!(other instanceof DefaultAndroidDirectoryResolver)) {
return false;
}
DefaultAndroidDirectoryResolver that = (DefaultAndroidDirectoryResolver) other;
return Objects.equals(targetBuildToolsVersion, that.targetBuildToolsVersion)
&& Objects.equals(targetNdkVersion, that.targetNdkVersion)
&& Objects.equals(getNdkOrAbsent(), that.getNdkOrAbsent());
}
@Override
public String toString() {
return String.format(
"%s targetBuildToolsVersion=%s, targetNdkVersion=%s, "
+ "AndroidSdkDir=%s, AndroidBuildToolsDir=%s, AndroidNdkDir=%s",
super.toString(),
targetBuildToolsVersion,
targetNdkVersion,
(sdk.isPresent()) ? (sdk.get()) : "SDK not available",
(buildTools.isPresent()) ? (buildTools.get()) : "Build tools not available",
(ndk.isPresent()) ? (ndk.get()) : "NDK not available");
}
@Override
public int hashCode() {
return Objects.hash(targetBuildToolsVersion, targetNdkVersion);
}
}