From 1560fd334308b3fa93b1af33136db5ce292da635 Mon Sep 17 00:00:00 2001 From: jens Date: Tue, 18 May 2021 20:02:45 +0200 Subject: [PATCH] SuperSu related changes. --- .../jens/automation2/ActivityPermissions.java | 12 +- .../location/WifiBroadcastReceiver.java | 13 +- .../receivers/ConnectivityReceiver.java | 4 +- .../chainfire/libsuperuser/Application.java | 21 +- .../java/eu/chainfire/libsuperuser/Debug.java | 125 +- .../libsuperuser/HideOverlaysReceiver.java | 11 +- .../libsuperuser/MarkerInputStream.java | 186 ++ .../java/eu/chainfire/libsuperuser/Shell.java | 2906 ++++++++++++++--- .../chainfire/libsuperuser/StreamGobbler.java | 194 +- 9 files changed, 2858 insertions(+), 614 deletions(-) create mode 100644 app/src/main/java/eu/chainfire/libsuperuser/MarkerInputStream.java diff --git a/app/src/main/java/com/jens/automation2/ActivityPermissions.java b/app/src/main/java/com/jens/automation2/ActivityPermissions.java index ae01b48..d2dbdb3 100644 --- a/app/src/main/java/com/jens/automation2/ActivityPermissions.java +++ b/app/src/main/java/com/jens/automation2/ActivityPermissions.java @@ -510,8 +510,9 @@ public class ActivityPermissions extends Activity case setAirplaneMode: addToArrayListUnique(Manifest.permission.WRITE_SETTINGS, requiredPermissions); addToArrayListUnique(Manifest.permission.ACCESS_NETWORK_STATE, requiredPermissions); - addToArrayListUnique(permissionNameSuperuser, requiredPermissions); addToArrayListUnique(Manifest.permission.CHANGE_NETWORK_STATE, requiredPermissions); + /* Permission was not required anymore, even before Android 6: https://su.chainfire.eu/#updates-permission + addToArrayListUnique(permissionNameSuperuser, requiredPermissions);*/ break; case setBluetooth: addToArrayListUnique(Manifest.permission.BLUETOOTH_ADMIN, requiredPermissions); @@ -522,8 +523,9 @@ public class ActivityPermissions extends Activity case setDataConnection: addToArrayListUnique(Manifest.permission.WRITE_SETTINGS, requiredPermissions); addToArrayListUnique(Manifest.permission.ACCESS_NETWORK_STATE, requiredPermissions); - addToArrayListUnique(permissionNameSuperuser, requiredPermissions); addToArrayListUnique(Manifest.permission.CHANGE_NETWORK_STATE, requiredPermissions); + /* Permission was not required anymore, even before Android 6: https://su.chainfire.eu/#updates-permission + addToArrayListUnique(permissionNameSuperuser, requiredPermissions);*/ break; case setDisplayRotation: addToArrayListUnique(Manifest.permission.WRITE_SETTINGS, requiredPermissions); @@ -1273,7 +1275,8 @@ public class ActivityPermissions extends Activity mapActionPermissions.put("sendTextMessage", Manifest.permission.SEND_SMS); mapActionPermissions.put("setAirplaneMode", Manifest.permission.WRITE_SETTINGS); mapActionPermissions.put("setAirplaneMode", Manifest.permission.ACCESS_NETWORK_STATE); - mapActionPermissions.put("setAirplaneMode", permissionNameSuperuser); + /* Permission was not required anymore, even before Android 6: https://su.chainfire.eu/#updates-permission + mapActionPermissions.put("setAirplaneMode", permissionNameSuperuser);*/ mapActionPermissions.put("setAirplaneMode", Manifest.permission.CHANGE_NETWORK_STATE); mapActionPermissions.put("setBluetooth", Manifest.permission.BLUETOOTH_ADMIN); mapActionPermissions.put("setBluetooth", Manifest.permission.BLUETOOTH); @@ -1281,7 +1284,8 @@ public class ActivityPermissions extends Activity mapActionPermissions.put("setBluetooth", Manifest.permission.WRITE_SETTINGS); mapActionPermissions.put("setDataConnection", Manifest.permission.WRITE_SETTINGS); mapActionPermissions.put("setDataConnection", Manifest.permission.ACCESS_NETWORK_STATE); - mapActionPermissions.put("setDataConnection", permissionNameSuperuser); + /* Permission was not required anymore, even before Android 6: https://su.chainfire.eu/#updates-permission + mapActionPermissions.put("setDataConnection", permissionNameSuperuser);*/ mapActionPermissions.put("setDataConnection", Manifest.permission.CHANGE_NETWORK_STATE); mapActionPermissions.put("setDisplayRotation", Manifest.permission.WRITE_SETTINGS); mapActionPermissions.put("setUsbTethering", Manifest.permission.WRITE_SETTINGS); diff --git a/app/src/main/java/com/jens/automation2/location/WifiBroadcastReceiver.java b/app/src/main/java/com/jens/automation2/location/WifiBroadcastReceiver.java index 63aaca7..c2b2ea5 100644 --- a/app/src/main/java/com/jens/automation2/location/WifiBroadcastReceiver.java +++ b/app/src/main/java/com/jens/automation2/location/WifiBroadcastReceiver.java @@ -10,6 +10,7 @@ import android.net.NetworkInfo; import android.net.wifi.WifiManager; import android.util.Log; +import com.jens.automation2.AutomationService; import com.jens.automation2.Miscellaneous; import com.jens.automation2.PointOfInterest; import com.jens.automation2.R; @@ -101,7 +102,7 @@ public class WifiBroadcastReceiver extends BroadcastReceiver Miscellaneous.logEvent("i", "WifiReceiver", context.getResources().getString(R.string.poiHasNoWifiNotStoppingCellLocationListener), 2); } - findRules(parentLocationProvider); + findRules(AutomationService.getInstance()); } else if(myWifi.isConnectedOrConnecting()) // first time connect from wifi-listener-perspective { @@ -113,7 +114,7 @@ public class WifiBroadcastReceiver extends BroadcastReceiver String ssid = myWifiManager.getConnectionInfo().getSSID(); setLastWifiSsid(ssid); lastConnectedState = true; - findRules(parentLocationProvider); + findRules(AutomationService.getInstance()); } else if(!myWifi.isConnectedOrConnecting()) // really disconnected? because sometimes also fires on connect { @@ -126,7 +127,7 @@ public class WifiBroadcastReceiver extends BroadcastReceiver mayCellLocationChangedReceiverBeActivatedFromWifiPointOfWifi = true; CellLocationChangedReceiver.startCellLocationChangedReceiver(); lastConnectedState = false; - findRules(parentLocationProvider); + findRules(AutomationService.getInstance()); } catch(Exception e) { @@ -141,13 +142,13 @@ public class WifiBroadcastReceiver extends BroadcastReceiver } } - public static void findRules(LocationProvider parentLocationProvider) + public static void findRules(AutomationService automationServiceInstance) { ArrayList ruleCandidates = Rule.findRuleCandidatesByWifiConnection(); for(Rule oneRule : ruleCandidates) { - if(oneRule.applies(parentLocationProvider.parentService)) - oneRule.activate(parentLocationProvider.parentService, false); + if(oneRule.applies(automationServiceInstance)) + oneRule.activate(automationServiceInstance, false); } } diff --git a/app/src/main/java/com/jens/automation2/receivers/ConnectivityReceiver.java b/app/src/main/java/com/jens/automation2/receivers/ConnectivityReceiver.java index 43e0b70..5345d8c 100644 --- a/app/src/main/java/com/jens/automation2/receivers/ConnectivityReceiver.java +++ b/app/src/main/java/com/jens/automation2/receivers/ConnectivityReceiver.java @@ -161,7 +161,7 @@ public class ConnectivityReceiver extends BroadcastReceiver implements Automatio WifiManager wifiManager = (WifiManager) context.getSystemService(Context.WIFI_SERVICE); WifiInfo wifiInfo = wifiManager.getConnectionInfo(); WifiBroadcastReceiver.setLastWifiSsid(wifiInfo.getSSID()); - WifiBroadcastReceiver.findRules(automationServiceRef.getLocationProvider()); + WifiBroadcastReceiver.findRules(automationServiceRef); break; case ConnectivityManager.TYPE_MOBILE: boolean isRoaming = isRoaming(context); @@ -219,7 +219,7 @@ public class ConnectivityReceiver extends BroadcastReceiver implements Automatio // This will serve as a disconnected event. Happens if wifi is connected, then module deactivated. Miscellaneous.logEvent("i", "Connectivity", "Wifi deactivated while having been connected before.", 4); WifiBroadcastReceiver.lastConnectedState = false; - WifiBroadcastReceiver.findRules(automationServiceRef.getLocationProvider()); + WifiBroadcastReceiver.findRules(automationServiceRef); } } } diff --git a/app/src/main/java/eu/chainfire/libsuperuser/Application.java b/app/src/main/java/eu/chainfire/libsuperuser/Application.java index 095c720..949affd 100644 --- a/app/src/main/java/eu/chainfire/libsuperuser/Application.java +++ b/app/src/main/java/eu/chainfire/libsuperuser/Application.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2012-2014 Jorrit "Chainfire" Jongma + * Copyright (C) 2012-2019 Jorrit "Chainfire" Jongma * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,18 +20,24 @@ import android.content.Context; import android.os.Handler; import android.widget.Toast; +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + /** * Base application class to extend from, solving some issues with * toasts and AsyncTasks you are likely to run into */ +@SuppressWarnings("WeakerAccess") public class Application extends android.app.Application { /** * Shows a toast message - * + * * @param context Any context belonging to this application * @param message The message to show */ - public static void toast(Context context, String message) { + @AnyThread + public static void toast(@Nullable Context context, @NonNull String message) { // this is a static method so it is easier to call, // as the context checking and casting is done for you @@ -45,7 +51,7 @@ public class Application extends android.app.Application { final Context c = context; final String m = message; - ((Application)context).runInApplicationThread(new Runnable() { + ((Application) context).runInApplicationThread(new Runnable() { @Override public void run() { Toast.makeText(c, m, Toast.LENGTH_LONG).show(); @@ -54,14 +60,15 @@ public class Application extends android.app.Application { } } - private static Handler mApplicationHandler = new Handler(); + private static final Handler mApplicationHandler = new Handler(); /** * Run a runnable in the main application thread - * + * * @param r Runnable to run */ - public void runInApplicationThread(Runnable r) { + @AnyThread + public void runInApplicationThread(@NonNull Runnable r) { mApplicationHandler.post(r); } diff --git a/app/src/main/java/eu/chainfire/libsuperuser/Debug.java b/app/src/main/java/eu/chainfire/libsuperuser/Debug.java index 4ad0718..08cab75 100644 --- a/app/src/main/java/eu/chainfire/libsuperuser/Debug.java +++ b/app/src/main/java/eu/chainfire/libsuperuser/Debug.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2012-2014 Jorrit "Chainfire" Jongma + * Copyright (C) 2012-2019 Jorrit "Chainfire" Jongma * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,12 +18,19 @@ package eu.chainfire.libsuperuser; import android.os.Looper; import android.util.Log; +import android.os.Process; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.jens.automation2.BuildConfig; /** * Utility class for logging and debug features that (by default) does nothing when not in debug mode */ +@SuppressWarnings({"WeakerAccess", "UnusedReturnValue", "unused"}) +@AnyThread public class Debug { // ----- DEBUGGING ----- @@ -32,23 +39,23 @@ public class Debug { /** *

Enable or disable debug mode

- * + * *

By default, debug mode is enabled for development * builds and disabled for exported APKs - see * BuildConfig.DEBUG

- * + * * @param enable Enable debug mode ? - */ - public static void setDebug(boolean enable) { - debug = enable; + */ + public static void setDebug(boolean enable) { + debug = enable; } /** *

Is debug mode enabled ?

- * + * * @return Debug mode enabled */ - public static boolean getDebug() { + public static boolean getDebug() { return debug; } @@ -63,25 +70,27 @@ public class Debug { public static final int LOG_GENERAL = 0x0001; public static final int LOG_COMMAND = 0x0002; public static final int LOG_OUTPUT = 0x0004; + public static final int LOG_POOL = 0x0008; public static final int LOG_NONE = 0x0000; public static final int LOG_ALL = 0xFFFF; private static int logTypes = LOG_ALL; + @Nullable private static OnLogListener logListener = null; /** *

Log a message (internal)

- * - *

Current debug and enabled logtypes decide what gets logged - - * even if a custom callback is registered

- * - * @param type Type of message to log + * + *

Current debug and enabled logtypes decide what gets logged - + * even if a custom callback is registered

+ * + * @param type Type of message to log * @param typeIndicator String indicator for message type - * @param message The message to log + * @param message The message to log */ - private static void logCommon(int type, String typeIndicator, String message) { + private static void logCommon(int type, @NonNull String typeIndicator, @NonNull String message) { if (debug && ((logTypes & type) == type)) { if (logListener != null) { logListener.onLog(type, typeIndicator, message); @@ -89,52 +98,61 @@ public class Debug { Log.d(TAG, "[" + TAG + "][" + typeIndicator + "]" + (!message.startsWith("[") && !message.startsWith(" ") ? " " : "") + message); } } - } + } /** *

Log a "general" message

- * + * *

These messages are infrequent and mostly occur at startup/shutdown or on error

- * + * * @param message The message to log */ - public static void log(String message) { + public static void log(@NonNull String message) { logCommon(LOG_GENERAL, "G", message); } /** *

Log a "per-command" message

- * + * *

This could produce a lot of output if the client runs many commands in the session

- * + * * @param message The message to log */ - public static void logCommand(String message) { + public static void logCommand(@NonNull String message) { logCommon(LOG_COMMAND, "C", message); } /** *

Log a line of stdout/stderr output

- * + * *

This could produce a lot of output if the shell commands are noisy

- * + * * @param message The message to log */ - public static void logOutput(String message) { + public static void logOutput(@NonNull String message) { logCommon(LOG_OUTPUT, "O", message); } + /** + *

Log pool event

+ * + * @param message The message to log + */ + public static void logPool(@NonNull String message) { + logCommon(LOG_POOL, "P", message); + } + /** *

Enable or disable logging specific types of message

- * + * *

You may | (or) LOG_* constants together. Note that * debug mode must also be enabled for actual logging to * occur.

- * - * @param type LOG_* constants + * + * @param type LOG_* constants * @param enable Enable or disable */ - public static void setLogTypeEnabled(int type, boolean enable) { + public static void setLogTypeEnabled(int type, boolean enable) { if (enable) { logTypes |= type; } else { @@ -144,26 +162,28 @@ public class Debug { /** *

Is logging for specific types of messages enabled ?

- * + * *

You may | (or) LOG_* constants together, to learn if * all passed message types are enabled for logging. Note * that debug mode must also be enabled for actual logging * to occur.

- * + * * @param type LOG_* constants + * @return enabled? */ - public static boolean getLogTypeEnabled(int type) { - return ((logTypes & type) == type); + public static boolean getLogTypeEnabled(int type) { + return ((logTypes & type) == type); } /** *

Is logging for specific types of messages enabled ?

- * + * *

You may | (or) LOG_* constants together, to learn if * all message types are enabled for logging. Takes * debug mode into account for the result.

- * + * * @param type LOG_* constants + * @return enabled and in debug mode? */ public static boolean getLogTypeEnabledEffective(int type) { return getDebug() && getLogTypeEnabled(type); @@ -171,22 +191,23 @@ public class Debug { /** *

Register a custom log handler

- * + * *

Replaces the log method (write to logcat) with your own * handler. Whether your handler gets called is still dependent * on debug mode and message types being enabled for logging.

- * + * * @param onLogListener Custom log listener or NULL to revert to default */ - public static void setOnLogListener(OnLogListener onLogListener) { + public static void setOnLogListener(@Nullable OnLogListener onLogListener) { logListener = onLogListener; } /** *

Get the currently registered custom log handler

- * - * @return Current custom log handler or NULL if none is present + * + * @return Current custom log handler or NULL if none is present */ + @Nullable public static OnLogListener getOnLogListener() { return logListener; } @@ -197,10 +218,10 @@ public class Debug { /** *

Enable or disable sanity checks

- * - *

Enables or disables the library crashing when su is called + * + *

Enables or disables the library crashing when su is called * from the main thread.

- * + * * @param enable Enable or disable */ public static void setSanityChecksEnabled(boolean enable) { @@ -209,10 +230,10 @@ public class Debug { /** *

Are sanity checks enabled ?

- * + * *

Note that debug mode must also be enabled for actual - * sanity checks to occur.

- * + * sanity checks to occur.

+ * * @return True if enabled */ public static boolean getSanityChecksEnabled() { @@ -221,9 +242,9 @@ public class Debug { /** *

Are sanity checks enabled ?

- * - *

Takes debug mode into account for the result.

- * + * + *

Takes debug mode into account for the result.

+ * * @return True if enabled */ public static boolean getSanityChecksEnabledEffective() { @@ -232,11 +253,11 @@ public class Debug { /** *

Are we running on the main thread ?

- * + * * @return Running on main thread ? - */ + */ public static boolean onMainThread() { - return ((Looper.myLooper() != null) && (Looper.myLooper() == Looper.getMainLooper())); + return ((Looper.myLooper() != null) && (Looper.myLooper() == Looper.getMainLooper()) && (Process.myUid() != 0)); } } diff --git a/app/src/main/java/eu/chainfire/libsuperuser/HideOverlaysReceiver.java b/app/src/main/java/eu/chainfire/libsuperuser/HideOverlaysReceiver.java index 4b4ce5a..f1e43f0 100644 --- a/app/src/main/java/eu/chainfire/libsuperuser/HideOverlaysReceiver.java +++ b/app/src/main/java/eu/chainfire/libsuperuser/HideOverlaysReceiver.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2012-2014 Jorrit "Chainfire" Jongma + * Copyright (C) 2012-2019 Jorrit "Chainfire" Jongma * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -37,6 +37,7 @@ import android.content.Intent; * window possibly obscuring SuperSU dialogs". *

*/ +@SuppressWarnings({"unused"}) public abstract class HideOverlaysReceiver extends BroadcastReceiver { public static final String ACTION_HIDE_OVERLAYS = "eu.chainfire.supersu.action.HIDE_OVERLAYS"; public static final String CATEGORY_HIDE_OVERLAYS = Intent.CATEGORY_INFO; @@ -45,15 +46,17 @@ public abstract class HideOverlaysReceiver extends BroadcastReceiver { @Override public final void onReceive(Context context, Intent intent) { if (intent.hasExtra(EXTRA_HIDE_OVERLAYS)) { - onHideOverlays(intent.getBooleanExtra(EXTRA_HIDE_OVERLAYS, false)); + onHideOverlays(context, intent, intent.getBooleanExtra(EXTRA_HIDE_OVERLAYS, false)); } } /** * Called when overlays should be hidden or may be shown * again. - * + * + * @param context App context + * @param intent Received intent * @param hide Should overlays be hidden? */ - public abstract void onHideOverlays(boolean hide); + public abstract void onHideOverlays(Context context, Intent intent, boolean hide); } diff --git a/app/src/main/java/eu/chainfire/libsuperuser/MarkerInputStream.java b/app/src/main/java/eu/chainfire/libsuperuser/MarkerInputStream.java new file mode 100644 index 0000000..1c55e0c --- /dev/null +++ b/app/src/main/java/eu/chainfire/libsuperuser/MarkerInputStream.java @@ -0,0 +1,186 @@ +/* + * Copyright (C) 2012-2019 Jorrit "Chainfire" Jongma + * + * 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 eu.chainfire.libsuperuser; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +@SuppressWarnings("WeakerAccess") +@AnyThread +public class MarkerInputStream extends InputStream { + private static final String EXCEPTION_EOF = "EOF encountered, shell probably died"; + + @NonNull + private final StreamGobbler gobbler; + private final InputStream inputStream; + private final byte[] marker; + private final int markerLength; + private final int markerMaxLength; + private final byte[] read1 = new byte[1]; + private final byte[] buffer = new byte[65536]; + private int bufferUsed = 0; + private volatile boolean eof = false; + private volatile boolean done = false; + + public MarkerInputStream(@NonNull StreamGobbler gobbler, @NonNull String marker) throws UnsupportedEncodingException { + this.gobbler = gobbler; + this.gobbler.suspendGobbling(); + this.inputStream = gobbler.getInputStream(); + this.marker = marker.getBytes("UTF-8"); + this.markerLength = marker.length(); + this.markerMaxLength = marker.length() + 5; // marker + space + exitCode(max(3)) + \n + } + + @Override + public int read() throws IOException { + while (true) { + int r = read(read1, 0, 1); + if (r < 0) return -1; + if (r == 0) { + // wait for data to become available + try { + Thread.sleep(16); + } catch (InterruptedException e) { + // no action + } + continue; + } + return (int)read1[0] & 0xFF; + } + } + + @Override + public int read(@NonNull byte[] b) throws IOException { + return read(b, 0, b.length); + } + + private void fill(int safeSizeToWaitFor) { + // fill up our own buffer + if (isEOF()) return; + try { + int a; + while (((a = inputStream.available()) > 0) || (safeSizeToWaitFor > 0)) { + int left = buffer.length - bufferUsed; + if (left == 0) return; + int r = inputStream.read(buffer, bufferUsed, Math.max(safeSizeToWaitFor, Math.min(a, left))); + if (r >= 0) { + bufferUsed += r; + safeSizeToWaitFor -= r; + } else { + // This shouldn't happen *unless* we have both the full content and the end + // marker, otherwise the shell was interrupted/died. An IOException is raised + // in read() below if that is the case. + setEOF(); + break; + } + } + } catch (IOException e) { + setEOF(); + } + } + + @Override + public synchronized int read(@NonNull byte[] b, int off, int len) throws IOException { + if (done) return -1; + + fill(markerLength - bufferUsed); + + // we need our buffer to be big enough to detect the marker + if (bufferUsed < markerLength) return 0; + + // see if we have our marker + int match = -1; + for (int i = Math.max(0, bufferUsed - markerMaxLength); i < bufferUsed - markerLength; i++) { + boolean found = true; + for (int j = 0; j < markerLength; j++) { + if (buffer[i + j] != marker[j]) { + found = false; + break; + } + } + if (found) { + match = i; + break; + } + } + + if (match == 0) { + // marker is at the front of the buffer + while (buffer[bufferUsed -1] != (byte)'\n') { + if (isEOF()) throw new IOException(EXCEPTION_EOF); + fill(1); + } + if (gobbler.getOnLineListener() != null) gobbler.getOnLineListener().onLine(new String(buffer, 0, bufferUsed - 1, "UTF-8")); + done = true; + return -1; + } else { + int ret; + if (match == -1) { + if (isEOF()) throw new IOException(EXCEPTION_EOF); + + // marker isn't in the buffer, drain as far as possible while keeping some space + // leftover so we can still find the marker if its read is split between two fill() + // calls + ret = Math.min(len, bufferUsed - markerMaxLength); + } else { + // even if eof, it is possibly we have both the content and the end marker, which + // counts as a completed command, so we don't throw IOException here + + // marker found, max drain up to marker, this will eventually cause the marker to be + // at the front of the buffer + ret = Math.min(len, match); + } + if (ret > 0) { + System.arraycopy(buffer, 0, b, off, ret); + bufferUsed -= ret; + System.arraycopy(buffer, ret, buffer, 0, bufferUsed); + } else { + try { + // prevent 100% CPU on reading from for example /dev/random + Thread.sleep(4); + } catch (Exception e) { + // no action + } + } + return ret; + } + } + + @SuppressWarnings("StatementWithEmptyBody") + @Override + public synchronized void close() throws IOException { + if (!isEOF() && !done) { + // drain + byte[] buffer = new byte[1024]; + while (read(buffer) >= 0) { + } + } + } + + public synchronized boolean isEOF() { + return eof; + } + + public synchronized void setEOF() { + eof = true; + } +} diff --git a/app/src/main/java/eu/chainfire/libsuperuser/Shell.java b/app/src/main/java/eu/chainfire/libsuperuser/Shell.java index 2c2386d..da38706 100644 --- a/app/src/main/java/eu/chainfire/libsuperuser/Shell.java +++ b/app/src/main/java/eu/chainfire/libsuperuser/Shell.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2012-2014 Jorrit "Chainfire" Jongma + * Copyright (C) 2012-2019 Jorrit "Chainfire" Jongma * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,7 +16,11 @@ package eu.chainfire.libsuperuser; +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.os.Build; import android.os.Handler; +import android.os.HandlerThread; import android.os.Looper; import java.io.DataOutputStream; @@ -24,6 +28,7 @@ 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; @@ -34,31 +39,102 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; +import java.lang.Object; +import java.lang.String; +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; import eu.chainfire.libsuperuser.StreamGobbler.OnLineListener; +import eu.chainfire.libsuperuser.StreamGobbler.OnStreamClosedListener; /** * Class providing functionality to execute commands in a (root) shell */ +@SuppressWarnings({"WeakerAccess", "UnusedReturnValue", "unused", "StatementWithEmptyBody", "DeprecatedIsStillUsed", "deprecation"}) public class Shell { + /** + * Exception class used to crash application when shell commands are executed + * from the main thread, and we are in debug mode. + */ + @SuppressWarnings({"serial", "WeakerAccess"}) + public static class ShellOnMainThreadException extends RuntimeException { + public static final String EXCEPTION_COMMAND = "Application attempted to run a shell command from the main thread"; + public static final String EXCEPTION_NOT_IDLE = "Application attempted to wait for a non-idle shell to close on the main thread"; + public static final String EXCEPTION_WAIT_IDLE = "Application attempted to wait for a shell to become idle on the main thread"; + public static final String EXCEPTION_TOOLBOX = "Application attempted to init the Toolbox class from the main thread"; + + public ShellOnMainThreadException(String message) { + super(message); + } + } + + /** + * Exception class used to notify developer that a shell was not close()d + */ + @SuppressWarnings({"serial", "WeakerAccess"}) + public static class ShellNotClosedException extends RuntimeException { + public static final String EXCEPTION_NOT_CLOSED = "Application did not close() interactive shell"; + + public ShellNotClosedException() { + super(EXCEPTION_NOT_CLOSED); + } + } + + /** + * Exception class used to notify developer that a shell was not close()d + */ + @SuppressWarnings({"serial", "WeakerAccess"}) + public static class ShellDiedException extends Exception { + public static final String EXCEPTION_SHELL_DIED = "Shell died (or access was not granted)"; + + public ShellDiedException() { + super(EXCEPTION_SHELL_DIED); + } + } + + private static volatile boolean redirectDeprecated = true; + + /** + * @see #setRedirectDeprecated(boolean) + * + * @return Whether deprecated calls are automatically redirected to {@link PoolWrapper} + */ + public static boolean isRedirectDeprecated() { + return redirectDeprecated; + } + + /** + * Set whether deprecated calls (such as Shell.run, Shell.SH/SU.run, etc) should automatically + * redirect to Shell.Pool.?.run(). This is true by default, but it is possible to disable this + * behavior for backwards compatibility + * + * @param redirectDeprecated Whether deprecated calls should be automatically redirected to {@link PoolWrapper} (default true) + */ + public static void setRedirectDeprecated(boolean redirectDeprecated) { + Shell.redirectDeprecated = redirectDeprecated; + } + /** *

* Runs commands using the supplied shell, and returns the output, or null * in case of errors. *

- *

- * This method is deprecated and only provided for backwards compatibility. - * Use {@link #run(String, String[], String[], boolean)} instead, and see - * that same method for usage notes. - *

- * + * + * @deprecated This method is deprecated and only provided for backwards + * compatibility. Use {@link Pool}'s method instead. If {@link #isRedirectDeprecated()} + * is true (default), these calls are now automatically redirected. + * * @param shell The shell to use for executing the commands * @param commands The commands to execute * @param wantSTDERR Return STDERR in the output ? * @return Output of the commands, or null in case of an error */ + @Nullable @Deprecated - public static List run(String shell, String[] commands, boolean wantSTDERR) { + @WorkerThread + public static List run(@NonNull String shell, @NonNull String[] commands, boolean wantSTDERR) { return run(shell, commands, null, wantSTDERR); } @@ -89,16 +165,22 @@ public class Shell { * something like 'ls -lR /' will probably have you run out of * memory. *

- * + * + * @deprecated This method is deprecated and only provided for backwards + * compatibility. Use {@link Pool}'s method instead. If {@link #isRedirectDeprecated()} + * is true (default), these calls are now automatically redirected. + * * @param shell The shell to use for executing the commands * @param commands The commands to execute - * @param environment List of all environment variables (in 'key=value' - * format) or null for defaults - * @param wantSTDERR Return STDERR in the output ? + * @param environment List of all environment variables (in 'key=value' format) or null for defaults + * @param wantSTDERR Return STDERR in the output ? * @return Output of the commands, or null in case of an error */ - public static List run(String shell, String[] commands, String[] environment, - boolean wantSTDERR) { + @Nullable + @Deprecated + @WorkerThread + public static List run(@NonNull String shell, @NonNull String[] commands, @Nullable String[] environment, + boolean wantSTDERR) { String shellUpper = shell.toUpperCase(Locale.ENGLISH); if (Debug.getSanityChecksEnabledEffective() && Debug.onMainThread()) { @@ -109,15 +191,20 @@ public class Shell { Debug.log(ShellOnMainThreadException.EXCEPTION_COMMAND); throw new ShellOnMainThreadException(ShellOnMainThreadException.EXCEPTION_COMMAND); } - Debug.logCommand(String.format("[%s%%] START", shellUpper)); + + if (redirectDeprecated) { + // use our Threaded pool implementation instead + return Pool.getWrapper(shell).run(commands, environment, wantSTDERR); + } + + Debug.logCommand(String.format(Locale.ENGLISH, "[%s%%] START", shellUpper)); List res = Collections.synchronizedList(new ArrayList()); try { // Combine passed environment with system environment if (environment != null) { - Map newEnvironment = new HashMap(); - newEnvironment.putAll(System.getenv()); + Map newEnvironment = new HashMap(System.getenv()); int split; for (String entry : environment) { if ((split = entry.indexOf("=")) >= 0) { @@ -146,18 +233,18 @@ public class Shell { STDERR.start(); try { for (String write : commands) { - Debug.logCommand(String.format("[%s+] %s", shellUpper, write)); + Debug.logCommand(String.format(Locale.ENGLISH, "[%s+] %s", shellUpper, write)); STDIN.write((write + "\n").getBytes("UTF-8")); STDIN.flush(); } STDIN.write("exit\n".getBytes("UTF-8")); STDIN.flush(); } catch (IOException e) { - 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 + if (e.getMessage().contains("EPIPE") || e.getMessage().contains("Stream closed")) { + // 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 + // these cases we want the output instead of returning null. } else { // other issues we don't know how to handle, leads to // returning null @@ -195,24 +282,23 @@ public class Shell { res = null; } - Debug.logCommand(String.format("[%s%%] END", shell.toUpperCase(Locale.ENGLISH))); + Debug.logCommand(String.format(Locale.ENGLISH, "[%s%%] END", shell.toUpperCase(Locale.ENGLISH))); return res; } - protected static String[] availableTestCommands = new String[] { + protected static final String[] availableTestCommands = new String[]{ "echo -BOC-", "id" }; /** * See if the shell is alive, and if so, check the UID - * + * * @param ret Standard output from running availableTestCommands - * @param checkForRoot true if we are expecting this shell to be running as - * root + * @param checkForRoot true if we are expecting this shell to be running as root * @return true on success, false on error */ - protected static boolean parseAvailableResult(List ret, boolean checkForRoot) { + protected static boolean parseAvailableResult(@Nullable List ret, boolean checkForRoot) { if (ret == null) return false; @@ -225,10 +311,8 @@ public class Shell { 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 + // of shell, let's hope it has root privileges - no way to know without + // additional native binaries echo_seen = true; } } @@ -242,33 +326,48 @@ public class Shell { public static class SH { /** * Runs command and return output - * + * + * @deprecated Consider using Shell.Pool.SH.run() instead + * * @param command The command to run * @return Output of the command, or null in case of an error */ - public static List run(String command) { - return Shell.run("sh", new String[] { + @Nullable + @Deprecated + @WorkerThread + public static List run(@NonNull String command) { + return Shell.run("sh", new String[]{ command }, null, false); } /** * Runs commands and return output - * + * + * @deprecated Consider using Shell.Pool.SH.run() instead + * * @param commands The commands to run * @return Output of the commands, or null in case of an error */ - public static List run(List commands) { - return Shell.run("sh", commands.toArray(new String[commands.size()]), null, false); + @Nullable + @Deprecated + @WorkerThread + public static List run(@NonNull List commands) { + return Shell.run("sh", commands.toArray(new String[0]), null, false); } /** * Runs commands and return output - * + * + * @deprecated Consider using Shell.Pool.SH.run() instead + * * @param commands The commands to run * @return Output of the commands, or null in case of an error */ - public static List run(String[] commands) { + @Nullable + @Deprecated + @WorkerThread + public static List run(@NonNull String[] commands) { return Shell.run("sh", commands, null, false); } } @@ -279,43 +378,60 @@ public class Shell { * if so which version. */ public static class SU { + @Nullable private static Boolean isSELinuxEnforcing = null; - private static String[] suVersion = new String[] { + @NonNull + private static String[] suVersion = new String[]{ null, null }; /** * Runs command as root (if available) and return output - * + * + * @deprecated Consider using Shell.Pool.SU.run() instead + * * @param command The command to run * @return Output of the command, or null if root isn't available or in - * case of an error + * case of an error */ - public static List run(String command) { - return Shell.run("su", new String[] { + @Nullable + @Deprecated + @WorkerThread + public static List run(@NonNull String command) { + return Shell.run("su", new String[]{ command }, null, false); } /** * Runs commands as root (if available) and return output - * + * + * @deprecated Consider using Shell.Pool.SU.run() instead + * * @param commands The commands to run * @return Output of the commands, or null if root isn't available or in - * case of an error + * case of an error */ - public static List run(List commands) { - return Shell.run("su", commands.toArray(new String[commands.size()]), null, false); + @Nullable + @Deprecated + @WorkerThread + public static List run(@NonNull List commands) { + return Shell.run("su", commands.toArray(new String[0]), null, false); } /** * Runs commands as root (if available) and return output - * + * + * @deprecated Consider using Shell.Pool.SU.run() instead + * * @param commands The commands to run * @return Output of the commands, or null if root isn't available or in - * case of an error + * case of an error */ - public static List run(String[] commands) { + @Nullable + @Deprecated + @WorkerThread + public static List run(@NonNull String[] commands) { return Shell.run("su", commands, null, false); } @@ -323,9 +439,10 @@ public class Shell { * 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 True if superuser access available */ + @WorkerThread public static boolean available() { // this is only one of many ways this can be done @@ -349,22 +466,42 @@ public class Shell { * This function caches its result to improve performance on multiple * calls *

- * - * @param internal Request human-readable version or application - * internal version + * + * @param internal Request human-readable version or application internal version * @return String containing the su version or null */ + @Nullable + @WorkerThread // if not cached public static synchronized String version(boolean internal) { int idx = internal ? 0 : 1; if (suVersion[idx] == null) { String version = null; - List ret = Shell.run( - internal ? "su -V" : "su -v", - new String[] { "exit" }, - null, - false + List ret; + if (!redirectDeprecated) { + ret = Shell.run( + internal ? "su -V" : "su -v", + new String[] { "exit" }, + null, + false + ); + } else { + ret = new ArrayList(); + try { + ret = new ArrayList(); + Shell.Pool.SH.run( + new String[] { + internal ? "su -V" : "su -v", + "exit" + }, + ret, + null, + false ); + } catch (ShellDiedException e) { + // no action + } + } if (ret != null) { for (String line : ret) { @@ -393,10 +530,11 @@ public class Shell { /** * 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 */ + @AnyThread public static boolean isSU(String shell) { // Strip parameters int pos = shell.indexOf(' '); @@ -410,19 +548,21 @@ public class Shell { shell = shell.substring(pos + 1); } - return shell.equals("su"); + return shell.toLowerCase(Locale.ENGLISH).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, String context) { + @NonNull + @WorkerThread + public static String shell(int uid, @Nullable String context) { // su[ --context ][ ] String shell = "su"; @@ -431,6 +571,7 @@ public class Shell { String internal = version(true); // We only know the format for SuperSU v1.90+ right now + //TODO add detection for other su's that support this if ((display != null) && (internal != null) && (display.endsWith("SUPERSU")) && @@ -452,9 +593,11 @@ public class 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 */ + @NonNull + @AnyThread public static String shellMountMaster() { if (android.os.Build.VERSION.SDK_INT >= 17) { return "su --mount-master"; @@ -464,10 +607,11 @@ public class Shell { /** * 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 + * + * @return true if SELinux set to enforcing, or false in the case of permissive or not present */ + @SuppressLint("PrivateApi") + @WorkerThread public static synchronized boolean isSELinuxEnforcing() { if (isSELinuxEnforcing == null) { Boolean enforcing = null; @@ -475,24 +619,41 @@ public class Shell { // First known firmware with SELinux built-in was a 4.2 (17) // leak if (android.os.Build.VERSION.SDK_INT >= 17) { + if (android.os.Build.VERSION.SDK_INT >= 28) { + // Due to non-SDK API greylisting, we cannot determine SELinux status + // through the methods below, so we assume SELinux is enforcing and + // potentially patch policies for nothing + enforcing = true; + } + // Detect enforcing through sysfs, not always present - File f = new File("/sys/fs/selinux/enforce"); - if (f.exists()) { - try { - InputStream is = new FileInputStream("/sys/fs/selinux/enforce"); + if (enforcing == null) { + File f = new File("/sys/fs/selinux/enforce"); + if (f.exists()) { try { - enforcing = (is.read() == '1'); - } finally { - is.close(); + InputStream is = new FileInputStream("/sys/fs/selinux/enforce"); + try { + enforcing = (is.read() == '1'); + } finally { + is.close(); + } + } catch (Exception e) { + // we might not be allowed to read, thanks SELinux } - } catch (Exception e) { - // we might not be allowed to read, thanks SELinux } } - // 4.4+ builds are enforcing by default, take the gamble + // 4.4+ has a new API to detect SELinux mode, so use it + // SELinux is typically in enforced mode, but emulators may have SELinux disabled if (enforcing == null) { - enforcing = (android.os.Build.VERSION.SDK_INT >= 19); + try { + Class seLinux = Class.forName("android.os.SELinux"); + Method isSELinuxEnforced = seLinux.getMethod("isSELinuxEnforced"); + enforcing = (Boolean) isSELinuxEnforced.invoke(seLinux.newInstance()); + } catch (Exception e) { + // 4.4+ release builds are enforcing by default, take the gamble + enforcing = (android.os.Build.VERSION.SDK_INT >= 19); + } } } @@ -516,6 +677,7 @@ public class Shell { * not impossible. *

*/ + @AnyThread public static synchronized void clearCachedResults() { isSELinuxEnforcing = null; suVersion[0] = null; @@ -523,8 +685,11 @@ public class Shell { } } - private interface OnResult { - // for any onCommandResult callback + /** + * DO NOT USE DIRECTLY. Base interface for result callbacks. + */ + public interface OnResult { + // for any callback int WATCHDOG_EXIT = -1; int SHELL_DIED = -2; @@ -534,14 +699,30 @@ public class Shell { int SHELL_RUNNING = 0; } + /** + * Callback for {@link Shell.Builder#open(Shell.OnShellOpenResultListener)} + */ + public interface OnShellOpenResultListener extends OnResult { + /** + * Callback for shell open result + * + * @param success whether the shell is opened + * @param reason reason why the shell isn't opened + */ + void onOpenResult(boolean success, int reason); + } + /** * Command result callback, notifies the recipient of the completion of a * command block, including the (last) exit code, and the full output + * + * @deprecated You probably want to use {@link OnCommandResultListener2} instead */ + @Deprecated public interface OnCommandResultListener extends OnResult { /** *

- * Command result callback + * Command result callback for STDOUT, optionally interleaved with STDERR *

*

* Depending on how and on which thread the shell was created, this @@ -551,23 +732,56 @@ public class Shell { * in a deadlock *

*

+ * If wantSTDERR is set, output of STDOUT and STDERR is interleaved into + * the output buffer. There are no guarantees of absolutely order + * correctness (just like in a real terminal) + *

+ *

+ * To get separate STDOUT and STDERR output, use {@link OnCommandResultListener2} + *

+ *

* See {@link 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); + void onCommandResult(int commandCode, int exitCode, @NonNull 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. + * Command result callback, notifies the recipient of the completion of a + * command block, including the (last) exit code, and the full output */ - public interface OnCommandLineListener extends OnResult, OnLineListener - { + public interface OnCommandResultListener2 extends OnResult { + /** + *

+ * Command result callback with separated STDOUT and STDERR + *

+ *

+ * 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 Interactive} for threading details + *

+ * + * @param commandCode Value previously supplied to addCommand + * @param exitCode Exit code of the last command in the block + * @param STDOUT All STDOUT output generated by the command block + * @param STDERR All STDERR output generated by the command block + */ + void onCommandResult(int commandCode, int exitCode, @NonNull List STDOUT, @NonNull List STDERR); + } + + /** + * DO NOT USE DIRECTLY. Command result callback that doesn't cause output to be buffered + */ + private interface OnCommandResultListenerUnbuffered extends OnResult { /** *

* Command result callback @@ -582,13 +796,112 @@ public class Shell { *

* See {@link 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); } + /** + * DO NOT USE DIRECTLY. Line callback for STDOUT + */ + private interface OnCommandLineSTDOUT { + /** + *

+ * Line callback for STDOUT + *

+ *

+ * 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 Interactive} for threading details + *

+ * + * @param line One line of STDOUT output + */ + void onSTDOUT(@NonNull String line); + } + + /** + * DO NOT USE DIRECTLY. Line callback for STDERR + */ + private interface OnCommandLineSTDERR { + /** + *

+ * Line callback for STDERR + *

+ *

+ * 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 Interactive} for threading details + *

+ * + * @param line One line of STDERR output + */ + void onSTDERR(@NonNull String line); + } + + /** + * 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 OnCommandResultListenerUnbuffered, OnCommandLineSTDOUT, OnCommandLineSTDERR { + } + + /** + * DO NOT USE DIRECTLY. InputStream callback + */ + public interface OnCommandInputStream extends OnCommandLineSTDERR { + /** + *

+ * InputStream callback + *

+ *

+ * The read() methods will return -1 when all input is consumed, and throw an + * IOException if the shell died before all data being read. + *

+ *

+ * If a Handler is not setup, 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. It may also be executed on the main thread, in which + * case you should offload handling to a different thread + *

+ *

+ * If a Handler is setup and it executes callbacks on the main thread, + * you should offload handling to a different thread, as reading from + * the InputStream would block your UI + *

+ *

+ * You must drain the InputStream (read until it returns -1 or throws + * an IOException), or call close(), otherwise execution of root commands will + * not continue. This cannot be solved automatically without keeping it safe to + * offload the InputStream to another thread. + *

+ * + * @param inputStream InputStream to read from + */ + void onInputStream(@NonNull InputStream inputStream); + } + + /** + * Command InputStream callback for direct access to STDOUT. It also notifies the + * recipient of the completion of a command block, including the (last) exit code. + */ + public interface OnCommandInputStreamListener extends OnCommandResultListenerUnbuffered, OnCommandInputStream { + } + /** * Internal class to store command block properties */ @@ -597,32 +910,77 @@ public class Shell { private final String[] commands; private final int code; + @Nullable private final OnCommandResultListener onCommandResultListener; + @Nullable + private final OnCommandResultListener2 onCommandResultListener2; + @Nullable private final OnCommandLineListener onCommandLineListener; + @Nullable + private final OnCommandInputStreamListener onCommandInputStreamListener; + @NonNull private final String marker; - public Command(String[] commands, int code, - OnCommandResultListener onCommandResultListener, - OnCommandLineListener onCommandLineListener) { - this.commands = commands; + @Nullable + private volatile MarkerInputStream markerInputStream = null; + + @SuppressWarnings("unchecked") // if the user passes in List<> of anything other than String, that's on them + public Command(@NonNull Object commands, int code, @Nullable OnResult listener) { + if (commands instanceof String) { + this.commands = new String[] { (String)commands }; + } else if (commands instanceof List) { + this.commands = ((List)commands).toArray(new String[0]); + } else if (commands instanceof String[]) { + this.commands = (String[])commands; + } else { + throw new IllegalArgumentException("commands parameter must be of type String, List or String[]"); + } this.code = code; - this.onCommandResultListener = onCommandResultListener; - this.onCommandLineListener = onCommandLineListener; - this.marker = UUID.randomUUID().toString() + String.format("-%08x", ++commandCounter); + this.marker = UUID.randomUUID().toString() + String.format(Locale.ENGLISH, "-%08x", ++commandCounter); + + OnCommandResultListener commandResultListener = null; + OnCommandResultListener2 commandResultListener2 = null; + OnCommandLineListener commandLineListener = null; + OnCommandInputStreamListener commandInputStreamListener = null; + if (listener != null) { + if (listener instanceof OnCommandInputStreamListener) { + commandInputStreamListener = (OnCommandInputStreamListener)listener; + } else if (listener instanceof OnCommandLineListener) { + commandLineListener = (OnCommandLineListener)listener; + } else if (listener instanceof OnCommandResultListener2) { + commandResultListener2 = (OnCommandResultListener2)listener; + } else if (listener instanceof OnCommandResultListener) { + commandResultListener = (OnCommandResultListener)listener; + } else { + throw new IllegalArgumentException("OnResult is not a supported callback interface"); + } + } + this.onCommandResultListener = commandResultListener; + this.onCommandResultListener2 = commandResultListener2; + this.onCommandLineListener = commandLineListener; + this.onCommandInputStreamListener = commandInputStreamListener; } } /** * Builder class for {@link Interactive} */ + @AnyThread public static class Builder { + @Nullable private Handler handler = null; private boolean autoHandler = true; private String shell = "sh"; private boolean wantSTDERR = false; + private boolean shellDiesOnSTDOUTERRClose = true; + private boolean detectOpen = true; + @NonNull private List commands = new LinkedList(); + @NonNull private Map environment = new HashMap(); + @Nullable private OnLineListener onSTDOUTLineListener = null; + @Nullable private OnLineListener onSTDERRLineListener = null; private int watchdogTimeout = 0; @@ -634,11 +992,12 @@ public class Shell { * See {@link 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) { + @NonNull + public Builder setHandler(@Nullable Handler handler) { this.handler = handler; return this; } @@ -651,10 +1010,11 @@ public class Shell { * See {@link Interactive} for further details on threading and * handlers *

- * + * * @param autoHandler Auto-create handler ? * @return This Builder object for method chaining */ + @NonNull public Builder setAutoHandler(boolean autoHandler) { this.autoHandler = autoHandler; return this; @@ -663,39 +1023,105 @@ public class Shell { /** * 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) { + @NonNull + public Builder setShell(@NonNull String shell) { this.shell = shell; return this; } /** * Convenience function to set "sh" as used shell - * + * * @return This Builder object for method chaining */ + @NonNull public Builder useSH() { return setShell("sh"); } /** * Convenience function to set "su" as used shell - * + * * @return This Builder object for method chaining */ + @NonNull public Builder useSU() { return setShell("su"); } /** - * Set if error output should be appended to command block result output - * + *

+ * Detect whether the shell was opened correctly ? + *

+ *

+ * When active, this runs test commands in the shell + * before it runs your own commands to determine if + * the shell is functioning correctly. This is also + * required for the {@link Interactive#isOpening()} + * method to return a proper result + *

+ *

+ * You probably want to keep this turned on, the + * option to turn it off exists only to support + * code using older versions of this library that + * may depend on these commands not being + * run + *

+ * + * @deprecated New users should leave the default + * + * @param detectOpen Detect shell running properly (default true) + * @return This Builder object for method chaining + */ + @NonNull + @Deprecated + public Builder setDetectOpen(boolean detectOpen) { + this.detectOpen = detectOpen; + return this; + } + + /** + *

+ * Treat STDOUT/STDERR close as shell death ? + *

+ *

+ * You probably want to keep this turned on. It is not + * completely unthinkable you may need to turn this off, + * but it is unlikely. Turning it off will break dead + * shell detection on commands providing an InputStream + *

+ * + * @deprecated New users should leave the default unless absolutely necessary + * + * @param shellDies Treat STDOUT/STDERR close as shell death (default true) + * @return This Builder object for method chaining + */ + @NonNull + @Deprecated + public Builder setShellDiesOnSTDOUTERRClose(boolean shellDies) { + this.shellDiesOnSTDOUTERRClose = shellDies; + return this; + } + + /** + *

+ * Set if STDERR output should be interleaved with STDOUT output (only) when {@link OnCommandResultListener} is used + *

+ *

+ * If you want separate STDOUT and STDERR output, use {@link OnCommandResultListener2} instead + *

+ * + * @deprecated You probably want to use {@link OnCommandResultListener2}, which ignores this setting + * * @param wantSTDERR Want error output ? * @return This Builder object for method chaining */ + @NonNull + @Deprecated public Builder setWantSTDERR(boolean wantSTDERR) { this.wantSTDERR = wantSTDERR; return this; @@ -703,119 +1129,72 @@ public class Shell { /** * 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) { + @NonNull + public Builder addEnvironment(@NonNull String key, @NonNull 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) { + @NonNull + public Builder addEnvironment(@NonNull Map addEnvironment) { environment.putAll(addEnvironment); return this; } /** - * Add a command to execute - * - * @param command Command to execute + * Add commands to execute, without a callback + * + * @param commands Commands to execute, accepts String, List<String>, and String[] * @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 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) { + @NonNull + public Builder addCommand(@NonNull Object commands) { return addCommand(commands, 0, null); } /** *

- * Add commands to execute, with a callback to be called on completion - * (of all commands) + * Add commands to execute, with a callback. Several callback interfaces are supported + *

+ *

+ * {@link OnCommandResultListener2}: provides only a callback with the result of the entire + * command and the (last) exit code. The results are buffered until command completion, so + * commands that generate massive amounts of output should use {@link OnCommandLineListener} + * instead. + *

+ *

+ * {@link OnCommandLineListener}: provides a per-line callback without internal buffering. + * Also provides a command completion callback with the (last) exit code. + *

+ *

+ * {@link OnCommandInputStreamListener}: provides a callback that is called with an + * InputStream you can read STDOUT from directly. Also provides a command completion + * callback with the (last) exit code. Note that this callback ignores the watchdog. *

*

* The thread on which the callback executes is dependent on various * factors, see {@link Interactive} for further details *

- * - * @param commands Commands to execute + * + * @param commands Commands to execute, accepts String, List<String>, and String[] * @param code User-defined value passed back to the callback - * @param onCommandResultListener Callback to be called on completion - * (of all commands) + * @param onResultListener One of OnCommandResultListener, OnCommandLineListener, OnCommandInputStreamListener * @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 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)); + @NonNull + public Builder addCommand(@NonNull Object commands, int code, @Nullable OnResult onResultListener) { + this.commands.add(new Command(commands, code, onResultListener)); return this; } @@ -827,11 +1206,12 @@ public class Shell { * The thread on which the callback executes is dependent on various * factors, see {@link Interactive} for further details *

- * + * * @param onLineListener Callback to be called for each line * @return This Builder object for method chaining */ - public Builder setOnSTDOUTLineListener(OnLineListener onLineListener) { + @NonNull + public Builder setOnSTDOUTLineListener(@Nullable OnLineListener onLineListener) { this.onSTDOUTLineListener = onLineListener; return this; } @@ -844,11 +1224,12 @@ public class Shell { * The thread on which the callback executes is dependent on various * factors, see {@link Interactive} for further details *

- * + * * @param onLineListener Callback to be called for each line * @return This Builder object for method chaining */ - public Builder setOnSTDERRLineListener(OnLineListener onLineListener) { + @NonNull + public Builder setOnSTDERRLineListener(@Nullable OnLineListener onLineListener) { this.onSTDERRLineListener = onLineListener; return this; } @@ -867,10 +1248,11 @@ public class Shell { * 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 */ + @NonNull public Builder setWatchdogTimeout(int watchdogTimeout) { this.watchdogTimeout = watchdogTimeout; return this; @@ -883,10 +1265,11 @@ public class Shell { *

* Note that this is a global setting *

- * + * * @param useMinimal true for reduced output, false for full output * @return This Builder object for method chaining */ + @NonNull public Builder setMinimalLogging(boolean useMinimal) { Debug.setLogTypeEnabled(Debug.LOG_COMMAND | Debug.LOG_OUTPUT, !useMinimal); return this; @@ -894,20 +1277,188 @@ public class Shell { /** * Construct a {@link Interactive} instance, and start the shell + * + * @return Interactive shell */ + @NonNull public Interactive open() { return new Interactive(this, null); } /** * Construct a {@link Interactive} instance, try to start the - * shell, and call onCommandResultListener to report success or failure - * - * @param onCommandResultListener Callback to return shell open status + * shell, and call onShellOpenResultListener to report success or failure + * + * @param onShellOpenResultListener Callback to return shell open status + * @return Interactive shell */ - public Interactive open(OnCommandResultListener onCommandResultListener) { - return new Interactive(this, onCommandResultListener); + @NonNull + public Interactive open(@Nullable OnShellOpenResultListener onShellOpenResultListener) { + return new Interactive(this, onShellOpenResultListener); } + + /** + *

+ * Construct a {@link Threaded} instance, and start the shell + *

+ *

+ * {@link Threaded} ignores the {@link #setHandler(Handler)}, + * {@link #setAutoHandler(boolean)}, {@link #setDetectOpen(boolean)} + * and {@link #setShellDiesOnSTDOUTERRClose(boolean)} settings on this + * Builder and uses its own values + *

+ *

+ * On API >= 19, the return value is {@link ThreadedAutoCloseable} + * rather than {@link Threaded} + *

+ * + * @return Threaded interactive shell + */ + @NonNull + public Threaded openThreaded() { + return openThreadedEx(null, false); + } + + /** + *

+ * Construct a {@link Threaded} instance, try to start the + * shell, and call onShellOpenResultListener to report success or failure + *

+ *

+ * {@link Threaded} ignores the {@link #setHandler(Handler)}, + * {@link #setAutoHandler(boolean)}, {@link #setDetectOpen(boolean)} + * and {@link #setShellDiesOnSTDOUTERRClose(boolean)} settings on this + * Builder and uses its own values + *

+ *

+ * On API >= 19, the return value is {@link ThreadedAutoCloseable} + * rather than {@link Threaded} + *

+ * + * @param onShellOpenResultListener Callback to return shell open status + * @return Threaded interactive shell + */ + @NonNull + public Threaded openThreaded(@Nullable OnShellOpenResultListener onShellOpenResultListener) { + return openThreadedEx(onShellOpenResultListener, false); + } + + private Threaded openThreadedEx(OnShellOpenResultListener onShellOpenResultListener, boolean pooled) { + if (Build.VERSION.SDK_INT >= 19) { + return new ThreadedAutoCloseable(this, onShellOpenResultListener, pooled); + } else { + return new Threaded(this, onShellOpenResultListener, pooled); + } + } + } + + /** + * Callback interface for {@link SyncCommands#run(Object, Shell.OnSyncCommandLineListener)} + */ + public interface OnSyncCommandLineListener extends OnCommandLineSTDOUT, OnCommandLineSTDERR { + } + + /** + * Callback interface for {@link SyncCommands#run(Object, Shell.OnSyncCommandInputStreamListener)} + */ + public interface OnSyncCommandInputStreamListener extends OnCommandInputStream, OnCommandLineSTDERR { + } + + /** + * Base interface for objects that support deprecated synchronous commands + */ + @Deprecated + @WorkerThread + public interface DeprecatedSyncCommands { + /** + * Run commands, returning the output, or null on error + * + * @deprecated This methods exists only as drop-in replacement for Shell.SU/SH.run() methods and should not be used by new users + * + * @param commands Commands to execute, accepts String, List<String>, and String[] + * @param wantSTDERR Return STDERR in the output ? + * @return Output of the commands, or null in case of an error + */ + @Nullable + @Deprecated + List run(@NonNull Object commands, boolean wantSTDERR); + + /** + * Run commands, with a set environment, returning the output, or null on error + * + * @deprecated This methods exists only as drop-in replacement for Shell.SU/SH.run() methods and should not be used by new users + * + * @param commands Commands to execute, accepts String, List<String>, and String[] + * @param environment List of all environment variables (in 'key=value' format) or null for defaults + * @param wantSTDERR Return STDERR in the output ? + * @return Output of the commands, or null in case of an error + */ + @Nullable + @Deprecated + List run(@NonNull Object commands, @Nullable String[] environment, boolean wantSTDERR); + } + + /** + * Base interface for objects that support synchronous commands + */ + @WorkerThread + public interface SyncCommands { + /** + * Run commands and return exit code + * + * @param commands Commands to execute, accepts String, List<String>, and String[] + * @return Exit code + * @throws ShellDiedException if shell is closed, was closed during command execution, or was never open (access denied) + */ + int run(@NonNull Object commands) throws ShellDiedException; + + /** + *

+ * Run commands and return STDOUT and STDERR output, and exit code + *

+ *

+ * Note that all output is buffered, and very large outputs may cause you to run out of + * memory. + *

+ * + * @param commands Commands to execute, accepts String, List<String>, and String[] + * @param STDOUT List<String> to receive STDOUT output, or null + * @param STDERR List<String> to receive STDERR output, or null + * @param clear Clear STDOUT/STDOUT before adding output ? + * @return Exit code + * @throws ShellDiedException if shell is closed, was closed during command execution, or was never open (access denied) + */ + int run(@NonNull Object commands, @Nullable List STDOUT, @Nullable List STDERR, boolean clear) throws ShellDiedException; + + /** + *

+ * Run commands using a callback that receives per-line STDOUT and STDERR output as they happen, and returns exit code + *

+ *

+ * You should not call other synchronous methods from the callback + *

+ * + * @param commands Commands to execute, accepts String, List<String>, and String[] + * @param onSyncCommandLineListener Callback interface for per-line output + * @return Exit code + * @throws ShellDiedException if shell is closed, was closed during command execution, or was never open (access denied) + */ + int run(@NonNull Object commands, @NonNull OnSyncCommandLineListener onSyncCommandLineListener) throws ShellDiedException; + + /** + *

+ * Run commands using a callback that receives an InputStream for STDOUT and per-line STDERR output as it happens, and returns exit code + *

+ *

+ * You should not call other synchronous methods from the callback + *

+ * + * @param commands Commands to execute, accepts String, List<String>, and String[] + * @param onSyncCommandInputStreamListener Callback interface for InputStream output + * @return Exit code + * @throws ShellDiedException if shell is closed, was closed during command execution, or was never open (access denied) + */ + int run(@NonNull Object commands, @NonNull OnSyncCommandInputStreamListener onSyncCommandInputStreamListener) throws ShellDiedException; } /** @@ -917,10 +1468,8 @@ public class Shell { * 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. + * STDERR output can be supplied as well, but (just like in a real terminal) + * output order between STDOUT and STDERR cannot be guaranteed to be correct. *

*

* Note as well that the close() and waitForIdle() methods will @@ -932,14 +1481,14 @@ public class Shell { * 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 when using a {@link OnCommandResultListener}. A work-around - * is to not supply this callback, but using (only) - * {@link Builder#setOnSTDOUTLineListener(OnLineListener)}. This way, - * an internal buffer will not be created and wasting your memory. + * Background threads are used to gobble STDOUT and STDERR so a deadlock does + * not occur if the shell produces massive output, but if you're using + * {@link OnCommandResultListener} or {@link OnCommandResultListener2} for + * callbacks, the output gets added to a List<String> until the command + * completes. As such if you're doing something like 'ls -lR /' you + * will probably run out of memory. A work-around is to use {@link OnCommandLineListener} + * which does not buffer the data nor waste memory, but you should make sure + * those callbacks do not block unnecessarily. *

*

Callbacks, threads and handlers

*

@@ -974,47 +1523,79 @@ public class Shell { * callbacks is thread-safe. *

*/ - public static class Interactive { - private final Handler handler; + public static class Interactive implements SyncCommands { + @Nullable + protected final Handler handler; private final boolean autoHandler; private final String shell; + private boolean shellDiesOnSTDOUTERRClose; private final boolean wantSTDERR; + @NonNull private final List commands; + @NonNull private final Map environment; + @Nullable private final OnLineListener onSTDOUTLineListener; + @Nullable private final OnLineListener onSTDERRLineListener; private int watchdogTimeout; + @Nullable private Process process = null; + @Nullable private DataOutputStream STDIN = null; + @Nullable private StreamGobbler STDOUT = null; + @Nullable private StreamGobbler STDERR = null; + private final Object STDclosedSync = new Object(); + private boolean STDOUTclosed = false; + private boolean STDERRclosed = false; + @Nullable private ScheduledThreadPoolExecutor watchdog = null; private volatile boolean running = false; + private volatile boolean lastOpening = false; + private volatile boolean opening = false; private volatile boolean idle = true; // read/write only synchronized - private volatile boolean closed = true; - private volatile int callbacks = 0; + protected volatile boolean closed = true; + protected volatile int callbacks = 0; private volatile int watchdogCount; + private volatile boolean doCloseWhenIdle = false; + protected volatile boolean inClosingJoin = false; private final Object idleSync = new Object(); - private final Object callbackSync = new Object(); + protected final Object callbackSync = new Object(); + private final Object openingSync = new Object(); + private final List emptyStringList = new ArrayList(); private volatile int lastExitCode = 0; + @Nullable private volatile String lastMarkerSTDOUT = null; + @Nullable private volatile String lastMarkerSTDERR = null; + @Nullable private volatile Command command = null; - private volatile List buffer = null; + @Nullable + private volatile List bufferSTDOUT = null; + @Nullable + private volatile List bufferSTDERR = null; /** - * The only way to create an instance: Shell.Builder::open() - * + * The only way to create an instance: Shell.Builder::open(...) + * + * @see Shell.Builder#open() + * @see Shell.Builder#open(Shell.OnShellOpenResultListener) + * * @param builder Builder class to take values from + * @param onShellOpenResultListener Callback */ - private Interactive(final Builder builder, - final OnCommandResultListener onCommandResultListener) { + @AnyThread + protected Interactive(@NonNull final Builder builder, + @Nullable final OnShellOpenResultListener onShellOpenResultListener) { autoHandler = builder.autoHandler; shell = builder.shell; + shellDiesOnSTDOUTERRClose = builder.shellDiesOnSTDOUTERRClose; wantSTDERR = builder.wantSTDERR; commands = builder.commands; environment = builder.environment; @@ -1022,36 +1603,82 @@ public class Shell { 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 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) { + if ((onShellOpenResultListener != null) || builder.detectOpen) { + lastOpening = true; + opening = true; + // 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.availableTestCommands, 0, new OnCommandResultListener() { - public void onCommandResult(int commandCode, int exitCode, List output) { - if ((exitCode == OnCommandResultListener.SHELL_RUNNING) && - !Shell.parseAvailableResult(output, SU.isSU(shell))) { + commands.add(0, new Command(Shell.availableTestCommands, 0, new OnCommandResultListener2() { + @Override + public void onCommandResult(int commandCode, int exitCode, @NonNull List STDOUT, @NonNull List STDERR) { + // we don't set opening to false here because idle must be set to true first + // to prevent falling through if 'isOpening() || isIdle()' is called + + // this always runs in one of the gobbler threads, hard-coded + if ((exitCode == OnCommandResultListener2.SHELL_RUNNING) && + !Shell.parseAvailableResult(STDOUT, Shell.SU.isSU(shell))) { // shell is up, but it's brain-damaged - exitCode = OnCommandResultListener.SHELL_WRONG_UID; + exitCode = OnCommandResultListener2.SHELL_WRONG_UID; + + // we're otherwise technically not idle in this callback, deadlock + // we're inside runNextCommand so we needn't bother with idleSync + idle = true; + closeImmediately(); // triggers SHELL_DIED on remaining commands } + + // reset watchdog to user value watchdogTimeout = builder.watchdogTimeout; - onCommandResultListener.onCommandResult(0, exitCode, output); + + // callback + if (onShellOpenResultListener != null) { + if (handler != null) { + final int fExitCode = exitCode; + startCallback(); + handler.post(new Runnable() { + @Override + public void run() { + try { + onShellOpenResultListener.onOpenResult(fExitCode == OnShellOpenResultListener.SHELL_RUNNING, fExitCode); + } finally { + endCallback(); + } + } + }); + } else { + onShellOpenResultListener.onOpenResult(exitCode == OnShellOpenResultListener.SHELL_RUNNING, exitCode); + } + } } - }, null)); + })); } - if (!open() && (onCommandResultListener != null)) { - onCommandResultListener.onCommandResult(0, - OnCommandResultListener.SHELL_EXEC_FAILED, null); + if (!open() && (onShellOpenResultListener != null)) { + if (handler != null) { + startCallback(); + handler.post(new Runnable() { + @Override + public void run() { + try { + onShellOpenResultListener.onOpenResult(false, OnShellOpenResultListener.SHELL_EXEC_FAILED); + } finally { + endCallback(); + } + } + }); + } else { + onShellOpenResultListener.onOpenResult(false, OnShellOpenResultListener.SHELL_EXEC_FAILED); + } } } @@ -1066,152 +1693,28 @@ public class Shell { } /** - * Add a command to execute - * - * @param command Command to execute + * Add commands to execute, without a callback + * + * @param commands Commands to execute, accepts String, List<String>, and String[] */ - public void addCommand(String command) { - addCommand(command, 0, (OnCommandResultListener) null); + @AnyThread + public synchronized void addCommand(@NonNull Object commands) { + addCommand(commands, 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 Interactive} for further details - *

- * - * @param command Command to execute + * Add commands to execute with a callback. See {@link Shell.Builder#addCommand(Object, int, Shell.OnResult)} + * for details + * + * @see Shell.Builder#addCommand(Object, int, Shell.OnResult) + * + * @param commands Commands to execute, accepts String, List<String>, and String[] * @param code User-defined value passed back to the callback - * @param onCommandResultListener Callback to be called on completion + * @param onResultListener One of OnCommandResultListener, OnCommandLineListener, OnCommandInputStreamListener */ - public void addCommand(String command, int code, - OnCommandResultListener onCommandResultListener) { - addCommand(new String[] { - command - }, code, onCommandResultListener); - } - - /** - *

- * 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 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(String command, int code, OnCommandLineListener onCommandLineListener) { - addCommand(new String[] { - command - }, code, onCommandLineListener); - } - - /** - * Add commands to execute - * - * @param commands Commands to execute - */ - public void addCommand(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 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(List commands, int code, - 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 Interactive} for further details - *

- * - * @param commands Commands to execute - * @param code User-defined value passed back to the callback - * @param onCommandLineListener Callback - */ - public void addCommand(List commands, int code, - OnCommandLineListener onCommandLineListener) { - addCommand(commands.toArray(new String[commands.size()]), code, onCommandLineListener); - } - - /** - * Add commands to execute - * - * @param commands Commands to execute - */ - public void addCommand(String[] 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 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 synchronized void addCommand(String[] commands, int code, - OnCommandResultListener onCommandResultListener) { - this.commands.add(new Command(commands, code, onCommandResultListener, 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 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(String[] commands, int code, - OnCommandLineListener onCommandLineListener) { - this.commands.add(new Command(commands, code, null, onCommandLineListener)); + @AnyThread + public synchronized void addCommand(@NonNull Object commands, int code, @Nullable OnResult onResultListener) { + this.commands.add(new Command(commands, code, onResultListener)); runNextCommand(); } @@ -1236,23 +1739,26 @@ public class Shell { return; if (!isRunning()) { - exitCode = OnCommandResultListener.SHELL_DIED; - Debug.log(String.format("[%s%%] SHELL_DIED", shell.toUpperCase(Locale.ENGLISH))); + exitCode = OnResult.SHELL_DIED; + Debug.log(String.format(Locale.ENGLISH, "[%s%%] SHELL_DIED", shell.toUpperCase(Locale.ENGLISH))); } else if (watchdogCount++ < watchdogTimeout) { return; } else { - exitCode = OnCommandResultListener.WATCHDOG_EXIT; - Debug.log(String.format("[%s%%] WATCHDOG_EXIT", shell.toUpperCase(Locale.ENGLISH))); + exitCode = OnResult.WATCHDOG_EXIT; + Debug.log(String.format(Locale.ENGLISH, "[%s%%] WATCHDOG_EXIT", shell.toUpperCase(Locale.ENGLISH))); } - if (handler != null) { - postCallback(command, exitCode, buffer); + if (command != null) { + //noinspection ConstantConditions // all write to 'command' are synchronized + postCallback(command, exitCode, bufferSTDOUT, bufferSTDERR, null); } // prevent multiple callbacks for the same command command = null; - buffer = null; + bufferSTDOUT = null; + bufferSTDERR = null; idle = true; + opening = false; watchdog.shutdown(); watchdog = null; @@ -1288,62 +1794,104 @@ public class Shell { /** * 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) + if (!running || closed) { idle = true; + opening = false; + } - if (running && idle && (commands.size() > 0)) { + if (running && !closed && idle && (commands.size() > 0)) { Command command = commands.get(0); commands.remove(0); - buffer = null; + bufferSTDOUT = null; + bufferSTDERR = 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()); - } + // STDIN and STDOUT would never be null here, but checks added to satisfy lint + if ((STDIN != null) && (STDOUT != null)) { + try { + if (command.onCommandResultListener != null) { + bufferSTDOUT = Collections.synchronizedList(new ArrayList()); + } else if (command.onCommandResultListener2 != null) { + bufferSTDOUT = Collections.synchronizedList(new ArrayList()); + bufferSTDERR = Collections.synchronizedList(new ArrayList()); + } - idle = false; - this.command = command; - startWatchdog(); - for (String write : command.commands) { - Debug.logCommand(String.format("[%s+] %s", - shell.toUpperCase(Locale.ENGLISH), write)); - STDIN.write((write + "\n").getBytes("UTF-8")); + idle = false; + this.command = command; + if (command.onCommandInputStreamListener != null) { + if (!STDOUT.isSuspended()) { + if (Thread.currentThread().getId() == STDOUT.getId()) { + // if we're on the Gobbler thread we can suspend immediately, + // as we're not currently in a readLine() call + STDOUT.suspendGobbling(); + } else { + // if not, we trigger the readLine() call in the Gobbler to + // complete, and have the suspend triggered in the next + // onLine call + STDIN.write(("echo inputstream\n").getBytes("UTF-8")); + STDIN.flush(); + STDOUT.waitForSuspend(); + } + } + } else { + STDOUT.resumeGobbling(); + startWatchdog(); + } + for (String write : command.commands) { + Debug.logCommand(String.format(Locale.ENGLISH, "[%s+] %s", + shell.toUpperCase(Locale.ENGLISH), write)); + 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(); + if (command.onCommandInputStreamListener != null) { + command.markerInputStream = new MarkerInputStream(STDOUT, command.marker); + postCallback(command, 0, null, null, command.markerInputStream); + } + } catch (IOException e) { + // STDIN might have closed } - 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 + } else if (!running || closed) { + // our shell died for unknown reasons or was closed - abort all submissions + Debug.log(String.format(Locale.ENGLISH, "[%s%%] SHELL_DIED", shell.toUpperCase(Locale.ENGLISH))); while (commands.size() > 0) { - postCallback(commands.remove(0), OnCommandResultListener.SHELL_DIED, null); + postCallback(commands.remove(0), OnResult.SHELL_DIED, null, null, null); + } + onClosed(); + } + + if (idle) { + if (running && doCloseWhenIdle) { + doCloseWhenIdle = false; + closeImmediately(true); + } + if (notifyIdle) { + synchronized (idleSync) { + idleSync.notifyAll(); + } } } - if (idle && notifyIdle) { - synchronized (idleSync) { - idleSync.notifyAll(); + if (lastOpening && !opening) { + lastOpening = opening; + synchronized (openingSync) { + openingSync.notifyAll(); } } } @@ -1351,62 +1899,82 @@ public class Shell { /** * Processes a STDOUT/STDERR line containing an end/exitCode marker */ + @SuppressWarnings("ConstantConditions") // all writes to 'command' are synchronized private synchronized void processMarker() { - if (command.marker.equals(lastMarkerSTDOUT) - && (command.marker.equals(lastMarkerSTDERR))) { - postCallback(command, lastExitCode, buffer); + if ((command != null) && + command.marker.equals(lastMarkerSTDOUT) && + command.marker.equals(lastMarkerSTDERR)) { + postCallback(command, lastExitCode, bufferSTDOUT, bufferSTDERR, null); stopWatchdog(); command = null; - buffer = null; + bufferSTDOUT = null; + bufferSTDERR = null; idle = true; + opening = false; runNextCommand(); } } /** - * Process a normal STDOUT/STDERR line - * + * Process a normal STDOUT/STDERR line, post to callback + * * @param line Line to process - * @param listener Callback to call or null + * @param listener Callback to call or null, supports OnLineListener, OnCommandLineSTDOUT, OnCommandLineSTDERR */ - private synchronized void processLine(String line, OnLineListener listener) { + private synchronized void processLine(@NonNull final String line, @Nullable final Object listener, final boolean isSTDERR) { if (listener != null) { if (handler != null) { - final String fLine = line; - final OnLineListener fListener = listener; - startCallback(); handler.post(new Runnable() { @Override public void run() { try { - fListener.onLine(fLine); + if (listener instanceof OnLineListener) { + ((OnLineListener)listener).onLine(line); + } else if ((listener instanceof OnCommandLineSTDOUT) && !isSTDERR) { + ((OnCommandLineSTDOUT)listener).onSTDOUT(line); + } else if ((listener instanceof OnCommandLineSTDERR) && isSTDERR) { + ((OnCommandLineSTDERR)listener).onSTDERR(line); + } } finally { endCallback(); } } }); } else { - listener.onLine(line); + if (listener instanceof OnLineListener) { + ((OnLineListener)listener).onLine(line); + } else if ((listener instanceof OnCommandLineSTDOUT) && !isSTDERR) { + ((OnCommandLineSTDOUT)listener).onSTDOUT(line); + } else if ((listener instanceof OnCommandLineSTDERR) && isSTDERR) { + ((OnCommandLineSTDERR)listener).onSTDERR(line); + } } } } /** * Add line to internal buffer - * + * * @param line Line to add */ - private synchronized void addBuffer(String line) { - if (buffer != null) { - buffer.add(line); + @SuppressWarnings("ConstantConditions") // all writes to bufferSTDxxx are synchronized + private synchronized void addBuffer(@NonNull String line, boolean isSTDERR) { + if (isSTDERR) { + if (bufferSTDERR != null) { + bufferSTDERR.add(line); + } else if (wantSTDERR && (bufferSTDOUT != null)) { + bufferSTDOUT.add(line); + } + } else if (bufferSTDOUT != null) { + bufferSTDOUT.add(line); } } /** * Increase callback counter */ - private void startCallback() { + void startCallback() { synchronized (callbackSync) { callbacks++; } @@ -1414,43 +1982,68 @@ public class Shell { /** * Schedule a callback to run on the appropriate thread + * + * @return if callback has already completed */ - private void postCallback(final Command fCommand, final int fExitCode, - final List fOutput) { - if (fCommand.onCommandResultListener == null && fCommand.onCommandLineListener == null) { - return; + private boolean postCallback(@NonNull final Command fCommand, final int fExitCode, + @Nullable final List fSTDOUT, @Nullable final List fSTDERR, + @Nullable final InputStream inputStream) { + if ( + (fCommand.onCommandResultListener == null) && + (fCommand.onCommandResultListener2 == null) && + (fCommand.onCommandLineListener == null) && + (fCommand.onCommandInputStreamListener == null) + ) { + return true; } - if (handler == null) { - if ((fCommand.onCommandResultListener != null) && (fOutput != null)) - fCommand.onCommandResultListener.onCommandResult(fCommand.code, fExitCode, - fOutput); - if (fCommand.onCommandLineListener != null) - fCommand.onCommandLineListener.onCommandResult(fCommand.code, fExitCode); - return; + + // we run the shell open test commands result immediately even if we have a handler, so + // it may close the shell before other commands start and pass them SHELL_DIED exit code + if ((handler == null) || (fCommand.commands == availableTestCommands)) { + if (inputStream == null) { + if (fCommand.onCommandResultListener != null) + fCommand.onCommandResultListener.onCommandResult(fCommand.code, fExitCode, fSTDOUT != null ? fSTDOUT : emptyStringList); + if (fCommand.onCommandResultListener2 != null) + fCommand.onCommandResultListener2.onCommandResult(fCommand.code, fExitCode, fSTDOUT != null ? fSTDOUT : emptyStringList, fSTDERR != null ? fSTDERR : emptyStringList); + if (fCommand.onCommandLineListener != null) + fCommand.onCommandLineListener.onCommandResult(fCommand.code, fExitCode); + if (fCommand.onCommandInputStreamListener != null) + fCommand.onCommandInputStreamListener.onCommandResult(fCommand.code, fExitCode); + } else if (fCommand.onCommandInputStreamListener != null) { + fCommand.onCommandInputStreamListener.onInputStream(inputStream); + } + return true; } 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); + if (inputStream == null) { + if (fCommand.onCommandResultListener != null) + fCommand.onCommandResultListener.onCommandResult(fCommand.code, fExitCode, fSTDOUT != null ? fSTDOUT : emptyStringList); + if (fCommand.onCommandResultListener2 != null) + fCommand.onCommandResultListener2.onCommandResult(fCommand.code, fExitCode, fSTDOUT != null ? fSTDOUT : emptyStringList, fSTDERR != null ? fSTDERR : emptyStringList); + if (fCommand.onCommandLineListener != null) + fCommand.onCommandLineListener.onCommandResult(fCommand.code, fExitCode); + if (fCommand.onCommandInputStreamListener != null) + fCommand.onCommandInputStreamListener.onCommandResult(fCommand.code, fExitCode); + } else if (fCommand.onCommandInputStreamListener != null) { + fCommand.onCommandInputStreamListener.onInputStream(inputStream); + } } finally { endCallback(); } } }); + return false; } /** * Decrease callback counter, signals callback complete state when * dropped to 0 */ - private void endCallback() { + void endCallback() { synchronized (callbackSync) { callbacks--; if (callbacks == 0) { @@ -1462,11 +2055,11 @@ public class Shell { /** * Internal call that launches the shell, starts gobbling, and starts * executing commands. See {@link Interactive} - * + * * @return Opened successfully ? */ private synchronized boolean open() { - Debug.log(String.format("[%s%%] START", shell.toUpperCase(Locale.ENGLISH))); + Debug.log(String.format(Locale.ENGLISH, "[%s%%] START", shell.toUpperCase(Locale.ENGLISH))); try { // setup our process, retrieve STDIN stream, and STDOUT/STDERR @@ -1486,52 +2079,135 @@ public class Shell { process = Runtime.getRuntime().exec(shell, env); } + // this should never actually happen + if (process == null) throw new NullPointerException(); + + OnStreamClosedListener onStreamClosedListener = new OnStreamClosedListener() { + @Override + public void onStreamClosed() { + if (shellDiesOnSTDOUTERRClose || !isRunning()) { + if ((STDERR != null) && (Thread.currentThread() == STDOUT)) STDERR.resumeGobbling(); + if ((STDOUT != null) && (Thread.currentThread() == STDERR)) STDOUT.resumeGobbling(); + + boolean isLast; + synchronized (STDclosedSync){ + if (Thread.currentThread() == STDOUT) STDOUTclosed = true; + if (Thread.currentThread() == STDERR) STDERRclosed = true; + isLast = STDOUTclosed && STDERRclosed; + + Command c = command; + if (c != null) { + MarkerInputStream mis = c.markerInputStream; + if (mis != null) { + mis.setEOF(); + } + } + } + + if (isLast) { // make sure both are done + waitForCallbacks(); + + synchronized (Interactive.this) { + // our shell died for unknown reasons - abort all submissions + if (command != null) { + //noinspection ConstantConditions // all writes to 'command' are synchronized + postCallback(command, OnResult.SHELL_DIED, bufferSTDOUT, bufferSTDERR, null); + command = null; + } + closed = true; + opening = false; + runNextCommand(); + } + } + } + } + }; + STDIN = new DataOutputStream(process.getOutputStream()); STDOUT = new StreamGobbler(shell.toUpperCase(Locale.ENGLISH) + "-", process.getInputStream(), new OnLineListener() { - @Override - public void onLine(String line) { - synchronized (Interactive.this) { - if (command == null) { - return; - } - if (line.startsWith(command.marker)) { - try { - lastExitCode = Integer.valueOf( - line.substring(command.marker.length() + 1), 10); - } catch (Exception e) { - // this really shouldn't happen - e.printStackTrace(); - } - lastMarkerSTDOUT = command.marker; - processMarker(); - } else { - addBuffer(line); - processLine(line, onSTDOUTLineListener); - processLine(line, command.onCommandLineListener); - } - } + @SuppressWarnings("ConstantConditions") // all writes to 'command' are synchronized + @Override + public void onLine(@NonNull String line) { + Command cmd = command; + if ((cmd != null) && (cmd.onCommandInputStreamListener != null)) { + // we need to suspend the normal input reader + if (line.equals("inputstream")) { + if (STDOUT != null) STDOUT.suspendGobbling(); + return; } - }); + } + + 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, false); + processLine(contentPart, onSTDOUTLineListener, false); + processLine(contentPart, command.onCommandLineListener, false); + } + + 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(); + } + } + } + }, onStreamClosedListener); STDERR = new StreamGobbler(shell.toUpperCase(Locale.ENGLISH) + "*", process.getErrorStream(), new OnLineListener() { - @Override - public void onLine(String line) { - synchronized (Interactive.this) { - if (command == null) { - return; - } - if (line.startsWith(command.marker)) { - lastMarkerSTDERR = command.marker; - processMarker(); - } else { - if (wantSTDERR) - addBuffer(line); - processLine(line, onSTDERRLineListener); - } - } + @SuppressWarnings("ConstantConditions") // all writes to 'command' are synchronized + @Override + public void onLine(@NonNull 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) { + addBuffer(contentPart, true); + processLine(contentPart, onSTDERRLineListener, true); + processLine(contentPart, command.onCommandLineListener, true); + processLine(contentPart, command.onCommandInputStreamListener, true); + } + + if (markerIndex >= 0) { + lastMarkerSTDERR = command.marker; + processMarker(); + } + } + } + }, onStreamClosedListener); // start gobbling and write our commands to the shell STDOUT.start(); @@ -1549,6 +2225,25 @@ public class Shell { } } + /** + * Currently unused. May be called multiple times for each actual close. + */ + protected void onClosed() { + // callbacks may still be scheduled/running at this point, and this may be called + // multiple times! + // if (inClosingJoin) return; // prevent deadlock, we will be called after + } + + /** + * Currently redirects to {@link #closeImmediately()}. You should use + * {@link #closeImmediately()} or {@link #closeWhenIdle()} directly instead + */ + // not annotated @deprecated because we do want to use this method in Threaded + @WorkerThread // if shell not idle + public void close() { + closeImmediately(); + } + /** * 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 @@ -1556,7 +2251,15 @@ public class Shell { * 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() { + @WorkerThread // if shell not idle + public void closeImmediately() { + closeImmediately(false); + } + + protected void closeImmediately(boolean fromIdle) { + // these should never happen, satisfy lint + if ((STDIN == null) || (STDOUT == null) || (STDERR == null) || (process == null)) throw new NullPointerException(); + boolean _idle = isIdle(); // idle must be checked synchronized synchronized (this) { @@ -1566,6 +2269,11 @@ public class Shell { closed = true; } + if (!isRunning()) { + onClosed(); + return; + } + // This method should not be called from the main thread unless the // shell is idle and can be cleaned up with (minimal) waiting. Only // throw in debug mode. @@ -1582,7 +2290,7 @@ public class Shell { STDIN.write(("exit\n").getBytes("UTF-8")); STDIN.flush(); } catch (IOException e) { - if (e.getMessage().contains("EPIPE")) { + if (e.getMessage().contains("EPIPE") || e.getMessage().contains("Stream closed")) { // we're not running a shell, the shell closed STDIN, // the script already contained the exit command, etc. } else { @@ -1604,8 +2312,19 @@ public class Shell { } catch (IOException e) { // STDIN going missing is no reason to abort } - STDOUT.join(); - STDERR.join(); + + // Make sure our threads our running + if (Thread.currentThread() != STDOUT) STDOUT.resumeGobbling(); + if (Thread.currentThread() != STDERR) STDERR.resumeGobbling(); + + // Otherwise we may deadlock waiting on eachother, happens when this is run from OnShellOpenResultListener + if ((Thread.currentThread() != STDOUT) && (Thread.currentThread() != STDERR)) { + inClosingJoin = true; + STDOUT.conditionalJoin(); + STDERR.conditionalJoin(); + inClosingJoin = false; + } + stopWatchdog(); process.destroy(); } catch (IOException e) { @@ -1614,7 +2333,23 @@ public class Shell { // this should really be re-thrown } - Debug.log(String.format("[%s%%] END", shell.toUpperCase(Locale.ENGLISH))); + Debug.log(String.format(Locale.ENGLISH, "[%s%%] END", shell.toUpperCase(Locale.ENGLISH))); + + onClosed(); + } + + /** + * {@link #close()} the shell when it becomes idle. Note that in contrast to + * {@link #closeImmediately()}, this method does not block until the + * shell is closed! + */ + @AnyThread + public void closeWhenIdle() { + if (idle) { + closeImmediately(true); + } else { + doCloseWhenIdle = true; + } } /** @@ -1622,7 +2357,11 @@ public class Shell { * wedged. Hopefully the StreamGobblers will croak on their own when the * other side of the pipe is closed. */ + @WorkerThread public synchronized void kill() { + // these should never happen, satisfy lint + if ((STDIN == null) || (process == null)) throw new NullPointerException(); + running = false; closed = true; @@ -1636,13 +2375,44 @@ public class Shell { } catch (Exception e) { // in case it was already destroyed or can't be } + + idle = true; + opening = false; + synchronized (idleSync) { + idleSync.notifyAll(); + } + if (lastOpening && !opening) { + lastOpening = opening; + synchronized (openingSync) { + openingSync.notifyAll(); + } + } + + onClosed(); + } + + /** + *

+ * Is our shell currently being opened ? + *

+ *

+ * Requires OnShellOpenResultCallback to be used when opening, or + * {@link Builder#setDetectOpen(boolean)} to be true + *

+ * + * @return Shell opening ? + */ + @AnyThread + public boolean isOpening() { + return isRunning() && opening; } /** * Is our shell still running ? - * + * * @return Shell running ? */ + @AnyThread public boolean isRunning() { if (process == null) { return false; @@ -1658,19 +2428,51 @@ public class Shell { /** * Have all commands completed executing ? - * + * * @return Shell idle ? */ + @AnyThread public synchronized boolean isIdle() { if (!isRunning()) { idle = true; + opening = false; synchronized (idleSync) { idleSync.notifyAll(); } + if (lastOpening && !opening) { + lastOpening = opening; + synchronized (openingSync) { + openingSync.notifyAll(); + } + } } return idle; } + private boolean waitForCallbacks() { + 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; + } + /** *

* Wait for idle state. As this is a blocking call, you should not call @@ -1700,9 +2502,10 @@ public class Shell { * See {@link Interactive} for further details on threading and * handlers *

- * + * * @return True if wait complete, false if wait interrupted */ + @WorkerThread public boolean waitForIdle() { if (Debug.getSanityChecksEnabledEffective() && Debug.onMainThread()) { Debug.log(ShellOnMainThreadException.EXCEPTION_WAIT_IDLE); @@ -1720,38 +2523,1105 @@ public class Shell { } } - 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 waitForCallbacks(); } return true; } + /** + *

+ * Wait for shell opening to complete + *

+ *

+ * Requires OnShellOpenResultCallback to be used when opening, or + * {@link Builder#setDetectOpen(boolean)} to be true + *

+ * + * @param defaultIfInterrupted What to return if an interrupt occurs, null to keep waiting + * @return If shell was opened successfully + */ + @WorkerThread + public boolean waitForOpened(@Nullable Boolean defaultIfInterrupted) { + if (Debug.getSanityChecksEnabledEffective() && Debug.onMainThread()) { + Debug.log(ShellOnMainThreadException.EXCEPTION_WAIT_IDLE); + throw new ShellOnMainThreadException(ShellOnMainThreadException.EXCEPTION_WAIT_IDLE); + } + + if (isRunning()) { + synchronized (openingSync) { + while (opening) { + try { + openingSync.wait(); + } catch (InterruptedException e) { + if (defaultIfInterrupted != null) { + return defaultIfInterrupted; + } + } + } + } + } + + return isRunning(); + } + /** * Are we using a Handler to post callbacks ? - * + * * @return Handler used ? */ + @AnyThread public boolean hasHandler() { return (handler != null); } + + /** + * Are there any commands scheduled ? + * + * @return Commands scheduled ? + */ + @AnyThread + public boolean hasCommands() { + return (commands.size() > 0); + } + + // documented in SyncCommands interface + @Override + @WorkerThread + public int run(@NonNull Object commands) throws ShellDiedException { + return run(commands, null, null, false); + } + + // documented in SyncCommands interface + @Override + @WorkerThread + public int run(@NonNull Object commands, @Nullable final List STDOUT, @Nullable final List STDERR, boolean clear) throws ShellDiedException { + if (clear) { + if (STDOUT != null) STDOUT.clear(); + if (STDERR != null) STDERR.clear(); + } + final int[] exitCode = new int[1]; + addCommand(commands, 0, new OnCommandResultListener2() { + @Override + public void onCommandResult(int commandCode, int intExitCode, @NonNull List intSTDOUT, @NonNull List intSTDERR) { + exitCode[0] = intExitCode; + if (STDOUT != null) STDOUT.addAll(intSTDOUT); + if (STDERR != null) STDERR.addAll(intSTDERR); + } + }); + waitForIdle(); + if (exitCode[0] < 0) throw new ShellDiedException(); + return exitCode[0]; + } + + // documented in SyncCommands interface + @Override + @WorkerThread + public int run(@NonNull Object commands, @NonNull final OnSyncCommandLineListener onSyncCommandLineListener) throws ShellDiedException { + final int[] exitCode = new int[1]; + addCommand(commands, 0, new OnCommandLineListener() { + @Override + public void onSTDERR(@NonNull String line) { + onSyncCommandLineListener.onSTDERR(line); + } + + @Override + public void onSTDOUT(@NonNull String line) { + onSyncCommandLineListener.onSTDOUT(line); + } + + @Override + public void onCommandResult(int commandCode, int intExitCode) { + exitCode[0] = intExitCode; + } + }); + waitForIdle(); + if (exitCode[0] < 0) throw new ShellDiedException(); + return exitCode[0]; + } + + // documented in SyncCommands interface + @Override + @WorkerThread + public int run(@NonNull Object commands, @NonNull final OnSyncCommandInputStreamListener onSyncCommandInputStreamListener) throws ShellDiedException { + final int[] exitCode = new int[1]; + addCommand(commands, 0, new OnCommandInputStreamListener() { + @Override + public void onSTDERR(@NonNull String line) { + onSyncCommandInputStreamListener.onSTDERR(line); + } + + @Override + public void onInputStream(@NonNull InputStream inputStream) { + onSyncCommandInputStreamListener.onInputStream(inputStream); + } + + @Override + public void onCommandResult(int commandCode, int intExitCode) { + exitCode[0] = intExitCode; + } + }); + waitForIdle(); + if (exitCode[0] < 0) throw new ShellDiedException(); + return exitCode[0]; + } + } + + /** + *

+ * Variant of {@link Interactive} that uses a dedicated background thread for callbacks, + * rather than requiring the library users to manage this themselves. It also provides + * support for pooling, and is used by {@link Pool} + *

+ *

+ * While {@link Interactive}'s asynchronous calls are relatively easy to use from the + * main UI thread, many developers struggle with implementing it correctly in background + * threads. This class is a one-stop solution for those issues + *

+ *

+ * You can use this class from the main UI thread as well, though you should take note + * that since the callbacks are run in a background thread, you cannot manipulate the + * UI directly. You can use the Activity#runOnUiThread() to work around this + *

+ *

+ * Please note that the {@link #close()} method behaves differently from the implementation + * in {@link Interactive}! + *

+ * + * @see Interactive + */ + public static class Threaded extends Interactive { + private static int threadCounter = 0; + private static int incThreadCounter() { + synchronized (Threaded.class) { + int ret = threadCounter; + threadCounter++; + return ret; + } + } + + @NonNull + private final HandlerThread handlerThread; + private final boolean pooled; + private final Object onCloseCalledSync = new Object(); + private volatile boolean onClosedCalled = false; + private final Object onPoolRemoveCalledSync = new Object(); + private volatile boolean onPoolRemoveCalled = false; + private volatile boolean reserved = true; + private volatile boolean closeEvenIfPooled = false; + + private static Handler createHandlerThread() { + // to work-around having to call super() as first line in constructor, but still + // being able to keep fields final + HandlerThread handlerThread = new HandlerThread("Shell.Threaded#" + incThreadCounter()); + handlerThread.start(); + return new Handler(handlerThread.getLooper()); + } + + /** + * The only way to create an instance: Shell.Builder::openThreaded(...) + * + * @see Shell.Builder#openThreaded() + * @see Shell.Builder#openThreaded(Shell.OnShellOpenResultListener) + * + * @param builder Builder class to take values from + * @param onShellOpenResultListener Callback + * @param pooled Will this instance be pooled ? + */ + protected Threaded(Builder builder, OnShellOpenResultListener onShellOpenResultListener, boolean pooled) { + super(builder.setHandler(createHandlerThread()). + setDetectOpen(true). + setShellDiesOnSTDOUTERRClose(true), + onShellOpenResultListener); + + // don't try this at home + //noinspection ConstantConditions + handlerThread = (HandlerThread)handler.getLooper().getThread(); + + // it's ok if close is called before this + this.pooled = pooled; + if (this.pooled) protect(); + } + + @Override + protected void finalize() throws Throwable { + if (pooled) closed = true; // prevent ShellNotClosedException exception on pool + super.finalize(); + } + + private void protect() { + synchronized (onCloseCalledSync) { + if (!onClosedCalled) { + Garbage.protect(this); + } + } + } + + private void collect() { + Garbage.collect(this); + } + + /** + *

+ * Redirects to non-blocking {@link #closeWhenIdle()} if this instance is not part of a + * pool; if it is, returns the instance to the pool + *

+ *

+ * Note that this behavior is different from the superclass' behavior, which redirects + * to the blocking {@link #closeImmediately()} + *

+ *

+ * This change in behavior between super- and subclass is a clear code smell, but it is + * needed to support AutoCloseable, makes the flow with {@link Pool} better, and maintains + * compatibility with older code using this library (which there is quite a bit of) + *

+ */ + @Override + @AnyThread + public void close() { + protect(); + + // NOT close(Immediately), but closeWhenIdle, note! + if (pooled) { + super.closeWhenIdle(); + } else { + closeWhenIdle(); + } + } + + @Override + protected void closeImmediately(boolean fromIdle) { + protect(); + + if (pooled) { + if (fromIdle) { + boolean callRelease = false; + synchronized (onPoolRemoveCalledSync) { + if (!onPoolRemoveCalled) { + callRelease = true; + } + } + if (callRelease) Pool.releaseReservation(this); + if (closeEvenIfPooled) { + super.closeImmediately(true); + } + } else { + boolean callRemove = false; + synchronized (onPoolRemoveCalledSync) { + if (!onPoolRemoveCalled) { + onPoolRemoveCalled = true; + callRemove = true; + } + } + if (callRemove) Pool.removeShell(this); + super.closeImmediately(false); + } + } else { + super.closeImmediately(fromIdle); + } + } + + private void closeWhenIdle(boolean fromPool) { + protect(); + + if (pooled) { + synchronized (onPoolRemoveCalledSync) { + if (!onPoolRemoveCalled) { + onPoolRemoveCalled = true; + Pool.removeShell(this); + } + } + if (fromPool) { + closeEvenIfPooled = true; + } + } + super.closeWhenIdle(); + } + + @Override + @AnyThread + public void closeWhenIdle() { + closeWhenIdle(false); + } + + boolean wasPoolRemoveCalled() { + synchronized (onPoolRemoveCalledSync) { + return onPoolRemoveCalled; + } + } + + @SuppressWarnings("ConstantConditions") // handler is never null + @Override + protected void onClosed() { + // clean up our thread + if (inClosingJoin) return; // prevent deadlock, we will be called after + + if (pooled) { + boolean callRemove = false; + synchronized (onPoolRemoveCalledSync) { + if (!onPoolRemoveCalled) { + onPoolRemoveCalled = true; + callRemove = true; + } + } + if (callRemove) { + protect(); + Pool.removeShell(this); + } + } + + // we've been GC'd by removeShell above, code below should already have been executed + if (onCloseCalledSync == null) return; + + synchronized (onCloseCalledSync) { + if (onClosedCalled) return; + onClosedCalled = true; + } + + try { + super.onClosed(); + } finally { + if (!handlerThread.isAlive()) { + collect(); + } else { + handler.post(new Runnable() { + @SuppressWarnings("ConstantConditions") // handler is never null + @Override + public void run() { + synchronized (callbackSync) { + if (callbacks > 0) { + // we still have some callbacks running + handler.postDelayed(this, 1000); + } else { + collect(); + if (Build.VERSION.SDK_INT >= 18) { + handlerThread.quitSafely(); + } else { + handlerThread.quit(); + } + } + } + } + }); + } + } + } + + /** + *

+ * Cast current instance to {@link ThreadedAutoCloseable} which can be used with + * try-with-resources + *

+ *

+ * On API >= 19, all instances are safe to cast this way, as all instances are + * created as {@link ThreadedAutoCloseable}. On older API levels, this returns null + *

+ * + * @return ThreadedAutoCloseable on API >= 19, null otherwise + */ + @Nullable + @AnyThread + public ThreadedAutoCloseable ac() { + if (this instanceof ThreadedAutoCloseable) { + return (ThreadedAutoCloseable)this; + } else { + return null; + } + } + + private boolean isReserved() { + return reserved; + } + + private void setReserved(boolean reserved) { + this.reserved = reserved; + } + } + + /** + *

+ * AutoClosable variant of {@link Threaded} that can be used with try-with-resources + *

+ *

+ * This class is automatically used instead of {@link Threaded} everywhere on + * API >= 19. Use {@link Threaded#ac()} to auto-cast (returns null on API <= 19) + *

+ */ + @TargetApi(Build.VERSION_CODES.KITKAT) + public static class ThreadedAutoCloseable extends Threaded implements AutoCloseable { + protected ThreadedAutoCloseable(@NonNull Builder builder, OnShellOpenResultListener onShellOpenResultListener, boolean pooled) { + super(builder, onShellOpenResultListener, pooled); + } + } + + /** + *

+ * Helper class for pooled command execution + *

+ *

+ * {@link Interactive} and {@link Threaded}'s run() methods operate on a specific + * shell instance. This class supports the same synchronous methods, but runs them + * on any available shell instance from the pool, creating a new instance when none + * is available. + *

+ *

+ * {@link Pool#SH} and {@link Pool#SH} are instances of this class, you can create + * a wrapper for other shell commands using {@link Pool#getWrapper(String)} + *

+ * + * @see Pool + */ + public static class PoolWrapper implements DeprecatedSyncCommands, SyncCommands { + private final String shellCommand; + + /** + * Constructor for {@link PoolWrapper} + * + * @param shell Shell command, like "sh" or "su" + */ + @AnyThread + public PoolWrapper(@NonNull String shell) { + this.shellCommand = shell; + } + + /** + *

+ * Retrieves a {@link Threaded} instance from the {@link Pool}, creating a new one + * if none are available. You must call {@link Threaded#close()} to return + * the instance to the {@link Pool} + *

+ *

+ * If called from a background thread, the shell is fully opened before this method + * returns. If called from the main UI thread, the shell may not have completed + * opening. + *

+ * + * @return A {@link Threaded} instance from the {@link Pool} + * @throws ShellDiedException if a shell could not be retrieved (execution failed, access denied) + */ + @NonNull + @AnyThread + public Threaded get() throws ShellDiedException { + return Shell.Pool.get(shellCommand); + } + + /** + *

+ * Retrieves a {@link Threaded} instance from the {@link Pool}, creating a new one + * if none are available. You must call {@link Threaded#close()} to return + * the instance to the {@link Pool} + *

+ *

+ * The callback with open status is called before this method returns if this method + * is called from a background thread. When called from the main UI thread, the + * method may return before the callback is executed (or the shell has completed opening) + *

+ * + * @param onShellOpenResultListener Callback to return shell open status + * @return A {@link Threaded} instance from the {@link Pool} + * @throws ShellDiedException if a shell could not be retrieved (execution failed, access denied) + */ + @NonNull + @AnyThread + public Threaded get(@Nullable OnShellOpenResultListener onShellOpenResultListener) throws ShellDiedException { + return Shell.Pool.get(shellCommand, onShellOpenResultListener); + } + + /** + *

+ * Retrieves a new {@link Threaded} instance that is not part of the {@link Pool} + *

+ * + * @return A {@link Threaded} instance + */ + @NonNull + @AnyThread + public Threaded getUnpooled() { + return Shell.Pool.getUnpooled(shellCommand); + } + + /** + *

+ * Retrieves a new {@link Threaded} instance that is not part of the {@link Pool} + *

+ * + * @param onShellOpenResultListener Callback to return shell open status + * @return A {@link Threaded} instance + */ + @NonNull + @AnyThread + public Threaded getUnpooled(@Nullable OnShellOpenResultListener onShellOpenResultListener) { + return Shell.Pool.getUnpooled(shellCommand, onShellOpenResultListener); + } + + // documented in DeprecatedSyncCommands interface + @Nullable + @Deprecated + @WorkerThread + public List run(@NonNull Object commands, final boolean wantSTDERR) { + try { + Threaded shell = get(); + try { + final int[] exitCode = new int[1]; + final List output = new ArrayList(); + shell.addCommand(commands, 0, new OnCommandResultListener2() { + @Override + public void onCommandResult(int commandCode, int intExitCode, @NonNull List intSTDOUT, @NonNull List intSTDERR) { + exitCode[0] = intExitCode; + output.addAll(intSTDOUT); + if (wantSTDERR) { + output.addAll(intSTDERR); + } + } + }); + shell.waitForIdle(); + if (exitCode[0] < 0) return null; + return output; + } finally { + shell.close(); + } + } catch (ShellDiedException e) { + return null; + } + } + + // documented in DeprecatedSyncCommands interface + @Nullable + @SuppressWarnings("unchecked") // if the user passes in List<> of anything other than String, that's on them + @Deprecated + @WorkerThread + public List run(@NonNull Object commands, @Nullable String[] environment, boolean wantSTDERR) { + if (environment == null) { + return run(commands, wantSTDERR); + } else { + String[] _commands; + if (commands instanceof String) { + _commands = new String[] { (String)commands }; + } else if (commands instanceof List) { + _commands = ((List)commands).toArray(new String[0]); + } else if (commands instanceof String[]) { + _commands = (String[])commands; + } else { + throw new IllegalArgumentException("commands parameter must be of type String, List or String[]"); + } + + StringBuilder sb = new StringBuilder(); + for (String entry : environment) { + int split; + if ((split = entry.indexOf("=")) >= 0) { + boolean quoted = entry.substring(split + 1, split + 2).equals("\""); + sb.append(entry, 0, split); + sb.append(quoted ? "=" : "=\""); + sb.append(entry.substring(split + 1)); + sb.append(quoted ? " " : "\" "); + } + } + sb.append("sh -c \"\n"); + for (String line : _commands) { + sb.append(line); + sb.append("\n"); + } + sb.append("\""); + return run(new String[] { sb.toString(). + replace("\\", "\\\\"). + replace("$", "\\$") + }, wantSTDERR); + } + } + + // documented in SyncCommands interface + @Override + @WorkerThread + public int run(@NonNull Object commands) throws ShellDiedException { + return run(commands, null, null, false); + } + + // documented in SyncCommands interface + @Override + @WorkerThread + public int run(@NonNull Object commands, @Nullable List STDOUT, @Nullable List STDERR, boolean clear) throws ShellDiedException { + Threaded shell = get(); + try { + return shell.run(commands, STDOUT, STDERR, clear); + } finally { + shell.close(); + } + } + + // documented in SyncCommands interface + @Override + @WorkerThread + public int run(@NonNull Object commands, @NonNull OnSyncCommandLineListener onSyncCommandLineListener) throws ShellDiedException { + Threaded shell = get(); + try { + return shell.run(commands, onSyncCommandLineListener); + } finally { + shell.close(); + } + } + + // documented in SyncCommands interface + @Override + @WorkerThread + public int run(@NonNull Object commands, @NonNull OnSyncCommandInputStreamListener onSyncCommandInputStreamListener) throws ShellDiedException { + Threaded shell = get(); + try { + return shell.run(commands, onSyncCommandInputStreamListener); + } finally { + shell.close(); + } + } + } + + /** + *

+ * Class that manages {@link Threaded} shell pools. When one of its (or {@link PoolWrapper}'s) + * get() methods is used, a shell is retrieved from the pool, or a new one is created if none + * are available. You must call {@link Threaded#close()} to return a shell to the pool + * for reuse + *

+ *

+ * While as many shells are created on-demand as necessary {@link #setPoolSize(int)} governs + * how many open shells are kept around in the pool once they become idle. Note that this only + * applies to "su"-based (root) shells, there is at most one instance of other shells + * (such as "sh") kept around, based on the assumption that starting those is cheap, while + * starting a "su"-based shell is expensive (and may interrupt the user with a permission + * dialog) + *

+ *

+ * If you want to change the default settings the {@link Threaded} shells are created with, + * call {@link #setOnNewBuilderListener(OnNewBuilderListener)}. It is advised to this only + * once, from Application::onCreate(). If you change this after shells have been created, + * you may end up with some shells have the default settings, and others having your + * customized ones + *

+ *

+ * For convenience, getUnpooled() methods are also provided, creating new {@link Threaded} + * shells you can manage on your own, but using the same {@link Builder} settings as + * configured for the pool + *

+ *

+ * {@link PoolWrapper} instances are setup for you already as {@link Shell.SH} and + * {@link Shell.SU}, allowing you to call Shell.SH/SU.run(...) without further management + * requirements. These methods retrieve an idle shell from the pool, run the passed + * commands in synchronous fashion (throwing {@link ShellDiedException} on any issue), + * and return the used shell to pool. Though their signatures are (intentionally) the + * same as the run(...) methods from the {@link Threaded} class (and indeed those are + * used internally), they should not be confused: the ones in the {@link Threaded} class + * operate specifically on that {@link Threaded} instance, that you should have get(...) + * before and will close() afterwards, while the {@link PoolWrapper} methods handle the + * pooling for you + *

+ *

+ * Should you need to pool shells that aren't "sh" or "su", a {@link PoolWrapper} instance + * can be created for these with {@link #getWrapper(String)} + *

+ * + */ + public static class Pool { + /** + * Callback interface to create a {@link Builder} for new shell instances + */ + public interface OnNewBuilderListener { + /** + * Called when a new {@link Builder} needs to be instantiated + * + * @return New {@link Builder} instance + */ + @NonNull + Shell.Builder newBuilder(); + } + + /** + * Default {@link OnNewBuilderListener} interface + * + * @see #setOnNewBuilderListener(OnNewBuilderListener) + */ + public static final OnNewBuilderListener defaultOnNewBuilderListener = new OnNewBuilderListener() { + @NonNull + @SuppressWarnings("deprecation") + @Override + public Shell.Builder newBuilder() { + return (new Shell.Builder()) + .setWantSTDERR(true) + .setWatchdogTimeout(0) + .setMinimalLogging(false); + } + }; + + @Nullable + private static OnNewBuilderListener onNewBuilderListener = null; + @NonNull + private static final Map> pool = new HashMap>(); + private static int poolSize = 4; // only applicable to su, we keep only 1 of others + + /** + * Get the currently set {@link OnNewBuilderListener} callback. {@link #defaultOnNewBuilderListener} + * is used when null + * + * @return Current {@link OnNewBuilderListener} interface, or null for {@link #defaultOnNewBuilderListener} + */ + @Nullable + @AnyThread + public static synchronized OnNewBuilderListener getOnNewBuilderListener() { + return onNewBuilderListener; + } + + /** + * Set current {@link OnNewBuilderListener} callback + * + * @param onNewBuilderListener {@link OnNewBuilderListener} to use, or null to revert to {@link #defaultOnNewBuilderListener} + */ + @AnyThread + public static synchronized void setOnNewBuilderListener(@Nullable OnNewBuilderListener onNewBuilderListener) { + Pool.onNewBuilderListener = onNewBuilderListener; + } + + /** + *

+ * Retrieve current kept pool size for "su"-based (root) shells. Only one instance of + * non-root shells is kept around long-term + *

+ *

+ * Note that more shells may be created as needed, this number only indicates how many + * idle instances to keep around for later use + *

+ * + * @return Current pool size + */ + @AnyThread + public static synchronized int getPoolSize() { + return poolSize; + } + + /** + *

+ * Set current kept pool size for "su"-based (root) shells. Only one instance of + * non-root shells is kept around long-term + *

+ *

+ * Note that more shells may be created as needed, this number only indicates how many + * idle instances to keep around for later use + *

+ * + * @param poolSize Pool size to use + */ + @AnyThread + public static synchronized void setPoolSize(int poolSize) { + poolSize = Math.max(poolSize, 1); + if (poolSize != Pool.poolSize) { + Pool.poolSize = poolSize; + cleanup(null, false); + } + } + + @NonNull + @AnyThread + private static synchronized Shell.Builder newBuilder() { + if (onNewBuilderListener != null) { + return onNewBuilderListener.newBuilder(); + } else { + return defaultOnNewBuilderListener.newBuilder(); + } + } + + /** + * Retrieves a new {@link Threaded} instance that is not part of the {@link Pool} + * + * @param shell Shell command + * @return A {@link Threaded} + */ + @NonNull + @AnyThread + public static Threaded getUnpooled(@NonNull String shell) { + return getUnpooled(shell, null); + } + + /** + *

+ * Retrieves a new {@link Threaded} instance that is not part of the {@link Pool}, + * with open result callback + *

+ * + * @param shell Shell command + * @param onShellOpenResultListener Callback to return shell open status + * @return A {@link Threaded} + */ + @NonNull + @AnyThread + public static Threaded getUnpooled(@NonNull String shell, @Nullable OnShellOpenResultListener onShellOpenResultListener) { + return newInstance(shell, onShellOpenResultListener, false); + } + + private static Threaded newInstance(@NonNull String shell, @Nullable OnShellOpenResultListener onShellOpenResultListener, boolean pooled) { + Debug.logPool(String.format(Locale.ENGLISH, "newInstance(shell:%s, pooled:%d)", shell, pooled ? 1 : 0)); + return newBuilder().setShell(shell).openThreadedEx(onShellOpenResultListener, pooled); + } + + /** + * Cleanup cycle for pooled shells + * + * @param toRemove Shell to remove, should already be closed, or null + * @param removeAll Remove all shells, closing them + */ + private static void cleanup(@Nullable Threaded toRemove, boolean removeAll) { + String[] keySet; + synchronized (pool) { + keySet = pool.keySet().toArray(new String[0]); + } + for (String key : keySet) { + ArrayList shellsModify = pool.get(key); + if (shellsModify == null) continue; + + @SuppressWarnings("unchecked") + ArrayList shellsCheck = (ArrayList)shellsModify.clone(); + // we use this so we don't need to synchronize the entire method, but can still + // prevent issues by the list being modified asynchronously + + int wantedTotal = Shell.SU.isSU(key) ? poolSize : 1; + int haveTotal = 0; + int haveAvailable = 0; + + for (int i = shellsCheck.size() - 1; i >= 0; i--) { + Threaded threaded = shellsCheck.get(i); + if (!threaded.isRunning() || (threaded == toRemove) || removeAll) { + Debug.logPool("shell removed"); + shellsCheck.remove(threaded); + synchronized (pool) { + shellsModify.remove(threaded); + } + if (removeAll) threaded.closeWhenIdle(); + } else { + haveTotal += 1; + if (!threaded.isReserved()) { + haveAvailable++; + } + } + } + + if ((haveTotal > wantedTotal) && (haveAvailable > 1)) { + int kill = Math.min(haveAvailable - 1, haveTotal - wantedTotal); + for (int i = shellsCheck.size() - 1; i >= 0; i--) { + Threaded threaded = shellsCheck.get(i); + if (!threaded.isReserved() && threaded.isIdle()) { + Debug.logPool("shell killed"); + shellsCheck.remove(threaded); + synchronized (pool) { + shellsModify.remove(threaded); + } + // not calling closeImmediately() due to possible race + threaded.closeWhenIdle(true); + kill--; + if (kill == 0) + break; + } + } + } + + synchronized (pool) { + if (shellsModify.size() == 0) { + pool.remove(key); + } + } + } + + if (Debug.getDebug()) { + synchronized (pool) { + for (String key : pool.keySet()) { + int reserved = 0; + ArrayList shells = pool.get(key); + if (shells == null) continue; // never happens, satisfy lint + for (int i = 0; i < shells.size(); i++) { + if (shells.get(i).isReserved()) reserved++; + } + Debug.logPool(String.format(Locale.ENGLISH, "cleanup: shell:%s count:%d reserved:%d", key, shells.size(), reserved)); + } + } + } + } + + /** + *

+ * Retrieves a {@link Threaded} instance from the {@link Pool}, creating a new one + * if none are available. You must call {@link Threaded#close()} to return + * the instance to the {@link Pool} + *

+ *

+ * If called from a background thread, the shell is fully opened before this method + * returns. If called from the main UI thread, the shell may not have completed + * opening. + *

+ * + * @param shell Shell command + * @return A {@link Threaded} + * @throws ShellDiedException if a shell could not be retrieved (execution failed, access denied) + */ + @NonNull + @AnyThread + public static Threaded get(@NonNull String shell) throws ShellDiedException { + return get(shell, null); + } + + /** + *

+ * Retrieves a {@link Threaded} instance from the {@link Pool}, creating a new one + * if none are available. You must call {@link Threaded#close()} to return + * the instance to the {@link Pool} + *

+ *

+ * The callback with open status is called before this method returns if this method + * is called from a background thread. When called from the main UI thread, the + * method may return before the callback is executed (or the shell has completed opening) + *

+ * + * @param shell Shell command + * @param onShellOpenResultListener Callback to return shell open status + * @return A {@link Threaded} instance from the {@link Pool} + * @throws ShellDiedException if a shell could not be retrieved (execution failed, access denied) + */ + @SuppressLint("WrongThread") + @NonNull + @AnyThread + public static Threaded get(@NonNull String shell, @Nullable final OnShellOpenResultListener onShellOpenResultListener) throws ShellDiedException { + Threaded threaded = null; + String shellUpper = shell.toUpperCase(Locale.ENGLISH); + + synchronized (Pool.class) { + cleanup(null, false); + + // find instance + ArrayList shells = pool.get(shellUpper); + if (shells != null) { + for (Threaded instance : shells) { + if (!instance.isReserved()) { + threaded = instance; + threaded.setReserved(true); + break; + } + } + } + } + + if (threaded == null) { + // create instance + threaded = newInstance(shell, onShellOpenResultListener, true); + if (!threaded.isRunning()) { + throw new ShellDiedException(); + } else { + if (!(Debug.getSanityChecksEnabledEffective() && Debug.onMainThread())) { + if (!threaded.waitForOpened(null)) { + throw new ShellDiedException(); + } + } + // otherwise failure will be in callbacks + } + synchronized (Pool.class) { + if (!threaded.wasPoolRemoveCalled()) { + if (pool.get(shellUpper) == null) { + pool.put(shellUpper, new ArrayList()); + } + //noinspection ConstantConditions // pool.get(shellUpper) is never null here + pool.get(shellUpper).add(threaded); + } + } + } else { + // shell is already open, but if an OnShellOpenResultListener was passed, call it + if (onShellOpenResultListener != null) { + final Threaded fThreaded = threaded; + threaded.startCallback(); + //noinspection ConstantConditions // handler is never null + threaded.handler.post(new Runnable() { + @Override + public void run() { + try { + onShellOpenResultListener.onOpenResult(true, OnShellOpenResultListener.SHELL_RUNNING); + } finally { + fThreaded.endCallback(); + } + } + }); + } + } + + return threaded; + } + + /** + * @param threaded Shell to return to pool + */ + private static synchronized void releaseReservation(@NonNull Threaded threaded) { + Debug.logPool("releaseReservation"); + threaded.setReserved(false); + cleanup(null, false); + } + + /** + * @param threaded This shell is dead + */ + private static synchronized void removeShell(@NonNull Threaded threaded) { + Debug.logPool("removeShell"); + cleanup(threaded, false); + } + + /** + * Close (as soon as they become idle) all pooled {@link Threaded} shells + */ + @AnyThread + public static synchronized void closeAll() { + cleanup(null, true); + } + + /** + * Create a {@link PoolWrapper} for the given shell command. A returned shell is + * automatically pooled. If the command is based on "su", {@link #getPoolSize()} + * applies, if not, only a single instance is kept + * + * @param shell Shell command, like "sh" or "su" + * @return {@link PoolWrapper} for this shell command + */ + @AnyThread + public static PoolWrapper getWrapper(@NonNull String shell) { + if (shell.toUpperCase(Locale.ENGLISH).equals("SH") && (SH != null)) { + return SH; + } else if (shell.toUpperCase(Locale.ENGLISH).equals("SU") && (SU != null)) { + return SU; + } else { + return new PoolWrapper(shell); + } + } + + /** + * {@link PoolWrapper} for the "sh" shell + */ + public static final PoolWrapper SH = getWrapper("sh"); + + /** + * {@link PoolWrapper} for the "su" (root) shell + */ + public static final PoolWrapper SU = getWrapper("su"); + } + + /** + *

+ * Helper class to prevent {@link Threaded} instances being garbage collected too soon. Not + * reference counted, a single {@link #collect(Threaded)} call clears the protection. + *

+ */ + static class Garbage { + static final ArrayList shells = new ArrayList(); + + @AnyThread + static synchronized void protect(@NonNull Threaded shell) { + if (shells.indexOf(shell) == -1) { + shells.add(shell); + } + } + + @AnyThread + static synchronized void collect(@NonNull Threaded shell) { + if (shells.indexOf(shell) != -1) { + shells.remove(shell); + } + } } } diff --git a/app/src/main/java/eu/chainfire/libsuperuser/StreamGobbler.java b/app/src/main/java/eu/chainfire/libsuperuser/StreamGobbler.java index 4ce6697..0899420 100644 --- a/app/src/main/java/eu/chainfire/libsuperuser/StreamGobbler.java +++ b/app/src/main/java/eu/chainfire/libsuperuser/StreamGobbler.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2012-2014 Jorrit "Chainfire" Jongma + * Copyright (C) 2012-2019 Jorrit "Chainfire" Jongma * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,78 +21,139 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.util.List; +import java.util.Locale; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; /** * Thread utility class continuously reading from an InputStream */ -public class StreamGobbler extends Thread { +@SuppressWarnings({"WeakerAccess"}) +public class StreamGobbler extends Thread { + private static int threadCounter = 0; + private static int incThreadCounter() { + synchronized (StreamGobbler.class) { + int ret = threadCounter; + threadCounter++; + return ret; + } + } + /** * Line callback interface */ - public interface OnLineListener { + public interface OnLineListener { /** *

Line callback

- * + * *

This callback should process the line as quickly as possible. * Delays in this callback may pause the native process or even * result in a deadlock

- * + * * @param line String that was gobbled */ void onLine(String line); } - private String shell = null; - private BufferedReader reader = null; - private List writer = null; - private OnLineListener listener = null; + /** + * Stream closed callback interface + */ + public interface OnStreamClosedListener { + /** + *

Stream closed callback

+ */ + void onStreamClosed(); + } + + @NonNull + private final String shell; + @NonNull + private final InputStream inputStream; + @NonNull + private final BufferedReader reader; + @Nullable + private final List writer; + @Nullable + private final OnLineListener lineListener; + @Nullable + private final OnStreamClosedListener streamClosedListener; + private volatile boolean active = true; + private volatile boolean calledOnClose = false; /** *

StreamGobbler constructor

- * - *

We use this class because shell STDOUT and STDERR should be read as quickly as + * + *

We use this class because shell STDOUT and STDERR should be read as quickly as * possible to prevent a deadlock from occurring, or Process.waitFor() never * returning (as the buffer is full, pausing the native process)

- * + * * @param shell Name of the shell * @param inputStream InputStream to read from - * @param outputList List to write to, or null + * @param outputList {@literal List} to write to, or null */ - public StreamGobbler(String shell, InputStream inputStream, List outputList) { + @AnyThread + public StreamGobbler(@NonNull String shell, @NonNull InputStream inputStream, @Nullable List outputList) { + super("Gobbler#" + incThreadCounter()); this.shell = shell; + this.inputStream = inputStream; reader = new BufferedReader(new InputStreamReader(inputStream)); writer = outputList; + lineListener = null; + streamClosedListener = null; } /** *

StreamGobbler constructor

- * - *

We use this class because shell STDOUT and STDERR should be read as quickly as + * + *

We use this class because shell STDOUT and STDERR should be read as quickly as * possible to prevent a deadlock from occurring, or Process.waitFor() never * returning (as the buffer is full, pausing the native process)

- * + * * @param shell Name of the shell * @param inputStream InputStream to read from * @param onLineListener OnLineListener callback + * @param onStreamClosedListener OnStreamClosedListener callback */ - public StreamGobbler(String shell, InputStream inputStream, OnLineListener onLineListener) { + @AnyThread + public StreamGobbler(@NonNull String shell, @NonNull InputStream inputStream, @Nullable OnLineListener onLineListener, @Nullable OnStreamClosedListener onStreamClosedListener) { + super("Gobbler#" + incThreadCounter()); this.shell = shell; + this.inputStream = inputStream; reader = new BufferedReader(new InputStreamReader(inputStream)); - listener = onLineListener; + lineListener = onLineListener; + streamClosedListener = onStreamClosedListener; + writer = null; } @Override public void run() { // keep reading the InputStream until it ends (or an error occurs) + // optionally pausing when a command is executed that consumes the InputStream itself try { String line; while ((line = reader.readLine()) != null) { - Debug.logOutput(String.format("[%s] %s", shell, line)); + Debug.logOutput(String.format(Locale.ENGLISH, "[%s] %s", shell, line)); if (writer != null) writer.add(line); - if (listener != null) listener.onLine(line); + if (lineListener != null) lineListener.onLine(line); + while (!active) { + synchronized (this) { + try { + this.wait(128); + } catch (InterruptedException e) { + // no action + } + } + } } } catch (IOException e) { // reader probably closed, expected exit condition + if (streamClosedListener != null) { + calledOnClose = true; + streamClosedListener.onStreamClosed(); + } } // make sure our stream is closed and resources will be freed @@ -101,5 +162,96 @@ public class StreamGobbler extends Thread { } catch (IOException e) { // read already closed } + + if (!calledOnClose) { + if (streamClosedListener != null) { + calledOnClose = true; + streamClosedListener.onStreamClosed(); + } + } + } + + /** + *

Resume consuming the input from the stream

+ */ + @AnyThread + public void resumeGobbling() { + if (!active) { + synchronized (this) { + active = true; + this.notifyAll(); + } + } + } + + /** + *

Suspend gobbling, so other code may read from the InputStream instead

+ * + *

This should only be called from the OnLineListener callback!

+ */ + @AnyThread + public void suspendGobbling() { + synchronized (this) { + active = false; + this.notifyAll(); + } + } + + /** + *

Wait for gobbling to be suspended

+ * + *

Obviously this cannot be called from the same thread as {@link #suspendGobbling()}

+ */ + @WorkerThread + public void waitForSuspend() { + synchronized (this) { + while (active) { + try { + this.wait(32); + } catch (InterruptedException e) { + // no action + } + } + } + } + + /** + *

Is gobbling suspended ?

+ * + * @return is gobbling suspended? + */ + @AnyThread + public boolean isSuspended() { + synchronized (this) { + return !active; + } + } + + /** + *

Get current source InputStream

+ * + * @return source InputStream + */ + @NonNull + @AnyThread + public InputStream getInputStream() { + return inputStream; + } + + /** + *

Get current OnLineListener

+ * + * @return OnLineListener + */ + @Nullable + @AnyThread + public OnLineListener getOnLineListener() { + return lineListener; + } + + void conditionalJoin() throws InterruptedException { + if (calledOnClose) return; // deadlock from callback, we're inside exit procedure + if (Thread.currentThread() == this) return; // can't join self + join(); } }