mirror of
https://github.com/RfidResearchGroup/RFIDtools.git
synced 2026-05-12 11:19:59 -07:00
Termux-APP module added.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
*/
|
||||
Binary file not shown.
Vendored
+11
@@ -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
|
||||
@@ -0,0 +1,159 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.termux"
|
||||
android:installLocation="internalOnly"
|
||||
android:sharedUserId="com.termux"
|
||||
android:sharedUserLabel="@string/shared_user_label">
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
|
||||
<permission
|
||||
android:name="${applicationId}.permission.RUN_COMMAND"
|
||||
android:description="@string/run_command_permission_description"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="@string/run_command_permission_label"
|
||||
android:protectionLevel="dangerous" />
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:banner="@drawable/banner"
|
||||
android:extractNativeLibs="true"
|
||||
android:supportsRtl="false">
|
||||
|
||||
<!-- This (or rather, value 2.1 or higher) is needed to make the Samsung Galaxy S8
|
||||
mark the app with "This app is optimized to run in full screen." -->
|
||||
<meta-data
|
||||
android:name="android.max_aspect"
|
||||
android:value="10.0" />
|
||||
|
||||
<activity
|
||||
android:name="com.termux.app.TermuxActivity"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|density|screenLayout|uiMode|keyboard|keyboardHidden|navigation"
|
||||
android:label="@string/application_name"
|
||||
android:launchMode="singleTask"
|
||||
android:resizeableActivity="true"
|
||||
android:theme="@style/Theme.Termux"
|
||||
android:windowSoftInputMode="adjustResize|stateAlwaysVisible">
|
||||
<!--<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>-->
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name="com.termux.app.TermuxHelpActivity"
|
||||
android:exported="false"
|
||||
android:label="@string/application_name"
|
||||
android:parentActivityName=".app.TermuxActivity"
|
||||
android:resizeableActivity="true"
|
||||
android:theme="@android:style/Theme.Material.Light.DarkActionBar" />
|
||||
|
||||
<activity
|
||||
android:name="com.termux.filepicker.TermuxFileReceiverActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:label="@string/application_name"
|
||||
android:noHistory="true"
|
||||
android:resizeableActivity="true"
|
||||
android:taskAffinity="com.termux.filereceiver">
|
||||
<!-- Accept multiple file types when sending. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
<data android:mimeType="application/*" />
|
||||
<data android:mimeType="audio/*" />
|
||||
<data android:mimeType="image/*" />
|
||||
<data android:mimeType="message/*" />
|
||||
<data android:mimeType="multipart/*" />
|
||||
<data android:mimeType="text/*" />
|
||||
<data android:mimeType="video/*" />
|
||||
</intent-filter>
|
||||
<!-- Be more restrictive for viewing files, restricting ourselves to text files. -->
|
||||
<intent-filter tools:ignore="AppLinkUrlError">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
<data android:mimeType="text/*" />
|
||||
<data android:mimeType="application/*log*" />
|
||||
<data android:mimeType="application/json" />
|
||||
<data android:mimeType="application/*xml*" />
|
||||
<data android:mimeType="application/*latex*" />
|
||||
<data android:mimeType="application/javascript" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity-alias
|
||||
android:name=".HomeActivity"
|
||||
android:targetActivity="com.termux.app.TermuxActivity">
|
||||
|
||||
<!-- Launch activity automatically on boot on Android Things devices -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.IOT_LAUNCHER" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<provider
|
||||
android:name=".filepicker.TermuxDocumentsProvider"
|
||||
android:authorities="${applicationId}.documents"
|
||||
android:exported="true"
|
||||
android:grantUriPermissions="true"
|
||||
android:permission="android.permission.MANAGE_DOCUMENTS">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
|
||||
</intent-filter>
|
||||
</provider>
|
||||
|
||||
<service
|
||||
android:name="com.termux.app.TermuxService"
|
||||
android:exported="false" />
|
||||
|
||||
<service
|
||||
android:name=".app.RunCommandService"
|
||||
android:exported="true"
|
||||
android:permission="${applicationId}.permission.RUN_COMMAND">
|
||||
<intent-filter>
|
||||
<action android:name="com.termux.RUN_COMMAND" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver android:name=".app.TermuxOpenReceiver" />
|
||||
|
||||
<provider
|
||||
android:name="com.termux.app.TermuxOpenReceiver$ContentProvider"
|
||||
android:authorities="${applicationId}.files"
|
||||
android:exported="true"
|
||||
android:grantUriPermissions="true"
|
||||
android:readPermission="android.permission.permRead" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.sec.android.support.multiwindow"
|
||||
android:value="true" />
|
||||
<meta-data
|
||||
android:name="com.samsung.android.multidisplay.keep_process_alive"
|
||||
android:value="true" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -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<String> 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<String> 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<String> 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]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<K,V> extends HashMap<K,V> {
|
||||
V get(K key, V defaultValue) {
|
||||
if(containsKey(key))
|
||||
return get(key);
|
||||
else
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
static class CharDisplayMap extends CleverMap<String, String> {}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -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<String, Integer> keyCodesForString = new HashMap<String, Integer>() {{
|
||||
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<SpecialButton, SpecialButtonState> specialButtons = new HashMap<SpecialButton, SpecialButtonState>() {{
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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:
|
||||
* <p/>
|
||||
* (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.
|
||||
* <p/>
|
||||
* (2) A progress dialog is shown with "Installing..." message and a spinner.
|
||||
* <p/>
|
||||
* (3) A staging folder, $STAGING_PREFIX, is {@link #deleteFolder(File)} if left over from broken installation below.
|
||||
* <p/>
|
||||
* (4) The zip file is loaded from a shared library.
|
||||
* <p/>
|
||||
* (5) The zip, containing entries relative to the $PREFIX, is is downloaded and extracted by a zip input stream
|
||||
* continuously encountering zip file entries:
|
||||
* <p/>
|
||||
* (5.1) If the zip entry encountered is SYMLINKS.txt, go through it and remember all symlinks to setup.
|
||||
* <p/>
|
||||
* (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<Pair<String, String>> 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<String, String> 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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<KeyboardShortcut> 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+<something>");
|
||||
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+<something>");
|
||||
return;
|
||||
} else {
|
||||
codePoint = Character.toCodePoint(input.charAt(1), c);
|
||||
}
|
||||
}
|
||||
shortcuts.add(new KeyboardShortcut(codePoint, shortcutAction));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.
|
||||
* <p/>
|
||||
* 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)}.
|
||||
* <p/>
|
||||
* 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.
|
||||
* <p/>
|
||||
* 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<TerminalSession> mTerminalSessions = new ArrayList<>();
|
||||
|
||||
final List<BackgroundJob> 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<TerminalSession> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<TermuxPreferences.KeyboardShortcut> 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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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.
|
||||
* <p/>
|
||||
* Note that this replaces providing an activity matching the ACTION_GET_CONTENT intent:
|
||||
* <p/>
|
||||
* "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<File> 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.
|
||||
* <p/>
|
||||
* 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@android:color/black"/>
|
||||
<foreground android:drawable="@drawable/ic_foreground"/>
|
||||
</adaptive-icon>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user