
com.jaredrummler.android.shell.Shell Maven / Gradle / Ivy
Show all versions of android-shell Show documentation
/*
* Copyright (C) 2017 Jared Rummler
*
* 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.jaredrummler.android.shell;
import android.os.Build;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Looper;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.WorkerThread;
import java.io.Closeable;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
* Class providing functionality to execute commands in a (root) shell
*/
@SuppressWarnings("unused")
public class Shell {
static final String[] AVAILABLE_TEST_COMMANDS = new String[]{"echo -BOC-", "id"};
/**
* Runs commands using the supplied shell, and returns the result.
*
* When in debug mode, the code will also excessively log the commands passed to and the output returned from
* the shell.
*
* Though this function uses background threads to gobble stdout and stderr so a deadlock does not occur if the
* shell produces massive output, the output is still stored in a List<String>, and as such doing something
* like 'ls -lR /' will probably have you run out of memory.
*
* @param shell
* The shell to use for executing the commands
* @param commands
* The commands to execute
* @return Output of the commands, or null in case of an error
*/
@WorkerThread
public static CommandResult run(@NonNull String shell, @NonNull String... commands) {
return run(shell, commands, null);
}
/**
* Runs commands using the supplied shell, and returns the result.
*
* When in debug mode, the code will also excessively log the commands passed to and the output returned from
* the shell.
*
* Though this function uses background threads to gobble stdout and stderr so a deadlock does not occur if the
* shell produces massive output, the output is still stored in a List<String>, and as such doing something
* like 'ls -lR /' will probably have you run out of memory.
*
* @param shell
* The shell to use for executing the commands
* @param commands
* The commands to execute
* @param env
* List of all environment variables (in 'key=value' format) or null for defaults
* @return Output of the commands, or null in case of an error
*/
@WorkerThread
public static CommandResult run(@NonNull String shell, @NonNull String[] commands, @Nullable String[] env) {
List stdout = Collections.synchronizedList(new ArrayList());
List stderr = Collections.synchronizedList(new ArrayList());
int exitCode;
try {
// setup our process, retrieve stdin stream, and stdout/stderr gobblers
Process process = runWithEnv(shell, env);
DataOutputStream stdin = new DataOutputStream(process.getOutputStream());
StreamGobbler stdoutGobbler = new StreamGobbler(process.getInputStream(), stdout);
StreamGobbler stderrGobbler = new StreamGobbler(process.getErrorStream(), stderr);
// start gobbling and write our commands to the shell
stdoutGobbler.start();
stderrGobbler.start();
try {
for (String write : commands) {
stdin.write((write + "\n").getBytes("UTF-8"));
stdin.flush();
}
stdin.write("exit\n".getBytes("UTF-8"));
stdin.flush();
} catch (IOException e) {
//noinspection StatementWithEmptyBody
if (e.getMessage().contains("EPIPE")) {
// method most horrid to catch broken pipe, in which case we do nothing. the command is not a shell, the
// shell closed stdin, the script already contained the exit command, etc. these cases we want the output
// instead of returning null
} else {
// other issues we don't know how to handle, leads to returning null
throw e;
}
}
// wait for our process to finish, while we gobble away in the background
exitCode = process.waitFor();
// make sure our threads are done gobbling, our streams are closed, and the process is destroyed - while the
// latter two shouldn't be needed in theory, and may even produce warnings, in "normal" Java they are required
// for guaranteed cleanup of resources, so lets be safe and do this on Android as well
try {
stdin.close();
} catch (IOException e) {
// might be closed already
}
stdoutGobbler.join();
stderrGobbler.join();
process.destroy();
} catch (InterruptedException e) {
exitCode = ShellExitCode.WATCHDOG_EXIT;
} catch (IOException e) {
exitCode = ShellExitCode.SHELL_WRONG_UID;
}
return new CommandResult(stdout, stderr, exitCode);
}
/**
* This code is adapted from java.lang.ProcessBuilder.start().
*
* The problem is that Android doesn't allow us to modify the map returned by ProcessBuilder.environment(), even
* though the JavaDoc indicates that it should. This is because it simply returns the SystemEnvironment object that
* System.getenv() gives us. The relevant portion in the source code is marked as "// android changed", so
* presumably it's not the case in the original version of the Apache Harmony project.
*
* @param command
* The name of the program to execute. E.g. "su" or "sh".
* @param environment
* List of all environment variables (in 'key=value' format) or null for defaults
* @return new {@link Process} instance.
* @throws IOException
* if the requested program could not be executed.
*/
@WorkerThread
public static Process runWithEnv(@NonNull String command, @Nullable String[] environment) throws IOException {
if (environment != null) {
Map newEnvironment = new HashMap<>();
newEnvironment.putAll(System.getenv());
int split;
for (String entry : environment) {
if ((split = entry.indexOf("=")) >= 0) {
newEnvironment.put(entry.substring(0, split), entry.substring(split + 1));
}
}
int i = 0;
environment = new String[newEnvironment.size()];
for (Map.Entry entry : newEnvironment.entrySet()) {
environment[i] = entry.getKey() + "=" + entry.getValue();
i++;
}
}
return Runtime.getRuntime().exec(command, environment);
}
/**
* This code is adapted from java.lang.ProcessBuilder.start().
*
* The problem is that Android doesn't allow us to modify the map returned by ProcessBuilder.environment(), even
* though the JavaDoc indicates that it should. This is because it simply returns the SystemEnvironment object that
* System.getenv() gives us. The relevant portion in the source code is marked as "// android changed", so
* presumably it's not the case in the original version of the Apache Harmony project.
*
* @param command
* The name of the program to execute. E.g. "su" or "sh".
* @param environment
* Map of all environment variables
* @return new {@link Process} instance.
* @throws IOException
* if the requested program could not be executed.
*/
@WorkerThread
public static Process runWithEnv(@NonNull String command, Map environment) throws IOException {
String[] env;
if (environment != null && environment.size() != 0) {
Map newEnvironment = new HashMap<>();
newEnvironment.putAll(System.getenv());
newEnvironment.putAll(environment);
int i = 0;
env = new String[newEnvironment.size()];
for (Map.Entry entry : newEnvironment.entrySet()) {
env[i] = entry.getKey() + "=" + entry.getValue();
i++;
}
} else {
env = null;
}
return Runtime.getRuntime().exec(command, env);
}
/**
* See if the shell is alive, and if so, check the UID
*
* @param stdout
* Standard output from running AVAILABLE_TEST_COMMANDS
* @param checkForRoot
* true if we are expecting this shell to be running as root
* @return true on success, false on error
*/
static boolean parseAvailableResult(List stdout, boolean checkForRoot) {
if (stdout == null) {
return false;
}
// this is only one of many ways this can be done
boolean echoSeen = false;
for (String line : stdout) {
if (line.contains("uid=")) {
// id command is working, let's see if we are actually root
return !checkForRoot || line.contains("uid=0");
} else if (line.contains("-BOC-")) {
// if we end up here, at least the su command starts some kind of shell, let's hope it has root privileges -
// no way to know without additional native binaries
echoSeen = true;
}
}
return echoSeen;
}
/**
* This class provides utility functions to easily execute commands using SH
*/
public static class SH {
private static volatile Console console;
/**
* The {@link Console} is used to keep the shell open for long periods of time so a new shell does not need to
* be created each time you run a command. Because this shell remains open, any commands sent to execute on it may
* need to wait for previous commands to finish. If you need to execute long running commands consider building
* your own interactive shell using {@link Builder} or {@link Console.Builder}.
*
* @return The {@link Console} instance for running commands in a normal shell.
* @throws ShellNotFoundException
*/
@WorkerThread
public static Console getConsole() throws ShellNotFoundException {
if (console == null || console.isClosed()) {
synchronized (SH.class) {
if (console == null || console.isClosed()) {
console = new Console.Builder().useSH().setWatchdogTimeout(30).build();
}
}
}
return console;
}
/**
* Closes the console if open
*/
public static void closeConsole() {
if (console != null) {
synchronized (SH.class) {
if (console != null) {
console.close();
console = null;
}
}
}
}
/**
* Runs commands and return output.
*
* A new shell is opened each time a command is run. If you want to keep the shell open then use
* {@link Shell.Interactive}, {@link Console} or {@link #getConsole()}
*
* @param commands
* The commands to run
* @return Output of the commands, or null in case of an error
*/
@WorkerThread
public static CommandResult run(@NonNull String... commands) {
return Shell.run("sh", commands);
}
}
/**
* This class provides utility functions to easily execute commands using SU (root shell), as well as detecting
* whether or not root is available, and if so which version.
*/
public static class SU {
private static Boolean isSELinuxEnforcing = null;
private static String[] suVersion = new String[]{null, null};
private static volatile Console console;
/**
* The {@link Console} is used to keep the shell open for long periods of time so a new shell does not need to
* be created each time you run a command. Because this shell remains open, any commands sent to execute on it may
* need to wait for previous commands to finish. If you need to execute long running commands consider building
* your own interactive shell using {@link Builder} or {@link Console.Builder}.
*
* @return The {@link Console} instance for running commands in a root shell.
* @throws ShellNotFoundException
*/
@WorkerThread
public static Console getConsole() throws ShellNotFoundException {
if (console == null || console.isClosed()) {
synchronized (SH.class) {
if (console == null || console.isClosed()) {
console = new Console.Builder().useSU().setWatchdogTimeout(30).build();
}
}
}
return console;
}
/**
* Closes the console if open
*/
public static void closeConsole() {
if (console != null) {
synchronized (SU.class) {
if (console != null) {
console.close();
console = null;
}
}
}
}
/**
* Runs commands as root (if available) and return output.
*
* The commands are run in an interactive shell that remains open after the commands finish execution. This is
* done so the superuser management app does not continually show toast messages for granting root access. If you
* want to run commands in a shell that exits on completion use {@link Shell#run(String, String...)} or
* {@link Shell#run(String, String[], String[])}. You can also open your own interactive shell using {@link
* Builder} or {@link Console.Builder}.
*
* @param commands
* The commands to run
* @return Output of the commands, or null if root isn't available or in case of an error
*/
@WorkerThread
public static CommandResult run(@NonNull String... commands) {
try {
Console console = SU.getConsole();
return console.run(commands);
} catch (ShellNotFoundException e) {
return new CommandResult(
Collections.emptyList(), Collections.emptyList(), ShellExitCode.SHELL_NOT_FOUND);
}
}
/**
* Detects whether or not superuser access is available, by checking the output of the "id"
* command if available, checking if a shell runs at all otherwise
*
* @return {@code true} if superuser access is available
*/
@WorkerThread
public static boolean available() {
// this is only one of many ways this can be done
CommandResult result = run(Shell.AVAILABLE_TEST_COMMANDS);
return Shell.parseAvailableResult(result.stdout, true);
}
/**
* Detects the version of the su binary installed (if any), if supported by the binary. Most binaries support
* two different version numbers, the public version that is displayed to users, and an internal version number
* that is used for version number comparisons. Returns null if su not available or retrieving the version isn't
* supported.
*
* Note that su binary version and GUI (APK) version can be completely different.
*
* This function caches its result to improve performance on multiple calls
*
* @param internal
* Request human-readable version or application internal version
* @return String containing the su version or null
*/
@WorkerThread
public static synchronized String version(boolean internal) {
int idx = internal ? 0 : 1;
if (suVersion[idx] == null) {
String version = null;
CommandResult result = Shell.run(internal ? "su -V" : "su -v", "exit");
for (String line : result.stdout) {
if (!internal) {
if (!line.trim().equals("")) {
version = line;
break;
}
} else {
try {
if (Integer.parseInt(line) > 0) {
version = line;
break;
}
} catch (NumberFormatException e) {
// should be parsable, try next line otherwise
}
}
}
suVersion[idx] = version;
}
return suVersion[idx];
}
/**
* Attempts to deduce if the shell command refers to a su shell
*
* @param shell
* Shell command to run
* @return Shell command appears to be su
*/
public static boolean isSU(@NonNull String shell) {
// Strip parameters
int pos = shell.indexOf(' ');
if (pos >= 0) {
shell = shell.substring(0, pos);
}
// Strip path
pos = shell.lastIndexOf('/');
if (pos >= 0) {
shell = shell.substring(pos + 1);
}
return shell.equals("su");
}
/**
* Constructs a shell command to start a su shell using the supplied uid and SELinux context.
* This is can be an expensive operation, consider caching the result.
*
* @param uid
* Uid to use (0 == root)
* @param context
* (SELinux) context name to use or null
* @return Shell command
*/
public static String shell(int uid, @Nullable String context) {
// su[ --context ][ ]
String shell = "su";
if ((context != null) && isSELinuxEnforcing()) {
String display = version(false);
String internal = version(true);
// We only know the format for SuperSU v1.90+ right now
if ((display != null) &&
(internal != null) &&
(display.endsWith("SUPERSU")) &&
(Integer.valueOf(internal) >= 190)) {
shell = String.format(Locale.ENGLISH, "%s --context %s", shell, context);
}
}
// Most su binaries support the "su " format, but in case they don't, lets skip it for
// the default 0 (root) case
if (uid > 0) {
shell = String.format(Locale.ENGLISH, "%s %d", shell, uid);
}
return shell;
}
/**
* Constructs a shell command to start a su shell connected to mount master daemon, to perform public mounts on
* Android 4.3+ (or 4.2+ in SELinux enforcing mode)
*
* @return Shell command
*/
public static String shellMountMaster() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
return "su --mount-master";
}
return "su";
}
/**
* Detect if SELinux is set to enforcing, caches result
*
* @return true if SELinux set to enforcing, or false in the case of permissive or not present
*/
@WorkerThread
public static synchronized boolean isSELinuxEnforcing() {
if (isSELinuxEnforcing == null) {
Boolean enforcing = null;
// First known firmware with SELinux built-in was a 4.2 (17) leak
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
// Detect enforcing through sysfs, not always present
File f = new File("/sys/fs/selinux/enforce");
if (f.exists()) {
InputStream is = null;
try {
is = new FileInputStream("/sys/fs/selinux/enforce");
enforcing = (is.read() == '1');
} catch (Exception e) {
// we might not be allowed to read, thanks SELinux
} finally {
if (is != null) {
try {
is.close();
} catch (IOException ignored) {
}
}
}
}
// 4.4+ builds are enforcing by default, take the gamble
if (enforcing == null) {
try {
Class> SELinux = Class.forName("android.os.SELinux");
Method isSELinuxEnforced = SELinux.getMethod("isSELinuxEnforced");
if (!isSELinuxEnforced.isAccessible()) isSELinuxEnforced.setAccessible(true);
enforcing = (Boolean) isSELinuxEnforced.invoke(SELinux.newInstance());
} catch (Exception e) {
// 4.4+ release builds are enforcing by default, take the gamble
enforcing = Build.VERSION.SDK_INT >= 19;
}
}
}
if (enforcing == null) {
enforcing = false;
}
isSELinuxEnforcing = enforcing;
}
return isSELinuxEnforcing;
}
/**
* Clears results cached by isSELinuxEnforcing() and version(boolean internal) calls.
*
* Most apps should never need to call this, as neither enforcing status nor su version is
* likely to change on a running device - though it is not impossible.
*/
public static synchronized void clearCachedResults() {
isSELinuxEnforcing = null;
suVersion[0] = null;
suVersion[1] = null;
}
}
/**
* Command result callback, notifies the recipient of the completion of a command block, including the (last) exit
* code, and the full output
*/
public interface OnCommandResultListener extends ShellExitCode {
/**
* Command result callback
*
* Depending on how and on which thread the shell was created, this callback may be executed on one of the
* gobbler threads. In that case, it is important the callback returns as quickly as possible, as delays in this
* callback may pause the native process or even result in a deadlock
*
* See {@link Shell.Interactive} for threading details
*
* @param commandCode
* Value previously supplied to addCommand
* @param exitCode
* Exit code of the last command in the block
* @param output
* All output generated by the command block
*/
void onCommandResult(int commandCode, int exitCode, List output);
}
/**
* Command per line callback for parsing the output line by line without buffering It also notifies the recipient
* of the completion of a command block, including the (last) exit code.
*/
public interface OnCommandLineListener extends ShellExitCode, StreamGobbler.OnLineListener {
/**
* Command result callback
*
* Depending on how and on which thread the shell was created, this callback may be executed on one of the
* gobbler threads. In that case, it is important the callback returns as quickly as possible, as delays in this
* callback may pause the native process or even result in a deadlock
*
* See {@link Shell.Interactive} for threading details
*
* @param commandCode
* Value previously supplied to addCommand
* @param exitCode
* Exit code of the last command in the block
*/
void onCommandResult(int commandCode, int exitCode);
}
/**
* Internal class to store command block properties
*/
private static class Command {
private static int commandCounter = 0;
private final String[] commands;
private final int code;
private final OnCommandResultListener onCommandResultListener;
private final OnCommandLineListener onCommandLineListener;
private final String marker;
public Command(String[] commands, int code, OnCommandResultListener onCommandResultListener,
OnCommandLineListener onCommandLineListener) {
this.commands = commands;
this.code = code;
this.onCommandResultListener = onCommandResultListener;
this.onCommandLineListener = onCommandLineListener;
this.marker = UUID.randomUUID().toString() + String.format("-%08x", ++commandCounter);
}
}
/**
* Builder class for {@link Shell.Interactive}
*/
public static class Builder {
private Map environment = new HashMap<>();
private List commands = new LinkedList<>();
private StreamGobbler.OnLineListener onStdoutLineListener;
private StreamGobbler.OnLineListener onStderrLineListener;
private Handler handler;
private boolean autoHandler = true;
private boolean wantStderr;
private String shell = "sh";
private int watchdogTimeout;
/**
* Set a custom handler that will be used to post all callbacks to
*
* See {@link Shell.Interactive} for further details on threading and handlers
*
* @param handler
* Handler to use
* @return This Builder object for method chaining
*/
public Builder setHandler(Handler handler) {
this.handler = handler;
return this;
}
/**
* Automatically create a handler if possible ? Default to true
*
* See {@link Shell.Interactive} for further details on threading and handlers
*
* @param autoHandler
* Auto-create handler ?
* @return This Builder object for method chaining
*/
public Builder setAutoHandler(boolean autoHandler) {
this.autoHandler = autoHandler;
return this;
}
/**
* Set shell binary to use. Usually "sh" or "su", do not use a full path unless you have a good
* reason to
*
* @param shell
* Shell to use
* @return This Builder object for method chaining
*/
public Builder setShell(String shell) {
this.shell = shell;
return this;
}
/**
* Convenience function to set "sh" as used shell
*
* @return This Builder object for method chaining
*/
public Builder useSH() {
return setShell("sh");
}
/**
* Convenience function to set "su" as used shell
*
* @return This Builder object for method chaining
*/
public Builder useSU() {
return setShell("su");
}
/**
* Set if error output should be appended to command block result output
*
* @param wantStderr
* Want error output ?
* @return This Builder object for method chaining
*/
public Builder setWantStderr(boolean wantStderr) {
this.wantStderr = wantStderr;
return this;
}
/**
* Add or update an environment variable
*
* @param key
* Key of the environment variable
* @param value
* Value of the environment variable
* @return This Builder object for method chaining
*/
public Builder addEnvironment(String key, String value) {
environment.put(key, value);
return this;
}
/**
* Add or update environment variables
*
* @param addEnvironment
* Map of environment variables
* @return This Builder object for method chaining
*/
public Builder addEnvironment(Map addEnvironment) {
environment.putAll(addEnvironment);
return this;
}
/**
* Add a command to execute
*
* @param command
* Command to execute
* @return This Builder object for method chaining
*/
public Builder addCommand(String command) {
return addCommand(command, 0, null);
}
/**
* Add a command to execute, with a callback to be called on completion
*
* The thread on which the callback executes is dependent on various factors, see {@link Shell.Interactive}
* for further details
*
* @param command
* Command to execute
* @param code
* User-defined value passed back to the callback
* @param onCommandResultListener
* Callback to be called on completion
* @return This Builder object for method chaining
*/
public Builder addCommand(String command, int code, OnCommandResultListener onCommandResultListener) {
return addCommand(new String[]{
command
}, code, onCommandResultListener);
}
/**
* Add commands to execute
*
* @param commands
* Commands to execute
* @return This Builder object for method chaining
*/
public Builder addCommand(List commands) {
return addCommand(commands, 0, null);
}
/**
* Add commands to execute, with a callback to be called on completion (of all commands)
*
* The thread on which the callback executes is dependent on various factors, see {@link Shell.Interactive}
* for further details
*
* @param commands
* Commands to execute
* @param code
* User-defined value passed back to the callback
* @param onCommandResultListener
* Callback to be called on completion (of all commands)
* @return This Builder object for method chaining
*/
public Builder addCommand(List commands, int code, OnCommandResultListener onCommandResultListener) {
return addCommand(commands.toArray(new String[commands.size()]), code,
onCommandResultListener);
}
/**
* Add commands to execute
*
* @param commands
* Commands to execute
* @return This Builder object for method chaining
*/
public Builder addCommand(String[] commands) {
return addCommand(commands, 0, null);
}
/**
* Add commands to execute, with a callback to be called on completion (of all commands)
*
* The thread on which the callback executes is dependent on various factors, see {@link Shell.Interactive}
* for further details
*
* @param commands
* Commands to execute
* @param code
* User-defined value passed back to the callback
* @param onCommandResultListener
* Callback to be called on completion
* (of all commands)
* @return This Builder object for method chaining
*/
public Builder addCommand(String[] commands, int code, OnCommandResultListener onCommandResultListener) {
this.commands.add(new Command(commands, code, onCommandResultListener, null));
return this;
}
/**
* Set a callback called for every line output to stdout by the shell
*
* The thread on which the callback executes is dependent on various factors, see {@link Shell.Interactive}
* for further details
*
* @param onLineListener
* Callback to be called for each line
* @return This Builder object for method chaining
*/
public Builder setOnStdoutLineListener(StreamGobbler.OnLineListener onLineListener) {
this.onStdoutLineListener = onLineListener;
return this;
}
/**
* Set a callback called for every line output to stderr by the shell
*
* The thread on which the callback executes is dependent on various factors, see {@link Shell.Interactive}
* for further details
*
* @param onLineListener
* Callback to be called for each line
* @return This Builder object for method chaining
*/
public Builder setOnStderrLineListener(StreamGobbler.OnLineListener onLineListener) {
this.onStderrLineListener = onLineListener;
return this;
}
/**
* Enable command timeout callback
*
* This will invoke the onCommandResult() callback with exitCode WATCHDOG_EXIT if a command takes longer than
* watchdogTimeout seconds to complete.
*
* If a watchdog timeout occurs, it generally means that the Interactive session is out of sync with the shell
* process. The caller should close the current session and open a new one.
*
* @param watchdogTimeout
* Timeout, in seconds; 0 to disable
* @return This Builder object for method chaining
*/
public Builder setWatchdogTimeout(int watchdogTimeout) {
this.watchdogTimeout = watchdogTimeout;
return this;
}
/**
* Construct a {@link Shell.Interactive} instance, and start the shell
*/
@WorkerThread
public Interactive open() {
return new Interactive(this, null);
}
/**
* Construct a {@link Shell.Interactive} instance, try to start the shell, and call onCommandResultListener to
* report success or failure
*
* @param onCommandResultListener
* Callback to return shell open status
*/
@WorkerThread
public Interactive open(OnCommandResultListener onCommandResultListener) {
return new Interactive(this, onCommandResultListener);
}
}
/**
* An interactive shell - initially created with {@link Shell.Builder} - that executes blocks of commands you
* supply in the background, optionally calling callbacks as each block completes.
*
* stderr output can be supplied as well, but due to compatibility with older Android versions, wantStderr is
* not implemented using redirectErrorStream, but rather appended to the output. stdout and stderr are thus not
* guaranteed to be in the correct order in the output.
*
* Note as well that the close() and waitForIdle() methods will intentionally crash when run in debug mode from
* the main thread of the application. Any blocking call should be run from a background thread.
*
* When in debug mode, the code will also excessively log the commands passed to and the output returned from
* the shell.
*
* Though this function uses background threads to gobble stdout and stderr so a deadlock does not occur if the
* shell produces massive output, the output is still stored in a List, and as such doing something like 'ls
* -lR /' will probably have you run out of memory when using a {@link Shell.OnCommandResultListener}. A
* work-around is to not supply this callback, but using (only)
* {@link Shell.Builder#setOnStdoutLineListener(StreamGobbler.OnLineListener)}. This way, an internal buffer will
* not be created and wasting your memory.
*
* Callbacks, threads and handlers
*
* On which thread the callbacks execute is dependent on your initialization. You can supply a custom Handler
* using {@link Shell.Builder#setHandler(Handler)} if needed. If you do not supply a custom Handler - unless you set
* {@link Shell.Builder#setAutoHandler(boolean)} to false - a Handler will be auto-created if the thread used for
* instantiation of the object has a Looper.
*
* If no Handler was supplied and it was also not auto-created, all callbacks will be called from either the
* stdout or stderr gobbler threads. These are important threads that should be blocked as little as possible, as
* blocking them may in rare cases pause the native process or even create a deadlock.
*
* The main thread must certainly have a Looper, thus if you call {@link Shell.Builder#open()} from the main
* thread, a handler will (by default) be auto-created, and all the callbacks will be called on the main thread.
* While this is often convenient and easy to code with, you should be aware that if your callbacks are 'expensive'
* to execute, this may negatively impact UI performance.
*
* Background threads usually do not have a Looper, so calling {@link Shell.Builder#open()} from such a
* background thread will (by default) result in all the callbacks being executed in one of the gobbler threads.
* You will have to make sure the code you execute in these callbacks is thread-safe.
*/
@SuppressWarnings("unused")
public static class Interactive {
private final Handler handler;
private final boolean autoHandler;
final String shell;
final boolean wantSTDERR;
private final List commands;
private final Map environment;
final StreamGobbler.OnLineListener onStdoutLineListener;
final StreamGobbler.OnLineListener onStderrLineListener;
private final Object idleSync = new Object();
private final Object callbackSync = new Object();
volatile String lastMarkerStdout;
volatile String lastMarkerStderr;
volatile Command command;
private volatile List buffer;
private volatile boolean running;
private volatile boolean idle = true; // read/write only synchronized
private volatile boolean closed = true;
private volatile int callbacks;
private volatile int watchdogCount;
volatile int lastExitCode;
private Process process;
private DataOutputStream stdin;
private StreamGobbler stdout;
private StreamGobbler stderr;
private ScheduledThreadPoolExecutor watchdog;
int watchdogTimeout;
/**
* The only way to create an instance: Shell.Builder::open()
*
* @param builder
* Builder class to take values from
*/
Interactive(final Builder builder, final OnCommandResultListener onCommandResultListener) {
autoHandler = builder.autoHandler;
shell = builder.shell;
wantSTDERR = builder.wantStderr;
commands = builder.commands;
environment = builder.environment;
onStdoutLineListener = builder.onStdoutLineListener;
onStderrLineListener = builder.onStderrLineListener;
watchdogTimeout = builder.watchdogTimeout;
// If a looper is available, we offload the callbacks from the gobbling threads to whichever thread created us.
// Would normally do this in open(), but then we could not declare handler as final
if ((Looper.myLooper() != null) && (builder.handler == null) && autoHandler) {
handler = new Handler();
} else {
handler = builder.handler;
}
if (onCommandResultListener != null) {
// Allow up to 60 seconds for SuperSU/Superuser dialog, then enable the user-specified timeout for all
// subsequent operations
watchdogTimeout = 60;
commands.add(0, new Command(Shell.AVAILABLE_TEST_COMMANDS, 0, new OnCommandResultListener() {
@Override public void onCommandResult(int commandCode, int exitCode, List output) {
if ((exitCode == ShellExitCode.SUCCESS) &&
!Shell.parseAvailableResult(output, Shell.SU.isSU(shell))) {
// shell is up, but it's brain-damaged
exitCode = ShellExitCode.SHELL_EXEC_FAILED;
}
watchdogTimeout = builder.watchdogTimeout;
onCommandResultListener.onCommandResult(0, exitCode, output);
}
}, null));
}
if (!open() && (onCommandResultListener != null)) {
onCommandResultListener.onCommandResult(0, ShellExitCode.SHELL_WRONG_UID, null);
}
}
/**
* Add a command to execute
*
* @param commands
* Commands to execute
*/
public void addCommand(@NonNull String... commands) {
addCommand(commands, 0, (OnCommandResultListener) null);
}
/**
* Add a command to execute, with a callback to be called on completion
*
* The thread on which the callback executes is dependent on various factors, see {@link Shell.Interactive}
* for further details
*
* @param command
* Command to execute
* @param code
* User-defined value passed back to the callback
* @param resultListener
* Callback to be called on completion
*/
public void addCommand(@NonNull String command, int code, @Nullable OnCommandResultListener resultListener) {
addCommand(new String[]{command}, code, resultListener);
}
/**
* Add a command to execute, with a callback. This callback gobbles the output line by line without buffering
* it and also returns the result code on completion.
*
* The thread on which the callback executes is dependent on various factors, see {@link Shell.Interactive}
* for further details
*
* @param command
* Command to execute
* @param code
* User-defined value passed back to the callback
* @param onCommandLineListener
* Callback
*/
public void addCommand(@NonNull String command, int code, @Nullable OnCommandLineListener onCommandLineListener) {
addCommand(new String[]{command}, code, onCommandLineListener);
}
/**
* Add commands to execute
*
* @param commands
* Commands to execute
*/
public void addCommand(@NonNull List commands) {
addCommand(commands, 0, (OnCommandResultListener) null);
}
/**
* Add commands to execute, with a callback to be called on completion (of all commands)
*
* The thread on which the callback executes is dependent on various factors, see {@link Shell.Interactive}
* for further details
*
* @param commands
* Commands to execute
* @param code
* User-defined value passed back to the callback
* @param onCommandResultListener
* Callback to be called on completion (of all commands)
*/
public void addCommand(@NonNull List commands, int code,
@Nullable OnCommandResultListener onCommandResultListener) {
addCommand(commands.toArray(new String[commands.size()]), code, onCommandResultListener);
}
/**
* Add commands to execute, with a callback. This callback gobbles the output line by line without buffering
* it and also returns the result code on completion.
*
* The thread on which the callback executes is dependent on various factors, see {@link Shell.Interactive}
* for further details
*
* @param commands
* Commands to execute
* @param code
* User-defined value passed back to the callback
* @param lineListener
* Callback
*/
public void addCommand(@NonNull List commands, int code,
@Nullable OnCommandLineListener lineListener) {
addCommand(commands.toArray(new String[commands.size()]), code, lineListener);
}
/**
* Add commands to execute, with a callback to be called on completion (of all commands)
*
* The thread on which the callback executes is dependent on various factors, see {@link Shell.Interactive}
* for further details
*
* @param commands
* Commands to execute
* @param code
* User-defined value passed back to the callback
* @param resultListener
* Callback to be called on completion (of all commands)
*/
public synchronized void addCommand(@NonNull String[] commands, int code,
@Nullable OnCommandResultListener resultListener) {
this.commands.add(new Command(commands, code, resultListener, null));
runNextCommand();
}
/**
* Add commands to execute, with a callback. This callback gobbles the output line by line without buffering
* it and also returns the result code on completion.
*
* The thread on which the callback executes is dependent on various factors, see {@link Shell.Interactive}
* for further details
*
* @param commands
* Commands to execute
* @param code
* User-defined value passed back to the callback
* @param onCommandLineListener
* Callback
*/
public synchronized void addCommand(@NonNull String[] commands, int code,
@Nullable OnCommandLineListener onCommandLineListener) {
this.commands.add(new Command(commands, code, null, onCommandLineListener));
runNextCommand();
}
/**
* Run the next command if any and if ready, signals idle state if no commands left
*/
private void runNextCommand() {
runNextCommand(true);
}
/**
* Called from a ScheduledThreadPoolExecutor timer thread every second when there is an outstanding command
*/
synchronized void handleWatchdog() {
final int exitCode;
if (watchdog == null)
return;
if (watchdogTimeout == 0)
return;
if (!isRunning()) {
exitCode = ShellExitCode.SHELL_DIED;
} else if (watchdogCount++ < watchdogTimeout) {
return;
} else {
exitCode = ShellExitCode.WATCHDOG_EXIT;
}
if (handler != null) {
postCallback(command, exitCode, buffer);
}
// prevent multiple callbacks for the same command
command = null;
buffer = null;
idle = true;
watchdog.shutdown();
watchdog = null;
kill();
}
/**
* Start the periodic timer when a command is submitted
*/
private void startWatchdog() {
if (watchdogTimeout == 0) {
return;
}
watchdogCount = 0;
watchdog = new ScheduledThreadPoolExecutor(1);
watchdog.scheduleAtFixedRate(new Runnable() {
@Override public void run() {
handleWatchdog();
}
}, 1, 1, TimeUnit.SECONDS);
}
/**
* Disable the watchdog timer upon command completion
*/
private void stopWatchdog() {
if (watchdog != null) {
watchdog.shutdownNow();
watchdog = null;
}
}
/**
* Run the next command if any and if ready
*
* @param notifyIdle
* signals idle state if no commands left ?
*/
private void runNextCommand(boolean notifyIdle) {
// must always be called from a synchronized method
boolean running = isRunning();
if (!running)
idle = true;
if (running && idle && (commands.size() > 0)) {
Command command = commands.get(0);
commands.remove(0);
buffer = null;
lastExitCode = 0;
lastMarkerStdout = null;
lastMarkerStderr = null;
if (command.commands.length > 0) {
try {
if (command.onCommandResultListener != null) {
// no reason to store the output if we don't have an OnCommandResultListener.
// user should catch the output with an OnLineListener in this case.
buffer = Collections.synchronizedList(new ArrayList());
}
idle = false;
this.command = command;
startWatchdog();
for (String write : command.commands) {
stdin.write((write + "\n").getBytes("UTF-8"));
}
stdin.write(("echo " + command.marker + " $?\n").getBytes("UTF-8"));
stdin.write(("echo " + command.marker + " >&2\n").getBytes("UTF-8"));
stdin.flush();
} catch (IOException e) {
// stdin might have closed
}
} else {
runNextCommand(false);
}
} else if (!running) {
// our shell died for unknown reasons - abort all submissions
while (commands.size() > 0) {
postCallback(commands.remove(0), ShellExitCode.SHELL_DIED, null);
}
}
if (idle && notifyIdle) {
synchronized (idleSync) {
idleSync.notifyAll();
}
}
}
/**
* Processes a stdout/stderr line containing an end/exitCode marker
*/
synchronized void processMarker() {
if (command.marker.equals(lastMarkerStdout)
&& (command.marker.equals(lastMarkerStderr))) {
postCallback(command, lastExitCode, buffer);
stopWatchdog();
command = null;
buffer = null;
idle = true;
runNextCommand();
}
}
/**
* Process a normal stdout/stderr line
*
* @param line
* Line to process
* @param listener
* Callback to call or null
*/
synchronized void processLine(String line, StreamGobbler.OnLineListener listener) {
if (listener != null) {
if (handler != null) {
final String fLine = line;
final StreamGobbler.OnLineListener fListener = listener;
startCallback();
handler.post(new Runnable() {
@Override public void run() {
try {
fListener.onLine(fLine);
} finally {
endCallback();
}
}
});
} else {
listener.onLine(line);
}
}
}
/**
* Add line to internal buffer
*
* @param line
* Line to add
*/
synchronized void addBuffer(String line) {
if (buffer != null) {
buffer.add(line);
}
}
/**
* Increase callback counter
*/
private void startCallback() {
synchronized (callbackSync) {
callbacks++;
}
}
/**
* Schedule a callback to run on the appropriate thread
*/
private void postCallback(final Command fCommand, final int fExitCode, final List fOutput) {
if (fCommand.onCommandResultListener == null && fCommand.onCommandLineListener == null) {
return;
}
if (handler == null/* || !handler.getLooper().getThread().isAlive()*/) {
if ((fCommand.onCommandResultListener != null) && (fOutput != null))
fCommand.onCommandResultListener.onCommandResult(fCommand.code, fExitCode, fOutput);
if (fCommand.onCommandLineListener != null)
fCommand.onCommandLineListener.onCommandResult(fCommand.code, fExitCode);
return;
}
startCallback();
handler.post(new Runnable() {
@Override public void run() {
try {
if ((fCommand.onCommandResultListener != null) && (fOutput != null))
fCommand.onCommandResultListener.onCommandResult(fCommand.code, fExitCode, fOutput);
if (fCommand.onCommandLineListener != null)
fCommand.onCommandLineListener.onCommandResult(fCommand.code, fExitCode);
} finally {
endCallback();
}
}
});
}
/**
* Decrease callback counter, signals callback complete state when dropped to 0
*/
void endCallback() {
synchronized (callbackSync) {
callbacks--;
if (callbacks == 0) {
callbackSync.notifyAll();
}
}
}
/**
* Internal call that launches the shell, starts gobbling, and starts executing commands. See
* {@link Shell.Interactive}
*
* @return Opened successfully ?
*/
private synchronized boolean open() {
try {
// setup our process, retrieve stdin stream, and stdout/stderr gobblers
process = runWithEnv(shell, environment);
stdin = new DataOutputStream(process.getOutputStream());
stdout = new StreamGobbler(process.getInputStream(), new StreamGobbler.OnLineListener() {
@Override public void onLine(String line) {
synchronized (Interactive.this) {
if (command == null) {
return;
}
String contentPart = line;
String markerPart = null;
int markerIndex = line.indexOf(command.marker);
if (markerIndex == 0) {
contentPart = null;
markerPart = line;
} else if (markerIndex > 0) {
contentPart = line.substring(0, markerIndex);
markerPart = line.substring(markerIndex);
}
if (contentPart != null) {
addBuffer(contentPart);
processLine(contentPart, onStdoutLineListener);
processLine(contentPart, command.onCommandLineListener);
}
if (markerPart != null) {
try {
lastExitCode = Integer.valueOf(markerPart.substring(command.marker.length() + 1), 10);
} catch (Exception e) {
// this really shouldn't happen
e.printStackTrace();
}
lastMarkerStdout = command.marker;
processMarker();
}
}
}
});
stderr = new StreamGobbler(process.getErrorStream(), new StreamGobbler.OnLineListener() {
@Override public void onLine(String line) {
synchronized (Interactive.this) {
if (command == null) {
return;
}
String contentPart = line;
int markerIndex = line.indexOf(command.marker);
if (markerIndex == 0) {
contentPart = null;
} else if (markerIndex > 0) {
contentPart = line.substring(0, markerIndex);
}
if (contentPart != null) {
if (wantSTDERR)
addBuffer(contentPart);
processLine(contentPart, onStderrLineListener);
}
if (markerIndex >= 0) {
lastMarkerStderr = command.marker;
processMarker();
}
}
}
});
// start gobbling and write our commands to the shell
stdout.start();
stderr.start();
running = true;
closed = false;
runNextCommand();
return true;
} catch (IOException e) {
// shell probably not found
return false;
}
}
/**
* Close shell and clean up all resources. Call this when you are done with the shell. If the shell is not idle
* (all commands completed) you should not call this method from the main UI thread because it may block for a
* long time. This method will intentionally crash your app (if in debug mode) if you try to do this anyway.
*/
public void close() {
boolean idle = isIdle(); // idle must be checked synchronized
synchronized (this) {
if (!running)
return;
running = false;
closed = true;
}
// This method should not be called from the main thread unless the shell is idle and can be cleaned up with
// (minimal) waiting.
if (!idle)
waitForIdle();
try {
try {
stdin.write(("exit\n").getBytes("UTF-8"));
stdin.flush();
} catch (IOException e) {
//noinspection StatementWithEmptyBody
if (e.getMessage().contains("EPIPE")) {
// we're not running a shell, the shell closed stdin,
// the script already contained the exit command, etc.
} else {
throw e;
}
}
// wait for our process to finish, while we gobble away in the background
process.waitFor();
// make sure our threads are done gobbling, our streams are closed, and the process is
// destroyed - while the latter two shouldn't be needed in theory, and may even produce
// warnings, in "normal" Java they are required for guaranteed cleanup of resources, so
// lets be safe and do this on Android as well
try {
stdin.close();
} catch (IOException e) {
// stdin going missing is no reason to abort
}
stdout.join();
stderr.join();
stopWatchdog();
process.destroy();
} catch (InterruptedException | IOException e) {
// various unforseen IO errors may still occur
}
}
/**
* Try to clean up as much as possible from a shell that's gotten itself wedged. Hopefully
* the StreamGobblers will croak on their own when the other side of the pipe is closed.
*/
public synchronized void kill() {
running = false;
closed = true;
try {
stdin.close();
} catch (IOException e) {
// in case it was closed
}
try {
process.destroy();
} catch (Exception e) {
// in case it was already destroyed or can't be
}
}
/**
* Is our shell still running ?
*
* @return Shell running ?
*/
public boolean isRunning() {
if (process == null) {
return false;
}
try {
process.exitValue();
return false;
} catch (IllegalThreadStateException e) {
// if this is thrown, we're still running
}
return true;
}
/**
* Have all commands completed executing ?
*
* @return Shell idle ?
*/
public synchronized boolean isIdle() {
if (!isRunning()) {
idle = true;
synchronized (idleSync) {
idleSync.notifyAll();
}
}
return idle;
}
/**
* Wait for idle state. As this is a blocking call, you should not call it from the main UI thread. If you do
* so and debug mode is enabled, this method will intentionally crash your app.
*
* If not interrupted, this method will not return until all commands have finished executing. Note that this
* does not necessarily mean that all the callbacks have fired yet.
*
* If no Handler is used, all callbacks will have been executed when this method returns. If a Handler is
* used, and this method is called from a different thread than associated with the Handler's Looper, all
* callbacks will have been executed when this method returns as well. If however a Handler is used but this
* method is called from the same thread as associated with the Handler's Looper, there is no way to know.
*
* In practice this means that in most simple cases all callbacks will have completed when this method
* returns, but if you actually depend on this behavior, you should make certain this is indeed the case.
*
* See {@link Shell.Interactive} for further details on threading and handlers
*
* @return True if wait complete, false if wait interrupted
*/
public boolean waitForIdle() {
if (isRunning()) {
synchronized (idleSync) {
while (!idle) {
try {
idleSync.wait();
} catch (InterruptedException e) {
return false;
}
}
}
if ((handler != null) &&
(handler.getLooper() != null) &&
(handler.getLooper() != Looper.myLooper())) {
// If the callbacks are posted to a different thread than this one, we can wait until all callbacks have
// called before returning. If we don't use a Handler at all, the callbacks are already called before we
// get here. If we do use a Handler but we use the same Looper, waiting here would actually block the
// callbacks from being called
synchronized (callbackSync) {
while (callbacks > 0) {
try {
callbackSync.wait();
} catch (InterruptedException e) {
return false;
}
}
}
}
}
return true;
}
/**
* Are we using a Handler to post callbacks ?
*
* @return Handler used ?
*/
public boolean hasHandler() {
return (handler != null);
}
}
/**
* Class that creates an {@link Interactive} shell and handles the callbacks and threads for you.
*/
public static class Console implements Closeable {
private final OnCloseListener onCloseListener;
private final Shell.Interactive shell;
final HandlerThread callbackThread;
private final boolean wantStderr;
List stdout;
List stderr;
int exitCode;
boolean isCommandRunning;
private boolean closed;
private final Shell.OnCommandResultListener commandResultListener = new Shell.OnCommandResultListener() {
@Override public void onCommandResult(int commandCode, int exitCode, List stdout) {
Console.this.exitCode = exitCode;
Console.this.stdout = stdout;
synchronized (callbackThread) {
isCommandRunning = false;
callbackThread.notifyAll();
}
}
};
Console(Builder builder) throws ShellNotFoundException {
try {
onCloseListener = builder.onCloseListener;
wantStderr = builder.wantStderr;
callbackThread = new HandlerThread("Shell Callback");
callbackThread.start();
isCommandRunning = true;
Shell.Builder shellBuilder = new Shell.Builder();
shellBuilder.setShell(builder.shell);
shellBuilder.setHandler(new Handler(callbackThread.getLooper()));
shellBuilder.setWatchdogTimeout(builder.watchdogTimeout);
shellBuilder.addEnvironment(builder.environment);
shellBuilder.setWantStderr(false);
if (builder.wantStderr) {
shellBuilder.setOnStderrLineListener(new StreamGobbler.OnLineListener() {
@Override public void onLine(String line) {
if (stderr != null) {
stderr.add(line);
}
}
});
}
shell = shellBuilder.open(commandResultListener);
waitForCommandFinished();
if (exitCode != ShellExitCode.SUCCESS) {
close();
throw new ShellNotFoundException("Access was denied or this is not a shell");
}
} catch (Exception e) {
throw new ShellNotFoundException("Error opening shell '" + builder.shell + "'", e);
}
}
/**
* Executes the commands in the shell and wait for its termination and return the result.
*
* @param commands
* The commands to execute.
* @return The {@link CommandResult}.
*/
@WorkerThread
public synchronized CommandResult run(String... commands) {
isCommandRunning = true;
if (wantStderr) {
stderr = Collections.synchronizedList(new ArrayList());
} else {
stderr = Collections.emptyList();
}
shell.addCommand(commands, 0, commandResultListener);
waitForCommandFinished();
CommandResult result = new CommandResult(stdout, stderr, exitCode);
stderr = null;
stdout = null;
return result;
}
/**
* Closes all resources related to the shell.
*/
@Override public synchronized void close() {
try {
shell.close();
} catch (Exception ignored) {
}
synchronized (callbackThread) {
callbackThread.notifyAll();
}
callbackThread.interrupt();
callbackThread.quit();
closed = true;
if (onCloseListener != null) {
onCloseListener.onClosed(this);
}
}
/**
* Check if the shell and callback thread have been closed.
*
* @return {@code true} if the {@link Interactive} shell has been closed.
*/
public boolean isClosed() {
return closed;
}
private void waitForCommandFinished() {
synchronized (callbackThread) {
while (isCommandRunning) {
try {
callbackThread.wait();
} catch (InterruptedException ignored) {
}
}
}
if (exitCode == ShellExitCode.WATCHDOG_EXIT || exitCode == ShellExitCode.SHELL_DIED) {
close();
}
}
/**
* Callback to be invoked when the shell closes.
*/
public interface OnCloseListener {
/**
* Called when the shell is closed. A shell may close unexpectedly, in which case a new shell should be opened.
*
* @param console
* The {@link Console} holding the interactive shell that was closed.
*/
void onClosed(Console console);
}
/**
* Create a {@link Console} for running commands in an {@link Interactive} shell.
*/
public static class Builder {
Console.OnCloseListener onCloseListener;
Map environment = new HashMap<>();
String shell = "sh";
boolean wantStderr = true;
int watchdogTimeout;
/**
* Set shell binary to use. Usually "sh" or "su", do not use a full path unless you have a good
* reason to
*
* @param shell
* Shell to use
* @return This Builder object for method chaining
*/
public Builder setShell(String shell) {
this.shell = shell;
return this;
}
/**
* Convenience function to set "sh" as used shell
*
* @return This Builder object for method chaining
*/
public Builder useSH() {
return setShell("sh");
}
/**
* Convenience function to set "su" as used shell
*
* @return This Builder object for method chaining
*/
public Builder useSU() {
return setShell("su");
}
/**
* Set if error output should be read and returned to the {@link CommandResult}.
*
* @param wantStderr
* {@code true} to read error output
* @return This Builder object for method chaining
*/
public Builder setWantStderr(boolean wantStderr) {
this.wantStderr = wantStderr;
return this;
}
/**
* Enable command timeout callback
*
* This will invoke the onCommandResult() callback with exitCode WATCHDOG_EXIT if a command takes longer than
* watchdogTimeout seconds to complete.
*
* If a watchdog timeout occurs, it generally means that the Interactive session is out of sync with the shell
* process. The caller should close the current session and open a new one.
*
* @param watchdogTimeout
* Timeout, in seconds; 0 to disable
* @return This Builder object for method chaining
*/
public Builder setWatchdogTimeout(int watchdogTimeout) {
this.watchdogTimeout = watchdogTimeout;
return this;
}
/**
* Add or update an environment variable
*
* @param key
* Key of the environment variable
* @param value
* Value of the environment variable
* @return This Builder object for method chaining
*/
public Builder addEnvironment(String key, String value) {
environment.put(key, value);
return this;
}
/**
* Add or update environment variables
*
* @param addEnvironment
* Map of environment variables
* @return This Builder object for method chaining
*/
public Builder addEnvironment(Map addEnvironment) {
environment.putAll(addEnvironment);
return this;
}
/**
* Set the listener that is called when the shell is closed.
*
* @param onCloseListener
* The {@link Console.OnCloseListener}
* @return This Builder object for method chaining
*/
public Builder setOnCloseListener(Console.OnCloseListener onCloseListener) {
this.onCloseListener = onCloseListener;
return this;
}
/**
* Opens the console
*
* @return The {@link Console} with the opened interactive shell
* @throws ShellNotFoundException
* If the shell is not an executable or could not be opened.
*/
public Console build() throws ShellNotFoundException {
return new Console(this);
}
}
}
}