diff --git a/termux-app/build.gradle b/termux-app/build.gradle
new file mode 100644
index 00000000..62665010
--- /dev/null
+++ b/termux-app/build.gradle
@@ -0,0 +1,137 @@
+plugins {
+ id "com.android.library"
+}
+
+android {
+ compileSdkVersion 28
+ ndkVersion '21.3.6528147'
+
+ dependencies {
+ implementation "androidx.annotation:annotation:1.1.0"
+ implementation "androidx.viewpager:viewpager:1.0.0"
+ implementation "androidx.drawerlayout:drawerlayout:1.0.0"
+ implementation project(":terminal-view")
+ }
+
+ defaultConfig {
+ // applicationId "com.termux"
+ minSdkVersion 24
+ targetSdkVersion 28
+ versionCode 95
+ versionName "0.95"
+
+ /*externalNativeBuild {
+ ndkBuild {
+ cFlags "-std=c11", "-Wall", "-Wextra", "-Werror", "-Os", "-fno-stack-protector", "-Wl,--gc-sections"
+ }
+ }
+
+ ndk {
+ abiFilters 'x86', 'x86_64', 'armeabi-v7a', 'arm64-v8a'
+ }*/
+
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled true
+ // shrinkResources true
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ /*externalNativeBuild {
+ ndkBuild {
+ path "src/main/cpp/Android.mk"
+ }
+ }*/
+
+ testOptions {
+ unitTests {
+ includeAndroidResources = true
+ }
+ }
+}
+
+dependencies {
+ testImplementation 'junit:junit:4.13'
+ testImplementation 'org.robolectric:robolectric:4.3.1'
+}
+
+/*
+task versionName {
+ doLast {
+ print android.defaultConfig.versionName
+ }
+}
+
+def downloadBootstrap(String arch, String expectedChecksum, int version) {
+ def digest = java.security.MessageDigest.getInstance("SHA-256")
+
+ def localUrl = "src/main/cpp/bootstrap-" + arch + ".zip"
+ def file = new File(projectDir, localUrl)
+ if (file.exists()) {
+ def buffer = new byte[8192]
+ def input = new FileInputStream(file)
+ while (true) {
+ def readBytes = input.read(buffer)
+ if (readBytes < 0) break
+ digest.update(buffer, 0, readBytes)
+ }
+ def checksum = new BigInteger(1, digest.digest()).toString(16)
+ if (checksum == expectedChecksum) {
+ return
+ } else {
+ logger.quiet("Deleting old local file with wrong hash: " + localUrl)
+ file.delete()
+ }
+ }
+
+ def remoteUrl = "https://bintray.com/termux/bootstrap/download_file?file_path=bootstrap-" + arch + "-v" + version + ".zip"
+ logger.quiet("Downloading " + remoteUrl + " ...")
+
+ file.parentFile.mkdirs()
+ def out = new BufferedOutputStream(new FileOutputStream(file))
+
+ def connection = new URL(remoteUrl).openConnection()
+ connection.setInstanceFollowRedirects(true)
+ def digestStream = new java.security.DigestInputStream(connection.inputStream, digest)
+ out << digestStream
+ out.close()
+
+ def checksum = new BigInteger(1, digest.digest()).toString(16)
+ if (checksum != expectedChecksum) {
+ file.delete()
+ throw new GradleException("Wrong checksum for " + remoteUrl + ": expected: " + expectedChecksum + ", actual: " + checksum)
+ }
+}
+
+clean {
+ doLast {
+ def tree = fileTree(new File(projectDir, 'src/main/cpp'))
+ tree.include 'bootstrap-*.zip'
+ tree.each { it.delete() }
+ }
+}
+
+task downloadBootstraps(){
+ doLast {
+ def version = 25
+ downloadBootstrap("aarch64", "633baa1f7edfd81f6064338a68d1149aa203d4b24cbc4f7c64283aaca109609e", version)
+ downloadBootstrap("arm", "a581a22e0d79a0e8cef9395b1bd951ba066ac2d688522e17cca0b3e1c0649daa", version)
+ downloadBootstrap("i686", "8288e13f0a6ddeb2ff9406d8f968a8930a58e9318d09fadb2b7c8970a034cfdc", version)
+ downloadBootstrap("x86_64", "c99b80a18d6bbb64c24c5a64d6ee6b8d4306729ebd172662b807bcb4a46dd39a", version)
+ }
+}
+
+afterEvaluate {
+ android.libraryVariants.all { variant ->
+ variant.javaCompileProvider.get().dependsOn(downloadBootstraps)
+ }
+}
+*/
\ No newline at end of file
diff --git a/termux-app/dev_keystore.jks b/termux-app/dev_keystore.jks
new file mode 100644
index 00000000..174cc95a
Binary files /dev/null and b/termux-app/dev_keystore.jks differ
diff --git a/termux-app/proguard-rules.pro b/termux-app/proguard-rules.pro
new file mode 100644
index 00000000..4306bcc4
--- /dev/null
+++ b/termux-app/proguard-rules.pro
@@ -0,0 +1,11 @@
+# Add project specific ProGuard rules here.
+# By default, the flags in this file are appended to flags specified
+# in android-sdk/tools/proguard/proguard-android.txt
+# You can edit the include path and order by changing the proguardFiles
+# directive in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+-renamesourcefileattribute SourceFile
+-keepattributes SourceFile,LineNumberTable
diff --git a/termux-app/src/main/AndroidManifest.xml b/termux-app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..d4635af6
--- /dev/null
+++ b/termux-app/src/main/AndroidManifest.xml
@@ -0,0 +1,159 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/termux-app/src/main/java/com/termux/app/BackgroundJob.java b/termux-app/src/main/java/com/termux/app/BackgroundJob.java
new file mode 100644
index 00000000..657cce7d
--- /dev/null
+++ b/termux-app/src/main/java/com/termux/app/BackgroundJob.java
@@ -0,0 +1,240 @@
+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 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("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:" + TermuxService.PREFIX_PATH + "/bin/applets");
+ 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/termux-app/src/main/java/com/termux/app/BellUtil.java b/termux-app/src/main/java/com/termux/app/BellUtil.java
new file mode 100644
index 00000000..666124ce
--- /dev/null
+++ b/termux-app/src/main/java/com/termux/app/BellUtil.java
@@ -0,0 +1,63 @@
+package com.termux.app;
+
+import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.SystemClock;
+import android.os.Vibrator;
+
+public class BellUtil {
+ private static BellUtil instance = null;
+ private static final Object lock = new Object();
+
+ public static BellUtil getInstance(Context context) {
+ if (instance == null) {
+ synchronized (lock) {
+ if (instance == null) {
+ instance = new BellUtil((Vibrator) context.getApplicationContext().getSystemService(Context.VIBRATOR_SERVICE));
+ }
+ }
+ }
+
+ return instance;
+ }
+
+ private static final long DURATION = 50;
+ private static final long MIN_PAUSE = 3 * DURATION;
+
+ private final Handler handler = new Handler(Looper.getMainLooper());
+ private long lastBell = 0;
+ private final Runnable bellRunnable;
+
+ private BellUtil(final Vibrator vibrator) {
+ bellRunnable = new Runnable() {
+ @Override
+ public void run() {
+ if (vibrator != null) {
+ vibrator.vibrate(DURATION);
+ }
+ }
+ };
+ }
+
+ public synchronized void doBell() {
+ long now = now();
+ long timeSinceLastBell = now - lastBell;
+
+ 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
+ handler.postDelayed(bellRunnable, MIN_PAUSE - timeSinceLastBell);
+ lastBell = lastBell + MIN_PAUSE;
+ } else {
+ // the last bell was long ago, do it now
+ bellRunnable.run();
+ lastBell = now;
+ }
+ }
+
+ private long now() {
+ return SystemClock.uptimeMillis();
+ }
+}
diff --git a/termux-app/src/main/java/com/termux/app/DialogUtils.java b/termux-app/src/main/java/com/termux/app/DialogUtils.java
new file mode 100644
index 00000000..4900f75d
--- /dev/null
+++ b/termux-app/src/main/java/com/termux/app/DialogUtils.java
@@ -0,0 +1,71 @@
+package com.termux.app;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.DialogInterface;
+import android.text.Selection;
+import android.util.TypedValue;
+import android.view.KeyEvent;
+import android.view.ViewGroup.LayoutParams;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+
+public final class DialogUtils {
+
+ public interface TextSetListener {
+ void onTextSet(String text);
+ }
+
+ public static void textInput(Activity activity, int titleText, String initialText,
+ int positiveButtonText, final TextSetListener onPositive,
+ int neutralButtonText, final TextSetListener onNeutral,
+ int negativeButtonText, final TextSetListener onNegative,
+ final DialogInterface.OnDismissListener onDismiss) {
+ final EditText input = new EditText(activity);
+ input.setSingleLine();
+ if (initialText != null) {
+ input.setText(initialText);
+ Selection.setSelection(input.getText(), initialText.length());
+ }
+
+ final AlertDialog[] dialogHolder = new AlertDialog[1];
+ input.setImeActionLabel(activity.getResources().getString(positiveButtonText), KeyEvent.KEYCODE_ENTER);
+ input.setOnEditorActionListener((v, actionId, event) -> {
+ onPositive.onTextSet(input.getText().toString());
+ dialogHolder[0].dismiss();
+ return true;
+ });
+
+ float dipInPixels = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1, activity.getResources().getDisplayMetrics());
+ // https://www.google.com/design/spec/components/dialogs.html#dialogs-specs
+ int paddingTopAndSides = Math.round(16 * dipInPixels);
+ int paddingBottom = Math.round(24 * dipInPixels);
+
+ LinearLayout layout = new LinearLayout(activity);
+ layout.setOrientation(LinearLayout.VERTICAL);
+ layout.setLayoutParams(new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT));
+ layout.setPadding(paddingTopAndSides, paddingTopAndSides, paddingTopAndSides, paddingBottom);
+ layout.addView(input);
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(activity)
+ .setTitle(titleText).setView(layout)
+ .setPositiveButton(positiveButtonText, (d, whichButton) -> onPositive.onTextSet(input.getText().toString()));
+
+ if (onNeutral != null) {
+ builder.setNeutralButton(neutralButtonText, (dialog, which) -> onNeutral.onTextSet(input.getText().toString()));
+ }
+
+ if (onNegative == null) {
+ builder.setNegativeButton(android.R.string.cancel, null);
+ } else {
+ builder.setNegativeButton(negativeButtonText, (dialog, which) -> onNegative.onTextSet(input.getText().toString()));
+ }
+
+ if (onDismiss != null) builder.setOnDismissListener(onDismiss);
+
+ dialogHolder[0] = builder.create();
+ dialogHolder[0].setCanceledOnTouchOutside(false);
+ dialogHolder[0].show();
+ }
+
+}
diff --git a/termux-app/src/main/java/com/termux/app/ExtraKeysInfos.java b/termux-app/src/main/java/com/termux/app/ExtraKeysInfos.java
new file mode 100644
index 00000000..86d97449
--- /dev/null
+++ b/termux-app/src/main/java/com/termux/app/ExtraKeysInfos.java
@@ -0,0 +1,338 @@
+package com.termux.app;
+
+import androidx.annotation.Nullable;
+
+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 {
+
+ /**
+ * Matrix of buttons displayed
+ */
+ private ExtraKeyButton[][] buttons;
+
+ /**
+ * This corresponds to one of the CharMapDisplay below
+ */
+ private String style = "default";
+
+ public ExtraKeysInfos(String propertiesInfo, String style) throws JSONException {
+ this.style = style;
+
+ // Convert String propertiesInfo to Array of Arrays
+ JSONArray arr = new JSONArray(propertiesInfo);
+ Object[][] matrix = new Object[arr.length()][];
+ for (int i = 0; i < arr.length(); i++) {
+ JSONArray line = arr.getJSONArray(i);
+ matrix[i] = new Object[line.length()];
+ for (int j = 0; j < line.length(); j++) {
+ matrix[i][j] = line.get(j);
+ }
+ }
+
+ // convert matrix to buttons
+ this.buttons = new ExtraKeyButton[matrix.length][];
+ for (int i = 0; i < matrix.length; i++) {
+ this.buttons[i] = new ExtraKeyButton[matrix[i].length];
+ for (int j = 0; j < matrix[i].length; j++) {
+ Object key = matrix[i][j];
+
+ JSONObject jobject = normalizeKeyConfig(key);
+
+ ExtraKeyButton button;
+
+ if(! jobject.has("popup")) {
+ // no popup
+ button = new ExtraKeyButton(getSelectedCharMap(), jobject);
+ } else {
+ // a popup
+ JSONObject popupJobject = normalizeKeyConfig(jobject.get("popup"));
+ ExtraKeyButton popup = new ExtraKeyButton(getSelectedCharMap(), popupJobject);
+ button = new ExtraKeyButton(getSelectedCharMap(), jobject, popup);
+ }
+
+ this.buttons[i][j] = button;
+ }
+ }
+ }
+
+ /**
+ * "hello" -> {"key": "hello"}
+ */
+ private static JSONObject normalizeKeyConfig(Object key) throws JSONException {
+ JSONObject jobject;
+ if(key instanceof String) {
+ jobject = new JSONObject();
+ jobject.put("key", key);
+ } 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");
+ }
+ return jobject;
+ }
+
+ public ExtraKeyButton[][] getMatrix() {
+ return buttons;
+ }
+
+ /**
+ * HashMap that implements Python dict.get(key, default) function.
+ * Default java.util .get(key) is then the same as .get(key, null);
+ */
+ static class CleverMap extends HashMap {
+ V get(K key, V defaultValue) {
+ if(containsKey(key))
+ return get(key);
+ else
+ return defaultValue;
+ }
+ }
+
+ static class CharDisplayMap extends CleverMap {}
+
+ /**
+ * Keys are displayed in a natural looking way, like "→" for "RIGHT"
+ */
+ static final CharDisplayMap classicArrowsDisplay = new CharDisplayMap() {{
+ // classic arrow keys (for ◀ ▶ ▲ ▼ @see arrowVariationDisplay)
+ put("LEFT", "←"); // U+2190 ← LEFTWARDS ARROW
+ put("RIGHT", "→"); // U+2192 → RIGHTWARDS ARROW
+ put("UP", "↑"); // U+2191 ↑ UPWARDS ARROW
+ put("DOWN", "↓"); // U+2193 ↓ DOWNWARDS ARROW
+ }};
+
+ static final CharDisplayMap wellKnownCharactersDisplay = new CharDisplayMap() {{
+ // well known characters // https://en.wikipedia.org/wiki/{Enter_key, Tab_key, Delete_key}
+ put("ENTER", "↲"); // U+21B2 ↲ DOWNWARDS ARROW WITH TIP LEFTWARDS
+ put("TAB", "↹"); // U+21B9 ↹ LEFTWARDS ARROW TO BAR OVER RIGHTWARDS ARROW TO BAR
+ put("BKSP", "⌫"); // U+232B ⌫ ERASE TO THE LEFT sometimes seen and easy to understand
+ put("DEL", "⌦"); // U+2326 ⌦ ERASE TO THE RIGHT not well known but easy to understand
+ put("DRAWER", "☰"); // U+2630 ☰ TRIGRAM FOR HEAVEN not well known but easy to understand
+ put("KEYBOARD", "⌨"); // U+2328 ⌨ KEYBOARD not well known but easy to understand
+ }};
+
+ static final CharDisplayMap lessKnownCharactersDisplay = new CharDisplayMap() {{
+ // https://en.wikipedia.org/wiki/{Home_key, End_key, Page_Up_and_Page_Down_keys}
+ // home key can mean "goto the beginning of line" or "goto first page" depending on context, hence the diagonal
+ put("HOME", "⇱"); // from IEC 9995 // U+21F1 ⇱ NORTH WEST ARROW TO CORNER
+ put("END", "⇲"); // from IEC 9995 // ⇲ // U+21F2 ⇲ SOUTH EAST ARROW TO CORNER
+ put("PGUP", "⇑"); // no ISO character exists, U+21D1 ⇑ UPWARDS DOUBLE ARROW will do the trick
+ put("PGDN", "⇓"); // no ISO character exists, U+21D3 ⇓ DOWNWARDS DOUBLE ARROW will do the trick
+ }};
+
+ static final CharDisplayMap arrowTriangleVariationDisplay = new CharDisplayMap() {{
+ // alternative to classic arrow keys
+ put("LEFT", "◀"); // U+25C0 ◀ BLACK LEFT-POINTING TRIANGLE
+ put("RIGHT", "▶"); // U+25B6 ▶ BLACK RIGHT-POINTING TRIANGLE
+ put("UP", "▲"); // U+25B2 ▲ BLACK UP-POINTING TRIANGLE
+ put("DOWN", "▼"); // U+25BC ▼ BLACK DOWN-POINTING TRIANGLE
+ }};
+
+ static final CharDisplayMap notKnownIsoCharacters = new CharDisplayMap() {{
+ // Control chars that are more clear as text // https://en.wikipedia.org/wiki/{Function_key, Alt_key, Control_key, Esc_key}
+ // put("FN", "FN"); // no ISO character exists
+ put("CTRL", "⎈"); // ISO character "U+2388 ⎈ HELM SYMBOL" is unknown to people and never printed on computers, however "U+25C7 ◇ WHITE DIAMOND" is a nice presentation, and "^" for terminal app and mac is often used
+ put("ALT", "⎇"); // ISO character "U+2387 ⎇ ALTERNATIVE KEY SYMBOL'" is unknown to people and only printed as the Option key "⌥" on Mac computer
+ put("ESC", "⎋"); // ISO character "U+238B ⎋ BROKEN CIRCLE WITH NORTHWEST ARROW" is unknown to people and not often printed on computers
+ }};
+
+ static final CharDisplayMap nicerLookingDisplay = new CharDisplayMap() {{
+ // nicer looking for most cases
+ put("-", "―"); // U+2015 ― HORIZONTAL BAR
+ }};
+
+ /**
+ * Multiple maps are available to quickly change
+ * the style of the keys.
+ */
+
+ /**
+ * Some classic symbols everybody knows
+ */
+ private static final CharDisplayMap defaultCharDisplay = new CharDisplayMap() {{
+ putAll(classicArrowsDisplay);
+ putAll(wellKnownCharactersDisplay);
+ putAll(nicerLookingDisplay);
+ // all other characters are displayed as themselves
+ }};
+
+ /**
+ * Classic symbols and less known symbols
+ */
+ private static final CharDisplayMap lotsOfArrowsCharDisplay = new CharDisplayMap() {{
+ putAll(classicArrowsDisplay);
+ putAll(wellKnownCharactersDisplay);
+ putAll(lessKnownCharactersDisplay); // NEW
+ putAll(nicerLookingDisplay);
+ }};
+
+ /**
+ * Only arrows
+ */
+ private static final CharDisplayMap arrowsOnlyCharDisplay = new CharDisplayMap() {{
+ putAll(classicArrowsDisplay);
+ // putAll(wellKnownCharactersDisplay); // REMOVED
+ // putAll(lessKnownCharactersDisplay); // REMOVED
+ putAll(nicerLookingDisplay);
+ }};
+
+ /**
+ * Full Iso
+ */
+ private static final CharDisplayMap fullIsoCharDisplay = new CharDisplayMap() {{
+ putAll(classicArrowsDisplay);
+ putAll(wellKnownCharactersDisplay);
+ putAll(lessKnownCharactersDisplay); // NEW
+ putAll(nicerLookingDisplay);
+ putAll(notKnownIsoCharacters); // NEW
+ }};
+
+ /**
+ * Some people might call our keys differently
+ */
+ static private final CharDisplayMap controlCharsAliases = new CharDisplayMap() {{
+ put("ESCAPE", "ESC");
+ put("CONTROL", "CTRL");
+ put("RETURN", "ENTER"); // Technically different keys, but most applications won't see the difference
+ put("FUNCTION", "FN");
+ // no alias for ALT
+
+ // Directions are sometimes written as first and last letter for brevety
+ put("LT", "LEFT");
+ put("RT", "RIGHT");
+ put("DN", "DOWN");
+ // put("UP", "UP"); well, "UP" is already two letters
+
+ put("PAGEUP", "PGUP");
+ put("PAGE_UP", "PGUP");
+ put("PAGE UP", "PGUP");
+ put("PAGE-UP", "PGUP");
+
+ // no alias for HOME
+ // no alias for END
+
+ put("PAGEDOWN", "PGDN");
+ put("PAGE_DOWN", "PGDN");
+ put("PAGE-DOWN", "PGDN");
+
+ put("DELETE", "DEL");
+ put("BACKSPACE", "BKSP");
+
+ // easier for writing in termux.properties
+ put("BACKSLASH", "\\");
+ put("QUOTE", "\"");
+ put("APOSTROPHE", "'");
+ }};
+
+ CharDisplayMap getSelectedCharMap() {
+ switch (style) {
+ case "arrows-only":
+ return arrowsOnlyCharDisplay;
+ case "arrows-all":
+ return lotsOfArrowsCharDisplay;
+ case "all":
+ return fullIsoCharDisplay;
+ case "none":
+ return new CharDisplayMap();
+ default:
+ return defaultCharDisplay;
+ }
+ }
+
+ /**
+ * Applies the 'controlCharsAliases' mapping to all the strings in *buttons*
+ * Modifies the array, doesn't return a new one.
+ */
+ public static String replaceAlias(String key) {
+ return controlCharsAliases.get(key, key);
+ }
+}
+
+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 = String.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/termux-app/src/main/java/com/termux/app/ExtraKeysView.java b/termux-app/src/main/java/com/termux/app/ExtraKeysView.java
new file mode 100644
index 00000000..b5687033
--- /dev/null
+++ b/termux-app/src/main/java/com/termux/app/ExtraKeysView.java
@@ -0,0 +1,354 @@
+package com.termux.app;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.os.Build;
+import android.provider.Settings;
+import android.util.AttributeSet;
+
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.ScheduledExecutorService;
+
+import java.util.Map;
+import java.util.HashMap;
+import java.util.Arrays;
+
+import android.view.Gravity;
+import android.view.HapticFeedbackConstants;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.Button;
+import android.widget.GridLayout;
+import android.widget.PopupWindow;
+import android.widget.ToggleButton;
+
+import com.termux.R;
+import com.termux.view.TerminalView;
+
+import androidx.drawerlayout.widget.DrawerLayout;
+
+/**
+ * A view showing extra keys (such as Escape, Ctrl, Alt) not normally available on an Android soft
+ * keyboard.
+ */
+public final class ExtraKeysView extends GridLayout {
+
+ private static final int TEXT_COLOR = 0xFFFFFFFF;
+ private static final int BUTTON_COLOR = 0x00000000;
+ private static final int INTERESTING_COLOR = 0xFF80DEEA;
+ private static final int BUTTON_PRESSED_COLOR = 0xFF7F7F7F;
+
+ public ExtraKeysView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ static final Map keyCodesForString = new HashMap() {{
+ put("SPACE", KeyEvent.KEYCODE_SPACE);
+ put("ESC", KeyEvent.KEYCODE_ESCAPE);
+ put("TAB", KeyEvent.KEYCODE_TAB);
+ put("HOME", KeyEvent.KEYCODE_MOVE_HOME);
+ put("END", KeyEvent.KEYCODE_MOVE_END);
+ put("PGUP", KeyEvent.KEYCODE_PAGE_UP);
+ put("PGDN", KeyEvent.KEYCODE_PAGE_DOWN);
+ put("INS", KeyEvent.KEYCODE_INSERT);
+ put("DEL", KeyEvent.KEYCODE_FORWARD_DEL);
+ put("BKSP", KeyEvent.KEYCODE_DEL);
+ put("UP", KeyEvent.KEYCODE_DPAD_UP);
+ put("LEFT", KeyEvent.KEYCODE_DPAD_LEFT);
+ put("RIGHT", KeyEvent.KEYCODE_DPAD_RIGHT);
+ put("DOWN", KeyEvent.KEYCODE_DPAD_DOWN);
+ put("ENTER", KeyEvent.KEYCODE_ENTER);
+ put("F1", KeyEvent.KEYCODE_F1);
+ put("F2", KeyEvent.KEYCODE_F2);
+ put("F3", KeyEvent.KEYCODE_F3);
+ put("F4", KeyEvent.KEYCODE_F4);
+ put("F5", KeyEvent.KEYCODE_F5);
+ put("F6", KeyEvent.KEYCODE_F6);
+ put("F7", KeyEvent.KEYCODE_F7);
+ put("F8", KeyEvent.KEYCODE_F8);
+ put("F9", KeyEvent.KEYCODE_F9);
+ put("F10", KeyEvent.KEYCODE_F10);
+ put("F11", KeyEvent.KEYCODE_F11);
+ put("F12", KeyEvent.KEYCODE_F12);
+ }};
+
+ private void sendKey(View view, String keyName, boolean forceCtrlDown, boolean forceLeftAltDown) {
+ TerminalView terminalView = view.findViewById(R.id.terminal_view);
+ if ("KEYBOARD".equals(keyName)) {
+ InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
+ imm.toggleSoftInput(0, 0);
+ } else if ("DRAWER".equals(keyName)) {
+ DrawerLayout drawer = view.findViewById(R.id.drawer_layout);
+ drawer.openDrawer(Gravity.LEFT);
+ } else if (keyCodesForString.containsKey(keyName)) {
+ int keyCode = keyCodesForString.get(keyName);
+ int metaState = 0;
+ if (forceCtrlDown) {
+ metaState |= KeyEvent.META_CTRL_ON | KeyEvent.META_CTRL_LEFT_ON;
+ }
+ if (forceLeftAltDown) {
+ metaState |= KeyEvent.META_ALT_ON | KeyEvent.META_ALT_LEFT_ON;
+ }
+ KeyEvent keyEvent = new KeyEvent(0, 0, KeyEvent.ACTION_UP, keyCode, 0, metaState);
+ terminalView.onKeyDown(keyCode, keyEvent);
+ } else {
+ // not a control char
+ keyName.codePoints().forEach(codePoint -> {
+ terminalView.inputCodePoint(codePoint, forceCtrlDown, forceLeftAltDown);
+ });
+ }
+ }
+
+ private void sendKey(View view, ExtraKeyButton buttonInfo) {
+ if (buttonInfo.isMacro()) {
+ String[] keys = buttonInfo.getKey().split(" ");
+ boolean ctrlDown = false;
+ boolean altDown = false;
+ for (String key : keys) {
+ if ("CTRL".equals(key)) {
+ ctrlDown = true;
+ } else if ("ALT".equals(key)) {
+ altDown = true;
+ } else {
+ sendKey(view, key, ctrlDown, altDown);
+ ctrlDown = false;
+ altDown = false;
+ }
+ }
+ } else {
+ sendKey(view, buttonInfo.getKey(), false, false);
+ }
+ }
+
+ public enum SpecialButton {
+ CTRL, ALT, FN
+ }
+
+ private static class SpecialButtonState {
+ boolean isOn = false;
+ ToggleButton button = null;
+ }
+
+ private Map specialButtons = new HashMap() {{
+ put(SpecialButton.CTRL, new SpecialButtonState());
+ put(SpecialButton.ALT, new SpecialButtonState());
+ put(SpecialButton.FN, new SpecialButtonState());
+ }};
+
+ private ScheduledExecutorService scheduledExecutor;
+ private PopupWindow popupWindow;
+ private int longPressCount;
+
+ public boolean readSpecialButton(SpecialButton name) {
+ SpecialButtonState state = specialButtons.get(name);
+ if (state == null)
+ throw new RuntimeException("Must be a valid special button (see source)");
+
+ if (! state.isOn)
+ return false;
+
+ if (state.button == null) {
+ return false;
+ }
+
+ if (state.button.isPressed())
+ return true;
+
+ if (! state.button.isChecked())
+ return false;
+
+ state.button.setChecked(false);
+ state.button.setTextColor(TEXT_COLOR);
+ return true;
+ }
+
+ void popup(View view, String text) {
+ int width = view.getMeasuredWidth();
+ int height = view.getMeasuredHeight();
+ Button button = new Button(getContext(), null, android.R.attr.buttonBarButtonStyle);
+ button.setText(text);
+ button.setTextColor(TEXT_COLOR);
+ button.setPadding(0, 0, 0, 0);
+ button.setMinHeight(0);
+ button.setMinWidth(0);
+ button.setMinimumWidth(0);
+ button.setMinimumHeight(0);
+ button.setWidth(width);
+ button.setHeight(height);
+ button.setBackgroundColor(BUTTON_PRESSED_COLOR);
+ popupWindow = new PopupWindow(this);
+ popupWindow.setWidth(LayoutParams.WRAP_CONTENT);
+ popupWindow.setHeight(LayoutParams.WRAP_CONTENT);
+ popupWindow.setContentView(button);
+ popupWindow.setOutsideTouchable(true);
+ popupWindow.setFocusable(false);
+ popupWindow.showAsDropDown(view, 0, -2 * height);
+ }
+
+ /**
+ * General util function to compute the longest column length in a matrix.
+ */
+ static int maximumLength(Object[][] matrix) {
+ int m = 0;
+ for (Object[] row : matrix)
+ m = Math.max(m, row.length);
+ return m;
+ }
+
+ /**
+ * Reload the view given parameters in termux.properties
+ *
+ * @param infos matrix as defined in termux.properties extrakeys
+ * Can Contain The Strings CTRL ALT TAB FN ENTER LEFT RIGHT UP DOWN or normal strings
+ * Some aliases are possible like RETURN for ENTER, LT for LEFT and more (@see controlCharsAliases for the whole list).
+ * Any string of length > 1 in total Uppercase will print a warning
+ *
+ * Examples:
+ * "ENTER" will trigger the ENTER keycode
+ * "LEFT" will trigger the LEFT keycode and be displayed as "←"
+ * "→" will input a "→" character
+ * "−" will input a "−" character
+ * "-_-" will input the string "-_-"
+ */
+ @SuppressLint("ClickableViewAccessibility")
+ void reload(ExtraKeysInfos infos) {
+ if(infos == null)
+ return;
+
+ for(SpecialButtonState state : specialButtons.values())
+ state.button = null;
+
+ removeAllViews();
+
+ ExtraKeyButton[][] buttons = infos.getMatrix();
+
+ setRowCount(buttons.length);
+ setColumnCount(maximumLength(buttons));
+
+ for (int row = 0; row < buttons.length; row++) {
+ for (int col = 0; col < buttons[row].length; col++) {
+ final ExtraKeyButton buttonInfo = buttons[row][col];
+
+ Button button;
+ if(Arrays.asList("CTRL", "ALT", "FN").contains(buttonInfo.getKey())) {
+ SpecialButtonState state = specialButtons.get(SpecialButton.valueOf(buttonInfo.getKey())); // for valueOf: https://stackoverflow.com/a/604426/1980630
+ state.isOn = true;
+ button = state.button = new ToggleButton(getContext(), null, android.R.attr.buttonBarButtonStyle);
+ button.setClickable(true);
+ } else {
+ button = new Button(getContext(), null, android.R.attr.buttonBarButtonStyle);
+ }
+
+ button.setText(buttonInfo.getDisplay());
+ button.setTextColor(TEXT_COLOR);
+ button.setPadding(0, 0, 0, 0);
+
+ final Button finalButton = button;
+ button.setOnClickListener(v -> {
+ if (Settings.System.getInt(getContext().getContentResolver(),
+ Settings.System.HAPTIC_FEEDBACK_ENABLED, 0) != 0) {
+
+ if (Build.VERSION.SDK_INT >= 28) {
+ finalButton.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
+ } else {
+ // Perform haptic feedback only if no total silence mode enabled.
+ if (Settings.Global.getInt(getContext().getContentResolver(), "zen_mode", 0) != 2) {
+ finalButton.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
+ }
+ }
+ }
+
+ View root = getRootView();
+ if (Arrays.asList("CTRL", "ALT", "FN").contains(buttonInfo.getKey())) {
+ ToggleButton self = (ToggleButton) finalButton;
+ self.setChecked(self.isChecked());
+ self.setTextColor(self.isChecked() ? INTERESTING_COLOR : TEXT_COLOR);
+ } else {
+ sendKey(root, buttonInfo);
+ }
+ });
+
+ button.setOnTouchListener((v, event) -> {
+ final View root = getRootView();
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_DOWN:
+ longPressCount = 0;
+ v.setBackgroundColor(BUTTON_PRESSED_COLOR);
+ if (Arrays.asList("UP", "DOWN", "LEFT", "RIGHT", "BKSP", "DEL").contains(buttonInfo.getKey())) {
+ // autorepeat
+ scheduledExecutor = Executors.newSingleThreadScheduledExecutor();
+ scheduledExecutor.scheduleWithFixedDelay(() -> {
+ longPressCount++;
+ sendKey(root, buttonInfo);
+ }, 400, 80, TimeUnit.MILLISECONDS);
+ }
+ return true;
+
+ case MotionEvent.ACTION_MOVE:
+ if (buttonInfo.getPopup() != null) {
+ if (popupWindow == null && event.getY() < 0) {
+ if (scheduledExecutor != null) {
+ scheduledExecutor.shutdownNow();
+ scheduledExecutor = null;
+ }
+ v.setBackgroundColor(BUTTON_COLOR);
+ String extraButtonDisplayedText = buttonInfo.getPopup().getDisplay();
+ popup(v, extraButtonDisplayedText);
+ }
+ if (popupWindow != null && event.getY() > 0) {
+ v.setBackgroundColor(BUTTON_PRESSED_COLOR);
+ popupWindow.dismiss();
+ popupWindow = null;
+ }
+ }
+ return true;
+
+ case MotionEvent.ACTION_CANCEL:
+ v.setBackgroundColor(BUTTON_COLOR);
+ if (scheduledExecutor != null) {
+ scheduledExecutor.shutdownNow();
+ scheduledExecutor = null;
+ }
+ return true;
+ case MotionEvent.ACTION_UP:
+ v.setBackgroundColor(BUTTON_COLOR);
+ if (scheduledExecutor != null) {
+ scheduledExecutor.shutdownNow();
+ scheduledExecutor = null;
+ }
+ if (longPressCount == 0 || popupWindow != null) {
+ if (popupWindow != null) {
+ popupWindow.setContentView(null);
+ popupWindow.dismiss();
+ popupWindow = null;
+ if (buttonInfo.getPopup() != null) {
+ sendKey(root, buttonInfo.getPopup());
+ }
+ } else {
+ v.performClick();
+ }
+ }
+ return true;
+
+ default:
+ return true;
+ }
+ });
+
+ LayoutParams param = new GridLayout.LayoutParams();
+ param.width = 0;
+ param.height = 0;
+ param.setMargins(0, 0, 0, 0);
+ param.columnSpec = GridLayout.spec(col, GridLayout.FILL, 1.f);
+ param.rowSpec = GridLayout.spec(row, GridLayout.FILL, 1.f);
+ button.setLayoutParams(param);
+
+ addView(button);
+ }
+ }
+ }
+
+}
diff --git a/termux-app/src/main/java/com/termux/app/RunCommandService.java b/termux-app/src/main/java/com/termux/app/RunCommandService.java
new file mode 100644
index 00000000..42174657
--- /dev/null
+++ b/termux-app/src/main/java/com/termux/app/RunCommandService.java
@@ -0,0 +1,86 @@
+package com.termux.app;
+
+import android.app.Service;
+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 java.io.File;
+import java.io.FileInputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.Properties;
+
+/**
+ * When allow-external-apps property is set to "true", Termux is able to process execute intents
+ * sent by third-party applications.
+ *
+ * Third-party program must declare com.termux.permission.RUN_COMMAND permission and it should be
+ * granted by user.
+ *
+ * Sample code to run command "top":
+ * 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");
+ * startService(intent);
+ */
+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";
+
+ class LocalBinder extends Binder {
+ public final RunCommandService service = RunCommandService.this;
+ }
+
+ private final IBinder mBinder = new RunCommandService.LocalBinder();
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return mBinder;
+ }
+
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ if (allowExternalApps() && RUN_COMMAND_ACTION.equals(intent.getAction())) {
+ Uri programUri = new Uri.Builder().scheme("com.termux.file").path(intent.getStringExtra(RUN_COMMAND_PATH)).build();
+
+ Intent execIntent = new Intent(TermuxService.ACTION_EXECUTE, programUri);
+ execIntent.setClass(this, TermuxService.class);
+ execIntent.putExtra(TermuxService.EXTRA_ARGUMENTS, intent.getStringExtra(RUN_COMMAND_ARGUMENTS));
+ execIntent.putExtra(TermuxService.EXTRA_CURRENT_WORKING_DIRECTORY, intent.getStringExtra(RUN_COMMAND_WORKDIR));
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ this.startForegroundService(execIntent);
+ } else {
+ this.startService(execIntent);
+ }
+ }
+
+ return Service.START_NOT_STICKY;
+ }
+
+ 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");
+ }
+}
diff --git a/termux-app/src/main/java/com/termux/app/TermuxActivity.java b/termux-app/src/main/java/com/termux/app/TermuxActivity.java
new file mode 100644
index 00000000..0c5086a4
--- /dev/null
+++ b/termux-app/src/main/java/com/termux/app/TermuxActivity.java
@@ -0,0 +1,963 @@
+package com.termux.app;
+
+import android.Manifest;
+import android.annotation.SuppressLint;
+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.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.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;
+import android.view.ViewGroup;
+import android.view.WindowManager;
+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.terminal.TerminalSession;
+import com.termux.terminal.TerminalSession.SessionChangedCallback;
+import com.termux.terminal.TextStyle;
+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 androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.drawerlayout.widget.DrawerLayout;
+import androidx.viewpager.widget.PagerAdapter;
+import androidx.viewpager.widget.ViewPager;
+
+/**
+ * A terminal emulator activity.
+ *
+ * See
+ *
+ * - http://www.mongrel-phones.com.au/default/how_to_make_a_local_service_and_bind_to_it_in_android
+ * - https://code.google.com/p/android/issues/detail?id=6426
+ *
+ * about memory leaks.
+ */
+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 MAX_SESSIONS = 8;
+
+ private static final int REQUESTCODE_PERMISSION_STORAGE = 1234;
+
+ private static final String RELOAD_STYLE_ACTION = "com.termux.app.reload_style";
+
+ /**
+ * 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;
+
+ /**
+ * Initialized in {@link #onServiceConnected(ComponentName, IBinder)}.
+ */
+ ArrayAdapter mListViewAdapter;
+
+ /**
+ * 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;
+
+ boolean mIsUsingBlackUI;
+
+ 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 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);
+
+ if (mExtraKeysView != null) {
+ mExtraKeysView.reload(mSettings.mExtraKeys);
+ }
+ }
+ }
+ };
+
+ void checkForFontAndColors() {
+ try {
+ @SuppressLint("SdCardPath") File fontFile = new File("/data/data/" + TermuxService.packageName + "/files/home/.termux/font.ttf");
+ @SuppressLint("SdCardPath") File colorsFile = new File("/data/data/" + TermuxService.packageName + "/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;
+ }
+ }
+
+ @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);
+ }
+
+ super.onCreate(bundle);
+
+ setContentView(R.layout.drawer_layout);
+
+ if (mIsUsingBlackUI) {
+ findViewById(R.id.left_drawer).setBackgroundColor(
+ getResources().getColor(android.R.color.background_dark)
+ );
+ }
+
+ mTerminalView = findViewById(R.id.terminal_view);
+ mTerminalView.setOnKeyListener(new TermuxViewClient(this));
+
+ mTerminalView.setTextSize(mSettings.getFontSize());
+ mTerminalView.setKeepScreenOn(mSettings.isScreenAlwaysOn());
+ mTerminalView.requestFocus();
+
+ final ViewPager viewPager = findViewById(R.id.viewpager);
+ if (mSettings.mShowExtraKeys) viewPager.setVisibility(View.VISIBLE);
+
+
+ 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);
+ } 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;
+ });
+
+ registerForContextMenu(mTerminalView);
+
+ Intent serviceIntent = new Intent(this, TermuxService.class);
+ // Start the service and make it run regardless of who is bound to it:
+ startService(serviceIntent);
+ if (!bindService(serviceIntent, this, 0))
+ throw new RuntimeException("bindService() failed");
+
+ checkForFontAndColors();
+
+ mBellSoundId = mBellSoundPool.load(this, R.raw.bell, 1);
+ }
+
+ 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();
+ }
+ }
+
+ /**
+ * Part of the {@link ServiceConnection} interface. The service is bound with
+ * {@link #bindService(Intent, ServiceConnection, int)} in {@link #onCreate(Bundle)} which will cause a call to this
+ * callback method.
+ */
+ @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();
+ }
+
+ @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();
+ }
+
+ @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);
+ }
+
+ 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 (mIsVisible) {
+ TermuxInstaller.setupIfNeeded(TermuxActivity.this, () -> {
+ if (mTermService == 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);
+ }
+ addNewSession(launchFailsafe, null);
+ } catch (WindowManager.BadTokenException e) {
+ // Activity finished - ignore.
+ }
+ });
+ } else {
+ // The service connected while not in foreground - just bail out.
+ finish();
+ }
+ } 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);
+ } else {
+ switchToSession(getStoredCurrentSessionOrLast());
+ }
+ }
+ }
+
+ 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);
+ }
+
+ @Override
+ public void onServiceDisconnected(ComponentName name) {
+ // Respect being stopped from the TermuxService notification action.
+ finish();
+ }
+
+ @Nullable
+ TerminalSession getCurrentTermSession() {
+ return mTerminalView.getCurrentSession();
+ }
+
+ @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();
+ }
+
+ @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();
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ if (mTermService != null) {
+ // RFIDTools change: exit on activity destroy.
+ startService(new Intent(this, TermuxService.class).setAction(TermuxService.ACTION_STOP_SERVICE));
+ // Do not leave service with references to activity.
+ mTermService.mSessionChangeCallback = null;
+ mTermService = null;
+ }
+ // RFIDTools change: same top
+ TerminalSession session = getCurrentTermSession();
+ if (session != null) session.finishIfRunning();
+ unbindService(this);
+ }
+
+ DrawerLayout getDrawer() {
+ return (DrawerLayout) findViewById(R.id.drawer_layout);
+ }
+
+ String getPM3ExecutablePath() {
+ return TermuxService.HOME_PATH + File.separator + "proxmark3";
+ }
+
+ String[] getPM3Args() {
+ if (true) return null;
+ else {
+ return new String[]{
+ "socket:DXL.COM.ASL"
+ };
+ }
+ }
+
+ 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();
+ } else {
+ TerminalSession currentSession = getCurrentTermSession();
+ String workingDirectory = (currentSession == null) ? null : currentSession.getCwd();
+ TerminalSession newSession = mTermService.createTermSession(getPM3ExecutablePath(), getPM3Args(), workingDirectory, failSafe);
+ if (sessionName != null) {
+ newSession.mSessionName = sessionName;
+ }
+ switchToSession(newSession);
+ getDrawer().closeDrawers();
+ }
+ }
+
+ /**
+ * 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();
+ }
+ }
+
+ 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);
+ }
+ 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);
+ }
+
+ @Override
+ public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) {
+ TerminalSession currentSession = getCurrentTermSession();
+ 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);
+ 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);
+ }
+
+ /**
+ * Hook system menu to show context menu instead.
+ */
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ mTerminalView.showContextMenu();
+ 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();
+
+ switch (item.getItemId()) {
+ case CONTEXTMENU_SELECT_URL_ID:
+ 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)));
+ }
+ return true;
+ case CONTEXTMENU_PASTE_ID:
+ doPaste();
+ 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();
+ return true;
+ case CONTEXTMENU_RESET_TERMINAL_ID: {
+ if (session != null) {
+ session.reset();
+ showToast(getResources().getString(R.string.reset_toast_notification), true);
+ }
+ 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("http://play.google.com/store/apps/details?id=com.termux.styling")))).setNegativeButton(android.R.string.cancel, null).show();
+ }
+ return true;
+ }
+ case CONTEXTMENU_HELP_ID:
+ startActivity(new Intent(this, TermuxHelpActivity.class));
+ 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);
+ }
+ return true;
+ }
+ 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);
+ }
+ }
+
+ void changeFontSize(boolean increase) {
+ mSettings.changeFontSize(this, increase);
+ mTerminalView.setTextSize(mSettings.getFontSize());
+ }
+
+ 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();
+ } else {
+ if (index >= service.getSessions().size()) {
+ index = service.getSessions().size() - 1;
+ }
+ switchToSession(service.getSessions().get(index));
+ }
+ }
+}
diff --git a/termux-app/src/main/java/com/termux/app/TermuxHelpActivity.java b/termux-app/src/main/java/com/termux/app/TermuxHelpActivity.java
new file mode 100644
index 00000000..0aa8a97a
--- /dev/null
+++ b/termux-app/src/main/java/com/termux/app/TermuxHelpActivity.java
@@ -0,0 +1,75 @@
+package com.termux.app;
+
+import android.app.Activity;
+import android.content.ActivityNotFoundException;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.ViewGroup;
+import android.webkit.WebSettings;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+import android.widget.ProgressBar;
+import android.widget.RelativeLayout;
+
+/** Basic embedded browser for viewing help pages. */
+public final class TermuxHelpActivity extends Activity {
+
+ WebView mWebView;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ final RelativeLayout progressLayout = new RelativeLayout(this);
+ RelativeLayout.LayoutParams lParams = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ lParams.addRule(RelativeLayout.CENTER_IN_PARENT);
+ ProgressBar progressBar = new ProgressBar(this);
+ progressBar.setIndeterminate(true);
+ progressBar.setLayoutParams(lParams);
+ progressLayout.addView(progressBar);
+
+ mWebView = new WebView(this);
+ WebSettings settings = mWebView.getSettings();
+ settings.setCacheMode(WebSettings.LOAD_NO_CACHE);
+ settings.setAppCacheEnabled(false);
+ setContentView(progressLayout);
+ mWebView.clearCache(true);
+
+ mWebView.setWebViewClient(new WebViewClient() {
+ @Override
+ public boolean shouldOverrideUrlLoading(WebView view, String url) {
+ if (url.startsWith("https://wiki.termux.com")) {
+ // Inline help.
+ setContentView(progressLayout);
+ return false;
+ }
+
+ try {
+ startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
+ } catch (ActivityNotFoundException e) {
+ // Android TV does not have a system browser.
+ setContentView(progressLayout);
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public void onPageFinished(WebView view, String url) {
+ setContentView(mWebView);
+ }
+ });
+ mWebView.loadUrl("https://wiki.termux.com/wiki/Main_Page");
+ }
+
+ @Override
+ public void onBackPressed() {
+ if (mWebView.canGoBack()) {
+ mWebView.goBack();
+ } else {
+ super.onBackPressed();
+ }
+ }
+
+}
diff --git a/termux-app/src/main/java/com/termux/app/TermuxInstaller.java b/termux-app/src/main/java/com/termux/app/TermuxInstaller.java
new file mode 100644
index 00000000..34275129
--- /dev/null
+++ b/termux-app/src/main/java/com/termux/app/TermuxInstaller.java
@@ -0,0 +1,240 @@
+package com.termux.app;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.ProgressDialog;
+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 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;
+import java.util.zip.ZipEntry;
+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.
+ *
+ * (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.
+ *
+ * (4) The zip file is loaded from a shared library.
+ *
+ * (5) The zip, containing entries relative to the $PREFIX, is is downloaded and extracted by a zip input stream
+ * continuously encountering zip file entries:
+ *
+ * (5.1) If the zip entry encountered is SYMLINKS.txt, go through it and remember all symlinks to setup.
+ *
+ * (5.2) For every other zip entry, extract it into $STAGING_PREFIX and set execute permissions if necessary.
+ */
+final class TermuxInstaller {
+
+ /**
+ * Performs setup if necessary.
+ */
+ static void setupIfNeeded(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)
+ .setOnDismissListener(dialog -> System.exit(0)).setPositiveButton(android.R.string.ok, null).show();
+ return;
+ }
+
+ final File PREFIX_FILE = new File(TermuxService.PREFIX_PATH);
+ if (PREFIX_FILE.isDirectory()) {
+ whenDone.run();
+ return;
+ }
+
+ final ProgressDialog progress = ProgressDialog.show(activity, null, activity.getString(R.string.bootstrap_installer_body), true, false);
+ new Thread() {
+ @Override
+ public void run() {
+ try {
+ final String STAGING_PREFIX_PATH = TermuxService.FILES_PATH + "/usr-staging";
+ final File STAGING_PREFIX_FILE = new File(STAGING_PREFIX_PATH);
+
+ if (STAGING_PREFIX_FILE.exists()) {
+ deleteFolder(STAGING_PREFIX_FILE);
+ }
+
+ /*final byte[] buffer = new byte[8096];
+ final List> symlinks = new ArrayList<>(50);
+
+ final byte[] zipBytes = loadZipBytes();
+ try (ZipInputStream zipInput = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
+ ZipEntry zipEntry;
+ while ((zipEntry = zipInput.getNextEntry()) != null) {
+ if (zipEntry.getName().equals("SYMLINKS.txt")) {
+ BufferedReader symlinksReader = new BufferedReader(new InputStreamReader(zipInput));
+ String line;
+ while ((line = symlinksReader.readLine()) != null) {
+ String[] parts = line.split("←");
+ if (parts.length != 2)
+ throw new RuntimeException("Malformed symlink line: " + line);
+ String oldPath = parts[0];
+ String newPath = STAGING_PREFIX_PATH + "/" + parts[1];
+ symlinks.add(Pair.create(oldPath, newPath));
+
+ ensureDirectoryExists(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());
+
+ if (!isDirectory) {
+ try (FileOutputStream outStream = new FileOutputStream(targetFile)) {
+ int readBytes;
+ while ((readBytes = zipInput.read(buffer)) != -1)
+ outStream.write(buffer, 0, readBytes);
+ }
+ if (zipEntryName.startsWith("bin/") || zipEntryName.startsWith("libexec") || zipEntryName.startsWith("lib/apt/methods")) {
+ //noinspection OctalInteger
+ Os.chmod(targetFile.getAbsolutePath(), 0700);
+ }
+ }
+ }
+ }
+ }
+
+ if (symlinks.isEmpty())
+ throw new RuntimeException("No SYMLINKS.txt encountered");
+ for (Pair symlink : symlinks) {
+ Os.symlink(symlink.first, symlink.second);
+ }
+
+ if (!STAGING_PREFIX_FILE.renameTo(PREFIX_FILE)) {
+ throw new RuntimeException("Unable to rename staging folder");
+ }*/
+
+ //TODO PM3 Client init
+
+
+ activity.runOnUiThread(whenDone);
+ } catch (final Exception e) {
+ Log.e(EmulatorDebug.LOG_TAG, "Bootstrap error", e);
+ activity.runOnUiThread(() -> {
+ try {
+ new AlertDialog.Builder(activity).setTitle(R.string.bootstrap_error_title).setMessage(R.string.bootstrap_error_body)
+ .setNegativeButton(R.string.bootstrap_error_abort, (dialog, which) -> {
+ dialog.dismiss();
+ activity.finish();
+ }).setPositiveButton(R.string.bootstrap_error_try_again, (dialog, which) -> {
+ dialog.dismiss();
+ TermuxInstaller.setupIfNeeded(activity, whenDone);
+ }).show();
+ } catch (WindowManager.BadTokenException e1) {
+ // Activity already dismissed - ignore.
+ }
+ });
+ } finally {
+ activity.runOnUiThread(() -> {
+ try {
+ progress.dismiss();
+ } catch (RuntimeException e) {
+ // Activity already dismissed - ignore.
+ }
+ });
+ }
+ }
+ }.start();
+ }
+
+ /**
+ * 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";
+ new Thread() {
+ public void run() {
+ try {
+ File storageDir = new File(TermuxService.HOME_PATH, "storage");
+
+ 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");
+ return;
+ }
+
+ File sharedDir = Environment.getExternalStorageDirectory();
+ Os.symlink(sharedDir.getAbsolutePath(), new File(storageDir, "shared").getAbsolutePath());
+
+ File downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
+ Os.symlink(downloadsDir.getAbsolutePath(), new File(storageDir, "downloads").getAbsolutePath());
+
+ File dcimDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM);
+ Os.symlink(dcimDir.getAbsolutePath(), new File(storageDir, "dcim").getAbsolutePath());
+
+ File picturesDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES);
+ Os.symlink(picturesDir.getAbsolutePath(), new File(storageDir, "pictures").getAbsolutePath());
+
+ File musicDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC);
+ Os.symlink(musicDir.getAbsolutePath(), new File(storageDir, "music").getAbsolutePath());
+
+ File moviesDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES);
+ Os.symlink(moviesDir.getAbsolutePath(), new File(storageDir, "movies").getAbsolutePath());
+
+ final File[] dirs = context.getExternalFilesDirs(null);
+ if (dirs != null && dirs.length > 1) {
+ for (int i = 1; i < dirs.length; i++) {
+ File dir = dirs[i];
+ if (dir == null) continue;
+ String symlinkName = "external-" + i;
+ Os.symlink(dir.getAbsolutePath(), new File(storageDir, symlinkName).getAbsolutePath());
+ }
+ }
+ } catch (Exception e) {
+ Log.e(LOG_TAG, "Error setting up link", e);
+ }
+ }
+ }.start();
+ }
+
+}
diff --git a/termux-app/src/main/java/com/termux/app/TermuxOpenReceiver.java b/termux-app/src/main/java/com/termux/app/TermuxOpenReceiver.java
new file mode 100644
index 00000000..6b8bf227
--- /dev/null
+++ b/termux-app/src/main/java/com/termux/app/TermuxOpenReceiver.java
@@ -0,0 +1,191 @@
+package com.termux.app;
+
+import android.content.ActivityNotFoundException;
+import android.content.BroadcastReceiver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+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 java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+
+import androidx.annotation.NonNull;
+
+public class TermuxOpenReceiver extends BroadcastReceiver {
+
+ @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");
+ return;
+ }
+
+ final String filePath = data.getPath();
+ final String contentTypeExtra = intent.getStringExtra("content-type");
+ final boolean useChooser = intent.getBooleanExtra("chooser", false);
+ final String intentAction = intent.getAction() == null ? Intent.ACTION_VIEW : intent.getAction();
+ switch (intentAction) {
+ case Intent.ACTION_SEND:
+ case Intent.ACTION_VIEW:
+ // Ok.
+ break;
+ default:
+ Log.e(EmulatorDebug.LOG_TAG, "Invalid action '" + intentAction + "', using 'view'");
+ break;
+ }
+
+ final boolean isExternalUrl = data.getScheme() != null && !data.getScheme().equals("file");
+ if (isExternalUrl) {
+ Intent urlIntent = new Intent(intentAction, data);
+ if (intentAction.equals(Intent.ACTION_SEND)) {
+ urlIntent.putExtra(Intent.EXTRA_TEXT, data.toString());
+ urlIntent.setData(null);
+ } else if (contentTypeExtra != null) {
+ urlIntent.setDataAndType(data, contentTypeExtra);
+ }
+ urlIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ try {
+ context.startActivity(urlIntent);
+ } catch (ActivityNotFoundException e) {
+ Log.e(EmulatorDebug.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() + "'");
+ return;
+ }
+
+ Intent sendIntent = new Intent();
+ sendIntent.setAction(intentAction);
+ sendIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_GRANT_READ_URI_PERMISSION);
+
+ String contentTypeToUse;
+ if (contentTypeExtra == null) {
+ String fileName = fileToShare.getName();
+ int lastDotIndex = fileName.lastIndexOf('.');
+ String fileExtension = fileName.substring(lastDotIndex + 1);
+ MimeTypeMap mimeTypes = MimeTypeMap.getSingleton();
+ // Lower casing makes it work with e.g. "JPG":
+ contentTypeToUse = mimeTypes.getMimeTypeFromExtension(fileExtension.toLowerCase());
+ if (contentTypeToUse == null) contentTypeToUse = "application/octet-stream";
+ } else {
+ contentTypeToUse = contentTypeExtra;
+ }
+
+ Uri uriToShare = Uri.parse("content://com.termux.files" + fileToShare.getAbsolutePath());
+
+ if (Intent.ACTION_SEND.equals(intentAction)) {
+ sendIntent.putExtra(Intent.EXTRA_STREAM, uriToShare);
+ sendIntent.setType(contentTypeToUse);
+ } else {
+ sendIntent.setDataAndType(uriToShare, contentTypeToUse);
+ }
+
+ if (useChooser) {
+ sendIntent = Intent.createChooser(sendIntent, null).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ }
+
+ try {
+ context.startActivity(sendIntent);
+ } catch (ActivityNotFoundException e) {
+ Log.e(EmulatorDebug.LOG_TAG, "termux-open: No app handles the url " + data);
+ }
+ }
+
+ public static class ContentProvider extends android.content.ContentProvider {
+
+ @Override
+ public boolean onCreate() {
+ return true;
+ }
+
+ @Override
+ public Cursor query(@NonNull Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+ File file = new File(uri.getPath());
+
+ if (projection == null) {
+ projection = new String[]{
+ MediaStore.MediaColumns.DISPLAY_NAME,
+ MediaStore.MediaColumns.SIZE,
+ MediaStore.MediaColumns._ID
+ };
+ }
+
+ Object[] row = new Object[projection.length];
+ for (int i = 0; i < projection.length; i++) {
+ String column = projection[i];
+ Object value;
+ switch (column) {
+ case MediaStore.MediaColumns.DISPLAY_NAME:
+ value = file.getName();
+ break;
+ case MediaStore.MediaColumns.SIZE:
+ value = (int) file.length();
+ break;
+ case MediaStore.MediaColumns._ID:
+ value = 1;
+ break;
+ default:
+ value = null;
+ }
+ row[i] = value;
+ }
+
+ MatrixCursor cursor = new MatrixCursor(projection);
+ cursor.addRow(row);
+ return cursor;
+ }
+
+ @Override
+ public String getType(@NonNull Uri uri) {
+ return null;
+ }
+
+ @Override
+ public Uri insert(@NonNull Uri uri, ContentValues values) {
+ return null;
+ }
+
+ @Override
+ public int delete(@NonNull Uri uri, String selection, String[] selectionArgs) {
+ return 0;
+ }
+
+ @Override
+ public int update(@NonNull Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+ return 0;
+ }
+
+ @Override
+ public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException {
+ File file = new File(uri.getPath());
+ try {
+ 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))) {
+ throw new IllegalArgumentException("Invalid path: " + path);
+ }
+ } catch (IOException e) {
+ throw new IllegalArgumentException(e);
+ }
+ return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
+ }
+ }
+
+}
diff --git a/termux-app/src/main/java/com/termux/app/TermuxPreferences.java b/termux-app/src/main/java/com/termux/app/TermuxPreferences.java
new file mode 100644
index 00000000..3703cd09
--- /dev/null
+++ b/termux-app/src/main/java/com/termux/app/TermuxPreferences.java
@@ -0,0 +1,251 @@
+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;
+
+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;
+
+ @AsciiBellBehaviour
+ int mBellBehaviour = BELL_VIBRATE;
+
+ boolean mBackIsEscape;
+ boolean mDisableVolumeVirtualKeys;
+ boolean mShowExtraKeys;
+
+ 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;
+ }
+
+ 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;
+ }
+
+ 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/termux-app/src/main/java/com/termux/app/TermuxService.java b/termux-app/src/main/java/com/termux/app/TermuxService.java
new file mode 100644
index 00000000..64bcf5b9
--- /dev/null
+++ b/termux-app/src/main/java/com/termux/app/TermuxService.java
@@ -0,0 +1,411 @@
+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;
+import android.content.ActivityNotFoundException;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.net.Uri;
+import android.net.wifi.WifiManager;
+import android.os.Binder;
+import android.os.Build;
+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.terminal.TerminalSession;
+import com.termux.terminal.TerminalSession.SessionChangedCallback;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 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
+ * 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
+ * as wanted by the user this service is a foreground service, {@link Service#startForeground(int, Notification)}.
+ *
+ * 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 {
+
+ private static final String NOTIFICATION_CHANNEL_ID = "termux_notification_channel";
+
+ // My main app package name.(important)
+ public static final String packageName = "com.rfidresearchgroup.rfidtools";
+
+ /**
+ * Note that this is a symlink on the Android M preview.
+ */
+ @SuppressLint("SdCardPath")
+ public static final String FILES_PATH = "/data/data/" + packageName + "/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;
+
+ public static final String ACTION_STOP_SERVICE = packageName + ".service_stop";
+ public static final String ACTION_LOCK_WAKE = packageName + ".service_wake_lock";
+ public static final String ACTION_UNLOCK_WAKE = packageName + ".service_wake_unlock";
+ /**
+ * Intent action to launch a new terminal session. Executed from TermuxWidgetProvider.
+ */
+ public static final String ACTION_EXECUTE = packageName + ".service_execute";
+
+ public static final String EXTRA_ARGUMENTS = packageName + ".execute.arguments";
+
+ public static final String EXTRA_CURRENT_WORKING_DIRECTORY = packageName + ".execute.cwd";
+ private static final String EXTRA_EXECUTE_IN_BACKGROUND = packageName + ".execute.background";
+
+ /**
+ * This service is only bound from inside the same process and never uses IPC.
+ */
+ class LocalBinder extends Binder {
+ public final TermuxService service = TermuxService.this;
+ }
+
+ private final IBinder mBinder = new LocalBinder();
+
+ 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()} }.
+ */
+ final List mTerminalSessions = new ArrayList<>();
+
+ final List mBackgroundTasks = new ArrayList<>();
+
+ /**
+ * Note that the service may often outlive the activity, so need to clear this reference.
+ */
+ SessionChangedCallback mSessionChangeCallback;
+
+ /**
+ * 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.
+ */
+ boolean mWantsToStop = false;
+
+ @SuppressLint("Wakelock")
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ 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);
+ 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();
+ }
+ } 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
+ // start of {@link Term):
+ return Service.START_NOT_STICKY;
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return mBinder;
+ }
+
+ @Override
+ public void onCreate() {
+ setupNotificationChannel();
+ startForeground(NOTIFICATION_ID, buildNotification());
+ }
+
+ /**
+ * 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());
+ }
+ }
+
+ 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");
+ if (taskCount > 0) {
+ contentText += ", " + taskCount + " task" + (taskCount == 1 ? "" : "s");
+ }
+
+ final boolean wakeLockHeld = mWakeLock != null;
+ if (wakeLockHeld) contentText += " (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);
+
+ // 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);
+
+ // No need to show a timestamp:
+ builder.setShowWhen(false);
+
+ // Background color for small notification icon:
+ builder.setColor(0xFF607D8B);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ builder.setChannelId(NOTIFICATION_CHANNEL_ID);
+ }
+
+ Resources res = getResources();
+ Intent exitIntent = new Intent(this, TermuxService.class).setAction(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;
+ 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);
+ 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 for RFIDTools";
+ 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);
+ }
+}
diff --git a/termux-app/src/main/java/com/termux/app/TermuxViewClient.java b/termux-app/src/main/java/com/termux/app/TermuxViewClient.java
new file mode 100644
index 00000000..3dcc406c
--- /dev/null
+++ b/termux-app/src/main/java/com/termux/app/TermuxViewClient.java
@@ -0,0 +1,283 @@
+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/termux-app/src/main/java/com/termux/filepicker/TermuxDocumentsProvider.java b/termux-app/src/main/java/com/termux/filepicker/TermuxDocumentsProvider.java
new file mode 100644
index 00000000..6a849040
--- /dev/null
+++ b/termux-app/src/main/java/com/termux/filepicker/TermuxDocumentsProvider.java
@@ -0,0 +1,271 @@
+package com.termux.filepicker;
+
+import android.content.res.AssetFileDescriptor;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.graphics.Point;
+import android.os.CancellationSignal;
+import android.os.ParcelFileDescriptor;
+import android.provider.DocumentsContract.Document;
+import android.provider.DocumentsContract.Root;
+import android.provider.DocumentsProvider;
+import android.webkit.MimeTypeMap;
+
+import com.termux.R;
+import com.termux.app.TermuxService;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.LinkedList;
+
+/**
+ * A document provider for the Storage Access Framework which exposes the files in the
+ * $HOME/ folder to other apps.
+ *
+ * Note that this replaces providing an activity matching the ACTION_GET_CONTENT intent:
+ *
+ * "A document provider and ACTION_GET_CONTENT should be considered mutually exclusive. If you
+ * support both of them simultaneously, your app will appear twice in the system picker UI,
+ * offering two different ways of accessing your stored data. This would be confusing for users."
+ * - http://developer.android.com/guide/topics/providers/document-provider.html#43
+ */
+public class TermuxDocumentsProvider extends DocumentsProvider {
+
+ private static final String ALL_MIME_TYPES = "*/*";
+
+ private static final File BASE_DIR = new File(TermuxService.HOME_PATH);
+
+
+ // The default columns to return information about a root if no specific
+ // columns are requested in a query.
+ private static final String[] DEFAULT_ROOT_PROJECTION = new String[]{
+ Root.COLUMN_ROOT_ID,
+ Root.COLUMN_MIME_TYPES,
+ Root.COLUMN_FLAGS,
+ Root.COLUMN_ICON,
+ Root.COLUMN_TITLE,
+ Root.COLUMN_SUMMARY,
+ Root.COLUMN_DOCUMENT_ID,
+ Root.COLUMN_AVAILABLE_BYTES
+ };
+
+ // The default columns to return information about a document if no specific
+ // columns are requested in a query.
+ private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[]{
+ Document.COLUMN_DOCUMENT_ID,
+ Document.COLUMN_MIME_TYPE,
+ Document.COLUMN_DISPLAY_NAME,
+ Document.COLUMN_LAST_MODIFIED,
+ Document.COLUMN_FLAGS,
+ Document.COLUMN_SIZE
+ };
+
+ @Override
+ public Cursor queryRoots(String[] projection) throws FileNotFoundException {
+ final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_ROOT_PROJECTION);
+ @SuppressWarnings("ConstantConditions") final String applicationName = getContext().getString(R.string.application_name);
+
+ final MatrixCursor.RowBuilder row = result.newRow();
+ row.add(Root.COLUMN_ROOT_ID, getDocIdForFile(BASE_DIR));
+ row.add(Root.COLUMN_DOCUMENT_ID, getDocIdForFile(BASE_DIR));
+ row.add(Root.COLUMN_SUMMARY, null);
+ row.add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE | Root.FLAG_SUPPORTS_SEARCH | Root.FLAG_SUPPORTS_IS_CHILD);
+ row.add(Root.COLUMN_TITLE, applicationName);
+ row.add(Root.COLUMN_MIME_TYPES, ALL_MIME_TYPES);
+ row.add(Root.COLUMN_AVAILABLE_BYTES, BASE_DIR.getFreeSpace());
+ row.add(Root.COLUMN_ICON, R.drawable.ic_launcher);
+ return result;
+ }
+
+ @Override
+ public Cursor queryDocument(String documentId, String[] projection) throws FileNotFoundException {
+ final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION);
+ includeFile(result, documentId, null);
+ return result;
+ }
+
+ @Override
+ public Cursor queryChildDocuments(String parentDocumentId, String[] projection, String sortOrder) throws FileNotFoundException {
+ final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION);
+ final File parent = getFileForDocId(parentDocumentId);
+ for (File file : parent.listFiles()) {
+ if (!file.getName().startsWith(".")) {
+ includeFile(result, null, file);
+ }
+ }
+ return result;
+ }
+
+ @Override
+ public ParcelFileDescriptor openDocument(final String documentId, String mode, CancellationSignal signal) throws FileNotFoundException {
+ final File file = getFileForDocId(documentId);
+ final int accessMode = ParcelFileDescriptor.parseMode(mode);
+ return ParcelFileDescriptor.open(file, accessMode);
+ }
+
+ @Override
+ public AssetFileDescriptor openDocumentThumbnail(String documentId, Point sizeHint, CancellationSignal signal) throws FileNotFoundException {
+ final File file = getFileForDocId(documentId);
+ final ParcelFileDescriptor pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
+ return new AssetFileDescriptor(pfd, 0, file.length());
+ }
+
+ @Override
+ public boolean onCreate() {
+ return true;
+ }
+
+ @Override
+ public String createDocument(String parentDocumentId, String mimeType, String displayName) throws FileNotFoundException {
+ File newFile = new File(parentDocumentId, displayName);
+ int noConflictId = 2;
+ while (newFile.exists()) {
+ newFile = new File(parentDocumentId, displayName + " (" + noConflictId++ + ")");
+ }
+ try {
+ boolean succeeded;
+ if (Document.MIME_TYPE_DIR.equals(mimeType)) {
+ succeeded = newFile.mkdir();
+ } else {
+ succeeded = newFile.createNewFile();
+ }
+ if (!succeeded) {
+ throw new FileNotFoundException("Failed to create document with id " + newFile.getPath());
+ }
+ } catch (IOException e) {
+ throw new FileNotFoundException("Failed to create document with id " + newFile.getPath());
+ }
+ return newFile.getPath();
+ }
+
+ @Override
+ public void deleteDocument(String documentId) throws FileNotFoundException {
+ File file = getFileForDocId(documentId);
+ if (!file.delete()) {
+ throw new FileNotFoundException("Failed to delete document with id " + documentId);
+ }
+ }
+
+ @Override
+ public String getDocumentType(String documentId) throws FileNotFoundException {
+ File file = getFileForDocId(documentId);
+ return getMimeType(file);
+ }
+
+ @Override
+ public Cursor querySearchDocuments(String rootId, String query, String[] projection) throws FileNotFoundException {
+ final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION);
+ final File parent = getFileForDocId(rootId);
+
+ // This example implementation searches file names for the query and doesn't rank search
+ // results, so we can stop as soon as we find a sufficient number of matches. Other
+ // implementations might rank results and use other data about files, rather than the file
+ // name, to produce a match.
+ final LinkedList pending = new LinkedList<>();
+ pending.add(parent);
+
+ 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
+ // through the whole SD card).
+ boolean isInsideHome;
+ try {
+ isInsideHome = file.getCanonicalPath().startsWith(TermuxService.HOME_PATH);
+ } catch (IOException e) {
+ isInsideHome = true;
+ }
+ final boolean isHidden = file.getName().startsWith(".");
+ if (isInsideHome && !isHidden) {
+ if (file.isDirectory()) {
+ Collections.addAll(pending, file.listFiles());
+ } else {
+ if (file.getName().toLowerCase().contains(query)) {
+ includeFile(result, null, file);
+ }
+ }
+ }
+ }
+
+ return result;
+ }
+
+ @Override
+ public boolean isChildDocument(String parentDocumentId, String documentId) {
+ return documentId.startsWith(parentDocumentId);
+ }
+
+ /**
+ * Get the document id given a file. This document id must be consistent across time as other
+ * applications may save the ID and use it to reference documents later.
+ *
+ * The reverse of @{link #getFileForDocId}.
+ */
+ private static String getDocIdForFile(File file) {
+ return file.getAbsolutePath();
+ }
+
+ /**
+ * Get the file given a document id (the reverse of {@link #getDocIdForFile(File)}).
+ */
+ private static File getFileForDocId(String docId) throws FileNotFoundException {
+ final File f = new File(docId);
+ if (!f.exists()) throw new FileNotFoundException(f.getAbsolutePath() + " not found");
+ return f;
+ }
+
+ private static String getMimeType(File file) {
+ if (file.isDirectory()) {
+ return Document.MIME_TYPE_DIR;
+ } else {
+ final String name = file.getName();
+ final int lastDot = name.lastIndexOf('.');
+ if (lastDot >= 0) {
+ final String extension = name.substring(lastDot + 1).toLowerCase();
+ final String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
+ if (mime != null) return mime;
+ }
+ return "application/octet-stream";
+ }
+ }
+
+ /**
+ * Add a representation of a file to a cursor.
+ *
+ * @param result the cursor to modify
+ * @param docId the document ID representing the desired file (may be null if given file)
+ * @param file the File object representing the desired file (may be null if given docID)
+ */
+ private void includeFile(MatrixCursor result, String docId, File file)
+ throws FileNotFoundException {
+ if (docId == null) {
+ docId = getDocIdForFile(file);
+ } else {
+ file = getFileForDocId(docId);
+ }
+
+ int flags = 0;
+ if (file.isDirectory()) {
+ if (file.canWrite()) flags |= Document.FLAG_DIR_SUPPORTS_CREATE;
+ } else if (file.canWrite()) {
+ flags |= Document.FLAG_SUPPORTS_WRITE;
+ }
+ if (file.getParentFile().canWrite()) flags |= Document.FLAG_SUPPORTS_DELETE;
+
+ final String displayName = file.getName();
+ final String mimeType = getMimeType(file);
+ if (mimeType.startsWith("image/")) flags |= Document.FLAG_SUPPORTS_THUMBNAIL;
+
+ final MatrixCursor.RowBuilder row = result.newRow();
+ row.add(Document.COLUMN_DOCUMENT_ID, docId);
+ row.add(Document.COLUMN_DISPLAY_NAME, displayName);
+ row.add(Document.COLUMN_SIZE, file.length());
+ row.add(Document.COLUMN_MIME_TYPE, mimeType);
+ row.add(Document.COLUMN_LAST_MODIFIED, file.lastModified());
+ row.add(Document.COLUMN_FLAGS, flags);
+ row.add(Document.COLUMN_ICON, R.drawable.ic_launcher);
+ }
+
+}
diff --git a/termux-app/src/main/java/com/termux/filepicker/TermuxFileReceiverActivity.java b/termux-app/src/main/java/com/termux/filepicker/TermuxFileReceiverActivity.java
new file mode 100644
index 00000000..e1ef5d42
--- /dev/null
+++ b/termux-app/src/main/java/com/termux/filepicker/TermuxFileReceiverActivity.java
@@ -0,0 +1,198 @@
+package com.termux.filepicker;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+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.app.TermuxService;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+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";
+
+ /**
+ * If the activity should be finished when the name input dialog is dismissed. This is disabled
+ * before showing an error dialog, since the act of showing the error dialog will cause the
+ * name input dialog to be implicitly dismissed, and we do not want to finish the activity directly
+ * when showing the error dialog.
+ */
+ boolean mFinishOnDismissNameDialog = true;
+
+ static boolean isSharedTextAnUrl(String sharedText) {
+ return Patterns.WEB_URL.matcher(sharedText).matches()
+ || Pattern.matches("magnet:\\?xt=urn:btih:.*?", sharedText);
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+
+ final Intent intent = getIntent();
+ final String action = intent.getAction();
+ final String type = intent.getType();
+ final String scheme = intent.getScheme();
+
+ if (Intent.ACTION_SEND.equals(action) && type != null) {
+ final String sharedText = intent.getStringExtra(Intent.EXTRA_TEXT);
+ final Uri sharedUri = intent.getParcelableExtra(Intent.EXTRA_STREAM);
+
+ if (sharedText != null) {
+ if (isSharedTextAnUrl(sharedText)) {
+ handleUrlAndFinish(sharedText);
+ } else {
+ String subject = intent.getStringExtra(Intent.EXTRA_SUBJECT);
+ if (subject == null) subject = intent.getStringExtra(Intent.EXTRA_TITLE);
+ if (subject != null) subject += ".txt";
+ promptNameAndSave(new ByteArrayInputStream(sharedText.getBytes(StandardCharsets.UTF_8)), subject);
+ }
+ } else if (sharedUri != null) {
+ handleContentUri(sharedUri, intent.getStringExtra(Intent.EXTRA_TITLE));
+ } else {
+ showErrorDialogAndQuit("Send action without content - nothing to save.");
+ }
+ } else if ("content".equals(scheme)) {
+ handleContentUri(intent.getData(), intent.getStringExtra(Intent.EXTRA_TITLE));
+ } else if ("file".equals(scheme)) {
+ // When e.g. clicking on a downloaded apk:
+ String path = intent.getData().getPath();
+ File file = new File(path);
+ try {
+ FileInputStream in = new FileInputStream(file);
+ promptNameAndSave(in, file.getName());
+ } catch (FileNotFoundException e) {
+ showErrorDialogAndQuit("Cannot open file: " + e.getMessage() + ".");
+ }
+ } else {
+ showErrorDialogAndQuit("Unable to receive any file or URL.");
+ }
+ }
+
+ void showErrorDialogAndQuit(String message) {
+ mFinishOnDismissNameDialog = false;
+ new AlertDialog.Builder(this).setMessage(message).setOnDismissListener(dialog -> finish()).setPositiveButton(android.R.string.ok, (dialog, which) -> finish()).show();
+ }
+
+ void handleContentUri(final Uri uri, String subjectFromIntent) {
+ try {
+ String attachmentFileName = null;
+
+ String[] projection = new String[]{OpenableColumns.DISPLAY_NAME};
+ try (Cursor c = getContentResolver().query(uri, projection, null, null, null)) {
+ if (c != null && c.moveToFirst()) {
+ final int fileNameColumnId = c.getColumnIndex(OpenableColumns.DISPLAY_NAME);
+ if (fileNameColumnId >= 0) attachmentFileName = c.getString(fileNameColumnId);
+ }
+ }
+
+ if (attachmentFileName == null) attachmentFileName = subjectFromIntent;
+
+ InputStream in = getContentResolver().openInputStream(uri);
+ promptNameAndSave(in, attachmentFileName);
+ } catch (Exception e) {
+ showErrorDialogAndQuit("Unable to handle shared content:\n\n" + e.getMessage());
+ Log.e("termux", "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 -> {
+ File outFile = saveStreamWithName(in, text);
+ if (outFile == null) return;
+
+ final File editorProgramFile = new File(EDITOR_PROGRAM);
+ if (!editorProgramFile.isFile()) {
+ showErrorDialogAndQuit("The following file does not exist:\n$HOME/bin/termux-file-editor\n\n"
+ + "Create this file as a script or a symlink - it will be called with the received file as only argument.");
+ return;
+ }
+
+ // Do this for the user if necessary:
+ //noinspection ResultOfMethodCallIgnored
+ editorProgramFile.setExecutable(true);
+
+ final Uri scriptUri = new Uri.Builder().scheme("file").path(EDITOR_PROGRAM).build();
+
+ Intent executeIntent = new Intent(TermuxService.ACTION_EXECUTE, scriptUri);
+ executeIntent.setClass(TermuxFileReceiverActivity.this, TermuxService.class);
+ executeIntent.putExtra(TermuxService.EXTRA_ARGUMENTS, new String[]{outFile.getAbsolutePath()});
+ startService(executeIntent);
+ finish();
+ },
+ R.string.file_received_open_folder_button, text -> {
+ if (saveStreamWithName(in, text) == null) return;
+
+ Intent executeIntent = new Intent(TermuxService.ACTION_EXECUTE);
+ executeIntent.putExtra(TermuxService.EXTRA_CURRENT_WORKING_DIRECTORY, TERMUX_RECEIVEDIR);
+ executeIntent.setClass(TermuxFileReceiverActivity.this, TermuxService.class);
+ startService(executeIntent);
+ finish();
+ },
+ android.R.string.cancel, text -> finish(), dialog -> {
+ if (mFinishOnDismissNameDialog) finish();
+ });
+ }
+
+ public File saveStreamWithName(InputStream in, String attachmentFileName) {
+ File receiveDir = new File(TERMUX_RECEIVEDIR);
+ if (!receiveDir.isDirectory() && !receiveDir.mkdirs()) {
+ showErrorDialogAndQuit("Cannot create directory: " + receiveDir.getAbsolutePath());
+ return null;
+ }
+ try {
+ final File outFile = new File(receiveDir, attachmentFileName);
+ try (FileOutputStream f = new FileOutputStream(outFile)) {
+ byte[] buffer = new byte[4096];
+ int readBytes;
+ while ((readBytes = in.read(buffer)) > 0) {
+ f.write(buffer, 0, readBytes);
+ }
+ }
+ return outFile;
+ } catch (IOException e) {
+ showErrorDialogAndQuit("Error saving file:\n\n" + e);
+ Log.e("termux", "Error saving file", e);
+ return null;
+ }
+ }
+
+ void handleUrlAndFinish(final String url) {
+ final File urlOpenerProgramFile = new File(URL_OPENER_PROGRAM);
+ if (!urlOpenerProgramFile.isFile()) {
+ showErrorDialogAndQuit("The following file does not exist:\n$HOME/bin/termux-url-opener\n\n"
+ + "Create this file as a script or a symlink - it will be called with the shared URL as only argument.");
+ return;
+ }
+
+ // Do this for the user if necessary:
+ //noinspection ResultOfMethodCallIgnored
+ urlOpenerProgramFile.setExecutable(true);
+
+ final Uri urlOpenerProgramUri = new Uri.Builder().scheme("file").path(URL_OPENER_PROGRAM).build();
+
+ Intent executeIntent = new Intent(TermuxService.ACTION_EXECUTE, urlOpenerProgramUri);
+ executeIntent.setClass(TermuxFileReceiverActivity.this, TermuxService.class);
+ executeIntent.putExtra(TermuxService.EXTRA_ARGUMENTS, new String[]{url});
+ startService(executeIntent);
+ finish();
+ }
+
+}
diff --git a/termux-app/src/main/res/drawable-anydpi-v26/ic_launcher.xml b/termux-app/src/main/res/drawable-anydpi-v26/ic_launcher.xml
new file mode 100644
index 00000000..6192469f
--- /dev/null
+++ b/termux-app/src/main/res/drawable-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/termux-app/src/main/res/drawable/banner.png b/termux-app/src/main/res/drawable/banner.png
new file mode 100644
index 00000000..445d384a
Binary files /dev/null and b/termux-app/src/main/res/drawable/banner.png differ
diff --git a/termux-app/src/main/res/drawable/current_session.xml b/termux-app/src/main/res/drawable/current_session.xml
new file mode 100644
index 00000000..90dd2818
--- /dev/null
+++ b/termux-app/src/main/res/drawable/current_session.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/termux-app/src/main/res/drawable/current_session_black.xml b/termux-app/src/main/res/drawable/current_session_black.xml
new file mode 100644
index 00000000..6a926499
--- /dev/null
+++ b/termux-app/src/main/res/drawable/current_session_black.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/termux-app/src/main/res/drawable/ic_foreground.xml b/termux-app/src/main/res/drawable/ic_foreground.xml
new file mode 100644
index 00000000..3f3e59b7
--- /dev/null
+++ b/termux-app/src/main/res/drawable/ic_foreground.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
diff --git a/termux-app/src/main/res/drawable/ic_launcher.xml b/termux-app/src/main/res/drawable/ic_launcher.xml
new file mode 100644
index 00000000..749a55bc
--- /dev/null
+++ b/termux-app/src/main/res/drawable/ic_launcher.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/termux-app/src/main/res/drawable/ic_new_session.xml b/termux-app/src/main/res/drawable/ic_new_session.xml
new file mode 100644
index 00000000..9b5b24a0
--- /dev/null
+++ b/termux-app/src/main/res/drawable/ic_new_session.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
diff --git a/termux-app/src/main/res/drawable/ic_service_notification.xml b/termux-app/src/main/res/drawable/ic_service_notification.xml
new file mode 100644
index 00000000..0fe78b4c
--- /dev/null
+++ b/termux-app/src/main/res/drawable/ic_service_notification.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/termux-app/src/main/res/drawable/selected_session_background.xml b/termux-app/src/main/res/drawable/selected_session_background.xml
new file mode 100644
index 00000000..3db6d6e5
--- /dev/null
+++ b/termux-app/src/main/res/drawable/selected_session_background.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/termux-app/src/main/res/drawable/selected_session_background_black.xml b/termux-app/src/main/res/drawable/selected_session_background_black.xml
new file mode 100644
index 00000000..25b7506f
--- /dev/null
+++ b/termux-app/src/main/res/drawable/selected_session_background_black.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/termux-app/src/main/res/drawable/session_ripple.xml b/termux-app/src/main/res/drawable/session_ripple.xml
new file mode 100644
index 00000000..9c4a1e79
--- /dev/null
+++ b/termux-app/src/main/res/drawable/session_ripple.xml
@@ -0,0 +1,7 @@
+
+
+ -
+
+
+
diff --git a/termux-app/src/main/res/drawable/session_ripple_black.xml b/termux-app/src/main/res/drawable/session_ripple_black.xml
new file mode 100644
index 00000000..21423eb5
--- /dev/null
+++ b/termux-app/src/main/res/drawable/session_ripple_black.xml
@@ -0,0 +1,7 @@
+
+
+ -
+
+
+
diff --git a/termux-app/src/main/res/drawable/terminal_scroll_shape.xml b/termux-app/src/main/res/drawable/terminal_scroll_shape.xml
new file mode 100644
index 00000000..76cb0719
--- /dev/null
+++ b/termux-app/src/main/res/drawable/terminal_scroll_shape.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/termux-app/src/main/res/layout/drawer_layout.xml b/termux-app/src/main/res/layout/drawer_layout.xml
new file mode 100644
index 00000000..6dbbfc92
--- /dev/null
+++ b/termux-app/src/main/res/layout/drawer_layout.xml
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/termux-app/src/main/res/layout/extra_keys_main.xml b/termux-app/src/main/res/layout/extra_keys_main.xml
new file mode 100644
index 00000000..45dfab6c
--- /dev/null
+++ b/termux-app/src/main/res/layout/extra_keys_main.xml
@@ -0,0 +1,8 @@
+
+
diff --git a/termux-app/src/main/res/layout/extra_keys_right.xml b/termux-app/src/main/res/layout/extra_keys_right.xml
new file mode 100644
index 00000000..c2513bf9
--- /dev/null
+++ b/termux-app/src/main/res/layout/extra_keys_right.xml
@@ -0,0 +1,15 @@
+
+
diff --git a/termux-app/src/main/res/layout/line_in_drawer.xml b/termux-app/src/main/res/layout/line_in_drawer.xml
new file mode 100644
index 00000000..060c7db0
--- /dev/null
+++ b/termux-app/src/main/res/layout/line_in_drawer.xml
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/termux-app/src/main/res/raw/bell.ogg b/termux-app/src/main/res/raw/bell.ogg
new file mode 100644
index 00000000..674f25d0
Binary files /dev/null and b/termux-app/src/main/res/raw/bell.ogg differ
diff --git a/termux-app/src/main/res/values/strings.xml b/termux-app/src/main/res/values/strings.xml
new file mode 100644
index 00000000..d3a16a54
--- /dev/null
+++ b/termux-app/src/main/res/values/strings.xml
@@ -0,0 +1,53 @@
+
+
+ Termux
+ Termux user
+ Run commands in Termux environment
+ Allow third-party applications to execute arbitrary commands within Termux environment.
+ New session
+ Failsafe
+ Keyboard
+ Reset
+ Style
+ Terminal transcript
+ Help
+ Keep screen on
+
+ Installing…
+ Unable to install
+ Termux was unable to install the bootstrap packages.
+ Abort
+ Try again
+ Termux can only be installed on the primary user account.
+
+ Max terminals reached
+ Close down existing ones before creating new.
+
+ Terminal reset.
+
+ Select URL
+ Click URL to copy or long press to open
+ Share transcript
+ No URL found in the terminal.
+ URL copied to clipboard
+ Send text to:
+
+ Kill process (%d)
+ Really kill this session?
+
+ Set session name
+ Set
+ New named session
+ Create
+
+ The Termux:Style add-on is not installed.
+ Install
+
+ Exit
+ Acquire wakelock
+ Release wakelock
+
+ Save file in ~/downloads/
+ Edit
+ Open folder
+
diff --git a/termux-app/src/main/res/values/styles.xml b/termux-app/src/main/res/values/styles.xml
new file mode 100644
index 00000000..1f352423
--- /dev/null
+++ b/termux-app/src/main/res/values/styles.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/termux-app/src/main/res/xml/shortcuts.xml b/termux-app/src/main/res/xml/shortcuts.xml
new file mode 100644
index 00000000..ae2e42f1
--- /dev/null
+++ b/termux-app/src/main/res/xml/shortcuts.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/termux-app/src/test/java/com/termux/app/TermuxActivityTest.java b/termux-app/src/test/java/com/termux/app/TermuxActivityTest.java
new file mode 100644
index 00000000..b4f5251f
--- /dev/null
+++ b/termux-app/src/test/java/com/termux/app/TermuxActivityTest.java
@@ -0,0 +1,30 @@
+package com.termux.app;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.util.Collections;
+import java.util.LinkedHashSet;
+
+public class TermuxActivityTest {
+
+ private void assertUrlsAre(String text, String... urls) {
+ LinkedHashSet expected = new LinkedHashSet<>();
+ Collections.addAll(expected, urls);
+ Assert.assertEquals(expected, TermuxActivity.extractUrls(text));
+ }
+
+ @Test
+ public void testExtractUrls() {
+ assertUrlsAre("hello http://example.com world", "http://example.com");
+
+ assertUrlsAre("http://example.com\nhttp://another.com", "http://example.com", "http://another.com");
+
+ assertUrlsAre("hello http://example.com world and http://more.example.com with secure https://more.example.com",
+ "http://example.com", "http://more.example.com", "https://more.example.com");
+
+ assertUrlsAre("hello https://example.com/#bar https://example.com/foo#bar",
+ "https://example.com/#bar", "https://example.com/foo#bar");
+ }
+
+}
diff --git a/termux-app/src/test/java/com/termux/filepicker/TermuxFileReceiverActivityTest.java b/termux-app/src/test/java/com/termux/filepicker/TermuxFileReceiverActivityTest.java
new file mode 100644
index 00000000..9becbefc
--- /dev/null
+++ b/termux-app/src/test/java/com/termux/filepicker/TermuxFileReceiverActivityTest.java
@@ -0,0 +1,32 @@
+package com.termux.filepicker;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@RunWith(RobolectricTestRunner.class)
+public class TermuxFileReceiverActivityTest {
+
+ @Test
+ public void testIsSharedTextAnUrl() {
+ List validUrls = new ArrayList<>();
+ validUrls.add("http://example.com");
+ validUrls.add("https://example.com");
+ validUrls.add("https://example.com/path/parameter=foo");
+ validUrls.add("magnet:?xt=urn:btih:d540fc48eb12f2833163eed6421d449dd8f1ce1f&dn=Ubuntu+desktop+19.04+%2864bit%29&tr=udp%3A%2F%2Ftracker.openbittorrent.com%3A80&tr=udp%3A%2F%2Ftracker.publicbt.com%3A80&tr=udp%3A%2F%2Ftracker.ccc.de%3A80");
+ for (String url : validUrls) {
+ Assert.assertTrue(TermuxFileReceiverActivity.isSharedTextAnUrl(url));
+ }
+
+ List invalidUrls = new ArrayList<>();
+ invalidUrls.add("a test with example.com");
+ for (String url : invalidUrls) {
+ Assert.assertFalse(TermuxFileReceiverActivity.isSharedTextAnUrl(url));
+ }
+ }
+
+}