diff --git a/.github/workflows/publish_libraries.yml b/.github/workflows/publish_libraries.yml index 41d76af4..077539de 100644 --- a/.github/workflows/publish_libraries.yml +++ b/.github/workflows/publish_libraries.yml @@ -7,6 +7,7 @@ on: paths: - 'terminal-emulator/build.gradle' - 'terminal-view/build.gradle' + - 'termux-shared/build.gradle' jobs: build: diff --git a/app/build.gradle b/app/build.gradle index 0cbce793..7bcd6bed 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -7,18 +7,36 @@ android { ndkVersion project.properties.ndkVersion dependencies { - implementation "androidx.annotation:annotation:1.1.0" - implementation "androidx.viewpager:viewpager:1.0.0" + implementation "androidx.annotation:annotation:1.2.0" + implementation "androidx.core:core:1.5.0-rc01" implementation "androidx.drawerlayout:drawerlayout:1.1.1" + implementation "androidx.preference:preference:1.1.1" + implementation "androidx.viewpager:viewpager:1.0.0" + implementation "com.google.guava:guava:24.1-jre" + implementation "io.noties.markwon:core:$markwonVersion" + implementation "io.noties.markwon:ext-strikethrough:$markwonVersion" + implementation "io.noties.markwon:linkify:$markwonVersion" + implementation "io.noties.markwon:recycler:$markwonVersion" + implementation project(":terminal-view") + implementation project(":termux-shared") } defaultConfig { applicationId "com.termux" minSdkVersion project.properties.minSdkVersion.toInteger() targetSdkVersion project.properties.targetSdkVersion.toInteger() - versionCode 108 - versionName "0.108" + versionCode 109 + versionName "0.109" + + manifestPlaceholders.TERMUX_PACKAGE_NAME = "com.termux" + manifestPlaceholders.TERMUX_APP_NAME = "Termux" + manifestPlaceholders.TERMUX_API_APP_NAME = "Termux:API" + manifestPlaceholders.TERMUX_BOOT_APP_NAME = "Termux:Boot" + manifestPlaceholders.TERMUX_FLOAT_APP_NAME = "Termux:Float" + manifestPlaceholders.TERMUX_STYLING_APP_NAME = "Termux:Styling" + manifestPlaceholders.TERMUX_TASKER_APP_NAME = "Termux:Tasker" + manifestPlaceholders.TERMUX_WIDGET_APP_NAME = "Termux:Widget" externalNativeBuild { ndkBuild { @@ -76,8 +94,8 @@ android { } dependencies { - testImplementation 'junit:junit:4.13.1' - testImplementation 'org.robolectric:robolectric:4.4' + testImplementation "junit:junit:4.13.2" + testImplementation "org.robolectric:robolectric:4.4" } task versionName { @@ -135,7 +153,7 @@ clean { } } -task downloadBootstraps(){ +task downloadBootstraps() { doLast { def version = "2021.02.19-r1" downloadBootstrap("aarch64", "1e3d80bd8cc8771715845ab4a1e67fc125d84c4deda3a1a435116fe4d1f86160", version) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 4306bcc4..7365abb1 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -7,5 +7,6 @@ # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html --renamesourcefileattribute SourceFile --keepattributes SourceFile,LineNumberTable +-dontobfuscate +#-renamesourcefileattribute SourceFile +#-keepattributes SourceFile,LineNumberTable diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 035fc8da..cef9943d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,17 +1,23 @@ + + android:sharedUserId="${TERMUX_PACKAGE_NAME}" + android:sharedUserLabel="@string/shared_user_label"> - - + + - @@ -20,62 +26,102 @@ - - + + + + + android:roundIcon="@mipmap/ic_launcher_round" + android:supportsRtl="false" + android:theme="@style/Theme.Termux"> - - + + + android:windowSoftInputMode="adjustResize|stateAlwaysVisible"> + + - + + - + + + + + + + + + + + android:theme="@android:style/Theme.Material.Light.DarkActionBar" /> + + + + + + + - + + + @@ -86,8 +132,10 @@ - + + + @@ -96,23 +144,11 @@ - - - - - - - - - - @@ -120,27 +156,32 @@ - + android:permission="${TERMUX_PACKAGE_NAME}.permission.RUN_COMMAND"> - + - - - + + + + diff --git a/app/src/main/java/com/termux/app/BackgroundJob.java b/app/src/main/java/com/termux/app/BackgroundJob.java deleted file mode 100644 index cfb11cd4..00000000 --- a/app/src/main/java/com/termux/app/BackgroundJob.java +++ /dev/null @@ -1,243 +0,0 @@ -package com.termux.app; - -import android.app.Activity; -import android.app.PendingIntent; -import android.content.Intent; -import android.os.Bundle; -import android.util.Log; - -import com.termux.BuildConfig; - -import java.io.BufferedReader; -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.lang.reflect.Field; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -/** - * A background job launched by Termux. - */ -public final class BackgroundJob { - - private static final String LOG_TAG = "termux-task"; - - final Process mProcess; - - public BackgroundJob(String cwd, String fileToExecute, final String[] args, final TermuxService service){ - this(cwd, fileToExecute, args, service, null); - } - - public BackgroundJob(String cwd, String fileToExecute, final String[] args, final TermuxService service, PendingIntent pendingIntent) { - String[] env = buildEnvironment(false, cwd); - if (cwd == null) cwd = TermuxService.HOME_PATH; - - final String[] progArray = setupProcessArgs(fileToExecute, args); - final String processDescription = Arrays.toString(progArray); - - Process process; - try { - process = Runtime.getRuntime().exec(progArray, env, new File(cwd)); - } catch (IOException e) { - mProcess = null; - // TODO: Visible error message? - Log.e(LOG_TAG, "Failed running background job: " + processDescription, e); - return; - } - - mProcess = process; - final int pid = getPid(mProcess); - final Bundle result = new Bundle(); - final StringBuilder outResult = new StringBuilder(); - final StringBuilder errResult = new StringBuilder(); - - Thread errThread = new Thread() { - @Override - public void run() { - InputStream stderr = mProcess.getErrorStream(); - BufferedReader reader = new BufferedReader(new InputStreamReader(stderr, StandardCharsets.UTF_8)); - String line; - try { - // FIXME: Long lines. - while ((line = reader.readLine()) != null) { - errResult.append(line).append('\n'); - Log.i(LOG_TAG, "[" + pid + "] stderr: " + line); - } - } catch (IOException e) { - // Ignore. - } - } - }; - errThread.start(); - - new Thread() { - @Override - public void run() { - Log.i(LOG_TAG, "[" + pid + "] starting: " + processDescription); - InputStream stdout = mProcess.getInputStream(); - BufferedReader reader = new BufferedReader(new InputStreamReader(stdout, StandardCharsets.UTF_8)); - - String line; - try { - // FIXME: Long lines. - while ((line = reader.readLine()) != null) { - Log.i(LOG_TAG, "[" + pid + "] stdout: " + line); - outResult.append(line).append('\n'); - } - } catch (IOException e) { - Log.e(LOG_TAG, "Error reading output", e); - } - - try { - int exitCode = mProcess.waitFor(); - service.onBackgroundJobExited(BackgroundJob.this); - if (exitCode == 0) { - Log.i(LOG_TAG, "[" + pid + "] exited normally"); - } else { - Log.w(LOG_TAG, "[" + pid + "] exited with code: " + exitCode); - } - - result.putString("stdout", outResult.toString()); - result.putInt("exitCode", exitCode); - - errThread.join(); - result.putString("stderr", errResult.toString()); - - Intent data = new Intent(); - data.putExtra("result", result); - - if(pendingIntent != null) { - try { - pendingIntent.send(service.getApplicationContext(), Activity.RESULT_OK, data); - } catch (PendingIntent.CanceledException e) { - // The caller doesn't want the result? That's fine, just ignore - } - } - } catch (InterruptedException e) { - // Ignore - } - } - }.start(); - } - - private static void addToEnvIfPresent(List environment, String name) { - String value = System.getenv(name); - if (value != null) { - environment.add(name + "=" + value); - } - } - - static String[] buildEnvironment(boolean failSafe, String cwd) { - new File(TermuxService.HOME_PATH).mkdirs(); - - if (cwd == null) cwd = TermuxService.HOME_PATH; - - List environment = new ArrayList<>(); - - environment.add("TERMUX_VERSION=" + BuildConfig.VERSION_NAME); - environment.add("TERM=xterm-256color"); - environment.add("COLORTERM=truecolor"); - environment.add("HOME=" + TermuxService.HOME_PATH); - environment.add("PREFIX=" + TermuxService.PREFIX_PATH); - environment.add("BOOTCLASSPATH=" + System.getenv("BOOTCLASSPATH")); - environment.add("ANDROID_ROOT=" + System.getenv("ANDROID_ROOT")); - environment.add("ANDROID_DATA=" + System.getenv("ANDROID_DATA")); - // EXTERNAL_STORAGE is needed for /system/bin/am to work on at least - // Samsung S7 - see https://plus.google.com/110070148244138185604/posts/gp8Lk3aCGp3. - environment.add("EXTERNAL_STORAGE=" + System.getenv("EXTERNAL_STORAGE")); - - // These variables are needed if running on Android 10 and higher. - addToEnvIfPresent(environment, "ANDROID_ART_ROOT"); - addToEnvIfPresent(environment, "DEX2OATBOOTCLASSPATH"); - addToEnvIfPresent(environment, "ANDROID_I18N_ROOT"); - addToEnvIfPresent(environment, "ANDROID_RUNTIME_ROOT"); - addToEnvIfPresent(environment, "ANDROID_TZDATA_ROOT"); - - if (failSafe) { - // Keep the default path so that system binaries can be used in the failsafe session. - environment.add("PATH= " + System.getenv("PATH")); - } else { - environment.add("LANG=en_US.UTF-8"); - environment.add("PATH=" + TermuxService.PREFIX_PATH + "/bin"); - environment.add("PWD=" + cwd); - environment.add("TMPDIR=" + TermuxService.PREFIX_PATH + "/tmp"); - } - - return environment.toArray(new String[0]); - } - - public static int getPid(Process p) { - try { - Field f = p.getClass().getDeclaredField("pid"); - f.setAccessible(true); - try { - return f.getInt(p); - } finally { - f.setAccessible(false); - } - } catch (Throwable e) { - return -1; - } - } - - static String[] setupProcessArgs(String fileToExecute, String[] args) { - // 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 - // system /system/bin/sh. The system shell may vary and may not work at all due to LD_LIBRARY_PATH. - // - A file with shebang, which we try to handle with e.g. /bin/foo -> $PREFIX/bin/foo. - String interpreter = null; - try { - File file = new File(fileToExecute); - try (FileInputStream in = new FileInputStream(file)) { - byte[] buffer = new byte[256]; - int bytesRead = in.read(buffer); - if (bytesRead > 4) { - if (buffer[0] == 0x7F && buffer[1] == 'E' && buffer[2] == 'L' && buffer[3] == 'F') { - // Elf file, do nothing. - } else if (buffer[0] == '#' && buffer[1] == '!') { - // Try to parse shebang. - StringBuilder builder = new StringBuilder(); - for (int i = 2; i < bytesRead; i++) { - char c = (char) buffer[i]; - if (c == ' ' || c == '\n') { - if (builder.length() == 0) { - // Skip whitespace after shebang. - } else { - // End of shebang. - String executable = builder.toString(); - if (executable.startsWith("/usr") || executable.startsWith("/bin")) { - String[] parts = executable.split("/"); - String binary = parts[parts.length - 1]; - interpreter = TermuxService.PREFIX_PATH + "/bin/" + binary; - } - break; - } - } else { - builder.append(c); - } - } - } else { - // No shebang and no ELF, use standard shell. - interpreter = TermuxService.PREFIX_PATH + "/bin/sh"; - } - } - } - } catch (IOException e) { - // Ignore. - } - - List result = new ArrayList<>(); - if (interpreter != null) result.add(interpreter); - result.add(fileToExecute); - if (args != null) Collections.addAll(result, args); - return result.toArray(new String[0]); - } - -} diff --git a/app/src/main/java/com/termux/app/RunCommandService.java b/app/src/main/java/com/termux/app/RunCommandService.java index 8079091a..d81b3ec5 100644 --- a/app/src/main/java/com/termux/app/RunCommandService.java +++ b/app/src/main/java/com/termux/app/RunCommandService.java @@ -1,75 +1,35 @@ package com.termux.app; import android.app.Notification; -import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.Service; -import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.Binder; import android.os.Build; import android.os.IBinder; -import android.util.Log; import com.termux.R; - -import java.io.File; -import java.io.FileInputStream; -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; -import java.util.Properties; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.TermuxConstants.TERMUX_APP.RUN_COMMAND_SERVICE; +import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_SERVICE; +import com.termux.shared.file.FileUtils; +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.shared.models.ExecutionCommand; /** - * When allow-external-apps property is set to "true" in ~/.termux/termux.properties, Termux - * is able to process execute intents sent by third-party applications. + * 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. * - * Third-party program must declare com.termux.permission.RUN_COMMAND permission and it should be - * granted by user. - * - * Absolute path of command or script must be given in "RUN_COMMAND_PATH" extra. - * The "RUN_COMMAND_ARGUMENTS", "RUN_COMMAND_WORKDIR" and "RUN_COMMAND_BACKGROUND" extras are - * optional. The workdir defaults to termux home. The background mode defaults to "false". - * The command path and workdir can optionally be prefixed with "$PREFIX/" or "~/" if an absolute - * path is not to be given. - * - * To automatically bring to foreground and start termux commands that were started with - * background mode "false" in android >= 10 without user having to click the notification manually, - * requires termux to be granted draw over apps permission due to new restrictions - * of starting activities from the background, this also applies to Termux:Tasker plugin. - * - * To reduce the chance of termux being killed by android even further due to violation of not - * being able to call startForeground() within ~5s of service start in android >= 8, the user - * may disable battery optimizations for termux. - * - * 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); - * 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' - * --esa com.termux.RUN_COMMAND_ARGUMENTS '-n,5' - * --es com.termux.RUN_COMMAND_WORKDIR '/data/data/com.termux/files/home' - * --ez com.termux.RUN_COMMAND_BACKGROUND 'false' + * Check https://github.com/termux/termux-app/wiki/RUN_COMMAND-Intent for more info. */ public class RunCommandService extends Service { - public static final String RUN_COMMAND_ACTION = "com.termux.RUN_COMMAND"; - public static final String RUN_COMMAND_PATH = "com.termux.RUN_COMMAND_PATH"; - public static final String RUN_COMMAND_ARGUMENTS = "com.termux.RUN_COMMAND_ARGUMENTS"; - public static final String RUN_COMMAND_WORKDIR = "com.termux.RUN_COMMAND_WORKDIR"; - public static final String RUN_COMMAND_BACKGROUND = "com.termux.RUN_COMMAND_BACKGROUND"; - - private static final String NOTIFICATION_CHANNEL_ID = "termux_run_command_notification_channel"; - private static final int NOTIFICATION_ID = 1338; + private static final String LOG_TAG = "RunCommandService"; class LocalBinder extends Binder { public final RunCommandService service = RunCommandService.this; @@ -84,30 +44,133 @@ public class RunCommandService extends Service { @Override public void onCreate() { + Logger.logVerbose(LOG_TAG, "onCreate"); runStartForeground(); } @Override public int onStartCommand(Intent intent, int flags, int startId) { + Logger.logDebug(LOG_TAG, "onStartCommand"); + + if (intent == null) return Service.START_NOT_STICKY; + // Run again in case service is already started and onCreate() is not called runStartForeground(); - if (allowExternalApps() && RUN_COMMAND_ACTION.equals(intent.getAction())) { - Uri programUri = new Uri.Builder().scheme("com.termux.file").path(parsePath(intent.getStringExtra(RUN_COMMAND_PATH))).build(); + ExecutionCommand executionCommand = new ExecutionCommand(); + executionCommand.pluginAPIHelp = this.getString(R.string.error_run_command_service_api_help, RUN_COMMAND_SERVICE.RUN_COMMAND_API_HELP_URL); - Intent execIntent = new Intent(TermuxService.ACTION_EXECUTE, programUri); - execIntent.setClass(this, TermuxService.class); - execIntent.putExtra(TermuxService.EXTRA_ARGUMENTS, intent.getStringArrayExtra(RUN_COMMAND_ARGUMENTS)); - execIntent.putExtra(TermuxService.EXTRA_CURRENT_WORKING_DIRECTORY, parsePath(intent.getStringExtra(RUN_COMMAND_WORKDIR))); - execIntent.putExtra(TermuxService.EXTRA_EXECUTE_IN_BACKGROUND, intent.getBooleanExtra(RUN_COMMAND_BACKGROUND, false)); + String errmsg; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - this.startForegroundService(execIntent); - } else { - this.startService(execIntent); + // If invalid action passed, then just return + if (!RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND.equals(intent.getAction())) { + errmsg = this.getString(R.string.error_run_command_service_invalid_intent_action, intent.getAction()); + executionCommand.setStateFailed(ExecutionCommand.RESULT_CODE_FAILED, errmsg, null); + PluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false); + return Service.START_NOT_STICKY; + } + + executionCommand.executable = intent.getStringExtra(RUN_COMMAND_SERVICE.EXTRA_COMMAND_PATH); + executionCommand.arguments = intent.getStringArrayExtra(RUN_COMMAND_SERVICE.EXTRA_ARGUMENTS); + executionCommand.stdin = intent.getStringExtra(RUN_COMMAND_SERVICE.EXTRA_STDIN); + executionCommand.workingDirectory = intent.getStringExtra(RUN_COMMAND_SERVICE.EXTRA_WORKDIR); + executionCommand.inBackground = intent.getBooleanExtra(RUN_COMMAND_SERVICE.EXTRA_BACKGROUND, false); + executionCommand.sessionAction = intent.getStringExtra(RUN_COMMAND_SERVICE.EXTRA_SESSION_ACTION); + executionCommand.commandLabel = DataUtils.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.isPluginExecutionCommand = true; + executionCommand.pluginPendingIntent = intent.getParcelableExtra(RUN_COMMAND_SERVICE.EXTRA_PENDING_INTENT); + + + + // If "allow-external-apps" property to not set to "true", then just return + // We enable force notifications if "allow-external-apps" policy is violated so that the + // user knows someone tried to run a command in termux context, since it may be malicious + // app or imported (tasker) plugin project and not the user himself. If a pending intent is + // also sent, then its creator is also logged and shown. + errmsg = PluginUtils.checkIfRunCommandServiceAllowExternalAppsPolicyIsViolated(this); + if (errmsg != null) { + executionCommand.setStateFailed(ExecutionCommand.RESULT_CODE_FAILED, errmsg, null); + PluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, true); + return Service.START_NOT_STICKY; + } + + + + // If executable is null or empty, then exit here instead of getting canonical path which would expand to "/" + if (executionCommand.executable == null || executionCommand.executable.isEmpty()) { + errmsg = this.getString(R.string.error_run_command_service_mandatory_extra_missing, RUN_COMMAND_SERVICE.EXTRA_COMMAND_PATH); + executionCommand.setStateFailed(ExecutionCommand.RESULT_CODE_FAILED, errmsg, null); + PluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false); + return Service.START_NOT_STICKY; + } + + // Get canonical path of executable + executionCommand.executable = FileUtils.getCanonicalPath(executionCommand.executable, null, true); + + // If executable is not a regular file, or is not readable or executable, then just return + // Setting of missing read and execute permissions is not done + errmsg = FileUtils.validateRegularFileExistenceAndPermissions(this, "executable", executionCommand.executable, null, + PluginUtils.PLUGIN_EXECUTABLE_FILE_PERMISSIONS, true, true, + false); + if (errmsg != null) { + errmsg += "\n" + this.getString(R.string.msg_executable_absolute_path, executionCommand.executable); + executionCommand.setStateFailed(ExecutionCommand.RESULT_CODE_FAILED, errmsg, null); + PluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false); + return Service.START_NOT_STICKY; + } + + + + // If workingDirectory is not null or empty + if (executionCommand.workingDirectory != null && !executionCommand.workingDirectory.isEmpty()) { + // Get canonical path of workingDirectory + executionCommand.workingDirectory = FileUtils.getCanonicalPath(executionCommand.workingDirectory, null, true); + + // If workingDirectory is not a directory, or is not readable or writable, then just return + // Creation of missing directory and setting of read, write and execute permissions are only done if workingDirectory is + // under {@link TermuxConstants#TERMUX_FILES_DIR_PATH} + // We try to set execute permissions, but ignore if they are missing, since only read and write permissions are required + // for working directories. + errmsg = FileUtils.validateDirectoryFileExistenceAndPermissions(this, "working", executionCommand.workingDirectory, TermuxConstants.TERMUX_FILES_DIR_PATH, true, + PluginUtils.PLUGIN_WORKING_DIRECTORY_PERMISSIONS, true, true, + true, true); + if (errmsg != null) { + errmsg += "\n" + this.getString(R.string.msg_working_directory_absolute_path, executionCommand.workingDirectory); + executionCommand.setStateFailed(ExecutionCommand.RESULT_CODE_FAILED, errmsg, null); + PluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false); + return Service.START_NOT_STICKY; } } + + + executionCommand.executableUri = new Uri.Builder().scheme(TERMUX_SERVICE.URI_SCHEME_SERVICE_EXECUTE).path(FileUtils.getExpandedTermuxPath(executionCommand.executable)).build(); + + Logger.logVerbose(LOG_TAG, executionCommand.toString()); + + // Create execution intent with the action TERMUX_SERVICE#ACTION_SERVICE_EXECUTE to be sent to the TERMUX_SERVICE + Intent execIntent = new Intent(TERMUX_SERVICE.ACTION_SERVICE_EXECUTE, executionCommand.executableUri); + execIntent.setClass(this, TermuxService.class); + execIntent.putExtra(TERMUX_SERVICE.EXTRA_ARGUMENTS, executionCommand.arguments); + execIntent.putExtra(TERMUX_SERVICE.EXTRA_STDIN, executionCommand.stdin); + if (executionCommand.workingDirectory != null && !executionCommand.workingDirectory.isEmpty()) execIntent.putExtra(TERMUX_SERVICE.EXTRA_WORKDIR, executionCommand.workingDirectory); + execIntent.putExtra(TERMUX_SERVICE.EXTRA_BACKGROUND, executionCommand.inBackground); + execIntent.putExtra(TERMUX_SERVICE.EXTRA_SESSION_ACTION, executionCommand.sessionAction); + execIntent.putExtra(TERMUX_SERVICE.EXTRA_COMMAND_LABEL, executionCommand.commandLabel); + 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) { + this.startForegroundService(execIntent); + } else { + this.startService(execIntent); + } + runStopForeground(); return Service.START_NOT_STICKY; @@ -116,7 +179,7 @@ public class RunCommandService extends Service { private void runStartForeground() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { setupNotificationChannel(); - startForeground(NOTIFICATION_ID, buildNotification()); + startForeground(TermuxConstants.TERMUX_RUN_COMMAND_NOTIFICATION_ID, buildNotification()); } } @@ -127,22 +190,21 @@ public class RunCommandService extends Service { } private Notification buildNotification() { - Notification.Builder builder = new Notification.Builder(this); - builder.setContentTitle(getText(R.string.application_name) + " Run Command"); - builder.setSmallIcon(R.drawable.ic_service_notification); - - // Use a low priority: - builder.setPriority(Notification.PRIORITY_LOW); + // Build the notification + Notification.Builder builder = NotificationUtils.geNotificationBuilder(this, + TermuxConstants.TERMUX_RUN_COMMAND_NOTIFICATION_CHANNEL_ID, Notification.PRIORITY_LOW, + TermuxConstants.TERMUX_RUN_COMMAND_NOTIFICATION_CHANNEL_NAME, null, null, + null, NotificationUtils.NOTIFICATION_MODE_SILENT); + if (builder == null) return null; // No need to show a timestamp: builder.setShowWhen(false); - // Background color for small notification icon: - builder.setColor(0xFF607D8B); + // Set notification icon + builder.setSmallIcon(R.drawable.ic_service_notification); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - builder.setChannelId(NOTIFICATION_CHANNEL_ID); - } + // Set background color for small notification icon + builder.setColor(0xFF607D8B); return builder.build(); } @@ -150,40 +212,8 @@ public class RunCommandService extends Service { private void setupNotificationChannel() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; - String channelName = "Termux Run Command"; - int importance = NotificationManager.IMPORTANCE_LOW; - - NotificationChannel channel = new NotificationChannel(NOTIFICATION_CHANNEL_ID, channelName, importance); - NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - manager.createNotificationChannel(channel); + NotificationUtils.setupNotificationChannel(this, TermuxConstants.TERMUX_RUN_COMMAND_NOTIFICATION_CHANNEL_ID, + TermuxConstants.TERMUX_RUN_COMMAND_NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW); } - private boolean allowExternalApps() { - File propsFile = new File(TermuxService.HOME_PATH + "/.termux/termux.properties"); - if (!propsFile.exists()) - propsFile = new File(TermuxService.HOME_PATH + "/.config/termux/termux.properties"); - - Properties props = new Properties(); - try { - if (propsFile.isFile() && propsFile.canRead()) { - try (FileInputStream in = new FileInputStream(propsFile)) { - props.load(new InputStreamReader(in, StandardCharsets.UTF_8)); - } - } - } catch (Exception e) { - Log.e("termux", "Error loading props", e); - } - - return props.getProperty("allow-external-apps", "false").equals("true"); - } - - /** Replace "$PREFIX/" or "~/" prefix with termux absolute paths */ - private String parsePath(String path) { - if(path != null && !path.isEmpty()) { - path = path.replaceAll("^\\$PREFIX\\/", TermuxService.PREFIX_PATH + "/"); - path = path.replaceAll("^~\\/", TermuxService.HOME_PATH + "/"); - } - - return path; - } } diff --git a/app/src/main/java/com/termux/app/TermuxActivity.java b/app/src/main/java/com/termux/app/TermuxActivity.java index 280d9378..f323d0fe 100644 --- a/app/src/main/java/com/termux/app/TermuxActivity.java +++ b/app/src/main/java/com/termux/app/TermuxActivity.java @@ -2,38 +2,23 @@ package com.termux.app; import android.Manifest; import android.annotation.SuppressLint; -import android.annotation.TargetApi; import android.app.Activity; import android.app.AlertDialog; import android.content.ActivityNotFoundException; import android.content.BroadcastReceiver; -import android.content.ClipData; -import android.content.ClipboardManager; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.ServiceConnection; import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.Typeface; -import android.media.AudioAttributes; -import android.media.SoundPool; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.os.IBinder; -import android.text.SpannableString; -import android.text.Spanned; -import android.text.TextUtils; -import android.text.style.StyleSpan; -import android.util.Log; import android.view.ContextMenu; import android.view.ContextMenu.ContextMenuInfo; import android.view.Gravity; -import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; import android.view.View; @@ -41,35 +26,35 @@ import android.view.ViewGroup; import android.view.WindowManager; import android.view.autofill.AutofillManager; import android.view.inputmethod.InputMethodManager; -import android.widget.ArrayAdapter; import android.widget.EditText; import android.widget.ListView; -import android.widget.TextView; import android.widget.Toast; import com.termux.R; -import com.termux.terminal.EmulatorDebug; -import com.termux.terminal.TerminalColors; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_ACTIVITY; +import com.termux.app.activities.HelpActivity; +import com.termux.app.activities.SettingsActivity; +import com.termux.shared.settings.preferences.TermuxAppSharedPreferences; +import com.termux.app.terminal.TermuxSessionsListViewController; +import com.termux.app.terminal.io.TerminalToolbarViewPager; +import com.termux.app.terminal.TermuxTerminalSessionClient; +import com.termux.app.terminal.TermuxTerminalViewClient; +import com.termux.app.terminal.io.extrakeys.ExtraKeysView; +import com.termux.app.settings.properties.TermuxAppSharedProperties; +import com.termux.shared.interact.DialogUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.termux.TermuxUtils; import com.termux.terminal.TerminalSession; -import com.termux.terminal.TerminalSession.SessionChangedCallback; -import com.termux.terminal.TextStyle; +import com.termux.terminal.TerminalSessionClient; +import com.termux.app.utils.CrashUtils; import com.termux.view.TerminalView; - -import java.io.File; -import java.io.FileInputStream; -import java.io.InputStream; -import java.util.Arrays; -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Properties; -import java.util.regex.Matcher; -import java.util.regex.Pattern; +import com.termux.view.TerminalViewClient; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; import androidx.drawerlayout.widget.DrawerLayout; -import androidx.viewpager.widget.PagerAdapter; import androidx.viewpager.widget.ViewPager; /** @@ -84,140 +69,106 @@ import androidx.viewpager.widget.ViewPager; */ public final class TermuxActivity extends Activity implements ServiceConnection { - public static final String TERMUX_FAILSAFE_SESSION_ACTION = "com.termux.app.failsafe_session"; - - private static final int CONTEXTMENU_SELECT_URL_ID = 0; - private static final int CONTEXTMENU_SHARE_TRANSCRIPT_ID = 1; - private static final int CONTEXTMENU_PASTE_ID = 3; - private static final int CONTEXTMENU_KILL_PROCESS_ID = 4; - private static final int CONTEXTMENU_RESET_TERMINAL_ID = 5; - private static final int CONTEXTMENU_STYLING_ID = 6; - private static final int CONTEXTMENU_HELP_ID = 8; - private static final int CONTEXTMENU_TOGGLE_KEEP_SCREEN_ON = 9; - private static final int CONTEXTMENU_AUTOFILL_ID = 10; - - private static final int MAX_SESSIONS = 8; - - private static final int REQUESTCODE_PERMISSION_STORAGE = 1234; - - private static final String RELOAD_STYLE_ACTION = "com.termux.app.reload_style"; - - private static final String BROADCAST_TERMUX_OPENED = "com.termux.app.OPENED"; - - /** The main view of the activity showing the terminal. Initialized in onCreate(). */ - @SuppressWarnings("NullableProblems") - @NonNull - TerminalView mTerminalView; - - ExtraKeysView mExtraKeysView; - - TermuxPreferences mSettings; /** * The connection to the {@link TermuxService}. Requested in {@link #onCreate(Bundle)} with a call to * {@link #bindService(Intent, ServiceConnection, int)}, and obtained and stored in * {@link #onServiceConnected(ComponentName, IBinder)}. */ - TermuxService mTermService; + TermuxService mTermuxService; - /** Initialized in {@link #onServiceConnected(ComponentName, IBinder)}. */ - ArrayAdapter mListViewAdapter; + /** + * The main view of the activity showing the terminal. Initialized in onCreate(). + */ + TerminalView mTerminalView; - /** The last toast shown, used cancel current toast before showing new in {@link #showToast(String, boolean)}. */ + /** + * The {@link TerminalViewClient} interface implementation to allow for communication between + * {@link TerminalView} and {@link TermuxActivity}. + */ + TermuxTerminalViewClient mTermuxTerminalViewClient; + + /** + * The {@link TerminalSessionClient} interface implementation to allow for communication between + * {@link TerminalSession} and {@link TermuxActivity}. + */ + TermuxTerminalSessionClient mTermuxTerminalSessionClient; + + /** + * Termux app shared preferences manager. + */ + private TermuxAppSharedPreferences mPreferences; + + /** + * Termux app shared properties manager, loaded from termux.properties + */ + private TermuxAppSharedProperties mProperties; + + /** + * The terminal extra keys view. + */ + ExtraKeysView mExtraKeysView; + + /** + * The termux sessions list controller. + */ + TermuxSessionsListViewController mTermuxSessionListViewController; + + /** + * The {@link TermuxActivity} broadcast receiver for various things like terminal style configuration changes. + */ + private final BroadcastReceiver mTermuxActivityBroadcastReceiver = new TermuxActivityBroadcastReceiver(); + + /** + * The last toast shown, used cancel current toast before showing new in {@link #showToast(String, boolean)}. + */ Toast mLastToast; /** * If between onResume() and onStop(). Note that only one session is in the foreground of the terminal view at the * time, so if the session causing a change is not in the foreground it should probably be treated as background. */ - boolean mIsVisible; + private boolean mIsVisible; - boolean mIsUsingBlackUI; + private int mNavBarHeight; - int mNavBarHeight; + private int mTerminalToolbarDefaultHeight; - final SoundPool mBellSoundPool = new SoundPool.Builder().setMaxStreams(1).setAudioAttributes( - new AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) - .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION).build()).build(); - int mBellSoundId; + private static final int CONTEXT_MENU_SELECT_URL_ID = 0; + private static final int CONTEXT_MENU_SHARE_TRANSCRIPT_ID = 1; + private static final int CONTEXT_MENU_AUTOFILL_ID = 2; + private static final int CONTEXT_MENU_RESET_TERMINAL_ID = 3; + private static final int CONTEXT_MENU_KILL_PROCESS_ID = 4; + private static final int CONTEXT_MENU_STYLING_ID = 5; + private static final int CONTEXT_MENU_TOGGLE_KEEP_SCREEN_ON = 6; + private static final int CONTEXT_MENU_HELP_ID = 7; + private static final int CONTEXT_MENU_SETTINGS_ID = 8; + private static final int CONTEXT_MENU_REPORT_ID = 9; - private final BroadcastReceiver mBroadcastReceiever = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - if (mIsVisible) { - String whatToReload = intent.getStringExtra(RELOAD_STYLE_ACTION); - if ("storage".equals(whatToReload)) { - if (ensureStoragePermissionGranted()) - TermuxInstaller.setupStorageSymlinks(TermuxActivity.this); - return; - } - checkForFontAndColors(); - mSettings.reloadFromProperties(TermuxActivity.this); + private static final int REQUESTCODE_PERMISSION_STORAGE = 1234; - if (mExtraKeysView != null) { - mExtraKeysView.reload(mSettings.mExtraKeys); - } - } - } - }; + private static final String ARG_TERMINAL_TOOLBAR_TEXT_INPUT = "terminal_toolbar_text_input"; - void checkForFontAndColors() { - try { - @SuppressLint("SdCardPath") File fontFile = new File("/data/data/com.termux/files/home/.termux/font.ttf"); - @SuppressLint("SdCardPath") File colorsFile = new File("/data/data/com.termux/files/home/.termux/colors.properties"); - - final Properties props = new Properties(); - if (colorsFile.isFile()) { - try (InputStream in = new FileInputStream(colorsFile)) { - props.load(in); - } - } - - TerminalColors.COLOR_SCHEME.updateWith(props); - TerminalSession session = getCurrentTermSession(); - if (session != null && session.getEmulator() != null) { - session.getEmulator().mColors.reset(); - } - updateBackgroundColor(); - - final Typeface newTypeface = (fontFile.exists() && fontFile.length() > 0) ? Typeface.createFromFile(fontFile) : Typeface.MONOSPACE; - mTerminalView.setTypeface(newTypeface); - } catch (Exception e) { - Log.e(EmulatorDebug.LOG_TAG, "Error in checkForFontAndColors()", e); - } - } - - void updateBackgroundColor() { - TerminalSession session = getCurrentTermSession(); - if (session != null && session.getEmulator() != null) { - getWindow().getDecorView().setBackgroundColor(session.getEmulator().mColors.mCurrentColors[TextStyle.COLOR_INDEX_BACKGROUND]); - } - } - - /** For processes to access shared internal storage (/sdcard) we need this permission. */ - public boolean ensureStoragePermissionGranted() { - if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) { - return true; - } else { - requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, REQUESTCODE_PERMISSION_STORAGE); - return false; - } - } + private static final String LOG_TAG = "TermuxActivity"; @Override - public void onCreate(Bundle bundle) { - mSettings = new TermuxPreferences(this); - mIsUsingBlackUI = mSettings.isUsingBlackUI(); - if (mIsUsingBlackUI) { - this.setTheme(R.style.Theme_Termux_Black); - } else { - this.setTheme(R.style.Theme_Termux); - } + public void onCreate(Bundle savedInstanceState) { - super.onCreate(bundle); + Logger.logDebug(LOG_TAG, "onCreate"); + // Check if a crash happened on last run of the app and show a + // notification with the crash details if it did + CrashUtils.notifyCrash(this, LOG_TAG); - setContentView(R.layout.drawer_layout); + // Load termux shared preferences and properties + mPreferences = new TermuxAppSharedPreferences(this); + mProperties = new TermuxAppSharedProperties(this); + + setActivityTheme(); + + super.onCreate(savedInstanceState); + + setContentView(R.layout.activity_termux); View content = findViewById(android.R.id.content); content.setOnApplyWindowInsetsListener((v, insets) -> { @@ -225,161 +176,68 @@ public final class TermuxActivity extends Activity implements ServiceConnection return insets; }); - if (mSettings.isUsingFullScreen()) { + if (mProperties.isUsingFullScreen()) { getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); } - if (mIsUsingBlackUI) { - findViewById(R.id.left_drawer).setBackgroundColor( - getResources().getColor(android.R.color.background_dark) - ); - } + setDrawerTheme(); - mTerminalView = findViewById(R.id.terminal_view); - mTerminalView.setOnKeyListener(new TermuxViewClient(this)); + setTermuxTerminalViewAndClients(); - mTerminalView.setTextSize(mSettings.getFontSize()); - mTerminalView.setKeepScreenOn(mSettings.isScreenAlwaysOn()); - mTerminalView.requestFocus(); + setTerminalToolbarView(savedInstanceState); - final ViewPager viewPager = findViewById(R.id.viewpager); - if (mSettings.mShowExtraKeys) viewPager.setVisibility(View.VISIBLE); + setNewSessionButtonView(); - - ViewGroup.LayoutParams layoutParams = viewPager.getLayoutParams(); - layoutParams.height = layoutParams.height * (mSettings.mExtraKeys == null ? 0 : mSettings.mExtraKeys.getMatrix().length); - viewPager.setLayoutParams(layoutParams); - - viewPager.setAdapter(new PagerAdapter() { - @Override - public int getCount() { - return 2; - } - - @Override - public boolean isViewFromObject(@NonNull View view, @NonNull Object object) { - return view == object; - } - - @NonNull - @Override - public Object instantiateItem(@NonNull ViewGroup collection, int position) { - LayoutInflater inflater = LayoutInflater.from(TermuxActivity.this); - View layout; - if (position == 0) { - layout = mExtraKeysView = (ExtraKeysView) inflater.inflate(R.layout.extra_keys_main, collection, false); - mExtraKeysView.reload(mSettings.mExtraKeys); - - // apply extra keys fix if enabled in prefs - if (mSettings.isUsingFullScreen() && mSettings.isUsingFullScreenWorkAround()) { - FullScreenWorkAround.apply(TermuxActivity.this); - } - - } else { - layout = inflater.inflate(R.layout.extra_keys_right, collection, false); - final EditText editText = layout.findViewById(R.id.text_input); - editText.setOnEditorActionListener((v, actionId, event) -> { - TerminalSession session = getCurrentTermSession(); - if (session != null) { - if (session.isRunning()) { - String textToSend = editText.getText().toString(); - if (textToSend.length() == 0) textToSend = "\r"; - session.write(textToSend); - } else { - removeFinishedSession(session); - } - editText.setText(""); - } - return true; - }); - } - collection.addView(layout); - return layout; - } - - @Override - public void destroyItem(@NonNull ViewGroup collection, int position, @NonNull Object view) { - collection.removeView((View) view); - } - }); - - viewPager.addOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() { - @Override - public void onPageSelected(int position) { - if (position == 0) { - mTerminalView.requestFocus(); - } else { - final EditText editText = viewPager.findViewById(R.id.text_input); - if (editText != null) editText.requestFocus(); - } - } - }); - - View newSessionButton = findViewById(R.id.new_session_button); - newSessionButton.setOnClickListener(v -> addNewSession(false, null)); - newSessionButton.setOnLongClickListener(v -> { - DialogUtils.textInput(TermuxActivity.this, R.string.session_new_named_title, null, R.string.session_new_named_positive_button, - text -> addNewSession(false, text), R.string.new_session_failsafe, text -> addNewSession(true, text) - , -1, null, null); - return true; - }); - - findViewById(R.id.toggle_keyboard_button).setOnClickListener(v -> { - InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); - imm.toggleSoftInput(InputMethodManager.SHOW_IMPLICIT, 0); - getDrawer().closeDrawers(); - }); - - findViewById(R.id.toggle_keyboard_button).setOnLongClickListener(v -> { - toggleShowExtraKeys(); - return true; - }); + setToggleKeyboardView(); registerForContextMenu(mTerminalView); + // Start the {@link TermuxService} and make it run regardless of who is bound to it Intent serviceIntent = new Intent(this, TermuxService.class); - // Start the service and make it run regardless of who is bound to it: startService(serviceIntent); + + // Attempt to bind to the service, this will call the {@link #onServiceConnected(ComponentName, IBinder)} + // callback if it succeeds. if (!bindService(serviceIntent, this, 0)) throw new RuntimeException("bindService() failed"); - checkForFontAndColors(); - - mBellSoundId = mBellSoundPool.load(this, R.raw.bell, 1); - - sendOpenedBroadcast(); + // Send the {@link TermuxConstants#BROADCAST_TERMUX_OPENED} broadcast to notify apps that Termux + // app has been opened. + TermuxUtils.sendTermuxOpenedBroadcast(this); } - public int getNavBarHeight() { - return mNavBarHeight; - } + @Override + public void onStart() { + super.onStart(); - /** - * Send a broadcast notifying Termux app has been opened - */ - void sendOpenedBroadcast() { - Intent broadcast = new Intent(BROADCAST_TERMUX_OPENED); - List matches = getPackageManager().queryBroadcastReceivers(broadcast, 0); + Logger.logDebug(LOG_TAG, "onStart"); - // send broadcast to registered Termux receivers - // this technique is needed to work around broadcast changes that Oreo introduced - for (ResolveInfo info : matches) { - Intent explicitBroadcast = new Intent(broadcast); - ComponentName cname = new ComponentName(info.activityInfo.applicationInfo.packageName, - info.activityInfo.name); - explicitBroadcast.setComponent(cname); - sendBroadcast(explicitBroadcast); + mIsVisible = true; + + if (mTermuxService != null) { + // The service has connected, but data may have changed since we were last in the foreground. + // Get the session stored in shared preferences stored by {@link #onStop} if its valid, + // otherwise get the last session currently running. + mTermuxTerminalSessionClient.setCurrentSession(mTermuxTerminalSessionClient.getCurrentStoredSessionOrLast()); + termuxSessionListNotifyUpdated(); } + + registerTermuxActivityBroadcastReceiver(); + + // If user changed the preference from {@link TermuxSettings} activity and returns, then + // update the {@link TerminalView#TERMINAL_VIEW_KEY_LOGGING_ENABLED} value. + mTerminalView.setIsTerminalViewKeyLoggingEnabled(mPreferences.getTerminalViewKeyLoggingEnabled()); + + // The current terminal session may have changed while being away, force + // a refresh of the displayed terminal. + mTerminalView.onScreenUpdated(); } - void toggleShowExtraKeys() { - final ViewPager viewPager = findViewById(R.id.viewpager); - final boolean showNow = mSettings.toggleShowExtraKeys(TermuxActivity.this); - viewPager.setVisibility(showNow ? View.VISIBLE : View.GONE); - if (showNow && viewPager.getCurrentItem() == 1) { - // Focus the text input view if just revealed. - findViewById(R.id.text_input).requestFocus(); - } + @Override + public void onResume() { + super.onResume(); + + setSoftKeyboardState(); } /** @@ -389,338 +247,287 @@ public final class TermuxActivity extends Activity implements ServiceConnection */ @Override public void onServiceConnected(ComponentName componentName, IBinder service) { - mTermService = ((TermuxService.LocalBinder) service).service; - mTermService.mSessionChangeCallback = new SessionChangedCallback() { - @Override - public void onTextChanged(TerminalSession changedSession) { - if (!mIsVisible) return; - if (getCurrentTermSession() == changedSession) mTerminalView.onScreenUpdated(); - } + Logger.logDebug(LOG_TAG, "onServiceConnected"); - @Override - public void onTitleChanged(TerminalSession updatedSession) { - if (!mIsVisible) return; - if (updatedSession != getCurrentTermSession()) { - // Only show toast for other sessions than the current one, since the user - // probably consciously caused the title change to change in the current session - // and don't want an annoying toast for that. - showToast(toToastTitle(updatedSession), false); - } - mListViewAdapter.notifyDataSetChanged(); - } + mTermuxService = ((TermuxService.LocalBinder) service).service; - @Override - public void onSessionFinished(final TerminalSession finishedSession) { - if (mTermService.mWantsToStop) { - // The service wants to stop as soon as possible. - finish(); - return; - } - if (mIsVisible && finishedSession != getCurrentTermSession()) { - // Show toast for non-current sessions that exit. - int indexOfSession = mTermService.getSessions().indexOf(finishedSession); - // Verify that session was not removed before we got told about it finishing: - if (indexOfSession >= 0) - showToast(toToastTitle(finishedSession) + " - exited", true); - } + setTermuxSessionsListView(); - if (getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK)) { - // On Android TV devices we need to use older behaviour because we may - // not be able to have multiple launcher icons. - if (mTermService.getSessions().size() > 1) { - removeFinishedSession(finishedSession); - } - } else { - // Once we have a separate launcher icon for the failsafe session, it - // should be safe to auto-close session on exit code '0' or '130'. - if (finishedSession.getExitStatus() == 0 || finishedSession.getExitStatus() == 130) { - removeFinishedSession(finishedSession); - } - } - - mListViewAdapter.notifyDataSetChanged(); - } - - @Override - public void onClipboardText(TerminalSession session, String text) { - if (!mIsVisible) return; - ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); - clipboard.setPrimaryClip(new ClipData(null, new String[]{"text/plain"}, new ClipData.Item(text))); - } - - @Override - public void onBell(TerminalSession session) { - if (!mIsVisible) return; - - switch (mSettings.mBellBehaviour) { - case TermuxPreferences.BELL_BEEP: - mBellSoundPool.play(mBellSoundId, 1.f, 1.f, 1, 0, 1.f); - break; - case TermuxPreferences.BELL_VIBRATE: - BellUtil.getInstance(TermuxActivity.this).doBell(); - break; - case TermuxPreferences.BELL_IGNORE: - // Ignore the bell character. - break; - } - - } - - @Override - public void onColorsChanged(TerminalSession changedSession) { - if (getCurrentTermSession() == changedSession) updateBackgroundColor(); - } - }; - - ListView listView = findViewById(R.id.left_drawer_list); - mListViewAdapter = new ArrayAdapter(getApplicationContext(), R.layout.line_in_drawer, mTermService.getSessions()) { - final StyleSpan boldSpan = new StyleSpan(Typeface.BOLD); - final StyleSpan italicSpan = new StyleSpan(Typeface.ITALIC); - - @NonNull - @Override - public View getView(int position, View convertView, @NonNull ViewGroup parent) { - View row = convertView; - if (row == null) { - LayoutInflater inflater = getLayoutInflater(); - row = inflater.inflate(R.layout.line_in_drawer, parent, false); - } - - TerminalSession sessionAtRow = getItem(position); - boolean sessionRunning = sessionAtRow.isRunning(); - - TextView firstLineView = row.findViewById(R.id.row_line); - if (mIsUsingBlackUI) { - firstLineView.setBackground( - getResources().getDrawable(R.drawable.selected_session_background_black) - ); - } - String name = sessionAtRow.mSessionName; - String sessionTitle = sessionAtRow.getTitle(); - - String numberPart = "[" + (position + 1) + "] "; - String sessionNamePart = (TextUtils.isEmpty(name) ? "" : name); - String sessionTitlePart = (TextUtils.isEmpty(sessionTitle) ? "" : ((sessionNamePart.isEmpty() ? "" : "\n") + sessionTitle)); - - String text = numberPart + sessionNamePart + sessionTitlePart; - SpannableString styledText = new SpannableString(text); - styledText.setSpan(boldSpan, 0, numberPart.length() + sessionNamePart.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - styledText.setSpan(italicSpan, numberPart.length() + sessionNamePart.length(), text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - - firstLineView.setText(styledText); - - if (sessionRunning) { - firstLineView.setPaintFlags(firstLineView.getPaintFlags() & ~Paint.STRIKE_THRU_TEXT_FLAG); - } else { - firstLineView.setPaintFlags(firstLineView.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG); - } - int defaultColor = mIsUsingBlackUI ? Color.WHITE : Color.BLACK; - int color = sessionRunning || sessionAtRow.getExitStatus() == 0 ? defaultColor : Color.RED; - firstLineView.setTextColor(color); - return row; - } - }; - listView.setAdapter(mListViewAdapter); - listView.setOnItemClickListener((parent, view, position, id) -> { - TerminalSession clickedSession = mListViewAdapter.getItem(position); - switchToSession(clickedSession); - getDrawer().closeDrawers(); - }); - listView.setOnItemLongClickListener((parent, view, position, id) -> { - final TerminalSession selectedSession = mListViewAdapter.getItem(position); - renameSession(selectedSession); - return true; - }); - - if (mTermService.getSessions().isEmpty()) { + if (mTermuxService.isTermuxSessionsEmpty()) { if (mIsVisible) { - TermuxInstaller.setupIfNeeded(TermuxActivity.this, () -> { - if (mTermService == null) return; // Activity might have been destroyed. + TermuxInstaller.setupBootstrapIfNeeded(TermuxActivity.this, () -> { + if (mTermuxService == null) return; // Activity might have been destroyed. try { Bundle bundle = getIntent().getExtras(); boolean launchFailsafe = false; if (bundle != null) { - launchFailsafe = bundle.getBoolean(TERMUX_FAILSAFE_SESSION_ACTION, false); + launchFailsafe = bundle.getBoolean(TERMUX_ACTIVITY.ACTION_FAILSAFE_SESSION, false); } - addNewSession(launchFailsafe, null); + mTermuxTerminalSessionClient.addNewSession(launchFailsafe, null); } catch (WindowManager.BadTokenException e) { // Activity finished - ignore. } }); } else { // The service connected while not in foreground - just bail out. - finish(); + finishActivityIfNotFinishing(); } } else { Intent i = getIntent(); if (i != null && Intent.ACTION_RUN.equals(i.getAction())) { // Android 7.1 app shortcut from res/xml/shortcuts.xml. - boolean failSafe = i.getBooleanExtra(TERMUX_FAILSAFE_SESSION_ACTION, false); - addNewSession(failSafe, null); + boolean isFailSafe = i.getBooleanExtra(TERMUX_ACTIVITY.ACTION_FAILSAFE_SESSION, false); + mTermuxTerminalSessionClient.addNewSession(isFailSafe, null); } else { - switchToSession(getStoredCurrentSessionOrLast()); + mTermuxTerminalSessionClient.setCurrentSession(mTermuxTerminalSessionClient.getCurrentStoredSessionOrLast()); } } - } - public void switchToSession(boolean forward) { - TerminalSession currentSession = getCurrentTermSession(); - int index = mTermService.getSessions().indexOf(currentSession); - if (forward) { - if (++index >= mTermService.getSessions().size()) index = 0; - } else { - if (--index < 0) index = mTermService.getSessions().size() - 1; - } - switchToSession(mTermService.getSessions().get(index)); - } - - @SuppressLint("InflateParams") - void renameSession(final TerminalSession sessionToRename) { - DialogUtils.textInput(this, R.string.session_rename_title, sessionToRename.mSessionName, R.string.session_rename_positive_button, text -> { - sessionToRename.mSessionName = text; - mListViewAdapter.notifyDataSetChanged(); - }, -1, null, -1, null, null); + // Update the {@link TerminalSession} and {@link TerminalEmulator} clients. + mTermuxService.setTermuxTerminalSessionClient(mTermuxTerminalSessionClient); } @Override public void onServiceDisconnected(ComponentName name) { - // Respect being stopped from the TermuxService notification action. - finish(); - } - @Nullable - TerminalSession getCurrentTermSession() { - return mTerminalView.getCurrentSession(); - } + Logger.logDebug(LOG_TAG, "onServiceDisconnected"); - @Override - public void onStart() { - super.onStart(); - mIsVisible = true; - - if (mTermService != null) { - // The service has connected, but data may have changed since we were last in the foreground. - switchToSession(getStoredCurrentSessionOrLast()); - mListViewAdapter.notifyDataSetChanged(); - } - - registerReceiver(mBroadcastReceiever, new IntentFilter(RELOAD_STYLE_ACTION)); - - // The current terminal session may have changed while being away, force - // a refresh of the displayed terminal: - mTerminalView.onScreenUpdated(); + // Respect being stopped from the {@link TermuxService} notification action. + finishActivityIfNotFinishing(); } @Override protected void onStop() { super.onStop(); - mIsVisible = false; - TerminalSession currentSession = getCurrentTermSession(); - if (currentSession != null) TermuxPreferences.storeCurrentSession(this, currentSession); - unregisterReceiver(mBroadcastReceiever); - getDrawer().closeDrawers(); - } - @Override - public void onBackPressed() { - if (getDrawer().isDrawerOpen(Gravity.LEFT)) { - getDrawer().closeDrawers(); - } else { - finish(); - } + Logger.logDebug(LOG_TAG, "onStop"); + + mIsVisible = false; + + // Store current session in shared preferences so that it can be restored later in + // {@link #onStart} if needed. + mTermuxTerminalSessionClient.setCurrentStoredSession(); + + unregisterTermuxActivityBroadcastReceiever(); + getDrawer().closeDrawers(); } @Override public void onDestroy() { super.onDestroy(); - if (mTermService != null) { - // Do not leave service with references to activity. - mTermService.mSessionChangeCallback = null; - mTermService = null; + + Logger.logDebug(LOG_TAG, "onDestroy"); + + if (mTermuxService != null) { + // Do not leave service and session clients with references to activity. + mTermuxService.unsetTermuxTerminalSessionClient(); + mTermuxService = null; } unbindService(this); } - DrawerLayout getDrawer() { - return (DrawerLayout) findViewById(R.id.drawer_layout); + @Override + public void onSaveInstanceState(@NonNull Bundle savedInstanceState) { + super.onSaveInstanceState(savedInstanceState); + saveTerminalToolbarTextInput(savedInstanceState); } - void addNewSession(boolean failSafe, String sessionName) { - if (mTermService.getSessions().size() >= MAX_SESSIONS) { - new AlertDialog.Builder(this).setTitle(R.string.max_terminals_reached_title).setMessage(R.string.max_terminals_reached_message) - .setPositiveButton(android.R.string.ok, null).show(); + + + private void setActivityTheme() { + if (mProperties.isUsingBlackUI()) { + this.setTheme(R.style.Theme_Termux_Black); } else { - TerminalSession currentSession = getCurrentTermSession(); + this.setTheme(R.style.Theme_Termux); + } + } - String workingDirectory; - if (currentSession == null) { - workingDirectory = mSettings.mDefaultWorkingDir; - } else { - workingDirectory = currentSession.getCwd(); - } + private void setDrawerTheme() { + if (mProperties.isUsingBlackUI()) { + findViewById(R.id.left_drawer).setBackgroundColor(ContextCompat.getColor(this, + android.R.color.background_dark)); + } + } - TerminalSession newSession = mTermService.createTermSession(null, null, workingDirectory, failSafe); - if (sessionName != null) { - newSession.mSessionName = sessionName; - } - switchToSession(newSession); + + + private void setTerminalToolbarView(Bundle savedInstanceState) { + final ViewPager terminalToolbarViewPager = findViewById(R.id.terminal_toolbar_view_pager); + if (mPreferences.getShowTerminalToolbar()) terminalToolbarViewPager.setVisibility(View.VISIBLE); + + ViewGroup.LayoutParams layoutParams = terminalToolbarViewPager.getLayoutParams(); + mTerminalToolbarDefaultHeight = layoutParams.height; + + setTerminalToolbarHeight(); + + String savedTextInput = null; + if (savedInstanceState != null) + savedTextInput = savedInstanceState.getString(ARG_TERMINAL_TOOLBAR_TEXT_INPUT); + + terminalToolbarViewPager.setAdapter(new TerminalToolbarViewPager.PageAdapter(this, savedTextInput)); + terminalToolbarViewPager.addOnPageChangeListener(new TerminalToolbarViewPager.OnPageChangeListener(this, terminalToolbarViewPager)); + } + + private void setTerminalToolbarHeight() { + final ViewPager terminalToolbarViewPager = findViewById(R.id.terminal_toolbar_view_pager); + if (terminalToolbarViewPager == null) return; + ViewGroup.LayoutParams layoutParams = terminalToolbarViewPager.getLayoutParams(); + layoutParams.height = (int) Math.round(mTerminalToolbarDefaultHeight * + (mProperties.getExtraKeysInfo() == null ? 0 : mProperties.getExtraKeysInfo().getMatrix().length) * + mProperties.getTerminalToolbarHeightScaleFactor()); + terminalToolbarViewPager.setLayoutParams(layoutParams); + } + + public void toggleTerminalToolbar() { + final ViewPager terminalToolbarViewPager = findViewById(R.id.terminal_toolbar_view_pager); + if (terminalToolbarViewPager == null) return; + + final boolean showNow = mPreferences.toogleShowTerminalToolbar(); + Logger.showToast(this, (showNow ? getString(R.string.msg_enabling_terminal_toolbar) : getString(R.string.msg_disabling_terminal_toolbar)), true); + terminalToolbarViewPager.setVisibility(showNow ? View.VISIBLE : View.GONE); + if (showNow && terminalToolbarViewPager.getCurrentItem() == 1) { + // Focus the text input view if just revealed. + findViewById(R.id.terminal_toolbar_text_input).requestFocus(); + } + } + + private void saveTerminalToolbarTextInput(Bundle savedInstanceState) { + if (savedInstanceState == null) return; + + final EditText textInputView = findViewById(R.id.terminal_toolbar_text_input); + if (textInputView != null) { + String textInput = textInputView.getText().toString(); + if (!textInput.isEmpty()) savedInstanceState.putString(ARG_TERMINAL_TOOLBAR_TEXT_INPUT, textInput); + } + } + + + + private void setNewSessionButtonView() { + View newSessionButton = findViewById(R.id.new_session_button); + newSessionButton.setOnClickListener(v -> mTermuxTerminalSessionClient.addNewSession(false, null)); + newSessionButton.setOnLongClickListener(v -> { + DialogUtils.textInput(TermuxActivity.this, R.string.title_create_named_session, null, + R.string.action_create_named_session_confirm, text -> mTermuxTerminalSessionClient.addNewSession(false, text), + R.string.action_new_session_failsafe, text -> mTermuxTerminalSessionClient.addNewSession(true, text), + -1, null, null); + return true; + }); + } + + private void setToggleKeyboardView() { + findViewById(R.id.toggle_keyboard_button).setOnClickListener(v -> { + InputMethodManager inputMethodManager = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); + inputMethodManager.toggleSoftInput(InputMethodManager.SHOW_IMPLICIT, 0); getDrawer().closeDrawers(); + }); + + findViewById(R.id.toggle_keyboard_button).setOnLongClickListener(v -> { + toggleTerminalToolbar(); + return true; + }); + } + + private void setSoftKeyboardState() { + // If soft keyboard is to disabled + if (!mPreferences.getSoftKeyboardEnabled()) { + getWindow().setFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM, WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM); + } else { + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM); + } + + // If soft keyboard is to be hidden on startup + if (mProperties.shouldSoftKeyboardBeHiddenOnStartup()) { + getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN); } } - /** Try switching to session and note about it, but do nothing if already displaying the session. */ - void switchToSession(TerminalSession session) { - if (mTerminalView.attachSession(session)) { - noteSessionInfo(); - updateBackgroundColor(); + + + private void setTermuxTerminalViewAndClients() { + // Set termux terminal view and session clients + mTermuxTerminalSessionClient = new TermuxTerminalSessionClient(this); + mTermuxTerminalViewClient = new TermuxTerminalViewClient(this, mTermuxTerminalSessionClient); + + // Set termux terminal view + mTerminalView = findViewById(R.id.terminal_view); + mTerminalView.setTerminalViewClient(mTermuxTerminalViewClient); + + mTerminalView.setTextSize(mPreferences.getFontSize()); + mTerminalView.setKeepScreenOn(mPreferences.getKeepScreenOn()); + + // Set {@link TerminalView#TERMINAL_VIEW_KEY_LOGGING_ENABLED} value + mTerminalView.setIsTerminalViewKeyLoggingEnabled(mPreferences.getTerminalViewKeyLoggingEnabled()); + + mTerminalView.requestFocus(); + + mTermuxTerminalSessionClient.checkForFontAndColors(); + } + + private void setTermuxSessionsListView() { + ListView termuxSessionsListView = findViewById(R.id.terminal_sessions_list); + mTermuxSessionListViewController = new TermuxSessionsListViewController(this, mTermuxService.getTermuxSessions()); + termuxSessionsListView.setAdapter(mTermuxSessionListViewController); + termuxSessionsListView.setOnItemClickListener(mTermuxSessionListViewController); + termuxSessionsListView.setOnItemLongClickListener(mTermuxSessionListViewController); + } + + + + + + @SuppressLint("RtlHardcoded") + @Override + public void onBackPressed() { + if (getDrawer().isDrawerOpen(Gravity.LEFT)) { + getDrawer().closeDrawers(); + } else { + finishActivityIfNotFinishing(); } } - String toToastTitle(TerminalSession session) { - final int indexOfSession = mTermService.getSessions().indexOf(session); - StringBuilder toastTitle = new StringBuilder("[" + (indexOfSession + 1) + "]"); - if (!TextUtils.isEmpty(session.mSessionName)) { - toastTitle.append(" ").append(session.mSessionName); + public void finishActivityIfNotFinishing() { + // prevent duplicate calls to finish() if called from multiple places + if (!TermuxActivity.this.isFinishing()) { + finish(); } - String title = session.getTitle(); - if (!TextUtils.isEmpty(title)) { - // Space to "[${NR}] or newline after session name: - toastTitle.append(session.mSessionName == null ? " " : "\n"); - toastTitle.append(title); - } - return toastTitle.toString(); } - void noteSessionInfo() { - if (!mIsVisible) return; - TerminalSession session = getCurrentTermSession(); - final int indexOfSession = mTermService.getSessions().indexOf(session); - showToast(toToastTitle(session), false); - mListViewAdapter.notifyDataSetChanged(); - final ListView lv = findViewById(R.id.left_drawer_list); - lv.setItemChecked(indexOfSession, true); - lv.smoothScrollToPosition(indexOfSession); + /** Show a toast and dismiss the last one if still visible. */ + public void showToast(String text, boolean longDuration) { + if (text == null || text.isEmpty()) return; + if (mLastToast != null) mLastToast.cancel(); + mLastToast = Toast.makeText(TermuxActivity.this, text, longDuration ? Toast.LENGTH_LONG : Toast.LENGTH_SHORT); + mLastToast.setGravity(Gravity.TOP, 0, 0); + mLastToast.show(); } + + @Override public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) { - TerminalSession currentSession = getCurrentTermSession(); + TerminalSession currentSession = getCurrentSession(); if (currentSession == null) return; - menu.add(Menu.NONE, CONTEXTMENU_SELECT_URL_ID, Menu.NONE, R.string.select_url); - menu.add(Menu.NONE, CONTEXTMENU_SHARE_TRANSCRIPT_ID, Menu.NONE, R.string.select_all_and_share); + boolean addAutoFillMenu = false; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { AutofillManager autofillManager = getSystemService(AutofillManager.class); if (autofillManager != null && autofillManager.isEnabled()) { - menu.add(Menu.NONE, CONTEXTMENU_AUTOFILL_ID, Menu.NONE, R.string.autofill_password); + addAutoFillMenu = true; } } - menu.add(Menu.NONE, CONTEXTMENU_RESET_TERMINAL_ID, Menu.NONE, R.string.reset_terminal); - menu.add(Menu.NONE, CONTEXTMENU_KILL_PROCESS_ID, Menu.NONE, getResources().getString(R.string.kill_process, getCurrentTermSession().getPid())).setEnabled(currentSession.isRunning()); - menu.add(Menu.NONE, CONTEXTMENU_STYLING_ID, Menu.NONE, R.string.style_terminal); - menu.add(Menu.NONE, CONTEXTMENU_TOGGLE_KEEP_SCREEN_ON, Menu.NONE, R.string.toggle_keep_screen_on).setCheckable(true).setChecked(mSettings.isScreenAlwaysOn()); - menu.add(Menu.NONE, CONTEXTMENU_HELP_ID, Menu.NONE, R.string.help); + + menu.add(Menu.NONE, CONTEXT_MENU_SELECT_URL_ID, Menu.NONE, R.string.action_select_url); + menu.add(Menu.NONE, CONTEXT_MENU_SHARE_TRANSCRIPT_ID, Menu.NONE, R.string.action_share_transcript); + if (addAutoFillMenu) menu.add(Menu.NONE, CONTEXT_MENU_AUTOFILL_ID, Menu.NONE, R.string.action_autofill_password); + menu.add(Menu.NONE, CONTEXT_MENU_RESET_TERMINAL_ID, Menu.NONE, R.string.action_reset_terminal); + menu.add(Menu.NONE, CONTEXT_MENU_KILL_PROCESS_ID, Menu.NONE, getResources().getString(R.string.action_kill_process, getCurrentSession().getPid())).setEnabled(currentSession.isRunning()); + menu.add(Menu.NONE, CONTEXT_MENU_STYLING_ID, Menu.NONE, R.string.action_style_terminal); + menu.add(Menu.NONE, CONTEXT_MENU_TOGGLE_KEEP_SCREEN_ON, Menu.NONE, R.string.action_toggle_keep_screen_on).setCheckable(true).setChecked(mPreferences.getKeepScreenOn()); + menu.add(Menu.NONE, CONTEXT_MENU_HELP_ID, Menu.NONE, R.string.action_open_help); + menu.add(Menu.NONE, CONTEXT_MENU_SETTINGS_ID, Menu.NONE, R.string.action_open_settings); + menu.add(Menu.NONE, CONTEXT_MENU_REPORT_ID, Menu.NONE, R.string.action_report_issue); } /** Hook system menu to show context menu instead. */ @@ -730,272 +537,270 @@ public final class TermuxActivity extends Activity implements ServiceConnection return false; } - static LinkedHashSet extractUrls(String text) { - - StringBuilder regex_sb = new StringBuilder(); - - regex_sb.append("("); // Begin first matching group. - regex_sb.append("(?:"); // Begin scheme group. - regex_sb.append("dav|"); // The DAV proto. - regex_sb.append("dict|"); // The DICT proto. - regex_sb.append("dns|"); // The DNS proto. - regex_sb.append("file|"); // File path. - regex_sb.append("finger|"); // The Finger proto. - regex_sb.append("ftp(?:s?)|"); // The FTP proto. - regex_sb.append("git|"); // The Git proto. - regex_sb.append("gopher|"); // The Gopher proto. - regex_sb.append("http(?:s?)|"); // The HTTP proto. - regex_sb.append("imap(?:s?)|"); // The IMAP proto. - regex_sb.append("irc(?:[6s]?)|"); // The IRC proto. - regex_sb.append("ip[fn]s|"); // The IPFS proto. - regex_sb.append("ldap(?:s?)|"); // The LDAP proto. - regex_sb.append("pop3(?:s?)|"); // The POP3 proto. - regex_sb.append("redis(?:s?)|"); // The Redis proto. - regex_sb.append("rsync|"); // The Rsync proto. - regex_sb.append("rtsp(?:[su]?)|"); // The RTSP proto. - regex_sb.append("sftp|"); // The SFTP proto. - regex_sb.append("smb(?:s?)|"); // The SAMBA proto. - regex_sb.append("smtp(?:s?)|"); // The SMTP proto. - regex_sb.append("svn(?:(?:\\+ssh)?)|"); // The Subversion proto. - regex_sb.append("tcp|"); // The TCP proto. - regex_sb.append("telnet|"); // The Telnet proto. - regex_sb.append("tftp|"); // The TFTP proto. - regex_sb.append("udp|"); // The UDP proto. - regex_sb.append("vnc|"); // The VNC proto. - regex_sb.append("ws(?:s?)"); // The Websocket proto. - regex_sb.append(")://"); // End scheme group. - regex_sb.append(")"); // End first matching group. - - - // Begin second matching group. - regex_sb.append("("); - - // User name and/or password in format 'user:pass@'. - regex_sb.append("(?:\\S+(?::\\S*)?@)?"); - - // Begin host group. - regex_sb.append("(?:"); - - // IP address (from http://www.regular-expressions.info/examples.html). - regex_sb.append("(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)|"); - - // Host name or domain. - regex_sb.append("(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)(?:(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*(?:\\.(?:[a-z\\u00a1-\\uffff]{2,})))?|"); - - // Just path. Used in case of 'file://' scheme. - regex_sb.append("/(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)"); - - // End host group. - regex_sb.append(")"); - - // Port number. - regex_sb.append("(?::\\d{1,5})?"); - - // Resource path with optional query string. - regex_sb.append("(?:/[a-zA-Z0-9:@%\\-._~!$&()*+,;=?/]*)?"); - - // Fragment. - regex_sb.append("(?:#[a-zA-Z0-9:@%\\-._~!$&()*+,;=?/]*)?"); - - // End second matching group. - regex_sb.append(")"); - - final Pattern urlPattern = Pattern.compile( - regex_sb.toString(), - Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL); - - LinkedHashSet urlSet = new LinkedHashSet<>(); - Matcher matcher = urlPattern.matcher(text); - - while (matcher.find()) { - int matchStart = matcher.start(1); - int matchEnd = matcher.end(); - String url = text.substring(matchStart, matchEnd); - urlSet.add(url); - } - - return urlSet; - } - - void showUrlSelection() { - String text = getCurrentTermSession().getEmulator().getScreen().getTranscriptTextWithFullLinesJoined(); - LinkedHashSet urlSet = extractUrls(text); - if (urlSet.isEmpty()) { - new AlertDialog.Builder(this).setMessage(R.string.select_url_no_found).show(); - return; - } - - final CharSequence[] urls = urlSet.toArray(new CharSequence[0]); - Collections.reverse(Arrays.asList(urls)); // Latest first. - - // Click to copy url to clipboard: - final AlertDialog dialog = new AlertDialog.Builder(TermuxActivity.this).setItems(urls, (di, which) -> { - String url = (String) urls[which]; - ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); - clipboard.setPrimaryClip(new ClipData(null, new String[]{"text/plain"}, new ClipData.Item(url))); - Toast.makeText(TermuxActivity.this, R.string.select_url_copied_to_clipboard, Toast.LENGTH_LONG).show(); - }).setTitle(R.string.select_url_dialog_title).create(); - - // Long press to open URL: - dialog.setOnShowListener(di -> { - ListView lv = dialog.getListView(); // this is a ListView with your "buds" in it - lv.setOnItemLongClickListener((parent, view, position, id) -> { - dialog.dismiss(); - String url = (String) urls[position]; - Intent i = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); - try { - startActivity(i, null); - } catch (ActivityNotFoundException e) { - // If no applications match, Android displays a system message. - startActivity(Intent.createChooser(i, null)); - } - return true; - }); - }); - - dialog.show(); - } - @Override public boolean onContextItemSelected(MenuItem item) { - TerminalSession session = getCurrentTermSession(); + TerminalSession session = getCurrentSession(); switch (item.getItemId()) { - case CONTEXTMENU_SELECT_URL_ID: - showUrlSelection(); + case CONTEXT_MENU_SELECT_URL_ID: + mTermuxTerminalViewClient.showUrlSelection(); return true; - case CONTEXTMENU_SHARE_TRANSCRIPT_ID: - if (session != null) { - Intent intent = new Intent(Intent.ACTION_SEND); - intent.setType("text/plain"); - String transcriptText = session.getEmulator().getScreen().getTranscriptTextWithoutJoinedLines().trim(); - // See https://github.com/termux/termux-app/issues/1166. - final int MAX_LENGTH = 100_000; - if (transcriptText.length() > MAX_LENGTH) { - int cutOffIndex = transcriptText.length() - MAX_LENGTH; - int nextNewlineIndex = transcriptText.indexOf('\n', cutOffIndex); - if (nextNewlineIndex != -1 && nextNewlineIndex != transcriptText.length() - 1) { - cutOffIndex = nextNewlineIndex + 1; - } - transcriptText = transcriptText.substring(cutOffIndex).trim(); - } - intent.putExtra(Intent.EXTRA_TEXT, transcriptText); - intent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.share_transcript_title)); - startActivity(Intent.createChooser(intent, getString(R.string.share_transcript_chooser_title))); - } + case CONTEXT_MENU_SHARE_TRANSCRIPT_ID: + mTermuxTerminalViewClient.shareSessionTranscript(); return true; - case CONTEXTMENU_PASTE_ID: - doPaste(); + case CONTEXT_MENU_AUTOFILL_ID: + requestAutoFill(); return true; - case CONTEXTMENU_KILL_PROCESS_ID: - final AlertDialog.Builder b = new AlertDialog.Builder(this); - b.setIcon(android.R.drawable.ic_dialog_alert); - b.setMessage(R.string.confirm_kill_process); - b.setPositiveButton(android.R.string.yes, (dialog, id) -> { - dialog.dismiss(); - getCurrentTermSession().finishIfRunning(); - }); - b.setNegativeButton(android.R.string.no, null); - b.show(); + case CONTEXT_MENU_RESET_TERMINAL_ID: + resetSession(session); return true; - case CONTEXTMENU_RESET_TERMINAL_ID: { - if (session != null) { - session.reset(); - showToast(getResources().getString(R.string.reset_toast_notification), true); - } + case CONTEXT_MENU_KILL_PROCESS_ID: + showKillSessionDialog(session); return true; - } - case CONTEXTMENU_STYLING_ID: { - Intent stylingIntent = new Intent(); - stylingIntent.setClassName("com.termux.styling", "com.termux.styling.TermuxStyleActivity"); - try { - startActivity(stylingIntent); - } catch (ActivityNotFoundException | IllegalArgumentException e) { - // The startActivity() call is not documented to throw IllegalArgumentException. - // However, crash reporting shows that it sometimes does, so catch it here. - new AlertDialog.Builder(this).setMessage(R.string.styling_not_installed) - .setPositiveButton(R.string.styling_install, (dialog, which) -> startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("https://f-droid.org/en/packages/com.termux.styling/")))).setNegativeButton(android.R.string.cancel, null).show(); - } + case CONTEXT_MENU_STYLING_ID: + showStylingDialog(); return true; - } - case CONTEXTMENU_HELP_ID: - startActivity(new Intent(this, TermuxHelpActivity.class)); + case CONTEXT_MENU_TOGGLE_KEEP_SCREEN_ON: + toggleKeepScreenOn(); return true; - case CONTEXTMENU_TOGGLE_KEEP_SCREEN_ON: { - if(mTerminalView.getKeepScreenOn()) { - mTerminalView.setKeepScreenOn(false); - mSettings.setScreenAlwaysOn(this, false); - } else { - mTerminalView.setKeepScreenOn(true); - mSettings.setScreenAlwaysOn(this, true); - } + case CONTEXT_MENU_HELP_ID: + startActivity(new Intent(this, HelpActivity.class)); + return true; + case CONTEXT_MENU_SETTINGS_ID: + startActivity(new Intent(this, SettingsActivity.class)); + return true; + case CONTEXT_MENU_REPORT_ID: + mTermuxTerminalViewClient.reportIssueFromTranscript(); return true; - } - case CONTEXTMENU_AUTOFILL_ID: { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - AutofillManager autofillManager = getSystemService(AutofillManager.class); - if (autofillManager != null && autofillManager.isEnabled()) { - autofillManager.requestAutofill(mTerminalView); - } - } - } default: return super.onContextItemSelected(item); } } - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String permissions[], @NonNull int[] grantResults) { - if (requestCode == REQUESTCODE_PERMISSION_STORAGE && grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - TermuxInstaller.setupStorageSymlinks(this); + private void showKillSessionDialog(TerminalSession session) { + if (session == null) return; + + final AlertDialog.Builder b = new AlertDialog.Builder(this); + b.setIcon(android.R.drawable.ic_dialog_alert); + b.setMessage(R.string.title_confirm_kill_process); + b.setPositiveButton(android.R.string.yes, (dialog, id) -> { + dialog.dismiss(); + session.finishIfRunning(); + }); + b.setNegativeButton(android.R.string.no, null); + b.show(); + } + + private void resetSession(TerminalSession session) { + if (session != null) { + session.reset(); + showToast(getResources().getString(R.string.msg_terminal_reset), true); } } - void changeFontSize(boolean increase) { - mSettings.changeFontSize(this, increase); - mTerminalView.setTextSize(mSettings.getFontSize()); + private void showStylingDialog() { + Intent stylingIntent = new Intent(); + stylingIntent.setClassName(TermuxConstants.TERMUX_STYLING_PACKAGE_NAME, TermuxConstants.TERMUX_STYLING.TERMUX_STYLING_ACTIVITY_NAME); + try { + startActivity(stylingIntent); + } catch (ActivityNotFoundException | IllegalArgumentException e) { + // The startActivity() call is not documented to throw IllegalArgumentException. + // However, crash reporting shows that it sometimes does, so catch it here. + new AlertDialog.Builder(this).setMessage(getString(R.string.error_styling_not_installed)) + .setPositiveButton(R.string.action_styling_install, (dialog, which) -> startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(TermuxConstants.TERMUX_STYLING_FDROID_PACKAGE_URL)))).setNegativeButton(android.R.string.cancel, null).show(); + } } - - void doPaste() { - ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); - ClipData clipData = clipboard.getPrimaryClip(); - if (clipData == null) return; - CharSequence paste = clipData.getItemAt(0).coerceToText(this); - if (!TextUtils.isEmpty(paste)) - getCurrentTermSession().getEmulator().paste(paste.toString()); - } - - /** The current session as stored or the last one if that does not exist. */ - public TerminalSession getStoredCurrentSessionOrLast() { - TerminalSession stored = TermuxPreferences.getCurrentSession(this); - if (stored != null) return stored; - List sessions = mTermService.getSessions(); - return sessions.isEmpty() ? null : sessions.get(sessions.size() - 1); - } - - /** Show a toast and dismiss the last one if still visible. */ - void showToast(String text, boolean longDuration) { - if (mLastToast != null) mLastToast.cancel(); - mLastToast = Toast.makeText(TermuxActivity.this, text, longDuration ? Toast.LENGTH_LONG : Toast.LENGTH_SHORT); - mLastToast.setGravity(Gravity.TOP, 0, 0); - mLastToast.show(); - } - - public void removeFinishedSession(TerminalSession finishedSession) { - // Return pressed with finished session - remove it. - TermuxService service = mTermService; - - int index = service.removeTermSession(finishedSession); - mListViewAdapter.notifyDataSetChanged(); - if (mTermService.getSessions().isEmpty()) { - // There are no sessions to show, so finish the activity. - finish(); + private void toggleKeepScreenOn() { + if (mTerminalView.getKeepScreenOn()) { + mTerminalView.setKeepScreenOn(false); + mPreferences.setKeepScreenOn(false); } else { - if (index >= service.getSessions().size()) { - index = service.getSessions().size() - 1; - } - switchToSession(service.getSessions().get(index)); + mTerminalView.setKeepScreenOn(true); + mPreferences.setKeepScreenOn(true); } } + private void requestAutoFill() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + AutofillManager autofillManager = getSystemService(AutofillManager.class); + if (autofillManager != null && autofillManager.isEnabled()) { + autofillManager.requestAutofill(mTerminalView); + } + } + } + + + + /** + * For processes to access shared internal storage (/sdcard) we need this permission. + */ + public boolean ensureStoragePermissionGranted() { + if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) { + return true; + } else { + Logger.logDebug(LOG_TAG, "Storage permission not granted, requesting permission."); + requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, REQUESTCODE_PERMISSION_STORAGE); + return false; + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + if (requestCode == REQUESTCODE_PERMISSION_STORAGE && grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + Logger.logDebug(LOG_TAG, "Storage permission granted by user on request."); + TermuxInstaller.setupStorageSymlinks(this); + } else { + Logger.logDebug(LOG_TAG, "Storage permission denied by user on request."); + } + } + + + + public int getNavBarHeight() { + return mNavBarHeight; + } + + public ExtraKeysView getExtraKeysView() { + return mExtraKeysView; + } + + public void setExtraKeysView(ExtraKeysView extraKeysView) { + mExtraKeysView = extraKeysView; + } + + public DrawerLayout getDrawer() { + return (DrawerLayout) findViewById(R.id.drawer_layout); + } + + public void termuxSessionListNotifyUpdated() { + mTermuxSessionListViewController.notifyDataSetChanged(); + } + + public boolean isVisible() { + return mIsVisible; + } + + + + public TermuxService getTermuxService() { + return mTermuxService; + } + + public TerminalView getTerminalView() { + return mTerminalView; + } + + public TermuxTerminalSessionClient getTermuxTerminalSessionClient() { + return mTermuxTerminalSessionClient; + } + + @Nullable + public TerminalSession getCurrentSession() { + if (mTerminalView != null) + return mTerminalView.getCurrentSession(); + else + return null; + } + + public TermuxAppSharedPreferences getPreferences() { + return mPreferences; + } + + public TermuxAppSharedProperties getProperties() { + return mProperties; + } + + + + + public static void updateTermuxActivityStyling(Context context) { + // Make sure that terminal styling is always applied. + Intent stylingIntent = new Intent(TERMUX_ACTIVITY.ACTION_RELOAD_STYLE); + context.sendBroadcast(stylingIntent); + } + + private void registerTermuxActivityBroadcastReceiver() { + IntentFilter intentFilter = new IntentFilter(); + intentFilter.addAction(TERMUX_ACTIVITY.ACTION_REQUEST_PERMISSIONS); + intentFilter.addAction(TERMUX_ACTIVITY.ACTION_RELOAD_STYLE); + + registerReceiver(mTermuxActivityBroadcastReceiver, intentFilter); + } + + private void unregisterTermuxActivityBroadcastReceiever() { + unregisterReceiver(mTermuxActivityBroadcastReceiver); + } + + private void fixTermuxActivityBroadcastReceieverIntent(Intent intent) { + if (intent == null) return; + + String extraReloadStyle = intent.getStringExtra(TERMUX_ACTIVITY.EXTRA_RELOAD_STYLE); + if ("storage".equals(extraReloadStyle)) { + intent.removeExtra(TERMUX_ACTIVITY.EXTRA_RELOAD_STYLE); + intent.setAction(TERMUX_ACTIVITY.ACTION_REQUEST_PERMISSIONS); + } + } + + class TermuxActivityBroadcastReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null) return; + + if (mIsVisible) { + fixTermuxActivityBroadcastReceieverIntent(intent); + + switch (intent.getAction()) { + case TERMUX_ACTIVITY.ACTION_REQUEST_PERMISSIONS: + Logger.logDebug(LOG_TAG, "Received intent to request storage permissions"); + if (ensureStoragePermissionGranted()) + TermuxInstaller.setupStorageSymlinks(TermuxActivity.this); + return; + case TERMUX_ACTIVITY.ACTION_RELOAD_STYLE: + Logger.logDebug(LOG_TAG, "Received intent to reload styling"); + reloadTermuxActivityStyling(); + return; + default: + } + } + } + } + + private void reloadTermuxActivityStyling() { + if (mTermuxTerminalSessionClient != null) { + mTermuxTerminalSessionClient.checkForFontAndColors(); + } + + if (mProperties!= null) { + mProperties.loadTermuxPropertiesFromDisk(); + + if (mExtraKeysView != null) { + mExtraKeysView.reload(mProperties.getExtraKeysInfo()); + } + } + + setTerminalToolbarHeight(); + + setSoftKeyboardState(); + + // To change the activity and drawer theme, activity needs to be recreated. + // But this will destroy the activity, and will call the onCreate() again. + // We need to investigate if enabling this is wise, since all stored variables and + // views will be destroyed and bindService() will be called again. Extra keys input + // text will we restored since that has already been implemented. Terminal sessions + // and transcripts are also already preserved. Theme does change properly too. + // TermuxActivity.this.recreate(); + } + + + + public static void startTermuxActivity(@NonNull final Context context) { + context.startActivity(newInstance(context)); + } + + public static Intent newInstance(@NonNull final Context context) { + Intent intent = new Intent(context, TermuxActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + return intent; + } + } diff --git a/app/src/main/java/com/termux/app/TermuxApplication.java b/app/src/main/java/com/termux/app/TermuxApplication.java new file mode 100644 index 00000000..3bc6689c --- /dev/null +++ b/app/src/main/java/com/termux/app/TermuxApplication.java @@ -0,0 +1,28 @@ +package com.termux.app; + +import android.app.Application; + +import com.termux.shared.crash.CrashHandler; +import com.termux.shared.settings.preferences.TermuxAppSharedPreferences; +import com.termux.shared.logger.Logger; + + +public class TermuxApplication extends Application { + public void onCreate() { + super.onCreate(); + + // Set crash handler for the app + CrashHandler.setCrashHandler(this); + + // Set log level for the app + setLogLevel(); + } + + private void setLogLevel() { + // Load the log level from shared preferences and set it to the {@link Logger.CURRENT_LOG_LEVEL} + TermuxAppSharedPreferences preferences = new TermuxAppSharedPreferences(getApplicationContext()); + preferences.setLogLevel(null, preferences.getLogLevel()); + Logger.logDebug("Starting Application"); + } +} + diff --git a/app/src/main/java/com/termux/app/TermuxInstaller.java b/app/src/main/java/com/termux/app/TermuxInstaller.java index 6e50b22d..bc258380 100644 --- a/app/src/main/java/com/termux/app/TermuxInstaller.java +++ b/app/src/main/java/com/termux/app/TermuxInstaller.java @@ -7,18 +7,18 @@ import android.content.Context; import android.os.Environment; import android.os.UserManager; import android.system.Os; -import android.util.Log; import android.util.Pair; import android.view.WindowManager; import com.termux.R; -import com.termux.terminal.EmulatorDebug; +import com.termux.shared.file.FileUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.termux.TermuxConstants; import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileOutputStream; -import java.io.IOException; import java.io.InputStreamReader; import java.util.ArrayList; import java.util.List; @@ -29,11 +29,11 @@ import java.util.zip.ZipInputStream; * Install the Termux bootstrap packages if necessary by following the below steps: *

* (1) If $PREFIX already exist, assume that it is correct and be done. Note that this relies on that we do not create a - * broken $PREFIX folder below. + * broken $PREFIX directory below. *

* (2) A progress dialog is shown with "Installing..." message and a spinner. *

- * (3) A staging folder, $STAGING_PREFIX, is {@link #deleteFolder(File)} if left over from broken installation below. + * (3) A staging directory, $STAGING_PREFIX, is cleared if left over from broken installation below. *

* (4) The zip file is loaded from a shared library. *

@@ -46,19 +46,23 @@ import java.util.zip.ZipInputStream; */ final class TermuxInstaller { - /** Performs setup if necessary. */ - static void setupIfNeeded(final Activity activity, final Runnable whenDone) { + private static final String LOG_TAG = "TermuxInstaller"; + + /** Performs bootstrap setup if necessary. */ + static void setupBootstrapIfNeeded(final Activity activity, final Runnable whenDone) { // Termux can only be run as the primary user (device owner) since only that // account has the expected file system paths. Verify that: UserManager um = (UserManager) activity.getSystemService(Context.USER_SERVICE); boolean isPrimaryUser = um.getSerialNumberForUser(android.os.Process.myUserHandle()) == 0; if (!isPrimaryUser) { - new AlertDialog.Builder(activity).setTitle(R.string.bootstrap_error_title).setMessage(R.string.bootstrap_error_not_primary_user_message) + String bootstrapErrorMessage = activity.getString(R.string.bootstrap_error_not_primary_user_message, TermuxConstants.TERMUX_PREFIX_DIR_PATH); + Logger.logError(LOG_TAG, bootstrapErrorMessage); + new AlertDialog.Builder(activity).setTitle(R.string.bootstrap_error_title).setMessage(bootstrapErrorMessage) .setOnDismissListener(dialog -> System.exit(0)).setPositiveButton(android.R.string.ok, null).show(); return; } - final File PREFIX_FILE = new File(TermuxService.PREFIX_PATH); + final File PREFIX_FILE = TermuxConstants.TERMUX_PREFIX_DIR; if (PREFIX_FILE.isDirectory()) { whenDone.run(); return; @@ -69,13 +73,20 @@ final class TermuxInstaller { @Override public void run() { try { - final String STAGING_PREFIX_PATH = TermuxService.FILES_PATH + "/usr-staging"; + Logger.logInfo(LOG_TAG, "Installing " + TermuxConstants.TERMUX_APP_NAME + " bootstrap packages."); + + String errmsg; + + final String STAGING_PREFIX_PATH = TermuxConstants.TERMUX_STAGING_PREFIX_DIR_PATH; final File STAGING_PREFIX_FILE = new File(STAGING_PREFIX_PATH); - if (STAGING_PREFIX_FILE.exists()) { - deleteFolder(STAGING_PREFIX_FILE); + errmsg = FileUtils.clearDirectory(activity, "prefix staging directory", STAGING_PREFIX_PATH); + if (errmsg != null) { + throw new RuntimeException(errmsg); } + Logger.logInfo(LOG_TAG, "Extracting bootstrap zip to prefix staging directory \"" + TermuxConstants.TERMUX_STAGING_PREFIX_DIR_PATH + "\"."); + final byte[] buffer = new byte[8096]; final List> symlinks = new ArrayList<>(50); @@ -94,14 +105,14 @@ final class TermuxInstaller { String newPath = STAGING_PREFIX_PATH + "/" + parts[1]; symlinks.add(Pair.create(oldPath, newPath)); - ensureDirectoryExists(new File(newPath).getParentFile()); + ensureDirectoryExists(activity, new File(newPath).getParentFile()); } } else { String zipEntryName = zipEntry.getName(); File targetFile = new File(STAGING_PREFIX_PATH, zipEntryName); boolean isDirectory = zipEntry.isDirectory(); - ensureDirectoryExists(isDirectory ? targetFile : targetFile.getParentFile()); + ensureDirectoryExists(activity, isDirectory ? targetFile : targetFile.getParentFile()); if (!isDirectory) { try (FileOutputStream outStream = new FileOutputStream(targetFile)) { @@ -124,13 +135,16 @@ final class TermuxInstaller { Os.symlink(symlink.first, symlink.second); } + Logger.logInfo(LOG_TAG, "Moving prefix staging to prefix directory."); + if (!STAGING_PREFIX_FILE.renameTo(PREFIX_FILE)) { - throw new RuntimeException("Unable to rename staging folder"); + throw new RuntimeException("Moving prefix staging to prefix directory failed"); } + Logger.logInfo(LOG_TAG, "Bootstrap packages installed successfully."); activity.runOnUiThread(whenDone); } catch (final Exception e) { - Log.e(EmulatorDebug.LOG_TAG, "Bootstrap error", e); + Logger.logStackTraceWithMessage(LOG_TAG, "Bootstrap error", e); activity.runOnUiThread(() -> { try { new AlertDialog.Builder(activity).setTitle(R.string.bootstrap_error_title).setMessage(R.string.bootstrap_error_body) @@ -138,9 +152,9 @@ final class TermuxInstaller { dialog.dismiss(); activity.finish(); }).setPositiveButton(R.string.bootstrap_error_try_again, (dialog, which) -> { - dialog.dismiss(); - TermuxInstaller.setupIfNeeded(activity, whenDone); - }).show(); + dialog.dismiss(); + TermuxInstaller.setupBootstrapIfNeeded(activity, whenDone); + }).show(); } catch (WindowManager.BadTokenException e1) { // Activity already dismissed - ignore. } @@ -158,58 +172,25 @@ final class TermuxInstaller { }.start(); } - private static void ensureDirectoryExists(File directory) { - if (!directory.isDirectory() && !directory.mkdirs()) { - throw new RuntimeException("Unable to create directory: " + directory.getAbsolutePath()); - } - } - - public static byte[] loadZipBytes() { - // Only load the shared library when necessary to save memory usage. - System.loadLibrary("termux-bootstrap"); - return getZip(); - } - - public static native byte[] getZip(); - - /** Delete a folder and all its content or throw. Don't follow symlinks. */ - static void deleteFolder(File fileOrDirectory) throws IOException { - if (fileOrDirectory.getCanonicalPath().equals(fileOrDirectory.getAbsolutePath()) && fileOrDirectory.isDirectory()) { - File[] children = fileOrDirectory.listFiles(); - - if (children != null) { - for (File child : children) { - deleteFolder(child); - } - } - } - - if (!fileOrDirectory.delete()) { - throw new RuntimeException("Unable to delete " + (fileOrDirectory.isDirectory() ? "directory " : "file ") + fileOrDirectory.getAbsolutePath()); - } - } - static void setupStorageSymlinks(final Context context) { final String LOG_TAG = "termux-storage"; + + Logger.logInfo(LOG_TAG, "Setting up storage symlinks."); + new Thread() { public void run() { try { - File storageDir = new File(TermuxService.HOME_PATH, "storage"); + String errmsg; + File storageDir = TermuxConstants.TERMUX_STORAGE_HOME_DIR; - if (storageDir.exists()) { - try { - deleteFolder(storageDir); - } catch (IOException e) { - Log.e(LOG_TAG, "Could not delete old $HOME/storage, " + e.getMessage()); - return; - } - } - - if (!storageDir.mkdirs()) { - Log.e(LOG_TAG, "Unable to mkdirs() for $HOME/storage"); + errmsg = FileUtils.clearDirectory(context, "~/storage", storageDir.getAbsolutePath()); + if (errmsg != null) { + Logger.logErrorAndShowToast(context, LOG_TAG, errmsg); return; } + Logger.logInfo(LOG_TAG, "Setting up storage symlinks at ~/storage/shared, ~/storage/downloads, ~/storage/dcim, ~/storage/pictures, ~/storage/music and ~/storage/movies for directories in \"" + Environment.getExternalStorageDirectory().getAbsolutePath() + "\"."); + File sharedDir = Environment.getExternalStorageDirectory(); Os.symlink(sharedDir.getAbsolutePath(), new File(storageDir, "shared").getAbsolutePath()); @@ -234,14 +215,34 @@ final class TermuxInstaller { File dir = dirs[i]; if (dir == null) continue; String symlinkName = "external-" + i; + Logger.logInfo(LOG_TAG, "Setting up storage symlinks at ~/storage/" + symlinkName + " for \"" + dir.getAbsolutePath() + "\"."); Os.symlink(dir.getAbsolutePath(), new File(storageDir, symlinkName).getAbsolutePath()); } } + + Logger.logInfo(LOG_TAG, "Storage symlinks created successfully."); } catch (Exception e) { - Log.e(LOG_TAG, "Error setting up link", e); + Logger.logStackTraceWithMessage(LOG_TAG, "Error setting up link", e); } } }.start(); } + private static void ensureDirectoryExists(Context context, File directory) { + String errmsg; + + errmsg = FileUtils.createDirectoryFile(context, directory.getAbsolutePath()); + if (errmsg != null) { + throw new RuntimeException(errmsg); + } + } + + public static byte[] loadZipBytes() { + // Only load the shared library when necessary to save memory usage. + System.loadLibrary("termux-bootstrap"); + return getZip(); + } + + public static native byte[] getZip(); + } diff --git a/app/src/main/java/com/termux/app/TermuxOpenReceiver.java b/app/src/main/java/com/termux/app/TermuxOpenReceiver.java index 6b8bf227..c3609dbc 100644 --- a/app/src/main/java/com/termux/app/TermuxOpenReceiver.java +++ b/app/src/main/java/com/termux/app/TermuxOpenReceiver.java @@ -11,10 +11,10 @@ import android.net.Uri; import android.os.Environment; import android.os.ParcelFileDescriptor; import android.provider.MediaStore; -import android.util.Log; import android.webkit.MimeTypeMap; -import com.termux.terminal.EmulatorDebug; +import com.termux.shared.logger.Logger; +import com.termux.shared.termux.TermuxConstants; import java.io.File; import java.io.FileNotFoundException; @@ -24,11 +24,13 @@ import androidx.annotation.NonNull; public class TermuxOpenReceiver extends BroadcastReceiver { + private static final String LOG_TAG = "TermuxOpenReceiver"; + @Override public void onReceive(Context context, Intent intent) { final Uri data = intent.getData(); if (data == null) { - Log.e(EmulatorDebug.LOG_TAG, "termux-open: Called without intent data"); + Logger.logError(LOG_TAG, "termux-open: Called without intent data"); return; } @@ -42,7 +44,7 @@ public class TermuxOpenReceiver extends BroadcastReceiver { // Ok. break; default: - Log.e(EmulatorDebug.LOG_TAG, "Invalid action '" + intentAction + "', using 'view'"); + Logger.logError(LOG_TAG, "Invalid action '" + intentAction + "', using 'view'"); break; } @@ -59,14 +61,14 @@ public class TermuxOpenReceiver extends BroadcastReceiver { try { context.startActivity(urlIntent); } catch (ActivityNotFoundException e) { - Log.e(EmulatorDebug.LOG_TAG, "termux-open: No app handles the url " + data); + Logger.logError(LOG_TAG, "termux-open: No app handles the url " + data); } return; } final File fileToShare = new File(filePath); if (!(fileToShare.isFile() && fileToShare.canRead())) { - Log.e(EmulatorDebug.LOG_TAG, "termux-open: Not a readable file: '" + fileToShare.getAbsolutePath() + "'"); + Logger.logError(LOG_TAG, "termux-open: Not a readable file: '" + fileToShare.getAbsolutePath() + "'"); return; } @@ -87,7 +89,7 @@ public class TermuxOpenReceiver extends BroadcastReceiver { contentTypeToUse = contentTypeExtra; } - Uri uriToShare = Uri.parse("content://com.termux.files" + fileToShare.getAbsolutePath()); + Uri uriToShare = Uri.parse("content://" + TermuxConstants.TERMUX_FILE_SHARE_URI_AUTHORITY + fileToShare.getAbsolutePath()); if (Intent.ACTION_SEND.equals(intentAction)) { sendIntent.putExtra(Intent.EXTRA_STREAM, uriToShare); @@ -103,7 +105,7 @@ public class TermuxOpenReceiver extends BroadcastReceiver { try { context.startActivity(sendIntent); } catch (ActivityNotFoundException e) { - Log.e(EmulatorDebug.LOG_TAG, "termux-open: No app handles the url " + data); + Logger.logError(LOG_TAG, "termux-open: No app handles the url " + data); } } @@ -178,7 +180,7 @@ public class TermuxOpenReceiver extends BroadcastReceiver { String path = file.getCanonicalPath(); String storagePath = Environment.getExternalStorageDirectory().getCanonicalPath(); // See https://support.google.com/faqs/answer/7496913: - if (!(path.startsWith(TermuxService.FILES_PATH) || path.startsWith(storagePath))) { + if (!(path.startsWith(TermuxConstants.TERMUX_FILES_DIR_PATH) || path.startsWith(storagePath))) { throw new IllegalArgumentException("Invalid path: " + path); } } catch (IOException e) { diff --git a/app/src/main/java/com/termux/app/TermuxPreferences.java b/app/src/main/java/com/termux/app/TermuxPreferences.java deleted file mode 100644 index d3df7fc3..00000000 --- a/app/src/main/java/com/termux/app/TermuxPreferences.java +++ /dev/null @@ -1,276 +0,0 @@ -package com.termux.app; - -import android.content.Context; -import android.content.SharedPreferences; -import android.content.res.Configuration; -import android.preference.PreferenceManager; -import android.util.Log; -import android.util.TypedValue; -import android.widget.Toast; -import com.termux.terminal.TerminalSession; -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStreamReader; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.Properties; - -import androidx.annotation.IntDef; - -import static com.termux.terminal.EmulatorDebug.LOG_TAG; - -final class TermuxPreferences { - - @IntDef({BELL_VIBRATE, BELL_BEEP, BELL_IGNORE}) - @Retention(RetentionPolicy.SOURCE) - @interface AsciiBellBehaviour { - } - - final static class KeyboardShortcut { - - KeyboardShortcut(int codePoint, int shortcutAction) { - this.codePoint = codePoint; - this.shortcutAction = shortcutAction; - } - - final int codePoint; - final int shortcutAction; - } - - static final int SHORTCUT_ACTION_CREATE_SESSION = 1; - static final int SHORTCUT_ACTION_NEXT_SESSION = 2; - static final int SHORTCUT_ACTION_PREVIOUS_SESSION = 3; - static final int SHORTCUT_ACTION_RENAME_SESSION = 4; - - static final int BELL_VIBRATE = 1; - static final int BELL_BEEP = 2; - static final int BELL_IGNORE = 3; - - private final int MIN_FONTSIZE; - private static final int MAX_FONTSIZE = 256; - - private static final String SHOW_EXTRA_KEYS_KEY = "show_extra_keys"; - private static final String FONTSIZE_KEY = "fontsize"; - private static final String CURRENT_SESSION_KEY = "current_session"; - private static final String SCREEN_ALWAYS_ON_KEY = "screen_always_on"; - - private boolean mUseDarkUI; - private boolean mScreenAlwaysOn; - private int mFontSize; - - private boolean mUseFullScreen; - private boolean mUseFullScreenWorkAround; - - @AsciiBellBehaviour - int mBellBehaviour = BELL_VIBRATE; - - boolean mBackIsEscape; - boolean mDisableVolumeVirtualKeys; - boolean mShowExtraKeys; - String mDefaultWorkingDir; - - ExtraKeysInfos mExtraKeys; - - final List shortcuts = new ArrayList<>(); - - /** - * If value is not in the range [min, max], set it to either min or max. - */ - static int clamp(int value, int min, int max) { - return Math.min(Math.max(value, min), max); - } - - TermuxPreferences(Context context) { - reloadFromProperties(context); - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - - float dipInPixels = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1, context.getResources().getDisplayMetrics()); - - // This is a bit arbitrary and sub-optimal. We want to give a sensible default for minimum font size - // to prevent invisible text due to zoom be mistake: - MIN_FONTSIZE = (int) (4f * dipInPixels); - - mShowExtraKeys = prefs.getBoolean(SHOW_EXTRA_KEYS_KEY, true); - mScreenAlwaysOn = prefs.getBoolean(SCREEN_ALWAYS_ON_KEY, false); - - // http://www.google.com/design/spec/style/typography.html#typography-line-height - int defaultFontSize = Math.round(12 * dipInPixels); - // Make it divisible by 2 since that is the minimal adjustment step: - if (defaultFontSize % 2 == 1) defaultFontSize--; - - try { - mFontSize = Integer.parseInt(prefs.getString(FONTSIZE_KEY, Integer.toString(defaultFontSize))); - } catch (NumberFormatException | ClassCastException e) { - mFontSize = defaultFontSize; - } - mFontSize = clamp(mFontSize, MIN_FONTSIZE, MAX_FONTSIZE); - } - - boolean toggleShowExtraKeys(Context context) { - mShowExtraKeys = !mShowExtraKeys; - PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean(SHOW_EXTRA_KEYS_KEY, mShowExtraKeys).apply(); - return mShowExtraKeys; - } - - int getFontSize() { - return mFontSize; - } - - void changeFontSize(Context context, boolean increase) { - mFontSize += (increase ? 1 : -1) * 2; - mFontSize = Math.max(MIN_FONTSIZE, Math.min(mFontSize, MAX_FONTSIZE)); - - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - prefs.edit().putString(FONTSIZE_KEY, Integer.toString(mFontSize)).apply(); - } - - boolean isScreenAlwaysOn() { - return mScreenAlwaysOn; - } - - boolean isUsingBlackUI() { - return mUseDarkUI; - } - - boolean isUsingFullScreen() { - return mUseFullScreen; - } - - boolean isUsingFullScreenWorkAround() { - return mUseFullScreenWorkAround; - } - - void setScreenAlwaysOn(Context context, boolean newValue) { - mScreenAlwaysOn = newValue; - PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean(SCREEN_ALWAYS_ON_KEY, newValue).apply(); - } - - static void storeCurrentSession(Context context, TerminalSession session) { - PreferenceManager.getDefaultSharedPreferences(context).edit().putString(TermuxPreferences.CURRENT_SESSION_KEY, session.mHandle).apply(); - } - - static TerminalSession getCurrentSession(TermuxActivity context) { - String sessionHandle = PreferenceManager.getDefaultSharedPreferences(context).getString(TermuxPreferences.CURRENT_SESSION_KEY, ""); - for (int i = 0, len = context.mTermService.getSessions().size(); i < len; i++) { - TerminalSession session = context.mTermService.getSessions().get(i); - if (session.mHandle.equals(sessionHandle)) return session; - } - return null; - } - - void reloadFromProperties(Context context) { - File propsFile = new File(TermuxService.HOME_PATH + "/.termux/termux.properties"); - if (!propsFile.exists()) - propsFile = new File(TermuxService.HOME_PATH + "/.config/termux/termux.properties"); - - Properties props = new Properties(); - try { - if (propsFile.isFile() && propsFile.canRead()) { - try (FileInputStream in = new FileInputStream(propsFile)) { - props.load(new InputStreamReader(in, StandardCharsets.UTF_8)); - } - } - } catch (Exception e) { - Toast.makeText(context, "Could not open properties file termux.properties: " + e.getMessage(), Toast.LENGTH_LONG).show(); - Log.e("termux", "Error loading props", e); - } - - switch (props.getProperty("bell-character", "vibrate")) { - case "beep": - mBellBehaviour = BELL_BEEP; - break; - case "ignore": - mBellBehaviour = BELL_IGNORE; - break; - default: // "vibrate". - mBellBehaviour = BELL_VIBRATE; - break; - } - - switch (props.getProperty("use-black-ui", "").toLowerCase()) { - case "true": - mUseDarkUI = true; - break; - case "false": - mUseDarkUI = false; - break; - default: - int nightMode = context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; - mUseDarkUI = nightMode == Configuration.UI_MODE_NIGHT_YES; - } - - mUseFullScreen = "true".equals(props.getProperty("fullscreen", "false").toLowerCase()); - mUseFullScreenWorkAround = "true".equals(props.getProperty("use-fullscreen-workaround", "false").toLowerCase()); - - mDefaultWorkingDir = props.getProperty("default-working-directory", TermuxService.HOME_PATH); - File workDir = new File(mDefaultWorkingDir); - if (!workDir.exists() || !workDir.isDirectory()) { - // Fallback to home directory if user configured working directory is not exist - // or is a regular file. - mDefaultWorkingDir = TermuxService.HOME_PATH; - } - - String defaultExtraKeys = "[[ESC, TAB, CTRL, ALT, {key: '-', popup: '|'}, DOWN, UP]]"; - - try { - String extrakeyProp = props.getProperty("extra-keys", defaultExtraKeys); - String extraKeysStyle = props.getProperty("extra-keys-style", "default"); - mExtraKeys = new ExtraKeysInfos(extrakeyProp, extraKeysStyle); - } catch (JSONException e) { - Toast.makeText(context, "Could not load the extra-keys property from the config: " + e.toString(), Toast.LENGTH_LONG).show(); - Log.e("termux", "Error loading props", e); - - try { - mExtraKeys = new ExtraKeysInfos(defaultExtraKeys, "default"); - } catch (JSONException e2) { - e2.printStackTrace(); - Toast.makeText(context, "Can't create default extra keys", Toast.LENGTH_LONG).show(); - mExtraKeys = null; - } - } - - mBackIsEscape = "escape".equals(props.getProperty("back-key", "back")); - mDisableVolumeVirtualKeys = "volume".equals(props.getProperty("volume-keys", "virtual")); - - shortcuts.clear(); - parseAction("shortcut.create-session", SHORTCUT_ACTION_CREATE_SESSION, props); - parseAction("shortcut.next-session", SHORTCUT_ACTION_NEXT_SESSION, props); - parseAction("shortcut.previous-session", SHORTCUT_ACTION_PREVIOUS_SESSION, props); - parseAction("shortcut.rename-session", SHORTCUT_ACTION_RENAME_SESSION, props); - } - - private void parseAction(String name, int shortcutAction, Properties props) { - String value = props.getProperty(name); - if (value == null) return; - String[] parts = value.toLowerCase().trim().split("\\+"); - String input = parts.length == 2 ? parts[1].trim() : null; - if (!(parts.length == 2 && parts[0].trim().equals("ctrl")) || input.isEmpty() || input.length() > 2) { - Log.e("termux", "Keyboard shortcut '" + name + "' is not Ctrl+"); - return; - } - - char c = input.charAt(0); - int codePoint = c; - if (Character.isLowSurrogate(c)) { - if (input.length() != 2 || Character.isHighSurrogate(input.charAt(1))) { - Log.e("termux", "Keyboard shortcut '" + name + "' is not Ctrl+"); - return; - } else { - codePoint = Character.toCodePoint(input.charAt(1), c); - } - } - shortcuts.add(new KeyboardShortcut(codePoint, shortcutAction)); - } - -} diff --git a/app/src/main/java/com/termux/app/TermuxService.java b/app/src/main/java/com/termux/app/TermuxService.java index 955ce865..c5056457 100644 --- a/app/src/main/java/com/termux/app/TermuxService.java +++ b/app/src/main/java/com/termux/app/TermuxService.java @@ -2,7 +2,6 @@ package com.termux.app; import android.annotation.SuppressLint; import android.app.Notification; -import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; import android.app.Service; @@ -18,22 +17,38 @@ import android.os.Handler; import android.os.IBinder; import android.os.PowerManager; import android.provider.Settings; -import android.util.Log; import android.widget.ArrayAdapter; import com.termux.R; -import com.termux.terminal.EmulatorDebug; +import com.termux.app.terminal.TermuxTerminalSessionClient; +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.shared.shell.TermuxSession; +import com.termux.shared.shell.TermuxTerminalSessionClientBase; +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.shared.models.ExecutionCommand; +import com.termux.shared.shell.TermuxTask; +import com.termux.terminal.TerminalEmulator; import com.termux.terminal.TerminalSession; -import com.termux.terminal.TerminalSession.SessionChangedCallback; +import com.termux.terminal.TerminalSessionClient; -import java.io.File; import java.util.ArrayList; import java.util.List; +import javax.annotation.Nullable; + /** - * A service holding a list of terminal sessions, {@link #mTerminalSessions}, 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 @@ -42,28 +57,9 @@ import java.util.List; * 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 implements SessionChangedCallback { +public final class TermuxService extends Service implements TermuxTask.TermuxTaskClient, TermuxSession.TermuxSessionClient { - private static final String NOTIFICATION_CHANNEL_ID = "termux_notification_channel"; - - /** Note that this is a symlink on the Android M preview. */ - @SuppressLint("SdCardPath") - public static final String FILES_PATH = "/data/data/com.termux/files"; - public static final String PREFIX_PATH = FILES_PATH + "/usr"; - public static final String HOME_PATH = FILES_PATH + "/home"; - - private static final int NOTIFICATION_ID = 1337; - - private static final String ACTION_STOP_SERVICE = "com.termux.service_stop"; - private static final String ACTION_LOCK_WAKE = "com.termux.service_wake_lock"; - private static final String ACTION_UNLOCK_WAKE = "com.termux.service_wake_unlock"; - /** Intent action to launch a new terminal session. Executed from TermuxWidgetProvider. */ - public static final String ACTION_EXECUTE = "com.termux.service_execute"; - - public static final String EXTRA_ARGUMENTS = "com.termux.execute.arguments"; - - public static final String EXTRA_CURRENT_WORKING_DIRECTORY = "com.termux.execute.cwd"; - public static final String EXTRA_EXECUTE_IN_BACKGROUND = "com.termux.execute.background"; + private static int EXECUTION_ID = 1000; /** This service is only bound from inside the same process and never uses IPC. */ class LocalBinder extends Binder { @@ -75,102 +71,81 @@ public final class TermuxService extends Service implements SessionChangedCallba private final Handler mHandler = new Handler(); /** - * The terminal sessions which this service manages. - *

- * Note that this list is observed by {@link TermuxActivity#mListViewAdapter}, so any changes must be made on the UI - * thread and followed by a call to {@link ArrayAdapter#notifyDataSetChanged()} }. + * 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()} }. */ - final List mTerminalSessions = new ArrayList<>(); + final List mTermuxSessions = new ArrayList<>(); - final List mBackgroundTasks = new ArrayList<>(); + /** + * The background TermuxTasks which this service manages. + */ + final List mTermuxTasks = new ArrayList<>(); - /** Note that the service may often outlive the activity, so need to clear this reference. */ - SessionChangedCallback mSessionChangeCallback; + /** + * 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. + */ + TermuxTerminalSessionClient mTermuxTerminalSessionClient; + + /** The basic implementation of the {@link TerminalSessionClient} interface to be used by {@link TerminalSession} + * that does not hold activity references. + */ + final TermuxTerminalSessionClientBase mTermuxTerminalSessionClientBase = new TermuxTerminalSessionClientBase(); /** The wake lock and wifi lock are always acquired and released together. */ private PowerManager.WakeLock mWakeLock; private WifiManager.WifiLock mWifiLock; - /** If the user has executed the {@link #ACTION_STOP_SERVICE} intent. */ + /** If the user has executed the {@link TERMUX_SERVICE#ACTION_STOP_SERVICE} intent. */ boolean mWantsToStop = false; + private static final String LOG_TAG = "TermuxService"; + + @Override + public void onCreate() { + Logger.logVerbose(LOG_TAG, "onCreate"); + runStartForeground(); + } + @SuppressLint("Wakelock") @Override public int onStartCommand(Intent intent, int flags, int startId) { + Logger.logDebug(LOG_TAG, "onStartCommand"); + + // Run again in case service is already started and onCreate() is not called + runStartForeground(); + String action = intent.getAction(); - if (ACTION_STOP_SERVICE.equals(action)) { - mWantsToStop = true; - for (int i = 0; i < mTerminalSessions.size(); i++) - mTerminalSessions.get(i).finishIfRunning(); - stopSelf(); - } else if (ACTION_LOCK_WAKE.equals(action)) { - if (mWakeLock == null) { - PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE); - mWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, EmulatorDebug.LOG_TAG + ":service-wakelock"); - mWakeLock.acquire(); - // http://tools.android.com/tech-docs/lint-in-studio-2-3#TOC-WifiManager-Leak - WifiManager wm = (WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE); - mWifiLock = wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, EmulatorDebug.LOG_TAG); - mWifiLock.acquire(); - - String packageName = getPackageName(); - if (!pm.isIgnoringBatteryOptimizations(packageName)) { - Intent whitelist = new Intent(); - whitelist.setAction(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS); - whitelist.setData(Uri.parse("package:" + packageName)); - whitelist.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - - try { - startActivity(whitelist); - } catch (ActivityNotFoundException e) { - Log.e(EmulatorDebug.LOG_TAG, "Failed to call ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS", e); - } - } - - updateNotification(); + if (action != null) { + switch (action) { + case TERMUX_SERVICE.ACTION_STOP_SERVICE: + Logger.logDebug(LOG_TAG, "ACTION_STOP_SERVICE intent received"); + actionStopService(); + break; + case TERMUX_SERVICE.ACTION_WAKE_LOCK: + Logger.logDebug(LOG_TAG, "ACTION_WAKE_LOCK intent received"); + actionAcquireWakeLock(); + break; + case TERMUX_SERVICE.ACTION_WAKE_UNLOCK: + Logger.logDebug(LOG_TAG, "ACTION_WAKE_UNLOCK intent received"); + actionReleaseWakeLock(true); + break; + case TERMUX_SERVICE.ACTION_SERVICE_EXECUTE: + Logger.logDebug(LOG_TAG, "ACTION_SERVICE_EXECUTE intent received"); + actionServiceExecute(intent); + break; + default: + Logger.logError(LOG_TAG, "Invalid action: \"" + action + "\""); + break; } - } else if (ACTION_UNLOCK_WAKE.equals(action)) { - if (mWakeLock != null) { - mWakeLock.release(); - mWakeLock = null; - - mWifiLock.release(); - mWifiLock = null; - - updateNotification(); - } - } else if (ACTION_EXECUTE.equals(action)) { - Uri executableUri = intent.getData(); - String executablePath = (executableUri == null ? null : executableUri.getPath()); - - String[] arguments = (executableUri == null ? null : intent.getStringArrayExtra(EXTRA_ARGUMENTS)); - String cwd = intent.getStringExtra(EXTRA_CURRENT_WORKING_DIRECTORY); - - if (intent.getBooleanExtra(EXTRA_EXECUTE_IN_BACKGROUND, false)) { - BackgroundJob task = new BackgroundJob(cwd, executablePath, arguments, this, intent.getParcelableExtra("pendingIntent")); - mBackgroundTasks.add(task); - updateNotification(); - } else { - boolean failsafe = intent.getBooleanExtra(TermuxActivity.TERMUX_FAILSAFE_SESSION_ACTION, false); - TerminalSession newSession = createTermSession(executablePath, arguments, cwd, failsafe); - - // Transform executable path to session name, e.g. "/bin/do-something.sh" => "do something.sh". - if (executablePath != null) { - int lastSlash = executablePath.lastIndexOf('/'); - String name = (lastSlash == -1) ? executablePath : executablePath.substring(lastSlash + 1); - name = name.replace('-', ' '); - newSession.mSessionName = name; - } - - // Make the newly created session the current one to be displayed: - TermuxPreferences.storeCurrentSession(this, newSession); - - // Launch the main Termux app, which will now show the current session: - startActivity(new Intent(this, TermuxActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); - } - } else if (action != null) { - Log.e(EmulatorDebug.LOG_TAG, "Unknown TermuxService action: '" + action + "'"); } // If this service really do get killed, there is no point restarting it automatically - let the user do on next @@ -178,216 +153,651 @@ public final class TermuxService extends Service implements SessionChangedCallba return Service.START_NOT_STICKY; } + @Override + public void onDestroy() { + Logger.logVerbose(LOG_TAG, "onDestroy"); + + ShellUtils.clearTermuxTMPDIR(this); + + actionReleaseWakeLock(false); + if (!mWantsToStop) + killAllTermuxExecutionCommands(); + runStopForeground(); + } + @Override public IBinder onBind(Intent intent) { + Logger.logVerbose(LOG_TAG, "onBind"); return mBinder; } @Override - public void onCreate() { - setupNotificationChannel(); - startForeground(NOTIFICATION_ID, buildNotification()); + public boolean onUnbind(Intent intent) { + Logger.logVerbose(LOG_TAG, "onUnbind"); + + // Since we cannot rely on {@link TermuxActivity.onDestroy()} to always complete, + // we unset clients here as well if it failed, so that we do not leave service and session + // clients with references to the activity. + if (mTermuxTerminalSessionClient != null) + unsetTermuxTerminalSessionClient(); + return false; } - /** Update the shown foreground service notification after making any changes that affect it. */ - void updateNotification() { - if (mWakeLock == null && mTerminalSessions.isEmpty() && mBackgroundTasks.isEmpty()) { - // Exit if we are updating after the user disabled all locks with no sessions or tasks running. - stopSelf(); - } else { - ((NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE)).notify(NOTIFICATION_ID, buildNotification()); + /** Make service run in foreground mode. */ + private void runStartForeground() { + setupNotificationChannel(); + startForeground(TermuxConstants.TERMUX_APP_NOTIFICATION_ID, buildNotification()); + } + + /** Make service leave foreground mode. */ + private void runStopForeground() { + stopForeground(true); + } + + /** Request to stop service. */ + private void requestStopService() { + Logger.logDebug(LOG_TAG, "Requesting to stop service"); + runStopForeground(); + stopSelf(); + } + + /** Process action to stop service. */ + private void actionStopService() { + mWantsToStop = true; + killAllTermuxExecutionCommands(); + requestStopService(); + } + + /** 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; + + Logger.logDebug(LOG_TAG, "Killing TermuxSessions=" + mTermuxSessions.size() + ", TermuxTasks=" + mTermuxTasks.size() + ", PendingPluginExecutionCommands=" + mPendingPluginExecutionCommands.size()); + + 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); + } + } } } - private Notification buildNotification() { - Intent notifyIntent = new Intent(this, TermuxActivity.class); - // PendingIntent#getActivity(): "Note that the activity will be started outside of the context of an existing - // activity, so you must use the Intent.FLAG_ACTIVITY_NEW_TASK launch flag in the Intent": - notifyIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, notifyIntent, 0); - int sessionCount = mTerminalSessions.size(); - int taskCount = mBackgroundTasks.size(); - String contentText = sessionCount + " session" + (sessionCount == 1 ? "" : "s"); + + /** Process action to acquire Power and Wi-Fi WakeLocks. */ + @SuppressLint({"WakelockTimeout", "BatteryLife"}) + private void actionAcquireWakeLock() { + if (mWakeLock != null) { + Logger.logDebug(LOG_TAG, "Ignoring acquiring WakeLocks since they are already held"); + return; + } + + Logger.logDebug(LOG_TAG, "Acquiring WakeLocks"); + + PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE); + mWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TermuxConstants.TERMUX_APP_NAME.toLowerCase() + ":service-wakelock"); + mWakeLock.acquire(); + + // http://tools.android.com/tech-docs/lint-in-studio-2-3#TOC-WifiManager-Leak + WifiManager wm = (WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE); + mWifiLock = wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, TermuxConstants.TERMUX_APP_NAME.toLowerCase()); + mWifiLock.acquire(); + + String packageName = getPackageName(); + if (!pm.isIgnoringBatteryOptimizations(packageName)) { + Intent whitelist = new Intent(); + whitelist.setAction(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS); + whitelist.setData(Uri.parse("package:" + packageName)); + whitelist.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + try { + startActivity(whitelist); + } catch (ActivityNotFoundException e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to call ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS", e); + } + } + + updateNotification(); + + Logger.logDebug(LOG_TAG, "WakeLocks acquired successfully"); + + } + + /** Process action to release Power and Wi-Fi WakeLocks. */ + private void actionReleaseWakeLock(boolean updateNotification) { + if (mWakeLock == null && mWifiLock == null) { + Logger.logDebug(LOG_TAG, "Ignoring releasing WakeLocks since none are already held"); + return; + } + + Logger.logDebug(LOG_TAG, "Releasing WakeLocks"); + + if (mWakeLock != null) { + mWakeLock.release(); + mWakeLock = null; + } + + if (mWifiLock != null) { + mWifiLock.release(); + mWifiLock = null; + } + + if (updateNotification) + updateNotification(); + + Logger.logDebug(LOG_TAG, "WakeLocks released successfully"); + } + + /** Process {@link TERMUX_SERVICE#ACTION_SERVICE_EXECUTE} intent to execute a shell command in + * 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"); + return; + } + + ExecutionCommand executionCommand = new ExecutionCommand(getNextExecutionId()); + + executionCommand.executableUri = intent.getData(); + executionCommand.inBackground = intent.getBooleanExtra(TERMUX_SERVICE.EXTRA_BACKGROUND, false); + + if (executionCommand.executableUri != null) { + executionCommand.executable = executionCommand.executableUri.getPath(); + executionCommand.arguments = intent.getStringArrayExtra(TERMUX_SERVICE.EXTRA_ARGUMENTS); + if (executionCommand.inBackground) + executionCommand.stdin = intent.getStringExtra(TERMUX_SERVICE.EXTRA_STDIN); + } + + executionCommand.workingDirectory = intent.getStringExtra(TERMUX_SERVICE.EXTRA_WORKDIR); + executionCommand.isFailsafe = intent.getBooleanExtra(TERMUX_ACTIVITY.ACTION_FAILSAFE_SESSION, false); + executionCommand.sessionAction = intent.getStringExtra(TERMUX_SERVICE.EXTRA_SESSION_ACTION); + executionCommand.commandLabel = DataUtils.getDefaultIfNull(intent.getStringExtra(TERMUX_SERVICE.EXTRA_COMMAND_LABEL), "Execution Intent Command"); + executionCommand.commandDescription = intent.getStringExtra(TERMUX_SERVICE.EXTRA_COMMAND_DESCRIPTION); + executionCommand.commandHelp = intent.getStringExtra(TERMUX_SERVICE.EXTRA_COMMAND_HELP); + executionCommand.pluginAPIHelp = intent.getStringExtra(TERMUX_SERVICE.EXTRA_PLUGIN_API_HELP); + 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 { + executeTermuxSessionCommand(executionCommand); + } + } + + + + + + /** Execute a shell command in background {@link TermuxTask}. */ + private void executeTermuxTaskCommand(ExecutionCommand executionCommand) { + if (executionCommand == null) return; + + Logger.logDebug(LOG_TAG, "Executing background \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxTask command"); + + TermuxTask newTermuxTask = createTermuxTask(executionCommand); + } + + /** Create a {@link TermuxTask}. */ + @Nullable + public TermuxTask createTermuxTask(String executablePath, String[] arguments, String stdin, String workingDirectory) { + return createTermuxTask(new ExecutionCommand(getNextExecutionId(), executablePath, arguments, stdin, workingDirectory, true, false)); + } + + /** Create a {@link TermuxTask}. */ + @Nullable + public synchronized TermuxTask createTermuxTask(ExecutionCommand executionCommand) { + if (executionCommand == null) return null; + + Logger.logDebug(LOG_TAG, "Creating \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxTask"); + + if (!executionCommand.inBackground) { + Logger.logDebug(LOG_TAG, "Ignoring a foreground execution command passed to createTermuxTask()"); + return null; + } + + if (Logger.getLogLevel() >= Logger.LOG_LEVEL_VERBOSE) + Logger.logVerbose(LOG_TAG, executionCommand.toString()); + + TermuxTask newTermuxTask = TermuxTask.execute(this, executionCommand, this, false); + if (newTermuxTask == null) { + 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. */ + @Override + public void onTermuxTaskExited(final TermuxTask termuxTask) { + mHandler.post(() -> { + 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(); + }); + } + + + + + + /** Execute a shell command in a foreground {@link TermuxSession}. */ + private void executeTermuxSessionCommand(ExecutionCommand executionCommand) { + if (executionCommand == null) return; + + Logger.logDebug(LOG_TAG, "Executing foreground \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession command"); + + String sessionName = null; + + // Transform executable path to session name, e.g. "/bin/do-something.sh" => "do something.sh". + if (executionCommand.executable != null) { + sessionName = ShellUtils.getExecutableBasename(executionCommand.executable).replace('-', ' '); + } + + TermuxSession newTermuxSession = createTermuxSession(executionCommand, sessionName); + if (newTermuxSession == null) return; + + handleSessionAction(DataUtils.getIntFromString(executionCommand.sessionAction, + TERMUX_SERVICE.VALUE_EXTRA_SESSION_ACTION_SWITCH_TO_NEW_SESSION_AND_OPEN_ACTIVITY), + newTermuxSession.getTerminalSession()); + } + + /** + * Create a {@link TermuxSession}. + * Currently called by {@link TermuxTerminalSessionClient#addNewSession(boolean, String)} to add a new {@link TermuxSession}. + */ + @Nullable + public TermuxSession createTermuxSession(String executablePath, String[] arguments, String stdin, String workingDirectory, boolean isFailSafe, String sessionName) { + return createTermuxSession(new ExecutionCommand(getNextExecutionId(), executablePath, arguments, stdin, workingDirectory, false, isFailSafe), sessionName); + } + + /** Create a {@link TermuxSession}. */ + @Nullable + public synchronized TermuxSession createTermuxSession(ExecutionCommand executionCommand, String sessionName) { + if (executionCommand == null) return null; + + Logger.logDebug(LOG_TAG, "Creating \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession"); + + if (executionCommand.inBackground) { + Logger.logDebug(LOG_TAG, "Ignoring a background execution command passed to createTermuxSession()"); + return null; + } + + if (Logger.getLogLevel() >= Logger.LOG_LEVEL_VERBOSE) + Logger.logVerbose(LOG_TAG, executionCommand.toString()); + + // 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, getTermuxTerminalSessionClient(), this, sessionName, executionCommand.isPluginExecutionCommand); + if (newTermuxSession == null) { + 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 (mTermuxTerminalSessionClient != null) + mTermuxTerminalSessionClient.termuxSessionListNotifyUpdated(); + + updateNotification(); + TermuxActivity.updateTermuxActivityStyling(this); + + return newTermuxSession; + } + + /** Remove a TermuxSession. */ + public synchronized int removeTermuxSession(TerminalSession sessionToRemove) { + int index = getIndexOfSession(sessionToRemove); + + if (index >= 0) + mTermuxSessions.get(index).finish(); + + 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); + + // Notify {@link TermuxSessionsListViewController} that sessions list has been updated if + // activity in is foreground + if (mTermuxTerminalSessionClient != null) + mTermuxTerminalSessionClient.termuxSessionListNotifyUpdated(); + } + + updateNotification(); + } + + + + + + /** Process session action for new session. */ + private void handleSessionAction(int sessionAction, TerminalSession newTerminalSession) { + Logger.logDebug(LOG_TAG, "Processing sessionAction \"" + sessionAction + "\" for session \"" + newTerminalSession.mSessionName + "\""); + + switch (sessionAction) { + case TERMUX_SERVICE.VALUE_EXTRA_SESSION_ACTION_SWITCH_TO_NEW_SESSION_AND_OPEN_ACTIVITY: + setCurrentStoredTerminalSession(newTerminalSession); + if (mTermuxTerminalSessionClient != null) + mTermuxTerminalSessionClient.setCurrentSession(newTerminalSession); + startTermuxActivity(); + break; + case TERMUX_SERVICE.VALUE_EXTRA_SESSION_ACTION_KEEP_CURRENT_SESSION_AND_OPEN_ACTIVITY: + if (getTermuxSessionsSize() == 1) + setCurrentStoredTerminalSession(newTerminalSession); + startTermuxActivity(); + break; + case TERMUX_SERVICE.VALUE_EXTRA_SESSION_ACTION_SWITCH_TO_NEW_SESSION_AND_DONT_OPEN_ACTIVITY: + setCurrentStoredTerminalSession(newTerminalSession); + if (mTermuxTerminalSessionClient != null) + mTermuxTerminalSessionClient.setCurrentSession(newTerminalSession); + break; + case TERMUX_SERVICE.VALUE_EXTRA_SESSION_ACTION_KEEP_CURRENT_SESSION_AND_DONT_OPEN_ACTIVITY: + if (getTermuxSessionsSize() == 1) + setCurrentStoredTerminalSession(newTerminalSession); + break; + default: + Logger.logError(LOG_TAG, "Invalid sessionAction: \"" + sessionAction + "\". Force using default sessionAction."); + handleSessionAction(TERMUX_SERVICE.VALUE_EXTRA_SESSION_ACTION_SWITCH_TO_NEW_SESSION_AND_OPEN_ACTIVITY, newTerminalSession); + break; + } + } + + /** 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 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); + } + } + + + + + + /** If {@link TermuxActivity} has not bound to the {@link TermuxService} yet or is destroyed, then + * interface functions requiring the activity should not be available to the terminal sessions, + * so we just return the {@link #mTermuxTerminalSessionClientBase}. Once {@link TermuxActivity} bind + * callback is received, it should call {@link #setTermuxTerminalSessionClient} to set the + * {@link TermuxService#mTermuxTerminalSessionClient} so that further terminal sessions are directly + * passed the {@link TermuxTerminalSessionClient} object which fully implements the + * {@link TerminalSessionClient} interface. + * + * @return Returns the {@link TermuxTerminalSessionClient} if {@link TermuxActivity} has bound with + * {@link TermuxService}, otherwise {@link TermuxTerminalSessionClientBase}. + */ + public synchronized TermuxTerminalSessionClientBase getTermuxTerminalSessionClient() { + if (mTermuxTerminalSessionClient != null) + return mTermuxTerminalSessionClient; + else + return mTermuxTerminalSessionClientBase; + } + + /** This should be called when {@link TermuxActivity#onServiceConnected} is called to set the + * {@link TermuxService#mTermuxTerminalSessionClient} variable and update the {@link TerminalSession} + * and {@link TerminalEmulator} clients in case they were passed {@link TermuxTerminalSessionClientBase} + * earlier. + * + * @param termuxTerminalSessionClient The {@link TermuxTerminalSessionClient} object that fully + * implements the {@link TerminalSessionClient} interface. + */ + public synchronized void setTermuxTerminalSessionClient(TermuxTerminalSessionClient termuxTerminalSessionClient) { + mTermuxTerminalSessionClient = termuxTerminalSessionClient; + + for (int i = 0; i < mTermuxSessions.size(); i++) + mTermuxSessions.get(i).getTerminalSession().updateTerminalSessionClient(mTermuxTerminalSessionClient); + } + + /** This should be called when {@link TermuxActivity} has been destroyed and in {@link #onUnbind(Intent)} + * so that the {@link TermuxService} and {@link TerminalSession} and {@link TerminalEmulator} + * clients do not hold an activity references. + */ + public synchronized void unsetTermuxTerminalSessionClient() { + for (int i = 0; i < mTermuxSessions.size(); i++) + mTermuxSessions.get(i).getTerminalSession().updateTerminalSessionClient(mTermuxTerminalSessionClientBase); + + mTermuxTerminalSessionClient = null; + } + + + + + + private Notification buildNotification() { + Resources res = getResources(); + + // Set pending intent to be launched when notification is clicked + Intent notificationIntent = TermuxActivity.newInstance(this); + PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, 0); + + + // Set notification text + int sessionCount = getTermuxSessionsSize(); + int taskCount = mTermuxTasks.size(); + String notificationText = sessionCount + " session" + (sessionCount == 1 ? "" : "s"); if (taskCount > 0) { - contentText += ", " + taskCount + " task" + (taskCount == 1 ? "" : "s"); + notificationText += ", " + taskCount + " task" + (taskCount == 1 ? "" : "s"); } final boolean wakeLockHeld = mWakeLock != null; - if (wakeLockHeld) contentText += " (wake lock held)"; + if (wakeLockHeld) notificationText += " (wake lock held)"; - Notification.Builder builder = new Notification.Builder(this); - builder.setContentTitle(getText(R.string.application_name)); - builder.setContentText(contentText); - builder.setSmallIcon(R.drawable.ic_service_notification); - builder.setContentIntent(pendingIntent); - builder.setOngoing(true); + // Set notification priority // If holding a wake or wifi lock consider the notification of high priority since it's using power, // otherwise use a low priority - builder.setPriority((wakeLockHeld) ? Notification.PRIORITY_HIGH : Notification.PRIORITY_LOW); + int priority = (wakeLockHeld) ? Notification.PRIORITY_HIGH : Notification.PRIORITY_LOW; + + + // Build the notification + Notification.Builder builder = NotificationUtils.geNotificationBuilder(this, + TermuxConstants.TERMUX_APP_NOTIFICATION_CHANNEL_ID, priority, + getText(R.string.application_name), notificationText, null, + pendingIntent, NotificationUtils.NOTIFICATION_MODE_SILENT); + if (builder == null) return null; // No need to show a timestamp: builder.setShowWhen(false); - // Background color for small notification icon: + // Set notification icon + builder.setSmallIcon(R.drawable.ic_service_notification); + + // Set background color for small notification icon builder.setColor(0xFF607D8B); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - builder.setChannelId(NOTIFICATION_CHANNEL_ID); - } + // TermuxSessions are always ongoing + builder.setOngoing(true); - Resources res = getResources(); - Intent exitIntent = new Intent(this, TermuxService.class).setAction(ACTION_STOP_SERVICE); + + // Set Exit button action + Intent exitIntent = new Intent(this, TermuxService.class).setAction(TERMUX_SERVICE.ACTION_STOP_SERVICE); builder.addAction(android.R.drawable.ic_delete, res.getString(R.string.notification_action_exit), PendingIntent.getService(this, 0, exitIntent, 0)); - String newWakeAction = wakeLockHeld ? ACTION_UNLOCK_WAKE : ACTION_LOCK_WAKE; + + // Set Wakelock button actions + String newWakeAction = wakeLockHeld ? TERMUX_SERVICE.ACTION_WAKE_UNLOCK : TERMUX_SERVICE.ACTION_WAKE_LOCK; Intent toggleWakeLockIntent = new Intent(this, TermuxService.class).setAction(newWakeAction); - String actionTitle = res.getString(wakeLockHeld ? - R.string.notification_action_wake_unlock : - R.string.notification_action_wake_lock); + String actionTitle = res.getString(wakeLockHeld ? R.string.notification_action_wake_unlock : R.string.notification_action_wake_lock); int actionIcon = wakeLockHeld ? android.R.drawable.ic_lock_idle_lock : android.R.drawable.ic_lock_lock; builder.addAction(actionIcon, actionTitle, PendingIntent.getService(this, 0, toggleWakeLockIntent, 0)); + return builder.build(); } - @Override - public void onDestroy() { - File termuxTmpDir = new File(TermuxService.PREFIX_PATH + "/tmp"); - - if (termuxTmpDir.exists()) { - try { - TermuxInstaller.deleteFolder(termuxTmpDir.getCanonicalFile()); - } catch (Exception e) { - Log.e(EmulatorDebug.LOG_TAG, "Error while removing file at " + termuxTmpDir.getAbsolutePath(), e); - } - - termuxTmpDir.mkdirs(); - } - - if (mWakeLock != null) mWakeLock.release(); - if (mWifiLock != null) mWifiLock.release(); - - stopForeground(true); - - for (int i = 0; i < mTerminalSessions.size(); i++) - mTerminalSessions.get(i).finishIfRunning(); - } - - public List getSessions() { - return mTerminalSessions; - } - - TerminalSession createTermSession(String executablePath, String[] arguments, String cwd, boolean failSafe) { - new File(HOME_PATH).mkdirs(); - - if (cwd == null) cwd = HOME_PATH; - - String[] env = BackgroundJob.buildEnvironment(failSafe, cwd); - boolean isLoginShell = false; - - if (executablePath == null) { - if (!failSafe) { - for (String shellBinary : new String[]{"login", "bash", "zsh"}) { - File shellFile = new File(PREFIX_PATH + "/bin/" + shellBinary); - if (shellFile.canExecute()) { - executablePath = shellFile.getAbsolutePath(); - break; - } - } - } - - if (executablePath == null) { - // Fall back to system shell as last resort: - executablePath = "/system/bin/sh"; - } - isLoginShell = true; - } - - String[] processArgs = BackgroundJob.setupProcessArgs(executablePath, arguments); - executablePath = processArgs[0]; - int lastSlashIndex = executablePath.lastIndexOf('/'); - String processName = (isLoginShell ? "-" : "") + - (lastSlashIndex == -1 ? executablePath : executablePath.substring(lastSlashIndex + 1)); - - String[] args = new String[processArgs.length]; - args[0] = processName; - if (processArgs.length > 1) System.arraycopy(processArgs, 1, args, 1, processArgs.length - 1); - - TerminalSession session = new TerminalSession(executablePath, cwd, args, env, this); - mTerminalSessions.add(session); - updateNotification(); - - // Make sure that terminal styling is always applied. - Intent stylingIntent = new Intent("com.termux.app.reload_style"); - stylingIntent.putExtra("com.termux.app.reload_style", "styling"); - sendBroadcast(stylingIntent); - - return session; - } - - public int removeTermSession(TerminalSession sessionToRemove) { - int indexOfRemoved = mTerminalSessions.indexOf(sessionToRemove); - mTerminalSessions.remove(indexOfRemoved); - if (mTerminalSessions.isEmpty() && mWakeLock == null) { - // Finish if there are no sessions 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. - stopSelf(); - } else { - updateNotification(); - } - return indexOfRemoved; - } - - @Override - public void onTitleChanged(TerminalSession changedSession) { - if (mSessionChangeCallback != null) mSessionChangeCallback.onTitleChanged(changedSession); - } - - @Override - public void onSessionFinished(final TerminalSession finishedSession) { - if (mSessionChangeCallback != null) - mSessionChangeCallback.onSessionFinished(finishedSession); - } - - @Override - public void onTextChanged(TerminalSession changedSession) { - if (mSessionChangeCallback != null) mSessionChangeCallback.onTextChanged(changedSession); - } - - @Override - public void onClipboardText(TerminalSession session, String text) { - if (mSessionChangeCallback != null) mSessionChangeCallback.onClipboardText(session, text); - } - - @Override - public void onBell(TerminalSession session) { - if (mSessionChangeCallback != null) mSessionChangeCallback.onBell(session); - } - - @Override - public void onColorsChanged(TerminalSession session) { - if (mSessionChangeCallback != null) mSessionChangeCallback.onColorsChanged(session); - } - - public void onBackgroundJobExited(final BackgroundJob task) { - mHandler.post(() -> { - mBackgroundTasks.remove(task); - updateNotification(); - }); - } - private void setupNotificationChannel() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; - String channelName = "Termux"; - String channelDescription = "Notifications from Termux"; - int importance = NotificationManager.IMPORTANCE_LOW; - - NotificationChannel channel = new NotificationChannel(NOTIFICATION_CHANNEL_ID, channelName,importance); - channel.setDescription(channelDescription); - NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - manager.createNotificationChannel(channel); + NotificationUtils.setupNotificationChannel(this, TermuxConstants.TERMUX_APP_NOTIFICATION_CHANNEL_ID, + TermuxConstants.TERMUX_APP_NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW); } + + /** Update the shown foreground service notification after making any changes that affect it. */ + 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(); + } else { + ((NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE)).notify(TermuxConstants.TERMUX_APP_NOTIFICATION_ID, buildNotification()); + } + } + + + + + + private void setCurrentStoredTerminalSession(TerminalSession session) { + if (session == null) return; + // Make the newly created session the current one to be displayed: + TermuxAppSharedPreferences preferences = new TermuxAppSharedPreferences(this); + preferences.setCurrentSession(session.mHandle); + } + + public synchronized boolean isTermuxSessionsEmpty() { + return mTermuxSessions.isEmpty(); + } + + public synchronized int getTermuxSessionsSize() { + return mTermuxSessions.size(); + } + + public synchronized List getTermuxSessions() { + return mTermuxSessions; + } + + @Nullable + public synchronized TermuxSession getTermuxSession(int index) { + if (index >= 0 && index < mTermuxSessions.size()) + return mTermuxSessions.get(index); + else + return null; + } + + public synchronized TermuxSession getLastTermuxSession() { + return mTermuxSessions.isEmpty() ? null : mTermuxSessions.get(mTermuxSessions.size() - 1); + } + + public synchronized int getIndexOfSession(TerminalSession terminalSession) { + for (int i = 0; i < mTermuxSessions.size(); i++) { + if (mTermuxSessions.get(i).getTerminalSession().equals(terminalSession)) + return i; + } + return -1; + } + + public synchronized TerminalSession getTerminalSessionForHandle(String sessionHandle) { + TerminalSession terminalSession; + for (int i = 0, len = mTermuxSessions.size(); i < len; i++) { + terminalSession = mTermuxSessions.get(i).getTerminalSession(); + if (terminalSession.mHandle.equals(sessionHandle)) + return terminalSession; + } + return null; + } + + + + public static synchronized int getNextExecutionId() { + return EXECUTION_ID++; + } + + public boolean wantsToStop() { + return mWantsToStop; + } + } diff --git a/app/src/main/java/com/termux/app/TermuxViewClient.java b/app/src/main/java/com/termux/app/TermuxViewClient.java deleted file mode 100644 index 3dcc406c..00000000 --- a/app/src/main/java/com/termux/app/TermuxViewClient.java +++ /dev/null @@ -1,283 +0,0 @@ -package com.termux.app; - -import android.content.Context; -import android.media.AudioManager; -import android.view.Gravity; -import android.view.InputDevice; -import android.view.KeyEvent; -import android.view.MotionEvent; -import android.view.inputmethod.InputMethodManager; - -import com.termux.terminal.KeyHandler; -import com.termux.terminal.TerminalEmulator; -import com.termux.terminal.TerminalSession; -import com.termux.view.TerminalViewClient; - -import java.util.List; - -import androidx.drawerlayout.widget.DrawerLayout; - -public final class TermuxViewClient implements TerminalViewClient { - - final TermuxActivity mActivity; - - /** Keeping track of the special keys acting as Ctrl and Fn for the soft keyboard and other hardware keys. */ - boolean mVirtualControlKeyDown, mVirtualFnKeyDown; - - public TermuxViewClient(TermuxActivity activity) { - this.mActivity = activity; - } - - @Override - public float onScale(float scale) { - if (scale < 0.9f || scale > 1.1f) { - boolean increase = scale > 1.f; - mActivity.changeFontSize(increase); - return 1.0f; - } - return scale; - } - - @Override - public void onSingleTapUp(MotionEvent e) { - InputMethodManager mgr = (InputMethodManager) mActivity.getSystemService(Context.INPUT_METHOD_SERVICE); - mgr.showSoftInput(mActivity.mTerminalView, InputMethodManager.SHOW_IMPLICIT); - } - - @Override - public boolean shouldBackButtonBeMappedToEscape() { - return mActivity.mSettings.mBackIsEscape; - } - - @Override - public void copyModeChanged(boolean copyMode) { - // Disable drawer while copying. - mActivity.getDrawer().setDrawerLockMode(copyMode ? DrawerLayout.LOCK_MODE_LOCKED_CLOSED : DrawerLayout.LOCK_MODE_UNLOCKED); - } - - @Override - public boolean onKeyDown(int keyCode, KeyEvent e, TerminalSession currentSession) { - if (handleVirtualKeys(keyCode, e, true)) return true; - - if (keyCode == KeyEvent.KEYCODE_ENTER && !currentSession.isRunning()) { - mActivity.removeFinishedSession(currentSession); - return true; - } else if (e.isCtrlPressed() && e.isAltPressed()) { - // Get the unmodified code point: - int unicodeChar = e.getUnicodeChar(0); - - if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN || unicodeChar == 'n'/* next */) { - mActivity.switchToSession(true); - } else if (keyCode == KeyEvent.KEYCODE_DPAD_UP || unicodeChar == 'p' /* previous */) { - mActivity.switchToSession(false); - } else if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) { - mActivity.getDrawer().openDrawer(Gravity.LEFT); - } else if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) { - mActivity.getDrawer().closeDrawers(); - } else if (unicodeChar == 'k'/* keyboard */) { - InputMethodManager imm = (InputMethodManager) mActivity.getSystemService(Context.INPUT_METHOD_SERVICE); - imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0); - } else if (unicodeChar == 'm'/* menu */) { - mActivity.mTerminalView.showContextMenu(); - } else if (unicodeChar == 'r'/* rename */) { - mActivity.renameSession(currentSession); - } else if (unicodeChar == 'c'/* create */) { - mActivity.addNewSession(false, null); - } else if (unicodeChar == 'u' /* urls */) { - mActivity.showUrlSelection(); - } else if (unicodeChar == 'v') { - mActivity.doPaste(); - } else if (unicodeChar == '+' || e.getUnicodeChar(KeyEvent.META_SHIFT_ON) == '+') { - // We also check for the shifted char here since shift may be required to produce '+', - // see https://github.com/termux/termux-api/issues/2 - mActivity.changeFontSize(true); - } else if (unicodeChar == '-') { - mActivity.changeFontSize(false); - } else if (unicodeChar >= '1' && unicodeChar <= '9') { - int num = unicodeChar - '1'; - TermuxService service = mActivity.mTermService; - if (service.getSessions().size() > num) - mActivity.switchToSession(service.getSessions().get(num)); - } - return true; - } - - return false; - - } - - @Override - public boolean onKeyUp(int keyCode, KeyEvent e) { - return handleVirtualKeys(keyCode, e, false); - } - - @Override - public boolean readControlKey() { - return (mActivity.mExtraKeysView != null && mActivity.mExtraKeysView.readSpecialButton(ExtraKeysView.SpecialButton.CTRL)) || mVirtualControlKeyDown; - } - - @Override - public boolean readAltKey() { - return (mActivity.mExtraKeysView != null && mActivity.mExtraKeysView.readSpecialButton(ExtraKeysView.SpecialButton.ALT)); - } - - @Override - public boolean onCodePoint(final int codePoint, boolean ctrlDown, TerminalSession session) { - if (mVirtualFnKeyDown) { - int resultingKeyCode = -1; - int resultingCodePoint = -1; - boolean altDown = false; - int lowerCase = Character.toLowerCase(codePoint); - switch (lowerCase) { - // Arrow keys. - case 'w': - resultingKeyCode = KeyEvent.KEYCODE_DPAD_UP; - break; - case 'a': - resultingKeyCode = KeyEvent.KEYCODE_DPAD_LEFT; - break; - case 's': - resultingKeyCode = KeyEvent.KEYCODE_DPAD_DOWN; - break; - case 'd': - resultingKeyCode = KeyEvent.KEYCODE_DPAD_RIGHT; - break; - - // Page up and down. - case 'p': - resultingKeyCode = KeyEvent.KEYCODE_PAGE_UP; - break; - case 'n': - resultingKeyCode = KeyEvent.KEYCODE_PAGE_DOWN; - break; - - // Some special keys: - case 't': - resultingKeyCode = KeyEvent.KEYCODE_TAB; - break; - case 'i': - resultingKeyCode = KeyEvent.KEYCODE_INSERT; - break; - case 'h': - resultingCodePoint = '~'; - break; - - // Special characters to input. - case 'u': - resultingCodePoint = '_'; - break; - case 'l': - resultingCodePoint = '|'; - break; - - // Function keys. - case '1': - case '2': - case '3': - case '4': - case '5': - case '6': - case '7': - case '8': - case '9': - resultingKeyCode = (codePoint - '1') + KeyEvent.KEYCODE_F1; - break; - case '0': - resultingKeyCode = KeyEvent.KEYCODE_F10; - break; - - // Other special keys. - case 'e': - resultingCodePoint = /*Escape*/ 27; - break; - case '.': - resultingCodePoint = /*^.*/ 28; - break; - - case 'b': // alt+b, jumping backward in readline. - case 'f': // alf+f, jumping forward in readline. - case 'x': // alt+x, common in emacs. - resultingCodePoint = lowerCase; - altDown = true; - break; - - // Volume control. - case 'v': - resultingCodePoint = -1; - AudioManager audio = (AudioManager) mActivity.getSystemService(Context.AUDIO_SERVICE); - audio.adjustSuggestedStreamVolume(AudioManager.ADJUST_SAME, AudioManager.USE_DEFAULT_STREAM_TYPE, AudioManager.FLAG_SHOW_UI); - break; - - // Writing mode: - case 'q': - case 'k': - mActivity.toggleShowExtraKeys(); - break; - } - - if (resultingKeyCode != -1) { - TerminalEmulator term = session.getEmulator(); - session.write(KeyHandler.getCode(resultingKeyCode, 0, term.isCursorKeysApplicationMode(), term.isKeypadApplicationMode())); - } else if (resultingCodePoint != -1) { - session.writeCodePoint(altDown, resultingCodePoint); - } - return true; - } else if (ctrlDown) { - if (codePoint == 106 /* Ctrl+j or \n */ && !session.isRunning()) { - mActivity.removeFinishedSession(session); - return true; - } - - List shortcuts = mActivity.mSettings.shortcuts; - if (!shortcuts.isEmpty()) { - int codePointLowerCase = Character.toLowerCase(codePoint); - for (int i = shortcuts.size() - 1; i >= 0; i--) { - TermuxPreferences.KeyboardShortcut shortcut = shortcuts.get(i); - if (codePointLowerCase == shortcut.codePoint) { - switch (shortcut.shortcutAction) { - case TermuxPreferences.SHORTCUT_ACTION_CREATE_SESSION: - mActivity.addNewSession(false, null); - return true; - case TermuxPreferences.SHORTCUT_ACTION_PREVIOUS_SESSION: - mActivity.switchToSession(false); - return true; - case TermuxPreferences.SHORTCUT_ACTION_NEXT_SESSION: - mActivity.switchToSession(true); - return true; - case TermuxPreferences.SHORTCUT_ACTION_RENAME_SESSION: - mActivity.renameSession(mActivity.getCurrentTermSession()); - return true; - } - } - } - } - } - - return false; - } - - @Override - public boolean onLongPress(MotionEvent event) { - return false; - } - - /** Handle dedicated volume buttons as virtual keys if applicable. */ - private boolean handleVirtualKeys(int keyCode, KeyEvent event, boolean down) { - InputDevice inputDevice = event.getDevice(); - if (mActivity.mSettings.mDisableVolumeVirtualKeys) { - return false; - } else if (inputDevice != null && inputDevice.getKeyboardType() == InputDevice.KEYBOARD_TYPE_ALPHABETIC) { - // Do not steal dedicated buttons from a full external keyboard. - return false; - } else if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) { - mVirtualControlKeyDown = down; - return true; - } else if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) { - mVirtualFnKeyDown = down; - return true; - } - return false; - } - - -} diff --git a/app/src/main/java/com/termux/app/TermuxHelpActivity.java b/app/src/main/java/com/termux/app/activities/HelpActivity.java similarity index 96% rename from app/src/main/java/com/termux/app/TermuxHelpActivity.java rename to app/src/main/java/com/termux/app/activities/HelpActivity.java index 0aa8a97a..fa32bfc4 100644 --- a/app/src/main/java/com/termux/app/TermuxHelpActivity.java +++ b/app/src/main/java/com/termux/app/activities/HelpActivity.java @@ -1,4 +1,4 @@ -package com.termux.app; +package com.termux.app.activities; import android.app.Activity; import android.content.ActivityNotFoundException; @@ -13,7 +13,7 @@ import android.widget.ProgressBar; import android.widget.RelativeLayout; /** Basic embedded browser for viewing help pages. */ -public final class TermuxHelpActivity extends Activity { +public final class HelpActivity extends Activity { WebView mWebView; diff --git a/app/src/main/java/com/termux/app/activities/ReportActivity.java b/app/src/main/java/com/termux/app/activities/ReportActivity.java new file mode 100644 index 00000000..2491005a --- /dev/null +++ b/app/src/main/java/com/termux/app/activities/ReportActivity.java @@ -0,0 +1,180 @@ +package com.termux.app.activities; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; + +import com.termux.R; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.markdown.MarkdownUtils; +import com.termux.shared.interact.ShareUtils; +import com.termux.app.models.ReportInfo; + +import org.commonmark.node.FencedCodeBlock; + +import io.noties.markwon.Markwon; +import io.noties.markwon.recycler.MarkwonAdapter; +import io.noties.markwon.recycler.SimpleEntry; + +public class ReportActivity extends AppCompatActivity { + + private static final String EXTRA_REPORT_INFO = "report_info"; + + ReportInfo mReportInfo; + String mReportMarkdownString; + String mReportActivityMarkdownString; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_report); + + Toolbar toolbar = findViewById(R.id.toolbar); + if (toolbar != null) { + setSupportActionBar(toolbar); + } + + Bundle bundle = null; + Intent intent = getIntent(); + if (intent != null) + bundle = intent.getExtras(); + else if (savedInstanceState != null) + bundle = savedInstanceState; + + updateUI(bundle); + + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + + if (intent != null) + updateUI(intent.getExtras()); + } + + private void updateUI(Bundle bundle) { + + if (bundle == null) { + finish(); + return; + } + + mReportInfo = (ReportInfo) bundle.getSerializable(EXTRA_REPORT_INFO); + + if (mReportInfo == null) { + finish(); + return; + } + + + final ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + if (mReportInfo.reportTitle != null) + actionBar.setTitle(mReportInfo.reportTitle); + else + actionBar.setTitle(TermuxConstants.TERMUX_APP_NAME + " App Report"); + } + + + RecyclerView recyclerView = findViewById(R.id.recycler_view); + + final Markwon markwon = MarkdownUtils.getRecyclerMarkwonBuilder(this); + + final MarkwonAdapter adapter = MarkwonAdapter.builderTextViewIsRoot(R.layout.activity_report_adapter_node_default) + .include(FencedCodeBlock.class, SimpleEntry.create(R.layout.activity_report_adapter_node_code_block, R.id.code_text_view)) + .build(); + + recyclerView.setLayoutManager(new LinearLayoutManager(this)); + recyclerView.setAdapter(adapter); + + + generateReportActivityMarkdownString(); + adapter.setMarkdown(markwon, mReportActivityMarkdownString); + adapter.notifyDataSetChanged(); + } + + + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + + outState.putSerializable(EXTRA_REPORT_INFO, mReportInfo); + } + + @Override + public boolean onCreateOptionsMenu(final Menu menu) { + final MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.menu_report, menu); + return true; + } + + @Override + public void onBackPressed() { + // Remove activity from recents menu on back button press + finishAndRemoveTask(); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + int id = item.getItemId(); + if (id == R.id.menu_item_share_report) { + if (mReportMarkdownString != null) + ShareUtils.shareText(this, getString(R.string.title_report_text), mReportMarkdownString); + } else if (id == R.id.menu_item_copy_report) { + if (mReportMarkdownString != null) + ShareUtils.copyTextToClipboard(this, mReportMarkdownString, null); + } + + return false; + } + + /** + * Generate the markdown {@link String} to be shown in {@link ReportActivity}. + */ + private void generateReportActivityMarkdownString() { + mReportMarkdownString = ReportInfo.getReportInfoMarkdownString(mReportInfo); + + mReportActivityMarkdownString = ""; + if (mReportInfo.reportStringPrefix != null) + mReportActivityMarkdownString += mReportInfo.reportStringPrefix; + + mReportActivityMarkdownString += mReportMarkdownString; + + if (mReportInfo.reportStringSuffix != null) + mReportActivityMarkdownString += mReportInfo.reportStringSuffix; + } + + + + public static void startReportActivity(@NonNull final Context context, @NonNull final ReportInfo reportInfo) { + context.startActivity(newInstance(context, reportInfo)); + } + + public static Intent newInstance(@NonNull final Context context, @NonNull final ReportInfo reportInfo) { + Intent intent = new Intent(context, ReportActivity.class); + Bundle bundle = new Bundle(); + bundle.putSerializable(EXTRA_REPORT_INFO, reportInfo); + intent.putExtras(bundle); + + // Note that ReportActivity task has documentLaunchMode="intoExisting" set in AndroidManifest.xml + // which has equivalent behaviour to the following. The following dynamic way doesn't seem to + // work for notification pending intent, i.e separate task isn't created and activity is + // launched in the same task as TermuxActivity. + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_NEW_DOCUMENT); + return intent; + } + +} diff --git a/app/src/main/java/com/termux/app/activities/SettingsActivity.java b/app/src/main/java/com/termux/app/activities/SettingsActivity.java new file mode 100644 index 00000000..b30b1a57 --- /dev/null +++ b/app/src/main/java/com/termux/app/activities/SettingsActivity.java @@ -0,0 +1,43 @@ +package com.termux.app.activities; + +import android.os.Bundle; + +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.preference.PreferenceFragmentCompat; + +import com.termux.R; + +public class SettingsActivity extends AppCompatActivity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_settings); + if (savedInstanceState == null) { + getSupportFragmentManager() + .beginTransaction() + .replace(R.id.settings, new RootPreferencesFragment()) + .commit(); + } + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setDisplayShowHomeEnabled(true); + } + } + + @Override + public boolean onSupportNavigateUp() { + onBackPressed(); + return true; + } + + public static class RootPreferencesFragment extends PreferenceFragmentCompat { + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + setPreferencesFromResource(R.xml.root_preferences, rootKey); + } + } + +} diff --git a/app/src/main/java/com/termux/app/fragments/settings/DebuggingPreferencesFragment.java b/app/src/main/java/com/termux/app/fragments/settings/DebuggingPreferencesFragment.java new file mode 100644 index 00000000..09da15c3 --- /dev/null +++ b/app/src/main/java/com/termux/app/fragments/settings/DebuggingPreferencesFragment.java @@ -0,0 +1,136 @@ +package com.termux.app.fragments.settings; + +import android.content.Context; +import android.os.Bundle; + +import androidx.annotation.Nullable; +import androidx.preference.ListPreference; +import androidx.preference.PreferenceCategory; +import androidx.preference.PreferenceDataStore; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.PreferenceManager; + +import com.termux.R; +import com.termux.shared.settings.preferences.TermuxAppSharedPreferences; +import com.termux.shared.logger.Logger; + +public class DebuggingPreferencesFragment extends PreferenceFragmentCompat { + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + PreferenceManager preferenceManager = getPreferenceManager(); + preferenceManager.setPreferenceDataStore(DebuggingPreferencesDataStore.getInstance(getContext())); + + setPreferencesFromResource(R.xml.debugging_preferences, rootKey); + + PreferenceCategory loggingCategory = findPreference("logging"); + + if (loggingCategory != null) { + final ListPreference logLevelListPreference = setLogLevelListPreferenceData(findPreference("log_level"), getActivity()); + loggingCategory.addPreference(logLevelListPreference); + } + } + + protected ListPreference setLogLevelListPreferenceData(ListPreference logLevelListPreference, Context context) { + if (logLevelListPreference == null) + logLevelListPreference = new ListPreference(context); + + CharSequence[] logLevels = Logger.getLogLevelsArray(); + CharSequence[] logLevelLabels = Logger.getLogLevelLabelsArray(context, logLevels, true); + + logLevelListPreference.setEntryValues(logLevels); + logLevelListPreference.setEntries(logLevelLabels); + + logLevelListPreference.setValue(String.valueOf(Logger.getLogLevel())); + logLevelListPreference.setDefaultValue(Logger.getLogLevel()); + + return logLevelListPreference; + } + +} + +class DebuggingPreferencesDataStore extends PreferenceDataStore { + + private final Context mContext; + private final TermuxAppSharedPreferences mPreferences; + + private static DebuggingPreferencesDataStore mInstance; + + private DebuggingPreferencesDataStore(Context context) { + mContext = context; + mPreferences = new TermuxAppSharedPreferences(context); + } + + public static synchronized DebuggingPreferencesDataStore getInstance(Context context) { + if (mInstance == null) { + mInstance = new DebuggingPreferencesDataStore(context.getApplicationContext()); + } + return mInstance; + } + + + + @Override + @Nullable + public String getString(String key, @Nullable String defValue) { + if (key == null) return null; + + switch (key) { + case "log_level": + return String.valueOf(mPreferences.getLogLevel()); + default: + return null; + } + } + + @Override + public void putString(String key, @Nullable String value) { + if (key == null) return; + + switch (key) { + case "log_level": + if (value != null) { + mPreferences.setLogLevel(mContext, Integer.parseInt(value)); + } + break; + default: + break; + } + } + + + + @Override + public void putBoolean(String key, boolean value) { + if (key == null) return; + + switch (key) { + case "terminal_view_key_logging_enabled": + mPreferences.setTerminalViewKeyLoggingEnabled(value); + break; + case "plugin_error_notifications_enabled": + mPreferences.setPluginErrorNotificationsEnabled(value); + break; + case "crash_report_notifications_enabled": + mPreferences.setCrashReportNotificationsEnabled(value); + break; + default: + break; + } + } + + @Override + public boolean getBoolean(String key, boolean defValue) { + switch (key) { + case "terminal_view_key_logging_enabled": + return mPreferences.getTerminalViewKeyLoggingEnabled(); + case "plugin_error_notifications_enabled": + return mPreferences.getPluginErrorNotificationsEnabled(); + case "crash_report_notifications_enabled": + return mPreferences.getCrashReportNotificationsEnabled(); + default: + return false; + } + } + +} diff --git a/app/src/main/java/com/termux/app/fragments/settings/TerminalIOPreferencesFragment.java b/app/src/main/java/com/termux/app/fragments/settings/TerminalIOPreferencesFragment.java new file mode 100644 index 00000000..987f9dad --- /dev/null +++ b/app/src/main/java/com/termux/app/fragments/settings/TerminalIOPreferencesFragment.java @@ -0,0 +1,69 @@ +package com.termux.app.fragments.settings; + +import android.content.Context; +import android.os.Bundle; + +import androidx.preference.PreferenceDataStore; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.PreferenceManager; + +import com.termux.R; +import com.termux.shared.settings.preferences.TermuxAppSharedPreferences; + +public class TerminalIOPreferencesFragment extends PreferenceFragmentCompat { + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + PreferenceManager preferenceManager = getPreferenceManager(); + preferenceManager.setPreferenceDataStore(TerminalIOPreferencesDataStore.getInstance(getContext())); + + setPreferencesFromResource(R.xml.terminal_io_preferences, rootKey); + } + +} + +class TerminalIOPreferencesDataStore extends PreferenceDataStore { + + private final Context mContext; + private final TermuxAppSharedPreferences mPreferences; + + private static TerminalIOPreferencesDataStore mInstance; + + private TerminalIOPreferencesDataStore(Context context) { + mContext = context; + mPreferences = new TermuxAppSharedPreferences(context); + } + + public static synchronized TerminalIOPreferencesDataStore getInstance(Context context) { + if (mInstance == null) { + mInstance = new TerminalIOPreferencesDataStore(context.getApplicationContext()); + } + return mInstance; + } + + + + @Override + public void putBoolean(String key, boolean value) { + if (key == null) return; + + switch (key) { + case "soft_keyboard_enabled": + mPreferences.setSoftKeyboardEnabled(value); + break; + default: + break; + } + } + + @Override + public boolean getBoolean(String key, boolean defValue) { + switch (key) { + case "soft_keyboard_enabled": + return mPreferences.getSoftKeyboardEnabled(); + default: + return false; + } + } + +} diff --git a/app/src/main/java/com/termux/app/models/ReportInfo.java b/app/src/main/java/com/termux/app/models/ReportInfo.java new file mode 100644 index 00000000..05c8853d --- /dev/null +++ b/app/src/main/java/com/termux/app/models/ReportInfo.java @@ -0,0 +1,64 @@ +package com.termux.app.models; + +import com.termux.shared.markdown.MarkdownUtils; +import com.termux.shared.termux.TermuxUtils; + +import java.io.Serializable; + +public class ReportInfo implements Serializable { + + /** The user action that was being processed for which the report was generated. */ + public final UserAction userAction; + /** The internal app component that sent the report. */ + public final String sender; + /** The report title. */ + public final String reportTitle; + /** The markdown report text prefix. Will not be part of copy and share operations, etc. */ + public final String reportStringPrefix; + /** The markdown report text. */ + public final String reportString; + /** The markdown report text suffix. Will not be part of copy and share operations, etc. */ + public final String reportStringSuffix; + /** If set to {@code true}, then report, app and device info will be added to the report when + * markdown is generated. + */ + public final boolean addReportInfoToMarkdown; + /** The timestamp for the report. */ + public final String reportTimestamp; + + public ReportInfo(UserAction userAction, String sender, String reportTitle, String reportStringPrefix, String reportString, String reportStringSuffix, boolean addReportInfoToMarkdown) { + this.userAction = userAction; + this.sender = sender; + this.reportTitle = reportTitle; + this.reportStringPrefix = reportStringPrefix; + this.reportString = reportString; + this.reportStringSuffix = reportStringSuffix; + this.addReportInfoToMarkdown = addReportInfoToMarkdown; + this.reportTimestamp = TermuxUtils.getCurrentTimeStamp(); + } + + /** + * Get a markdown {@link String} for {@link ReportInfo}. + * + * @param reportInfo The {@link ReportInfo} to convert. + * @return Returns the markdown {@link String}. + */ + public static String getReportInfoMarkdownString(final ReportInfo reportInfo) { + if (reportInfo == null) return "null"; + + StringBuilder markdownString = new StringBuilder(); + + if (reportInfo.addReportInfoToMarkdown) { + markdownString.append("## Report Info\n\n"); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("User Action", reportInfo.userAction, "-")); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Sender", reportInfo.sender, "-")); + markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Report Timestamp", reportInfo.reportTimestamp, "-")); + markdownString.append("\n##\n\n"); + } + + markdownString.append(reportInfo.reportString); + + return markdownString.toString(); + } + +} diff --git a/app/src/main/java/com/termux/app/models/UserAction.java b/app/src/main/java/com/termux/app/models/UserAction.java new file mode 100644 index 00000000..ad56fbef --- /dev/null +++ b/app/src/main/java/com/termux/app/models/UserAction.java @@ -0,0 +1,19 @@ +package com.termux.app.models; + +public enum UserAction { + + PLUGIN_EXECUTION_COMMAND("plugin execution command"), + CRASH_REPORT("crash report"), + REPORT_ISSUE_FROM_TRANSCRIPT("report issue from transcript"); + + private final String name; + + UserAction(final String name) { + this.name = name; + } + + public String getName() { + return name; + } + +} diff --git a/app/src/main/java/com/termux/app/settings/properties/TermuxAppSharedProperties.java b/app/src/main/java/com/termux/app/settings/properties/TermuxAppSharedProperties.java new file mode 100644 index 00000000..2ad42095 --- /dev/null +++ b/app/src/main/java/com/termux/app/settings/properties/TermuxAppSharedProperties.java @@ -0,0 +1,99 @@ +package com.termux.app.settings.properties; + +import android.content.Context; + +import com.termux.app.terminal.io.KeyboardShortcut; +import com.termux.app.terminal.io.extrakeys.ExtraKeysInfo; +import com.termux.shared.logger.Logger; +import com.termux.shared.settings.properties.SharedPropertiesParser; +import com.termux.shared.settings.properties.TermuxPropertyConstants; +import com.termux.shared.settings.properties.TermuxSharedProperties; + +import org.json.JSONException; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import javax.annotation.Nonnull; + +public class TermuxAppSharedProperties extends TermuxSharedProperties implements SharedPropertiesParser { + + private ExtraKeysInfo mExtraKeysInfo; + private List mSessionShortcuts = new ArrayList<>(); + + private static final String LOG_TAG = "TermuxAppSharedProperties"; + + public TermuxAppSharedProperties(@Nonnull Context context) { + super(context); + } + + /** + * Reload the termux properties from disk into an in-memory cache. + */ + @Override + public void loadTermuxPropertiesFromDisk() { + super.loadTermuxPropertiesFromDisk(); + + setExtraKeys(); + setSessionShortcuts(); + } + + /** + * Set the terminal extra keys and style. + */ + private void setExtraKeys() { + mExtraKeysInfo = null; + + try { + // The mMap stores the extra key and style string values while loading properties + // Check {@link #getExtraKeysInternalPropertyValueFromValue(String)} and + // {@link #getExtraKeysStyleInternalPropertyValueFromValue(String)} + String extrakeys = (String) getInternalPropertyValue(TermuxPropertyConstants.KEY_EXTRA_KEYS, true); + String extraKeysStyle = (String) getInternalPropertyValue(TermuxPropertyConstants.KEY_EXTRA_KEYS_STYLE, true); + mExtraKeysInfo = new ExtraKeysInfo(extrakeys, extraKeysStyle); + } catch (JSONException e) { + Logger.showToast(mContext, "Could not load and set the \"" + TermuxPropertyConstants.KEY_EXTRA_KEYS + "\" property from the properties file: " + e.toString(), true); + Logger.logStackTraceWithMessage(LOG_TAG, "Could not load and set the \"" + TermuxPropertyConstants.KEY_EXTRA_KEYS + "\" property from the properties file: ", e); + + try { + mExtraKeysInfo = new ExtraKeysInfo(TermuxPropertyConstants.DEFAULT_IVALUE_EXTRA_KEYS, TermuxPropertyConstants.DEFAULT_IVALUE_EXTRA_KEYS_STYLE); + } catch (JSONException e2) { + Logger.showToast(mContext, "Can't create default extra keys",true); + Logger.logStackTraceWithMessage(LOG_TAG, "Could create default extra keys: ", e); + mExtraKeysInfo = null; + } + } + } + + /** + * Set the terminal sessions shortcuts. + */ + private void setSessionShortcuts() { + if (mSessionShortcuts == null) + mSessionShortcuts = new ArrayList<>(); + else + mSessionShortcuts.clear(); + + // The {@link TermuxPropertyConstants#MAP_SESSION_SHORTCUTS} stores the session shortcut key and action pair + for (Map.Entry entry : TermuxPropertyConstants.MAP_SESSION_SHORTCUTS.entrySet()) { + // The mMap stores the code points for the session shortcuts while loading properties + Integer codePoint = (Integer) getInternalPropertyValue(entry.getKey(), true); + // If codePoint is null, then session shortcut did not exist in properties or was invalid + // as parsed by {@link #getCodePointForSessionShortcuts(String,String)} + // If codePoint is not null, then get the action for the MAP_SESSION_SHORTCUTS key and + // add the code point to sessionShortcuts + if (codePoint != null) + mSessionShortcuts.add(new KeyboardShortcut(codePoint, entry.getValue())); + } + } + + public List getSessionShortcuts() { + return mSessionShortcuts; + } + + public ExtraKeysInfo getExtraKeysInfo() { + return mExtraKeysInfo; + } + +} diff --git a/app/src/main/java/com/termux/app/terminal/TermuxSessionsListViewController.java b/app/src/main/java/com/termux/app/terminal/TermuxSessionsListViewController.java new file mode 100644 index 00000000..76c79309 --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/TermuxSessionsListViewController.java @@ -0,0 +1,107 @@ +package com.termux.app.terminal; + +import android.annotation.SuppressLint; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Typeface; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.style.StyleSpan; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.TextView; + +import androidx.annotation.NonNull; +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; + +public class TermuxSessionsListViewController extends ArrayAdapter implements AdapterView.OnItemClickListener, AdapterView.OnItemLongClickListener { + + final TermuxActivity mActivity; + + final StyleSpan boldSpan = new StyleSpan(Typeface.BOLD); + final StyleSpan italicSpan = new StyleSpan(Typeface.ITALIC); + + public TermuxSessionsListViewController(TermuxActivity activity, List sessionList) { + super(activity.getApplicationContext(), R.layout.item_terminal_sessions_list, sessionList); + this.mActivity = activity; + } + + @SuppressLint("SetTextI18n") + @NonNull + @Override + public View getView(int position, View convertView, @NonNull ViewGroup parent) { + View sessionRowView = convertView; + if (sessionRowView == null) { + LayoutInflater inflater = mActivity.getLayoutInflater(); + sessionRowView = inflater.inflate(R.layout.item_terminal_sessions_list, parent, false); + } + + TextView sessionTitleView = sessionRowView.findViewById(R.id.session_title); + + TerminalSession sessionAtRow = getItem(position).getTerminalSession(); + if (sessionAtRow == null) { + sessionTitleView.setText("null session"); + return sessionRowView; + } + + boolean isUsingBlackUI = mActivity.getProperties().isUsingBlackUI(); + + if (isUsingBlackUI) { + sessionTitleView.setBackground( + ContextCompat.getDrawable(mActivity, R.drawable.session_background_black_selected) + ); + } + + String name = sessionAtRow.mSessionName; + String sessionTitle = sessionAtRow.getTitle(); + + String numberPart = "[" + (position + 1) + "] "; + String sessionNamePart = (TextUtils.isEmpty(name) ? "" : name); + String sessionTitlePart = (TextUtils.isEmpty(sessionTitle) ? "" : ((sessionNamePart.isEmpty() ? "" : "\n") + sessionTitle)); + + String fullSessionTitle = numberPart + sessionNamePart + sessionTitlePart; + SpannableString fullSessionTitleStyled = new SpannableString(fullSessionTitle); + fullSessionTitleStyled.setSpan(boldSpan, 0, numberPart.length() + sessionNamePart.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + fullSessionTitleStyled.setSpan(italicSpan, numberPart.length() + sessionNamePart.length(), fullSessionTitle.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + sessionTitleView.setText(fullSessionTitleStyled); + + boolean sessionRunning = sessionAtRow.isRunning(); + + if (sessionRunning) { + sessionTitleView.setPaintFlags(sessionTitleView.getPaintFlags() & ~Paint.STRIKE_THRU_TEXT_FLAG); + } else { + sessionTitleView.setPaintFlags(sessionTitleView.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG); + } + int defaultColor = isUsingBlackUI ? Color.WHITE : Color.BLACK; + int color = sessionRunning || sessionAtRow.getExitStatus() == 0 ? defaultColor : Color.RED; + sessionTitleView.setTextColor(color); + return sessionRowView; + } + + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + TermuxSession clickedSession = getItem(position); + mActivity.getTermuxTerminalSessionClient().setCurrentSession(clickedSession.getTerminalSession()); + mActivity.getDrawer().closeDrawers(); + } + + @Override + public boolean onItemLongClick(AdapterView parent, View view, int position, long id) { + final TermuxSession selectedSession = getItem(position); + mActivity.getTermuxTerminalSessionClient().renameSession(selectedSession.getTerminalSession()); + return true; + } + +} diff --git a/app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionClient.java b/app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionClient.java new file mode 100644 index 00000000..8beb7971 --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionClient.java @@ -0,0 +1,343 @@ +package com.termux.app.terminal; + +import android.annotation.SuppressLint; +import android.app.AlertDialog; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.pm.PackageManager; +import android.graphics.Typeface; +import android.media.AudioAttributes; +import android.media.SoundPool; +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.TermuxTerminalSessionClientBase; +import com.termux.shared.termux.TermuxConstants; +import com.termux.app.TermuxService; +import com.termux.shared.settings.properties.TermuxPropertyConstants; +import com.termux.app.terminal.io.BellHandler; +import com.termux.shared.logger.Logger; +import com.termux.terminal.TerminalColors; +import com.termux.terminal.TerminalSession; +import com.termux.terminal.TextStyle; + +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.util.Properties; + +public class TermuxTerminalSessionClient extends TermuxTerminalSessionClientBase { + + private final TermuxActivity mActivity; + + private static final int MAX_SESSIONS = 8; + + private final SoundPool mBellSoundPool = new SoundPool.Builder().setMaxStreams(1).setAudioAttributes( + new AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION).build()).build(); + + private final int mBellSoundId; + + private static final String LOG_TAG = "TermuxTerminalSessionClient"; + + public TermuxTerminalSessionClient(TermuxActivity activity) { + this.mActivity = activity; + + mBellSoundId = mBellSoundPool.load(activity, R.raw.bell, 1); + } + + @Override + public void onTextChanged(TerminalSession changedSession) { + if (!mActivity.isVisible()) return; + + if (mActivity.getCurrentSession() == changedSession) mActivity.getTerminalView().onScreenUpdated(); + } + + @Override + public void onTitleChanged(TerminalSession updatedSession) { + if (!mActivity.isVisible()) return; + + if (updatedSession != mActivity.getCurrentSession()) { + // Only show toast for other sessions than the current one, since the user + // probably consciously caused the title change to change in the current session + // and don't want an annoying toast for that. + mActivity.showToast(toToastTitle(updatedSession), true); + } + + termuxSessionListNotifyUpdated(); + } + + @Override + public void onSessionFinished(final TerminalSession finishedSession) { + if (mActivity.getTermuxService().wantsToStop()) { + // The service wants to stop as soon as possible. + mActivity.finishActivityIfNotFinishing(); + return; + } + + if (mActivity.isVisible() && finishedSession != mActivity.getCurrentSession()) { + // Show toast for non-current sessions that exit. + int indexOfSession = mActivity.getTermuxService().getIndexOfSession(finishedSession); + // Verify that session was not removed before we got told about it finishing: + if (indexOfSession >= 0) + mActivity.showToast(toToastTitle(finishedSession) + " - exited", true); + } + + if (mActivity.getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK)) { + // On Android TV devices we need to use older behaviour because we may + // not be able to have multiple launcher icons. + if (mActivity.getTermuxService().getTermuxSessionsSize() > 1) { + removeFinishedSession(finishedSession); + } + } else { + // Once we have a separate launcher icon for the failsafe session, it + // should be safe to auto-close session on exit code '0' or '130'. + if (finishedSession.getExitStatus() == 0 || finishedSession.getExitStatus() == 130) { + removeFinishedSession(finishedSession); + } + } + } + + @Override + public void onClipboardText(TerminalSession session, String text) { + if (!mActivity.isVisible()) return; + + ClipboardManager clipboard = (ClipboardManager) mActivity.getSystemService(Context.CLIPBOARD_SERVICE); + clipboard.setPrimaryClip(new ClipData(null, new String[]{"text/plain"}, new ClipData.Item(text))); + } + + @Override + public void onBell(TerminalSession session) { + if (!mActivity.isVisible()) return; + + switch (mActivity.getProperties().getBellBehaviour()) { + case TermuxPropertyConstants.IVALUE_BELL_BEHAVIOUR_VIBRATE: + BellHandler.getInstance(mActivity).doBell(); + break; + case TermuxPropertyConstants.IVALUE_BELL_BEHAVIOUR_BEEP: + mBellSoundPool.play(mBellSoundId, 1.f, 1.f, 1, 0, 1.f); + break; + case TermuxPropertyConstants.IVALUE_BELL_BEHAVIOUR_IGNORE: + // Ignore the bell character. + break; + } + + } + + @Override + public void onColorsChanged(TerminalSession changedSession) { + if (mActivity.getCurrentSession() == changedSession) + updateBackgroundColor(); + } + + + + /** Try switching to session. */ + public void setCurrentSession(TerminalSession session) { + if (session == null) return; + + if (mActivity.getTerminalView().attachSession(session)) { + // notify about switched session if not already displaying the session + notifyOfSessionChange(); + } + + // We call the following even when the session is already being displayed since config may + // be stale, like current session not selected or scrolled to. + checkAndScrollToSession(session); + updateBackgroundColor(); + } + + void notifyOfSessionChange() { + if (!mActivity.isVisible()) return; + + TerminalSession session = mActivity.getCurrentSession(); + mActivity.showToast(toToastTitle(session), false); + } + + public void switchToSession(boolean forward) { + TermuxService service = mActivity.getTermuxService(); + + TerminalSession currentTerminalSession = mActivity.getCurrentSession(); + int index = service.getIndexOfSession(currentTerminalSession); + int size = service.getTermuxSessionsSize(); + if (forward) { + if (++index >= size) index = 0; + } else { + if (--index < 0) index = size - 1; + } + + TermuxSession termuxSession = service.getTermuxSession(index); + if (termuxSession != null) + setCurrentSession(termuxSession.getTerminalSession()); + } + + public void switchToSession(int index) { + TermuxSession termuxSession = mActivity.getTermuxService().getTermuxSession(index); + if (termuxSession != null) + setCurrentSession(termuxSession.getTerminalSession()); + } + + @SuppressLint("InflateParams") + public void renameSession(final TerminalSession sessionToRename) { + if (sessionToRename == null) return; + + DialogUtils.textInput(mActivity, R.string.title_rename_session, sessionToRename.mSessionName, R.string.action_rename_session_confirm, text -> { + sessionToRename.mSessionName = text; + termuxSessionListNotifyUpdated(); + }, -1, null, -1, null, null); + } + + public void addNewSession(boolean isFailSafe, String sessionName) { + if (mActivity.getTermuxService().getTermuxSessionsSize() >= MAX_SESSIONS) { + new AlertDialog.Builder(mActivity).setTitle(R.string.title_max_terminals_reached).setMessage(R.string.msg_max_terminals_reached) + .setPositiveButton(android.R.string.ok, null).show(); + } else { + TerminalSession currentSession = mActivity.getCurrentSession(); + + String workingDirectory; + if (currentSession == null) { + workingDirectory = mActivity.getProperties().getDefaultWorkingDirectory(); + } else { + workingDirectory = currentSession.getCwd(); + } + + TermuxSession newTermuxSession = mActivity.getTermuxService().createTermuxSession(null, null, null, workingDirectory, isFailSafe, sessionName); + if (newTermuxSession == null) return; + + TerminalSession newTerminalSession = newTermuxSession.getTerminalSession(); + setCurrentSession(newTerminalSession); + + mActivity.getDrawer().closeDrawers(); + } + } + + public void setCurrentStoredSession() { + TerminalSession currentSession = mActivity.getCurrentSession(); + if (currentSession != null) + mActivity.getPreferences().setCurrentSession(currentSession.mHandle); + else + mActivity.getPreferences().setCurrentSession(null); + } + + /** The current session as stored or the last one if that does not exist. */ + public TerminalSession getCurrentStoredSessionOrLast() { + TerminalSession stored = getCurrentStoredSession(mActivity); + + if (stored != null) { + // If a stored session is in the list of currently running sessions, then return it + return stored; + } else { + // Else return the last session currently running + TermuxSession termuxSession = mActivity.getTermuxService().getLastTermuxSession(); + if (termuxSession != null) + return termuxSession.getTerminalSession(); + else + return null; + } + } + + private TerminalSession getCurrentStoredSession(TermuxActivity context) { + String sessionHandle = mActivity.getPreferences().getCurrentSession(); + + // If no session is stored in shared preferences + if (sessionHandle == null) + return null; + + // Check if the session handle found matches one of the currently running sessions + return context.getTermuxService().getTerminalSessionForHandle(sessionHandle); + } + + public void removeFinishedSession(TerminalSession finishedSession) { + // Return pressed with finished session - remove it. + 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. + mActivity.finishActivityIfNotFinishing(); + } else { + if (index >= size) { + index = size - 1; + } + TermuxSession termuxSession = service.getTermuxSession(index); + if (termuxSession != null) + setCurrentSession(termuxSession.getTerminalSession()); + } + } + + public void termuxSessionListNotifyUpdated() { + mActivity.termuxSessionListNotifyUpdated(); + } + + public void checkAndScrollToSession(TerminalSession session) { + if (!mActivity.isVisible()) return; + final int indexOfSession = mActivity.getTermuxService().getIndexOfSession(session); + if (indexOfSession < 0) return; + final ListView termuxSessionsListView = mActivity.findViewById(R.id.terminal_sessions_list); + if (termuxSessionsListView == null) return; + + termuxSessionsListView.setItemChecked(indexOfSession, true); + // Delay is necessary otherwise sometimes scroll to newly added session does not happen + termuxSessionsListView.postDelayed(() -> termuxSessionsListView.smoothScrollToPosition(indexOfSession), 1000); + } + + + String toToastTitle(TerminalSession session) { + final int indexOfSession = mActivity.getTermuxService().getIndexOfSession(session); + if (indexOfSession < 0) return null; + StringBuilder toastTitle = new StringBuilder("[" + (indexOfSession + 1) + "]"); + if (!TextUtils.isEmpty(session.mSessionName)) { + toastTitle.append(" ").append(session.mSessionName); + } + String title = session.getTitle(); + if (!TextUtils.isEmpty(title)) { + // Space to "[${NR}] or newline after session name: + toastTitle.append(session.mSessionName == null ? " " : "\n"); + toastTitle.append(title); + } + return toastTitle.toString(); + } + + + public void checkForFontAndColors() { + try { + File colorsFile = TermuxConstants.TERMUX_COLOR_PROPERTIES_FILE; + File fontFile = TermuxConstants.TERMUX_FONT_FILE; + + final Properties props = new Properties(); + if (colorsFile.isFile()) { + try (InputStream in = new FileInputStream(colorsFile)) { + props.load(in); + } + } + + TerminalColors.COLOR_SCHEME.updateWith(props); + TerminalSession session = mActivity.getCurrentSession(); + if (session != null && session.getEmulator() != null) { + session.getEmulator().mColors.reset(); + } + updateBackgroundColor(); + + final Typeface newTypeface = (fontFile.exists() && fontFile.length() > 0) ? Typeface.createFromFile(fontFile) : Typeface.MONOSPACE; + mActivity.getTerminalView().setTypeface(newTypeface); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Error in checkForFontAndColors()", e); + } + } + + public void updateBackgroundColor() { + if (!mActivity.isVisible()) return; + TerminalSession session = mActivity.getCurrentSession(); + if (session != null && session.getEmulator() != null) { + mActivity.getWindow().getDecorView().setBackgroundColor(session.getEmulator().mColors.mCurrentColors[TextStyle.COLOR_INDEX_BACKGROUND]); + } + } + +} diff --git a/app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java b/app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java new file mode 100644 index 00000000..39ca33d9 --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java @@ -0,0 +1,480 @@ +package com.termux.app.terminal; + +import android.annotation.SuppressLint; +import android.app.AlertDialog; +import android.content.ActivityNotFoundException; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.Intent; +import android.media.AudioManager; +import android.net.Uri; +import android.text.TextUtils; +import android.view.Gravity; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.inputmethod.InputMethodManager; +import android.widget.ListView; +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; +import com.termux.app.models.UserAction; +import com.termux.app.terminal.io.KeyboardShortcut; +import com.termux.app.terminal.io.extrakeys.ExtraKeysView; +import com.termux.shared.settings.properties.TermuxPropertyConstants; +import com.termux.shared.data.DataUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.markdown.MarkdownUtils; +import com.termux.shared.termux.TermuxUtils; +import com.termux.terminal.KeyHandler; +import com.termux.terminal.TerminalEmulator; +import com.termux.terminal.TerminalSession; +import com.termux.view.TerminalViewClient; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; + +import androidx.drawerlayout.widget.DrawerLayout; + +public class TermuxTerminalViewClient implements TerminalViewClient { + + final TermuxActivity mActivity; + + final TermuxTerminalSessionClient mTermuxTerminalSessionClient; + + /** Keeping track of the special keys acting as Ctrl and Fn for the soft keyboard and other hardware keys. */ + boolean mVirtualControlKeyDown, mVirtualFnKeyDown; + + public TermuxTerminalViewClient(TermuxActivity activity, TermuxTerminalSessionClient termuxTerminalSessionClient) { + this.mActivity = activity; + this.mTermuxTerminalSessionClient = termuxTerminalSessionClient; + } + + @Override + public float onScale(float scale) { + if (scale < 0.9f || scale > 1.1f) { + boolean increase = scale > 1.f; + changeFontSize(increase); + return 1.0f; + } + return scale; + } + + + + @Override + public void onSingleTapUp(MotionEvent e) { + InputMethodManager mgr = (InputMethodManager) mActivity.getSystemService(Context.INPUT_METHOD_SERVICE); + mgr.showSoftInput(mActivity.getTerminalView(), InputMethodManager.SHOW_IMPLICIT); + } + + @Override + public boolean shouldBackButtonBeMappedToEscape() { + return mActivity.getProperties().isBackKeyTheEscapeKey(); + } + + @Override + public boolean shouldEnforceCharBasedInput() { + return mActivity.getProperties().isEnforcingCharBasedInput(); + } + + @Override + public boolean shouldUseCtrlSpaceWorkaround() { + return mActivity.getProperties().isUsingCtrlSpaceWorkaround(); + } + + + + @Override + public void copyModeChanged(boolean copyMode) { + // Disable drawer while copying. + mActivity.getDrawer().setDrawerLockMode(copyMode ? DrawerLayout.LOCK_MODE_LOCKED_CLOSED : DrawerLayout.LOCK_MODE_UNLOCKED); + } + + + + @SuppressLint("RtlHardcoded") + @Override + public boolean onKeyDown(int keyCode, KeyEvent e, TerminalSession currentSession) { + if (handleVirtualKeys(keyCode, e, true)) return true; + + if (keyCode == KeyEvent.KEYCODE_ENTER && !currentSession.isRunning()) { + mTermuxTerminalSessionClient.removeFinishedSession(currentSession); + return true; + } else if (e.isCtrlPressed() && e.isAltPressed()) { + // Get the unmodified code point: + int unicodeChar = e.getUnicodeChar(0); + + if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN || unicodeChar == 'n'/* next */) { + mTermuxTerminalSessionClient.switchToSession(true); + } else if (keyCode == KeyEvent.KEYCODE_DPAD_UP || unicodeChar == 'p' /* previous */) { + mTermuxTerminalSessionClient.switchToSession(false); + } else if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) { + mActivity.getDrawer().openDrawer(Gravity.LEFT); + } else if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) { + mActivity.getDrawer().closeDrawers(); + } else if (unicodeChar == 'k'/* keyboard */) { + InputMethodManager imm = (InputMethodManager) mActivity.getSystemService(Context.INPUT_METHOD_SERVICE); + imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0); + } else if (unicodeChar == 'm'/* menu */) { + mActivity.getTerminalView().showContextMenu(); + } else if (unicodeChar == 'r'/* rename */) { + mTermuxTerminalSessionClient.renameSession(currentSession); + } else if (unicodeChar == 'c'/* create */) { + mTermuxTerminalSessionClient.addNewSession(false, null); + } else if (unicodeChar == 'u' /* urls */) { + showUrlSelection(); + } else if (unicodeChar == 'v') { + doPaste(); + } else if (unicodeChar == '+' || e.getUnicodeChar(KeyEvent.META_SHIFT_ON) == '+') { + // We also check for the shifted char here since shift may be required to produce '+', + // see https://github.com/termux/termux-api/issues/2 + changeFontSize(true); + } else if (unicodeChar == '-') { + changeFontSize(false); + } else if (unicodeChar >= '1' && unicodeChar <= '9') { + int index = unicodeChar - '1'; + mTermuxTerminalSessionClient.switchToSession(index); + } + return true; + } + + return false; + + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent e) { + return handleVirtualKeys(keyCode, e, false); + } + + /** Handle dedicated volume buttons as virtual keys if applicable. */ + private boolean handleVirtualKeys(int keyCode, KeyEvent event, boolean down) { + InputDevice inputDevice = event.getDevice(); + if (mActivity.getProperties().areVirtualVolumeKeysDisabled()) { + return false; + } else if (inputDevice != null && inputDevice.getKeyboardType() == InputDevice.KEYBOARD_TYPE_ALPHABETIC) { + // Do not steal dedicated buttons from a full external keyboard. + return false; + } else if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) { + mVirtualControlKeyDown = down; + return true; + } else if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) { + mVirtualFnKeyDown = down; + return true; + } + return false; + } + + + + @Override + public boolean readControlKey() { + return (mActivity.getExtraKeysView() != null && mActivity.getExtraKeysView().readSpecialButton(ExtraKeysView.SpecialButton.CTRL)) || mVirtualControlKeyDown; + } + + @Override + public boolean readAltKey() { + return (mActivity.getExtraKeysView() != null && mActivity.getExtraKeysView().readSpecialButton(ExtraKeysView.SpecialButton.ALT)); + } + + @Override + public boolean onLongPress(MotionEvent event) { + return false; + } + + + + @Override + public boolean onCodePoint(final int codePoint, boolean ctrlDown, TerminalSession session) { + if (mVirtualFnKeyDown) { + int resultingKeyCode = -1; + int resultingCodePoint = -1; + boolean altDown = false; + int lowerCase = Character.toLowerCase(codePoint); + switch (lowerCase) { + // Arrow keys. + case 'w': + resultingKeyCode = KeyEvent.KEYCODE_DPAD_UP; + break; + case 'a': + resultingKeyCode = KeyEvent.KEYCODE_DPAD_LEFT; + break; + case 's': + resultingKeyCode = KeyEvent.KEYCODE_DPAD_DOWN; + break; + case 'd': + resultingKeyCode = KeyEvent.KEYCODE_DPAD_RIGHT; + break; + + // Page up and down. + case 'p': + resultingKeyCode = KeyEvent.KEYCODE_PAGE_UP; + break; + case 'n': + resultingKeyCode = KeyEvent.KEYCODE_PAGE_DOWN; + break; + + // Some special keys: + case 't': + resultingKeyCode = KeyEvent.KEYCODE_TAB; + break; + case 'i': + resultingKeyCode = KeyEvent.KEYCODE_INSERT; + break; + case 'h': + resultingCodePoint = '~'; + break; + + // Special characters to input. + case 'u': + resultingCodePoint = '_'; + break; + case 'l': + resultingCodePoint = '|'; + break; + + // Function keys. + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + resultingKeyCode = (codePoint - '1') + KeyEvent.KEYCODE_F1; + break; + case '0': + resultingKeyCode = KeyEvent.KEYCODE_F10; + break; + + // Other special keys. + case 'e': + resultingCodePoint = /*Escape*/ 27; + break; + case '.': + resultingCodePoint = /*^.*/ 28; + break; + + case 'b': // alt+b, jumping backward in readline. + case 'f': // alf+f, jumping forward in readline. + case 'x': // alt+x, common in emacs. + resultingCodePoint = lowerCase; + altDown = true; + break; + + // Volume control. + case 'v': + resultingCodePoint = -1; + AudioManager audio = (AudioManager) mActivity.getSystemService(Context.AUDIO_SERVICE); + audio.adjustSuggestedStreamVolume(AudioManager.ADJUST_SAME, AudioManager.USE_DEFAULT_STREAM_TYPE, AudioManager.FLAG_SHOW_UI); + break; + + // Writing mode: + case 'q': + case 'k': + mActivity.toggleTerminalToolbar(); + mVirtualFnKeyDown=false; // force disable fn key down to restore keyboard input into terminal view, fixes termux/termux-app#1420 + break; + } + + if (resultingKeyCode != -1) { + TerminalEmulator term = session.getEmulator(); + session.write(KeyHandler.getCode(resultingKeyCode, 0, term.isCursorKeysApplicationMode(), term.isKeypadApplicationMode())); + } else if (resultingCodePoint != -1) { + session.writeCodePoint(altDown, resultingCodePoint); + } + return true; + } else if (ctrlDown) { + if (codePoint == 106 /* Ctrl+j or \n */ && !session.isRunning()) { + mTermuxTerminalSessionClient.removeFinishedSession(session); + return true; + } + + List shortcuts = mActivity.getProperties().getSessionShortcuts(); + if (shortcuts != null && !shortcuts.isEmpty()) { + int codePointLowerCase = Character.toLowerCase(codePoint); + for (int i = shortcuts.size() - 1; i >= 0; i--) { + KeyboardShortcut shortcut = shortcuts.get(i); + if (codePointLowerCase == shortcut.codePoint) { + switch (shortcut.shortcutAction) { + case TermuxPropertyConstants.ACTION_SHORTCUT_CREATE_SESSION: + mTermuxTerminalSessionClient.addNewSession(false, null); + return true; + case TermuxPropertyConstants.ACTION_SHORTCUT_NEXT_SESSION: + mTermuxTerminalSessionClient.switchToSession(true); + return true; + case TermuxPropertyConstants.ACTION_SHORTCUT_PREVIOUS_SESSION: + mTermuxTerminalSessionClient.switchToSession(false); + return true; + case TermuxPropertyConstants.ACTION_SHORTCUT_RENAME_SESSION: + mTermuxTerminalSessionClient.renameSession(mActivity.getCurrentSession()); + return true; + } + } + } + } + } + + return false; + } + + + + public void changeFontSize(boolean increase) { + mActivity.getPreferences().changeFontSize(increase); + mActivity.getTerminalView().setTextSize(mActivity.getPreferences().getFontSize()); + } + + + + public void shareSessionTranscript() { + TerminalSession session = mActivity.getCurrentSession(); + if (session == null) return; + + String transcriptText = ShellUtils.getTerminalSessionTranscriptText(session, false, true); + if (transcriptText == null) return; + + try { + // See https://github.com/termux/termux-app/issues/1166. + Intent intent = new Intent(Intent.ACTION_SEND); + intent.setType("text/plain"); + transcriptText = DataUtils.getTruncatedCommandOutput(transcriptText, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES, false, true, false).trim(); + intent.putExtra(Intent.EXTRA_TEXT, transcriptText); + intent.putExtra(Intent.EXTRA_SUBJECT, mActivity.getString(R.string.title_share_transcript)); + mActivity.startActivity(Intent.createChooser(intent, mActivity.getString(R.string.title_share_transcript_with))); + } catch (Exception e) { + Logger.logStackTraceWithMessage("Failed to get share session transcript of length " + transcriptText.length(), e); + } + } + + public void showUrlSelection() { + TerminalSession session = mActivity.getCurrentSession(); + if (session == null) return; + + String text = ShellUtils.getTerminalSessionTranscriptText(session, true, true); + + LinkedHashSet urlSet = DataUtils.extractUrls(text); + if (urlSet.isEmpty()) { + new AlertDialog.Builder(mActivity).setMessage(R.string.title_select_url_none_found).show(); + return; + } + + final CharSequence[] urls = urlSet.toArray(new CharSequence[0]); + Collections.reverse(Arrays.asList(urls)); // Latest first. + + // Click to copy url to clipboard: + final AlertDialog dialog = new AlertDialog.Builder(mActivity).setItems(urls, (di, which) -> { + String url = (String) urls[which]; + ClipboardManager clipboard = (ClipboardManager) mActivity.getSystemService(Context.CLIPBOARD_SERVICE); + clipboard.setPrimaryClip(new ClipData(null, new String[]{"text/plain"}, new ClipData.Item(url))); + Toast.makeText(mActivity, R.string.msg_select_url_copied_to_clipboard, Toast.LENGTH_LONG).show(); + }).setTitle(R.string.title_select_url_dialog).create(); + + // Long press to open URL: + dialog.setOnShowListener(di -> { + ListView lv = dialog.getListView(); // this is a ListView with your "buds" in it + lv.setOnItemLongClickListener((parent, view, position, id) -> { + dialog.dismiss(); + String url = (String) urls[position]; + Intent i = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + try { + mActivity.startActivity(i, null); + } catch (ActivityNotFoundException e) { + // If no applications match, Android displays a system message. + mActivity.startActivity(Intent.createChooser(i, null)); + } + return true; + }); + }); + + dialog.show(); + } + + public void reportIssueFromTranscript() { + TerminalSession session = mActivity.getCurrentSession(); + if (session == null) return; + + 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(); + + StringBuilder reportString = new StringBuilder(); + + String title = TermuxConstants.TERMUX_APP_NAME + " Report Issue"; + + reportString.append("## Transcript\n"); + reportString.append("\n").append(MarkdownUtils.getMarkdownCodeForString(transcriptText, true)); + + reportString.append("\n\n").append(TermuxUtils.getAppInfoMarkdownString(mActivity, true)); + reportString.append("\n\n").append(TermuxUtils.getDeviceInfoMarkdownString(mActivity)); + + String termuxAptInfo = TermuxUtils.geAPTInfoMarkdownString(mActivity); + if (termuxAptInfo != null) + reportString.append("\n\n").append(termuxAptInfo); + + ReportActivity.startReportActivity(mActivity, new ReportInfo(UserAction.REPORT_ISSUE_FROM_TRANSCRIPT, TermuxConstants.TERMUX_APP.TERMUX_ACTIVITY_NAME, title, null, reportString.toString(), "\n\n" + TermuxUtils.getReportIssueMarkdownString(mActivity), false)); + } + + public void doPaste() { + TerminalSession session = mActivity.getCurrentSession(); + if (session == null) return; + if (!session.isRunning()) return; + + ClipboardManager clipboard = (ClipboardManager) mActivity.getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clipData = clipboard.getPrimaryClip(); + if (clipData == null) return; + CharSequence paste = clipData.getItemAt(0).coerceToText(mActivity); + if (!TextUtils.isEmpty(paste)) + session.getEmulator().paste(paste.toString()); + } + + + + @Override + public void logError(String tag, String message) { + Logger.logError(tag, message); + } + + @Override + public void logWarn(String tag, String message) { + Logger.logWarn(tag, message); + } + + @Override + public void logInfo(String tag, String message) { + Logger.logInfo(tag, message); + } + + @Override + public void logDebug(String tag, String message) { + Logger.logDebug(tag, message); + } + + @Override + public void logVerbose(String tag, String message) { + Logger.logVerbose(tag, message); + } + + @Override + public void logStackTraceWithMessage(String tag, String message, Exception e) { + Logger.logStackTraceWithMessage(tag, message, e); + } + + @Override + public void logStackTrace(String tag, Exception e) { + Logger.logStackTrace(tag, e); + } + +} diff --git a/app/src/main/java/com/termux/app/BellUtil.java b/app/src/main/java/com/termux/app/terminal/io/BellHandler.java similarity index 77% rename from app/src/main/java/com/termux/app/BellUtil.java rename to app/src/main/java/com/termux/app/terminal/io/BellHandler.java index 666124ce..728e12e6 100644 --- a/app/src/main/java/com/termux/app/BellUtil.java +++ b/app/src/main/java/com/termux/app/terminal/io/BellHandler.java @@ -1,4 +1,4 @@ -package com.termux.app; +package com.termux.app.terminal.io; import android.content.Context; import android.os.Handler; @@ -6,15 +6,15 @@ import android.os.Looper; import android.os.SystemClock; import android.os.Vibrator; -public class BellUtil { - private static BellUtil instance = null; +public class BellHandler { + private static BellHandler instance = null; private static final Object lock = new Object(); - public static BellUtil getInstance(Context context) { + public static BellHandler getInstance(Context context) { if (instance == null) { synchronized (lock) { if (instance == null) { - instance = new BellUtil((Vibrator) context.getApplicationContext().getSystemService(Context.VIBRATOR_SERVICE)); + instance = new BellHandler((Vibrator) context.getApplicationContext().getSystemService(Context.VIBRATOR_SERVICE)); } } } @@ -29,7 +29,7 @@ public class BellUtil { private long lastBell = 0; private final Runnable bellRunnable; - private BellUtil(final Vibrator vibrator) { + private BellHandler(final Vibrator vibrator) { bellRunnable = new Runnable() { @Override public void run() { @@ -47,7 +47,7 @@ public class BellUtil { if (timeSinceLastBell < 0) { // there is a next bell pending; don't schedule another one } else if (timeSinceLastBell < MIN_PAUSE) { - // there was a bell recently, scheudle the next one + // there was a bell recently, schedule the next one handler.postDelayed(bellRunnable, MIN_PAUSE - timeSinceLastBell); lastBell = lastBell + MIN_PAUSE; } else { diff --git a/app/src/main/java/com/termux/app/FullScreenWorkAround.java b/app/src/main/java/com/termux/app/terminal/io/FullScreenWorkAround.java similarity index 91% rename from app/src/main/java/com/termux/app/FullScreenWorkAround.java rename to app/src/main/java/com/termux/app/terminal/io/FullScreenWorkAround.java index 006918ee..c01f8994 100644 --- a/app/src/main/java/com/termux/app/FullScreenWorkAround.java +++ b/app/src/main/java/com/termux/app/terminal/io/FullScreenWorkAround.java @@ -1,9 +1,11 @@ -package com.termux.app; +package com.termux.app.terminal.io; import android.graphics.Rect; import android.view.View; import android.view.ViewGroup; +import com.termux.app.TermuxActivity; + /** * Work around for fullscreen mode in Termux to fix ExtraKeysView not being visible. * This class is derived from: @@ -13,11 +15,11 @@ import android.view.ViewGroup; * For more information, see https://issuetracker.google.com/issues/36911528 */ public class FullScreenWorkAround { - private View mChildOfContent; + private final View mChildOfContent; private int mUsableHeightPrevious; - private ViewGroup.LayoutParams mViewGroupLayoutParams; + private final ViewGroup.LayoutParams mViewGroupLayoutParams; - private int mNavBarHeight; + private final int mNavBarHeight; public static void apply(TermuxActivity activity) { diff --git a/app/src/main/java/com/termux/app/terminal/io/KeyboardShortcut.java b/app/src/main/java/com/termux/app/terminal/io/KeyboardShortcut.java new file mode 100644 index 00000000..00a832dd --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/io/KeyboardShortcut.java @@ -0,0 +1,13 @@ +package com.termux.app.terminal.io; + +public class KeyboardShortcut { + + public final int codePoint; + public final int shortcutAction; + + public KeyboardShortcut(int codePoint, int shortcutAction) { + this.codePoint = codePoint; + this.shortcutAction = shortcutAction; + } + +} diff --git a/app/src/main/java/com/termux/app/terminal/io/TerminalToolbarViewPager.java b/app/src/main/java/com/termux/app/terminal/io/TerminalToolbarViewPager.java new file mode 100644 index 00000000..06bdfe19 --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/io/TerminalToolbarViewPager.java @@ -0,0 +1,114 @@ +package com.termux.app.terminal.io; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; + +import androidx.annotation.NonNull; +import androidx.viewpager.widget.PagerAdapter; +import androidx.viewpager.widget.ViewPager; + +import com.termux.R; +import com.termux.app.TermuxActivity; +import com.termux.app.terminal.io.extrakeys.ExtraKeysView; +import com.termux.terminal.TerminalSession; + +public class TerminalToolbarViewPager { + + public static class PageAdapter extends PagerAdapter { + + final TermuxActivity mActivity; + String mSavedTextInput; + + public PageAdapter(TermuxActivity activity, String savedTextInput) { + this.mActivity = activity; + this.mSavedTextInput = savedTextInput; + } + + @Override + public int getCount() { + return 2; + } + + @Override + public boolean isViewFromObject(@NonNull View view, @NonNull Object object) { + return view == object; + } + + @NonNull + @Override + public Object instantiateItem(@NonNull ViewGroup collection, int position) { + LayoutInflater inflater = LayoutInflater.from(mActivity); + View layout; + if (position == 0) { + layout = inflater.inflate(R.layout.view_terminal_toolbar_extra_keys, collection, false); + ExtraKeysView extraKeysView = (ExtraKeysView) layout; + mActivity.setExtraKeysView(extraKeysView); + extraKeysView.reload(mActivity.getProperties().getExtraKeysInfo()); + + // apply extra keys fix if enabled in prefs + if (mActivity.getProperties().isUsingFullScreen() && mActivity.getProperties().isUsingFullScreenWorkAround()) { + FullScreenWorkAround.apply(mActivity); + } + + } else { + layout = inflater.inflate(R.layout.view_terminal_toolbar_text_input, collection, false); + final EditText editText = layout.findViewById(R.id.terminal_toolbar_text_input); + + if (mSavedTextInput != null) { + editText.setText(mSavedTextInput); + mSavedTextInput = null; + } + + editText.setOnEditorActionListener((v, actionId, event) -> { + TerminalSession session = mActivity.getCurrentSession(); + if (session != null) { + if (session.isRunning()) { + String textToSend = editText.getText().toString(); + if (textToSend.length() == 0) textToSend = "\r"; + session.write(textToSend); + } else { + mActivity.getTermuxTerminalSessionClient().removeFinishedSession(session); + } + editText.setText(""); + } + return true; + }); + } + collection.addView(layout); + return layout; + } + + @Override + public void destroyItem(@NonNull ViewGroup collection, int position, @NonNull Object view) { + collection.removeView((View) view); + } + + } + + + + public static class OnPageChangeListener extends ViewPager.SimpleOnPageChangeListener { + + final TermuxActivity mActivity; + final ViewPager mTerminalToolbarViewPager; + + public OnPageChangeListener(TermuxActivity activity, ViewPager viewPager) { + this.mActivity = activity; + this.mTerminalToolbarViewPager = viewPager; + } + + @Override + public void onPageSelected(int position) { + if (position == 0) { + mActivity.getTerminalView().requestFocus(); + } else { + final EditText editText = mTerminalToolbarViewPager.findViewById(R.id.terminal_toolbar_text_input); + if (editText != null) editText.requestFocus(); + } + } + + } + +} diff --git a/app/src/main/java/com/termux/app/terminal/io/extrakeys/ExtraKeyButton.java b/app/src/main/java/com/termux/app/terminal/io/extrakeys/ExtraKeyButton.java new file mode 100644 index 00000000..94540819 --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/io/extrakeys/ExtraKeyButton.java @@ -0,0 +1,92 @@ +package com.termux.app.terminal.io.extrakeys; + +import android.text.TextUtils; + +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Arrays; +import java.util.stream.Collectors; + +public class ExtraKeyButton { + + /** + * The key that will be sent to the terminal, either a control character + * defined in ExtraKeysView.keyCodesForString (LEFT, RIGHT, PGUP...) or + * some text. + */ + private final String key; + + /** + * If the key is a macro, i.e. a sequence of keys separated by space. + */ + private final boolean macro; + + /** + * The text that will be shown on the button. + */ + private final String display; + + /** + * The information of the popup (triggered by swipe up). + */ + @Nullable + private ExtraKeyButton popup; + + public ExtraKeyButton(ExtraKeysInfo.CharDisplayMap charDisplayMap, JSONObject config) throws JSONException { + this(charDisplayMap, config, null); + } + + public ExtraKeyButton(ExtraKeysInfo.CharDisplayMap charDisplayMap, JSONObject config, @Nullable ExtraKeyButton popup) throws JSONException { + String keyFromConfig = config.optString("key", null); + String macroFromConfig = config.optString("macro", null); + String[] keys; + if (keyFromConfig != null && macroFromConfig != null) { + throw new JSONException("Both key and macro can't be set for the same key"); + } else if (keyFromConfig != null) { + keys = new String[]{keyFromConfig}; + this.macro = false; + } else if (macroFromConfig != null) { + keys = macroFromConfig.split(" "); + this.macro = true; + } else { + throw new JSONException("All keys have to specify either key or macro"); + } + + for (int i = 0; i < keys.length; i++) { + keys[i] = ExtraKeysInfo.replaceAlias(keys[i]); + } + + this.key = TextUtils.join(" ", keys); + + String displayFromConfig = config.optString("display", null); + if (displayFromConfig != null) { + this.display = displayFromConfig; + } else { + this.display = Arrays.stream(keys) + .map(key -> charDisplayMap.get(key, key)) + .collect(Collectors.joining(" ")); + } + + this.popup = popup; + } + + public String getKey() { + return key; + } + + public boolean isMacro() { + return macro; + } + + public String getDisplay() { + return display; + } + + @Nullable + public ExtraKeyButton getPopup() { + return popup; + } +} diff --git a/app/src/main/java/com/termux/app/ExtraKeysInfos.java b/app/src/main/java/com/termux/app/terminal/io/extrakeys/ExtraKeysInfo.java similarity index 76% rename from app/src/main/java/com/termux/app/ExtraKeysInfos.java rename to app/src/main/java/com/termux/app/terminal/io/extrakeys/ExtraKeysInfo.java index 1274c224..26fec3d6 100644 --- a/app/src/main/java/com/termux/app/ExtraKeysInfos.java +++ b/app/src/main/java/com/termux/app/terminal/io/extrakeys/ExtraKeysInfo.java @@ -1,31 +1,24 @@ -package com.termux.app; - -import android.text.TextUtils; - -import androidx.annotation.Nullable; +package com.termux.app.terminal.io.extrakeys; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; -import java.util.Arrays; import java.util.HashMap; -import java.util.Map; -import java.util.stream.Collectors; -public class ExtraKeysInfos { +public class ExtraKeysInfo { /** * Matrix of buttons displayed */ - private ExtraKeyButton[][] buttons; + private final ExtraKeyButton[][] buttons; /** * This corresponds to one of the CharMapDisplay below */ - private String style = "default"; + private String style; - public ExtraKeysInfos(String propertiesInfo, String style) throws JSONException { + public ExtraKeysInfo(String propertiesInfo, String style) throws JSONException { this.style = style; // Convert String propertiesInfo to Array of Arrays @@ -50,7 +43,7 @@ public class ExtraKeysInfos { ExtraKeyButton button; - if(! jobject.has("popup")) { + if (! jobject.has("popup")) { // no popup button = new ExtraKeyButton(getSelectedCharMap(), jobject); } else { @@ -70,10 +63,10 @@ public class ExtraKeysInfos { */ private static JSONObject normalizeKeyConfig(Object key) throws JSONException { JSONObject jobject; - if(key instanceof String) { + if (key instanceof String) { jobject = new JSONObject(); jobject.put("key", key); - } else if(key instanceof JSONObject) { + } else if (key instanceof JSONObject) { jobject = (JSONObject) key; } else { throw new JSONException("An key in the extra-key matrix must be a string or an object"); @@ -91,7 +84,7 @@ public class ExtraKeysInfos { */ static class CleverMap extends HashMap { V get(K key, V defaultValue) { - if(containsKey(key)) + if (containsKey(key)) return get(key); else return defaultValue; @@ -151,7 +144,7 @@ public class ExtraKeysInfos { put("-", "―"); // U+2015 ― HORIZONTAL BAR }}; - /** + /* * Multiple maps are available to quickly change * the style of the keys. */ @@ -258,83 +251,3 @@ public class ExtraKeysInfos { } } -class ExtraKeyButton { - - /** - * The key that will be sent to the terminal, either a control character - * defined in ExtraKeysView.keyCodesForString (LEFT, RIGHT, PGUP...) or - * some text. - */ - private String key; - - /** - * If the key is a macro, i.e. a sequence of keys separated by space. - */ - private boolean macro; - - /** - * The text that will be shown on the button. - */ - private String display; - - /** - * The information of the popup (triggered by swipe up). - */ - @Nullable - private ExtraKeyButton popup = null; - - public ExtraKeyButton(ExtraKeysInfos.CharDisplayMap charDisplayMap, JSONObject config) throws JSONException { - this(charDisplayMap, config, null); - } - - public ExtraKeyButton(ExtraKeysInfos.CharDisplayMap charDisplayMap, JSONObject config, ExtraKeyButton popup) throws JSONException { - String keyFromConfig = config.optString("key", null); - String macroFromConfig = config.optString("macro", null); - String[] keys; - if (keyFromConfig != null && macroFromConfig != null) { - throw new JSONException("Both key and macro can't be set for the same key"); - } else if (keyFromConfig != null) { - keys = new String[]{keyFromConfig}; - this.macro = false; - } else if (macroFromConfig != null) { - keys = macroFromConfig.split(" "); - this.macro = true; - } else { - throw new JSONException("All keys have to specify either key or macro"); - } - - for (int i = 0; i < keys.length; i++) { - keys[i] = ExtraKeysInfos.replaceAlias(keys[i]); - } - - this.key = TextUtils.join(" ", keys); - - String displayFromConfig = config.optString("display", null); - if (displayFromConfig != null) { - this.display = displayFromConfig; - } else { - this.display = Arrays.stream(keys) - .map(key -> charDisplayMap.get(key, key)) - .collect(Collectors.joining(" ")); - } - - this.popup = popup; - } - - public String getKey() { - return key; - } - - public boolean isMacro() { - return macro; - } - - public String getDisplay() { - return display; - } - - @Nullable - public ExtraKeyButton getPopup() { - return popup; - } -} diff --git a/app/src/main/java/com/termux/app/ExtraKeysView.java b/app/src/main/java/com/termux/app/terminal/io/extrakeys/ExtraKeysView.java similarity index 96% rename from app/src/main/java/com/termux/app/ExtraKeysView.java rename to app/src/main/java/com/termux/app/terminal/io/extrakeys/ExtraKeysView.java index e475e32a..da4cbeaf 100644 --- a/app/src/main/java/com/termux/app/ExtraKeysView.java +++ b/app/src/main/java/com/termux/app/terminal/io/extrakeys/ExtraKeysView.java @@ -1,4 +1,4 @@ -package com.termux.app; +package com.termux.app.terminal.io.extrakeys; import android.annotation.SuppressLint; import android.content.Context; @@ -78,6 +78,7 @@ public final class ExtraKeysView extends GridLayout { put("F12", KeyEvent.KEYCODE_F12); }}; + @SuppressLint("RtlHardcoded") private void sendKey(View view, String keyName, boolean forceCtrlDown, boolean forceLeftAltDown) { TerminalView terminalView = view.findViewById(R.id.terminal_view); if ("KEYBOARD".equals(keyName)) { @@ -87,7 +88,8 @@ public final class ExtraKeysView extends GridLayout { DrawerLayout drawer = view.findViewById(R.id.drawer_layout); drawer.openDrawer(Gravity.LEFT); } else if (keyCodesForString.containsKey(keyName)) { - int keyCode = keyCodesForString.get(keyName); + Integer keyCode = keyCodesForString.get(keyName); + if (keyCode == null) return; int metaState = 0; if (forceCtrlDown) { metaState |= KeyEvent.META_CTRL_ON | KeyEvent.META_CTRL_LEFT_ON; @@ -172,6 +174,7 @@ public final class ExtraKeysView extends GridLayout { private Button createSpecialButton(String buttonKey, boolean needUpdate) { SpecialButtonState state = specialButtons.get(SpecialButton.valueOf(buttonKey)); + if (state == null) return null; state.isOn = true; Button button = new Button(getContext(), null, android.R.attr.buttonBarButtonStyle); button.setTextColor(state.isActive ? INTERESTING_COLOR : TEXT_COLOR); @@ -185,8 +188,9 @@ public final class ExtraKeysView extends GridLayout { int width = view.getMeasuredWidth(); int height = view.getMeasuredHeight(); Button button; - if(isSpecialButton(extraButton)) { + if (isSpecialButton(extraButton)) { button = createSpecialButton(extraButton.getKey(), false); + if (button == null) return; } else { button = new Button(getContext(), null, android.R.attr.buttonBarButtonStyle); button.setTextColor(TEXT_COLOR); @@ -235,8 +239,8 @@ public final class ExtraKeysView extends GridLayout { * "-_-" will input the string "-_-" */ @SuppressLint("ClickableViewAccessibility") - void reload(ExtraKeysInfos infos) { - if(infos == null) + public void reload(ExtraKeysInfo infos) { + if (infos == null) return; for(SpecialButtonState state : specialButtons.values()) @@ -254,8 +258,9 @@ public final class ExtraKeysView extends GridLayout { final ExtraKeyButton buttonInfo = buttons[row][col]; Button button; - if(isSpecialButton(buttonInfo)) { + if (isSpecialButton(buttonInfo)) { button = createSpecialButton(buttonInfo.getKey(), true); + if (button == null) return; } else { button = new Button(getContext(), null, android.R.attr.buttonBarButtonStyle); } @@ -282,6 +287,7 @@ public final class ExtraKeysView extends GridLayout { View root = getRootView(); if (isSpecialButton(buttonInfo)) { SpecialButtonState state = specialButtons.get(SpecialButton.valueOf(buttonInfo.getKey())); + if (state == null) return; state.setIsActive(!state.isActive); } else { sendKey(root, buttonInfo); @@ -343,6 +349,7 @@ public final class ExtraKeysView extends GridLayout { if (buttonInfo.getPopup() != null) { if (isSpecialButton(buttonInfo.getPopup())) { SpecialButtonState state = specialButtons.get(SpecialButton.valueOf(buttonInfo.getPopup().getKey())); + if (state == null) return true; state.setIsActive(!state.isActive); } else { sendKey(root, buttonInfo.getPopup()); diff --git a/app/src/main/java/com/termux/app/utils/CrashUtils.java b/app/src/main/java/com/termux/app/utils/CrashUtils.java new file mode 100644 index 00000000..a192e5ab --- /dev/null +++ b/app/src/main/java/com/termux/app/utils/CrashUtils.java @@ -0,0 +1,156 @@ +package com.termux.app.utils; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; + +import androidx.annotation.Nullable; + +import com.termux.R; +import com.termux.app.activities.ReportActivity; +import com.termux.shared.notification.NotificationUtils; +import com.termux.shared.file.FileUtils; +import com.termux.app.models.ReportInfo; +import com.termux.app.models.UserAction; +import com.termux.shared.settings.preferences.TermuxAppSharedPreferences; +import com.termux.shared.settings.preferences.TermuxPreferenceConstants; +import com.termux.shared.data.DataUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.termux.TermuxUtils; + +import com.termux.shared.termux.TermuxConstants; + +import java.nio.charset.Charset; + +public class CrashUtils { + + private static final String LOG_TAG = "CrashUtils"; + + /** + * Notify the user of a previous app crash by reading the crash info from the crash log file at + * {@link TermuxConstants#TERMUX_CRASH_LOG_FILE_PATH}. The crash log file would have been + * created by {@link com.termux.shared.crash.CrashHandler}. + * + * If the crash log file exists and is not empty and + * {@link TermuxPreferenceConstants.TERMUX_APP#KEY_CRASH_REPORT_NOTIFICATIONS_ENABLED} is + * enabled, then a notification will be shown for the crash on the + * {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_NAME} channel, otherwise nothing will be done. + * + * After reading from the crash log file, it will be moved to {@link TermuxConstants#TERMUX_CRASH_LOG_BACKUP_FILE_PATH}. + * + * @param context The {@link Context} for operations. + * @param logTagParam The log tag to use for logging. + */ + public static void notifyCrash(final Context context, final String logTagParam) { + if (context == null) return; + + + TermuxAppSharedPreferences preferences = new TermuxAppSharedPreferences(context); + // If user has disabled notifications for crashes + if (!preferences.getCrashReportNotificationsEnabled()) + return; + + new Thread() { + @Override + public void run() { + String logTag = DataUtils.getDefaultIfNull(logTagParam, LOG_TAG); + + if (!FileUtils.regularFileExists(TermuxConstants.TERMUX_CRASH_LOG_FILE_PATH, false)) + return; + + String errmsg; + StringBuilder reportStringBuilder = new StringBuilder(); + + // Read report string from crash log file + errmsg = FileUtils.readStringFromFile(context, "crash log", TermuxConstants.TERMUX_CRASH_LOG_FILE_PATH, Charset.defaultCharset(), reportStringBuilder, false); + if (errmsg != null) { + Logger.logError(logTag, errmsg); + return; + } + + // Move crash log file to backup location if it exists + FileUtils.moveRegularFile(context, "crash log", TermuxConstants.TERMUX_CRASH_LOG_FILE_PATH, TermuxConstants.TERMUX_CRASH_LOG_BACKUP_FILE_PATH, true); + if (errmsg != null) { + Logger.logError(logTag, errmsg); + } + + String reportString = reportStringBuilder.toString(); + + if (reportString == null || reportString.isEmpty()) + return; + + // Send a notification to show the crash log which when clicked will open the {@link ReportActivity} + // to show the details of the crash + String title = TermuxConstants.TERMUX_APP_NAME + " Crash Report"; + + Logger.logDebug(logTag, "The crash log file at \"" + TermuxConstants.TERMUX_CRASH_LOG_FILE_PATH + "\" found. Sending \"" + title + "\" notification."); + + Intent notificationIntent = ReportActivity.newInstance(context, new ReportInfo(UserAction.CRASH_REPORT, logTag, title, null, reportString, "\n\n" + TermuxUtils.getReportIssueMarkdownString(context), true)); + PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT); + + // Setup the notification channel if not already set up + setupCrashReportsNotificationChannel(context); + + // Build the notification + Notification.Builder builder = getCrashReportsNotificationBuilder(context, title, null, null, pendingIntent, NotificationUtils.NOTIFICATION_MODE_VIBRATE); + if (builder == null) return; + + // Send the notification + int nextNotificationId = NotificationUtils.getNextNotificationId(context); + NotificationManager notificationManager = NotificationUtils.getNotificationManager(context); + if (notificationManager != null) + notificationManager.notify(nextNotificationId, builder.build()); + } + }.start(); + } + + /** + * Get {@link Notification.Builder} for {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_ID} + * and {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_NAME}. + * + * @param context The {@link Context} for operations. + * @param title The title for the notification. + * @param notificationText The second line text of the notification. + * @param notificationBigText The full text of the notification that may optionally be styled. + * @param pendingIntent The {@link PendingIntent} which should be sent when notification is clicked. + * @param notificationMode The notification mode. It must be one of {@code NotificationUtils.NOTIFICATION_MODE_*}. + * @return Returns the {@link Notification.Builder}. + */ + @Nullable + public static Notification.Builder getCrashReportsNotificationBuilder(final Context context, final CharSequence title, final CharSequence notificationText, final CharSequence notificationBigText, final PendingIntent pendingIntent, final int notificationMode) { + + Notification.Builder builder = NotificationUtils.geNotificationBuilder(context, + TermuxConstants.TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_ID, Notification.PRIORITY_HIGH, + title, notificationText, notificationBigText, pendingIntent, notificationMode); + + if (builder == null) return null; + + // Enable timestamp + builder.setShowWhen(true); + + // Set notification icon + builder.setSmallIcon(R.drawable.ic_error_notification); + + // Set background color for small notification icon + builder.setColor(0xFF607D8B); + + // Dismiss on click + builder.setAutoCancel(true); + + return builder; + } + + /** + * Setup the notification channel for {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_ID} and + * {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_NAME}. + * + * @param context The {@link Context} for operations. + */ + public static void setupCrashReportsNotificationChannel(final Context context) { + NotificationUtils.setupNotificationChannel(context, TermuxConstants.TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_ID, + TermuxConstants.TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH); + } + +} diff --git a/app/src/main/java/com/termux/app/utils/PluginUtils.java b/app/src/main/java/com/termux/app/utils/PluginUtils.java new file mode 100644 index 00000000..98d46919 --- /dev/null +++ b/app/src/main/java/com/termux/app/utils/PluginUtils.java @@ -0,0 +1,330 @@ +package com.termux.app.utils; + +import android.app.Activity; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import androidx.annotation.Nullable; + +import com.termux.R; +import com.termux.shared.notification.NotificationUtils; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_SERVICE; +import com.termux.app.activities.ReportActivity; +import com.termux.shared.logger.Logger; +import com.termux.shared.settings.preferences.TermuxAppSharedPreferences; +import com.termux.shared.settings.preferences.TermuxPreferenceConstants.TERMUX_APP; +import com.termux.shared.settings.properties.SharedProperties; +import com.termux.shared.settings.properties.TermuxPropertyConstants; +import com.termux.app.models.ReportInfo; +import com.termux.shared.models.ExecutionCommand; +import com.termux.app.models.UserAction; +import com.termux.shared.data.DataUtils; +import com.termux.shared.markdown.MarkdownUtils; +import com.termux.shared.termux.TermuxUtils; + +public class PluginUtils { + + /** Required file permissions for the executable file of execute intent. Executable file must have read and execute permissions */ + public static final String PLUGIN_EXECUTABLE_FILE_PERMISSIONS = "r-x"; // Default: "r-x" + /** Required file permissions for the working directory of execute intent. Working directory must have read and write permissions. + * Execute permissions should be attempted to be set, but ignored if they are missing */ + public static final String PLUGIN_WORKING_DIRECTORY_PERMISSIONS = "rwx"; // Default: "rwx" + + private static final String LOG_TAG = "PluginUtils"; + + /** + * Process {@link ExecutionCommand} result. + * + * The ExecutionCommand currentState must be greater or equal to + * {@link ExecutionCommand.ExecutionState#EXECUTED}. + * If the {@link ExecutionCommand#isPluginExecutionCommand} is {@code true} and + * {@link ExecutionCommand#pluginPendingIntent} is not {@code null}, then the result of commands + * are sent back to the {@link PendingIntent} creator. + * + * @param context The {@link Context} that will be used to send result intent to the {@link PendingIntent} creator. + * @param logTag The log tag to use for logging. + * @param executionCommand The {@link ExecutionCommand} to process. + */ + public static void processPluginExecutionCommandResult(final Context context, String logTag, final ExecutionCommand executionCommand) { + if (executionCommand == null) return; + + logTag = DataUtils.getDefaultIfNull(logTag, LOG_TAG); + + if (!executionCommand.hasExecuted()) { + Logger.logWarn(logTag, "Ignoring call to processPluginExecutionCommandResult() since the execution command state is not higher than the ExecutionState.EXECUTED"); + return; + } + + Logger.logDebug(LOG_TAG, executionCommand.toString()); + + boolean result = true; + + // If isPluginExecutionCommand is true and pluginPendingIntent is not null, then + // send pluginPendingIntent to its creator with the result + if (executionCommand.isPluginExecutionCommand && executionCommand.pluginPendingIntent != null) { + String errmsg = executionCommand.errmsg; + + //Combine errmsg and stacktraces + if (executionCommand.isStateFailed()) { + errmsg = Logger.getMessageAndStackTracesString(executionCommand.errmsg, executionCommand.throwableList); + } + + // Send pluginPendingIntent to its creator + result = sendPluginExecutionCommandResultPendingIntent(context, logTag, executionCommand.getCommandIdAndLabelLogString(), executionCommand.stdout, executionCommand.stderr, executionCommand.exitCode, executionCommand.errCode, errmsg, executionCommand.pluginPendingIntent); + } + + if (!executionCommand.isStateFailed() && result) + executionCommand.setState(ExecutionCommand.ExecutionState.SUCCESS); + } + + /** + * Process {@link ExecutionCommand} error. + * + * The ExecutionCommand currentState must be equal to {@link ExecutionCommand.ExecutionState#FAILED}. + * The {@link ExecutionCommand#errCode} must have been set to a value greater than + * {@link ExecutionCommand#RESULT_CODE_OK}. + * The {@link ExecutionCommand#errmsg} and any {@link ExecutionCommand#throwableList} must also + * be set with appropriate error info. + * + * If the {@link ExecutionCommand#isPluginExecutionCommand} is {@code true} and + * {@link ExecutionCommand#pluginPendingIntent} is not {@code null}, then the errors of commands + * are sent back to the {@link PendingIntent} creator. + * + * Otherwise if the {@link TERMUX_APP#KEY_PLUGIN_ERROR_NOTIFICATIONS_ENABLED} is + * enabled, then a flash and a notification will be shown for the error as well + * on the {@link TermuxConstants#TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_NAME} channel instead of just logging + * the error. + * + * @param context The {@link Context} for operations. + * @param logTag The log tag to use for logging. + * @param executionCommand The {@link ExecutionCommand} that failed. + * @param forceNotification If set to {@code true}, then a flash and notification will be shown + * regardless of if pending intent is {@code null} or + * {@link TERMUX_APP#KEY_PLUGIN_ERROR_NOTIFICATIONS_ENABLED} + * is {@code false}. + */ + public static void processPluginExecutionCommandError(final Context context, String logTag, final ExecutionCommand executionCommand, boolean forceNotification) { + if (context == null || executionCommand == null) return; + + logTag = DataUtils.getDefaultIfNull(logTag, LOG_TAG); + + if (!executionCommand.isStateFailed()) { + Logger.logWarn(logTag, "Ignoring call to processPluginExecutionCommandError() since the execution command is not in ExecutionState.FAILED"); + return; + } + + // Log the error and any exception + Logger.logStackTracesWithMessage(logTag, "(" + executionCommand.errCode + ") " + executionCommand.errmsg, executionCommand.throwableList); + + + // If isPluginExecutionCommand is true and pluginPendingIntent is not null, then + // send pluginPendingIntent to its creator with the errors + if (executionCommand.isPluginExecutionCommand && executionCommand.pluginPendingIntent != null) { + String errmsg = executionCommand.errmsg; + + //Combine errmsg and stacktraces + if (executionCommand.isStateFailed()) { + errmsg = Logger.getMessageAndStackTracesString(executionCommand.errmsg, executionCommand.throwableList); + } + + sendPluginExecutionCommandResultPendingIntent(context, logTag, executionCommand.getCommandIdAndLabelLogString(), executionCommand.stdout, executionCommand.stderr, executionCommand.exitCode, executionCommand.errCode, errmsg, executionCommand.pluginPendingIntent); + + // No need to show notifications if a pending intent was sent, let the caller handle the result himself + if (!forceNotification) return; + } + + + TermuxAppSharedPreferences preferences = new TermuxAppSharedPreferences(context); + // If user has disabled notifications for plugin, then just return + if (!preferences.getPluginErrorNotificationsEnabled() && !forceNotification) + return; + + // Flash the errmsg + Logger.showToast(context, executionCommand.errmsg, true); + + // Send a notification to show the errmsg which when clicked will open the {@link ReportActivity} + // to show the details of the error + String title = TermuxConstants.TERMUX_APP_NAME + " Plugin Execution Command Error"; + + StringBuilder reportString = new StringBuilder(); + + reportString.append(ExecutionCommand.getExecutionCommandMarkdownString(executionCommand)); + reportString.append("\n\n").append(TermuxUtils.getAppInfoMarkdownString(context, true)); + reportString.append("\n\n").append(TermuxUtils.getDeviceInfoMarkdownString(context)); + + Intent notificationIntent = ReportActivity.newInstance(context, new ReportInfo(UserAction.PLUGIN_EXECUTION_COMMAND, logTag, title, null, reportString.toString(), null,true)); + PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT); + + // Setup the notification channel if not already set up + setupPluginCommandErrorsNotificationChannel(context); + + // Use markdown in notification + CharSequence notificationText = MarkdownUtils.getSpannedMarkdownText(context, executionCommand.errmsg); + //CharSequence notificationText = executionCommand.errmsg; + + // Build the notification + Notification.Builder builder = getPluginCommandErrorsNotificationBuilder(context, title, notificationText, notificationText, pendingIntent, NotificationUtils.NOTIFICATION_MODE_VIBRATE); + if (builder == null) return; + + // Send the notification + int nextNotificationId = NotificationUtils.getNextNotificationId(context); + NotificationManager notificationManager = NotificationUtils.getNotificationManager(context); + if (notificationManager != null) + notificationManager.notify(nextNotificationId, builder.build()); + + } + + /** + * Send {@link ExecutionCommand} result {@link PendingIntent} in the + * {@link TERMUX_SERVICE#EXTRA_PLUGIN_RESULT_BUNDLE} bundle. + * + * + * @param context The {@link Context} that will be used to send result intent to the {@link PendingIntent} creator. + * @param logTag The log tag to use for logging. + * @param label The label of {@link ExecutionCommand}. + * @param stdout The stdout of {@link ExecutionCommand}. + * @param stderr The stderr of {@link ExecutionCommand}. + * @param exitCode The exitCode of {@link ExecutionCommand}. + * @param errCode The errCode of {@link ExecutionCommand}. + * @param errmsg The errmsg of {@link ExecutionCommand}. + * @param pluginPendingIntent The pluginPendingIntent of {@link ExecutionCommand}. + * @return Returns {@code true} if pluginPendingIntent was successfully send, otherwise [@code false}. + */ + public static boolean sendPluginExecutionCommandResultPendingIntent(Context context, String logTag, String label, String stdout, String stderr, Integer exitCode, Integer errCode, String errmsg, PendingIntent pluginPendingIntent) { + if (context == null || pluginPendingIntent == null) return false; + + logTag = DataUtils.getDefaultIfNull(logTag, LOG_TAG); + + Logger.logDebug(logTag, "Sending execution result for Execution Command \"" + label + "\" to " + pluginPendingIntent.getCreatorPackage()); + + String truncatedStdout = null; + String truncatedStderr = null; + + String stdoutOriginalLength = (stdout == null) ? null: String.valueOf(stdout.length()); + String stderrOriginalLength = (stderr == null) ? null: String.valueOf(stderr.length()); + + // Truncate stdout and stdout to max TRANSACTION_SIZE_LIMIT_IN_BYTES + if (stderr == null || stderr.isEmpty()) { + truncatedStdout = DataUtils.getTruncatedCommandOutput(stdout, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES, false, false, false); + } else if (stdout == null || stdout.isEmpty()) { + truncatedStderr = DataUtils.getTruncatedCommandOutput(stderr, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES, false, false, false); + } else { + truncatedStdout = DataUtils.getTruncatedCommandOutput(stdout, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES / 2, false, false, false); + truncatedStderr = DataUtils.getTruncatedCommandOutput(stderr, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES / 2, false, false, false); + } + + if (truncatedStdout != null && truncatedStdout.length() < stdout.length()) { + Logger.logWarn(logTag, "Execution Result for Execution Command \"" + label + "\" stdout length truncated from " + stdoutOriginalLength + " to " + truncatedStdout.length()); + stdout = truncatedStdout; + } + + if (truncatedStderr != null && truncatedStderr.length() < stderr.length()) { + Logger.logWarn(logTag, "Execution Result for Execution Command \"" + label + "\" stderr length truncated from " + stderrOriginalLength + " to " + truncatedStderr.length()); + stderr = truncatedStderr; + } + + String errmsgOriginalLength = (errmsg == null) ? null: String.valueOf(errmsg.length()); + + // Truncate errmsg to max TRANSACTION_SIZE_LIMIT_IN_BYTES / 4 + // trim from end to preserve start of stacktraces + String truncatedErrmsg = DataUtils.getTruncatedCommandOutput(errmsg, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES / 4, true, false, false); + if (truncatedErrmsg != null && truncatedErrmsg.length() < errmsg.length()) { + Logger.logWarn(logTag, "Execution Result for Execution Command \"" + label + "\" errmsg length truncated from " + errmsgOriginalLength + " to " + truncatedErrmsg.length()); + errmsg = truncatedErrmsg; + } + + + final Bundle resultBundle = new Bundle(); + resultBundle.putString(TERMUX_SERVICE.EXTRA_PLUGIN_RESULT_BUNDLE_STDOUT, stdout); + resultBundle.putString(TERMUX_SERVICE.EXTRA_PLUGIN_RESULT_BUNDLE_STDOUT_ORIGINAL_LENGTH, stdoutOriginalLength); + resultBundle.putString(TERMUX_SERVICE.EXTRA_PLUGIN_RESULT_BUNDLE_STDERR, stderr); + resultBundle.putString(TERMUX_SERVICE.EXTRA_PLUGIN_RESULT_BUNDLE_STDERR_ORIGINAL_LENGTH, stderrOriginalLength); + if (exitCode != null) resultBundle.putInt(TERMUX_SERVICE.EXTRA_PLUGIN_RESULT_BUNDLE_EXIT_CODE, exitCode); + if (errCode != null) resultBundle.putInt(TERMUX_SERVICE.EXTRA_PLUGIN_RESULT_BUNDLE_ERR, errCode); + resultBundle.putString(TERMUX_SERVICE.EXTRA_PLUGIN_RESULT_BUNDLE_ERRMSG, errmsg); + + Intent resultIntent = new Intent(); + resultIntent.putExtra(TERMUX_SERVICE.EXTRA_PLUGIN_RESULT_BUNDLE, resultBundle); + + try { + 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; + } + + + + /** + * Get {@link Notification.Builder} for {@link TermuxConstants#TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_ID} + * and {@link TermuxConstants#TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_NAME}. + * + * @param context The {@link Context} for operations. + * @param title The title for the notification. + * @param notificationText The second line text of the notification. + * @param notificationBigText The full text of the notification that may optionally be styled. + * @param pendingIntent The {@link PendingIntent} which should be sent when notification is clicked. + * @param notificationMode The notification mode. It must be one of {@code NotificationUtils.NOTIFICATION_MODE_*}. + * @return Returns the {@link Notification.Builder}. + */ + @Nullable + public static Notification.Builder getPluginCommandErrorsNotificationBuilder(final Context context, final CharSequence title, final CharSequence notificationText, final CharSequence notificationBigText, final PendingIntent pendingIntent, final int notificationMode) { + + Notification.Builder builder = NotificationUtils.geNotificationBuilder(context, + TermuxConstants.TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_ID, Notification.PRIORITY_HIGH, + title, notificationText, notificationBigText, pendingIntent, notificationMode); + + if (builder == null) return null; + + // Enable timestamp + builder.setShowWhen(true); + + // Set notification icon + builder.setSmallIcon(R.drawable.ic_error_notification); + + // Set background color for small notification icon + builder.setColor(0xFF607D8B); + + // Dismiss on click + builder.setAutoCancel(true); + + return builder; + } + + /** + * Setup the notification channel for {@link TermuxConstants#TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_ID} and + * {@link TermuxConstants#TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_NAME}. + * + * @param context The {@link Context} for operations. + */ + public static void setupPluginCommandErrorsNotificationChannel(final Context context) { + NotificationUtils.setupNotificationChannel(context, TermuxConstants.TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_ID, + TermuxConstants.TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH); + } + + + + /** + * Check if {@link TermuxConstants#PROP_ALLOW_EXTERNAL_APPS} property is not set to "true". + * + * @param context The {@link Context} to get error string. + * @return Returns the {@code errmsg} if policy is violated, otherwise {@code null}. + */ + public static String checkIfRunCommandServiceAllowExternalAppsPolicyIsViolated(final Context context) { + String errmsg = null; + if (!SharedProperties.isPropertyValueTrue(context, TermuxPropertyConstants.getTermuxPropertiesFile(), TermuxConstants.PROP_ALLOW_EXTERNAL_APPS)) { + errmsg = context.getString(R.string.error_run_command_service_allow_external_apps_ungranted); + } + + return errmsg; + } + +} diff --git a/app/src/main/java/com/termux/filepicker/TermuxDocumentsProvider.java b/app/src/main/java/com/termux/filepicker/TermuxDocumentsProvider.java index b72442ac..7974d6db 100644 --- a/app/src/main/java/com/termux/filepicker/TermuxDocumentsProvider.java +++ b/app/src/main/java/com/termux/filepicker/TermuxDocumentsProvider.java @@ -12,7 +12,7 @@ import android.provider.DocumentsProvider; import android.webkit.MimeTypeMap; import com.termux.R; -import com.termux.app.TermuxService; +import com.termux.shared.termux.TermuxConstants; import java.io.File; import java.io.FileNotFoundException; @@ -22,7 +22,7 @@ import java.util.LinkedList; /** * A document provider for the Storage Access Framework which exposes the files in the - * $HOME/ folder to other apps. + * $HOME/ directory to other apps. *

* Note that this replaces providing an activity matching the ACTION_GET_CONTENT intent: *

@@ -35,7 +35,7 @@ public class TermuxDocumentsProvider extends DocumentsProvider { private static final String ALL_MIME_TYPES = "*/*"; - private static final File BASE_DIR = new File(TermuxService.HOME_PATH); + private static final File BASE_DIR = TermuxConstants.TERMUX_HOME_DIR; // The default columns to return information about a root if no specific @@ -63,9 +63,9 @@ public class TermuxDocumentsProvider extends DocumentsProvider { }; @Override - public Cursor queryRoots(String[] projection) throws FileNotFoundException { + public Cursor queryRoots(String[] projection) { final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_ROOT_PROJECTION); - @SuppressWarnings("ConstantConditions") final String applicationName = getContext().getString(R.string.application_name); + final String applicationName = getContext().getString(R.string.application_name); final MatrixCursor.RowBuilder row = result.newRow(); row.add(Root.COLUMN_ROOT_ID, getDocIdForFile(BASE_DIR)); @@ -167,11 +167,11 @@ public class TermuxDocumentsProvider extends DocumentsProvider { final int MAX_SEARCH_RESULTS = 50; while (!pending.isEmpty() && result.getCount() < MAX_SEARCH_RESULTS) { final File file = pending.removeFirst(); - // Avoid folders outside the $HOME folders linked in to symlinks (to avoid e.g. search + // Avoid directories outside the $HOME directory linked with symlinks (to avoid e.g. search // through the whole SD card). boolean isInsideHome; try { - isInsideHome = file.getCanonicalPath().startsWith(TermuxService.HOME_PATH); + isInsideHome = file.getCanonicalPath().startsWith(TermuxConstants.TERMUX_HOME_DIR_PATH); } catch (IOException e) { isInsideHome = true; } diff --git a/app/src/main/java/com/termux/filepicker/TermuxFileReceiverActivity.java b/app/src/main/java/com/termux/filepicker/TermuxFileReceiverActivity.java index e1ef5d42..ccee5765 100644 --- a/app/src/main/java/com/termux/filepicker/TermuxFileReceiverActivity.java +++ b/app/src/main/java/com/termux/filepicker/TermuxFileReceiverActivity.java @@ -6,12 +6,14 @@ import android.content.Intent; import android.database.Cursor; import android.net.Uri; import android.provider.OpenableColumns; -import android.util.Log; import android.util.Patterns; import com.termux.R; -import com.termux.app.DialogUtils; +import com.termux.shared.interact.DialogUtils; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_SERVICE; import com.termux.app.TermuxService; +import com.termux.shared.logger.Logger; import java.io.ByteArrayInputStream; import java.io.File; @@ -25,9 +27,9 @@ import java.util.regex.Pattern; public class TermuxFileReceiverActivity extends Activity { - static final String TERMUX_RECEIVEDIR = TermuxService.FILES_PATH + "/home/downloads"; - static final String EDITOR_PROGRAM = TermuxService.HOME_PATH + "/bin/termux-file-editor"; - static final String URL_OPENER_PROGRAM = TermuxService.HOME_PATH + "/bin/termux-url-opener"; + static final String TERMUX_RECEIVEDIR = TermuxConstants.TERMUX_FILES_DIR_PATH + "/home/downloads"; + static final String EDITOR_PROGRAM = TermuxConstants.TERMUX_HOME_DIR_PATH + "/bin/termux-file-editor"; + static final String URL_OPENER_PROGRAM = TermuxConstants.TERMUX_HOME_DIR_PATH + "/bin/termux-url-opener"; /** * If the activity should be finished when the name input dialog is dismissed. This is disabled @@ -37,6 +39,8 @@ public class TermuxFileReceiverActivity extends Activity { */ boolean mFinishOnDismissNameDialog = true; + private static final String LOG_TAG = "TermuxFileReceiverActivity"; + static boolean isSharedTextAnUrl(String sharedText) { return Patterns.WEB_URL.matcher(sharedText).matches() || Pattern.matches("magnet:\\?xt=urn:btih:.*?", sharedText); @@ -109,12 +113,12 @@ public class TermuxFileReceiverActivity extends Activity { promptNameAndSave(in, attachmentFileName); } catch (Exception e) { showErrorDialogAndQuit("Unable to handle shared content:\n\n" + e.getMessage()); - Log.e("termux", "handleContentUri(uri=" + uri + ") failed", e); + Logger.logStackTraceWithMessage(LOG_TAG, "handleContentUri(uri=" + uri + ") failed", e); } } void promptNameAndSave(final InputStream in, final String attachmentFileName) { - DialogUtils.textInput(this, R.string.file_received_title, attachmentFileName, R.string.file_received_edit_button, text -> { + DialogUtils.textInput(this, R.string.title_file_received, attachmentFileName, R.string.action_file_received_edit, text -> { File outFile = saveStreamWithName(in, text); if (outFile == null) return; @@ -131,17 +135,17 @@ public class TermuxFileReceiverActivity extends Activity { final Uri scriptUri = new Uri.Builder().scheme("file").path(EDITOR_PROGRAM).build(); - Intent executeIntent = new Intent(TermuxService.ACTION_EXECUTE, scriptUri); + Intent executeIntent = new Intent(TERMUX_SERVICE.ACTION_SERVICE_EXECUTE, scriptUri); executeIntent.setClass(TermuxFileReceiverActivity.this, TermuxService.class); - executeIntent.putExtra(TermuxService.EXTRA_ARGUMENTS, new String[]{outFile.getAbsolutePath()}); + executeIntent.putExtra(TERMUX_SERVICE.EXTRA_ARGUMENTS, new String[]{outFile.getAbsolutePath()}); startService(executeIntent); finish(); }, - R.string.file_received_open_folder_button, text -> { + R.string.action_file_received_open_directory, text -> { if (saveStreamWithName(in, text) == null) return; - Intent executeIntent = new Intent(TermuxService.ACTION_EXECUTE); - executeIntent.putExtra(TermuxService.EXTRA_CURRENT_WORKING_DIRECTORY, TERMUX_RECEIVEDIR); + Intent executeIntent = new Intent(TERMUX_SERVICE.ACTION_SERVICE_EXECUTE); + executeIntent.putExtra(TERMUX_SERVICE.EXTRA_WORKDIR, TERMUX_RECEIVEDIR); executeIntent.setClass(TermuxFileReceiverActivity.this, TermuxService.class); startService(executeIntent); finish(); @@ -169,7 +173,7 @@ public class TermuxFileReceiverActivity extends Activity { return outFile; } catch (IOException e) { showErrorDialogAndQuit("Error saving file:\n\n" + e); - Log.e("termux", "Error saving file", e); + Logger.logStackTraceWithMessage(LOG_TAG, "Error saving file", e); return null; } } @@ -188,9 +192,9 @@ public class TermuxFileReceiverActivity extends Activity { final Uri urlOpenerProgramUri = new Uri.Builder().scheme("file").path(URL_OPENER_PROGRAM).build(); - Intent executeIntent = new Intent(TermuxService.ACTION_EXECUTE, urlOpenerProgramUri); + Intent executeIntent = new Intent(TERMUX_SERVICE.ACTION_SERVICE_EXECUTE, urlOpenerProgramUri); executeIntent.setClass(TermuxFileReceiverActivity.this, TermuxService.class); - executeIntent.putExtra(TermuxService.EXTRA_ARGUMENTS, new String[]{url}); + executeIntent.putExtra(TERMUX_SERVICE.EXTRA_ARGUMENTS, new String[]{url}); startService(executeIntent); finish(); } diff --git a/app/src/main/res/drawable/ic_copy.xml b/app/src/main/res/drawable/ic_copy.xml new file mode 100644 index 00000000..0fb13c2d --- /dev/null +++ b/app/src/main/res/drawable/ic_copy.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_error_notification.xml b/app/src/main/res/drawable/ic_error_notification.xml new file mode 100644 index 00000000..67f17712 --- /dev/null +++ b/app/src/main/res/drawable/ic_error_notification.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 00000000..e5d1108b --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml new file mode 100644 index 00000000..9300daf6 --- /dev/null +++ b/app/src/main/res/drawable/ic_share.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/selected_session_background_black.xml b/app/src/main/res/drawable/session_background_black_selected.xml similarity index 100% rename from app/src/main/res/drawable/selected_session_background_black.xml rename to app/src/main/res/drawable/session_background_black_selected.xml diff --git a/app/src/main/res/drawable/selected_session_background.xml b/app/src/main/res/drawable/session_background_selected.xml similarity index 100% rename from app/src/main/res/drawable/selected_session_background.xml rename to app/src/main/res/drawable/session_background_selected.xml diff --git a/app/src/main/res/layout/activity_report.xml b/app/src/main/res/layout/activity_report.xml new file mode 100644 index 00000000..a4e133bb --- /dev/null +++ b/app/src/main/res/layout/activity_report.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/app/src/main/res/layout/activity_report_adapter_node_code_block.xml b/app/src/main/res/layout/activity_report_adapter_node_code_block.xml new file mode 100644 index 00000000..a130c35c --- /dev/null +++ b/app/src/main/res/layout/activity_report_adapter_node_code_block.xml @@ -0,0 +1,26 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_report_adapter_node_default.xml b/app/src/main/res/layout/activity_report_adapter_node_default.xml new file mode 100644 index 00000000..44661419 --- /dev/null +++ b/app/src/main/res/layout/activity_report_adapter_node_default.xml @@ -0,0 +1,15 @@ + + diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml new file mode 100644 index 00000000..7dc1a6c5 --- /dev/null +++ b/app/src/main/res/layout/activity_settings.xml @@ -0,0 +1,9 @@ + + + + diff --git a/app/src/main/res/layout/drawer_layout.xml b/app/src/main/res/layout/activity_termux.xml similarity index 90% rename from app/src/main/res/layout/drawer_layout.xml rename to app/src/main/res/layout/activity_termux.xml index 71d36996..12ee0d46 100644 --- a/app/src/main/res/layout/drawer_layout.xml +++ b/app/src/main/res/layout/activity_termux.xml @@ -8,7 +8,7 @@ android:id="@+id/drawer_layout" android:layout_width="match_parent" android:layout_alignParentTop="true" - android:layout_above="@+id/viewpager" + android:layout_above="@+id/terminal_toolbar_view_pager" android:layout_height="match_parent"> + android:text="@string/action_toggle_soft_keyboard" />