Add support for sending back background and foreground command results for RUN_COMMAND intent and foreground command results for Termux:Tasker

Previously, termux only supported getting result of BACKGROUND commands back if they were started via Termux:Tasker plugin. Getting back result of foreground commands was not possible with any way.

Now with RUN_COMMAND intent or Termux:Tasker, the third party apps and users can get the foreground command results as well. Note that by "foreground results" we only mean the session transcript. The session transcript will contain both stdout and stderr combined, basically anything sent to the the pseudo terminal /dev/pts, including PS1 prefixes for interactive sessions. Getting separate stdout and stderr can currently only be done with background commands.

Moreover, with RUN_COMMAND intent, third party apps and users can get the background commands results as well. This means separate extras for stdout and stderr.

The exit code will also be returned for either case.

### RUN_COMMAND intent

The result extras are returned in the TERMUX_SERVICE.EXTRA_PLUGIN_RESULT_BUNDLE bundle via the pending intent received.

The RUN_COMMAND_SERVICE.EXTRA_PENDING_INTENT extra can be used to send the pending intent with which termux should return the result bundle. The pending intent can be received back by the app with an IntentService. Check RunCommandService for reference implementation.

For foreground commands (RUN_COMMAND_SERVICE.EXTRA_BACKGROUND is false):
- EXTRA_PLUGIN_RESULT_BUNDLE_STDOUT will contain session transcript.
- EXTRA_PLUGIN_RESULT_BUNDLE_STDERR will be null since its not used.
- EXTRA_PLUGIN_RESULT_BUNDLE_EXIT_CODE will contain exit code of session.

For background commands (RUN_COMMAND_SERVICE.EXTRA_BACKGROUND is true):
- EXTRA_PLUGIN_RESULT_BUNDLE_STDOUT will contain stdout of commands.
- EXTRA_PLUGIN_RESULT_BUNDLE_STDERR will contain stderr of commands.
- EXTRA_PLUGIN_RESULT_BUNDLE_EXIT_CODE will contain exit code of command.

The internal errors raised by termux outside the shell will be sent in the the EXTRA_PLUGIN_RESULT_BUNDLE_ERR and 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 Activity.RESULT_OK(-1) if no internal errors are raised.

The stdout and stderr will be truncated from the start to max 100KB combined and errmsg will also be truncated from end to max 25KB. This is necessary to prevent TransactionTooLargeException exceptions from being raised if stdout or stderr are too large in length. The original length of stdout and stderr will be provided in EXTRA_PLUGIN_RESULT_BUNDLE_STDOUT_ORIGINAL_LENGTH and EXTRA_PLUGIN_RESULT_BUNDLE_STDERR_ORIGINAL_LENGTH extras respectively, so that the caller can check if either of them were truncated.

### Termux:Tasker

Support for Termux:Tasker for getting back result of foreground commands will require an update to it since it currently immediately returns control to plugin host app like Tasker without waiting if a foreground command is to be executed.
This commit is contained in:
agnostic-apollo
2021-03-25 23:05:55 +05:00
parent 2cc6285a81
commit a2209ddd5e
6 changed files with 204 additions and 30 deletions

View File

@@ -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
* ```
* <service android:name=".PluginResultsService" />
* ```
*
*
* 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) {

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -96,6 +96,9 @@
<!-- Termux Execution Commands -->
<string name="msg_executable_absolute_path">Executable Absolute Path: \"%1$s\"</string>
<string name="msg_working_directory_absolute_path">Working Directory Absolute Path: \"%1$s\"</string>
<string name="error_sending_sigkill_to_process">Sending SIGKILL to process on user request or because android is killing the service</string>
<string name="error_failed_to_execute_termux_session_command">"Failed to execute \"%1$s\" termux session command"</string>
<string name="error_failed_to_execute_termux_task_command">"Failed to execute \"%1$s\" termux task command"</string>