diff --git a/app/src/main/java/com/termux/app/RunCommandService.java b/app/src/main/java/com/termux/app/RunCommandService.java index ff3afb08..aac26428 100644 --- a/app/src/main/java/com/termux/app/RunCommandService.java +++ b/app/src/main/java/com/termux/app/RunCommandService.java @@ -18,7 +18,7 @@ import com.termux.shared.logger.Logger; import com.termux.shared.notification.NotificationUtils; import com.termux.app.utils.PluginUtils; import com.termux.shared.data.DataUtils; -import com.termux.app.models.ExecutionCommand; +import com.termux.shared.models.ExecutionCommand; /** * A service that receives {@link RUN_COMMAND_SERVICE#ACTION_RUN_COMMAND} intent from third party apps and diff --git a/app/src/main/java/com/termux/app/TermuxService.java b/app/src/main/java/com/termux/app/TermuxService.java index e9d47799..e158c39d 100644 --- a/app/src/main/java/com/termux/app/TermuxService.java +++ b/app/src/main/java/com/termux/app/TermuxService.java @@ -20,21 +20,21 @@ import android.provider.Settings; import android.widget.ArrayAdapter; import com.termux.R; +import com.termux.app.terminal.TermuxSessionClient; +import com.termux.app.utils.PluginUtils; import com.termux.shared.termux.TermuxConstants; import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_ACTIVITY; import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_SERVICE; import com.termux.shared.settings.preferences.TermuxAppSharedPreferences; -import com.termux.app.terminal.TermuxSession; -import com.termux.app.terminal.TermuxSessionClient; -import com.termux.app.terminal.TermuxSessionClientBase; +import com.termux.shared.shell.TermuxSession; +import com.termux.shared.shell.TermuxSessionClientBase; import com.termux.shared.logger.Logger; import com.termux.shared.notification.NotificationUtils; import com.termux.shared.packages.PermissionUtils; import com.termux.shared.shell.ShellUtils; import com.termux.shared.data.DataUtils; -import com.termux.app.models.ExecutionCommand; -import com.termux.app.models.ExecutionCommand.ExecutionState; -import com.termux.app.terminal.TermuxTask; +import com.termux.shared.models.ExecutionCommand; +import com.termux.shared.shell.TermuxTask; import com.termux.terminal.TerminalEmulator; import com.termux.terminal.TerminalSession; import com.termux.terminal.TerminalSessionClient; @@ -45,9 +45,10 @@ import java.util.List; import javax.annotation.Nullable; /** - * A service holding a list of termux sessions, {@link #mTermuxSessions}, showing a foreground notification while - * running so that it is not terminated. The user interacts with the session through {@link TermuxActivity}, but this - * service may outlive the activity when the user or the system disposes of the activity. In that case the user may + * A service holding a list of {@link TermuxSession} in {@link #mTermuxSessions} and background {@link TermuxTask} + * in {@link #mTermuxTasks}, showing a foreground notification while running so that it is not terminated. + * The user interacts with the session through {@link TermuxActivity}, but this service may outlive + * the activity when the user or the system disposes of the activity. In that case the user may * restart {@link TermuxActivity} later to yet again access the sessions. *

* In order to keep both terminal sessions and spawned processes (who may outlive the terminal sessions) alive as long @@ -56,7 +57,7 @@ import javax.annotation.Nullable; * Optionally may hold a wake and a wifi lock, in which case that is shown in the notification - see * {@link #buildNotification()}. */ -public final class TermuxService extends Service { +public final class TermuxService extends Service implements TermuxTask.TermuxTaskClient, TermuxSession.TermuxSessionClient { private static int EXECUTION_ID = 1000; @@ -70,7 +71,7 @@ public final class TermuxService extends Service { private final Handler mHandler = new Handler(); /** - * The foreground termux sessions which this service manages. + * The foreground TermuxSessions which this service manages. * Note that this list is observed by {@link TermuxActivity#mTermuxSessionListViewController}, * so any changes must be made on the UI thread and followed by a call to * {@link ArrayAdapter#notifyDataSetChanged()} }. @@ -78,15 +79,20 @@ public final class TermuxService extends Service { final List mTermuxSessions = new ArrayList<>(); /** - * The background termux tasks which this service manages. + * The background TermuxTasks which this service manages. */ final List mTermuxTasks = new ArrayList<>(); + /** + * The pending plugin ExecutionCommands that have yet to be processed by this service. + */ + final List mPendingPluginExecutionCommands = new ArrayList<>(); + /** The full implementation of the {@link TerminalSessionClient} interface to be used by {@link TerminalSession} * that holds activity references for activity related functions. * Note that the service may often outlive the activity, so need to clear this reference. */ - TermuxSessionClient mTermuxSessionClient; + com.termux.app.terminal.TermuxSessionClient mTermuxSessionClient; /** The basic implementation of the {@link TerminalSessionClient} interface to be used by {@link TerminalSession} * that does not hold activity references. @@ -154,7 +160,8 @@ public final class TermuxService extends Service { ShellUtils.clearTermuxTMPDIR(this); actionReleaseWakeLock(false); - finishAllTermuxSessions(); + if (!mWantsToStop) + killAllTermuxExecutionCommands(); runStopForeground(); } @@ -197,34 +204,77 @@ public final class TermuxService extends Service { /** Process action to stop service. */ private void actionStopService() { mWantsToStop = true; - finishAllTermuxSessions(); + killAllTermuxExecutionCommands(); requestStopService(); } - /** Finish all termux sessions by sending SIGKILL to their shells. */ - private synchronized void finishAllTermuxSessions() { - ExecutionCommand executionCommand; + /** Kill all TermuxSessions and TermuxTasks by sending SIGKILL to their processes. + * + * For TermuxSessions, all sessions will be killed, whether user manually exited Termux or if + * onDestroy() was directly called because of unintended shutdown. The processing of results + * will only be done if user manually exited termux or if the session was started by a plugin + * which **expects** the result back via a pending intent. + * + * For TermuxTasks, only tasks that were started by a plugin which **expects** the result + * back via a pending intent will be killed, whether user manually exited Termux or if + * onDestroy() was directly called because of unintended shutdown. The processing of results + * will always be done for the tasks that are killed. The remaining processes will keep on + * running until the termux app process is killed by android, like by OOM, so we let them run + * as long as they can. + * + * Some plugin execution commands may not have been processed and added to mTermuxSessions and + * mTermuxTasks lists before the service is killed, so we maintain a separate + * mPendingPluginExecutionCommands list for those, so that we can notify the pending intent + * creators that execution was cancelled. + * + * Note that if user didn't manually exit Termux and if onDestroy() was directly called because + * of unintended shutdown, like android deciding to kill the service, then there will be no + * guarantee that onDestroy() will be allowed to finish and termux app process may be killed before + * it has finished. This means that in those cases some results may not be sent back to their + * creators for plugin commands but we still try to process whatever results can be processed + * despite the unreliable behaviour of onDestroy(). + * + * Note that if don't kill the processes started by plugins which **expect** the result back + * and notify their creators that they have been killed, then they may get stuck waiting for + * the results forever like in case of commands started by Termux:Tasker or RUN_COMMAND intent, + * since once TermuxService has been killed, no result will be sent back. They may still get + * stuck if termux app process gets killed, so for this case reasonable timeout values should + * be used, like in Tasker for the Termux:Tasker actions. + * + * We make copies of each list since items are removed inside the loop. + */ + private synchronized void killAllTermuxExecutionCommands() { + boolean processResult; - // TODO: Should SIGKILL also be send to background processes maintained by mTermuxTasks? - for (int i = 0; i < mTermuxSessions.size(); i++) { - TermuxSession termuxSession = mTermuxSessions.get(i); - executionCommand = termuxSession.getExecutionCommand(); + Logger.logDebug(LOG_TAG, "Killing TermuxSessions=" + mTermuxSessions.size() + ", TermuxTasks=" + mTermuxTasks.size() + ", PendingPluginExecutionCommands=" + mPendingPluginExecutionCommands.size()); - // 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); + List termuxSessions = new ArrayList<>(mTermuxSessions); + for (int i = 0; i < termuxSessions.size(); i++) { + ExecutionCommand executionCommand = termuxSessions.get(i).getExecutionCommand(); + processResult = mWantsToStop || (executionCommand.isPluginExecutionCommand && executionCommand.pluginPendingIntent != null); + termuxSessions.get(i).killIfExecuting(this, processResult); + } + + List termuxTasks = new ArrayList<>(mTermuxTasks); + for (int i = 0; i < termuxTasks.size(); i++) { + ExecutionCommand executionCommand = termuxTasks.get(i).getExecutionCommand(); + if(executionCommand.isPluginExecutionCommand && executionCommand.pluginPendingIntent != null) + termuxTasks.get(i).killIfExecuting(this, true); + } + + List pendingPluginExecutionCommands = new ArrayList<>(mPendingPluginExecutionCommands); + for (int i = 0; i < pendingPluginExecutionCommands.size(); i++) { + ExecutionCommand executionCommand = pendingPluginExecutionCommands.get(i); + if(!executionCommand.shouldNotProcessResults() && executionCommand.pluginPendingIntent != null) { + if (executionCommand.setStateFailed(ExecutionCommand.RESULT_CODE_CANCELED, this.getString(com.termux.shared.R.string.error_execution_cancelled), null)) { + PluginUtils.processPluginExecutionCommandResult(this, LOG_TAG, executionCommand); } } - - termuxSession.getTerminalSession().finishIfRunning(); } } - - /** Process action to acquire Power and Wi-Fi WakeLocks. */ @SuppressLint({"WakelockTimeout", "BatteryLife"}) private void actionAcquireWakeLock() { @@ -290,7 +340,7 @@ public final class TermuxService extends Service { } /** Process {@link TERMUX_SERVICE#ACTION_SERVICE_EXECUTE} intent to execute a shell command in - * a foreground termux session or in background. */ + * a foreground TermuxSession or in a background TermuxTask. */ private void actionServiceExecute(Intent intent) { if (intent == null){ Logger.logError(LOG_TAG, "Ignoring null intent to actionServiceExecute"); @@ -317,6 +367,9 @@ public final class TermuxService extends Service { executionCommand.isPluginExecutionCommand = true; executionCommand.pluginPendingIntent = intent.getParcelableExtra(TERMUX_SERVICE.EXTRA_PENDING_INTENT); + // Add the execution command to pending plugin execution commands list + mPendingPluginExecutionCommands.add(executionCommand); + if (executionCommand.inBackground) { executeTermuxTaskCommand(executionCommand); } else { @@ -332,7 +385,7 @@ public final class TermuxService extends Service { private void executeTermuxTaskCommand(ExecutionCommand executionCommand) { if (executionCommand == null) return; - Logger.logDebug(LOG_TAG, "Starting background termux task command"); + Logger.logDebug(LOG_TAG, "Executing background \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxTask command"); TermuxTask newTermuxTask = createTermuxTask(executionCommand); } @@ -348,7 +401,7 @@ public final class TermuxService extends Service { public synchronized TermuxTask createTermuxTask(ExecutionCommand executionCommand) { if (executionCommand == null) return null; - Logger.logDebug(LOG_TAG, "Creating termux task"); + Logger.logDebug(LOG_TAG, "Creating \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxTask"); if (!executionCommand.inBackground) { Logger.logDebug(LOG_TAG, "Ignoring a foreground execution command passed to createTermuxTask()"); @@ -358,23 +411,40 @@ public final class TermuxService extends Service { if(Logger.getLogLevel() >= Logger.LOG_LEVEL_VERBOSE) Logger.logVerbose(LOG_TAG, executionCommand.toString()); - TermuxTask newTermuxTask = TermuxTask.create(this, executionCommand); + TermuxTask newTermuxTask = TermuxTask.execute(this, executionCommand, this, false); if (newTermuxTask == null) { - Logger.logError(LOG_TAG, "Failed to execute new termux task command for:\n" + executionCommand.getCommandIdAndLabelLogString()); + Logger.logError(LOG_TAG, "Failed to execute new TermuxTask command for:\n" + executionCommand.getCommandIdAndLabelLogString()); return null; } mTermuxTasks.add(newTermuxTask); + // Remove the execution command from the pending plugin execution commands list since it has + // now been processed + if(executionCommand.isPluginExecutionCommand) + mPendingPluginExecutionCommands.remove(executionCommand); + updateNotification(); return newTermuxTask; } /** Callback received when a {@link TermuxTask} finishes. */ - public synchronized void onTermuxTaskExited(final TermuxTask task) { + @Override + public void onTermuxTaskExited(final TermuxTask termuxTask) { mHandler.post(() -> { - mTermuxTasks.remove(task); + if(termuxTask != null) { + ExecutionCommand executionCommand = termuxTask.getExecutionCommand(); + + Logger.logVerbose(LOG_TAG, "The onTermuxTaskExited() callback called for \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxTask command"); + + // If the execution command was started for a plugin, then process the results + if(executionCommand != null && executionCommand.isPluginExecutionCommand) + PluginUtils.processPluginExecutionCommandResult(this, LOG_TAG, executionCommand); + + mTermuxTasks.remove(termuxTask); + } + updateNotification(); }); } @@ -386,8 +456,8 @@ public final class TermuxService extends Service { /** Execute a shell command in a foreground {@link TermuxSession}. */ private void executeTermuxSessionCommand(ExecutionCommand executionCommand) { if (executionCommand == null) return; - - Logger.logDebug(LOG_TAG, "Starting foreground termux session command"); + + Logger.logDebug(LOG_TAG, "Executing foreground \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession command"); String sessionName = null; @@ -406,7 +476,7 @@ public final class TermuxService extends Service { /** * Create a {@link TermuxSession}. - * Currently called by {@link TermuxSessionClient#addNewSession(boolean, String)} to add a new termux session. + * Currently called by {@link TermuxSessionClient#addNewSession(boolean, String)} to add a new {@link TermuxSession}. */ @Nullable public TermuxSession createTermuxSession(String executablePath, String[] arguments, String workingDirectory, boolean isFailSafe, String sessionName) { @@ -418,7 +488,7 @@ public final class TermuxService extends Service { public synchronized TermuxSession createTermuxSession(ExecutionCommand executionCommand, String sessionName) { if (executionCommand == null) return null; - Logger.logDebug(LOG_TAG, "Creating termux session"); + Logger.logDebug(LOG_TAG, "Creating \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession"); if (executionCommand.inBackground) { Logger.logDebug(LOG_TAG, "Ignoring a background execution command passed to createTermuxSession()"); @@ -427,15 +497,23 @@ public final class TermuxService extends Service { if(Logger.getLogLevel() >= Logger.LOG_LEVEL_VERBOSE) Logger.logVerbose(LOG_TAG, executionCommand.toString()); - - TermuxSession newTermuxSession = TermuxSession.create(this, executionCommand, getTermuxSessionClient(), sessionName); + + // If the execution command was started for a plugin, only then will the stdout be set + // Otherwise if command was manually started by the user like by adding a new terminal session, + // then no need to set stdout + TermuxSession newTermuxSession = TermuxSession.execute(this, executionCommand, getTermuxSessionClient(), this, sessionName, executionCommand.isPluginExecutionCommand); if (newTermuxSession == null) { - Logger.logError(LOG_TAG, "Failed to execute new termux session command for:\n" + executionCommand.getCommandIdAndLabelLogString()); + Logger.logError(LOG_TAG, "Failed to execute new TermuxSession command for:\n" + executionCommand.getCommandIdAndLabelLogString()); return null; } mTermuxSessions.add(newTermuxSession); + // Remove the execution command from the pending plugin execution commands list since it has + // now been processed + if(executionCommand.isPluginExecutionCommand) + mPendingPluginExecutionCommands.remove(executionCommand); + // Notify {@link TermuxSessionsListViewController} that sessions list has been updated if // activity in is foreground if(mTermuxSessionClient != null) @@ -447,20 +525,27 @@ public final class TermuxService extends Service { return newTermuxSession; } - /** Remove a termux session. */ + /** Remove a TermuxSession. */ public synchronized int removeTermuxSession(TerminalSession sessionToRemove) { int index = getIndexOfSession(sessionToRemove); - if(index >= 0) { - TermuxSession termuxSession = mTermuxSessions.get(index); + if(index >= 0) + mTermuxSessions.get(index).finish(); - 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); - } + return index; + } + + /** Callback received when a {@link TermuxSession} finishes. */ + @Override + public void onTermuxSessionExited(final TermuxSession termuxSession) { + if(termuxSession != null) { + ExecutionCommand executionCommand = termuxSession.getExecutionCommand(); + + Logger.logVerbose(LOG_TAG, "The onTermuxSessionExited() callback called for \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession command"); + + // If the execution command was started for a plugin, then process the results + if(executionCommand != null && executionCommand.isPluginExecutionCommand) + PluginUtils.processPluginExecutionCommandResult(this, LOG_TAG, executionCommand); mTermuxSessions.remove(termuxSession); @@ -471,13 +556,12 @@ public final class TermuxService extends Service { } if (mTermuxSessions.isEmpty() && mWakeLock == null) { - // Finish if there are no sessions left and the wake lock is not held, otherwise keep the service alive if + // Finish if there are no TermuxSessions left and the wake lock is not held, otherwise keep the service alive if // holding wake lock since there may be daemon processes (e.g. sshd) running. requestStopService(); } else { updateNotification(); } - return index; } @@ -519,7 +603,7 @@ public final class TermuxService extends Service { /** Launch the {@link }TermuxActivity} to bring it to foreground. */ private void startTermuxActivity() { // For android >= 10, apps require Display over other apps permission to start foreground activities - // from background (services). If it is not granted, then termux sessions that are started will + // from background (services). If it is not granted, then TermuxSessions that are started will // show in Termux notification but will not run until user manually clicks the notification. if(PermissionUtils.validateDisplayOverOtherAppsPermissionForPostAndroid10(this)) { TermuxActivity.startTermuxActivity(this); @@ -620,7 +704,7 @@ public final class TermuxService extends Service { // Set background color for small notification icon builder.setColor(0xFF607D8B); - // Termux sessions are always ongoing + // TermuxSessions are always ongoing builder.setOngoing(true); @@ -648,7 +732,7 @@ public final class TermuxService extends Service { } /** Update the shown foreground service notification after making any changes that affect it. */ - void updateNotification() { + private synchronized void updateNotification() { if (mWakeLock == null && mTermuxSessions.isEmpty() && mTermuxTasks.isEmpty()) { // Exit if we are updating after the user disabled all locks with no sessions or tasks running. requestStopService(); diff --git a/app/src/main/java/com/termux/app/terminal/TermuxSession.java b/app/src/main/java/com/termux/app/terminal/TermuxSession.java deleted file mode 100644 index 7b8381e3..00000000 --- a/app/src/main/java/com/termux/app/terminal/TermuxSession.java +++ /dev/null @@ -1,116 +0,0 @@ -package com.termux.app.terminal; - -import androidx.annotation.NonNull; - -import com.termux.R; -import com.termux.shared.termux.TermuxConstants; -import com.termux.app.TermuxService; -import com.termux.shared.logger.Logger; -import com.termux.app.utils.PluginUtils; -import com.termux.shared.shell.ShellUtils; -import com.termux.app.models.ExecutionCommand; -import com.termux.terminal.TerminalSession; - -import java.io.File; - -/** - * A class that maintains info for foreground Termux sessions. - * It also provides a way to link each {@link TerminalSession} with the {@link ExecutionCommand} - * that started it. - */ -public class TermuxSession { - - private final TerminalSession mTerminalSession; - private final ExecutionCommand mExecutionCommand; - - private static final String LOG_TAG = "TermuxSession"; - - private TermuxSession(TerminalSession terminalSession, ExecutionCommand executionCommand) { - this.mTerminalSession = terminalSession; - this.mExecutionCommand = executionCommand; - } - - 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(service, executionCommand.isFailsafe, executionCommand.workingDirectory); - - boolean isLoginShell = false; - if (executionCommand.executable == null) { - if (!executionCommand.isFailsafe) { - for (String shellBinary : new String[]{"login", "bash", "zsh"}) { - File shellFile = new File(TermuxConstants.TERMUX_BIN_PREFIX_DIR_PATH, shellBinary); - if (shellFile.canExecute()) { - executionCommand.executable = shellFile.getAbsolutePath(); - break; - } - } - } - - if (executionCommand.executable == null) { - // Fall back to system shell as last resort: - executionCommand.executable = "/system/bin/sh"; - } - isLoginShell = true; - } - - String[] processArgs = ShellUtils.setupProcessArgs(executionCommand.executable, executionCommand.arguments); - - executionCommand.executable = processArgs[0]; - String processName = (isLoginShell ? "-" : "") + ShellUtils.getExecutableBasename(executionCommand.executable); - - String[] arguments = new String[processArgs.length]; - arguments[0] = processName; - if (processArgs.length > 1) System.arraycopy(processArgs, 1, arguments, 1, processArgs.length - 1); - - executionCommand.arguments = arguments; - - 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()); - - TerminalSession terminalSession = new TerminalSession(executionCommand.executable, executionCommand.workingDirectory, executionCommand.arguments, environment, termuxSessionClient); - - if (sessionName != null) { - terminalSession.mSessionName = sessionName; - } - - 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; - - // Must be a normal command like foreground terminal session started by user, so just 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; - } - - public ExecutionCommand getExecutionCommand() { - return mExecutionCommand; - } - -} diff --git a/app/src/main/java/com/termux/app/terminal/TermuxSessionClient.java b/app/src/main/java/com/termux/app/terminal/TermuxSessionClient.java index 2dbc5552..ec4020b2 100644 --- a/app/src/main/java/com/termux/app/terminal/TermuxSessionClient.java +++ b/app/src/main/java/com/termux/app/terminal/TermuxSessionClient.java @@ -13,8 +13,10 @@ import android.text.TextUtils; import android.widget.ListView; import com.termux.R; +import com.termux.shared.shell.TermuxSession; import com.termux.shared.interact.DialogUtils; import com.termux.app.TermuxActivity; +import com.termux.shared.shell.TermuxSessionClientBase; import com.termux.shared.termux.TermuxConstants; import com.termux.app.TermuxService; import com.termux.shared.settings.properties.TermuxPropertyConstants; @@ -255,6 +257,7 @@ public class TermuxSessionClient extends TermuxSessionClientBase { TermuxService service = mActivity.getTermuxService(); int index = service.removeTermuxSession(finishedSession); + int size = mActivity.getTermuxService().getTermuxSessionsSize(); if (size == 0) { // There are no sessions to show, so finish the activity. diff --git a/app/src/main/java/com/termux/app/terminal/TermuxSessionsListViewController.java b/app/src/main/java/com/termux/app/terminal/TermuxSessionsListViewController.java index 5dcec5e0..a87780ec 100644 --- a/app/src/main/java/com/termux/app/terminal/TermuxSessionsListViewController.java +++ b/app/src/main/java/com/termux/app/terminal/TermuxSessionsListViewController.java @@ -20,6 +20,7 @@ import androidx.core.content.ContextCompat; import com.termux.R; import com.termux.app.TermuxActivity; +import com.termux.shared.shell.TermuxSession; import com.termux.terminal.TerminalSession; import java.util.List; diff --git a/app/src/main/java/com/termux/app/terminal/TermuxTask.java b/app/src/main/java/com/termux/app/terminal/TermuxTask.java deleted file mode 100644 index f70ee9e5..00000000 --- a/app/src/main/java/com/termux/app/terminal/TermuxTask.java +++ /dev/null @@ -1,142 +0,0 @@ -package com.termux.app.terminal; - -import androidx.annotation.NonNull; - -import com.termux.R; -import com.termux.shared.termux.TermuxConstants; -import com.termux.app.TermuxService; -import com.termux.shared.shell.StreamGobbler; -import com.termux.shared.logger.Logger; -import com.termux.app.utils.PluginUtils; -import com.termux.shared.shell.ShellUtils; -import com.termux.app.models.ExecutionCommand; -import com.termux.app.models.ExecutionCommand.ExecutionState; - -import java.io.File; -import java.io.IOException; - -/** - * A class that maintains info for background Termux tasks. - * It also provides a way to link each {@link Process} with the {@link ExecutionCommand} - * that started it. - */ -public final class TermuxTask { - - private final Process mProcess; - private final ExecutionCommand mExecutionCommand; - - private static final String LOG_TAG = "TermuxTask"; - - private TermuxTask(Process process, ExecutionCommand executionCommand) { - this.mProcess = process; - this.mExecutionCommand = executionCommand; - } - - public static TermuxTask create(@NonNull final TermuxService service, @NonNull ExecutionCommand executionCommand) { - if (executionCommand.workingDirectory == null || executionCommand.workingDirectory.isEmpty()) executionCommand.workingDirectory = TermuxConstants.TERMUX_HOME_DIR_PATH; - - String[] env = ShellUtils.buildEnvironment(service, false, executionCommand.workingDirectory); - - final String[] commandArray = ShellUtils.setupProcessArgs(executionCommand.executable, executionCommand.arguments); - // final String commandDescription = Arrays.toString(commandArray); - - if(!executionCommand.setState(ExecutionState.EXECUTING)) - return null; - - Logger.logDebug(LOG_TAG, executionCommand.toString()); - - String taskName = ShellUtils.getExecutableBasename(executionCommand.executable); - - if(executionCommand.commandLabel == null) - executionCommand.commandLabel = taskName; - - // Exec the process - final Process process; - try { - process = Runtime.getRuntime().exec(commandArray, env, new File(executionCommand.workingDirectory)); - } catch (IOException e) { - 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; - } - - final int pid = ShellUtils.getPid(process); - - Logger.logDebug(LOG_TAG, "Running \"" + executionCommand.commandLabel + "\" background task with pid " + pid); - - final TermuxTask termuxTask = new TermuxTask(process, executionCommand); - - StringBuilder stdout = new StringBuilder(); - StringBuilder stderr = new StringBuilder(); - - new Thread() { - @Override - public void run() { - try { - // setup stdout and stderr gobblers - StreamGobbler STDOUT = new StreamGobbler(pid + "-stdout", process.getInputStream(), stdout); - StreamGobbler STDERR = new StreamGobbler(pid + "-stderr", process.getErrorStream(), stderr); - - // start gobbling - STDOUT.start(); - STDERR.start(); - - // wait for our process to finish, while we gobble away in the - // background - int exitCode = process.waitFor(); - - // make sure our threads are done gobbling - // and the process is destroyed - while the latter shouldn't be - // needed in theory, and may even produce warnings, in "normal" Java - // they are required for guaranteed cleanup of resources, so lets be - // safe and do this on Android as well - STDOUT.join(); - STDERR.join(); - process.destroy(); - - - // Process result - if (exitCode == 0) - Logger.logDebug(LOG_TAG, "The \"" + executionCommand.commandLabel + "\" background task with pid " + pid + " exited normally"); - else - Logger.logDebug(LOG_TAG, "The \"" + executionCommand.commandLabel + "\" background task with pid " + pid + " exited with code: " + exitCode); - - executionCommand.stdout = stdout.toString(); - executionCommand.stderr = stderr.toString(); - executionCommand.exitCode = exitCode; - - if(!executionCommand.setState(ExecutionState.EXECUTED)) - return; - - TermuxTask.processTermuxTaskResult(service, termuxTask, null); - - } catch (IllegalThreadStateException | InterruptedException e) { - // TODO: Should either of these be handled or returned? - } - } - }.start(); - - return termuxTask; - } - - public static void processTermuxTaskResult(@NonNull final TermuxService service, final TermuxTask termuxTask, ExecutionCommand executionCommand) { - if(termuxTask != null) - executionCommand = termuxTask.mExecutionCommand; - - if(executionCommand == null) return; - - PluginUtils.processPluginExecutionCommandResult(service.getApplicationContext(), LOG_TAG, executionCommand); - - if(termuxTask != null) - service.onTermuxTaskExited(termuxTask); - } - - public Process getTerminalSession() { - return mProcess; - } - - public ExecutionCommand getExecutionCommand() { - return mExecutionCommand; - } - -} diff --git a/app/src/main/java/com/termux/app/terminal/TermuxViewClient.java b/app/src/main/java/com/termux/app/terminal/TermuxViewClient.java index 08586fe5..7c19aa78 100644 --- a/app/src/main/java/com/termux/app/terminal/TermuxViewClient.java +++ b/app/src/main/java/com/termux/app/terminal/TermuxViewClient.java @@ -20,6 +20,7 @@ import android.widget.Toast; import com.termux.R; import com.termux.app.TermuxActivity; +import com.termux.shared.shell.ShellUtils; import com.termux.shared.termux.TermuxConstants; import com.termux.app.activities.ReportActivity; import com.termux.app.models.ReportInfo; @@ -341,7 +342,7 @@ public class TermuxViewClient implements TerminalViewClient { TerminalSession session = mActivity.getCurrentSession(); if (session == null) return; - String transcriptText = session.getEmulator().getScreen().getTranscriptTextWithoutJoinedLines().trim(); + String transcriptText = ShellUtils.getTerminalSessionTranscriptText(session, false, true); if (transcriptText == null) return; try { @@ -361,7 +362,7 @@ public class TermuxViewClient implements TerminalViewClient { TerminalSession session = mActivity.getCurrentSession(); if (session == null) return; - String text = session.getEmulator().getScreen().getTranscriptTextWithFullLinesJoined(); + String text = ShellUtils.getTerminalSessionTranscriptText(session, true, true); LinkedHashSet urlSet = DataUtils.extractUrls(text); if (urlSet.isEmpty()) { @@ -404,7 +405,7 @@ public class TermuxViewClient implements TerminalViewClient { TerminalSession session = mActivity.getCurrentSession(); if (session == null) return; - String transcriptText = session.getEmulator().getScreen().getTranscriptTextWithoutJoinedLines().trim(); + String transcriptText = ShellUtils.getTerminalSessionTranscriptText(session, false, true); if (transcriptText == null) return; transcriptText = DataUtils.getTruncatedCommandOutput(transcriptText, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES, false, true, false).trim(); diff --git a/app/src/main/java/com/termux/app/utils/PluginUtils.java b/app/src/main/java/com/termux/app/utils/PluginUtils.java index a174c0d5..552c3b99 100644 --- a/app/src/main/java/com/termux/app/utils/PluginUtils.java +++ b/app/src/main/java/com/termux/app/utils/PluginUtils.java @@ -21,7 +21,7 @@ import com.termux.shared.settings.preferences.TermuxPreferenceConstants.TERMUX_A import com.termux.shared.settings.properties.SharedProperties; import com.termux.shared.settings.properties.TermuxPropertyConstants; import com.termux.app.models.ReportInfo; -import com.termux.app.models.ExecutionCommand; +import com.termux.shared.models.ExecutionCommand; import com.termux.app.models.UserAction; import com.termux.shared.data.DataUtils; import com.termux.shared.markdown.MarkdownUtils; @@ -56,7 +56,7 @@ public class PluginUtils { logTag = DataUtils.getDefaultIfNull(logTag, LOG_TAG); if(!executionCommand.hasExecuted()) { - Logger.logWarn(logTag, "Ignoring call to processPluginExecutionCommandResult() since the execution command has not been ExecutionState.EXECUTED"); + Logger.logWarn(logTag, "Ignoring call to processPluginExecutionCommandResult() since the execution command state is not higher than the ExecutionState.EXECUTED"); return; } @@ -114,7 +114,7 @@ public class PluginUtils { logTag = DataUtils.getDefaultIfNull(logTag, LOG_TAG); if(!executionCommand.isStateFailed()) { - Logger.logWarn(logTag, "Ignoring call to processPluginExecutionCommandError() since the execution command does not have ExecutionState.FAILED state"); + Logger.logWarn(logTag, "Ignoring call to processPluginExecutionCommandError() since the execution command is not in ExecutionState.FAILED"); return; } @@ -255,6 +255,7 @@ public class PluginUtils { pluginPendingIntent.send(context, Activity.RESULT_OK, resultIntent); } catch (PendingIntent.CanceledException e) { // The caller doesn't want the result? That's fine, just ignore + Logger.logDebug(logTag, "The Execution Command \"" + label + "\" creator " + pluginPendingIntent.getCreatorPackage() + " does not want the results anymore"); } return true; diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 741e8498..0cebd0b5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -100,9 +100,6 @@ 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" diff --git a/termux-shared/build.gradle b/termux-shared/build.gradle index f4199365..a1523253 100644 --- a/termux-shared/build.gradle +++ b/termux-shared/build.gradle @@ -16,6 +16,8 @@ android { // will be runtime exceptions on android < 8 // due to missing classes like java.nio.file.Path. implementation "commons-io:commons-io:2.5" + + implementation project(":terminal-view") } defaultConfig { diff --git a/app/src/main/java/com/termux/app/models/ExecutionCommand.java b/termux-shared/src/main/java/com/termux/shared/models/ExecutionCommand.java similarity index 97% rename from app/src/main/java/com/termux/app/models/ExecutionCommand.java rename to termux-shared/src/main/java/com/termux/shared/models/ExecutionCommand.java index 240af229..7069e531 100644 --- a/app/src/main/java/com/termux/app/models/ExecutionCommand.java +++ b/termux-shared/src/main/java/com/termux/shared/models/ExecutionCommand.java @@ -1,4 +1,4 @@ -package com.termux.app.models; +package com.termux.shared.models; import android.app.Activity; import android.app.PendingIntent; @@ -56,6 +56,7 @@ public class ExecutionCommand { public final static int RESULT_CODE_OK = Activity.RESULT_OK; public final static int RESULT_CODE_OK_MINOR_FAILURES = Activity.RESULT_FIRST_USER; public final static int RESULT_CODE_FAILED = Activity.RESULT_FIRST_USER + 1; + public final static int RESULT_CODE_CANCELED = Activity.RESULT_FIRST_USER + 2; /** The optional unique id for the {@link ExecutionCommand}. */ public Integer id; @@ -125,6 +126,9 @@ public class ExecutionCommand { /** The internal exceptions of {@link ExecutionCommand}. */ public List throwableList = new ArrayList<>(); + /** Defines if processing results already called for this {@link ExecutionCommand}. */ + public boolean processingResultsAlreadyCalled; + public ExecutionCommand(){ @@ -527,6 +531,15 @@ public class ExecutionCommand { return true; } + public synchronized boolean shouldNotProcessResults() { + if (processingResultsAlreadyCalled) { + return true; + } else { + processingResultsAlreadyCalled = true; + return false; + } + } + public synchronized boolean isStateFailed() { if (currentState != ExecutionState.FAILED) return false; diff --git a/termux-shared/src/main/java/com/termux/shared/shell/ShellUtils.java b/termux-shared/src/main/java/com/termux/shared/shell/ShellUtils.java index d30a01fa..9d5455cb 100644 --- a/termux-shared/src/main/java/com/termux/shared/shell/ShellUtils.java +++ b/termux-shared/src/main/java/com/termux/shared/shell/ShellUtils.java @@ -2,11 +2,16 @@ package com.termux.shared.shell; import android.content.Context; +import androidx.annotation.NonNull; + import com.termux.shared.termux.TermuxConstants; import com.termux.shared.file.FileUtils; import com.termux.shared.logger.Logger; import com.termux.shared.packages.PackageUtils; import com.termux.shared.termux.TermuxUtils; +import com.termux.terminal.TerminalBuffer; +import com.termux.terminal.TerminalEmulator; +import com.termux.terminal.TerminalSession; import java.io.File; import java.io.FileInputStream; @@ -85,7 +90,7 @@ public class ShellUtils { } } - public static String[] setupProcessArgs(String fileToExecute, String[] arguments) { + public static String[] setupProcessArgs(@NonNull String fileToExecute, String[] arguments) { // The file to execute may either be: // - An elf file, in which we execute it directly. // - A script file without shebang, which we execute with our standard shell $PREFIX/bin/sh instead of the @@ -153,4 +158,28 @@ public class ShellUtils { } } + public static String getTerminalSessionTranscriptText(TerminalSession terminalSession, boolean linesJoined, boolean trim) { + if (terminalSession == null) return null; + + TerminalEmulator terminalEmulator = terminalSession.getEmulator(); + if (terminalEmulator == null) return null; + + TerminalBuffer terminalBuffer = terminalEmulator.getScreen(); + if (terminalBuffer == null) return null; + + String transcriptText; + + if(linesJoined) + transcriptText = terminalBuffer.getTranscriptTextWithFullLinesJoined(); + else + transcriptText = terminalBuffer.getTranscriptTextWithoutJoinedLines(); + + if (transcriptText == null) return null; + + if(trim) + transcriptText = transcriptText.trim(); + + return transcriptText; + } + } diff --git a/termux-shared/src/main/java/com/termux/shared/shell/StreamGobbler.java b/termux-shared/src/main/java/com/termux/shared/shell/StreamGobbler.java index ede6145c..8d785c5d 100644 --- a/termux-shared/src/main/java/com/termux/shared/shell/StreamGobbler.java +++ b/termux-shared/src/main/java/com/termux/shared/shell/StreamGobbler.java @@ -32,6 +32,9 @@ import com.termux.shared.logger.Logger; /** * Thread utility class continuously reading from an InputStream + * + * https://github.com/Chainfire/libsuperuser/blob/1.1.0.201907261845/libsuperuser/src/eu/chainfire/libsuperuser/Shell.java#L141 + * https://github.com/Chainfire/libsuperuser/blob/1.1.0.201907261845/libsuperuser/src/eu/chainfire/libsuperuser/StreamGobbler.java */ @SuppressWarnings({"WeakerAccess"}) public class StreamGobbler extends Thread { @@ -170,6 +173,7 @@ public class StreamGobbler extends Thread { // optionally pausing when a command is executed that consumes the InputStream itself int currentLogLevel = Logger.getLogLevel(); int logLevelVerbose = Logger.LOG_LEVEL_VERBOSE; + try { String line; while ((line = reader.readLine()) != null) { diff --git a/termux-shared/src/main/java/com/termux/shared/shell/TermuxSession.java b/termux-shared/src/main/java/com/termux/shared/shell/TermuxSession.java new file mode 100644 index 00000000..837edbc2 --- /dev/null +++ b/termux-shared/src/main/java/com/termux/shared/shell/TermuxSession.java @@ -0,0 +1,258 @@ +package com.termux.shared.shell; + +import android.content.Context; +import android.system.OsConstants; + +import androidx.annotation.NonNull; + +import com.termux.shared.R; +import com.termux.shared.models.ExecutionCommand; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.logger.Logger; +import com.termux.terminal.TerminalSession; +import com.termux.terminal.TerminalSessionClient; + +import java.io.File; + +/** + * A class that maintains info for foreground Termux sessions. + * It also provides a way to link each {@link TerminalSession} with the {@link ExecutionCommand} + * that started it. + */ +public class TermuxSession { + + private final TerminalSession mTerminalSession; + private final ExecutionCommand mExecutionCommand; + private final TermuxSessionClient mTermuxSessionClient; + private final boolean mSetStdoutOnExit; + + private static final String LOG_TAG = "TermuxSession"; + + private TermuxSession(@NonNull final TerminalSession terminalSession, @NonNull final ExecutionCommand executionCommand, + final TermuxSessionClient termuxSessionClient, final boolean setStdoutOnExit) { + this.mTerminalSession = terminalSession; + this.mExecutionCommand = executionCommand; + this.mTermuxSessionClient = termuxSessionClient; + this.mSetStdoutOnExit = setStdoutOnExit; + } + + /** + * Start execution of an {@link ExecutionCommand} with {@link Runtime#exec(String[], String[], File)}. + * + * The {@link ExecutionCommand#executable}, must be set, {@link ExecutionCommand#commandLabel}, + * {@link ExecutionCommand#arguments} and {@link ExecutionCommand#workingDirectory} may optionally + * be set. + * + * If {@link ExecutionCommand#executable} is {@code null}, then a default shell is automatically + * chosen. + * + * @param context The {@link Context} for operations. + * @param executionCommand The {@link ExecutionCommand} containing the information for execution command. + * @param terminalSessionClient The {@link TerminalSessionClient} interface implementation. + * @param termuxSessionClient The {@link TermuxSessionClient} interface implementation. + * @param sessionName The optional {@link TerminalSession} name. + * @param setStdoutOnExit If set to {@code true}, then the {@link ExecutionCommand#stdout} + * available in the {@link TermuxSessionClient#onTermuxSessionExited(TermuxSession)} + * callback will be set to the {@link TerminalSession} transcript. The session + * transcript will contain both stdout and stderr combined, basically + * anything sent to the the pseudo terminal /dev/pts, including PS1 prefixes. + * Set this to {@code true} only if the session transcript is required, + * since this requires extra processing to get it. + * @return Returns the {@link TermuxSession}. This will be {@code null} if failed to start the execution command. + */ + public static TermuxSession execute(@NonNull final Context context, @NonNull ExecutionCommand executionCommand, + @NonNull final TerminalSessionClient terminalSessionClient, final TermuxSessionClient termuxSessionClient, + final String sessionName, final boolean setStdoutOnExit) { + if (executionCommand.workingDirectory == null || executionCommand.workingDirectory.isEmpty()) executionCommand.workingDirectory = TermuxConstants.TERMUX_HOME_DIR_PATH; + + String[] environment = ShellUtils.buildEnvironment(context, executionCommand.isFailsafe, executionCommand.workingDirectory); + + boolean isLoginShell = false; + if (executionCommand.executable == null) { + if (!executionCommand.isFailsafe) { + for (String shellBinary : new String[]{"login", "bash", "zsh"}) { + File shellFile = new File(TermuxConstants.TERMUX_BIN_PREFIX_DIR_PATH, shellBinary); + if (shellFile.canExecute()) { + executionCommand.executable = shellFile.getAbsolutePath(); + break; + } + } + } + + if (executionCommand.executable == null) { + // Fall back to system shell as last resort: + executionCommand.executable = "/system/bin/sh"; + } + isLoginShell = true; + } + + String[] processArgs = ShellUtils.setupProcessArgs(executionCommand.executable, executionCommand.arguments); + + executionCommand.executable = processArgs[0]; + String processName = (isLoginShell ? "-" : "") + ShellUtils.getExecutableBasename(executionCommand.executable); + + String[] arguments = new String[processArgs.length]; + arguments[0] = processName; + if (processArgs.length > 1) System.arraycopy(processArgs, 1, arguments, 1, processArgs.length - 1); + + executionCommand.arguments = arguments; + + if (executionCommand.commandLabel == null) + executionCommand.commandLabel = processName; + + if (!executionCommand.setState(ExecutionCommand.ExecutionState.EXECUTING)) { + executionCommand.setStateFailed(ExecutionCommand.RESULT_CODE_FAILED, context.getString(R.string.error_failed_to_execute_termux_session_command, executionCommand.getCommandIdAndLabelLogString()), null); + TermuxSession.processTermuxSessionResult(null, executionCommand); + return null; + } + + Logger.logDebug(LOG_TAG, executionCommand.toString()); + + Logger.logDebug(LOG_TAG, "Running \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession"); + TerminalSession terminalSession = new TerminalSession(executionCommand.executable, executionCommand.workingDirectory, executionCommand.arguments, environment, terminalSessionClient); + + if (sessionName != null) { + terminalSession.mSessionName = sessionName; + } + + return new TermuxSession(terminalSession, executionCommand, termuxSessionClient, setStdoutOnExit); + } + + /** + * Signal that this {@link TermuxSession} has finished. This should be called when + * {@link TerminalSessionClient#onSessionFinished(TerminalSession)} callback is received by the caller. + * + * If the processes has finished, then sets {@link ExecutionCommand#stdout}, {@link ExecutionCommand#stderr} + * and {@link ExecutionCommand#exitCode} for the {@link #mExecutionCommand} of the {@code termuxTask} + * and then calls {@link #processTermuxSessionResult(TermuxSession, ExecutionCommand)} to process the result}. + * + */ + public void finish() { + // If process is still running, then ignore the call + if (mTerminalSession.isRunning()) return; + + int exitCode = mTerminalSession.getExitStatus(); + + if (exitCode == 0) + Logger.logDebug(LOG_TAG, "The \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession with exited normally"); + else + Logger.logDebug(LOG_TAG, "The \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession with exited with code: " + exitCode); + + // If the execution command has already failed, like SIGKILL was sent, then don't continue + if (mExecutionCommand.isStateFailed()) { + Logger.logDebug(LOG_TAG, "Ignoring setting \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession state to ExecutionState.EXECUTED and processing results since it has already failed"); + return; + } + + if (this.mSetStdoutOnExit) + mExecutionCommand.stdout = ShellUtils.getTerminalSessionTranscriptText(mTerminalSession, true, false); + else + mExecutionCommand.stdout = null; + + mExecutionCommand.stderr = null; + mExecutionCommand.exitCode = exitCode; + + if (!mExecutionCommand.setState(ExecutionCommand.ExecutionState.EXECUTED)) + return; + + TermuxSession.processTermuxSessionResult(this, null); + } + + /** + * Kill this {@link TermuxSession} by sending a {@link OsConstants#SIGILL} to its {@link #mTerminalSession} + * if its still executing. + * + * We process the results even if + * + * @param context The {@link Context} for operations. + * @param processResult If set to {@code true}, then the {@link #processTermuxSessionResult(TermuxSession, ExecutionCommand)} + * will be called to process the failure. + */ + public void killIfExecuting(@NonNull final Context context, boolean processResult) { + // If execution command has already finished executing, then no need to process results or send SIGKILL + if(mExecutionCommand.hasExecuted()) { + Logger.logDebug(LOG_TAG, "Ignoring sending SIGKILL to \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession since it has already finished executing"); + return; + } + + Logger.logDebug(LOG_TAG, "Send SIGKILL to \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession"); + if (mExecutionCommand.setStateFailed(ExecutionCommand.RESULT_CODE_FAILED, context.getString(R.string.error_sending_sigkill_to_process), null)) { + if (processResult) { + // Get whatever output has been set till now in case its needed + if (this.mSetStdoutOnExit) + mExecutionCommand.stdout = ShellUtils.getTerminalSessionTranscriptText(mTerminalSession, true, false); + else + mExecutionCommand.stdout = null; + + mExecutionCommand.stderr = null; + mExecutionCommand.exitCode = 137; // SIGKILL + + TermuxSession.processTermuxSessionResult(this, null); + } + } + + // Send SIGKILL to process + mTerminalSession.finishIfRunning(); + } + + /** + * Process the results of {@link TermuxSession} or {@link ExecutionCommand}. + * + * Only one of {@code termuxSession} and {@code executionCommand} must be set. + * + * If the {@code termuxSession} and its {@link #mTermuxSessionClient} are not {@code null}, + * then the {@link TermuxSession.TermuxSessionClient#onTermuxSessionExited(TermuxSession)} + * callback will be called. + * + * @param termuxSession The {@link TermuxSession}, which should be set if + * {@link #execute(Context, ExecutionCommand, TerminalSessionClient, TermuxSessionClient, String, boolean)} + * successfully started the process. + * @param executionCommand The {@link ExecutionCommand}, which should be set if + * {@link #execute(Context, ExecutionCommand, TerminalSessionClient, TermuxSessionClient, String, boolean)} + * failed to start the process. + */ + private static void processTermuxSessionResult(final TermuxSession termuxSession, ExecutionCommand executionCommand) { + if (termuxSession != null) + executionCommand = termuxSession.mExecutionCommand; + + if (executionCommand == null) return; + + if (executionCommand.shouldNotProcessResults()) { + Logger.logDebug(LOG_TAG, "Ignoring duplicate call to process \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession result"); + return; + } + + Logger.logDebug(LOG_TAG, "Processing \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession result"); + + if (termuxSession != null && termuxSession.mTermuxSessionClient != null) { + termuxSession.mTermuxSessionClient.onTermuxSessionExited(termuxSession); + } else { + // If a callback is not set and execution command didn't fail, then we set success state now + // Otherwise, the callback host can set it himself when its done with the termuxSession + if (!executionCommand.isStateFailed()) + executionCommand.setState(ExecutionCommand.ExecutionState.SUCCESS); + } + } + + public TerminalSession getTerminalSession() { + return mTerminalSession; + } + + public ExecutionCommand getExecutionCommand() { + return mExecutionCommand; + } + + + + public interface TermuxSessionClient { + + /** + * Callback function for when {@link TermuxSession} exits. + * + * @param termuxSession The {@link TermuxSession} that exited. + */ + void onTermuxSessionExited(TermuxSession termuxSession); + + } + +} diff --git a/app/src/main/java/com/termux/app/terminal/TermuxSessionClientBase.java b/termux-shared/src/main/java/com/termux/shared/shell/TermuxSessionClientBase.java similarity index 98% rename from app/src/main/java/com/termux/app/terminal/TermuxSessionClientBase.java rename to termux-shared/src/main/java/com/termux/shared/shell/TermuxSessionClientBase.java index cb0dec8f..194c8aab 100644 --- a/app/src/main/java/com/termux/app/terminal/TermuxSessionClientBase.java +++ b/termux-shared/src/main/java/com/termux/shared/shell/TermuxSessionClientBase.java @@ -1,4 +1,4 @@ -package com.termux.app.terminal; +package com.termux.shared.shell; import com.termux.shared.logger.Logger; import com.termux.terminal.TerminalSession; diff --git a/termux-shared/src/main/java/com/termux/shared/shell/TermuxTask.java b/termux-shared/src/main/java/com/termux/shared/shell/TermuxTask.java new file mode 100644 index 00000000..17d6795f --- /dev/null +++ b/termux-shared/src/main/java/com/termux/shared/shell/TermuxTask.java @@ -0,0 +1,273 @@ +package com.termux.shared.shell; + +import android.content.Context; +import android.system.ErrnoException; +import android.system.Os; +import android.system.OsConstants; + +import androidx.annotation.NonNull; + +import com.termux.shared.R; +import com.termux.shared.models.ExecutionCommand; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.logger.Logger; +import com.termux.shared.models.ExecutionCommand.ExecutionState; + +import java.io.File; +import java.io.IOException; + +/** + * A class that maintains info for background Termux tasks run with {@link Runtime#exec(String[], String[], File)}. + * It also provides a way to link each {@link Process} with the {@link ExecutionCommand} + * that started it. + */ +public final class TermuxTask { + + private final Process mProcess; + private final ExecutionCommand mExecutionCommand; + private final TermuxTaskClient mTermuxTaskClient; + + private final StringBuilder mStdout = new StringBuilder(); + private final StringBuilder mStderr = new StringBuilder(); + + private static final String LOG_TAG = "TermuxTask"; + + private TermuxTask(@NonNull final Process process, @NonNull final ExecutionCommand executionCommand, + final TermuxTaskClient termuxTaskClient) { + this.mProcess = process; + this.mExecutionCommand = executionCommand; + this.mTermuxTaskClient = termuxTaskClient; + } + + /** + * Start execution of an {@link ExecutionCommand} with {@link Runtime#exec(String[], String[], File)}. + * + * The {@link ExecutionCommand#executable}, must be set. + * The {@link ExecutionCommand#commandLabel}, {@link ExecutionCommand#arguments} and + * {@link ExecutionCommand#workingDirectory} may optionally be set. + * + * @param context The {@link Context} for operations. + * @param executionCommand The {@link ExecutionCommand} containing the information for execution command. + * @param termuxTaskClient The {@link TermuxTaskClient} interface implementation. + * The {@link TermuxTaskClient#onTermuxTaskExited(TermuxTask)} will + * be called regardless of {@code isSynchronous} value but not if + * {@code null} is returned by this method. This can + * optionally be {@code null}. + * @param isSynchronous If set to {@code true}, then the command will be executed in the + * caller thread and results returned synchronously in the {@link ExecutionCommand} + * sub object of the {@link TermuxTask} returned. + * If set to {@code false}, then a new thread is started run the commands + * asynchronously in the background and control is returned to the caller thread. + * @return Returns the {@link TermuxTask}. This will be {@code null} if failed to start the execution command. + */ + public static TermuxTask execute(@NonNull final Context context, @NonNull ExecutionCommand executionCommand, + final TermuxTaskClient termuxTaskClient, final boolean isSynchronous) { + if (executionCommand.workingDirectory == null || executionCommand.workingDirectory.isEmpty()) executionCommand.workingDirectory = TermuxConstants.TERMUX_HOME_DIR_PATH; + + String[] env = ShellUtils.buildEnvironment(context, false, executionCommand.workingDirectory); + + final String[] commandArray = ShellUtils.setupProcessArgs(executionCommand.executable, executionCommand.arguments); + + if (!executionCommand.setState(ExecutionState.EXECUTING)) + return null; + + Logger.logDebug(LOG_TAG, executionCommand.toString()); + + String taskName = ShellUtils.getExecutableBasename(executionCommand.executable); + + if (executionCommand.commandLabel == null) + executionCommand.commandLabel = taskName; + + // Exec the process + final Process process; + try { + process = Runtime.getRuntime().exec(commandArray, env, new File(executionCommand.workingDirectory)); + } catch (IOException e) { + executionCommand.setStateFailed(ExecutionCommand.RESULT_CODE_FAILED, context.getString(R.string.error_failed_to_execute_termux_task_command, executionCommand.getCommandIdAndLabelLogString()), e); + TermuxTask.processTermuxTaskResult(null, executionCommand); + return null; + } + + final TermuxTask termuxTask = new TermuxTask(process, executionCommand, termuxTaskClient); + + if (isSynchronous) { + try { + termuxTask.executeInner(); + } catch (IllegalThreadStateException | InterruptedException e) { + // TODO: Should either of these be handled or returned? + } + } else { + new Thread() { + @Override + public void run() { + try { + termuxTask.executeInner(); + } catch (IllegalThreadStateException | InterruptedException e) { + // TODO: Should either of these be handled or returned? + } + } + }.start(); + } + + return termuxTask; + } + + /** + * Sets up stdout and stderr readers for the {@link #mProcess} and waits for the process to end. + * + * If the processes finishes, then sets {@link ExecutionCommand#stdout}, {@link ExecutionCommand#stderr} + * and {@link ExecutionCommand#exitCode} for the {@link #mExecutionCommand} of the {@code termuxTask} + * and then calls {@link #processTermuxTaskResult(TermuxTask, ExecutionCommand) to process the result}. + */ + private void executeInner() throws IllegalThreadStateException, InterruptedException { + final int pid = ShellUtils.getPid(mProcess); + + Logger.logDebug(LOG_TAG, "Running \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" TermuxTask with pid " + pid); + + mExecutionCommand.stdout = null; + mExecutionCommand.stderr = null; + mExecutionCommand.exitCode = null; + + + + // setup stdout and stderr gobblers + StreamGobbler STDOUT = new StreamGobbler(pid + "-stdout", mProcess.getInputStream(), mStdout); + StreamGobbler STDERR = new StreamGobbler(pid + "-stderr", mProcess.getErrorStream(), mStderr); + + // start gobbling + STDOUT.start(); + STDERR.start(); + + // wait for our process to finish, while we gobble away in the background + int exitCode = mProcess.waitFor(); + + // make sure our threads are done gobbling + // and the process is destroyed - while the latter shouldn't be + // needed in theory, and may even produce warnings, in "normal" Java + // they are required for guaranteed cleanup of resources, so lets be + // safe and do this on Android as well + STDOUT.join(); + STDERR.join(); + mProcess.destroy(); + + // Process result + if (exitCode == 0) + Logger.logDebug(LOG_TAG, "The \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" TermuxTask with pid " + pid + " exited normally"); + else + Logger.logDebug(LOG_TAG, "The \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" TermuxTask with pid " + pid + " exited with code: " + exitCode); + + // If the execution command has already failed, like SIGKILL was sent, then don't continue + if (mExecutionCommand.isStateFailed()) { + Logger.logDebug(LOG_TAG, "Ignoring setting \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" TermuxTask state to ExecutionState.EXECUTED and processing results since it has already failed"); + return; + } + + mExecutionCommand.stdout = mStdout.toString(); + mExecutionCommand.stderr = mStderr.toString(); + mExecutionCommand.exitCode = exitCode; + + if (!mExecutionCommand.setState(ExecutionState.EXECUTED)) + return; + + TermuxTask.processTermuxTaskResult(this, null); + } + + /** + * Kill this {@link TermuxTask} by sending a {@link OsConstants#SIGILL} to its {@link #mProcess} + * if its still executing. + * + * @param context The {@link Context} for operations. + * @param processResult If set to {@code true}, then the {@link #processTermuxTaskResult(TermuxTask, ExecutionCommand)} + * will be called to process the failure. + */ + public void killIfExecuting(@NonNull final Context context, boolean processResult) { + // If execution command has already finished executing, then no need to process results or send SIGKILL + if(mExecutionCommand.hasExecuted()) { + Logger.logDebug(LOG_TAG, "Ignoring sending SIGKILL to \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" TermuxTask since it has already finished executing"); + return; + } + + Logger.logDebug(LOG_TAG, "Send SIGKILL to \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" TermuxTask"); + + if (mExecutionCommand.setStateFailed(ExecutionCommand.RESULT_CODE_FAILED, context.getString(R.string.error_sending_sigkill_to_process), null)) { + if (processResult) { + // Get whatever output has been set till now in case its needed + mExecutionCommand.stdout = mStdout.toString(); + mExecutionCommand.stderr = mStderr.toString(); + mExecutionCommand.exitCode = 137; // SIGKILL + + TermuxTask.processTermuxTaskResult(this, null); + } + } + + if (mExecutionCommand.isExecuting()) { + int pid = ShellUtils.getPid(mProcess); + try { + // Send SIGKILL to process + Os.kill(pid, OsConstants.SIGKILL); + } catch (ErrnoException e) { + Logger.logWarn(LOG_TAG, "Failed to send SIGKILL to \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" TermuxTask with pid " + pid + ": " + e.getMessage()); + } + } + } + + /** + * Process the results of {@link TermuxTask} or {@link ExecutionCommand}. + * + * Only one of {@code termuxTask} and {@code executionCommand} must be set. + * + * If the {@code termuxTask} and its {@link #mTermuxTaskClient} are not {@code null}, + * then the {@link TermuxTaskClient#onTermuxTaskExited(TermuxTask)} callback will be called. + * + * @param termuxTask The {@link TermuxTask}, which should be set if + * {@link #execute(Context, ExecutionCommand, TermuxTaskClient, boolean)} + * successfully started the process. + * @param executionCommand The {@link ExecutionCommand}, which should be set if + * {@link #execute(Context, ExecutionCommand, TermuxTaskClient, boolean)} + * failed to start the process. + */ + private static void processTermuxTaskResult(final TermuxTask termuxTask, ExecutionCommand executionCommand) { + if (termuxTask != null) + executionCommand = termuxTask.mExecutionCommand; + + if (executionCommand == null) return; + + if (executionCommand.shouldNotProcessResults()) { + Logger.logDebug(LOG_TAG, "Ignoring duplicate call to process \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxTask result"); + return; + } + + Logger.logDebug(LOG_TAG, "Processing \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxTask result"); + + if (termuxTask != null && termuxTask.mTermuxTaskClient != null) { + termuxTask.mTermuxTaskClient.onTermuxTaskExited(termuxTask); + } else { + // If a callback is not set and execution command didn't fail, then we set success state now + // Otherwise, the callback host can set it himself when its done with the termuxTask + if (!executionCommand.isStateFailed()) + executionCommand.setState(ExecutionCommand.ExecutionState.SUCCESS); + } + } + + public Process getProcess() { + return mProcess; + } + + public ExecutionCommand getExecutionCommand() { + return mExecutionCommand; + } + + + + public interface TermuxTaskClient { + + /** + * Callback function for when {@link TermuxTask} exits. + * + * @param termuxTask The {@link TermuxTask} that exited. + */ + void onTermuxTaskExited(TermuxTask termuxTask); + + } + +} diff --git a/termux-shared/src/main/res/values/strings.xml b/termux-shared/src/main/res/values/strings.xml index 16746d2d..a4b77fc5 100644 --- a/termux-shared/src/main/res/values/strings.xml +++ b/termux-shared/src/main/res/values/strings.xml @@ -76,6 +76,15 @@ If you want to report this issue, then copy its text from the options menu (3-dots on top right) and post an issue on one of the following links. If you are posting on Github, then post it in the repository at which the report belongs at. You may optionally remove any device specific info that you consider private or don\'t want to share or that is not relevant to the issue. + + + Sending SIGKILL to process on user request or because android is killing the execution service + Execution has been cancelled since execution service is being killed + "Failed to execute \"%1$s\" termux session command" + "Failed to execute \"%1$s\" termux task command" + + + Log Level "Off"