/*
* Copyright (C) 2009 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 vogar.commands;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.PrintStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import vogar.util.Log;
import vogar.util.Strings;
import vogar.util.Threads;
/**
* An out of process executable.
*/
public final class Command {
private final List<String> args;
private final Map<String, String> env;
private final File workingDirectory;
private final boolean permitNonZeroExitStatus;
private final PrintStream tee;
private final boolean nativeOutput;
private volatile Process process;
public Command(String... args) {
this(Arrays.asList(args));
}
public Command(List<String> args) {
this.args = new ArrayList<String>(args);
this.env = Collections.emptyMap();
this.workingDirectory = null;
this.permitNonZeroExitStatus = false;
this.tee = null;
this.nativeOutput = false;
}
private Command(Builder builder) {
this.args = new ArrayList<String>(builder.args);
this.env = builder.env;
this.workingDirectory = builder.workingDirectory;
this.permitNonZeroExitStatus = builder.permitNonZeroExitStatus;
this.tee = builder.tee;
if (builder.maxLength != -1) {
String string = toString();
if (string.length() > builder.maxLength) {
throw new IllegalStateException("Maximum command length " + builder.maxLength
+ " exceeded by: " + string);
}
}
this.nativeOutput = builder.nativeOutput;
}
public void start() throws IOException {
if (isStarted()) {
throw new IllegalStateException("Already started!");
}
Log.verbose("executing " + this);
ProcessBuilder processBuilder = new ProcessBuilder()
.command(args)
.redirectErrorStream(true);
if (workingDirectory != null) {
processBuilder.directory(workingDirectory);
}
processBuilder.environment().putAll(env);
process = processBuilder.start();
}
public boolean isStarted() {
return process != null;
}
public InputStream getInputStream() {
if (!isStarted()) {
throw new IllegalStateException("Not started!");
}
return process.getInputStream();
}
public List<String> gatherOutput()
throws IOException, InterruptedException {
if (!isStarted()) {
throw new IllegalStateException("Not started!");
}
BufferedReader in = new BufferedReader(
new InputStreamReader(getInputStream(), "UTF-8"));
List<String> outputLines = new ArrayList<String>();
String outputLine;
while ((outputLine = in.readLine()) != null) {
if (tee != null) {
tee.println(outputLine);
}
if (nativeOutput) {
Log.nativeOutput(outputLine);
}
outputLines.add(outputLine);
}
if (process.waitFor() != 0 && !permitNonZeroExitStatus) {
StringBuilder message = new StringBuilder();
for (String line : outputLines) {
message.append("\n").append(line);
}
throw new CommandFailedException(args, outputLines);
}
return outputLines;
}
public List<String> execute() {
try {
start();
return gatherOutput();
} catch (IOException e) {
throw new RuntimeException("Failed to execute process: " + args, e);
} catch (InterruptedException e) {
throw new RuntimeException("Interrupted while executing process: " + args, e);
}
}
/**
* Executes a command with a specified timeout. If the process does not
* complete normally before the timeout has elapsed, it will be destroyed.
*
* @param timeoutSeconds how long to wait, or 0 to wait indefinitely
* @return the command's output, or null if the command timed out
*/
public List<String> executeWithTimeout(int timeoutSeconds)
throws TimeoutException {
if (timeoutSeconds == 0) {
return execute();
}
try {
return executeLater().get(timeoutSeconds, TimeUnit.SECONDS);
} catch (InterruptedException e) {
throw new RuntimeException("Interrupted while executing process: " + args, e);
} catch (ExecutionException e) {
throw new RuntimeException(e);
} finally {
destroy();
}
}
/**
* Executes the command on a new background thread. This method returns
* immediately.
*
* @return a future to retrieve the command's output.
*/
public Future<List<String>> executeLater() {
ExecutorService executor = Threads.fixedThreadsExecutor("command", 1);
Future<List<String>> result = executor.submit(new Callable<List<String>>() {
public List<String> call() throws Exception {
start();
return gatherOutput();
}
});
executor.shutdown();
return result;
}
/**
* Destroys the underlying process and closes its associated streams.
*/
public void destroy() {
if (process == null) {
return;
}
process.destroy();
try {
process.waitFor();
int exitValue = process.exitValue();
Log.verbose("received exit value " + exitValue
+ " from destroyed command " + this);
} catch (IllegalThreadStateException destroyUnsuccessful) {
Log.warn("couldn't destroy " + this);
} catch (InterruptedException e) {
Log.warn("couldn't destroy " + this);
}
}
@Override public String toString() {
String envString = !env.isEmpty() ? (Strings.join(env.entrySet(), " ") + " ") : "";
return envString + Strings.join(args, " ");
}
public static class Builder {
private final List<String> args = new ArrayList<String>();
private final Map<String, String> env = new LinkedHashMap<String, String>();
private File workingDirectory;
private boolean permitNonZeroExitStatus = false;
private PrintStream tee = null;
private boolean nativeOutput;
private int maxLength = -1;
public Builder args(Object... objects) {
for (Object object : objects) {
args(object.toString());
}
return this;
}
public Builder setNativeOutput(boolean nativeOutput) {
this.nativeOutput = nativeOutput;
return this;
}
public Builder args(String... args) {
return args(Arrays.asList(args));
}
public Builder args(Collection<String> args) {
this.args.addAll(args);
return this;
}
public Builder env(String key, String value) {
env.put(key, value);
return this;
}
/**
* Sets the working directory from which the command will be executed.
* This must be a <strong>local</strong> directory; Commands run on
* remote devices (ie. via {@code adb shell}) require a local working
* directory.
*/
public Builder workingDirectory(File workingDirectory) {
this.workingDirectory = workingDirectory;
return this;
}
public Builder tee(PrintStream printStream) {
tee = printStream;
return this;
}
public Builder maxLength(int maxLength) {
this.maxLength = maxLength;
return this;
}
public Command build() {
return new Command(this);
}
public List<String> execute() {
return build().execute();
}
}
}