/** * Keep a persistent root shell running in the background * * Copyright (C) 2013 Kevin Cernekee * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. * * @author Kevin Cernekee * @version 1.0 */ package dev.ukanth.ufirewall.service; import android.app.Service; import android.content.Context; import android.content.Intent; import android.os.Binder; import android.os.Handler; import android.os.IBinder; import android.os.Looper; import android.widget.Toast; import java.util.ArrayList; import java.util.Date; import java.util.LinkedList; import java.util.List; import java.util.ListIterator; import java.util.NoSuchElementException; import dev.ukanth.ufirewall.log.Log; import eu.chainfire.libsuperuser.Debug; import eu.chainfire.libsuperuser.Shell; public class RootShell extends Service { public static final String TAG = "AFWall"; /* write command completion times to logcat */ private static final boolean enableProfiling = false; private static Shell.Interactive rootSession; private static Context mContext; private final static int STATE_INIT = 0; private final static int STATE_READY = 1; private final static int STATE_BUSY = 2; private final static int STATE_FAILED = 3; private static int rootState = STATE_INIT; private final static int MAX_RETRIES = 10; private static LinkedList<RootCommand> waitQueue = new LinkedList<RootCommand>(); public final static int EXIT_NO_ROOT_ACCESS = -1; public final static int NO_TOAST = -1; public static class RootCommand { private List<String> script; private Callback cb = null; private int successToast = NO_TOAST; private int failureToast = NO_TOAST; private boolean reopenShell = false; private int retryExitCode = -1; private int commandIndex; private boolean ignoreExitCode; private Date startTime; private int retryCount; public StringBuilder res; public String lastCommand; public StringBuilder lastCommandResult; public int exitCode; public boolean done = false; private boolean startCheck = false; public boolean isStartCheck() { return startCheck; } public RootCommand setStartCheck(boolean startCheck) { this.startCheck = startCheck; return this; } public static abstract class Callback { /** * Optional user-specified callback */ public abstract void cbFunc(RootCommand state); } /** * Set callback to run after command completion * * @param cb Callback object, with cbFunc() populated * @return RootCommand builder object */ public RootCommand setCallback(Callback cb) { this.cb = cb; return this; } /** * Tell RootShell to display a toast message on success * * @param resId Resource ID of the toast string * @return RootCommand builder object */ public RootCommand setSuccessToast(int resId) { this.successToast = resId; return this; } /** * Tell RootShell to display a toast message on failure * * @param resId Resource ID of the toast string * @return RootCommand builder object */ public RootCommand setFailureToast(int resId) { this.failureToast = resId; return this; } /** * Tell RootShell whether or not it should try to open a new root shell if the last attempt * died. To avoid "thrashing" it might be best to only try this in response to a user * request * * @param reopenShell true to attempt reopening a failed shell * @return RootCommand builder object */ public RootCommand setReopenShell(boolean reopenShell) { this.reopenShell = reopenShell; return this; } /** * Capture the command output in this.res * * @param enableLog true to enable logging * @return RootCommand builder object */ public RootCommand setLogging(boolean enableLog) { if (enableLog) { this.res = new StringBuilder(); } else { this.res = null; } return this; } /** * Retry a failed command on a specific exit code * * @param retryExitCode code that indicates a transient failure * @return RootCommand builder object */ public RootCommand setRetryExitCode(int retryExitCode) { this.retryExitCode = retryExitCode; return this; } /** * Run a series of commands as root; call cb.cbFunc() when complete * * @param ctx Context object used to create toasts * @param script List of commands to run as root */ public final void run(Context ctx, List<String> script) { RootShell.runScriptAsRoot(ctx, script, this); } /** * Run a single command as root; call cb.cbFunc() when complete * * @param ctx Context object used to create toasts * @param cmd Command to run as root */ public final void run(Context ctx, String cmd) { List<String> script = new ArrayList<String>(); script.add(cmd); RootShell.runScriptAsRoot(ctx, script, this); } } private static void complete(final RootCommand state, int exitCode) { if (enableProfiling) { Log.d(TAG, "RootShell: " + state.script.size() + " commands completed in " + (new Date().getTime() - state.startTime.getTime()) + " ms"); } state.exitCode = exitCode; state.done = true; if (state.cb != null) { state.cb.cbFunc(state); } if (exitCode == 0 && state.successToast != NO_TOAST) { showToastUIThread(mContext.getString(state.successToast)); } else if (exitCode != 0 && state.failureToast != NO_TOAST) { showToastUIThread(mContext.getString(state.failureToast)); } } private static void runNextSubmission() { do { RootCommand state; try { state = waitQueue.remove(); } catch (NoSuchElementException e) { // nothing left to do if (rootState == STATE_BUSY) { rootState = STATE_READY; } break; } if (enableProfiling) { state.startTime = new Date(); } if (rootState == STATE_FAILED) { // if we don't have root, abort all queued commands complete(state, EXIT_NO_ROOT_ACCESS); continue; } else if (rootState == STATE_READY) { rootState = STATE_BUSY; submitNextCommand(state); } } while (false); } private static void submitNextCommand(final RootCommand state) { String s = state.script.get(state.commandIndex); if(s != null) { if (s.startsWith("#NOCHK# ")) { s = s.replaceFirst("#NOCHK# ", ""); state.ignoreExitCode = true; } else { state.ignoreExitCode = false; } state.lastCommand = s; state.lastCommandResult = new StringBuilder(); rootSession.addCommand(s, 0, new Shell.OnCommandResultListener() { @Override public void onCommandResult(int commandCode, int exitCode, List<String> output) { if(output != null) { ListIterator<String> iter = output.listIterator(); while (iter.hasNext()) { String line = iter.next(); if (!line.equals("")) { if (state.res != null) { state.res.append(line + "\n"); } state.lastCommandResult.append(line + "\n"); } } } if (exitCode >= 0 && exitCode == state.retryExitCode && state.retryCount < MAX_RETRIES) { state.retryCount++; Log.d(TAG, "command '" + state.lastCommand + "' exited with status " + exitCode + ", retrying (attempt " + state.retryCount + "/" + MAX_RETRIES + ")"); submitNextCommand(state); return; } state.commandIndex++; state.retryCount = 0; boolean errorExit = exitCode != 0 && !state.ignoreExitCode; if (state.commandIndex >= state.script.size() || errorExit) { complete(state, exitCode); if (exitCode < 0) { rootState = STATE_FAILED; Log.e(TAG, "libsuperuser error " + exitCode + " on command '" + state.lastCommand + "'"); } else { if (errorExit) { Log.i(TAG, "command '" + state.lastCommand + "' exited with status " + exitCode + "\nOutput:\n" + state.lastCommandResult); } rootState = STATE_READY; } runNextSubmission(); } else { submitNextCommand(state); } } }); } } private static void setupLogging() { Debug.setDebug(true); Debug.setLogTypeEnabled(Debug.LOG_ALL, false); Debug.setLogTypeEnabled(Debug.LOG_GENERAL, true); Debug.setSanityChecksEnabled(true); Debug.setOnLogListener(new Debug.OnLogListener() { @Override public void onLog(int type, String typeIndicator, String message) { Log.i(TAG, "[libsuperuser] " + message); } }); } private static void startShellInBackground() { Log.d(TAG, "Starting root shell..."); setupLogging(); rootSession = new Shell.Builder(). useSU(). setWantSTDERR(true). setWatchdogTimeout(5). open(new Shell.OnCommandResultListener() { public void onCommandResult(int commandCode, int exitCode, List<String> output) { if (exitCode < 0) { Log.e(TAG, "Can't open root shell: exitCode " + exitCode); rootState = STATE_FAILED; } else { Log.d(TAG, "Root shell is open"); rootState = STATE_READY; } runNextSubmission(); } }); } private static void runScriptAsRoot(Context ctx, List<String> script, RootCommand state) { state.script = script; state.commandIndex = 0; state.retryCount = 0; if (mContext == null) { mContext = ctx.getApplicationContext(); } waitQueue.add(state); if (rootState == STATE_INIT || (rootState == STATE_FAILED && state.reopenShell)) { rootState = STATE_BUSY; startShellInBackground(); Intent intent = new Intent(ctx, RootShell.class); ctx.startService(intent); } else if (rootState != STATE_BUSY) { runNextSubmission(); } } private final IBinder mBinder = new Binder(); @Override public IBinder onBind(Intent intent) { return mBinder; } private static void showToastUIThread(final String msg) { try { Thread thread = new Thread() { public void run() { Looper.prepare(); final Handler handler = new Handler(); handler.postDelayed(new Runnable() { @Override public void run() { Toast.makeText(mContext,msg,Toast.LENGTH_LONG).show(); handler.removeCallbacks(this); Looper.myLooper().quit(); } }, 2000); Looper.loop(); } }; thread.start(); }catch(Exception e) {} } }