/*
* Copyright 2014-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.exopackage;
import com.android.ddmlib.IDevice;
import com.facebook.buck.android.AdbHelper;
import com.facebook.buck.android.HasInstallableApk;
import com.facebook.buck.android.agent.util.AgentUtil;
import com.facebook.buck.event.BuckEventBus;
import com.facebook.buck.event.ConsoleEvent;
import com.facebook.buck.event.InstallEvent;
import com.facebook.buck.event.PerfEventId;
import com.facebook.buck.event.SimplePerfEvent;
import com.facebook.buck.io.ProjectFilesystem;
import com.facebook.buck.log.Logger;
import com.facebook.buck.rules.ExopackageInfo;
import com.facebook.buck.rules.ExopackageInfo.ResourcesInfo;
import com.facebook.buck.rules.SourcePath;
import com.facebook.buck.rules.SourcePathResolver;
import com.facebook.buck.step.ExecutionContext;
import com.facebook.buck.util.NamedTemporaryFile;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Charsets;
import com.google.common.base.Joiner;
import com.google.common.base.Preconditions;
import com.google.common.base.Predicates;
import com.google.common.base.Splitter;
import com.google.common.collect.FluentIterable;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableMultimap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.common.io.Closer;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** ExopackageInstaller manages the installation of apps with the "exopackage" flag set to true. */
public class ExopackageInstaller {
private static final Logger LOG = Logger.get(ExopackageInstaller.class);
@VisibleForTesting public static final Path SECONDARY_DEX_DIR = Paths.get("secondary-dex");
@VisibleForTesting public static final Path NATIVE_LIBS_DIR = Paths.get("native-libs");
@VisibleForTesting public static final Path RESOURCES_DIR = Paths.get("resources");
@VisibleForTesting
public static final Pattern DEX_FILE_PATTERN =
Pattern.compile("secondary-([0-9a-f]+)\\.[\\w.-]*");
@VisibleForTesting
public static final Pattern NATIVE_LIB_PATTERN = Pattern.compile("native-([0-9a-f]+)\\.so");
@VisibleForTesting
public static final Pattern RESOURCES_FILE_PATTERN = Pattern.compile("([0-9a-f]+)\\.apk");
private static final Pattern LINE_ENDING = Pattern.compile("\r?\n");
public static final Path EXOPACKAGE_INSTALL_ROOT = Paths.get("/data/local/tmp/exopackage/");
private final ProjectFilesystem projectFilesystem;
private final BuckEventBus eventBus;
private final SourcePathResolver pathResolver;
private final AdbInterface adbHelper;
private final HasInstallableApk apkRule;
private final String packageName;
private final Path dataRoot;
private final ExopackageInfo exopackageInfo;
/**
* AdbInterface provides a way to interact with multiple devices as ExopackageDevices (rather than
* IDevices).
*
* <p>
*
* <p>All of ExopackageInstaller's interaction with devices and adb goes through this class and
* ExopackageDevice making it easy to provide different implementations in tests.
*/
@VisibleForTesting
public interface AdbInterface {
/**
* This is basically the same as AdbHelper.AdbCallable except that it takes an ExopackageDevice
* instead of an IDevice.
*/
interface AdbCallable {
boolean apply(ExopackageDevice device) throws Exception;
}
boolean adbCall(String description, AdbCallable func, boolean quiet)
throws InterruptedException;
}
static class RealAdbInterface implements AdbInterface {
private AdbHelper adbHelper;
private BuckEventBus eventBus;
private Path agentApkPath;
/**
* The next port number to use for communicating with the agent on a device. This resets for
* every instance of RealAdbInterface, but is incremented for every device we are installing on
* when using "-x".
*/
private final AtomicInteger nextAgentPort = new AtomicInteger(2828);
RealAdbInterface(BuckEventBus eventBus, AdbHelper adbHelper, Path agentApkPath) {
this.eventBus = eventBus;
this.adbHelper = adbHelper;
this.agentApkPath = agentApkPath;
}
@Override
public boolean adbCall(String description, AdbCallable func, boolean quiet)
throws InterruptedException {
return adbHelper.adbCall(
new AdbHelper.AdbCallable() {
@Override
public boolean call(IDevice device) throws Exception {
return func.apply(
new RealExopackageDevice(
eventBus, device, adbHelper, agentApkPath, nextAgentPort.getAndIncrement()));
}
@Override
public String toString() {
return description;
}
},
quiet);
}
}
private static Path getApkFilePathFromProperties() {
String apkFileName = System.getProperty("buck.android_agent_path");
if (apkFileName == null) {
throw new RuntimeException("Android agent apk path not specified in properties");
}
return Paths.get(apkFileName);
}
public ExopackageInstaller(
SourcePathResolver pathResolver,
ExecutionContext context,
AdbHelper adbHelper,
HasInstallableApk apkRule) {
this(
pathResolver,
context,
new RealAdbInterface(context.getBuckEventBus(), adbHelper, getApkFilePathFromProperties()),
apkRule);
}
public ExopackageInstaller(
SourcePathResolver pathResolver,
ExecutionContext context,
AdbInterface adbInterface,
HasInstallableApk apkRule) {
this.pathResolver = pathResolver;
this.adbHelper = adbInterface;
this.projectFilesystem = apkRule.getProjectFilesystem();
this.eventBus = context.getBuckEventBus();
this.apkRule = apkRule;
this.packageName =
AdbHelper.tryToExtractPackageNameFromManifest(pathResolver, apkRule.getApkInfo());
this.dataRoot = EXOPACKAGE_INSTALL_ROOT.resolve(packageName);
Preconditions.checkArgument(AdbHelper.PACKAGE_NAME_PATTERN.matcher(packageName).matches());
Optional<ExopackageInfo> exopackageInfo = apkRule.getApkInfo().getExopackageInfo();
Preconditions.checkArgument(exopackageInfo.isPresent());
this.exopackageInfo = exopackageInfo.get();
}
/** Installs the app specified in the constructor. This object should be discarded afterward. */
public synchronized boolean install(boolean quiet) throws InterruptedException {
InstallEvent.Started started = InstallEvent.started(apkRule.getBuildTarget());
eventBus.post(started);
boolean success =
adbHelper.adbCall(
"install exopackage apk",
device -> new SingleDeviceInstaller(device).doInstall(),
quiet);
eventBus.post(
InstallEvent.finished(
started,
success,
Optional.empty(),
Optional.of(
AdbHelper.tryToExtractPackageNameFromManifest(
pathResolver, apkRule.getApkInfo()))));
return success;
}
/** Helper class to manage the state required to install on a single device. */
private class SingleDeviceInstaller {
/** Device that we are installing onto. */
private final ExopackageDevice device;
private SingleDeviceInstaller(ExopackageDevice device) {
this.device = device;
}
boolean doInstall() throws Exception {
final File apk = pathResolver.getAbsolutePath(apkRule.getApkInfo().getApkPath()).toFile();
// TODO(dreiss): Support SD installation.
final boolean installViaSd = false;
if (shouldAppBeInstalled()) {
try (SimplePerfEvent.Scope ignored = SimplePerfEvent.scope(eventBus, "install_exo_apk")) {
boolean success = device.installApkOnDevice(apk, installViaSd, false);
if (!success) {
return false;
}
}
}
// TODO(cjhopman): We should clear out the directories on the device for types we don't
// install.
if (exopackageInfo.getDexInfo().isPresent()) {
installSecondaryDexFiles();
}
if (exopackageInfo.getNativeLibsInfo().isPresent()) {
installNativeLibraryFiles();
}
if (exopackageInfo.getResourcesInfo().isPresent()) {
installResourcesFiles();
}
// TODO(dreiss): Make this work on Gingerbread.
try (SimplePerfEvent.Scope ignored = SimplePerfEvent.scope(eventBus, "kill_app")) {
device.stopPackage(packageName);
}
return true;
}
private void installSecondaryDexFiles() throws Exception {
final ImmutableMap<String, Path> hashToSources = getRequiredDexFiles();
final ImmutableSet<String> requiredHashes = hashToSources.keySet();
final ImmutableSet<String> presentHashes = prepareSecondaryDexDir(requiredHashes);
final Set<String> hashesToInstall = Sets.difference(requiredHashes, presentHashes);
Map<String, Path> filesToInstallByHash =
Maps.filterKeys(hashToSources, hashesToInstall::contains);
// This is a bit gross. It was a late addition. Ideally, we could eliminate this, but
// it wouldn't be terrible if we don't. We store the dexed jars on the device
// with the full SHA-1 hashes in their names. This is the format that the loader uses
// internally, so ideally we would just load them in place. However, the code currently
// expects to be able to copy the jars from a directory that matches the name in the
// metadata file, like "secondary-1.dex.jar". We don't want to give up putting the
// hashes in the file names (because we use that to skip re-uploads), so just hack
// the metadata file to have hash-like names.
String metadataContents =
com.google.common.io.Files.toString(
projectFilesystem
.resolve(exopackageInfo.getDexInfo().get().getMetadata())
.toFile(),
Charsets.UTF_8)
.replaceAll(
"secondary-(\\d+)\\.dex\\.jar (\\p{XDigit}{40}) ", "secondary-$2.dex.jar $2 ");
ImmutableMap<Path, Path> filesToInstall =
applyFilenameFormat(filesToInstallByHash, SECONDARY_DEX_DIR, "secondary-%s.dex.jar");
installFiles("secondary_dex", filesToInstall);
installMetadata(ImmutableMap.of(SECONDARY_DEX_DIR.resolve("metadata.txt"), metadataContents));
}
private void installResourcesFiles() throws Exception {
ResourcesInfo info = exopackageInfo.getResourcesInfo().get();
ImmutableMap.Builder<String, Path> hashToSourcesBuilder = ImmutableMap.builder();
String metadataContent = "";
String prefix = "";
for (SourcePath sourcePath : info.getResourcesPaths()) {
Path path = pathResolver.getRelativePath(sourcePath);
String hash = projectFilesystem.computeSha1(path).getHash();
metadataContent += prefix + "resources " + hash;
prefix = "\n";
hashToSourcesBuilder.put(hash, path);
}
final ImmutableMap<String, Path> hashToSources = hashToSourcesBuilder.build();
final ImmutableSet<String> requiredHashes = hashToSources.keySet();
final ImmutableSet<String> presentHashes = prepareResourcesDir(requiredHashes);
final Set<String> hashesToInstall = Sets.difference(requiredHashes, presentHashes);
Map<String, Path> filesToInstallByHash =
Maps.filterKeys(hashToSources, hashesToInstall::contains);
ImmutableMap<Path, Path> filesToInstall =
applyFilenameFormat(filesToInstallByHash, RESOURCES_DIR, "%s.apk");
installFiles("resources", filesToInstall);
installMetadata(ImmutableMap.of(RESOURCES_DIR.resolve("metadata.txt"), metadataContent));
}
private void installNativeLibraryFiles() throws Exception {
ImmutableMultimap<String, Path> allLibraries = getAllLibraries();
ImmutableSet.Builder<String> providedLibraries = ImmutableSet.builder();
for (String abi : device.getDeviceAbis()) {
ImmutableMap<String, Path> libraries =
getRequiredLibrariesForAbi(allLibraries, abi, providedLibraries.build());
installNativeLibrariesForAbi(abi, libraries);
providedLibraries.addAll(libraries.keySet());
}
}
private void installNativeLibrariesForAbi(String abi, ImmutableMap<String, Path> libraries)
throws Exception {
if (libraries.isEmpty()) {
return;
}
String metadataContents =
Joiner.on('\n')
.join(
FluentIterable.from(libraries.entrySet())
.transform(
input -> {
String hash = input.getKey();
String filename = input.getValue().getFileName().toString();
int index = filename.indexOf('.');
String libname = index == -1 ? filename : filename.substring(0, index);
return String.format("%s native-%s.so", libname, hash);
}));
ImmutableSet<String> requiredHashes = libraries.keySet();
ImmutableSet<String> presentHashes = prepareNativeLibsDir(abi, requiredHashes);
Map<String, Path> filesToInstallByHash =
Maps.filterKeys(libraries, Predicates.not(presentHashes::contains));
Path abiDir = NATIVE_LIBS_DIR.resolve(abi);
ImmutableMap<Path, Path> filesToInstall =
applyFilenameFormat(filesToInstallByHash, abiDir, "native-%s.so");
installFiles("native_library", filesToInstall);
installMetadata(ImmutableMap.of(abiDir.resolve("metadata.txt"), metadataContents));
}
private Optional<PackageInfo> getPackageInfo(final String packageName) throws Exception {
try (SimplePerfEvent.Scope ignored =
SimplePerfEvent.scope(
eventBus, PerfEventId.of("get_package_info"), "package", packageName)) {
return device.getPackageInfo(packageName);
}
}
private boolean shouldAppBeInstalled() throws Exception {
Optional<PackageInfo> appPackageInfo = getPackageInfo(packageName);
if (!appPackageInfo.isPresent()) {
eventBus.post(ConsoleEvent.info("App not installed. Installing now."));
return true;
}
LOG.debug("App path: %s", appPackageInfo.get().apkPath);
String installedAppSignature = getInstalledAppSignature(appPackageInfo.get().apkPath);
String localAppSignature =
AgentUtil.getJarSignature(
pathResolver.getAbsolutePath(apkRule.getApkInfo().getApkPath()).toString());
LOG.debug("Local app signature: %s", localAppSignature);
LOG.debug("Remote app signature: %s", installedAppSignature);
if (!installedAppSignature.equals(localAppSignature)) {
LOG.debug("App signatures do not match. Must re-install.");
return true;
}
LOG.debug("App signatures match. No need to install.");
return false;
}
private String getInstalledAppSignature(final String packagePath) throws Exception {
try (SimplePerfEvent.Scope ignored = SimplePerfEvent.scope(eventBus, "get_app_signature")) {
String output = device.getSignature(packagePath);
String result = output.trim();
if (result.contains("\n") || result.contains("\r")) {
throw new IllegalStateException("Unexpected return from get-signature:\n" + output);
}
return result;
}
}
private ImmutableMap<String, Path> getRequiredDexFiles() throws IOException {
ExopackageInfo.DexInfo dexInfo = exopackageInfo.getDexInfo().get();
ImmutableMultimap<String, Path> multimap =
parseExopackageInfoMetadata(
dexInfo.getMetadata(), dexInfo.getDirectory(), projectFilesystem);
// Convert multimap to a map, because every key should have only one value.
ImmutableMap.Builder<String, Path> builder = ImmutableMap.builder();
for (Map.Entry<String, Path> entry : multimap.entries()) {
builder.put(entry);
}
return builder.build();
}
private ImmutableSet<String> prepareSecondaryDexDir(ImmutableSet<String> requiredHashes)
throws Exception {
return prepareDirectory(SECONDARY_DEX_DIR, DEX_FILE_PATTERN, requiredHashes);
}
private ImmutableSet<String> prepareNativeLibsDir(
String abi, ImmutableSet<String> requiredHashes) throws Exception {
return prepareDirectory(NATIVE_LIBS_DIR.resolve(abi), NATIVE_LIB_PATTERN, requiredHashes);
}
private ImmutableSet<String> prepareResourcesDir(ImmutableSet<String> requiredHashes)
throws Exception {
return prepareDirectory(RESOURCES_DIR, RESOURCES_FILE_PATTERN, requiredHashes);
}
private ImmutableSet<String> prepareDirectory(
Path dirname, Pattern filePattern, ImmutableSet<String> requiredHashes) throws Exception {
try (SimplePerfEvent.Scope ignored = SimplePerfEvent.scope(eventBus, "prepare_" + dirname)) {
String dirPath = dataRoot.resolve(dirname).toString();
device.mkDirP(dirPath);
String output = device.listDir(dirPath);
ImmutableSet.Builder<String> foundHashes = ImmutableSet.builder();
ImmutableSet.Builder<String> filesToDelete = ImmutableSet.builder();
processLsOutput(output, filePattern, requiredHashes, foundHashes, filesToDelete);
device.rmFiles(dirPath, filesToDelete.build());
return foundHashes.build();
}
}
private ImmutableMap<Path, Path> applyFilenameFormat(
Map<String, Path> filesToHashes, Path deviceDir, String filenameFormat) {
ImmutableMap.Builder<Path, Path> filesBuilder = ImmutableMap.builder();
for (Map.Entry<String, Path> entry : filesToHashes.entrySet()) {
filesBuilder.put(
deviceDir.resolve(String.format(filenameFormat, entry.getKey())), entry.getValue());
}
return filesBuilder.build();
}
private void installFiles(String filesType, ImmutableMap<Path, Path> filesToInstall)
throws Exception {
try (SimplePerfEvent.Scope ignored =
SimplePerfEvent.scope(eventBus, "multi_install_" + filesType);
AutoCloseable ignored1 = device.createForward()) {
filesToInstall.forEach(
(devicePath, hostPath) -> {
Path destination = dataRoot.resolve(devicePath);
Path source = projectFilesystem.resolve(hostPath);
try (SimplePerfEvent.Scope ignored2 =
SimplePerfEvent.scope(eventBus, "install_file")) {
device.installFile(destination, source);
} catch (Exception e) {
throw new RuntimeException(e);
}
});
}
}
private void installMetadata(ImmutableMap<Path, String> metadataToInstall) throws Exception {
try (Closer closer = Closer.create()) {
Map<Path, Path> filesToInstall = new HashMap<>();
for (Map.Entry<Path, String> entry : metadataToInstall.entrySet()) {
NamedTemporaryFile temp = closer.register(new NamedTemporaryFile("metadata", "tmp"));
com.google.common.io.Files.write(
entry.getValue().getBytes(Charsets.UTF_8), temp.get().toFile());
filesToInstall.put(entry.getKey(), temp.get());
}
installFiles("metadata", ImmutableMap.copyOf(filesToInstall));
}
}
}
private ImmutableMultimap<String, Path> getAllLibraries() throws IOException {
ExopackageInfo.NativeLibsInfo nativeLibsInfo = exopackageInfo.getNativeLibsInfo().get();
return parseExopackageInfoMetadata(
nativeLibsInfo.getMetadata(), nativeLibsInfo.getDirectory(), projectFilesystem);
}
private ImmutableMap<String, Path> getRequiredLibrariesForAbi(
ImmutableMultimap<String, Path> allLibraries,
String abi,
ImmutableSet<String> ignoreLibraries) {
return filterLibrariesForAbi(
exopackageInfo.getNativeLibsInfo().get().getDirectory(),
allLibraries,
abi,
ignoreLibraries);
}
@VisibleForTesting
public static ImmutableMap<String, Path> filterLibrariesForAbi(
Path nativeLibsDir,
ImmutableMultimap<String, Path> allLibraries,
String abi,
ImmutableSet<String> ignoreLibraries) {
ImmutableMap.Builder<String, Path> filteredLibraries = ImmutableMap.builder();
for (Map.Entry<String, Path> entry : allLibraries.entries()) {
Path relativePath = nativeLibsDir.relativize(entry.getValue());
// relativePath is of the form libs/x86/foo.so, or assetLibs/x86/foo.so etc.
Preconditions.checkState(relativePath.getNameCount() == 3);
Preconditions.checkState(
relativePath.getName(0).toString().equals("libs")
|| relativePath.getName(0).toString().equals("assetLibs"));
String libAbi = relativePath.getParent().getFileName().toString();
String libName = relativePath.getFileName().toString();
if (libAbi.equals(abi) && !ignoreLibraries.contains(libName)) {
filteredLibraries.put(entry);
}
}
return filteredLibraries.build();
}
/**
* Parses a text file which is supposed to be in the following format: "file_path_without_spaces
* file_hash ...." i.e. it parses the first two columns of each line and ignores the rest of it.
*
* @return A multi map from the file hash to its path, which equals the raw path resolved against
* {@code resolvePathAgainst}.
*/
@VisibleForTesting
public static ImmutableMultimap<String, Path> parseExopackageInfoMetadata(
Path metadataTxt, Path resolvePathAgainst, ProjectFilesystem filesystem) throws IOException {
ImmutableMultimap.Builder<String, Path> builder = ImmutableMultimap.builder();
for (String line : filesystem.readLines(metadataTxt)) {
// ignore lines that start with '.'
if (line.startsWith(".")) {
continue;
}
List<String> parts = Splitter.on(' ').splitToList(line);
if (parts.size() < 2) {
throw new RuntimeException("Illegal line in metadata file: " + line);
}
builder.put(parts.get(1), resolvePathAgainst.resolve(parts.get(0)));
}
return builder.build();
}
@VisibleForTesting
public static Optional<PackageInfo> parsePathAndPackageInfo(
String packageName, String rawOutput) {
Iterable<String> lines = Splitter.on(LINE_ENDING).omitEmptyStrings().split(rawOutput);
String pmPathPrefix = "package:";
String pmPath = null;
for (String line : lines) {
// Ignore silly linker warnings about non-PIC code on emulators
if (!line.startsWith("WARNING: linker: ")) {
pmPath = line;
break;
}
}
if (pmPath == null || !pmPath.startsWith(pmPathPrefix)) {
LOG.warn("unable to locate package path for [" + packageName + "]");
return Optional.empty();
}
final String packagePrefix = " Package [" + packageName + "] (";
final String otherPrefix = " Package [";
boolean sawPackageLine = false;
final Splitter splitter = Splitter.on('=').limit(2);
String codePath = null;
String resourcePath = null;
String nativeLibPath = null;
String versionCode = null;
for (String line : lines) {
// Just ignore everything until we see the line that says we are in the right package.
if (line.startsWith(packagePrefix)) {
sawPackageLine = true;
continue;
}
// This should never happen, but if we do see a different package, stop parsing.
if (line.startsWith(otherPrefix)) {
break;
}
// Ignore lines before our package.
if (!sawPackageLine) {
continue;
}
// Parse key-value pairs.
List<String> parts = splitter.splitToList(line.trim());
if (parts.size() != 2) {
continue;
}
switch (parts.get(0)) {
case "codePath":
codePath = parts.get(1);
break;
case "resourcePath":
resourcePath = parts.get(1);
break;
case "nativeLibraryPath":
nativeLibPath = parts.get(1);
break;
// Lollipop uses this name. Not sure what's "legacy" about it yet.
// Maybe something to do with 64-bit?
// Might need to update if people report failures.
case "legacyNativeLibraryDir":
nativeLibPath = parts.get(1);
break;
case "versionCode":
// Extra split to get rid of the SDK thing.
versionCode = parts.get(1).split(" ", 2)[0];
break;
default:
break;
}
}
if (!sawPackageLine) {
return Optional.empty();
}
Preconditions.checkNotNull(codePath, "Could not find codePath");
Preconditions.checkNotNull(resourcePath, "Could not find resourcePath");
Preconditions.checkNotNull(nativeLibPath, "Could not find nativeLibraryPath");
Preconditions.checkNotNull(versionCode, "Could not find versionCode");
if (!codePath.equals(resourcePath)) {
throw new IllegalStateException("Code and resource path do not match");
}
// Lollipop doesn't give the full path to the apk anymore. Not sure why it's "base.apk".
if (!codePath.endsWith(".apk")) {
codePath += "/base.apk";
}
return Optional.of(new PackageInfo(codePath, nativeLibPath, versionCode));
}
/**
* @param output Output of "ls" command.
* @param filePattern A {@link Pattern} that is used to check if a file is valid, and if it
* matches, {@code filePattern.group(1)} should return the hash in the file name.
* @param requiredHashes Hashes of dex files required for this apk.
* @param foundHashes Builder to receive hashes that we need and were found.
* @param toDelete Builder to receive files that we need to delete.
*/
@VisibleForTesting
public static void processLsOutput(
String output,
Pattern filePattern,
ImmutableSet<String> requiredHashes,
ImmutableSet.Builder<String> foundHashes,
ImmutableSet.Builder<String> toDelete) {
for (String line : Splitter.on(LINE_ENDING).omitEmptyStrings().split(output)) {
if (line.equals("lock")) {
continue;
}
Matcher m = filePattern.matcher(line);
if (m.matches()) {
if (requiredHashes.contains(m.group(1))) {
foundHashes.add(m.group(1));
} else {
toDelete.add(line);
}
} else {
toDelete.add(line);
}
}
}
}