diff --git a/app/src/main/java/com/termux/app/RunCommandService.java b/app/src/main/java/com/termux/app/RunCommandService.java index c18ed3f6..0b206bd8 100644 --- a/app/src/main/java/com/termux/app/RunCommandService.java +++ b/app/src/main/java/com/termux/app/RunCommandService.java @@ -1,5 +1,7 @@ package com.termux.app; +import android.app.Activity; +import android.app.IntentService; import android.app.Notification; import android.app.NotificationManager; import android.app.Service; @@ -104,6 +106,10 @@ import com.termux.models.ExecutionCommand.ExecutionState; * the command. This can add details about the command. 3rd party apps can provide more info * to users for setting up commands. Ideally a url link should be provided that goes into full * details. + * 9. The {@code Parcelable} {@link RUN_COMMAND_SERVICE#EXTRA_PENDING_INTENT} extra containing the + * pending intent with which result of commands should be returned to the caller. The results + * will be sent in the {@link TERMUX_SERVICE#EXTRA_PLUGIN_RESULT_BUNDLE} bundle. This is optional + * and only needed if caller wants the results back. * * * The {@link RUN_COMMAND_SERVICE#EXTRA_COMMAND_PATH} and {@link RUN_COMMAND_SERVICE#EXTRA_WORKDIR} @@ -132,19 +138,23 @@ import com.termux.models.ExecutionCommand.ExecutionState; * https://developer.android.com/training/basics/intents/package-visibility#package-name * * + * Its probably wiser for apps to import the {@link TermuxConstants} class and use the variables + * provided for actions and extras instead of using hardcoded string values. * * Sample code to run command "top" with java: - * Intent intent = new Intent(); - * intent.setClassName("com.termux", "com.termux.app.RunCommandService"); - * intent.setAction("com.termux.RUN_COMMAND"); - * intent.putExtra("com.termux.RUN_COMMAND_PATH", "/data/data/com.termux/files/usr/bin/top"); - * intent.putExtra("com.termux.RUN_COMMAND_ARGUMENTS", new String[]{"-n", "5"}); - * intent.putExtra("com.termux.RUN_COMMAND_WORKDIR", "/data/data/com.termux/files/home"); - * intent.putExtra("com.termux.RUN_COMMAND_BACKGROUND", false); - * intent.putExtra("com.termux.RUN_COMMAND_SESSION_ACTION", "0"); - * startService(intent); + * ``` + * intent.setClassName("com.termux", "com.termux.app.RunCommandService"); + * intent.setAction("com.termux.RUN_COMMAND"); + * intent.putExtra("com.termux.RUN_COMMAND_PATH", "/data/data/com.termux/files/usr/bin/top"); + * intent.putExtra("com.termux.RUN_COMMAND_ARGUMENTS", new String[]{"-n", "5"}); + * intent.putExtra("com.termux.RUN_COMMAND_WORKDIR", "/data/data/com.termux/files/home"); + * intent.putExtra("com.termux.RUN_COMMAND_BACKGROUND", false); + * intent.putExtra("com.termux.RUN_COMMAND_SESSION_ACTION", "0"); + * startService(intent); + * ``` * * Sample code to run command "top" with "am startservice" command: + * ``` * am startservice --user 0 -n com.termux/com.termux.app.RunCommandService \ * -a com.termux.RUN_COMMAND \ * --es com.termux.RUN_COMMAND_PATH '/data/data/com.termux/files/usr/bin/top' \ @@ -152,6 +162,111 @@ import com.termux.models.ExecutionCommand.ExecutionState; * --es com.termux.RUN_COMMAND_WORKDIR '/data/data/com.termux/files/home' \ * --ez com.termux.RUN_COMMAND_BACKGROUND 'false' \ * --es com.termux.RUN_COMMAND_SESSION_ACTION '0' + * + * + * + * + * The {@link RUN_COMMAND_SERVICE#ACTION_RUN_COMMAND} intent returns the following extras + * in the {@link TERMUX_SERVICE#EXTRA_PLUGIN_RESULT_BUNDLE} bundle if a pending intent is sent by the + * called in {@code Parcelable} {@link RUN_COMMAND_SERVICE#EXTRA_PENDING_INTENT} extra: + * + * For foreground commands ({@link RUN_COMMAND_SERVICE#EXTRA_BACKGROUND} is `false`): + * - {@link TERMUX_SERVICE#EXTRA_PLUGIN_RESULT_BUNDLE_STDOUT} will contain session transcript. + * - {@link TERMUX_SERVICE#EXTRA_PLUGIN_RESULT_BUNDLE_STDERR} will be null since its not used. + * - {@link TERMUX_SERVICE#EXTRA_PLUGIN_RESULT_BUNDLE_EXIT_CODE} will contain exit code of session. + + * For background commands ({@link RUN_COMMAND_SERVICE#EXTRA_BACKGROUND} is `true`): + * - {@link TERMUX_SERVICE#EXTRA_PLUGIN_RESULT_BUNDLE_STDOUT} will contain stdout of commands. + * - {@link TERMUX_SERVICE#EXTRA_PLUGIN_RESULT_BUNDLE_STDERR} will contain stderr of commands. + * - {@link TERMUX_SERVICE#EXTRA_PLUGIN_RESULT_BUNDLE_EXIT_CODE} will contain exit code of command. + * + * The {@link TERMUX_SERVICE#EXTRA_PLUGIN_RESULT_BUNDLE_STDOUT_ORIGINAL_LENGTH} and + * {@link TERMUX_SERVICE#EXTRA_PLUGIN_RESULT_BUNDLE_STDERR_ORIGINAL_LENGTH} will contain + * the original length of stdout and stderr respectively. This is useful to detect cases where + * stdout and stderr was too large to be sent back via an intent, otherwise + * + * The internal errors raised by termux outside the shell will be sent in the the + * {@link TERMUX_SERVICE#EXTRA_PLUGIN_RESULT_BUNDLE_ERR} and {@link TERMUX_SERVICE#EXTRA_PLUGIN_RESULT_BUNDLE_ERRMSG} + * extras. These will contain errors like if starting a termux command failed or if the user manually + * exited the termux sessions or android killed the termux service before the commands had finished executing. + * The err value will be {@link Activity#RESULT_OK}(-1) if no internal errors are raised. + * + * Note that if stdout or stderr are too large in length, then a {@link android.os.TransactionTooLargeException} + * exception will be raised when the pending intent is sent back containing the results, But it cannot + * be caught by the intent sender and intent will silently fail with logcat entries for the exception + * raised internally by android os components. To prevent this, the stdout and stderr sent + * back will be truncated from the start to max 100KB combined. The original length of stdout and + * stderr will be provided in + * {@link TERMUX_SERVICE#EXTRA_PLUGIN_RESULT_BUNDLE_STDOUT_ORIGINAL_LENGTH} and + * {@link TERMUX_SERVICE#EXTRA_PLUGIN_RESULT_BUNDLE_STDERR_ORIGINAL_LENGTH} extras respectively, so + * that the caller can check if either of them were truncated. The errmsg will also be truncated + * from end to max 25KB to preserve start of stacktraces. + * + * + * + * If your app (not shell) wants to receive termux session command results, then put the + * pending intent for your app like for an {@link IntentService} in the "com.termux.RUN_COMMAND_PENDING_INTENT" + * extra. + * ``` + * // Create intent for your IntentService class + * Intent pluginResultsServiceIntent = new Intent(MainActivity.this, PluginResultsService.class); + * // Create PendingIntent that will be used by termux service to send result of commands back to PluginResultsService + * PendingIntent pendingIntent = PendingIntent.getService(context, 1, pluginResultsServiceIntent, PendingIntent.FLAG_ONE_SHOT); + * intent.putExtra("com.termux.RUN_COMMAND_PENDING_INTENT", pendingIntent); + * ``` + * + * + * Declare `PluginResultsService` entry in AndroidManifest.xml + * ``` + * + * ``` + * + * + * Define the `PluginResultsService` class + * ``` + * public class PluginResultsService extends IntentService { + * + * public static final String PLUGIN_SERVICE_LABEL = "PluginResultsService"; + * + * private static final String LOG_TAG = "PluginResultsService"; + * + * public PluginResultsService(){ + * super(PLUGIN_SERVICE_LABEL); + * } + * + * @Override + * protected void onHandleIntent(@Nullable Intent intent) { + * if (intent == null) return; + * + * if(intent.getComponent() != null) + * Log.d(LOG_TAG, PLUGIN_SERVICE_LABEL + " received execution result from " + intent.getComponent().toString()); + * + * + * final Bundle resultBundle = intent.getBundleExtra("result"); + * if (resultBundle == null) { + * Log.e(LOG_TAG, "The intent does not contain the result bundle at the \"result\" key."); + * return; + * } + * + * Log.d(LOG_TAG, "stdout:\n```\n" + resultBundle.getString("stdout", "") + "\n```\n" + + * "stdout_original_length: `" + resultBundle.getString("stdout_original_length") + "`\n" + + * "stderr:\n```\n" + resultBundle.getString("stderr", "") + "\n```\n" + + * "stderr_original_length: `" + resultBundle.getString("stderr_original_length") + "`\n" + + * "exitCode: `" + resultBundle.getInt("exitCode") + "`\n" + + * "errCode: `" + resultBundle.getInt("err") + "`\n" + + * "errmsg: `" + resultBundle.getString("errmsg", "") + "`"); + * } + * + * } + *``` + * + * + * + * + * + * A service that receives {@link RUN_COMMAND_SERVICE#ACTION_RUN_COMMAND} intent from third party apps and + * plugins that contains info on command execution and forwards the extras to {@link TermuxService} + * for the actual execution. */ public class RunCommandService extends Service { @@ -206,6 +321,9 @@ public class RunCommandService extends Service { executionCommand.commandLabel = TextDataUtils.getDefaultIfNull(intent.getStringExtra(RUN_COMMAND_SERVICE.EXTRA_COMMAND_LABEL), "RUN_COMMAND Execution Intent Command"); executionCommand.commandDescription = intent.getStringExtra(RUN_COMMAND_SERVICE.EXTRA_COMMAND_DESCRIPTION); executionCommand.commandHelp = intent.getStringExtra(RUN_COMMAND_SERVICE.EXTRA_COMMAND_HELP); + executionCommand.pluginPendingIntent = intent.getParcelableExtra(RUN_COMMAND_SERVICE.EXTRA_PENDING_INTENT); + + if(!executionCommand.setState(ExecutionState.PRE_EXECUTION)) return Service.START_NOT_STICKY; @@ -286,6 +404,7 @@ public class RunCommandService extends Service { execIntent.putExtra(TERMUX_SERVICE.EXTRA_COMMAND_DESCRIPTION, executionCommand.commandDescription); execIntent.putExtra(TERMUX_SERVICE.EXTRA_COMMAND_HELP, executionCommand.commandHelp); execIntent.putExtra(TERMUX_SERVICE.EXTRA_PLUGIN_API_HELP, executionCommand.pluginAPIHelp); + execIntent.putExtra(TERMUX_SERVICE.EXTRA_PENDING_INTENT, executionCommand.pluginPendingIntent); // Start TERMUX_SERVICE and pass it execution intent if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { diff --git a/app/src/main/java/com/termux/app/TermuxService.java b/app/src/main/java/com/termux/app/TermuxService.java index f81d26dc..5277a4e6 100644 --- a/app/src/main/java/com/termux/app/TermuxService.java +++ b/app/src/main/java/com/termux/app/TermuxService.java @@ -216,9 +216,22 @@ public final class TermuxService extends Service { /** Finish all termux sessions by sending SIGKILL to their shells. */ private synchronized void finishAllTermuxSessions() { + ExecutionCommand executionCommand; + // TODO: Should SIGKILL also be send to background processes maintained by mTermuxTasks? - for (int i = 0; i < mTermuxSessions.size(); i++) - mTermuxSessions.get(i).getTerminalSession().finishIfRunning(); + for (int i = 0; i < mTermuxSessions.size(); i++) { + TermuxSession termuxSession = mTermuxSessions.get(i); + executionCommand = termuxSession.getExecutionCommand(); + + // If the execution command was started for a plugin and is currently executing, then notify the callers + if(executionCommand.isPluginExecutionCommand && executionCommand.isExecuting()) { + if (executionCommand.setStateFailed(ExecutionCommand.RESULT_CODE_FAILED, this.getString(R.string.error_sending_sigkill_to_process), null)) { + TermuxSession.processTermuxSessionResult(this, termuxSession, null); + } + } + + termuxSession.getTerminalSession().finishIfRunning(); + } } @@ -360,7 +373,7 @@ public final class TermuxService extends Service { TermuxTask newTermuxTask = TermuxTask.create(this, executionCommand); if (newTermuxTask == null) { - // Logger.logError(LOG_TAG, "Failed to execute new termux task command for:\n" + executionCommand.toString()); + Logger.logError(LOG_TAG, "Failed to execute new termux task command for:\n" + executionCommand.getCommandIdAndLabelLogString()); return null; }; @@ -428,9 +441,9 @@ public final class TermuxService extends Service { if(Logger.getLogLevel() >= Logger.LOG_LEVEL_VERBOSE) Logger.logVerbose(LOG_TAG, executionCommand.toString()); - TermuxSession newTermuxSession = TermuxSession.create(executionCommand, getTermuxSessionClient(), sessionName); + TermuxSession newTermuxSession = TermuxSession.create(this, executionCommand, getTermuxSessionClient(), sessionName); if (newTermuxSession == null) { - Logger.logError(LOG_TAG, "Failed to execute new termux session command for:\n" + executionCommand.toString()); + Logger.logError(LOG_TAG, "Failed to execute new termux session command for:\n" + executionCommand.getCommandIdAndLabelLogString()); return null; }; @@ -455,7 +468,11 @@ public final class TermuxService extends Service { TermuxSession termuxSession = mTermuxSessions.get(index); if (termuxSession.getExecutionCommand().setState(ExecutionState.EXECUTED)) { - ; + // If the execution command was started for a plugin and is currently executing, then process the result + if(termuxSession.getExecutionCommand().isPluginExecutionCommand) + TermuxSession.processTermuxSessionResult(this, termuxSession, null); + else + termuxSession.getExecutionCommand().setState(ExecutionState.SUCCESS); } mTermuxSessions.remove(termuxSession); diff --git a/app/src/main/java/com/termux/app/terminal/TermuxSession.java b/app/src/main/java/com/termux/app/terminal/TermuxSession.java index 3af9f73d..e085cf5e 100644 --- a/app/src/main/java/com/termux/app/terminal/TermuxSession.java +++ b/app/src/main/java/com/termux/app/terminal/TermuxSession.java @@ -1,7 +1,12 @@ package com.termux.app.terminal; +import androidx.annotation.NonNull; + +import com.termux.R; import com.termux.app.TermuxConstants; +import com.termux.app.TermuxService; import com.termux.app.utils.Logger; +import com.termux.app.utils.PluginUtils; import com.termux.app.utils.ShellUtils; import com.termux.models.ExecutionCommand; import com.termux.terminal.TerminalSession; @@ -25,14 +30,12 @@ public class TermuxSession { this.mExecutionCommand = executionCommand; } - public static TermuxSession create(ExecutionCommand executionCommand, TermuxSessionClientBase termuxSessionClient, String sessionName) { - TermuxConstants.TERMUX_HOME_DIR.mkdirs(); - + public static TermuxSession create(@NonNull final TermuxService service, @NonNull ExecutionCommand executionCommand, @NonNull TermuxSessionClientBase termuxSessionClient, String sessionName) { if (executionCommand.workingDirectory == null || executionCommand.workingDirectory.isEmpty()) executionCommand.workingDirectory = TermuxConstants.TERMUX_HOME_DIR_PATH; String[] environment = ShellUtils.buildEnvironment(executionCommand.isFailsafe, executionCommand.workingDirectory); - boolean isLoginShell = false; + boolean isLoginShell = false; if (executionCommand.executable == null) { if (!executionCommand.isFailsafe) { for (String shellBinary : new String[]{"login", "bash", "zsh"}) { @@ -62,8 +65,13 @@ public class TermuxSession { executionCommand.arguments = arguments; - if(!executionCommand.setState(ExecutionCommand.ExecutionState.EXECUTING)) + if(!executionCommand.setState(ExecutionCommand.ExecutionState.EXECUTING)) { + executionCommand.setStateFailed(ExecutionCommand.RESULT_CODE_FAILED, service.getString(R.string.error_failed_to_execute_termux_session_command, executionCommand.getCommandIdAndLabelLogString()), null); + if(executionCommand.isPluginExecutionCommand) { + TermuxSession.processTermuxSessionResult(service, null, executionCommand); + } return null; + } Logger.logDebug(LOG_TAG, executionCommand.toString()); @@ -76,6 +84,26 @@ public class TermuxSession { return new TermuxSession(terminalSession, executionCommand); } + public static void processTermuxSessionResult(@NonNull final TermuxService service, final TermuxSession termuxSession, ExecutionCommand executionCommand) { + TerminalSession terminalSession = null; + if(termuxSession != null) { + executionCommand = termuxSession.mExecutionCommand; + terminalSession = termuxSession.mTerminalSession; + } + + if(executionCommand == null) return; + + if(!executionCommand.isPluginExecutionCommand) return; + + if(terminalSession != null && !terminalSession.isRunning() && executionCommand.hasExecuted() && !executionCommand.isStateFailed()) { + executionCommand.stdout = terminalSession.getEmulator().getScreen().getTranscriptTextWithFullLinesJoined(); + executionCommand.stderr = null; + executionCommand.exitCode = terminalSession.getExitStatus(); + } + + PluginUtils.processPluginExecutionCommandResult(service.getApplicationContext(), LOG_TAG, executionCommand); + } + public TerminalSession getTerminalSession() { return mTerminalSession; } diff --git a/app/src/main/java/com/termux/app/terminal/TermuxTask.java b/app/src/main/java/com/termux/app/terminal/TermuxTask.java index 547066b7..c6e505e3 100644 --- a/app/src/main/java/com/termux/app/terminal/TermuxTask.java +++ b/app/src/main/java/com/termux/app/terminal/TermuxTask.java @@ -2,6 +2,7 @@ package com.termux.app.terminal; import androidx.annotation.NonNull; +import com.termux.R; import com.termux.app.TermuxConstants; import com.termux.app.TermuxService; import com.termux.app.utils.Logger; @@ -53,8 +54,8 @@ public final class TermuxTask { try { process = Runtime.getRuntime().exec(commandArray, env, new File(executionCommand.workingDirectory)); } catch (IOException e) { - executionCommand.setStateFailed(ExecutionCommand.RESULT_CODE_FAILED, "Failed to run \"" + executionCommand.commandLabel + "\" background task", e); - TermuxTask.processTermuxTaskResult(service, null, executionCommand); + executionCommand.setStateFailed(ExecutionCommand.RESULT_CODE_FAILED, service.getString(R.string.error_failed_to_execute_termux_task_command, executionCommand.getCommandIdAndLabelLogString()), e); + TermuxSession.processTermuxSessionResult(service, null, executionCommand); return null; } @@ -117,7 +118,7 @@ public final class TermuxTask { return termuxTask; } - public static void processTermuxTaskResult(final TermuxService service, final TermuxTask termuxTask, ExecutionCommand executionCommand) { + public static void processTermuxTaskResult(@NonNull final TermuxService service, final TermuxTask termuxTask, ExecutionCommand executionCommand) { if(termuxTask != null) executionCommand = termuxTask.mExecutionCommand; @@ -125,7 +126,7 @@ public final class TermuxTask { PluginUtils.processPluginExecutionCommandResult(service.getApplicationContext(), LOG_TAG, executionCommand); - if(termuxTask != null && service != null) + if(termuxTask != null) service.onTermuxTaskExited(termuxTask); } diff --git a/app/src/main/java/com/termux/models/ExecutionCommand.java b/app/src/main/java/com/termux/models/ExecutionCommand.java index 0205f018..8d4b9b33 100644 --- a/app/src/main/java/com/termux/models/ExecutionCommand.java +++ b/app/src/main/java/com/termux/models/ExecutionCommand.java @@ -6,6 +6,7 @@ import android.net.Uri; import androidx.annotation.NonNull; +import com.termux.app.TermuxConstants.TERMUX_APP.TERMUX_SERVICE; import com.termux.app.utils.Logger; import com.termux.app.utils.MarkdownUtils; import com.termux.app.utils.TextDataUtils; @@ -101,8 +102,9 @@ public class ExecutionCommand { public String pluginAPIHelp; - /** Defines if {@link ExecutionCommand} was started because of an external plugin request or from - * within Termux app itself. */ + /** Defines if {@link ExecutionCommand} was started because of an external plugin request + * like {@link TERMUX_SERVICE#ACTION_SERVICE_EXECUTE} intent or from within Termux app itself. + */ public boolean isPluginExecutionCommand; /** Defines {@link PendingIntent} that should be sent if an external plugin requested the execution. */ public PendingIntent pluginPendingIntent; @@ -503,7 +505,7 @@ public class ExecutionCommand { - public boolean setState(ExecutionState newState) { + public synchronized boolean setState(ExecutionState newState) { // The state transition cannot go back or change if already at {@link ExecutionState#SUCCESS} if(newState.getValue() < currentState.getValue() || currentState == ExecutionState.SUCCESS) { Logger.logError("Invalid "+ getCommandIdAndLabelLogString() + " state transition from \"" + currentState.getName() + "\" to " + "\"" + newState.getName() + "\""); @@ -520,7 +522,7 @@ public class ExecutionCommand { return true; } - public boolean setStateFailed(int errCode, String errmsg, Throwable throwable) { + public synchronized boolean setStateFailed(int errCode, String errmsg, Throwable throwable) { if (errCode > RESULT_CODE_OK) { this.errCode = errCode; } else { @@ -542,7 +544,7 @@ public class ExecutionCommand { return true; } - public boolean isStateFailed() { + public synchronized boolean isStateFailed() { if(currentState != ExecutionState.FAILED) return false; @@ -554,8 +556,12 @@ public class ExecutionCommand { } } - public boolean hasExecuted() { + public synchronized boolean hasExecuted() { return currentState.getValue() >= ExecutionState.EXECUTED.getValue(); } + public synchronized boolean isExecuting() { + return currentState == ExecutionState.EXECUTING; + } + } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 967650ea..4f290d51 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -96,6 +96,9 @@ Executable Absolute Path: \"%1$s\" Working Directory Absolute Path: \"%1$s\" + Sending SIGKILL to process on user request or because android is killing the service + "Failed to execute \"%1$s\" termux session command" + "Failed to execute \"%1$s\" termux task command"