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 @@ + + + + + + + + + + + + +